274 lines
17 KiB
TypeScript
274 lines
17 KiB
TypeScript
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(<QueryClientProvider client={client}>
|
|
<AppShell canLogout={false} />
|
|
</QueryClientProvider>);
|
|
}
|
|
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<void>(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();
|
|
});
|