fix: harden Pi management readiness
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { execFile as nodeExecFile } from "node:child_process";
|
import { execFile as nodeExecFile } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import type { AppConfig } from "../config.js";
|
import type { AppConfig } from "../config.js";
|
||||||
|
import { secretValue } from "../config/secret-bundle.js";
|
||||||
import {
|
import {
|
||||||
loadSettings,
|
loadSettings,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
@@ -12,6 +13,11 @@ import {
|
|||||||
isPiManagedConfigError,
|
isPiManagedConfigError,
|
||||||
} from "./managed-config.js";
|
} from "./managed-config.js";
|
||||||
import { createPiProviderSmoke, type PiProviderSmoke } from "./provider-smoke.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 execFile = promisify(nodeExecFile);
|
||||||
const REASONING_CHOICES = ["low", "medium", "high"] as const;
|
const REASONING_CHOICES = ["low", "medium", "high"] as const;
|
||||||
@@ -31,6 +37,7 @@ export interface PiInstallationConfig {
|
|||||||
export interface PiStatus {
|
export interface PiStatus {
|
||||||
version?: string;
|
version?: string;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
|
credentials: PiCredentialStatus;
|
||||||
config: PiInstallationConfig;
|
config: PiInstallationConfig;
|
||||||
checkedAt: string;
|
checkedAt: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -89,6 +96,7 @@ interface PiManagementDeps {
|
|||||||
readSettings?: () => Settings;
|
readSettings?: () => Settings;
|
||||||
saveSettings?: (settings: Settings) => Settings;
|
saveSettings?: (settings: Settings) => Settings;
|
||||||
readLogs?: () => string | Promise<string>;
|
readLogs?: () => string | Promise<string>;
|
||||||
|
credentialStatus?: (provider: string | undefined) => PiCredentialStatus;
|
||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +112,18 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P
|
|||||||
const persistSettings = deps.saveSettings ?? ((settings) => saveSettings(config, settings));
|
const persistSettings = deps.saveSettings ?? ((settings) => saveSettings(config, settings));
|
||||||
const readLogs = deps.readLogs ?? (() => diagnostics.join("\n"));
|
const readLogs = deps.readLogs ?? (() => diagnostics.join("\n"));
|
||||||
const smokeProvider = deps.smokeProvider ?? createPiProviderSmoke(config);
|
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<Omit<PiOptions, "checkedAt">> => {
|
const closedOptions = async (): Promise<Omit<PiOptions, "checkedAt">> => {
|
||||||
let listed: PiModel[];
|
let listed: PiModel[];
|
||||||
@@ -168,14 +188,15 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P
|
|||||||
async status(): Promise<PiStatus> {
|
async status(): Promise<PiStatus> {
|
||||||
const checkedAt = now().toISOString();
|
const checkedAt = now().toISOString();
|
||||||
const current = installationConfig();
|
const current = installationConfig();
|
||||||
|
const credentials = credentialStatus(current.provider);
|
||||||
try {
|
try {
|
||||||
const currentVersion = await version();
|
const currentVersion = await version();
|
||||||
addDiagnostic("Pi version probe succeeded");
|
addDiagnostic("Pi version probe succeeded");
|
||||||
return { version: currentVersion, ready: true, config: current, checkedAt };
|
return { version: currentVersion, ready: true, credentials, config: current, checkedAt };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = stableMessage(error, "Pi runtime is unavailable");
|
const message = stableMessage(error, "Pi runtime is unavailable");
|
||||||
addDiagnostic(message);
|
addDiagnostic(message);
|
||||||
return { ready: false, config: current, checkedAt, message };
|
return { ready: false, credentials, config: current, checkedAt, message };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export function canonicalPiProvider(provider: string | undefined): string | unde
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PiCredentialStatus = "present" | "missing";
|
||||||
|
|
||||||
export interface CredentialFsOps {
|
export interface CredentialFsOps {
|
||||||
lstat(path: string): Stats;
|
lstat(path: string): Stats;
|
||||||
open(path: string, flags: number): number;
|
open(path: string, flags: number): number;
|
||||||
@@ -172,3 +174,22 @@ export function buildPiChildEnv(opts: {
|
|||||||
}
|
}
|
||||||
return env;
|
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<string>;
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,12 +38,14 @@ test("status parses only a Pi version from a fixed execFile argument array", asy
|
|||||||
execute: successfulExec(calls),
|
execute: successfulExec(calls),
|
||||||
listModels: async () => supportedModels,
|
listModels: async () => supportedModels,
|
||||||
readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }),
|
readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }),
|
||||||
|
credentialStatus: () => "missing",
|
||||||
now: () => new Date("2026-08-05T10:00:00.000Z"),
|
now: () => new Date("2026-08-05T10:00:00.000Z"),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.status()).resolves.toEqual({
|
await expect(service.status()).resolves.toEqual({
|
||||||
version: "0.80.3",
|
version: "0.80.3",
|
||||||
ready: true,
|
ready: true,
|
||||||
|
credentials: "missing",
|
||||||
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
||||||
checkedAt: "2026-08-05T10:00:00.000Z",
|
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);
|
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<string | undefined> = [];
|
||||||
|
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
|
// Catches an options response that leaks provider metadata or lets callers choose model IDs that
|
||||||
// Pi did not explicitly enable for this installation.
|
// Pi did not explicitly enable for this installation.
|
||||||
test("options expose only closed provider, model, and reasoning choices", async () => {
|
test("options expose only closed provider, model, and reasoning choices", async () => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
PI_0803_CREDENTIAL_ENV_NAMES,
|
PI_0803_CREDENTIAL_ENV_NAMES,
|
||||||
buildPiChildEnv,
|
buildPiChildEnv,
|
||||||
canonicalPiProvider,
|
canonicalPiProvider,
|
||||||
|
piProviderCredentialStatus,
|
||||||
} from "../src/pi/provider-credentials.js";
|
} from "../src/pi/provider-credentials.js";
|
||||||
|
|
||||||
test("canonical provider aliases resolve to packaged Pi 0.80.3 IDs", () => {
|
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");
|
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", () => {
|
test("bundle value is injected without exposing bundle metadata to Pi", () => {
|
||||||
const env = buildPiChildEnv({
|
const env = buildPiChildEnv({
|
||||||
ambient: {
|
ambient: {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ function fakeService(): PiManagementService {
|
|||||||
return {
|
return {
|
||||||
status: vi.fn(async () => ({
|
status: vi.fn(async () => ({
|
||||||
version: "0.80.3", ready: true,
|
version: "0.80.3", ready: true,
|
||||||
|
credentials: "present",
|
||||||
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
||||||
checkedAt: "2026-08-05T10:00:00.000Z",
|
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.statusCode).toBe(200);
|
||||||
expect(response.json()).toEqual({
|
expect(response.json()).toEqual({
|
||||||
version: "0.80.3", ready: true,
|
version: "0.80.3", ready: true,
|
||||||
|
credentials: "present",
|
||||||
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
||||||
checkedAt: "2026-08-05T10:00:00.000Z",
|
checkedAt: "2026-08-05T10:00:00.000Z",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { apiFetch } from "./client";
|
|||||||
test("body-less POST omits content-type (avoids Fastify empty-body 400)", async () => {
|
test("body-less POST omits content-type (avoids Fastify empty-body 400)", async () => {
|
||||||
let contentType: string | null = "unset";
|
let contentType: string | null = "unset";
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/s1/resume", ({ request }) => {
|
http.post("/api/sessions/s1/resume", ({ request }) => {
|
||||||
contentType = request.headers.get("content-type");
|
contentType = request.headers.get("content-type");
|
||||||
return HttpResponse.json({ id: "s1" });
|
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 () => {
|
test("POST with a body sends application/json content-type", async () => {
|
||||||
let contentType: string | null = null;
|
let contentType: string | null = null;
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/s1/steer", ({ request }) => {
|
http.post("/api/sessions/s1/steer", ({ request }) => {
|
||||||
contentType = request.headers.get("content-type");
|
contentType = request.headers.get("content-type");
|
||||||
return new HttpResponse(null, { status: 204 });
|
return new HttpResponse(null, { status: 204 });
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ test("Pi management client calls only the sanctioned sanitized endpoints", async
|
|||||||
server.use(
|
server.use(
|
||||||
http.get("/api/pi-management/status", ({ request }) => {
|
http.get("/api/pi-management/status", ({ request }) => {
|
||||||
calls.push({ method: request.method, path: new URL(request.url).pathname });
|
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 }) => {
|
http.get("/api/pi-management/options", ({ request }) => {
|
||||||
calls.push({ method: request.method, path: new URL(request.url).pathname });
|
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(getPiManagementOptions()).resolves.toMatchObject({ providers: ["zai"] });
|
||||||
await expect(savePiManagementConfig({ provider: "zai", model: "glm-5.2", reasoning: "high" })).resolves.toMatchObject({ reasoning: "high" });
|
await expect(savePiManagementConfig({ provider: "zai", model: "glm-5.2", reasoning: "high" })).resolves.toMatchObject({ reasoning: "high" });
|
||||||
await expect(runPiManagementTest()).resolves.toMatchObject({ ready: true });
|
await expect(runPiManagementTest()).resolves.toMatchObject({ ready: true });
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface PiInstallationConfig {
|
|||||||
export interface PiManagementStatus {
|
export interface PiManagementStatus {
|
||||||
version?: string;
|
version?: string;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
|
credentials: "present" | "missing";
|
||||||
config: Partial<PiInstallationConfig>;
|
config: Partial<PiInstallationConfig>;
|
||||||
checkedAt: string;
|
checkedAt: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ test("createSession migrates legacy selections and POSTs browser preferences", a
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
let body: unknown = null;
|
let body: unknown = null;
|
||||||
server.use(
|
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",
|
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",
|
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();
|
body = await request.json();
|
||||||
return HttpResponse.json({ id: "s1" });
|
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) => {
|
test.each([202, 204])("prewarmRuntime accepts a body-less %s response", async (status) => {
|
||||||
let called = false;
|
let called = false;
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/runtime/prewarm", () => {
|
http.post("/api/runtime/prewarm", () => {
|
||||||
called = true;
|
called = true;
|
||||||
return new HttpResponse(null, { status });
|
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 () => {
|
test("listSessions defaults to the current user's scope", async () => {
|
||||||
let scope: string | null = null;
|
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");
|
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 }]);
|
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 () => {
|
test("listSessions requests the selected administrator scope", async () => {
|
||||||
let scope: string | null = null;
|
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");
|
scope = new URL(request.url).searchParams.get("scope");
|
||||||
return HttpResponse.json([]);
|
return HttpResponse.json([]);
|
||||||
}));
|
}));
|
||||||
@@ -64,7 +64,7 @@ test("listSessions requests the selected administrator scope", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("getMe fetches the typed authenticated principal", 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 }),
|
HttpResponse.json({ issuer: "portal", subject: "42", displayName: "Alice", isAdmin: true }),
|
||||||
));
|
));
|
||||||
await expect(getMe()).resolves.toEqual({
|
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 () => {
|
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 })));
|
HttpResponse.json({ id: "s1", alreadyActive: false })));
|
||||||
|
|
||||||
const result: { id: string; alreadyActive: boolean } = await resumeSession("s1");
|
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 () => {
|
test("renameSession POSTs {name}", async () => {
|
||||||
let body: unknown = null;
|
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();
|
body = await request.json();
|
||||||
return new HttpResponse(null, { status: 204 });
|
return new HttpResponse(null, { status: 204 });
|
||||||
}));
|
}));
|
||||||
@@ -92,7 +92,7 @@ test("renameSession POSTs {name}", async () => {
|
|||||||
|
|
||||||
test("setSessionGroup POSTs {group}", async () => {
|
test("setSessionGroup POSTs {group}", async () => {
|
||||||
let body: unknown = null;
|
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();
|
body = await request.json();
|
||||||
return new HttpResponse(null, { status: 204 });
|
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 () => {
|
test("archive / unarchive / delete hit the right verbs+paths", async () => {
|
||||||
const hits: string[] = [];
|
const hits: string[] = [];
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/s1/archive", () => { hits.push("archive"); return new HttpResponse(null, { status: 204 }); }),
|
http.post("/api/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.post("/api/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.delete("/api/sessions/s1", () => { hits.push("delete"); return new HttpResponse(null, { status: 204 }); }),
|
||||||
);
|
);
|
||||||
await archiveSession("s1");
|
await archiveSession("s1");
|
||||||
await unarchiveSession("s1");
|
await unarchiveSession("s1");
|
||||||
@@ -114,7 +114,7 @@ test("archive / unarchive / delete hit the right verbs+paths", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("getSessionDocuments GETs the array", 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" }]),
|
HttpResponse.json([{ phase: "—", key: "question", title: "t", format: "text", content: "q" }]),
|
||||||
));
|
));
|
||||||
const docs = await getSessionDocuments("s1");
|
const docs = await getSessionDocuments("s1");
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const workspace: CanonicalWorkspace = {
|
|||||||
|
|
||||||
test("uploads a workspace bundle without JSON content type", async () => {
|
test("uploads a workspace bundle without JSON content type", async () => {
|
||||||
let contentType: string | null = null;
|
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");
|
contentType = request.headers.get("content-type");
|
||||||
return HttpResponse.json({ draft: { workspace: {} } });
|
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 () => {
|
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"],
|
code: "workspace_conflict", message: "Workspace changed in the registry.", fields: ["dwh.password"],
|
||||||
base: { ...workspace, dwh: { ...workspace.dwh, password: "secret" } }, local: workspace, remote: workspace,
|
base: { ...workspace, dwh: { ...workspace.dwh, password: "secret" } }, local: workspace, remote: workspace,
|
||||||
}, { status: 409 })));
|
}, { status: 409 })));
|
||||||
@@ -73,7 +73,7 @@ const diagnosticsWorkspace: CanonicalWorkspace = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test.each(optionalDiagnosticsConflictFields)("accepts optional diagnostics conflict branch %s", async (field) => {
|
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],
|
code: "workspace_conflict", message: "Workspace changed in the registry.", fields: [field],
|
||||||
expected: { commit: "a".repeat(40), blob: "b".repeat(40) },
|
expected: { commit: "a".repeat(40), blob: "b".repeat(40) },
|
||||||
actual: { commit: "c".repeat(40), blob: "d".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" } },
|
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.",
|
code: "workspace_conflict", message: "Workspace changed in the registry.",
|
||||||
fields: [field],
|
fields: [field],
|
||||||
expected: { commit: "a".repeat(40), blob: "b".repeat(40) },
|
expected: { commit: "a".repeat(40), blob: "b".repeat(40) },
|
||||||
|
|||||||
@@ -18,19 +18,19 @@ beforeEach(() => {
|
|||||||
(globalThis as any).EventSource = FakeEventSource;
|
(globalThis as any).EventSource = FakeEventSource;
|
||||||
useSessionStore.getState().resetSession();
|
useSessionStore.getState().resetSession();
|
||||||
server.use(
|
server.use(
|
||||||
http.get("http://localhost:8787/me", () => HttpResponse.json({ issuer: "test", subject: "test", displayName: "Test", isAdmin: false })),
|
http.get("/api/me", () => HttpResponse.json({ issuer: "test", subject: "test", displayName: "Test", isAdmin: false })),
|
||||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([])),
|
http.get("/api/sessions", () => HttpResponse.json([])),
|
||||||
http.get("http://localhost:8787/settings", () =>
|
http.get("/api/settings", () =>
|
||||||
HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })),
|
HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })),
|
||||||
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([])),
|
http.get("/api/workspaces", () => HttpResponse.json([])),
|
||||||
http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [] })),
|
http.get("/api/models", () => HttpResponse.json({ models: [] })),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("New session starts prewarm without delaying composer focus", async () => {
|
test("New session starts prewarm without delaying composer focus", async () => {
|
||||||
let prewarmStarted = false;
|
let prewarmStarted = false;
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/runtime/prewarm", async () => {
|
http.post("/api/runtime/prewarm", async () => {
|
||||||
prewarmStarted = true;
|
prewarmStarted = true;
|
||||||
await delay(100);
|
await delay(100);
|
||||||
return new HttpResponse(null, { status: 202 });
|
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;
|
let releaseCreate!: () => void;
|
||||||
const createMayFinish = new Promise<void>((resolve) => { releaseCreate = resolve; });
|
const createMayFinish = new Promise<void>((resolve) => { releaseCreate = resolve; });
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions", async () => {
|
http.post("/api/sessions", async () => {
|
||||||
await createMayFinish;
|
await createMayFinish;
|
||||||
return HttpResponse.json({ id: "s-new" });
|
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 () => {
|
test("a failed create restores the landing view and preserves the question for retry", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions", () =>
|
http.post("/api/sessions", () =>
|
||||||
new HttpResponse("unavailable", { status: 503 })),
|
new HttpResponse("unavailable", { status: 503 })),
|
||||||
);
|
);
|
||||||
renderShell();
|
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 () => {
|
test("a DWH-unreachable precheck shows a specific alert and preserves the question", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions", () =>
|
http.post("/api/sessions", () =>
|
||||||
HttpResponse.json(
|
HttpResponse.json(
|
||||||
{
|
{
|
||||||
error: "Cannot start a session: the database is unreachable. Check the VPN connection and try again.",
|
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 () => {
|
test("model selector shows the three Pi-enabled models and stores the selected provider locally", async () => {
|
||||||
server.use(
|
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",
|
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: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
|
||||||
{ provider: "deepseek", id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", 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 },
|
{ 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 () => {
|
test("opens Workspace management from the right sidebar without interrupting the shell", async () => {
|
||||||
server.use(
|
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 })),
|
HttpResponse.json({ branch: "main", ahead: 0, behind: 0, degraded: false })),
|
||||||
);
|
);
|
||||||
renderShell();
|
renderShell();
|
||||||
|
|||||||
@@ -56,20 +56,20 @@ beforeEach(() => {
|
|||||||
window.matchMedia = vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() });
|
window.matchMedia = vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() });
|
||||||
useSessionStore.getState().resetSession();
|
useSessionStore.getState().resetSession();
|
||||||
server.use(
|
server.use(
|
||||||
http.get("http://localhost:8787/me", () =>
|
http.get("/api/me", () =>
|
||||||
HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false }),
|
HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false }),
|
||||||
),
|
),
|
||||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json(LIST)),
|
http.get("/api/sessions", () => HttpResponse.json(LIST)),
|
||||||
http.get("http://localhost:8787/sessions/:id/documents", () => HttpResponse.json([
|
http.get("/api/sessions/:id/documents", () => HttpResponse.json([
|
||||||
{ phase: "—", key: "question", title: "Domanda originale", format: "text", content: "Attiva uno" },
|
{ 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 () => {
|
test("regular users load only their sessions and never see administrator controls", async () => {
|
||||||
let scope: string | null = null;
|
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");
|
scope = new URL(request.url).searchParams.get("scope");
|
||||||
return HttpResponse.json(LIST);
|
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();
|
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();
|
const user = userEvent.setup();
|
||||||
server.use(
|
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({
|
http.get("/api/pi-management/status", () => HttpResponse.json({
|
||||||
version: "0.80.3", ready: true,
|
version: "0.80.3", ready: true,
|
||||||
|
credentials: "present",
|
||||||
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
||||||
checkedAt: "2026-08-05T10:00:00.000Z",
|
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" }],
|
providers: ["zai"], models: [{ provider: "zai", id: "glm-5.2" }],
|
||||||
reasoning: ["low", "medium", "high"], checkedAt: "2026-08-05T10:00:00.000Z",
|
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();
|
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" }));
|
await user.click(screen.getByRole("button", { name: "Pi management" }));
|
||||||
expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible();
|
expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible();
|
||||||
const shell = document.querySelector('[data-testid="app-shell"]');
|
const hiddenSummary = document.querySelector<HTMLElement>('aside[aria-label="Session summary"]');
|
||||||
expect(shell).toHaveTextContent("Workspace management");
|
expect(hiddenSummary).toHaveAttribute("aria-hidden", "true");
|
||||||
expect(shell).toHaveTextContent("Active sessions");
|
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 () => {
|
test("administrators can explicitly switch to all sessions and see owners", async () => {
|
||||||
let scope = "";
|
let scope = "";
|
||||||
server.use(
|
server.use(
|
||||||
http.get("http://localhost:8787/me", () =>
|
http.get("/api/me", () =>
|
||||||
HttpResponse.json({ issuer: "portal", subject: "alice-id", displayName: "Alice", isAdmin: true }),
|
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") ?? "";
|
scope = new URL(request.url).searchParams.get("scope") ?? "";
|
||||||
return HttpResponse.json([
|
return HttpResponse.json([
|
||||||
{ ...LIST[0], author: "Alice" },
|
{ ...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 () => {
|
test("administrator confirms before deleting a same-named user's session", async () => {
|
||||||
let deletes = 0;
|
let deletes = 0;
|
||||||
server.use(
|
server.use(
|
||||||
http.get("http://localhost:8787/me", () =>
|
http.get("/api/me", () =>
|
||||||
HttpResponse.json({ issuer: "portal", subject: "alice-id", displayName: "Alice", isAdmin: true }),
|
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[0], author: "Alice" },
|
||||||
{ ...LIST[1], id: "s3", question: "Second session", archived: false, author: "Bob" },
|
{ ...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;
|
deletes += 1;
|
||||||
return new HttpResponse(null, { status: 204 });
|
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;
|
let archives = 0;
|
||||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
|
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||||
server.use(
|
server.use(
|
||||||
http.get("http://localhost:8787/me", () =>
|
http.get("/api/me", () =>
|
||||||
HttpResponse.json({ issuer: "portal", subject: "alice-id", displayName: "Alice", isAdmin: true }),
|
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[0], author: "Alice" },
|
||||||
])),
|
])),
|
||||||
http.post("http://localhost:8787/sessions/:id/archive", () => {
|
http.post("/api/sessions/:id/archive", () => {
|
||||||
archives += 1;
|
archives += 1;
|
||||||
return new HttpResponse(null, { status: 204 });
|
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 () => {
|
test("ungrouped sessions render after groups with no 'No group' label", async () => {
|
||||||
server.use(
|
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: "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 },
|
{ 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 () => {
|
test("clicking a session with a live runtime reconnects to its gate instead of the panel", async () => {
|
||||||
let resumed: string | null = null;
|
let resumed: string | null = null;
|
||||||
server.use(
|
server.use(
|
||||||
http.get("http://localhost:8787/sessions", () =>
|
http.get("/api/sessions", () =>
|
||||||
HttpResponse.json([{ ...LIST[0], active: true }])),
|
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;
|
resumed = params.id as string;
|
||||||
return resumeResult(resumed, true); // warm runtime → alreadyActive
|
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 })),
|
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
||||||
);
|
);
|
||||||
wrap();
|
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 () => {
|
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 })));
|
HttpResponse.json({ status: "warming" }, { status: 202 })));
|
||||||
wrap();
|
wrap();
|
||||||
await userEvent.click(await screen.findByText("Attiva uno")); // cold session → panel opens
|
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" }],
|
activityLog: [{ kind: "status", phase: "F7", text: "Stale prior activity", level: "info" }],
|
||||||
});
|
});
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => {
|
http.post("/api/sessions/:id/resume", ({ params }) => {
|
||||||
resumed = params.id as string;
|
resumed = params.id as string;
|
||||||
return resumeResult(resumed);
|
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 () => {
|
test("Resume paints the re-entry phase from the manifest after the cold Resume succeeds", async () => {
|
||||||
useSessionStore.getState().resetSession();
|
useSessionStore.getState().resetSession();
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")),
|
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: 4 })),
|
HttpResponse.json({ id: "s1", status: "open", phase: 4 })),
|
||||||
);
|
);
|
||||||
wrap();
|
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 () => {
|
test("an already-active same-session Resume preserves its EventSource and store", async () => {
|
||||||
let resumeCalls = 0;
|
let resumeCalls = 0;
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", () => {
|
http.post("/api/sessions/:id/resume", () => {
|
||||||
resumeCalls += 1;
|
resumeCalls += 1;
|
||||||
return resumeResult("s1", 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 })),
|
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
||||||
);
|
);
|
||||||
wrap();
|
wrap();
|
||||||
@@ -379,14 +391,14 @@ test("concurrent same-id Resume invocations share one cold request and replaceme
|
|||||||
const coldGate = deferred();
|
const coldGate = deferred();
|
||||||
const coldStarted = deferred();
|
const coldStarted = deferred();
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
http.post("/api/sessions/:id/resume", async () => {
|
||||||
resumeCalls += 1;
|
resumeCalls += 1;
|
||||||
if (resumeCalls === 1) return resumeResult("s1", false);
|
if (resumeCalls === 1) return resumeResult("s1", false);
|
||||||
if (resumeCalls === 2) coldStarted.resolve();
|
if (resumeCalls === 2) coldStarted.resolve();
|
||||||
await coldGate.promise;
|
await coldGate.promise;
|
||||||
return resumeResult("s1", false);
|
return resumeResult("s1", false);
|
||||||
}),
|
}),
|
||||||
http.get("http://localhost:8787/sessions/:id", () =>
|
http.get("/api/sessions/:id", () =>
|
||||||
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
||||||
);
|
);
|
||||||
wrap();
|
wrap();
|
||||||
@@ -408,7 +420,7 @@ test("concurrent same-id Resume invocations share one cold request and replaceme
|
|||||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
|
||||||
const replacement = FakeEventSource.instances[1];
|
const replacement = FakeEventSource.instances[1];
|
||||||
expect(oldSource.closed).toBe(true);
|
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([
|
expect(useSessionStore.getState().activityLog).toEqual([
|
||||||
{ kind: "lifecycle", phase: null, text: "Resuming session" },
|
{ 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 firstS1ManifestGate = deferred();
|
||||||
const firstS1ManifestStarted = deferred();
|
const firstS1ManifestStarted = deferred();
|
||||||
server.use(
|
server.use(
|
||||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
|
http.get("/api/sessions", () => HttpResponse.json([LIST[0], other])),
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => {
|
http.post("/api/sessions/:id/resume", ({ params }) => {
|
||||||
const id = params.id as string;
|
const id = params.id as string;
|
||||||
if (id === "s1") s1ResumeCalls += 1;
|
if (id === "s1") s1ResumeCalls += 1;
|
||||||
return resumeResult(id);
|
return resumeResult(id);
|
||||||
}),
|
}),
|
||||||
http.get("http://localhost:8787/sessions/:id", async ({ params }) => {
|
http.get("/api/sessions/:id", async ({ params }) => {
|
||||||
if (params.id === "s1") {
|
if (params.id === "s1") {
|
||||||
s1ManifestCalls += 1;
|
s1ManifestCalls += 1;
|
||||||
if (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 () => {
|
test("cold same-session Resume keeps the old stream until success then receives post-clear events once", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", () =>
|
http.post("/api/sessions/:id/resume", () =>
|
||||||
resumeResult("s1")),
|
resumeResult("s1")),
|
||||||
http.get("http://localhost:8787/sessions/:id", () =>
|
http.get("/api/sessions/:id", () =>
|
||||||
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
||||||
);
|
);
|
||||||
wrap();
|
wrap();
|
||||||
@@ -492,7 +504,7 @@ test("cold same-session Resume keeps the old stream until success then receives
|
|||||||
let markStarted!: () => void;
|
let markStarted!: () => void;
|
||||||
const resumeStarted = new Promise<void>((resolve) => { markStarted = resolve; });
|
const resumeStarted = new Promise<void>((resolve) => { markStarted = resolve; });
|
||||||
const resumeReleased = new Promise<void>((resolve) => { releaseResume = resolve; });
|
const resumeReleased = new Promise<void>((resolve) => { releaseResume = resolve; });
|
||||||
server.use(http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
server.use(http.post("/api/sessions/:id/resume", async () => {
|
||||||
markStarted();
|
markStarted();
|
||||||
await resumeReleased;
|
await resumeReleased;
|
||||||
return resumeResult("s1");
|
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));
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
|
||||||
expect(first.closed).toBe(true);
|
expect(first.closed).toBe(true);
|
||||||
const replacement = FakeEventSource.instances[1];
|
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().transcript).toEqual([]);
|
||||||
expect(useSessionStore.getState().activityLog).toEqual([
|
expect(useSessionStore.getState().activityLog).toEqual([
|
||||||
{ kind: "lifecycle", phase: null, text: "Resuming session" },
|
{ 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 () => {
|
test("a failed same-session Resume preserves its source, activity, and document panel", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", () =>
|
http.post("/api/sessions/:id/resume", () =>
|
||||||
resumeResult("s1")),
|
resumeResult("s1")),
|
||||||
http.get("http://localhost:8787/sessions/:id", () =>
|
http.get("/api/sessions/:id", () =>
|
||||||
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
||||||
);
|
);
|
||||||
wrap();
|
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"));
|
act(() => first.emitNamed("info", { type: "info", text: "Keep me" }, "4"));
|
||||||
const before = useSessionStore.getState().activityLog.map((entry) => ({ ...entry }));
|
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 })));
|
new HttpResponse(null, { status: 409 })));
|
||||||
await userEvent.click(screen.getByTestId("session-item-s1"));
|
await userEvent.click(screen.getByTestId("session-item-s1"));
|
||||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
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",
|
created_at: "2026-01-03T00:00:00Z",
|
||||||
};
|
};
|
||||||
server.use(
|
server.use(
|
||||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
|
http.get("/api/sessions", () => HttpResponse.json([LIST[0], other])),
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) =>
|
http.post("/api/sessions/:id/resume", ({ params }) =>
|
||||||
resumeResult(params.id as string, params.id === "s3")),
|
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 })),
|
HttpResponse.json({ id: params.id, status: "open", phase: params.id === "s3" ? 3 : 1 })),
|
||||||
);
|
);
|
||||||
wrap();
|
wrap();
|
||||||
@@ -617,8 +629,8 @@ test("competing Resume requests for different ids commit only the latest intent"
|
|||||||
const s1Started = deferred();
|
const s1Started = deferred();
|
||||||
const s3Started = deferred();
|
const s3Started = deferred();
|
||||||
server.use(
|
server.use(
|
||||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
|
http.get("/api/sessions", () => HttpResponse.json([LIST[0], other])),
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", async ({ params }) => {
|
http.post("/api/sessions/:id/resume", async ({ params }) => {
|
||||||
const id = params.id as string;
|
const id = params.id as string;
|
||||||
if (id === "s1") {
|
if (id === "s1") {
|
||||||
s1Started.resolve();
|
s1Started.resolve();
|
||||||
@@ -629,7 +641,7 @@ test("competing Resume requests for different ids commit only the latest intent"
|
|||||||
}
|
}
|
||||||
return resumeResult(id);
|
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 })),
|
HttpResponse.json({ id: params.id, status: "open", phase: params.id === "s3" ? 3 : 1 })),
|
||||||
);
|
);
|
||||||
wrap();
|
wrap();
|
||||||
@@ -667,10 +679,10 @@ test("a stale Resume manifest cannot repaint the latest session phase", async ()
|
|||||||
const s1ManifestGate = deferred();
|
const s1ManifestGate = deferred();
|
||||||
const s1ManifestStarted = deferred();
|
const s1ManifestStarted = deferred();
|
||||||
server.use(
|
server.use(
|
||||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
|
http.get("/api/sessions", () => HttpResponse.json([LIST[0], other])),
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) =>
|
http.post("/api/sessions/:id/resume", ({ params }) =>
|
||||||
resumeResult(params.id as string)),
|
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") {
|
if (params.id === "s1") {
|
||||||
s1ManifestStarted.resolve();
|
s1ManifestStarted.resolve();
|
||||||
await s1ManifestGate.promise;
|
await s1ManifestGate.promise;
|
||||||
@@ -700,12 +712,12 @@ test("starting a new question invalidates a pending Resume intent", async () =>
|
|||||||
const resumeGate = deferred();
|
const resumeGate = deferred();
|
||||||
const resumeStarted = deferred();
|
const resumeStarted = deferred();
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
http.post("/api/sessions/:id/resume", async () => {
|
||||||
resumeStarted.resolve();
|
resumeStarted.resolve();
|
||||||
await resumeGate.promise;
|
await resumeGate.promise;
|
||||||
return resumeResult("s1");
|
return resumeResult("s1");
|
||||||
}),
|
}),
|
||||||
http.post("http://localhost:8787/runtime/prewarm", () =>
|
http.post("/api/runtime/prewarm", () =>
|
||||||
HttpResponse.json({ status: "warming" }, { status: 202 })),
|
HttpResponse.json({ status: "warming" }, { status: 202 })),
|
||||||
);
|
);
|
||||||
wrap();
|
wrap();
|
||||||
@@ -728,12 +740,12 @@ test("a successful Delete invalidates an earlier pending Resume for the same tar
|
|||||||
const resumeStarted = deferred();
|
const resumeStarted = deferred();
|
||||||
const deleteCompleted = deferred();
|
const deleteCompleted = deferred();
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
http.post("/api/sessions/:id/resume", async () => {
|
||||||
resumeStarted.resolve();
|
resumeStarted.resolve();
|
||||||
await resumeGate.promise;
|
await resumeGate.promise;
|
||||||
return resumeResult("s1");
|
return resumeResult("s1");
|
||||||
}),
|
}),
|
||||||
http.delete("http://localhost:8787/sessions/:id", ({ params }) => {
|
http.delete("/api/sessions/:id", ({ params }) => {
|
||||||
expect(params.id).toBe("s1");
|
expect(params.id).toBe("s1");
|
||||||
deleteCompleted.resolve();
|
deleteCompleted.resolve();
|
||||||
return new HttpResponse(null, { status: 204 });
|
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 deleteGate = deferred();
|
||||||
const deleteStarted = deferred();
|
const deleteStarted = deferred();
|
||||||
server.use(
|
server.use(
|
||||||
http.delete("http://localhost:8787/sessions/:id", async ({ params }) => {
|
http.delete("/api/sessions/:id", async ({ params }) => {
|
||||||
expect(params.id).toBe("s1");
|
expect(params.id).toBe("s1");
|
||||||
deleteStarted.resolve();
|
deleteStarted.resolve();
|
||||||
await deleteGate.promise;
|
await deleteGate.promise;
|
||||||
return new HttpResponse(null, { status: 204 });
|
return new HttpResponse(null, { status: 204 });
|
||||||
}),
|
}),
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")),
|
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 })),
|
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
||||||
);
|
);
|
||||||
wrap();
|
wrap();
|
||||||
@@ -794,12 +806,12 @@ test("deleting another session does not invalidate a pending Resume", async () =
|
|||||||
const resumeStarted = deferred();
|
const resumeStarted = deferred();
|
||||||
const deleteCompleted = deferred();
|
const deleteCompleted = deferred();
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
http.post("/api/sessions/:id/resume", async () => {
|
||||||
resumeStarted.resolve();
|
resumeStarted.resolve();
|
||||||
await resumeGate.promise;
|
await resumeGate.promise;
|
||||||
return resumeResult("s1");
|
return resumeResult("s1");
|
||||||
}),
|
}),
|
||||||
http.delete("http://localhost:8787/sessions/:id", ({ params }) => {
|
http.delete("/api/sessions/:id", ({ params }) => {
|
||||||
expect(params.id).toBe("s2");
|
expect(params.id).toBe("s2");
|
||||||
deleteCompleted.resolve();
|
deleteCompleted.resolve();
|
||||||
return new HttpResponse(null, { status: 204 });
|
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 s3ResumeGate = deferred();
|
||||||
const s3ResumeStarted = deferred();
|
const s3ResumeStarted = deferred();
|
||||||
server.use(
|
server.use(
|
||||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
|
http.get("/api/sessions", () => HttpResponse.json([LIST[0], other])),
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", async ({ params }) => {
|
http.post("/api/sessions/:id/resume", async ({ params }) => {
|
||||||
const id = params.id as string;
|
const id = params.id as string;
|
||||||
if (id === "s3") {
|
if (id === "s3") {
|
||||||
s3ResumeStarted.resolve();
|
s3ResumeStarted.resolve();
|
||||||
@@ -840,9 +852,9 @@ test("deleting active A preserves a pending Resume for different session B", asy
|
|||||||
}
|
}
|
||||||
return resumeResult(id);
|
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 })),
|
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");
|
expect(params.id).toBe("s1");
|
||||||
return new HttpResponse(null, { status: 204 });
|
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 resumeStarted = deferred();
|
||||||
const deleteFailed = deferred();
|
const deleteFailed = deferred();
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
http.post("/api/sessions/:id/resume", async () => {
|
||||||
resumeStarted.resolve();
|
resumeStarted.resolve();
|
||||||
await resumeGate.promise;
|
await resumeGate.promise;
|
||||||
return resumeResult("s1");
|
return resumeResult("s1");
|
||||||
}),
|
}),
|
||||||
http.delete("http://localhost:8787/sessions/:id", ({ params }) => {
|
http.delete("/api/sessions/:id", ({ params }) => {
|
||||||
expect(params.id).toBe("s1");
|
expect(params.id).toBe("s1");
|
||||||
deleteFailed.resolve();
|
deleteFailed.resolve();
|
||||||
return new HttpResponse(null, { status: 500 });
|
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 () => {
|
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({
|
useSessionStore.setState({
|
||||||
activityLog: [{ kind: "status", phase: "F7", text: "Preserve activity", level: "info" }],
|
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 () => {
|
test("closing and reopening Model activity preserves the complete activity log", async () => {
|
||||||
wrap();
|
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.findByText("Attiva uno"));
|
||||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -971,8 +983,8 @@ test("session finalization shows the completion banner and returns to landing",
|
|||||||
useSessionStore.getState().resetSession();
|
useSessionStore.getState().resetSession();
|
||||||
let finalized = false;
|
let finalized = false;
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")),
|
http.post("/api/sessions/:id/resume", () => resumeResult("s1")),
|
||||||
http.get("http://localhost:8787/sessions", () =>
|
http.get("/api/sessions", () =>
|
||||||
HttpResponse.json(finalized ? [{ ...LIST[0], status: "finalized" }] : LIST)),
|
HttpResponse.json(finalized ? [{ ...LIST[0], status: "finalized" }] : LIST)),
|
||||||
);
|
);
|
||||||
wrap();
|
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 () => {
|
test("renaming a group reassigns its members via setSessionGroup", async () => {
|
||||||
const groupSets: Array<{ id: string; group: string }> = [];
|
const groupSets: Array<{ id: string; group: string }> = [];
|
||||||
server.use(
|
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 };
|
const body = (await request.json()) as { group: string };
|
||||||
groupSets.push({ id: params.id as string, group: body.group });
|
groupSets.push({ id: params.id as string, group: body.group });
|
||||||
return new HttpResponse(null, { status: 204 });
|
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 () => {
|
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();
|
wrap();
|
||||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
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 () => {
|
test("resizes the activity split with keyboard and exposes responsive drawer classes", async () => {
|
||||||
localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "448");
|
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();
|
wrap();
|
||||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
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 () => {
|
test("uses the measured app shell for the desktop activity split", async () => {
|
||||||
localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "576");
|
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();
|
wrap();
|
||||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
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 () => {
|
test("cancels an active resize when the measured shell becomes too narrow", async () => {
|
||||||
localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "576");
|
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();
|
wrap();
|
||||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||||
|
|||||||
@@ -31,13 +31,13 @@ test("submitting includes browser-local migrated preferences and calls onCreated
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
let body: unknown = null;
|
let body: unknown = null;
|
||||||
server.use(
|
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",
|
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",
|
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();
|
body = await request.json();
|
||||||
return HttpResponse.json({ id: "s1" });
|
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",
|
workspaceId: "psd-clinical", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
|
||||||
}));
|
}));
|
||||||
server.use(
|
server.use(
|
||||||
http.get("http://localhost:8787/workspaces", () => {
|
http.get("/api/workspaces", () => {
|
||||||
summaryRequestStarted = true;
|
summaryRequestStarted = true;
|
||||||
return HttpResponse.json([{
|
return HttpResponse.json([{
|
||||||
id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical",
|
id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical",
|
||||||
revision,
|
revision,
|
||||||
}]);
|
}]);
|
||||||
}),
|
}),
|
||||||
http.get("http://localhost:8787/workspaces/psd-clinical", async () => {
|
http.get("/api/workspaces/psd-clinical", async () => {
|
||||||
policyRequestStarted = true;
|
policyRequestStarted = true;
|
||||||
await policyMayFinish;
|
await policyMayFinish;
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
workspace: { llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] } }, revision,
|
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();
|
body = await request.json();
|
||||||
return HttpResponse.json({ id: "s1" });
|
return HttpResponse.json({ id: "s1" });
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { PiManagement } from "./PiManagement";
|
|||||||
const readyStatus = {
|
const readyStatus = {
|
||||||
version: "0.80.3",
|
version: "0.80.3",
|
||||||
ready: true,
|
ready: true,
|
||||||
|
credentials: "present",
|
||||||
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
||||||
checkedAt: "2026-08-05T10:00:00.000Z",
|
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.getByRole("status", { name: "Pi readiness" })).toHaveTextContent("Ready");
|
||||||
expect(screen.getByText("Pi 0.80.3")).toBeVisible();
|
expect(screen.getByText("Pi 0.80.3")).toBeVisible();
|
||||||
expect(screen.getByText("Defaults ready")).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 () => {
|
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");
|
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();
|
const user = userEvent.setup();
|
||||||
let tests = 0;
|
let tests = 0;
|
||||||
server.use(http.post("/api/pi-management/test", () => {
|
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();
|
renderManagement();
|
||||||
|
|
||||||
await user.click(await screen.findByRole("button", { name: "Run smoke test" }));
|
const testButton = await screen.findByRole("button", { name: "Test saved defaults" });
|
||||||
expect(await screen.findByText("Credentials present")).toBeVisible();
|
expect(screen.getByText("Defaults ready")).toBeVisible();
|
||||||
await user.click(screen.getByRole("button", { name: "Run smoke test" }));
|
expect(screen.queryByText("Changes are not saved yet.")).not.toBeInTheDocument();
|
||||||
expect(await screen.findByText("Credentials missing")).toBeVisible();
|
expect(testButton).toBeEnabled();
|
||||||
expect(screen.getByRole("status", { name: "Pi management feedback" })).toHaveTextContent("Pi provider smoke check failed");
|
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 () => {
|
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(await screen.findByRole("alert", { name: "Pi management unavailable" })).toHaveTextContent("Pi management is not permitted");
|
||||||
expect(screen.queryByLabelText("Provider")).not.toBeInTheDocument();
|
expect(screen.queryByLabelText("Provider")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Save defaults" })).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 () => {
|
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"));
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
savePiManagementConfig,
|
savePiManagementConfig,
|
||||||
type PiInstallationConfig,
|
type PiInstallationConfig,
|
||||||
type PiManagementOptions,
|
type PiManagementOptions,
|
||||||
|
type PiManagementStatus,
|
||||||
} from "../api/pi-management";
|
} from "../api/pi-management";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog";
|
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";
|
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 Feedback = { tone: "success" | "error"; message: string } | undefined;
|
||||||
type CredentialState = "present" | "missing" | undefined;
|
type SmokeState = "passed" | "failed" | undefined;
|
||||||
|
|
||||||
function configFrom(status: { config: Partial<PiInstallationConfig> }, options: PiManagementOptions): PiInstallationConfig | undefined {
|
function suggestedConfig(status: { config: Partial<PiInstallationConfig> }, options: PiManagementOptions): PiInstallationConfig | undefined {
|
||||||
const provider = status.config.provider && options.providers.includes(status.config.provider)
|
const provider = status.config.provider && options.providers.includes(status.config.provider)
|
||||||
? status.config.provider
|
? status.config.provider
|
||||||
: options.providers[0];
|
: options.providers[0];
|
||||||
@@ -33,27 +34,55 @@ function configFrom(status: { config: Partial<PiInstallationConfig> }, options:
|
|||||||
return provider && model && reasoning ? { provider, model, reasoning } : undefined;
|
return provider && model && reasoning ? { provider, model, reasoning } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSupportedConfig(
|
||||||
|
config: Partial<PiInstallationConfig> | 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<PiInstallationConfig> | 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 {
|
function errorMessage(error: unknown, fallback: string): string {
|
||||||
return asPiManagementApiError(error)?.message ?? fallback;
|
return asPiManagementApiError(error)?.message ?? fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReadinessRail({ ready, configured, credentialState }: {
|
function ReadinessRail({ ready, configured, credentials, smokeState }: {
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
credentialState: CredentialState;
|
credentials: "present" | "missing";
|
||||||
|
smokeState: SmokeState;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
data-testid="pi-readiness-rail"
|
data-testid="pi-readiness-rail"
|
||||||
aria-label="Pi readiness sequence"
|
aria-label="Pi readiness sequence"
|
||||||
className="grid gap-3 border-l-2 border-border pl-4 sm:grid-cols-3"
|
className="grid gap-3 border-l-2 border-border pl-4 sm:grid-cols-4"
|
||||||
>
|
>
|
||||||
<RailItem label="Runtime" value={ready ? "Runtime ready" : "Runtime needs attention"} state={ready ? "ready" : "attention"} />
|
<RailItem label="Runtime" value={ready ? "Runtime ready" : "Runtime needs attention"} state={ready ? "ready" : "attention"} />
|
||||||
<RailItem label="Defaults" value={configured ? "Defaults ready" : "Defaults incomplete"} state={configured ? "ready" : "attention"} />
|
<RailItem label="Defaults" value={configured ? "Defaults ready" : "Defaults incomplete"} state={configured ? "ready" : "attention"} />
|
||||||
<RailItem
|
<RailItem
|
||||||
label="Provider smoke"
|
label="Credentials"
|
||||||
value={credentialState === "present" ? "Credentials present" : credentialState === "missing" ? "Credentials missing" : "Run smoke test"}
|
value={`Credentials ${credentials}`}
|
||||||
state={credentialState === "missing" ? "attention" : credentialState === "present" ? "ready" : "idle"}
|
state={credentials === "present" ? "ready" : "attention"}
|
||||||
|
/>
|
||||||
|
<RailItem
|
||||||
|
label="Saved-config test"
|
||||||
|
value={smokeState === "passed" ? "Saved configuration test passed" : smokeState === "failed" ? "Saved configuration test failed" : "Saved configuration test not run"}
|
||||||
|
state={smokeState === "passed" ? "ready" : smokeState === "failed" ? "attention" : "idle"}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -78,7 +107,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [draft, setDraft] = useState<PiInstallationConfig>();
|
const [draft, setDraft] = useState<PiInstallationConfig>();
|
||||||
const [feedback, setFeedback] = useState<Feedback>();
|
const [feedback, setFeedback] = useState<Feedback>();
|
||||||
const [credentialState, setCredentialState] = useState<CredentialState>();
|
const [smokeState, setSmokeState] = useState<SmokeState>();
|
||||||
const [logsRequested, setLogsRequested] = useState(false);
|
const [logsRequested, setLogsRequested] = useState(false);
|
||||||
const statusQuery = useQuery({ queryKey: ["pi-management", "status"], queryFn: getPiManagementStatus, enabled: open });
|
const statusQuery = useQuery({ queryKey: ["pi-management", "status"], queryFn: getPiManagementStatus, enabled: open });
|
||||||
const optionsQuery = useQuery({ queryKey: ["pi-management", "options"], queryFn: getPiManagementOptions, 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) ?? [],
|
() => optionsQuery.data?.models.filter((model) => model.provider === draft?.provider) ?? [],
|
||||||
[draft?.provider, optionsQuery.data?.models],
|
[draft?.provider, optionsQuery.data?.models],
|
||||||
);
|
);
|
||||||
const configured = Boolean(draft?.provider && draft.model && draft.reasoning);
|
const initialDraft = useMemo(
|
||||||
const valid = Boolean(
|
() => statusQuery.data && optionsQuery.data
|
||||||
draft
|
? suggestedConfig(statusQuery.data, optionsQuery.data)
|
||||||
&& optionsQuery.data?.providers.includes(draft.provider)
|
: undefined,
|
||||||
&& optionsQuery.data.models.some((model) => model.provider === draft.provider && model.id === draft.model)
|
[optionsQuery.data, statusQuery.data],
|
||||||
&& optionsQuery.data.reasoning.includes(draft.reasoning),
|
|
||||||
);
|
);
|
||||||
|
const persistedReady = isSupportedConfig(statusQuery.data?.config, optionsQuery.data);
|
||||||
|
const validDraft = isSupportedConfig(draft, optionsQuery.data);
|
||||||
|
const dirty = Boolean(draft && !sameConfig(statusQuery.data?.config, draft));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDraft(undefined);
|
setDraft(undefined);
|
||||||
setFeedback(undefined);
|
setFeedback(undefined);
|
||||||
setCredentialState(undefined);
|
setSmokeState(undefined);
|
||||||
setLogsRequested(false);
|
setLogsRequested(false);
|
||||||
queryClient.removeQueries({ queryKey: ["pi-management"] });
|
queryClient.removeQueries({ queryKey: ["pi-management"] });
|
||||||
}
|
}
|
||||||
}, [open, queryClient]);
|
}, [open, queryClient]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (draft || !statusQuery.data || !optionsQuery.data) return;
|
if (draft || !initialDraft) return;
|
||||||
setDraft(configFrom(statusQuery.data, optionsQuery.data));
|
setDraft(initialDraft);
|
||||||
}, [draft, optionsQuery.data, statusQuery.data]);
|
}, [draft, initialDraft]);
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: savePiManagementConfig,
|
mutationFn: savePiManagementConfig,
|
||||||
onSuccess: () => {
|
onSuccess: (saved) => {
|
||||||
|
const config = { provider: saved.provider, model: saved.model, reasoning: saved.reasoning };
|
||||||
|
queryClient.setQueryData<PiManagementStatus>(["pi-management", "status"], (current) => (
|
||||||
|
current ? { ...current, config } : current
|
||||||
|
));
|
||||||
|
setDraft(config);
|
||||||
|
setSmokeState(undefined);
|
||||||
setFeedback({ tone: "success", message: "Defaults saved." });
|
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.") }),
|
onError: (error) => setFeedback({ tone: "error", message: errorMessage(error, "Could not save Pi defaults.") }),
|
||||||
});
|
});
|
||||||
const smokeMutation = useMutation({
|
const smokeMutation = useMutation({
|
||||||
mutationFn: runPiManagementTest,
|
mutationFn: runPiManagementTest,
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setCredentialState(result.ready ? "present" : "missing");
|
setSmokeState(result.ready ? "passed" : "failed");
|
||||||
setFeedback({ tone: result.ready ? "success" : "error", message: result.message ?? (result.ready ? "Pi smoke test passed." : "Pi smoke test 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() {
|
async function copyUpdateCommand() {
|
||||||
@@ -138,15 +182,29 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
function saveDefaults() {
|
function saveDefaults() {
|
||||||
if (!draft || !valid) {
|
if (!draft || !validDraft) {
|
||||||
setFeedback({ tone: "error", message: "Choose a supported provider, model, and reasoning level." });
|
setFeedback({ tone: "error", message: "Choose a supported provider, model, and reasoning level." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
saveMutation.mutate(draft);
|
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 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;
|
const unavailable = statusQuery.isError || optionsQuery.isError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -155,7 +213,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
|||||||
<DialogHeader className="border-b border-border/70 px-5 pb-4 pt-5 pr-12">
|
<DialogHeader className="border-b border-border/70 px-5 pb-4 pt-5 pr-12">
|
||||||
<p className="thot-label">Installation controls</p>
|
<p className="thot-label">Installation controls</p>
|
||||||
<DialogTitle>Pi management</DialogTitle>
|
<DialogTitle>Pi management</DialogTitle>
|
||||||
<DialogDescription>Review the bundled runtime, set safe defaults, and run a sanitized provider check.</DialogDescription>
|
<DialogDescription>Review the bundled runtime, set safe defaults, and test the saved provider configuration.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Button aria-label="Close Pi management" className="absolute right-2 top-2" size="icon-sm" variant="ghost" onClick={onClose}><X /></Button>
|
<Button aria-label="Close Pi management" className="absolute right-2 top-2" size="icon-sm" variant="ghost" onClick={onClose}><X /></Button>
|
||||||
<div className="max-h-[calc(100vh-11rem)] overflow-y-auto px-5 py-5">
|
<div className="max-h-[calc(100vh-11rem)] overflow-y-auto px-5 py-5">
|
||||||
@@ -169,7 +227,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
|||||||
<p className="font-semibold">{errorMessage(statusQuery.error ?? optionsQuery.error, "Pi management is unavailable")}</p>
|
<p className="font-semibold">{errorMessage(statusQuery.error ?? optionsQuery.error, "Pi management is unavailable")}</p>
|
||||||
<Button className="mt-3" size="sm" variant="outline" onClick={() => { void statusQuery.refetch(); void optionsQuery.refetch(); }}>Retry</Button>
|
<Button className="mt-3" size="sm" variant="outline" onClick={() => { void statusQuery.refetch(); void optionsQuery.refetch(); }}>Retry</Button>
|
||||||
</div>
|
</div>
|
||||||
) : loading ? <p className="text-sm text-muted-foreground">Loading Pi management…</p> : statusQuery.data && optionsQuery.data && draft && (
|
) : loading ? <p className="text-sm text-muted-foreground">Loading Pi management…</p> : statusQuery.data && optionsQuery.data && (
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -181,7 +239,12 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ReadinessRail ready={statusQuery.data.ready} configured={configured} credentialState={credentialState} />
|
<ReadinessRail
|
||||||
|
ready={statusQuery.data.ready}
|
||||||
|
configured={persistedReady}
|
||||||
|
credentials={statusQuery.data.credentials ?? "missing"}
|
||||||
|
smokeState={smokeState}
|
||||||
|
/>
|
||||||
|
|
||||||
{statusQuery.data.message && <p role="status" aria-live="polite" className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-200">{statusQuery.data.message}</p>}
|
{statusQuery.data.message && <p role="status" aria-live="polite" className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-200">{statusQuery.data.message}</p>}
|
||||||
{feedback && <p role="status" aria-label="Pi management feedback" aria-live="polite" className={`flex items-start gap-2 rounded-md border px-3 py-2 text-sm ${feedback.tone === "error" ? "border-destructive/30 bg-destructive/5" : "border-border bg-muted/50"}`}>
|
{feedback && <p role="status" aria-label="Pi management feedback" aria-live="polite" className={`flex items-start gap-2 rounded-md border px-3 py-2 text-sm ${feedback.tone === "error" ? "border-destructive/30 bg-destructive/5" : "border-border bg-muted/50"}`}>
|
||||||
@@ -193,30 +256,55 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
|||||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
<div><h3 className="font-heading text-sm font-semibold">Installation defaults</h3><p className="mt-1 text-sm text-muted-foreground">These choices apply to new Pi work. Credentials remain outside this browser.</p></div>
|
<div><h3 className="font-heading text-sm font-semibold">Installation defaults</h3><p className="mt-1 text-sm text-muted-foreground">These choices apply to new Pi work. Credentials remain outside this browser.</p></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
{draft ? (
|
||||||
<Field label="Provider">
|
<>
|
||||||
<select aria-label="Provider" className={fieldClass} value={draft.provider} onChange={(event) => {
|
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||||
const provider = event.target.value;
|
<Field label="Provider">
|
||||||
setDraft((current) => current ? { ...current, provider, model: optionsQuery.data.models.find((model) => model.provider === provider)?.id ?? "" } : current);
|
<select aria-label="Provider" className={fieldClass} value={draft.provider} onChange={(event) => {
|
||||||
}}>
|
const provider = event.target.value;
|
||||||
{optionsQuery.data.providers.map((provider) => <option key={provider} value={provider}>{provider}</option>)}
|
updateDraft((current) => ({
|
||||||
</select>
|
...current,
|
||||||
</Field>
|
provider,
|
||||||
<Field label="Model">
|
model: optionsQuery.data.models.find((model) => model.provider === provider)?.id ?? "",
|
||||||
<select aria-label="Model" className={fieldClass} value={draft.model} onChange={(event) => setDraft((current) => current ? { ...current, model: event.target.value } : current)}>
|
}));
|
||||||
{models.map((model) => <option key={model.id} value={model.id}>{model.id === "glm-5.2" ? "GLM 5.2" : model.id}</option>)}
|
}}>
|
||||||
</select>
|
{optionsQuery.data.providers.map((provider) => <option key={provider} value={provider}>{provider}</option>)}
|
||||||
</Field>
|
</select>
|
||||||
<Field label="Reasoning level">
|
</Field>
|
||||||
<select aria-label="Reasoning level" className={fieldClass} value={draft.reasoning} onChange={(event) => setDraft((current) => current ? { ...current, reasoning: event.target.value as PiInstallationConfig["reasoning"] } : current)}>
|
<Field label="Model">
|
||||||
{optionsQuery.data.reasoning.map((reasoning) => <option key={reasoning} value={reasoning}>{reasoning}</option>)}
|
<select aria-label="Model" className={fieldClass} value={draft.model} onChange={(event) => updateDraft((current) => ({ ...current, model: event.target.value }))}>
|
||||||
</select>
|
{models.map((model) => <option key={model.id} value={model.id}>{model.id === "glm-5.2" ? "GLM 5.2" : model.id}</option>)}
|
||||||
</Field>
|
</select>
|
||||||
</div>
|
</Field>
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<Field label="Reasoning level">
|
||||||
<Button size="sm" variant="outline" disabled={saveMutation.isPending} onClick={saveDefaults}>{saveMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <ClipboardCheck />}Save defaults</Button>
|
<select aria-label="Reasoning level" className={fieldClass} value={draft.reasoning} onChange={(event) => updateDraft((current) => ({ ...current, reasoning: event.target.value as PiInstallationConfig["reasoning"] }))}>
|
||||||
<Button size="sm" variant="outline" disabled={smokeMutation.isPending} onClick={() => smokeMutation.mutate()}>{smokeMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <FlaskConical />}Run smoke test</Button>
|
{optionsQuery.data.reasoning.map((reasoning) => <option key={reasoning} value={reasoning}>{reasoning}</option>)}
|
||||||
</div>
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{dirty && <p className="mt-3 text-sm text-amber-800 dark:text-amber-300">
|
||||||
|
{persistedReady ? "Changes are not saved yet." : "Suggested choices are not saved yet."}
|
||||||
|
</p>}
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Button size="sm" variant="outline" disabled={saveMutation.isPending || !validDraft || !dirty} onClick={saveDefaults}>{saveMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <ClipboardCheck />}Save defaults</Button>
|
||||||
|
<Button size="sm" variant="outline" disabled={smokeMutation.isPending || dirty || !persistedReady} onClick={testSavedDefaults}>{smokeMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <FlaskConical />}Test saved defaults</Button>
|
||||||
|
</div>
|
||||||
|
{dirty && <p className="mt-2 text-xs text-muted-foreground">Save these changes before testing. The test always uses saved defaults.</p>}
|
||||||
|
{!dirty && !persistedReady && <p className="mt-2 text-xs text-muted-foreground">Save supported defaults before testing. The test always uses saved defaults.</p>}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div role="alert" aria-label="Pi configuration incomplete" className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-900 dark:text-amber-200">
|
||||||
|
<p className="font-semibold">No enabled provider and model choices are available.</p>
|
||||||
|
<p className="mt-1">Check the host-managed Pi model configuration, then retry this panel.</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Button size="sm" variant="outline" disabled><ClipboardCheck />Save defaults</Button>
|
||||||
|
<Button size="sm" variant="outline" disabled><FlaskConical />Test saved defaults</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => { void statusQuery.refetch(); void optionsQuery.refetch(); }}>Retry choices</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-label="Sanitized diagnostics" className="border-t border-border/70 pt-5">
|
<section aria-label="Sanitized diagnostics" className="border-t border-border/70 pt-5">
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const base: SessionSummary = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
server.use(http.get("http://localhost:8787/sessions/s1/documents", () =>
|
server.use(http.get("/api/sessions/s1/documents", () =>
|
||||||
HttpResponse.json([
|
HttpResponse.json([
|
||||||
{ phase: "—", key: "question", title: "Original question", format: "text", content: "How many ablations?" },
|
{ phase: "—", key: "question", title: "Original question", format: "text", content: "How many ablations?" },
|
||||||
{ phase: "F7", key: "sql", title: "Final SQL", format: "sql", content: "SELECT 1" },
|
{ 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.
|
// unmounts the tree); the good sibling document must still render.
|
||||||
// resetHandlers(...) replaces the beforeEach handler so this response is used.
|
// resetHandlers(...) replaces the beforeEach handler so this response is used.
|
||||||
server.resetHandlers(
|
server.resetHandlers(
|
||||||
http.get("http://localhost:8787/sessions/s1/documents", () =>
|
http.get("/api/sessions/s1/documents", () =>
|
||||||
HttpResponse.json([
|
HttpResponse.json([
|
||||||
{ phase: "F4", key: "schema", title: "Schema linking", format: "schema-linking", content: '{"joins":[]}' },
|
{ phase: "F4", key: "schema", title: "Schema linking", format: "schema-linking", content: '{"joins":[]}' },
|
||||||
{ phase: "—", key: "question", title: "Original question", format: "text", content: "SIBLING-SURVIVES" },
|
{ 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 () => {
|
test("renders the canonical summary order and formats human text as Markdown", async () => {
|
||||||
server.resetHandlers(
|
server.resetHandlers(
|
||||||
http.get("http://localhost:8787/sessions/s1/documents", () =>
|
http.get("/api/sessions/s1/documents", () =>
|
||||||
HttpResponse.json([
|
HttpResponse.json([
|
||||||
{ phase: "—", key: "question", title: "Original question", format: "text", content: "Original **question**" },
|
{ phase: "—", key: "question", title: "Original question", format: "text", content: "Original **question**" },
|
||||||
{ phase: "F7", key: "sql", title: "Final SQL", format: "sql", content: "SELECT 1" },
|
{ 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 () => {
|
test("renders one approved-then-declined memory list with Markdown details", async () => {
|
||||||
server.resetHandlers(
|
server.resetHandlers(
|
||||||
http.get("http://localhost:8787/sessions/s1/documents", () =>
|
http.get("/api/sessions/s1/documents", () =>
|
||||||
HttpResponse.json([
|
HttpResponse.json([
|
||||||
{
|
{
|
||||||
phase: "F8",
|
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**" },
|
{ type: "column_excluded", subject: "fact_a.note", detail: "Non **pertinente**" },
|
||||||
].map((decision) => JSON.stringify(decision)).join("\n");
|
].map((decision) => JSON.stringify(decision)).join("\n");
|
||||||
server.resetHandlers(
|
server.resetHandlers(
|
||||||
http.get("http://localhost:8787/sessions/s1/documents", () =>
|
http.get("/api/sessions/s1/documents", () =>
|
||||||
HttpResponse.json([
|
HttpResponse.json([
|
||||||
{ phase: "—", key: "decisions", title: "Decisions", format: "decisions", content: lines },
|
{ phase: "—", key: "decisions", title: "Decisions", format: "decisions", content: lines },
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { ComposerFooter, ContextGauge, SteerInput } from "./SteerInput";
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/steer", () =>
|
http.post("/api/sessions/:id/steer", () =>
|
||||||
new HttpResponse(null, { status: 204 }),
|
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",
|
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "high",
|
||||||
}));
|
}));
|
||||||
server.use(
|
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",
|
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();
|
body = await request.json();
|
||||||
return HttpResponse.json({ id: "s1" });
|
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 () => {
|
test("submitting typed text POSTs to /steer and clears the input", async () => {
|
||||||
let captured: unknown = null;
|
let captured: unknown = null;
|
||||||
server.use(
|
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() };
|
captured = { id: params.id, body: await request.json() };
|
||||||
return new HttpResponse(null, { status: 204 });
|
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 () => {
|
test("pressing Enter in the input submits the steer", async () => {
|
||||||
let captured: unknown = null;
|
let captured: unknown = null;
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/steer", async ({ request }) => {
|
http.post("/api/sessions/:id/steer", async ({ request }) => {
|
||||||
captured = await request.json();
|
captured = await request.json();
|
||||||
return new HttpResponse(null, { status: 204 });
|
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 () => {
|
test("does not POST when input is empty", async () => {
|
||||||
let called = false;
|
let called = false;
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/steer", () => {
|
http.post("/api/sessions/:id/steer", () => {
|
||||||
called = true;
|
called = true;
|
||||||
return new HttpResponse(null, { status: 204 });
|
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 () => {
|
test("footer shows cumulative k-token counters after workspace and context gauge after thinking", async () => {
|
||||||
server.use(
|
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",
|
workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium",
|
||||||
})),
|
})),
|
||||||
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ name: "psd" }])),
|
http.get("/api/workspaces", () => HttpResponse.json([{ name: "psd" }])),
|
||||||
http.get("http://localhost:8787/workspaces/psd", () => HttpResponse.json({
|
http.get("/api/workspaces/psd", () => HttpResponse.json({
|
||||||
workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } },
|
workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } },
|
||||||
revision: { id: "psd", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" },
|
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 }],
|
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 () => {
|
test("footer limits model choices to the selected workspace policy", async () => {
|
||||||
server.use(
|
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",
|
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",
|
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" },
|
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"] } },
|
workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } },
|
||||||
revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" },
|
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: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
|
||||||
{ provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", 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,
|
id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const,
|
||||||
});
|
});
|
||||||
server.use(
|
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",
|
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: "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") },
|
{ 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"] } },
|
workspace: { workspace: { id: "research" }, llm_policy: { allowed: ["deepseek/deepseek-v4-pro"] } },
|
||||||
revision: revision("research"),
|
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"] } },
|
workspace: { workspace: { id: "psd-clinical" }, llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] } },
|
||||||
revision: revision("psd-clinical"),
|
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: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
|
||||||
{ provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", 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();
|
body = await request.json();
|
||||||
return HttpResponse.json({ id: "s1" });
|
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,
|
id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const,
|
||||||
});
|
});
|
||||||
server.use(
|
server.use(
|
||||||
http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "research" })),
|
http.get("/api/settings", () => HttpResponse.json({ workspace: "research" })),
|
||||||
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: "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") },
|
{ 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"),
|
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;
|
policyRequestStarted = true;
|
||||||
await policyMayFinish;
|
await policyMayFinish;
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
@@ -264,11 +264,11 @@ test("immediate submit waits for a switched workspace policy before creating a s
|
|||||||
revision: revision("psd-clinical"),
|
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: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
|
||||||
{ provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", 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();
|
body = await request.json();
|
||||||
return HttpResponse.json({ id: "s1" });
|
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,
|
id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const,
|
||||||
});
|
});
|
||||||
server.use(
|
server.use(
|
||||||
http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "psd-clinical" })),
|
http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical" })),
|
||||||
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: revision("psd-clinical") },
|
{ 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;
|
policyRequestStarted = true;
|
||||||
await policyMayFinish;
|
await policyMayFinish;
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
@@ -316,11 +316,11 @@ test("initial restored workspace waits for its delayed policy before creating a
|
|||||||
revision: revision("psd-clinical"),
|
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: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
|
||||||
{ provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", 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();
|
body = await request.json();
|
||||||
return HttpResponse.json({ id: "s1" });
|
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",
|
workspaceId: "legacy-workspace", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
|
||||||
}));
|
}));
|
||||||
server.use(
|
server.use(
|
||||||
http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "legacy-workspace" })),
|
http.get("/api/settings", () => HttpResponse.json({ workspace: "legacy-workspace" })),
|
||||||
http.get("http://localhost:8787/workspaces", async () => {
|
http.get("/api/workspaces", async () => {
|
||||||
summaryRequestStarted = true;
|
summaryRequestStarted = true;
|
||||||
await summariesMayFinish;
|
await summariesMayFinish;
|
||||||
return HttpResponse.json([{
|
return HttpResponse.json([{
|
||||||
id: "legacy-workspace", name: "legacy-workspace", file: "legacy-workspace.yaml", displayName: "Legacy workspace",
|
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 },
|
{ 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();
|
body = await request.json();
|
||||||
return HttpResponse.json({ id: "s1" });
|
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",
|
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium",
|
||||||
}));
|
}));
|
||||||
server.use(
|
server.use(
|
||||||
http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "psd-clinical" })),
|
http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical" })),
|
||||||
http.get("http://localhost:8787/workspaces", () => {
|
http.get("/api/workspaces", () => {
|
||||||
summaryRequestFailed = true;
|
summaryRequestFailed = true;
|
||||||
return new HttpResponse(null, { status: 503 });
|
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 },
|
{ 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();
|
body = await request.json();
|
||||||
return HttpResponse.json({ id: "s1" });
|
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,
|
id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const,
|
||||||
});
|
});
|
||||||
server.use(
|
server.use(
|
||||||
http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "research" })),
|
http.get("/api/settings", () => HttpResponse.json({ workspace: "research" })),
|
||||||
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: "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-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") },
|
{ 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"),
|
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;
|
bPolicyRequestStarted = true;
|
||||||
await new Promise(() => undefined);
|
await new Promise(() => undefined);
|
||||||
return HttpResponse.json({});
|
return HttpResponse.json({});
|
||||||
}),
|
}),
|
||||||
http.get("http://localhost:8787/workspaces/workspace-c", async () => {
|
http.get("/api/workspaces/workspace-c", async () => {
|
||||||
cPolicyRequestStarted = true;
|
cPolicyRequestStarted = true;
|
||||||
await cPolicyMayFinish;
|
await cPolicyMayFinish;
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
@@ -455,11 +455,11 @@ test("submit follows a rapid workspace switch instead of waiting for an abandone
|
|||||||
revision: revision("workspace-c"),
|
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: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
|
||||||
{ provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", 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();
|
body = await request.json();
|
||||||
return HttpResponse.json({ id: "s1" });
|
return HttpResponse.json({ id: "s1" });
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ function renderManager() {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
server.use(
|
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 })),
|
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",
|
id: "psd-clinical", name: "PSD Clinical", displayName: "PSD Clinical", description: "Clinical data",
|
||||||
language: "en", file: "workspaces/psd-clinical.yaml",
|
language: "en", file: "workspaces/psd-clinical.yaml",
|
||||||
revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd", state: "operational" },
|
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,
|
workspace,
|
||||||
revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd", state: "operational" },
|
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 user = userEvent.setup();
|
||||||
const publishSpy = vi.fn();
|
const publishSpy = vi.fn();
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/workspaces/import", () => HttpResponse.json({ draft: { workspace, contract: {} } })),
|
http.post("/api/workspaces/import", () => HttpResponse.json({ draft: { workspace, contract: {} } })),
|
||||||
http.post("http://localhost:8787/workspaces/publish", () => {
|
http.post("/api/workspaces/publish", () => {
|
||||||
publishSpy();
|
publishSpy();
|
||||||
return HttpResponse.json({});
|
return HttpResponse.json({});
|
||||||
}),
|
}),
|
||||||
@@ -87,9 +87,9 @@ test("pulls and exports only when the manager explicitly requests each action",
|
|||||||
vi.stubGlobal("URL", DownloadUrl);
|
vi.stubGlobal("URL", DownloadUrl);
|
||||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
|
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
|
||||||
server.use(
|
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.post("/api/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.get("/api/workspaces/psd-clinical/export", () => new HttpResponse(new Blob(["bundle"], { type: "application/zip" }))),
|
||||||
http.post("http://localhost:8787/workspaces/publish", () => {
|
http.post("/api/workspaces/publish", () => {
|
||||||
publishSpy();
|
publishSpy();
|
||||||
return HttpResponse.json({});
|
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 () => {
|
test("stages duplicate and delete operations without publishing", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let published = false;
|
let published = false;
|
||||||
server.use(http.post("http://localhost:8787/workspaces/publish", () => {
|
server.use(http.post("/api/workspaces/publish", () => {
|
||||||
published = true;
|
published = true;
|
||||||
return HttpResponse.json({});
|
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 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" } } };
|
const remote = { ...workspace, semantic_index: { ...workspace.semantic_index, embedding: { ...workspace.semantic_index.embedding, model: "remote-model" } } };
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/workspaces/validate", () => HttpResponse.json({ workspace: local, contract: {} })),
|
http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: local, contract: {} })),
|
||||||
http.post("http://localhost:8787/workspaces/publish", () => {
|
http.post("/api/workspaces/publish", () => {
|
||||||
publishCalls += 1;
|
publishCalls += 1;
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
code: "workspace_conflict", message: "Workspace changed in the registry.", fields: ["semantic_index.embedding.model"],
|
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 maxId = `w${"a".repeat(62)}`;
|
||||||
const maxWorkspace = { ...workspace, workspace: { ...workspace.workspace, id: maxId } };
|
const maxWorkspace = { ...workspace, workspace: { ...workspace.workspace, id: maxId } };
|
||||||
server.use(
|
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`,
|
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" },
|
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,
|
workspace: maxWorkspace,
|
||||||
revision: { id: maxId, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/maximum", state: "operational" },
|
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 () => {
|
test("runs validation and installation test with only sanitized messages", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} })),
|
http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} })),
|
||||||
http.post("http://localhost:8787/workspaces/psd-clinical/test", () => HttpResponse.json({
|
http.post("/api/workspaces/psd-clinical/test", () => HttpResponse.json({
|
||||||
activatable: false,
|
activatable: false,
|
||||||
diagnostics: [{ level: "warning", code: "binding_missing", field: "dwh", message: "DWH binding is not configured" }],
|
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 () => {
|
test("shows an accessible retry instead of a loading status when the registry status query fails", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
server.use(http.get("http://localhost:8787/workspace-registry/status", () => {
|
server.use(http.get("/api/workspace-registry/status", () => {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
return calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json({ branch: "main", ahead: 0, behind: 0, degraded: false });
|
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 () => {
|
test("shows an accessible retry instead of an empty list when the workspace list query fails", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
server.use(http.get("http://localhost:8787/workspaces", () => {
|
server.use(http.get("/api/workspaces", () => {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
return calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json([]);
|
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 () => {
|
test("shows an accessible retry when the selected workspace detail query fails", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
server.use(http.get("http://localhost:8787/workspaces/psd-clinical", () => {
|
server.use(http.get("/api/workspaces/psd-clinical", () => {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
return calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json({
|
return calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json({
|
||||||
workspace,
|
workspace,
|
||||||
|
|||||||
@@ -43,14 +43,14 @@ const diagnosticsBranchConflict: WorkspaceConflict = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
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 () => {
|
test("validates a draft and requires a separate confirmation before publishing", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const published = vi.fn();
|
const published = vi.fn();
|
||||||
let publishCalls = 0;
|
let publishCalls = 0;
|
||||||
server.use(http.post("http://localhost:8787/workspaces/publish", () => {
|
server.use(http.post("/api/workspaces/publish", () => {
|
||||||
publishCalls += 1;
|
publishCalls += 1;
|
||||||
return HttpResponse.json({ revision: { id: "psd-clinical", commit: "c".repeat(40), blob: "d".repeat(40), snapshotPath: "/safe", state: "operational" } });
|
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 () => {
|
test("shows a field-level conflict and never overwrites the remote workspace", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let published = false;
|
let published = false;
|
||||||
server.use(http.post("http://localhost:8787/workspaces/publish", () => {
|
server.use(http.post("/api/workspaces/publish", () => {
|
||||||
published = true;
|
published = true;
|
||||||
return HttpResponse.json({ ...conflict, message: "Workspace changed in the registry." }, { status: 409 });
|
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 user = userEvent.setup();
|
||||||
const saved = vi.fn();
|
const saved = vi.fn();
|
||||||
let publishCalls = 0;
|
let publishCalls = 0;
|
||||||
server.use(http.post("http://localhost:8787/workspaces/publish", () => {
|
server.use(http.post("/api/workspaces/publish", () => {
|
||||||
publishCalls += 1;
|
publishCalls += 1;
|
||||||
return HttpResponse.json({ ...conflict, message: "Workspace changed in the registry." }, { status: 409 });
|
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 () => {
|
test("rebases a selected optional diagnostics branch into the revised draft", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const saved = vi.fn();
|
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.",
|
...diagnosticsBranchConflict, message: "Workspace changed in the registry.",
|
||||||
}, { status: 409 })));
|
}, { status: 409 })));
|
||||||
render(<WorkspacePublishDialog open request={request} onOpenChange={vi.fn()} onPublished={vi.fn()} onResolved={saved} onPull={vi.fn()} onReload={vi.fn()} />);
|
render(<WorkspacePublishDialog open request={request} onOpenChange={vi.fn()} onPublished={vi.fn()} onResolved={saved} onPull={vi.fn()} onReload={vi.fn()} />);
|
||||||
@@ -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 () => {
|
test("keeps a conflict open and redacts a failed registry pull", async () => {
|
||||||
const user = userEvent.setup();
|
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.",
|
...conflict, message: "Workspace changed in the registry.",
|
||||||
}, { status: 409 })));
|
}, { status: 409 })));
|
||||||
render(<WorkspacePublishDialog open request={request} onOpenChange={vi.fn()} onPublished={vi.fn()} onResolved={vi.fn()} onPull={vi.fn().mockRejectedValue(new Error("token=secret"))} onReload={vi.fn()} />);
|
render(<WorkspacePublishDialog open request={request} onOpenChange={vi.fn()} onPublished={vi.fn()} onResolved={vi.fn()} onPull={vi.fn().mockRejectedValue(new Error("token=secret"))} onReload={vi.fn()} />);
|
||||||
|
|||||||
@@ -18,16 +18,16 @@ beforeEach(() => {
|
|||||||
test("F1: create session -> widget via SSE -> respond -> POST /response", async () => {
|
test("F1: create session -> widget via SSE -> respond -> POST /response", async () => {
|
||||||
let responded: any = null;
|
let responded: any = null;
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions", () => HttpResponse.json({ id: "s1" })),
|
http.post("/api/sessions", () => HttpResponse.json({ id: "s1" })),
|
||||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([])),
|
http.get("/api/sessions", () => HttpResponse.json([])),
|
||||||
http.get("http://localhost:8787/settings", () => HttpResponse.json({
|
http.get("/api/settings", () => HttpResponse.json({
|
||||||
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
|
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" }]),
|
HttpResponse.json([{ name: "default", file: "default.db" }]),
|
||||||
),
|
),
|
||||||
http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [] })),
|
http.get("/api/models", () => HttpResponse.json({ models: [] })),
|
||||||
http.post("http://localhost:8787/sessions/s1/response", async ({ request }) => {
|
http.post("/api/sessions/s1/response", async ({ request }) => {
|
||||||
responded = await request.json();
|
responded = await request.json();
|
||||||
return new HttpResponse(null, { status: 204 });
|
return new HttpResponse(null, { status: 204 });
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ test("flushes pending stream text before a structural event", () => {
|
|||||||
test("opens an EventSource and feeds NAMED events to the store", () => {
|
test("opens an EventSource and feeds NAMED events to the store", () => {
|
||||||
renderHook(() => useSessionStream("s1"));
|
renderHook(() => useSessionStream("s1"));
|
||||||
const es = FakeEventSource.instances[0];
|
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
|
// Backend sends `event: ui_request` (named) — drive the addEventListener path
|
||||||
// that production relies on, not the unnamed onmessage fallback.
|
// that production relies on, not the unnamed onmessage fallback.
|
||||||
act(() =>
|
act(() =>
|
||||||
@@ -146,7 +146,7 @@ test("a same-session generation reconnect includes the last consumed SSE id", ()
|
|||||||
expect(first.closed).toBe(true);
|
expect(first.closed).toBe(true);
|
||||||
expect(FakeEventSource.instances).toHaveLength(2);
|
expect(FakeEventSource.instances).toHaveLength(2);
|
||||||
expect(FakeEventSource.instances[1].url).toBe(
|
expect(FakeEventSource.instances[1].url).toBe(
|
||||||
"http://localhost:8787/sessions/s1/events?lastEventId=7",
|
"/api/sessions/s1/events?lastEventId=7",
|
||||||
);
|
);
|
||||||
|
|
||||||
act(() =>
|
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(first.closed).toBe(true);
|
||||||
expect(FakeEventSource.instances).toHaveLength(2);
|
expect(FakeEventSource.instances).toHaveLength(2);
|
||||||
expect(FakeEventSource.instances[1].url).toBe(
|
expect(FakeEventSource.instances[1].url).toBe(
|
||||||
"http://localhost:8787/sessions/s1/events",
|
"/api/sessions/s1/events",
|
||||||
);
|
);
|
||||||
|
|
||||||
act(() =>
|
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 });
|
rerender({ generation: 0, resetEpoch: 1 });
|
||||||
const replacement = FakeEventSource.instances[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(() =>
|
act(() =>
|
||||||
replacement.emitNamed(
|
replacement.emitNamed(
|
||||||
"ui_request",
|
"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");
|
expect(useSessionStore.getState().pendingWidget?.id).toBe("preserved-high-gate");
|
||||||
rerender({ generation: 1, resetEpoch: 1 });
|
rerender({ generation: 1, resetEpoch: 1 });
|
||||||
expect(FakeEventSource.instances[2].url).toBe(
|
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" });
|
rerender({ sessionId: "s2" });
|
||||||
|
|
||||||
expect(first.closed).toBe(true);
|
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", () => {
|
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 });
|
rerender({ sessionId: "s2", generation: 0 });
|
||||||
expect(first.closed).toBe(true);
|
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();
|
useSessionStore.getState().resetSession();
|
||||||
|
|
||||||
act(() =>
|
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([]);
|
expect(useSessionStore.getState().transcript).toEqual([]);
|
||||||
|
|
||||||
rerender({ sessionId: "s2", generation: 1 });
|
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", () => {
|
test("the old source is invalid before later layout effects can deliver a queued event", () => {
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { setupServer } from "msw/node";
|
import { setupServer } from "msw/node";
|
||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
export const server = setupServer(
|
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" })),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { server } from "../test/msw";
|
import { server } from "../test/msw";
|
||||||
import { ResultsPanel } from "./ResultsPanel";
|
import { ResultsPanel } from "./ResultsPanel";
|
||||||
|
|
||||||
const BASE = "http://localhost:8787";
|
const BASE = "/api";
|
||||||
|
|
||||||
function makeClient() {
|
function makeClient() {
|
||||||
return new QueryClient({
|
return new QueryClient({
|
||||||
|
|||||||
Reference in New Issue
Block a user