From 7df21b5f216bc2d09f85604a20005742e13bbc86 Mon Sep 17 00:00:00 2001 From: mptyl Date: Wed, 5 Aug 2026 06:20:25 +0200 Subject: [PATCH] fix: harden Pi management readiness --- backend/src/pi/management.ts | 25 ++- backend/src/pi/provider-credentials.ts | 21 ++ backend/test/pi-management.test.ts | 32 +++ backend/test/provider-credentials.test.ts | 14 ++ backend/test/routes-pi-management.test.ts | 2 + frontend/src/api/client.test.ts | 4 +- frontend/src/api/pi-management.test.ts | 4 +- frontend/src/api/pi-management.ts | 1 + frontend/src/api/sessions.test.ts | 28 +-- frontend/src/api/workspaces.test.ts | 8 +- .../src/shell/AppShell.new-session.test.tsx | 24 +-- .../src/shell/AppShell.session-mgmt.test.tsx | 172 ++++++++-------- frontend/src/shell/NewSessionDialog.test.tsx | 12 +- frontend/src/shell/PiManagement.test.tsx | 121 ++++++++++- frontend/src/shell/PiManagement.tsx | 194 +++++++++++++----- .../src/shell/SessionDocumentsPanel.test.tsx | 10 +- frontend/src/shell/SteerInput.test.tsx | 92 ++++----- frontend/src/shell/WorkspaceManager.test.tsx | 36 ++-- .../src/shell/WorkspacePublishDialog.test.tsx | 12 +- frontend/src/shell/f1-loop.test.tsx | 12 +- frontend/src/stream/useSessionStream.test.tsx | 16 +- frontend/src/test/msw-contract.test.ts | 7 + frontend/src/test/msw.ts | 2 +- frontend/src/viewers/ResultsPanel.test.tsx | 2 +- 24 files changed, 577 insertions(+), 274 deletions(-) create mode 100644 frontend/src/test/msw-contract.test.ts diff --git a/backend/src/pi/management.ts b/backend/src/pi/management.ts index 86bc8e35..ccba2e5b 100644 --- a/backend/src/pi/management.ts +++ b/backend/src/pi/management.ts @@ -1,6 +1,7 @@ import { execFile as nodeExecFile } from "node:child_process"; import { promisify } from "node:util"; import type { AppConfig } from "../config.js"; +import { secretValue } from "../config/secret-bundle.js"; import { loadSettings, saveSettings, @@ -12,6 +13,11 @@ import { isPiManagedConfigError, } from "./managed-config.js"; import { createPiProviderSmoke, type PiProviderSmoke } from "./provider-smoke.js"; +import { loadPiAuthProviders } from "./auth-providers.js"; +import { + piProviderCredentialStatus, + type PiCredentialStatus, +} from "./provider-credentials.js"; const execFile = promisify(nodeExecFile); const REASONING_CHOICES = ["low", "medium", "high"] as const; @@ -31,6 +37,7 @@ export interface PiInstallationConfig { export interface PiStatus { version?: string; ready: boolean; + credentials: PiCredentialStatus; config: PiInstallationConfig; checkedAt: string; message?: string; @@ -89,6 +96,7 @@ interface PiManagementDeps { readSettings?: () => Settings; saveSettings?: (settings: Settings) => Settings; readLogs?: () => string | Promise; + credentialStatus?: (provider: string | undefined) => PiCredentialStatus; now?: () => Date; } @@ -104,6 +112,18 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P const persistSettings = deps.saveSettings ?? ((settings) => saveSettings(config, settings)); const readLogs = deps.readLogs ?? (() => diagnostics.join("\n")); const smokeProvider = deps.smokeProvider ?? createPiProviderSmoke(config); + const credentialStatus = deps.credentialStatus ?? ((provider: string | undefined) => { + try { + return piProviderCredentialStatus({ + provider, + authProviders: loadPiAuthProviders(), + credentialValue: secretValue(config, "THT_MODEL_API_KEY"), + credentialFile: config.modelApiKeyFile, + }); + } catch { + return "missing"; + } + }); const closedOptions = async (): Promise> => { let listed: PiModel[]; @@ -168,14 +188,15 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P async status(): Promise { const checkedAt = now().toISOString(); const current = installationConfig(); + const credentials = credentialStatus(current.provider); try { const currentVersion = await version(); addDiagnostic("Pi version probe succeeded"); - return { version: currentVersion, ready: true, config: current, checkedAt }; + return { version: currentVersion, ready: true, credentials, config: current, checkedAt }; } catch (error) { const message = stableMessage(error, "Pi runtime is unavailable"); addDiagnostic(message); - return { ready: false, config: current, checkedAt, message }; + return { ready: false, credentials, config: current, checkedAt, message }; } }, diff --git a/backend/src/pi/provider-credentials.ts b/backend/src/pi/provider-credentials.ts index dfac758e..6e2aab5b 100644 --- a/backend/src/pi/provider-credentials.ts +++ b/backend/src/pi/provider-credentials.ts @@ -53,6 +53,8 @@ export function canonicalPiProvider(provider: string | undefined): string | unde return value; } +export type PiCredentialStatus = "present" | "missing"; + export interface CredentialFsOps { lstat(path: string): Stats; open(path: string, flags: number): number; @@ -172,3 +174,22 @@ export function buildPiChildEnv(opts: { } return env; } + +/** Report only whether the selected hosted provider has a usable credential source. */ +export function piProviderCredentialStatus(opts: { + provider?: string; + credentialFile?: string; + credentialValue?: string; + authProviders?: ReadonlySet; + fsOps?: CredentialFsOps; +}): PiCredentialStatus { + const provider = canonicalPiProvider(opts.provider); + if (!provider || LOCAL_PROVIDERS.has(provider)) return "missing"; + if (opts.authProviders?.has(provider)) return "present"; + try { + buildPiChildEnv({ ...opts, ambient: {} }); + return "present"; + } catch { + return "missing"; + } +} diff --git a/backend/test/pi-management.test.ts b/backend/test/pi-management.test.ts index 493e8ddb..452deae9 100644 --- a/backend/test/pi-management.test.ts +++ b/backend/test/pi-management.test.ts @@ -38,12 +38,14 @@ test("status parses only a Pi version from a fixed execFile argument array", asy execute: successfulExec(calls), listModels: async () => supportedModels, readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }), + credentialStatus: () => "missing", now: () => new Date("2026-08-05T10:00:00.000Z"), }); await expect(service.status()).resolves.toEqual({ version: "0.80.3", ready: true, + credentials: "missing", config: { provider: "zai", model: "glm-5.2", reasoning: "medium" }, checkedAt: "2026-08-05T10:00:00.000Z", }); @@ -53,6 +55,36 @@ test("status parses only a Pi version from a fixed execFile argument array", asy expect(calls[0].timeout).toBeLessThanOrEqual(750); }); +// Catches credential presence being inferred from smoke success/failure or exposing any +// credential material instead of the installation's explicit sanitized presence state. +test.each(["present", "missing"] as const)( + "status reports configured-provider credentials only as %s", + async (credentials) => { + const checkedProviders: Array = []; + const service = createPiManagement(configFor(), { + execute: successfulExec([]), + listModels: async () => supportedModels, + readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }), + credentialStatus: (provider) => { + checkedProviders.push(provider); + return credentials; + }, + now: () => new Date("2026-08-05T10:00:00.000Z"), + }); + + const status = await service.status(); + expect(status).toEqual({ + version: "0.80.3", + ready: true, + credentials, + config: { provider: "zai", model: "glm-5.2", reasoning: "medium" }, + checkedAt: "2026-08-05T10:00:00.000Z", + }); + expect(checkedProviders).toEqual(["zai"]); + expect(JSON.stringify(status)).not.toMatch(/api.?key|token|password|secret/i); + }, +); + // Catches an options response that leaks provider metadata or lets callers choose model IDs that // Pi did not explicitly enable for this installation. test("options expose only closed provider, model, and reasoning choices", async () => { diff --git a/backend/test/provider-credentials.test.ts b/backend/test/provider-credentials.test.ts index ff7414ae..98911e29 100644 --- a/backend/test/provider-credentials.test.ts +++ b/backend/test/provider-credentials.test.ts @@ -5,6 +5,7 @@ import { PI_0803_CREDENTIAL_ENV_NAMES, buildPiChildEnv, canonicalPiProvider, + piProviderCredentialStatus, } from "../src/pi/provider-credentials.js"; test("canonical provider aliases resolve to packaged Pi 0.80.3 IDs", () => { @@ -130,6 +131,19 @@ test("local-qwen is an explicit local provider and needs no generic key", () => expect(env).not.toHaveProperty("THT_MODEL_API_KEY_FILE"); }); +test("credential status reports only present or missing without treating local providers as credentialed", () => { + expect(piProviderCredentialStatus({ + provider: "deepseek", + authProviders: new Set(["deepseek"]), + credentialValue: "must-not-be-returned", + })).toBe("present"); + expect(piProviderCredentialStatus({ provider: "deepseek" })).toBe("missing"); + expect(piProviderCredentialStatus({ + provider: "local-qwen", + credentialValue: "must-not-be-returned", + })).toBe("missing"); +}); + test("bundle value is injected without exposing bundle metadata to Pi", () => { const env = buildPiChildEnv({ ambient: { diff --git a/backend/test/routes-pi-management.test.ts b/backend/test/routes-pi-management.test.ts index 4477d5b0..9b4902e2 100644 --- a/backend/test/routes-pi-management.test.ts +++ b/backend/test/routes-pi-management.test.ts @@ -10,6 +10,7 @@ function fakeService(): PiManagementService { return { status: vi.fn(async () => ({ version: "0.80.3", ready: true, + credentials: "present", config: { provider: "zai", model: "glm-5.2", reasoning: "medium" }, checkedAt: "2026-08-05T10:00:00.000Z", })), @@ -77,6 +78,7 @@ test("loopback-only AUTH_MODE=none may read the sanitized Pi status", async () = expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ version: "0.80.3", ready: true, + credentials: "present", config: { provider: "zai", model: "glm-5.2", reasoning: "medium" }, checkedAt: "2026-08-05T10:00:00.000Z", }); diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index bf9bcfbb..83d9094c 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -5,7 +5,7 @@ import { apiFetch } from "./client"; test("body-less POST omits content-type (avoids Fastify empty-body 400)", async () => { let contentType: string | null = "unset"; server.use( - http.post("http://localhost:8787/sessions/s1/resume", ({ request }) => { + http.post("/api/sessions/s1/resume", ({ request }) => { contentType = request.headers.get("content-type"); return HttpResponse.json({ id: "s1" }); }), @@ -17,7 +17,7 @@ test("body-less POST omits content-type (avoids Fastify empty-body 400)", async test("POST with a body sends application/json content-type", async () => { let contentType: string | null = null; server.use( - http.post("http://localhost:8787/sessions/s1/steer", ({ request }) => { + http.post("/api/sessions/s1/steer", ({ request }) => { contentType = request.headers.get("content-type"); return new HttpResponse(null, { status: 204 }); }), diff --git a/frontend/src/api/pi-management.test.ts b/frontend/src/api/pi-management.test.ts index 8f33a803..52968706 100644 --- a/frontend/src/api/pi-management.test.ts +++ b/frontend/src/api/pi-management.test.ts @@ -14,7 +14,7 @@ test("Pi management client calls only the sanctioned sanitized endpoints", async server.use( http.get("/api/pi-management/status", ({ request }) => { calls.push({ method: request.method, path: new URL(request.url).pathname }); - return HttpResponse.json({ ready: true, version: "0.80.3", config: {}, checkedAt: "2026-08-05T10:00:00.000Z" }); + return HttpResponse.json({ ready: true, version: "0.80.3", credentials: "present", config: {}, checkedAt: "2026-08-05T10:00:00.000Z" }); }), http.get("/api/pi-management/options", ({ request }) => { calls.push({ method: request.method, path: new URL(request.url).pathname }); @@ -34,7 +34,7 @@ test("Pi management client calls only the sanctioned sanitized endpoints", async }), ); - await expect(getPiManagementStatus()).resolves.toMatchObject({ version: "0.80.3", ready: true }); + await expect(getPiManagementStatus()).resolves.toMatchObject({ version: "0.80.3", ready: true, credentials: "present" }); await expect(getPiManagementOptions()).resolves.toMatchObject({ providers: ["zai"] }); await expect(savePiManagementConfig({ provider: "zai", model: "glm-5.2", reasoning: "high" })).resolves.toMatchObject({ reasoning: "high" }); await expect(runPiManagementTest()).resolves.toMatchObject({ ready: true }); diff --git a/frontend/src/api/pi-management.ts b/frontend/src/api/pi-management.ts index 39a13538..78008d75 100644 --- a/frontend/src/api/pi-management.ts +++ b/frontend/src/api/pi-management.ts @@ -11,6 +11,7 @@ export interface PiInstallationConfig { export interface PiManagementStatus { version?: string; ready: boolean; + credentials: "present" | "missing"; config: Partial; checkedAt: string; message?: string; diff --git a/frontend/src/api/sessions.test.ts b/frontend/src/api/sessions.test.ts index 12acc26e..9e77f24c 100644 --- a/frontend/src/api/sessions.test.ts +++ b/frontend/src/api/sessions.test.ts @@ -10,13 +10,13 @@ test("createSession migrates legacy selections and POSTs browser preferences", a localStorage.clear(); let body: unknown = null; server.use( - http.get("http://localhost:8787/settings", () => HttpResponse.json({ + http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low", })), - http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ + http.get("/api/workspaces", () => HttpResponse.json([{ id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", }])), - http.post("http://localhost:8787/sessions", async ({ request }) => { + http.post("/api/sessions", async ({ request }) => { body = await request.json(); return HttpResponse.json({ id: "s1" }); }), @@ -33,7 +33,7 @@ test("createSession migrates legacy selections and POSTs browser preferences", a test.each([202, 204])("prewarmRuntime accepts a body-less %s response", async (status) => { let called = false; server.use( - http.post("http://localhost:8787/runtime/prewarm", () => { + http.post("/api/runtime/prewarm", () => { called = true; return new HttpResponse(null, { status }); }), @@ -44,7 +44,7 @@ test.each([202, 204])("prewarmRuntime accepts a body-less %s response", async (s test("listSessions defaults to the current user's scope", async () => { let scope: string | null = null; - server.use(http.get("http://localhost:8787/sessions", ({ request }) => { + server.use(http.get("/api/sessions", ({ request }) => { scope = new URL(request.url).searchParams.get("scope"); return HttpResponse.json([{ id: "s1", status: "open", question: "q", summary: null, created_at: "t", updated_at: null, author: null }]); })); @@ -55,7 +55,7 @@ test("listSessions defaults to the current user's scope", async () => { test("listSessions requests the selected administrator scope", async () => { let scope: string | null = null; - server.use(http.get("http://localhost:8787/sessions", ({ request }) => { + server.use(http.get("/api/sessions", ({ request }) => { scope = new URL(request.url).searchParams.get("scope"); return HttpResponse.json([]); })); @@ -64,7 +64,7 @@ test("listSessions requests the selected administrator scope", async () => { }); test("getMe fetches the typed authenticated principal", async () => { - server.use(http.get("http://localhost:8787/me", () => + server.use(http.get("/api/me", () => HttpResponse.json({ issuer: "portal", subject: "42", displayName: "Alice", isAdmin: true }), )); await expect(getMe()).resolves.toEqual({ @@ -73,7 +73,7 @@ test("getMe fetches the typed authenticated principal", async () => { }); test("resumeSession returns the typed runtime disposition", async () => { - server.use(http.post("http://localhost:8787/sessions/s1/resume", () => + server.use(http.post("/api/sessions/s1/resume", () => HttpResponse.json({ id: "s1", alreadyActive: false }))); const result: { id: string; alreadyActive: boolean } = await resumeSession("s1"); @@ -82,7 +82,7 @@ test("resumeSession returns the typed runtime disposition", async () => { test("renameSession POSTs {name}", async () => { let body: unknown = null; - server.use(http.post("http://localhost:8787/sessions/s1/rename", async ({ request }) => { + server.use(http.post("/api/sessions/s1/rename", async ({ request }) => { body = await request.json(); return new HttpResponse(null, { status: 204 }); })); @@ -92,7 +92,7 @@ test("renameSession POSTs {name}", async () => { test("setSessionGroup POSTs {group}", async () => { let body: unknown = null; - server.use(http.post("http://localhost:8787/sessions/s1/group", async ({ request }) => { + server.use(http.post("/api/sessions/s1/group", async ({ request }) => { body = await request.json(); return new HttpResponse(null, { status: 204 }); })); @@ -103,9 +103,9 @@ test("setSessionGroup POSTs {group}", async () => { test("archive / unarchive / delete hit the right verbs+paths", async () => { const hits: string[] = []; server.use( - http.post("http://localhost:8787/sessions/s1/archive", () => { hits.push("archive"); return new HttpResponse(null, { status: 204 }); }), - http.post("http://localhost:8787/sessions/s1/unarchive", () => { hits.push("unarchive"); return new HttpResponse(null, { status: 204 }); }), - http.delete("http://localhost:8787/sessions/s1", () => { hits.push("delete"); return new HttpResponse(null, { status: 204 }); }), + http.post("/api/sessions/s1/archive", () => { hits.push("archive"); return new HttpResponse(null, { status: 204 }); }), + http.post("/api/sessions/s1/unarchive", () => { hits.push("unarchive"); return new HttpResponse(null, { status: 204 }); }), + http.delete("/api/sessions/s1", () => { hits.push("delete"); return new HttpResponse(null, { status: 204 }); }), ); await archiveSession("s1"); await unarchiveSession("s1"); @@ -114,7 +114,7 @@ test("archive / unarchive / delete hit the right verbs+paths", async () => { }); test("getSessionDocuments GETs the array", async () => { - server.use(http.get("http://localhost:8787/sessions/s1/documents", () => + server.use(http.get("/api/sessions/s1/documents", () => HttpResponse.json([{ phase: "—", key: "question", title: "t", format: "text", content: "q" }]), )); const docs = await getSessionDocuments("s1"); diff --git a/frontend/src/api/workspaces.test.ts b/frontend/src/api/workspaces.test.ts index 756d1502..b97dc86c 100644 --- a/frontend/src/api/workspaces.test.ts +++ b/frontend/src/api/workspaces.test.ts @@ -15,7 +15,7 @@ const workspace: CanonicalWorkspace = { test("uploads a workspace bundle without JSON content type", async () => { let contentType: string | null = null; - server.use(http.post("http://localhost:8787/workspaces/import", ({ request }) => { + server.use(http.post("/api/workspaces/import", ({ request }) => { contentType = request.headers.get("content-type"); return HttpResponse.json({ draft: { workspace: {} } }); })); @@ -29,7 +29,7 @@ test("uploads a workspace bundle without JSON content type", async () => { }); test("rejects a conflict payload that attempts to surface a secret field", async () => { - server.use(http.post("http://localhost:8787/workspaces/publish", () => HttpResponse.json({ + server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({ code: "workspace_conflict", message: "Workspace changed in the registry.", fields: ["dwh.password"], base: { ...workspace, dwh: { ...workspace.dwh, password: "secret" } }, local: workspace, remote: workspace, }, { status: 409 }))); @@ -73,7 +73,7 @@ const diagnosticsWorkspace: CanonicalWorkspace = { }; test.each(optionalDiagnosticsConflictFields)("accepts optional diagnostics conflict branch %s", async (field) => { - server.use(http.post("http://localhost:8787/workspaces/publish", () => HttpResponse.json({ + server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({ code: "workspace_conflict", message: "Workspace changed in the registry.", fields: [field], expected: { commit: "a".repeat(40), blob: "b".repeat(40) }, actual: { commit: "c".repeat(40), blob: "d".repeat(40) }, @@ -99,7 +99,7 @@ test.each(diagnosticConflictFields)("accepts canonical diagnostic conflict leaf embedding: { method: "GET", path: "/models", auth: "none", response: { model: "model", dimensions: "dimensions" } }, }, }; - server.use(http.post("http://localhost:8787/workspaces/publish", () => HttpResponse.json({ + server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({ code: "workspace_conflict", message: "Workspace changed in the registry.", fields: [field], expected: { commit: "a".repeat(40), blob: "b".repeat(40) }, diff --git a/frontend/src/shell/AppShell.new-session.test.tsx b/frontend/src/shell/AppShell.new-session.test.tsx index 375e9b83..a7c5a14d 100644 --- a/frontend/src/shell/AppShell.new-session.test.tsx +++ b/frontend/src/shell/AppShell.new-session.test.tsx @@ -18,19 +18,19 @@ beforeEach(() => { (globalThis as any).EventSource = FakeEventSource; useSessionStore.getState().resetSession(); server.use( - http.get("http://localhost:8787/me", () => HttpResponse.json({ issuer: "test", subject: "test", displayName: "Test", isAdmin: false })), - http.get("http://localhost:8787/sessions", () => HttpResponse.json([])), - http.get("http://localhost:8787/settings", () => + 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("http://localhost:8787/workspaces", () => HttpResponse.json([])), - http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [] })), + 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("http://localhost:8787/runtime/prewarm", async () => { + http.post("/api/runtime/prewarm", async () => { prewarmStarted = true; await delay(100); return new HttpResponse(null, { status: 202 }); @@ -51,7 +51,7 @@ test("records the prompt without central duplication, then opens the live log", let releaseCreate!: () => void; const createMayFinish = new Promise((resolve) => { releaseCreate = resolve; }); server.use( - http.post("http://localhost:8787/sessions", async () => { + http.post("/api/sessions", async () => { await createMayFinish; return HttpResponse.json({ id: "s-new" }); }), @@ -81,7 +81,7 @@ test("records the prompt without central duplication, then opens the live log", test("a failed create restores the landing view and preserves the question for retry", async () => { server.use( - http.post("http://localhost:8787/sessions", () => + http.post("/api/sessions", () => new HttpResponse("unavailable", { status: 503 })), ); renderShell(); @@ -99,7 +99,7 @@ test("a failed create restores the landing view and preserves the question for r test("a DWH-unreachable precheck shows a specific alert and preserves the question", async () => { server.use( - http.post("http://localhost:8787/sessions", () => + http.post("/api/sessions", () => HttpResponse.json( { error: "Cannot start a session: the database is unreachable. Check the VPN connection and try again.", @@ -125,10 +125,10 @@ test("a DWH-unreachable precheck shows a specific alert and preserves the questi test("model selector shows the three Pi-enabled models and stores the selected provider locally", async () => { server.use( - http.get("http://localhost:8787/settings", () => HttpResponse.json({ + http.get("/api/settings", () => HttpResponse.json({ workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low", })), - http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ + 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 }, @@ -151,7 +151,7 @@ test("model selector shows the three Pi-enabled models and stores the selected p test("opens Workspace management from the right sidebar without interrupting the shell", async () => { server.use( - http.get("http://localhost:8787/workspace-registry/status", () => + http.get("/api/workspace-registry/status", () => HttpResponse.json({ branch: "main", ahead: 0, behind: 0, degraded: false })), ); renderShell(); diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index 947a9a35..16cd5fff 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -56,20 +56,20 @@ beforeEach(() => { window.matchMedia = vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() }); useSessionStore.getState().resetSession(); server.use( - http.get("http://localhost:8787/me", () => + http.get("/api/me", () => HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false }), ), - http.get("http://localhost:8787/sessions", () => HttpResponse.json(LIST)), - http.get("http://localhost:8787/sessions/:id/documents", () => HttpResponse.json([ + http.get("/api/sessions", () => HttpResponse.json(LIST)), + http.get("/api/sessions/:id/documents", () => HttpResponse.json([ { phase: "—", key: "question", title: "Domanda originale", format: "text", content: "Attiva uno" }, ])), - http.post("http://localhost:8787/sessions/:id/archive", () => new HttpResponse(null, { status: 204 })), + http.post("/api/sessions/:id/archive", () => new HttpResponse(null, { status: 204 })), ); }); test("regular users load only their sessions and never see administrator controls", async () => { let scope: string | null = null; - server.use(http.get("http://localhost:8787/sessions", ({ request }) => { + server.use(http.get("/api/sessions", ({ request }) => { scope = new URL(request.url).searchParams.get("scope"); return HttpResponse.json(LIST); })); @@ -80,17 +80,12 @@ test("regular users load only their sessions and never see administrator control expect(screen.queryByText(/administrator view/i)).not.toBeInTheDocument(); }); -test("opens Pi management from the session rail without replacing session controls", async () => { +test("Pi management preserves the open session summary and the model activity timeline", async () => { const user = userEvent.setup(); server.use( - http.get("/api/me", () => HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false })), - http.get("/api/sessions", () => HttpResponse.json(LIST)), - http.get("/api/health/dwh", () => HttpResponse.json({ ok: true, detail: "ok" })), - http.get("/api/settings", () => HttpResponse.json({})), - http.get("/api/workspaces", () => HttpResponse.json([])), - http.get("/api/models", () => HttpResponse.json([])), http.get("/api/pi-management/status", () => HttpResponse.json({ version: "0.80.3", ready: true, + credentials: "present", config: { provider: "zai", model: "glm-5.2", reasoning: "medium" }, checkedAt: "2026-08-05T10:00:00.000Z", })), @@ -98,24 +93,41 @@ test("opens Pi management from the session rail without replacing session contro providers: ["zai"], models: [{ provider: "zai", id: "glm-5.2" }], reasoning: ["low", "medium", "high"], checkedAt: "2026-08-05T10:00:00.000Z", })), + http.post("/api/sessions/:id/resume", () => resumeResult("s1")), + http.get("/api/sessions/:id", () => HttpResponse.json({ phase: 4 })), ); wrap(); - expect(await screen.findByText("Attiva uno")).toBeVisible(); + await user.click(await screen.findByText("Attiva uno")); + expect(await screen.findByRole("complementary", { name: "Session summary" })).toHaveTextContent("Domanda originale"); await user.click(screen.getByRole("button", { name: "Pi management" })); expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible(); - const shell = document.querySelector('[data-testid="app-shell"]'); - expect(shell).toHaveTextContent("Workspace management"); - expect(shell).toHaveTextContent("Active sessions"); + const hiddenSummary = document.querySelector('aside[aria-label="Session summary"]'); + expect(hiddenSummary).toHaveAttribute("aria-hidden", "true"); + expect(hiddenSummary).toHaveTextContent("Attiva uno"); + await user.click(screen.getByRole("button", { name: "Close Pi management" })); + await waitFor(() => { + expect(screen.getByRole("complementary", { name: "Session summary" })).toHaveTextContent("Domanda originale"); + }); + + await user.click(screen.getByRole("button", { name: "Resume" })); + await screen.findByRole("button", { name: "Show model activity" }); + act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "Preserved activity" })); + await user.click(screen.getByRole("button", { name: "Pi management" })); + await user.click(await screen.findByRole("button", { name: "Close Pi management" })); + await waitFor(() => expect(screen.queryByRole("heading", { name: "Pi management" })).not.toBeInTheDocument()); + await user.click(screen.getByRole("button", { name: "Show model activity" })); + expect(await screen.findByRole("heading", { name: "Model activity" })).toBeVisible(); + expect(screen.getByLabelText("Model activity timeline")).toHaveTextContent("Preserved activity"); }); test("administrators can explicitly switch to all sessions and see owners", async () => { let scope = ""; server.use( - http.get("http://localhost:8787/me", () => + http.get("/api/me", () => HttpResponse.json({ issuer: "portal", subject: "alice-id", displayName: "Alice", isAdmin: true }), ), - http.get("http://localhost:8787/sessions", ({ request }) => { + http.get("/api/sessions", ({ request }) => { scope = new URL(request.url).searchParams.get("scope") ?? ""; return HttpResponse.json([ { ...LIST[0], author: "Alice" }, @@ -138,14 +150,14 @@ test("administrators can explicitly switch to all sessions and see owners", asyn test("administrator confirms before deleting a same-named user's session", async () => { let deletes = 0; server.use( - http.get("http://localhost:8787/me", () => + http.get("/api/me", () => HttpResponse.json({ issuer: "portal", subject: "alice-id", displayName: "Alice", isAdmin: true }), ), - http.get("http://localhost:8787/sessions", () => HttpResponse.json([ + http.get("/api/sessions", () => HttpResponse.json([ { ...LIST[0], author: "Alice" }, { ...LIST[1], id: "s3", question: "Second session", archived: false, author: "Bob" }, ])), - http.delete("http://localhost:8787/sessions/:id", () => { + http.delete("/api/sessions/:id", () => { deletes += 1; return new HttpResponse(null, { status: 204 }); }), @@ -165,13 +177,13 @@ test("administrator confirms before archiving a same-named user's session", asyn let archives = 0; const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); server.use( - http.get("http://localhost:8787/me", () => + http.get("/api/me", () => HttpResponse.json({ issuer: "portal", subject: "alice-id", displayName: "Alice", isAdmin: true }), ), - http.get("http://localhost:8787/sessions", () => HttpResponse.json([ + http.get("/api/sessions", () => HttpResponse.json([ { ...LIST[0], author: "Alice" }, ])), - http.post("http://localhost:8787/sessions/:id/archive", () => { + http.post("/api/sessions/:id/archive", () => { archives += 1; return new HttpResponse(null, { status: 204 }); }), @@ -195,7 +207,7 @@ test("active list shows group header and hides archived sessions", async () => { test("ungrouped sessions render after groups with no 'No group' label", async () => { server.use( - http.get("http://localhost:8787/sessions", () => HttpResponse.json([ + http.get("/api/sessions", () => HttpResponse.json([ { id: "g1", status: "open", question: "In gruppo", summary: null, created_at: "2026-01-02T00:00:00Z", updated_at: null, author: null, name: null, group: "Aritmologia", archived: false }, { id: "u1", status: "open", question: "Senza gruppo", summary: null, created_at: "2026-01-03T00:00:00Z", updated_at: null, author: null, name: null, group: null, archived: false }, ])), @@ -271,13 +283,13 @@ test("supports keyboard session resizing and hides its divider when the split is test("clicking a session with a live runtime reconnects to its gate instead of the panel", async () => { let resumed: string | null = null; server.use( - http.get("http://localhost:8787/sessions", () => + http.get("/api/sessions", () => HttpResponse.json([{ ...LIST[0], active: true }])), - http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => { + http.post("/api/sessions/:id/resume", ({ params }) => { resumed = params.id as string; return resumeResult(resumed, true); // warm runtime → alreadyActive }), - http.get("http://localhost:8787/sessions/:id", () => + http.get("/api/sessions/:id", () => HttpResponse.json({ id: "s1", status: "open", phase: 1 })), ); wrap(); @@ -289,7 +301,7 @@ test("clicking a session with a live runtime reconnects to its gate instead of t }); test("New session closes an open session detail panel", async () => { - server.use(http.post("http://localhost:8787/runtime/prewarm", () => + server.use(http.post("/api/runtime/prewarm", () => HttpResponse.json({ status: "warming" }, { status: 202 }))); wrap(); await userEvent.click(await screen.findByText("Attiva uno")); // cold session → panel opens @@ -316,7 +328,7 @@ test("Resume from the panel activates the session and closes the panel", async ( activityLog: [{ kind: "status", phase: "F7", text: "Stale prior activity", level: "info" }], }); server.use( - http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => { + http.post("/api/sessions/:id/resume", ({ params }) => { resumed = params.id as string; return resumeResult(resumed); }), @@ -335,8 +347,8 @@ test("Resume from the panel activates the session and closes the panel", async ( test("Resume paints the re-entry phase from the manifest after the cold Resume succeeds", async () => { useSessionStore.getState().resetSession(); server.use( - http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")), - http.get("http://localhost:8787/sessions/:id", () => + http.post("/api/sessions/:id/resume", () => resumeResult("s1")), + http.get("/api/sessions/:id", () => HttpResponse.json({ id: "s1", status: "open", phase: 4 })), ); wrap(); @@ -349,11 +361,11 @@ test("Resume paints the re-entry phase from the manifest after the cold Resume s test("an already-active same-session Resume preserves its EventSource and store", async () => { let resumeCalls = 0; server.use( - http.post("http://localhost:8787/sessions/:id/resume", () => { + http.post("/api/sessions/:id/resume", () => { resumeCalls += 1; return resumeResult("s1", resumeCalls > 1); }), - http.get("http://localhost:8787/sessions/:id", () => + http.get("/api/sessions/:id", () => HttpResponse.json({ id: "s1", status: "open", phase: 1 })), ); wrap(); @@ -379,14 +391,14 @@ test("concurrent same-id Resume invocations share one cold request and replaceme const coldGate = deferred(); const coldStarted = deferred(); server.use( - http.post("http://localhost:8787/sessions/:id/resume", async () => { + http.post("/api/sessions/:id/resume", async () => { resumeCalls += 1; if (resumeCalls === 1) return resumeResult("s1", false); if (resumeCalls === 2) coldStarted.resolve(); await coldGate.promise; return resumeResult("s1", false); }), - http.get("http://localhost:8787/sessions/:id", () => + http.get("/api/sessions/:id", () => HttpResponse.json({ id: "s1", status: "open", phase: 1 })), ); wrap(); @@ -408,7 +420,7 @@ test("concurrent same-id Resume invocations share one cold request and replaceme await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2)); const replacement = FakeEventSource.instances[1]; expect(oldSource.closed).toBe(true); - expect(replacement.url).toBe("http://localhost:8787/sessions/s1/events"); + expect(replacement.url).toBe("/api/sessions/s1/events"); expect(useSessionStore.getState().activityLog).toEqual([ { kind: "lifecycle", phase: null, text: "Resuming session" }, ]); @@ -431,13 +443,13 @@ test("a committed Resume releases same-id single-flight before its manifest sett const firstS1ManifestGate = deferred(); const firstS1ManifestStarted = deferred(); server.use( - http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])), - http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => { + http.get("/api/sessions", () => HttpResponse.json([LIST[0], other])), + http.post("/api/sessions/:id/resume", ({ params }) => { const id = params.id as string; if (id === "s1") s1ResumeCalls += 1; return resumeResult(id); }), - http.get("http://localhost:8787/sessions/:id", async ({ params }) => { + http.get("/api/sessions/:id", async ({ params }) => { if (params.id === "s1") { s1ManifestCalls += 1; if (s1ManifestCalls === 1) { @@ -476,9 +488,9 @@ test("a committed Resume releases same-id single-flight before its manifest sett test("cold same-session Resume keeps the old stream until success then receives post-clear events once", async () => { server.use( - http.post("http://localhost:8787/sessions/:id/resume", () => + http.post("/api/sessions/:id/resume", () => resumeResult("s1")), - http.get("http://localhost:8787/sessions/:id", () => + http.get("/api/sessions/:id", () => HttpResponse.json({ id: "s1", status: "open", phase: 1 })), ); wrap(); @@ -492,7 +504,7 @@ test("cold same-session Resume keeps the old stream until success then receives let markStarted!: () => void; const resumeStarted = new Promise((resolve) => { markStarted = resolve; }); const resumeReleased = new Promise((resolve) => { releaseResume = resolve; }); - server.use(http.post("http://localhost:8787/sessions/:id/resume", async () => { + server.use(http.post("/api/sessions/:id/resume", async () => { markStarted(); await resumeReleased; return resumeResult("s1"); @@ -517,7 +529,7 @@ test("cold same-session Resume keeps the old stream until success then receives await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2)); expect(first.closed).toBe(true); const replacement = FakeEventSource.instances[1]; - expect(replacement.url).toBe("http://localhost:8787/sessions/s1/events"); + expect(replacement.url).toBe("/api/sessions/s1/events"); expect(useSessionStore.getState().transcript).toEqual([]); expect(useSessionStore.getState().activityLog).toEqual([ { kind: "lifecycle", phase: null, text: "Resuming session" }, @@ -549,9 +561,9 @@ test("cold same-session Resume keeps the old stream until success then receives test("a failed same-session Resume preserves its source, activity, and document panel", async () => { server.use( - http.post("http://localhost:8787/sessions/:id/resume", () => + http.post("/api/sessions/:id/resume", () => resumeResult("s1")), - http.get("http://localhost:8787/sessions/:id", () => + http.get("/api/sessions/:id", () => HttpResponse.json({ id: "s1", status: "open", phase: 1 })), ); wrap(); @@ -562,7 +574,7 @@ test("a failed same-session Resume preserves its source, activity, and document act(() => first.emitNamed("info", { type: "info", text: "Keep me" }, "4")); const before = useSessionStore.getState().activityLog.map((entry) => ({ ...entry })); - server.use(http.post("http://localhost:8787/sessions/:id/resume", () => + server.use(http.post("/api/sessions/:id/resume", () => new HttpResponse(null, { status: 409 }))); await userEvent.click(screen.getByTestId("session-item-s1")); await userEvent.click(await screen.findByRole("button", { name: /resume/i })); @@ -581,10 +593,10 @@ test("resuming a different already-active session binds it only after success", created_at: "2026-01-03T00:00:00Z", }; server.use( - http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])), - http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => + http.get("/api/sessions", () => HttpResponse.json([LIST[0], other])), + http.post("/api/sessions/:id/resume", ({ params }) => resumeResult(params.id as string, params.id === "s3")), - http.get("http://localhost:8787/sessions/:id", ({ params }) => + http.get("/api/sessions/:id", ({ params }) => HttpResponse.json({ id: params.id, status: "open", phase: params.id === "s3" ? 3 : 1 })), ); wrap(); @@ -617,8 +629,8 @@ test("competing Resume requests for different ids commit only the latest intent" const s1Started = deferred(); const s3Started = deferred(); server.use( - http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])), - http.post("http://localhost:8787/sessions/:id/resume", async ({ params }) => { + http.get("/api/sessions", () => HttpResponse.json([LIST[0], other])), + http.post("/api/sessions/:id/resume", async ({ params }) => { const id = params.id as string; if (id === "s1") { s1Started.resolve(); @@ -629,7 +641,7 @@ test("competing Resume requests for different ids commit only the latest intent" } return resumeResult(id); }), - http.get("http://localhost:8787/sessions/:id", ({ params }) => + http.get("/api/sessions/:id", ({ params }) => HttpResponse.json({ id: params.id, status: "open", phase: params.id === "s3" ? 3 : 1 })), ); wrap(); @@ -667,10 +679,10 @@ test("a stale Resume manifest cannot repaint the latest session phase", async () const s1ManifestGate = deferred(); const s1ManifestStarted = deferred(); server.use( - http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])), - http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => + http.get("/api/sessions", () => HttpResponse.json([LIST[0], other])), + http.post("/api/sessions/:id/resume", ({ params }) => resumeResult(params.id as string)), - http.get("http://localhost:8787/sessions/:id", async ({ params }) => { + http.get("/api/sessions/:id", async ({ params }) => { if (params.id === "s1") { s1ManifestStarted.resolve(); await s1ManifestGate.promise; @@ -700,12 +712,12 @@ test("starting a new question invalidates a pending Resume intent", async () => const resumeGate = deferred(); const resumeStarted = deferred(); server.use( - http.post("http://localhost:8787/sessions/:id/resume", async () => { + http.post("/api/sessions/:id/resume", async () => { resumeStarted.resolve(); await resumeGate.promise; return resumeResult("s1"); }), - http.post("http://localhost:8787/runtime/prewarm", () => + http.post("/api/runtime/prewarm", () => HttpResponse.json({ status: "warming" }, { status: 202 })), ); wrap(); @@ -728,12 +740,12 @@ test("a successful Delete invalidates an earlier pending Resume for the same tar const resumeStarted = deferred(); const deleteCompleted = deferred(); server.use( - http.post("http://localhost:8787/sessions/:id/resume", async () => { + http.post("/api/sessions/:id/resume", async () => { resumeStarted.resolve(); await resumeGate.promise; return resumeResult("s1"); }), - http.delete("http://localhost:8787/sessions/:id", ({ params }) => { + http.delete("/api/sessions/:id", ({ params }) => { expect(params.id).toBe("s1"); deleteCompleted.resolve(); return new HttpResponse(null, { status: 204 }); @@ -760,14 +772,14 @@ test("a successful Delete detaches a Resume that commits while Delete is pending const deleteGate = deferred(); const deleteStarted = deferred(); server.use( - http.delete("http://localhost:8787/sessions/:id", async ({ params }) => { + http.delete("/api/sessions/:id", async ({ params }) => { expect(params.id).toBe("s1"); deleteStarted.resolve(); await deleteGate.promise; return new HttpResponse(null, { status: 204 }); }), - http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")), - http.get("http://localhost:8787/sessions/:id", () => + http.post("/api/sessions/:id/resume", () => resumeResult("s1")), + http.get("/api/sessions/:id", () => HttpResponse.json({ id: "s1", status: "open", phase: 1 })), ); wrap(); @@ -794,12 +806,12 @@ test("deleting another session does not invalidate a pending Resume", async () = const resumeStarted = deferred(); const deleteCompleted = deferred(); server.use( - http.post("http://localhost:8787/sessions/:id/resume", async () => { + http.post("/api/sessions/:id/resume", async () => { resumeStarted.resolve(); await resumeGate.promise; return resumeResult("s1"); }), - http.delete("http://localhost:8787/sessions/:id", ({ params }) => { + http.delete("/api/sessions/:id", ({ params }) => { expect(params.id).toBe("s2"); deleteCompleted.resolve(); return new HttpResponse(null, { status: 204 }); @@ -831,8 +843,8 @@ test("deleting active A preserves a pending Resume for different session B", asy const s3ResumeGate = deferred(); const s3ResumeStarted = deferred(); server.use( - http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])), - http.post("http://localhost:8787/sessions/:id/resume", async ({ params }) => { + http.get("/api/sessions", () => HttpResponse.json([LIST[0], other])), + http.post("/api/sessions/:id/resume", async ({ params }) => { const id = params.id as string; if (id === "s3") { s3ResumeStarted.resolve(); @@ -840,9 +852,9 @@ test("deleting active A preserves a pending Resume for different session B", asy } return resumeResult(id); }), - http.get("http://localhost:8787/sessions/:id", ({ params }) => + http.get("/api/sessions/:id", ({ params }) => HttpResponse.json({ id: params.id, status: "open", phase: params.id === "s3" ? 3 : 1 })), - http.delete("http://localhost:8787/sessions/:id", ({ params }) => { + http.delete("/api/sessions/:id", ({ params }) => { expect(params.id).toBe("s1"); return new HttpResponse(null, { status: 204 }); }), @@ -876,12 +888,12 @@ test("a failed Delete does not invalidate a pending Resume for its target", asyn const resumeStarted = deferred(); const deleteFailed = deferred(); server.use( - http.post("http://localhost:8787/sessions/:id/resume", async () => { + http.post("/api/sessions/:id/resume", async () => { resumeStarted.resolve(); await resumeGate.promise; return resumeResult("s1"); }), - http.delete("http://localhost:8787/sessions/:id", ({ params }) => { + http.delete("/api/sessions/:id", ({ params }) => { expect(params.id).toBe("s1"); deleteFailed.resolve(); return new HttpResponse(null, { status: 500 }); @@ -905,7 +917,7 @@ test("a failed Delete does not invalidate a pending Resume for its target", asyn }); test("a failed Resume with no active session preserves the panel and existing activity", async () => { - server.use(http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 409 }))); + server.use(http.post("/api/sessions/:id/resume", () => new HttpResponse(null, { status: 409 }))); useSessionStore.setState({ activityLog: [{ kind: "status", phase: "F7", text: "Preserve activity", level: "info" }], }); @@ -923,7 +935,7 @@ test("a failed Resume with no active session preserves the panel and existing ac test("closing and reopening Model activity preserves the complete activity log", async () => { wrap(); - server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1"))); + server.use(http.post("/api/sessions/:id/resume", () => resumeResult("s1"))); await userEvent.click(await screen.findByText("Attiva uno")); await userEvent.click(await screen.findByRole("button", { name: /resume/i })); act(() => { @@ -971,8 +983,8 @@ test("session finalization shows the completion banner and returns to landing", useSessionStore.getState().resetSession(); let finalized = false; server.use( - http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")), - http.get("http://localhost:8787/sessions", () => + http.post("/api/sessions/:id/resume", () => resumeResult("s1")), + http.get("/api/sessions", () => HttpResponse.json(finalized ? [{ ...LIST[0], status: "finalized" }] : LIST)), ); wrap(); @@ -995,7 +1007,7 @@ test("session finalization shows the completion banner and returns to landing", test("renaming a group reassigns its members via setSessionGroup", async () => { const groupSets: Array<{ id: string; group: string }> = []; server.use( - http.post("http://localhost:8787/sessions/:id/group", async ({ params, request }) => { + http.post("/api/sessions/:id/group", async ({ params, request }) => { const body = (await request.json()) as { group: string }; groupSets.push({ id: params.id as string, group: body.group }); return new HttpResponse(null, { status: 204 }); @@ -1013,7 +1025,7 @@ test("renaming a group reassigns its members via setSessionGroup", async () => { test("opens an accessible resizable activity split and persists pointer width", async () => { - server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1"))); + server.use(http.post("/api/sessions/:id/resume", () => resumeResult("s1"))); wrap(); await userEvent.click(await screen.findByText("Attiva uno")); await userEvent.click(await screen.findByRole("button", { name: /resume/i })); @@ -1043,7 +1055,7 @@ test("opens an accessible resizable activity split and persists pointer width", test("resizes the activity split with keyboard and exposes responsive drawer classes", async () => { localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "448"); - server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1"))); + server.use(http.post("/api/sessions/:id/resume", () => resumeResult("s1"))); wrap(); await userEvent.click(await screen.findByText("Attiva uno")); await userEvent.click(await screen.findByRole("button", { name: /resume/i })); @@ -1070,7 +1082,7 @@ test("resizes the activity split with keyboard and exposes responsive drawer cla test("uses the measured app shell for the desktop activity split", async () => { localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "576"); - server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1"))); + server.use(http.post("/api/sessions/:id/resume", () => resumeResult("s1"))); wrap(); await userEvent.click(await screen.findByText("Attiva uno")); await userEvent.click(await screen.findByRole("button", { name: /resume/i })); @@ -1092,7 +1104,7 @@ test("uses the measured app shell for the desktop activity split", async () => { test("cancels an active resize when the measured shell becomes too narrow", async () => { localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "576"); - server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1"))); + server.use(http.post("/api/sessions/:id/resume", () => resumeResult("s1"))); wrap(); await userEvent.click(await screen.findByText("Attiva uno")); await userEvent.click(await screen.findByRole("button", { name: /resume/i })); diff --git a/frontend/src/shell/NewSessionDialog.test.tsx b/frontend/src/shell/NewSessionDialog.test.tsx index bf28d88c..2e7e5cfd 100644 --- a/frontend/src/shell/NewSessionDialog.test.tsx +++ b/frontend/src/shell/NewSessionDialog.test.tsx @@ -31,13 +31,13 @@ test("submitting includes browser-local migrated preferences and calls onCreated localStorage.clear(); let body: unknown = null; server.use( - http.get("http://localhost:8787/settings", () => HttpResponse.json({ + http.get("/api/settings", () => HttpResponse.json({ workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low", })), - http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ + http.get("/api/workspaces", () => HttpResponse.json([{ id: "default", name: "default", file: "default.yaml", displayName: "Default", }])), - http.post("http://localhost:8787/sessions", async ({ request }) => { + http.post("/api/sessions", async ({ request }) => { body = await request.json(); return HttpResponse.json({ id: "s1" }); }), @@ -67,21 +67,21 @@ test("first-run direct dialog creation waits for registry policy without a mount workspaceId: "psd-clinical", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", })); server.use( - http.get("http://localhost:8787/workspaces", () => { + http.get("/api/workspaces", () => { summaryRequestStarted = true; return HttpResponse.json([{ id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", revision, }]); }), - http.get("http://localhost:8787/workspaces/psd-clinical", async () => { + http.get("/api/workspaces/psd-clinical", async () => { policyRequestStarted = true; await policyMayFinish; return HttpResponse.json({ workspace: { llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] } }, revision, }); }), - http.post("http://localhost:8787/sessions", async ({ request }) => { + http.post("/api/sessions", async ({ request }) => { body = await request.json(); return HttpResponse.json({ id: "s1" }); }), diff --git a/frontend/src/shell/PiManagement.test.tsx b/frontend/src/shell/PiManagement.test.tsx index 272061a0..4bf01e3c 100644 --- a/frontend/src/shell/PiManagement.test.tsx +++ b/frontend/src/shell/PiManagement.test.tsx @@ -8,6 +8,7 @@ import { PiManagement } from "./PiManagement"; const readyStatus = { version: "0.80.3", ready: true, + credentials: "present", config: { provider: "zai", model: "glm-5.2", reasoning: "medium" }, checkedAt: "2026-08-05T10:00:00.000Z", }; @@ -45,7 +46,7 @@ test("loads Pi version and readiness as an accessible operational rail", async ( expect(screen.getByRole("status", { name: "Pi readiness" })).toHaveTextContent("Ready"); expect(screen.getByText("Pi 0.80.3")).toBeVisible(); expect(screen.getByText("Defaults ready")).toBeVisible(); - expect(screen.getByRole("button", { name: "Run smoke test" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Test saved defaults" })).toBeVisible(); }); test("uses closed provider, model, and reasoning choices without a secret field or terminal", async () => { @@ -80,7 +81,7 @@ test("saves only a selected non-secret configuration", async () => { expect(screen.getByRole("status", { name: "Pi management feedback" })).toHaveTextContent("Defaults saved"); }); -test("runs a provider smoke test and reports credentials only as present or missing", async () => { +test("runs the saved-configuration test without changing credential presence", async () => { const user = userEvent.setup(); let tests = 0; server.use(http.post("/api/pi-management/test", () => { @@ -91,11 +92,17 @@ test("runs a provider smoke test and reports credentials only as present or miss })); renderManagement(); - await user.click(await screen.findByRole("button", { name: "Run smoke test" })); - expect(await screen.findByText("Credentials present")).toBeVisible(); - await user.click(screen.getByRole("button", { name: "Run smoke test" })); - expect(await screen.findByText("Credentials missing")).toBeVisible(); - expect(screen.getByRole("status", { name: "Pi management feedback" })).toHaveTextContent("Pi provider smoke check failed"); + const testButton = await screen.findByRole("button", { name: "Test saved defaults" }); + expect(screen.getByText("Defaults ready")).toBeVisible(); + expect(screen.queryByText("Changes are not saved yet.")).not.toBeInTheDocument(); + expect(testButton).toBeEnabled(); + await user.click(testButton); + await waitFor(() => expect(tests).toBe(1)); + expect(await screen.findByText("Saved configuration test passed")).toBeVisible(); + expect(screen.getByText("Credentials present")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Test saved defaults" })); + expect(await screen.findByText("Saved configuration test failed")).toBeVisible(); + expect(screen.getByText("Credentials present")).toBeVisible(); }); test("fetches and displays bounded sanitized diagnostic logs only on request", async () => { @@ -125,7 +132,7 @@ test("explains forbidden management access without offering mutation controls", expect(await screen.findByRole("alert", { name: "Pi management unavailable" })).toHaveTextContent("Pi management is not permitted"); expect(screen.queryByLabelText("Provider")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Save defaults" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Run smoke test" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Test saved defaults" })).not.toBeInTheDocument(); }); test("offers a copyable host-side Pi update instruction without an update action", async () => { @@ -168,3 +175,101 @@ test("reloads installation defaults when the panel is reopened", async () => { await waitFor(() => expect(screen.getByLabelText("Provider")).toHaveValue("deepseek")); }); + +test("shows an explicit recoverable incomplete state when no provider model is available", async () => { + server.use( + http.get("/api/pi-management/status", () => HttpResponse.json({ ...readyStatus, credentials: "present" })), + http.get("/api/pi-management/options", () => HttpResponse.json({ + ...options, + providers: ["zai"], + models: [], + })), + ); + renderManagement(); + + const incomplete = await screen.findByRole("alert", { name: "Pi configuration incomplete" }); + expect(incomplete).toHaveTextContent("No enabled provider and model choices are available"); + expect(incomplete).toHaveTextContent("Check the host-managed Pi model configuration"); + expect(screen.queryByText("Loading Pi management…")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save defaults" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Test saved defaults" })).toBeDisabled(); +}); + +test("keeps suggested draft choices distinct from invalid persisted defaults until save succeeds", async () => { + const persisted = { + ...readyStatus, + credentials: "missing", + config: { provider: "retired", model: "old-model", reasoning: "medium" }, + }; + server.use( + http.get("/api/pi-management/status", () => HttpResponse.json(persisted)), + http.put("/api/pi-management/config", async ({ request }) => HttpResponse.json({ + ...await request.json() as object, + updatedAt: "2026-08-05T10:05:00.000Z", + })), + ); + const user = userEvent.setup(); + renderManagement(); + + expect(await screen.findByLabelText("Provider")).toHaveValue("zai"); + expect(screen.getByText("Defaults incomplete")).toBeVisible(); + expect(screen.getByText("Suggested choices are not saved yet.")).toBeVisible(); + expect(screen.getByRole("button", { name: "Test saved defaults" })).toBeDisabled(); + + await user.click(screen.getByRole("button", { name: "Save defaults" })); + expect(await screen.findByText("Defaults ready")).toBeVisible(); + expect(screen.getByRole("button", { name: "Test saved defaults" })).toBeEnabled(); +}); + +test.each(["present", "missing"] as const)( + "shows credentials %s from status without inferring them from smoke", + async (credentials) => { + server.use(http.get("/api/pi-management/status", () => HttpResponse.json({ + ...readyStatus, + credentials, + }))); + renderManagement(); + + expect(await screen.findByText(`Credentials ${credentials}`)).toBeVisible(); + }, +); + +test("labels smoke only as a saved-configuration test and resets it when the draft changes", async () => { + server.use( + http.get("/api/pi-management/status", () => HttpResponse.json({ ...readyStatus, credentials: "present" })), + http.post("/api/pi-management/test", () => HttpResponse.json({ + ready: true, + checkedAt: "2026-08-05T10:06:00.000Z", + })), + ); + const user = userEvent.setup(); + renderManagement(); + + await user.click(await screen.findByRole("button", { name: "Test saved defaults" })); + expect(await screen.findByText("Saved configuration test passed")).toBeVisible(); + expect(screen.getByText("Credentials present")).toBeVisible(); + + await user.selectOptions(screen.getByLabelText("Provider"), "deepseek"); + expect(screen.getByText("Saved configuration test not run")).toBeVisible(); + expect(screen.getByText("Save these changes before testing. The test always uses saved defaults.")).toBeVisible(); + expect(screen.getByRole("button", { name: "Test saved defaults" })).toBeDisabled(); + expect(screen.getByText("Credentials present")).toBeVisible(); +}); + +test("a failed saved-configuration test does not change backend credential presence", async () => { + server.use( + http.get("/api/pi-management/status", () => HttpResponse.json({ ...readyStatus, credentials: "present" })), + http.post("/api/pi-management/test", () => HttpResponse.json({ + ready: false, + message: "Pi runtime is unavailable", + checkedAt: "2026-08-05T10:07:00.000Z", + })), + ); + const user = userEvent.setup(); + renderManagement(); + + await user.click(await screen.findByRole("button", { name: "Test saved defaults" })); + expect(await screen.findByText("Saved configuration test failed")).toBeVisible(); + expect(screen.getByText("Credentials present")).toBeVisible(); + expect(screen.queryByText("Credentials missing")).not.toBeInTheDocument(); +}); diff --git a/frontend/src/shell/PiManagement.tsx b/frontend/src/shell/PiManagement.tsx index 770bbb15..ea3f1da8 100644 --- a/frontend/src/shell/PiManagement.tsx +++ b/frontend/src/shell/PiManagement.tsx @@ -10,6 +10,7 @@ import { savePiManagementConfig, type PiInstallationConfig, type PiManagementOptions, + type PiManagementStatus, } from "../api/pi-management"; import { Button } from "../components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog"; @@ -18,9 +19,9 @@ const UPDATE_COMMAND = "thothctl pi update"; const fieldClass = "h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:ring-3 focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-60"; type Feedback = { tone: "success" | "error"; message: string } | undefined; -type CredentialState = "present" | "missing" | undefined; +type SmokeState = "passed" | "failed" | undefined; -function configFrom(status: { config: Partial }, options: PiManagementOptions): PiInstallationConfig | undefined { +function suggestedConfig(status: { config: Partial }, options: PiManagementOptions): PiInstallationConfig | undefined { const provider = status.config.provider && options.providers.includes(status.config.provider) ? status.config.provider : options.providers[0]; @@ -33,27 +34,55 @@ function configFrom(status: { config: Partial }, options: return provider && model && reasoning ? { provider, model, reasoning } : undefined; } +function isSupportedConfig( + config: Partial | undefined, + options: PiManagementOptions | undefined, +): config is PiInstallationConfig { + return Boolean( + config?.provider + && config.model + && config.reasoning + && options?.providers.includes(config.provider) + && options.models.some((model) => model.provider === config.provider && model.id === config.model) + && options.reasoning.includes(config.reasoning), + ); +} + +function sameConfig(a: Partial | undefined, b: PiInstallationConfig | undefined): boolean { + return Boolean( + a?.provider === b?.provider + && a?.model === b?.model + && a?.reasoning === b?.reasoning, + ); +} + function errorMessage(error: unknown, fallback: string): string { return asPiManagementApiError(error)?.message ?? fallback; } -function ReadinessRail({ ready, configured, credentialState }: { +function ReadinessRail({ ready, configured, credentials, smokeState }: { ready: boolean; configured: boolean; - credentialState: CredentialState; + credentials: "present" | "missing"; + smokeState: SmokeState; }) { return (
+
); @@ -78,7 +107,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () => const queryClient = useQueryClient(); const [draft, setDraft] = useState(); const [feedback, setFeedback] = useState(); - const [credentialState, setCredentialState] = useState(); + const [smokeState, setSmokeState] = useState(); const [logsRequested, setLogsRequested] = useState(false); const statusQuery = useQuery({ queryKey: ["pi-management", "status"], queryFn: getPiManagementStatus, enabled: open }); const optionsQuery = useQuery({ queryKey: ["pi-management", "options"], queryFn: getPiManagementOptions, enabled: open }); @@ -87,44 +116,59 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () => () => optionsQuery.data?.models.filter((model) => model.provider === draft?.provider) ?? [], [draft?.provider, optionsQuery.data?.models], ); - const configured = Boolean(draft?.provider && draft.model && draft.reasoning); - const valid = Boolean( - draft - && optionsQuery.data?.providers.includes(draft.provider) - && optionsQuery.data.models.some((model) => model.provider === draft.provider && model.id === draft.model) - && optionsQuery.data.reasoning.includes(draft.reasoning), + const initialDraft = useMemo( + () => statusQuery.data && optionsQuery.data + ? suggestedConfig(statusQuery.data, optionsQuery.data) + : undefined, + [optionsQuery.data, statusQuery.data], ); + const persistedReady = isSupportedConfig(statusQuery.data?.config, optionsQuery.data); + const validDraft = isSupportedConfig(draft, optionsQuery.data); + const dirty = Boolean(draft && !sameConfig(statusQuery.data?.config, draft)); useEffect(() => { if (!open) { setDraft(undefined); setFeedback(undefined); - setCredentialState(undefined); + setSmokeState(undefined); setLogsRequested(false); queryClient.removeQueries({ queryKey: ["pi-management"] }); } }, [open, queryClient]); useEffect(() => { - if (draft || !statusQuery.data || !optionsQuery.data) return; - setDraft(configFrom(statusQuery.data, optionsQuery.data)); - }, [draft, optionsQuery.data, statusQuery.data]); + if (draft || !initialDraft) return; + setDraft(initialDraft); + }, [draft, initialDraft]); const saveMutation = useMutation({ mutationFn: savePiManagementConfig, - onSuccess: () => { + onSuccess: (saved) => { + const config = { provider: saved.provider, model: saved.model, reasoning: saved.reasoning }; + queryClient.setQueryData(["pi-management", "status"], (current) => ( + current ? { ...current, config } : current + )); + setDraft(config); + setSmokeState(undefined); setFeedback({ tone: "success", message: "Defaults saved." }); - void queryClient.invalidateQueries({ queryKey: ["pi-management", "status"] }); }, onError: (error) => setFeedback({ tone: "error", message: errorMessage(error, "Could not save Pi defaults.") }), }); const smokeMutation = useMutation({ mutationFn: runPiManagementTest, onSuccess: (result) => { - setCredentialState(result.ready ? "present" : "missing"); - setFeedback({ tone: result.ready ? "success" : "error", message: result.message ?? (result.ready ? "Pi smoke test passed." : "Pi smoke test failed.") }); + setSmokeState(result.ready ? "passed" : "failed"); + setFeedback({ + tone: result.ready ? "success" : "error", + message: result.ready + ? "Saved configuration test passed." + : `Saved configuration test failed.${result.message ? ` ${result.message}` : ""}`, + }); + }, + onError: (error) => { + setSmokeState("failed"); + setFeedback({ tone: "error", message: errorMessage(error, "Could not test the saved Pi defaults.") }); }, - onError: (error) => setFeedback({ tone: "error", message: errorMessage(error, "Could not run the Pi smoke test.") }), }); async function copyUpdateCommand() { @@ -138,15 +182,29 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () => } function saveDefaults() { - if (!draft || !valid) { + if (!draft || !validDraft) { setFeedback({ tone: "error", message: "Choose a supported provider, model, and reasoning level." }); return; } saveMutation.mutate(draft); } + function updateDraft(update: (current: PiInstallationConfig) => PiInstallationConfig) { + setDraft((current) => current ? update(current) : current); + setSmokeState(undefined); + setFeedback(undefined); + } + + function testSavedDefaults() { + if (!persistedReady || dirty) { + setFeedback({ tone: "error", message: "Save supported defaults before running the test." }); + return; + } + smokeMutation.mutate(); + } + const forbidden = asPiManagementApiError(statusQuery.error)?.code === "pi_management_forbidden"; - const loading = statusQuery.isLoading || optionsQuery.isLoading || !draft; + const loading = statusQuery.isLoading || optionsQuery.isLoading || Boolean(!draft && initialDraft); const unavailable = statusQuery.isError || optionsQuery.isError; return ( @@ -155,7 +213,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>

Installation controls

Pi management - Review the bundled runtime, set safe defaults, and run a sanitized provider check. + Review the bundled runtime, set safe defaults, and test the saved provider configuration.
@@ -169,7 +227,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>

{errorMessage(statusQuery.error ?? optionsQuery.error, "Pi management is unavailable")}

- ) : loading ?

Loading Pi management…

: statusQuery.data && optionsQuery.data && draft && ( + ) : loading ?

Loading Pi management…

: statusQuery.data && optionsQuery.data && (
@@ -181,7 +239,12 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>

- + {statusQuery.data.message &&

{statusQuery.data.message}

} {feedback &&

@@ -193,30 +256,55 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>

Installation defaults

These choices apply to new Pi work. Credentials remain outside this browser.

-
- - - - - - - - - -
-
- - -
+ {draft ? ( + <> +
+ + + + + + + + + +
+ {dirty &&

+ {persistedReady ? "Changes are not saved yet." : "Suggested choices are not saved yet."} +

} +
+ + +
+ {dirty &&

Save these changes before testing. The test always uses saved defaults.

} + {!dirty && !persistedReady &&

Save supported defaults before testing. The test always uses saved defaults.

} + + ) : ( +
+
+

No enabled provider and model choices are available.

+

Check the host-managed Pi model configuration, then retry this panel.

+
+
+ + + +
+
+ )}
diff --git a/frontend/src/shell/SessionDocumentsPanel.test.tsx b/frontend/src/shell/SessionDocumentsPanel.test.tsx index bd853fbe..df24e306 100644 --- a/frontend/src/shell/SessionDocumentsPanel.test.tsx +++ b/frontend/src/shell/SessionDocumentsPanel.test.tsx @@ -23,7 +23,7 @@ const base: SessionSummary = { }; beforeEach(() => { - server.use(http.get("http://localhost:8787/sessions/s1/documents", () => + server.use(http.get("/api/sessions/s1/documents", () => HttpResponse.json([ { phase: "—", key: "question", title: "Original question", format: "text", content: "How many ablations?" }, { phase: "F7", key: "sql", title: "Final SQL", format: "sql", content: "SELECT 1" }, @@ -77,7 +77,7 @@ test("a malformed document renders a fallback without taking down its siblings", // unmounts the tree); the good sibling document must still render. // resetHandlers(...) replaces the beforeEach handler so this response is used. server.resetHandlers( - http.get("http://localhost:8787/sessions/s1/documents", () => + http.get("/api/sessions/s1/documents", () => HttpResponse.json([ { phase: "F4", key: "schema", title: "Schema linking", format: "schema-linking", content: '{"joins":[]}' }, { phase: "—", key: "question", title: "Original question", format: "text", content: "SIBLING-SURVIVES" }, @@ -95,7 +95,7 @@ test("a malformed document renders a fallback without taking down its siblings", test("renders the canonical summary order and formats human text as Markdown", async () => { server.resetHandlers( - http.get("http://localhost:8787/sessions/s1/documents", () => + http.get("/api/sessions/s1/documents", () => HttpResponse.json([ { phase: "—", key: "question", title: "Original question", format: "text", content: "Original **question**" }, { phase: "F7", key: "sql", title: "Final SQL", format: "sql", content: "SELECT 1" }, @@ -129,7 +129,7 @@ test("renders the canonical summary order and formats human text as Markdown", a test("renders one approved-then-declined memory list with Markdown details", async () => { server.resetHandlers( - http.get("http://localhost:8787/sessions/s1/documents", () => + http.get("/api/sessions/s1/documents", () => HttpResponse.json([ { phase: "F8", @@ -182,7 +182,7 @@ test("never renders technical approval, promotion, or memory decisions", async ( { type: "column_excluded", subject: "fact_a.note", detail: "Non **pertinente**" }, ].map((decision) => JSON.stringify(decision)).join("\n"); server.resetHandlers( - http.get("http://localhost:8787/sessions/s1/documents", () => + http.get("/api/sessions/s1/documents", () => HttpResponse.json([ { phase: "—", key: "decisions", title: "Decisions", format: "decisions", content: lines }, ]), diff --git a/frontend/src/shell/SteerInput.test.tsx b/frontend/src/shell/SteerInput.test.tsx index b2c10bd7..4b52f5a0 100644 --- a/frontend/src/shell/SteerInput.test.tsx +++ b/frontend/src/shell/SteerInput.test.tsx @@ -10,7 +10,7 @@ import { ComposerFooter, ContextGauge, SteerInput } from "./SteerInput"; beforeEach(() => { localStorage.clear(); server.use( - http.post("http://localhost:8787/sessions/:id/steer", () => + http.post("/api/sessions/:id/steer", () => new HttpResponse(null, { status: 204 }), ), ); @@ -22,10 +22,10 @@ test("new sessions send the browser-selected workspace, model, provider, and thi workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "high", })); server.use( - http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ + http.get("/api/workspaces", () => HttpResponse.json([{ id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", }])), - http.post("http://localhost:8787/sessions", async ({ request }) => { + http.post("/api/sessions", async ({ request }) => { body = await request.json(); return HttpResponse.json({ id: "s1" }); }), @@ -49,7 +49,7 @@ test("renders a text input and submit button", () => { test("submitting typed text POSTs to /steer and clears the input", async () => { let captured: unknown = null; server.use( - http.post("http://localhost:8787/sessions/:id/steer", async ({ request, params }) => { + http.post("/api/sessions/:id/steer", async ({ request, params }) => { captured = { id: params.id, body: await request.json() }; return new HttpResponse(null, { status: 204 }); }), @@ -67,7 +67,7 @@ test("submitting typed text POSTs to /steer and clears the input", async () => { test("pressing Enter in the input submits the steer", async () => { let captured: unknown = null; server.use( - http.post("http://localhost:8787/sessions/:id/steer", async ({ request }) => { + http.post("/api/sessions/:id/steer", async ({ request }) => { captured = await request.json(); return new HttpResponse(null, { status: 204 }); }), @@ -83,7 +83,7 @@ test("pressing Enter in the input submits the steer", async () => { test("does not POST when input is empty", async () => { let called = false; server.use( - http.post("http://localhost:8787/sessions/:id/steer", () => { + http.post("/api/sessions/:id/steer", () => { called = true; return new HttpResponse(null, { status: 204 }); }), @@ -126,15 +126,15 @@ test("the context gauge uses green, yellow, and red at the requested thresholds" test("footer shows cumulative k-token counters after workspace and context gauge after thinking", async () => { server.use( - http.get("http://localhost:8787/settings", () => HttpResponse.json({ + http.get("/api/settings", () => HttpResponse.json({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium", })), - http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ name: "psd" }])), - http.get("http://localhost:8787/workspaces/psd", () => HttpResponse.json({ + http.get("/api/workspaces", () => HttpResponse.json([{ name: "psd" }])), + http.get("/api/workspaces/psd", () => HttpResponse.json({ workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } }, revision: { id: "psd", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" }, })), - http.get("http://localhost:8787/models", () => HttpResponse.json({ + http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }], })), ); @@ -162,18 +162,18 @@ test("footer shows cumulative k-token counters after workspace and context gauge test("footer limits model choices to the selected workspace policy", async () => { server.use( - http.get("http://localhost:8787/settings", () => HttpResponse.json({ + http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium", })), - http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ + http.get("/api/workspaces", () => HttpResponse.json([{ id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" }, }])), - http.get("http://localhost:8787/workspaces/psd-clinical", () => HttpResponse.json({ + http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } }, revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" }, })), - http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ + http.get("/api/models", () => HttpResponse.json({ models: [ { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true }, ] })), @@ -195,26 +195,26 @@ test("switching workspaces replaces an out-of-policy model before session creati id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const, }); server.use( - http.get("http://localhost:8787/settings", () => HttpResponse.json({ + http.get("/api/settings", () => HttpResponse.json({ workspace: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", })), - http.get("http://localhost:8787/workspaces", () => HttpResponse.json([ + http.get("/api/workspaces", () => HttpResponse.json([ { id: "research", name: "research", file: "research.yaml", displayName: "Research", revision: revision("research") }, { id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", revision: revision("psd-clinical") }, ])), - http.get("http://localhost:8787/workspaces/research", () => HttpResponse.json({ + http.get("/api/workspaces/research", () => HttpResponse.json({ workspace: { workspace: { id: "research" }, llm_policy: { allowed: ["deepseek/deepseek-v4-pro"] } }, revision: revision("research"), })), - http.get("http://localhost:8787/workspaces/psd-clinical", () => HttpResponse.json({ + http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace: { workspace: { id: "psd-clinical" }, llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] } }, revision: revision("psd-clinical"), })), - http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ + http.get("/api/models", () => HttpResponse.json({ models: [ { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true }, ] })), - http.post("http://localhost:8787/sessions", async ({ request }) => { + http.post("/api/sessions", async ({ request }) => { body = await request.json(); return HttpResponse.json({ id: "s1" }); }), @@ -248,15 +248,15 @@ test("immediate submit waits for a switched workspace policy before creating a s id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const, }); server.use( - http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "research" })), - http.get("http://localhost:8787/workspaces", () => HttpResponse.json([ + http.get("/api/settings", () => HttpResponse.json({ workspace: "research" })), + http.get("/api/workspaces", () => HttpResponse.json([ { id: "research", name: "research", file: "research.yaml", displayName: "Research", revision: revision("research") }, { id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", revision: revision("psd-clinical") }, ])), - http.get("http://localhost:8787/workspaces/research", () => HttpResponse.json({ + http.get("/api/workspaces/research", () => HttpResponse.json({ workspace: { llm_policy: { allowed: ["deepseek/deepseek-v4-pro"] } }, revision: revision("research"), })), - http.get("http://localhost:8787/workspaces/psd-clinical", async () => { + http.get("/api/workspaces/psd-clinical", async () => { policyRequestStarted = true; await policyMayFinish; return HttpResponse.json({ @@ -264,11 +264,11 @@ test("immediate submit waits for a switched workspace policy before creating a s revision: revision("psd-clinical"), }); }), - http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ + http.get("/api/models", () => HttpResponse.json({ models: [ { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true }, ] })), - http.post("http://localhost:8787/sessions", async ({ request }) => { + http.post("/api/sessions", async ({ request }) => { body = await request.json(); return HttpResponse.json({ id: "s1" }); }), @@ -304,11 +304,11 @@ test("initial restored workspace waits for its delayed policy before creating a id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const, }); server.use( - http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "psd-clinical" })), - http.get("http://localhost:8787/workspaces", () => HttpResponse.json([ + http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical" })), + http.get("/api/workspaces", () => HttpResponse.json([ { id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", revision: revision("psd-clinical") }, ])), - http.get("http://localhost:8787/workspaces/psd-clinical", async () => { + http.get("/api/workspaces/psd-clinical", async () => { policyRequestStarted = true; await policyMayFinish; return HttpResponse.json({ @@ -316,11 +316,11 @@ test("initial restored workspace waits for its delayed policy before creating a revision: revision("psd-clinical"), }); }), - http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ + http.get("/api/models", () => HttpResponse.json({ models: [ { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true }, ] })), - http.post("http://localhost:8787/sessions", async ({ request }) => { + http.post("/api/sessions", async ({ request }) => { body = await request.json(); return HttpResponse.json({ id: "s1" }); }), @@ -350,18 +350,18 @@ test("initial submit waits for delayed workspace summaries before allowing a con workspaceId: "legacy-workspace", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", })); server.use( - http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "legacy-workspace" })), - http.get("http://localhost:8787/workspaces", async () => { + http.get("/api/settings", () => HttpResponse.json({ workspace: "legacy-workspace" })), + http.get("/api/workspaces", async () => { summaryRequestStarted = true; await summariesMayFinish; return HttpResponse.json([{ id: "legacy-workspace", name: "legacy-workspace", file: "legacy-workspace.yaml", displayName: "Legacy workspace", }]); }), - http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ + http.get("/api/models", () => HttpResponse.json({ models: [ { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true }, ] })), - http.post("http://localhost:8787/sessions", async ({ request }) => { + http.post("/api/sessions", async ({ request }) => { body = await request.json(); return HttpResponse.json({ id: "s1" }); }), @@ -390,15 +390,15 @@ test("failed workspace summaries block creation and report a safe error", async workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium", })); server.use( - http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "psd-clinical" })), - http.get("http://localhost:8787/workspaces", () => { + http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical" })), + http.get("/api/workspaces", () => { summaryRequestFailed = true; return new HttpResponse(null, { status: 503 }); }), - http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ + http.get("/api/models", () => HttpResponse.json({ models: [ { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, ] })), - http.post("http://localhost:8787/sessions", async ({ request }) => { + http.post("/api/sessions", async ({ request }) => { body = await request.json(); return HttpResponse.json({ id: "s1" }); }), @@ -433,21 +433,21 @@ test("submit follows a rapid workspace switch instead of waiting for an abandone id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const, }); server.use( - http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "research" })), - http.get("http://localhost:8787/workspaces", () => HttpResponse.json([ + http.get("/api/settings", () => HttpResponse.json({ workspace: "research" })), + http.get("/api/workspaces", () => HttpResponse.json([ { id: "research", name: "research", file: "research.yaml", displayName: "Research", revision: revision("research") }, { id: "workspace-b", name: "workspace-b", file: "workspace-b.yaml", displayName: "Workspace B", revision: revision("workspace-b") }, { id: "workspace-c", name: "workspace-c", file: "workspace-c.yaml", displayName: "Workspace C", revision: revision("workspace-c") }, ])), - http.get("http://localhost:8787/workspaces/research", () => HttpResponse.json({ + http.get("/api/workspaces/research", () => HttpResponse.json({ workspace: { llm_policy: { allowed: ["deepseek/deepseek-v4-pro"] } }, revision: revision("research"), })), - http.get("http://localhost:8787/workspaces/workspace-b", async () => { + http.get("/api/workspaces/workspace-b", async () => { bPolicyRequestStarted = true; await new Promise(() => undefined); return HttpResponse.json({}); }), - http.get("http://localhost:8787/workspaces/workspace-c", async () => { + http.get("/api/workspaces/workspace-c", async () => { cPolicyRequestStarted = true; await cPolicyMayFinish; return HttpResponse.json({ @@ -455,11 +455,11 @@ test("submit follows a rapid workspace switch instead of waiting for an abandone revision: revision("workspace-c"), }); }), - http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ + http.get("/api/models", () => HttpResponse.json({ models: [ { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true }, ] })), - http.post("http://localhost:8787/sessions", async ({ request }) => { + http.post("/api/sessions", async ({ request }) => { body = await request.json(); return HttpResponse.json({ id: "s1" }); }), diff --git a/frontend/src/shell/WorkspaceManager.test.tsx b/frontend/src/shell/WorkspaceManager.test.tsx index 43252f6c..903ec312 100644 --- a/frontend/src/shell/WorkspaceManager.test.tsx +++ b/frontend/src/shell/WorkspaceManager.test.tsx @@ -24,14 +24,14 @@ function renderManager() { beforeEach(() => { localStorage.clear(); server.use( - http.get("http://localhost:8787/workspace-registry/status", () => + http.get("/api/workspace-registry/status", () => HttpResponse.json({ branch: "main", head: "a".repeat(40), ahead: 0, behind: 0, degraded: false })), - http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ + http.get("/api/workspaces", () => HttpResponse.json([{ id: "psd-clinical", name: "PSD Clinical", displayName: "PSD Clinical", description: "Clinical data", language: "en", file: "workspaces/psd-clinical.yaml", revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd", state: "operational" }, }])), - http.get("http://localhost:8787/workspaces/psd-clinical", () => HttpResponse.json({ + http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace, revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd", state: "operational" }, })), @@ -60,8 +60,8 @@ test("imports a bundle as a local draft and never publishes it automatically", a const user = userEvent.setup(); const publishSpy = vi.fn(); server.use( - http.post("http://localhost:8787/workspaces/import", () => HttpResponse.json({ draft: { workspace, contract: {} } })), - http.post("http://localhost:8787/workspaces/publish", () => { + http.post("/api/workspaces/import", () => HttpResponse.json({ draft: { workspace, contract: {} } })), + http.post("/api/workspaces/publish", () => { publishSpy(); return HttpResponse.json({}); }), @@ -87,9 +87,9 @@ test("pulls and exports only when the manager explicitly requests each action", vi.stubGlobal("URL", DownloadUrl); vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); server.use( - http.post("http://localhost:8787/workspace-registry/pull", () => HttpResponse.json({ branch: "main", head: "c".repeat(40), ahead: 0, behind: 0, degraded: false })), - http.get("http://localhost:8787/workspaces/psd-clinical/export", () => new HttpResponse(new Blob(["bundle"], { type: "application/zip" }))), - http.post("http://localhost:8787/workspaces/publish", () => { + http.post("/api/workspace-registry/pull", () => HttpResponse.json({ branch: "main", head: "c".repeat(40), ahead: 0, behind: 0, degraded: false })), + http.get("/api/workspaces/psd-clinical/export", () => new HttpResponse(new Blob(["bundle"], { type: "application/zip" }))), + http.post("/api/workspaces/publish", () => { publishSpy(); return HttpResponse.json({}); }), @@ -109,7 +109,7 @@ test("pulls and exports only when the manager explicitly requests each action", test("stages duplicate and delete operations without publishing", async () => { const user = userEvent.setup(); let published = false; - server.use(http.post("http://localhost:8787/workspaces/publish", () => { + server.use(http.post("/api/workspaces/publish", () => { published = true; return HttpResponse.json({}); })); @@ -131,8 +131,8 @@ test("saves resolved conflict choices as a rebased browser draft without publish const local = { ...workspace, semantic_index: { ...workspace.semantic_index, embedding: { ...workspace.semantic_index.embedding, model: "local-model" } } }; const remote = { ...workspace, semantic_index: { ...workspace.semantic_index, embedding: { ...workspace.semantic_index.embedding, model: "remote-model" } } }; server.use( - http.post("http://localhost:8787/workspaces/validate", () => HttpResponse.json({ workspace: local, contract: {} })), - http.post("http://localhost:8787/workspaces/publish", () => { + http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: local, contract: {} })), + http.post("/api/workspaces/publish", () => { publishCalls += 1; return HttpResponse.json({ code: "workspace_conflict", message: "Workspace changed in the registry.", fields: ["semantic_index.embedding.model"], @@ -162,11 +162,11 @@ test("proposes a different valid ID when duplicating a 63-character workspace ID const maxId = `w${"a".repeat(62)}`; const maxWorkspace = { ...workspace, workspace: { ...workspace.workspace, id: maxId } }; server.use( - http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ + http.get("/api/workspaces", () => HttpResponse.json([{ id: maxId, name: "Maximum", displayName: "Maximum", language: "en", file: `workspaces/${maxId}.yaml`, revision: { id: maxId, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/maximum", state: "operational" }, }])), - http.get(`http://localhost:8787/workspaces/${maxId}`, () => HttpResponse.json({ + http.get(`/api/workspaces/${maxId}`, () => HttpResponse.json({ workspace: maxWorkspace, revision: { id: maxId, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/maximum", state: "operational" }, })), @@ -197,8 +197,8 @@ test("does not show a saved-draft toast when manager validation rejects a DWH ti test("runs validation and installation test with only sanitized messages", async () => { const user = userEvent.setup(); server.use( - http.post("http://localhost:8787/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} })), - http.post("http://localhost:8787/workspaces/psd-clinical/test", () => HttpResponse.json({ + http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} })), + http.post("/api/workspaces/psd-clinical/test", () => HttpResponse.json({ activatable: false, diagnostics: [{ level: "warning", code: "binding_missing", field: "dwh", message: "DWH binding is not configured" }], })), @@ -216,7 +216,7 @@ test("runs validation and installation test with only sanitized messages", async test("shows an accessible retry instead of a loading status when the registry status query fails", async () => { const user = userEvent.setup(); let calls = 0; - server.use(http.get("http://localhost:8787/workspace-registry/status", () => { + server.use(http.get("/api/workspace-registry/status", () => { calls += 1; return calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json({ branch: "main", ahead: 0, behind: 0, degraded: false }); })); @@ -231,7 +231,7 @@ test("shows an accessible retry instead of a loading status when the registry st test("shows an accessible retry instead of an empty list when the workspace list query fails", async () => { const user = userEvent.setup(); let calls = 0; - server.use(http.get("http://localhost:8787/workspaces", () => { + server.use(http.get("/api/workspaces", () => { calls += 1; return calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json([]); })); @@ -247,7 +247,7 @@ test("shows an accessible retry instead of an empty list when the workspace list test("shows an accessible retry when the selected workspace detail query fails", async () => { const user = userEvent.setup(); let calls = 0; - server.use(http.get("http://localhost:8787/workspaces/psd-clinical", () => { + server.use(http.get("/api/workspaces/psd-clinical", () => { calls += 1; return calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json({ workspace, diff --git a/frontend/src/shell/WorkspacePublishDialog.test.tsx b/frontend/src/shell/WorkspacePublishDialog.test.tsx index 07db7e0f..65c32174 100644 --- a/frontend/src/shell/WorkspacePublishDialog.test.tsx +++ b/frontend/src/shell/WorkspacePublishDialog.test.tsx @@ -43,14 +43,14 @@ const diagnosticsBranchConflict: WorkspaceConflict = { }; beforeEach(() => { - server.use(http.post("http://localhost:8787/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} }))); + server.use(http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} }))); }); test("validates a draft and requires a separate confirmation before publishing", async () => { const user = userEvent.setup(); const published = vi.fn(); let publishCalls = 0; - server.use(http.post("http://localhost:8787/workspaces/publish", () => { + server.use(http.post("/api/workspaces/publish", () => { publishCalls += 1; return HttpResponse.json({ revision: { id: "psd-clinical", commit: "c".repeat(40), blob: "d".repeat(40), snapshotPath: "/safe", state: "operational" } }); })); @@ -71,7 +71,7 @@ test("validates a draft and requires a separate confirmation before publishing", test("shows a field-level conflict and never overwrites the remote workspace", async () => { const user = userEvent.setup(); let published = false; - server.use(http.post("http://localhost:8787/workspaces/publish", () => { + server.use(http.post("/api/workspaces/publish", () => { published = true; return HttpResponse.json({ ...conflict, message: "Workspace changed in the registry." }, { status: 409 }); })); @@ -94,7 +94,7 @@ test("saves explicit local choices as a rebased draft and does not republish it" const user = userEvent.setup(); const saved = vi.fn(); let publishCalls = 0; - server.use(http.post("http://localhost:8787/workspaces/publish", () => { + server.use(http.post("/api/workspaces/publish", () => { publishCalls += 1; return HttpResponse.json({ ...conflict, message: "Workspace changed in the registry." }, { status: 409 }); })); @@ -116,7 +116,7 @@ test("saves explicit local choices as a rebased draft and does not republish it" test("rebases a selected optional diagnostics branch into the revised draft", async () => { const user = userEvent.setup(); const saved = vi.fn(); - server.use(http.post("http://localhost:8787/workspaces/publish", () => HttpResponse.json({ + server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({ ...diagnosticsBranchConflict, message: "Workspace changed in the registry.", }, { status: 409 }))); render(); @@ -135,7 +135,7 @@ test("rebases a selected optional diagnostics branch into the revised draft", as test("keeps a conflict open and redacts a failed registry pull", async () => { const user = userEvent.setup(); - server.use(http.post("http://localhost:8787/workspaces/publish", () => HttpResponse.json({ + server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({ ...conflict, message: "Workspace changed in the registry.", }, { status: 409 }))); render(); diff --git a/frontend/src/shell/f1-loop.test.tsx b/frontend/src/shell/f1-loop.test.tsx index b57eba47..8b03df88 100644 --- a/frontend/src/shell/f1-loop.test.tsx +++ b/frontend/src/shell/f1-loop.test.tsx @@ -18,16 +18,16 @@ beforeEach(() => { test("F1: create session -> widget via SSE -> respond -> POST /response", async () => { let responded: any = null; server.use( - http.post("http://localhost:8787/sessions", () => HttpResponse.json({ id: "s1" })), - http.get("http://localhost:8787/sessions", () => HttpResponse.json([])), - http.get("http://localhost:8787/settings", () => HttpResponse.json({ + http.post("/api/sessions", () => HttpResponse.json({ id: "s1" })), + http.get("/api/sessions", () => HttpResponse.json([])), + http.get("/api/settings", () => HttpResponse.json({ workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low", })), - http.get("http://localhost:8787/workspaces", () => + http.get("/api/workspaces", () => HttpResponse.json([{ name: "default", file: "default.db" }]), ), - http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [] })), - http.post("http://localhost:8787/sessions/s1/response", async ({ request }) => { + http.get("/api/models", () => HttpResponse.json({ models: [] })), + http.post("/api/sessions/s1/response", async ({ request }) => { responded = await request.json(); return new HttpResponse(null, { status: 204 }); }), diff --git a/frontend/src/stream/useSessionStream.test.tsx b/frontend/src/stream/useSessionStream.test.tsx index 4e669f36..70b604eb 100644 --- a/frontend/src/stream/useSessionStream.test.tsx +++ b/frontend/src/stream/useSessionStream.test.tsx @@ -56,7 +56,7 @@ test("flushes pending stream text before a structural event", () => { test("opens an EventSource and feeds NAMED events to the store", () => { renderHook(() => useSessionStream("s1")); const es = FakeEventSource.instances[0]; - expect(es.url).toBe("http://localhost:8787/sessions/s1/events"); + expect(es.url).toBe("/api/sessions/s1/events"); // Backend sends `event: ui_request` (named) — drive the addEventListener path // that production relies on, not the unnamed onmessage fallback. act(() => @@ -146,7 +146,7 @@ test("a same-session generation reconnect includes the last consumed SSE id", () expect(first.closed).toBe(true); expect(FakeEventSource.instances).toHaveLength(2); expect(FakeEventSource.instances[1].url).toBe( - "http://localhost:8787/sessions/s1/events?lastEventId=7", + "/api/sessions/s1/events?lastEventId=7", ); act(() => @@ -176,7 +176,7 @@ test("a same-session reset epoch drops a high cursor and accepts fresh low-id ev expect(first.closed).toBe(true); expect(FakeEventSource.instances).toHaveLength(2); expect(FakeEventSource.instances[1].url).toBe( - "http://localhost:8787/sessions/s1/events", + "/api/sessions/s1/events", ); act(() => @@ -199,7 +199,7 @@ test("a reset epoch also accepts an in-process preserved high id and resumes fro rerender({ generation: 0, resetEpoch: 1 }); const replacement = FakeEventSource.instances[1]; - expect(replacement.url).toBe("http://localhost:8787/sessions/s1/events"); + expect(replacement.url).toBe("/api/sessions/s1/events"); act(() => replacement.emitNamed( "ui_request", @@ -211,7 +211,7 @@ test("a reset epoch also accepts an in-process preserved high id and resumes fro expect(useSessionStore.getState().pendingWidget?.id).toBe("preserved-high-gate"); rerender({ generation: 1, resetEpoch: 1 }); expect(FakeEventSource.instances[2].url).toBe( - "http://localhost:8787/sessions/s1/events?lastEventId=902", + "/api/sessions/s1/events?lastEventId=902", ); }); @@ -226,7 +226,7 @@ test("changing the session resets the manual reconnect cursor", () => { rerender({ sessionId: "s2" }); expect(first.closed).toBe(true); - expect(FakeEventSource.instances[1].url).toBe("http://localhost:8787/sessions/s2/events"); + expect(FakeEventSource.instances[1].url).toBe("/api/sessions/s2/events"); }); test("a queued event from a replaced source cannot mutate the new session or its cursor", () => { @@ -239,7 +239,7 @@ test("a queued event from a replaced source cannot mutate the new session or its rerender({ sessionId: "s2", generation: 0 }); expect(first.closed).toBe(true); - expect(FakeEventSource.instances[1].url).toBe("http://localhost:8787/sessions/s2/events"); + expect(FakeEventSource.instances[1].url).toBe("/api/sessions/s2/events"); useSessionStore.getState().resetSession(); act(() => @@ -252,7 +252,7 @@ test("a queued event from a replaced source cannot mutate the new session or its expect(useSessionStore.getState().transcript).toEqual([]); rerender({ sessionId: "s2", generation: 1 }); - expect(FakeEventSource.instances[2].url).toBe("http://localhost:8787/sessions/s2/events"); + expect(FakeEventSource.instances[2].url).toBe("/api/sessions/s2/events"); }); test("the old source is invalid before later layout effects can deliver a queued event", () => { diff --git a/frontend/src/test/msw-contract.test.ts b/frontend/src/test/msw-contract.test.ts new file mode 100644 index 00000000..5d2af906 --- /dev/null +++ b/frontend/src/test/msw-contract.test.ts @@ -0,0 +1,7 @@ +import { checkDwhHealth } from "../api/sessions"; + +// Catches shared browser test fixtures drifting from the same-origin /api contract and +// attempting real localhost network requests instead of using MSW. +test("the shared MSW health fixture follows the browser same-origin API contract", async () => { + await expect(checkDwhHealth()).resolves.toEqual({ ok: true, detail: "ok" }); +}); diff --git a/frontend/src/test/msw.ts b/frontend/src/test/msw.ts index e9a9111c..cb00660a 100644 --- a/frontend/src/test/msw.ts +++ b/frontend/src/test/msw.ts @@ -1,5 +1,5 @@ import { setupServer } from "msw/node"; import { http, HttpResponse } from "msw"; export const server = setupServer( - http.get("http://localhost:8787/health/dwh", () => HttpResponse.json({ ok: true, detail: "ok" })), + http.get("/api/health/dwh", () => HttpResponse.json({ ok: true, detail: "ok" })), ); diff --git a/frontend/src/viewers/ResultsPanel.test.tsx b/frontend/src/viewers/ResultsPanel.test.tsx index 191d3cd7..a941e60c 100644 --- a/frontend/src/viewers/ResultsPanel.test.tsx +++ b/frontend/src/viewers/ResultsPanel.test.tsx @@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { server } from "../test/msw"; import { ResultsPanel } from "./ResultsPanel"; -const BASE = "http://localhost:8787"; +const BASE = "/api"; function makeClient() { return new QueryClient({