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();
});