import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { http, HttpResponse } from "msw"; import { vi } from "vitest"; import { server } from "../test/msw"; import { setAuthState } from "../auth/authState"; import { useSessionStore } from "../store/sessionStore"; import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures"; import { AppShell } from "./AppShell"; import { workspacePreferences } from "../workspaces/preferences"; import { FakeEventSource } from "../test/fakeEventSource"; const permissions = ["session.use", "database.manage", "workspace.manage", "workspace.secrets.manage", "memory.manage", "evidence.manage", "pi.manage"]; function renderShell(client = new QueryClient({ defaultOptions: { queries: { retry: false } } })) { return render( ); } beforeEach(() => { setAuthState({ issuer: "test", subject: "admin", roles: ["admin"], permissions, isAdmin: true, csrfToken: null, session: null }); localStorage.clear(); useSessionStore.getState().resetSession(); workspacePreferences.reset(); window.history.replaceState(null, "", "/"); FakeEventSource.instances = []; (globalThis as any).EventSource = FakeEventSource; server.use( http.get("/api/sessions", () => HttpResponse.json([])), http.get("/api/settings", () => HttpResponse.json({ workspace: "alpha", provider: "test", model: "test", thinking: "low" })), http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "test", id: "test", name: "Test LLM", reasoning: true }] })), http.get("/api/workspaces", () => HttpResponse.json([workspaceSummaryFixture("alpha"), workspaceSummaryFixture("beta")])), http.get("/api/workspace-registry/status", () => HttpResponse.json({ branch: "main", ahead: 0, behind: 0, degraded: false })), http.get("/api/workspaces/:id/runtime-configuration", ({ params }) => HttpResponse.json({ workspaceId: params.id, revision: workspaceRevisionFixture(String(params.id)), configurationState: "ready", requirements: [] })), http.get("/api/workspaces/:id", ({ params }) => HttpResponse.json({ workspace: canonicalWorkspaceFixture(String(params.id)), revision: workspaceRevisionFixture(String(params.id)) })), http.get("/api/pi-management/status", () => HttpResponse.json({ ready: true, credentials: "present", config: { provider: "test", model: "test", reasoning: "low" } })), http.get("/api/workspaces/:id/memory", () => HttpResponse.json({ items: [], total: 0, page: 1, page_size: 25 })), http.get("/api/workspaces/:id/memory/pending", () => HttpResponse.json([])), ); }); afterEach(() => vi.restoreAllMocks()); test("uses short Admin buttons while keeping management in page titles", async () => { renderShell(); await screen.findByRole("textbox", { name: /new question/i }); await userEvent.click(screen.getByRole("button", { name: "Administration" })); for (const [label, title] of [["Workspace", "Workspace"], ["Evidence", "Evidence"], ["Memory", "Memory"], ["Database", "Database"], ["Pi configuration", "Pi"]]) { await userEvent.click(screen.getByRole("button", { name: label })); expect(await screen.findByRole("main", { name: `${title} management` })).toBeVisible(); expect(screen.queryByRole("button", { name: `${title} management` })).not.toBeInTheDocument(); } }); test.each(["Workspace", "Evidence", "Memory", "Database", "Pi"])("reloads the %s route as a full page", async name => { window.history.replaceState({ portal: "kept" }, "", `/portal?lang=it&thoth_route=administration/${name.toLowerCase()}#host`); renderShell(); expect(await screen.findByRole("main", { name: `${name} management` })).toBeVisible(); expect(screen.queryByRole("dialog", { name: `${name} management` })).not.toBeInTheDocument(); expect(window.location.hash).toBe("#host"); expect(window.history.state.portal).toBe("kept"); }); test("preserves host state and query parameters through Back and Forward", async () => { window.history.replaceState({ portal: { view: "thoth" } }, "", "/portal?lang=it#host"); renderShell(); await userEvent.click(screen.getByRole("button", { name: "Administration" })); await userEvent.click(screen.getByRole("button", { name: "Workspace" })); await userEvent.click(screen.getByRole("button", { name: "Pi configuration" })); act(() => window.history.back()); expect(await screen.findByRole("main", { name: "Workspace management" })).toBeVisible(); act(() => window.history.forward()); expect(await screen.findByRole("main", { name: "Pi management" })).toBeVisible(); expect(new URLSearchParams(window.location.search).get("lang")).toBe("it"); expect(window.location.pathname).toBe("/portal"); expect(window.location.hash).toBe("#host"); expect(window.history.state.portal).toEqual({ view: "thoth" }); }); test.each(["workspace", "database", "memory", "evidence", "pi"])("denies a direct %s link for a non-administrator", async name => { setAuthState({ issuer: "test", subject: "reader", roles: ["user"], permissions: ["session.use"], isAdmin: false, csrfToken: null, session: null }); window.history.replaceState(null, "", `/?thoth_route=administration/${name}`); renderShell(); expect(await screen.findByRole("main", { name: "Administration unavailable" })).toBeVisible(); expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument(); }); test("Back and Core navigation preserve a Memory draft until the editor cancels it", async () => { renderShell(); await userEvent.click(screen.getByRole("button", { name: "Administration" })); await userEvent.click(screen.getByRole("button", { name: "Workspace" })); await userEvent.click(screen.getByRole("button", { name: "Memory" })); const page = await screen.findByRole("main", { name: "Memory management" }); await userEvent.click(await within(page).findByRole("button", { name: "New card" })); await userEvent.type(within(page).getByLabelText("Title"), "Keep this draft"); const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); act(() => window.history.back()); await waitFor(() => expect(new URLSearchParams(window.location.search).get("thoth_route")).toBe("administration/memory")); expect(within(page).getByLabelText("Title")).toHaveValue("Keep this draft"); await userEvent.click(screen.getByRole("button", { name: "Session" })); expect(page).toBeVisible(); expect(confirm).not.toHaveBeenCalled(); confirm.mockReturnValue(true); await userEvent.click(within(page).getByRole("button", { name: "Cancel" })); act(() => window.history.back()); expect(await screen.findByRole("main", { name: "Workspace management" })).toBeVisible(); act(() => window.history.forward()); expect(await screen.findByRole("main", { name: "Memory management" })).toBeVisible(); }); test("runs preprocessing for the globally selected workspace", async () => { const targets: string[] = []; server.use(http.post("/api/workspaces/:id/preprocessing", ({ params }) => { targets.push(String(params.id)); return HttpResponse.json({ schemaVersion: 1, workspaceId: params.id, state: "ready", actionable: true, clearable: true, detail: "Prepared" }); })); window.history.replaceState(null, "", "/?thoth_route=administration/workspace&thoth_workspace=beta"); workspacePreferences.selectWorkspace("beta"); renderShell(); const preparation = await screen.findByRole("region", { name: "Workspace readiness" }); await userEvent.click(await within(preparation).findByRole("button", { name: "Run again" })); await waitFor(() => expect(targets).toEqual(["beta"])); expect(within(preparation).getByRole("link", { name: "Open Database configuration and schema" })).toHaveAttribute("href", "/?thoth_route=administration%2Fdatabase&thoth_workspace=beta"); }); test("option A has one collapsible global context and preserves the Core draft across Admin", async () => { renderShell(); const composer = await screen.findByRole("textbox", { name: /new question/i }); await userEvent.type(composer, "Keep my original Core question"); await userEvent.click(screen.getByRole("button", { name: /Working context/ })); expect(screen.getAllByRole("combobox", { name: "Workspace" })).toHaveLength(1); expect(screen.getAllByRole("combobox", { name: "Model" })).toHaveLength(1); expect(screen.getByRole("button", { name: "Done" }).closest(".thot-context-fields")) .toBe(screen.getByRole("combobox", { name: "Model" }).closest(".thot-context-fields")); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "beta"); expect(workspacePreferences.load()).toMatchObject({ workspaceId: "beta", provider: "test", model: "test" }); await userEvent.click(screen.getByRole("button", { name: "Done" })); expect(screen.queryByRole("combobox", { name: "Model" })).not.toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Administration" })); await userEvent.click(screen.getByRole("button", { name: "Memory" })); expect(await screen.findByRole("main", { name: "Memory management" })).toBeVisible(); expect(screen.queryByRole("textbox", { name: /new question/i })).not.toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Session" })); expect(screen.getByRole("textbox", { name: /new question/i })).toBe(composer); expect(composer).toHaveValue("Keep my original Core question"); }); test("invalid remembered choices do not silently fall back and gate both Core and Admin", async () => { workspacePreferences.selectWorkspace("removed"); workspacePreferences.selectModel("removed/model"); window.history.replaceState(null, "", "/?thoth_route=administration/memory"); renderShell(); await screen.findByText(/Unavailable saved choices are not replaced/); expect(screen.getByRole("button", { name: "Session" })).toBeDisabled(); expect(screen.queryByRole("main", { name: "Memory management" })).not.toBeInTheDocument(); expect(screen.queryByRole("textbox", { name: /new question/i })).not.toBeInTheDocument(); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "alpha"); expect(screen.queryByRole("main", { name: "Memory management" })).not.toBeInTheDocument(); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Model" }), "test/test"); expect(await screen.findByRole("main", { name: "Memory management" })).toBeVisible(); }); test("workspace and model stay locked during Core and unlock after it finishes", async () => { renderShell(); await screen.findByRole("textbox", { name: /new question/i }); await userEvent.click(screen.getByRole("button", { name: /Working context/ })); act(() => useSessionStore.getState().setAgentActive(true)); expect(screen.getByRole("combobox", { name: "Workspace" })).toBeDisabled(); expect(screen.getByRole("combobox", { name: "Model" })).toBeDisabled(); act(() => useSessionStore.getState().setAgentActive(false)); expect(screen.getByRole("combobox", { name: "Workspace" })).toBeEnabled(); expect(screen.getByRole("combobox", { name: "Model" })).toBeEnabled(); }); test("dirty Memory blocks workspace and model changes, including after a failed save", async () => { let saveAttempts = 0; server.use( http.get("/api/models", () => HttpResponse.json({ models: [ { provider: "test", id: "test", name: "Test LLM", reasoning: true }, { provider: "test", id: "other", name: "Other LLM", reasoning: true }, ] })), http.post("/api/workspaces/:id/memory", () => { saveAttempts++; return HttpResponse.json({ code: "memory_unavailable", error: "Save failed" }, { status: 500 }); }), ); window.history.replaceState(null, "", "/?thoth_route=administration/memory"); renderShell(); const page = await screen.findByRole("main", { name: "Memory management" }); await userEvent.click(within(page).getByRole("button", { name: "New card" })); await userEvent.type(within(page).getByLabelText("Title"), "Do not lose this"); await userEvent.click(screen.getByRole("button", { name: /Working context/ })); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "beta"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Model" }), "test/other"); expect(workspacePreferences.load()).toMatchObject({ workspaceId: "alpha", model: "test" }); await userEvent.type(within(page).getByLabelText("Content"), "Retain this content."); await userEvent.type(within(page).getByLabelText("Scope"), "This workspace."); await userEvent.click(within(page).getByRole("button", { name: "Save card" })); await waitFor(() => expect(saveAttempts).toBe(1)); expect(await within(page).findByRole("alert")).toBeVisible(); await userEvent.click(screen.getByRole("button", { name: "Session" })); expect(page).toBeVisible(); expect(within(page).getByLabelText("Title")).toHaveValue("Do not lose this"); }); test("a failed catalog refresh retains a mounted Admin draft", async () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); window.history.replaceState(null, "", "/?thoth_route=administration/memory"); renderShell(client); const page = await screen.findByRole("main", { name: "Memory management" }); await userEvent.click(within(page).getByRole("button", { name: "New card" })); await userEvent.type(within(page).getByLabelText("Title"), "Still here"); server.use(http.get("/api/models", () => new HttpResponse(null, { status: 503 }))); await act(async () => { await client.refetchQueries({ queryKey: ["models"], exact: true }); }); expect(page).toBeVisible(); expect(within(page).getByLabelText("Title")).toHaveValue("Still here"); }); test("Resume binds the pinned workspace immediately and leaves the global model unchanged", async () => { server.use( http.get("/api/sessions", () => HttpResponse.json([{ id: "in-beta", status: "open", question: "Beta session", active: true, created_at: "2026-09-12T00:00:00Z", archived: false, }])), http.post("/api/sessions/in-beta/resume", () => HttpResponse.json({ id: "in-beta", alreadyActive: true, workspaceId: "beta" })), http.get("/api/sessions/in-beta", () => HttpResponse.json({ id: "in-beta", phase: 3, workspace_id: "beta", provider: "old", model: "old" })), ); renderShell(); await screen.findByRole("textbox", { name: /new question/i }); await userEvent.click(screen.getByRole("button", { name: "Active sessions" })); await userEvent.click(await screen.findByTestId("session-item-in-beta")); await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1)); expect(workspacePreferences.load()).toMatchObject({ workspaceId: "beta", provider: "test", model: "test" }); }); test("a successful Memory save releases the navigation guard and the context lock", async () => { let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); server.use(http.post("/api/workspaces/:id/memory", async ({ request }) => { const card = await request.json() as object; await gate; return HttpResponse.json({ id: "saved", saved: true, indexed: true, action: "upsert", card: { ...card, id: "saved", workspace_id: "alpha", revision: "1", origin: "manual", indexed: true } }); })); window.history.replaceState(null, "", "/?thoth_route=administration/memory"); renderShell(); const page = await screen.findByRole("main", { name: "Memory management" }); await userEvent.click(within(page).getByRole("button", { name: "New card" })); await userEvent.type(within(page).getByLabelText("Title"), "Saved card"); await userEvent.type(within(page).getByLabelText("Scope"), "This workspace"); await userEvent.click(screen.getByRole("button", { name: /Working context/ })); await userEvent.click(within(page).getByRole("button", { name: "Save card" })); expect(screen.getByRole("combobox", { name: "Model" })).toBeDisabled(); await userEvent.click(screen.getByRole("button", { name: "Session" })); expect(page).toBeVisible(); await act(async () => release()); await within(page).findByText("Saved and indexed."); expect(screen.getByRole("combobox", { name: "Model" })).toBeEnabled(); await userEvent.click(screen.getByRole("button", { name: "Session" })); expect(await screen.findByRole("textbox", { name: /new question/i })).toBeVisible(); }); test("Admin generation locks the context and Core submission across page navigation", async () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); renderShell(client); const composer = await screen.findByRole("textbox", { name: /new question/i }); await userEvent.type(composer, "Wait for Admin to finish"); await userEvent.click(screen.getByRole("button", { name: /Working context/ })); await act(async () => { client.setQueryData(["description-generation-runs", 50], [{ id: "r1", status: "running" }]); }); await waitFor(() => expect(screen.getByRole("combobox", { name: "Model" })).toBeDisabled()); expect(screen.getByRole("combobox", { name: "Workspace" })).toBeDisabled(); expect(screen.getByRole("button", { name: /send/i })).toBeDisabled(); await act(async () => { client.setQueryData(["description-generation-runs", 50], []); }); await waitFor(() => expect(screen.getByRole("combobox", { name: "Model" })).toBeEnabled()); expect(screen.getByRole("button", { name: /send/i })).toBeEnabled(); });