feat: establish unified administration and model context baseline
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
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.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 management" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Pi management" }));
|
||||
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 management" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Memory management" }));
|
||||
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: "Return to 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);
|
||||
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 management" }));
|
||||
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: "Return to 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: "New 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: "Return to 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(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: "Return to 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: "Return to 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();
|
||||
});
|
||||
Reference in New Issue
Block a user