179 lines
7.5 KiB
TypeScript
179 lines
7.5 KiB
TypeScript
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { http, HttpResponse, delay } from "msw";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { server } from "../test/msw";
|
|
import { FakeEventSource } from "../test/fakeEventSource";
|
|
import { useSessionStore } from "../store/sessionStore";
|
|
import { AppShell } from "./AppShell";
|
|
import { clearAuthState, setAuthState } from "../auth/authState";
|
|
import { workspacePreferences } from "../workspaces/preferences";
|
|
|
|
function renderShell() {
|
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
return render(<QueryClientProvider client={client}><AppShell canLogout={false} /></QueryClientProvider>);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
clearAuthState();
|
|
setAuthState({
|
|
issuer: "test", subject: "test", roles: ["user"], permissions: ["session.use"],
|
|
isAdmin: false, csrfToken: null, session: null,
|
|
});
|
|
localStorage.clear();
|
|
FakeEventSource.instances = [];
|
|
(globalThis as any).EventSource = FakeEventSource;
|
|
useSessionStore.getState().resetSession();
|
|
server.use(
|
|
http.get("/api/me", () => HttpResponse.json({ issuer: "test", subject: "test", displayName: "Test", isAdmin: false })),
|
|
http.get("/api/sessions", () => HttpResponse.json([])),
|
|
http.get("/api/settings", () =>
|
|
HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })),
|
|
http.get("/api/workspaces", () => HttpResponse.json([])),
|
|
http.get("/api/models", () => HttpResponse.json({ models: [] })),
|
|
);
|
|
});
|
|
|
|
test("New session starts prewarm without delaying composer focus", async () => {
|
|
let prewarmStarted = false;
|
|
server.use(
|
|
http.post("/api/runtime/prewarm", async () => {
|
|
prewarmStarted = true;
|
|
await delay(100);
|
|
return new HttpResponse(null, { status: 202 });
|
|
}),
|
|
);
|
|
renderShell();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: /^new session$/i }));
|
|
|
|
const composer = screen.getByRole("textbox", { name: /new question/i });
|
|
await waitFor(() => expect(prewarmStarted).toBe(true));
|
|
await waitFor(() => expect(composer).toHaveFocus());
|
|
expect(composer).toHaveAttribute("data-awaiting-input", "true");
|
|
expect(composer).toHaveClass("thot-awaiting-input");
|
|
});
|
|
|
|
test("records the prompt without central duplication, then opens the live log", async () => {
|
|
let releaseCreate!: () => void;
|
|
const createMayFinish = new Promise<void>((resolve) => { releaseCreate = resolve; });
|
|
server.use(
|
|
http.post("/api/sessions", async () => {
|
|
await createMayFinish;
|
|
return HttpResponse.json({ id: "s-new" });
|
|
}),
|
|
);
|
|
renderShell();
|
|
|
|
const composer = screen.getByRole("textbox", { name: /new question/i });
|
|
await userEvent.type(composer, "How many patients?");
|
|
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
|
|
|
expect(screen.queryByText("How many patients?", { selector: "p" })).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Creating session…")).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument();
|
|
expect(useSessionStore.getState().activityLog[0]).toEqual({
|
|
kind: "prompt",
|
|
phase: "F1",
|
|
text: "How many patients?",
|
|
});
|
|
releaseCreate();
|
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
|
act(() => FakeEventSource.instances[0].emit({ type: "text_delta", text: "Inspecting cohort" }));
|
|
expect(await screen.findByRole("log", { name: "Live model activity" }))
|
|
.toHaveTextContent("Inspecting cohort");
|
|
expect(screen.queryByText("Analyzing question…")).not.toBeInTheDocument();
|
|
expect(useSessionStore.getState().currentPhase).toBe("F1");
|
|
});
|
|
|
|
test("a failed create restores the landing view and preserves the question for retry", async () => {
|
|
server.use(
|
|
http.post("/api/sessions", () =>
|
|
new HttpResponse("unavailable", { status: 503 })),
|
|
);
|
|
renderShell();
|
|
|
|
const composer = screen.getByRole("textbox", { name: /new question/i });
|
|
await userEvent.type(composer, "Keep this question");
|
|
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
|
|
|
expect(await screen.findByText(/failed to create session/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/type your question/i)).toBeInTheDocument();
|
|
expect(composer).toHaveValue("Keep this question");
|
|
expect(useSessionStore.getState().lastUserEntry).toBeNull();
|
|
expect(FakeEventSource.instances).toHaveLength(0);
|
|
});
|
|
|
|
test("a DWH-unreachable precheck shows a specific alert and preserves the question", async () => {
|
|
server.use(
|
|
http.post("/api/sessions", () =>
|
|
HttpResponse.json(
|
|
{
|
|
error: "Cannot start a session: the database is unreachable. Check the VPN connection and try again.",
|
|
code: "dwh_unreachable",
|
|
},
|
|
{ status: 503 },
|
|
)),
|
|
);
|
|
renderShell();
|
|
|
|
const composer = screen.getByRole("textbox", { name: /new question/i });
|
|
await userEvent.type(composer, "quanti pazienti?");
|
|
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
|
|
|
// Specific alert, not the generic "failed to create session" hint.
|
|
expect(await screen.findByText(/database is unreachable/i)).toBeInTheDocument();
|
|
expect(screen.queryByText(/failed to create session/i)).not.toBeInTheDocument();
|
|
// No session was created: landing view stays and the question is kept for retry.
|
|
expect(screen.getByText(/type your question/i)).toBeInTheDocument();
|
|
expect(composer).toHaveValue("quanti pazienti?");
|
|
expect(FakeEventSource.instances).toHaveLength(0);
|
|
});
|
|
|
|
test("model selector shows the three Pi-enabled models and stores the selected provider locally", async () => {
|
|
server.use(
|
|
http.get("/api/settings", () => HttpResponse.json({
|
|
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
|
|
})),
|
|
http.get("/api/models", () => HttpResponse.json({ models: [
|
|
{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
|
|
{ provider: "deepseek", id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", reasoning: true },
|
|
{ provider: "local-qwen", id: "qwen3.6-35b-a3b", name: "Qwen3.6 35B A3B Local", reasoning: false },
|
|
] })),
|
|
);
|
|
renderShell();
|
|
|
|
const select = await screen.findByRole("combobox", { name: "Model" });
|
|
await waitFor(() => {
|
|
expect(within(select).getAllByRole("option").map((option) => option.textContent)).toEqual([
|
|
"GLM-5.2", "DeepSeek V4 Flash", "Qwen3.6 35B A3B Local",
|
|
]);
|
|
});
|
|
await userEvent.selectOptions(select, "qwen3.6-35b-a3b");
|
|
await waitFor(() => expect(workspacePreferences.load()).toEqual({
|
|
workspaceId: "default", provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low",
|
|
}));
|
|
});
|
|
|
|
test("opens Workspace management from the right sidebar without interrupting the shell", async () => {
|
|
server.use(
|
|
http.get("/api/workspace-registry/status", () =>
|
|
HttpResponse.json({ branch: "main", ahead: 0, behind: 0, degraded: false })),
|
|
);
|
|
renderShell();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Workspace management" }));
|
|
|
|
expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeVisible();
|
|
expect(screen.getByTestId("app-shell")).toHaveAttribute("data-activity-layout", "closed");
|
|
});
|
|
|
|
|
|
test("marks the composer as awaiting input for a pending freetext gate", () => {
|
|
useSessionStore.setState({
|
|
pendingWidget: { id: "free-1", widget: "freetext", title: "Clarify" },
|
|
});
|
|
renderShell();
|
|
expect(screen.getByRole("textbox", { name: /new question/i })).toHaveAttribute("data-awaiting-input", "true");
|
|
});
|