From 30775f4dcce2069b8d698c9f55211c4c3855b7ec Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 24 Mar 2026 23:30:29 -0400 Subject: [PATCH] refactor(test): replace try/finally with t.after() in src/tests (o-z) (#2392) --- src/tests/integration/e2e-smoke.test.ts | 184 ++-- src/tests/integration/pack-install.test.ts | 170 ++-- .../integration/web-mode-assembled.test.ts | 770 +++++++-------- .../integration/web-mode-onboarding.test.ts | 342 +++---- src/tests/provider.test.ts | 80 +- src/tests/resource-loader.test.ts | 102 +- src/tests/resource-sync-staleness.test.ts | 18 +- src/tests/search-loop-guard.test.ts | 210 ++-- src/tests/search-provider-command.test.ts | 110 +-- src/tests/search-tavily.test.ts | 228 ++--- src/tests/secret-scan.test.ts | 102 +- src/tests/terminal-cmux.test.ts | 20 +- src/tests/tool-bootstrap.test.ts | 60 +- src/tests/ttsr-rule-loader.test.ts | 56 +- src/tests/update-check.test.ts | 364 ++++--- src/tests/web-bridge-contract.test.ts | 296 +++--- .../web-bridge-terminal-contract.test.ts | 124 +-- src/tests/web-cli-entry.test.ts | 122 ++- .../web-live-interaction-contract.test.ts | 468 ++++----- src/tests/web-live-state-contract.test.ts | 274 +++--- src/tests/web-mode-cli.test.ts | 908 +++++++++--------- src/tests/web-mode-network-flags.test.ts | 194 ++-- src/tests/web-multi-project-contract.test.ts | 296 +++--- src/tests/web-onboarding-contract.test.ts | 512 +++++----- .../web-recovery-diagnostics-contract.test.ts | 124 +-- src/tests/web-session-parity-contract.test.ts | 310 +++--- src/tests/web-state-surfaces-contract.test.ts | 370 ++++--- .../web-workflow-action-execution.test.ts | 32 +- src/tests/welcome-screen.test.ts | 12 +- 29 files changed, 3360 insertions(+), 3498 deletions(-) diff --git a/src/tests/integration/e2e-smoke.test.ts b/src/tests/integration/e2e-smoke.test.ts index 3f09b196d..21025f5ab 100644 --- a/src/tests/integration/e2e-smoke.test.ts +++ b/src/tests/integration/e2e-smoke.test.ts @@ -406,156 +406,144 @@ test("gsd -h is equivalent to --help", async () => { // 13. gsd headless without .gsd/ directory exits 1 with clean error // --------------------------------------------------------------------------- -test("gsd headless without .gsd/ directory exits 1 with clean error", async () => { +test("gsd headless without .gsd/ directory exits 1 with clean error", async (t) => { const tmpDir = mkdtempSync(join(tmpdir(), "gsd-e2e-no-gsd-")); - try { - const result = await runGsd(["headless"], 10_000, {}, tmpDir); + t.after(() => { rmSync(tmpDir, { recursive: true, force: true }); }); - assert.ok(!result.timedOut, "process should not hang"); - assert.strictEqual(result.code, 1, `expected exit 1, got ${result.code}`); + const result = await runGsd(["headless"], 10_000, {}, tmpDir); - const combined = stripAnsi(result.stdout + result.stderr); - assert.ok( - combined.includes(".gsd/") || combined.includes("No .gsd"), - `expected .gsd/ missing error, got:\n${combined.slice(0, 500)}`, - ); + assert.ok(!result.timedOut, "process should not hang"); + assert.strictEqual(result.code, 1, `expected exit 1, got ${result.code}`); - assertNoCrashMarkers(combined); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } + const combined = stripAnsi(result.stdout + result.stderr); + assert.ok( + combined.includes(".gsd/") || combined.includes("No .gsd"), + `expected .gsd/ missing error, got:\n${combined.slice(0, 500)}`, + ); + + assertNoCrashMarkers(combined); }); // --------------------------------------------------------------------------- // 14. gsd headless new-milestone without --context exits 1 // --------------------------------------------------------------------------- -test("gsd headless new-milestone without --context exits 1", async () => { +test("gsd headless new-milestone without --context exits 1", async (t) => { const tmpDir = mkdtempSync(join(tmpdir(), "gsd-e2e-no-ctx-")); - try { - const result = await runGsd(["headless", "new-milestone"], 10_000, {}, tmpDir); + t.after(() => { rmSync(tmpDir, { recursive: true, force: true }); }); - assert.ok(!result.timedOut, "process should not hang"); - assert.strictEqual(result.code, 1, `expected exit 1, got ${result.code}`); + const result = await runGsd(["headless", "new-milestone"], 10_000, {}, tmpDir); - const combined = stripAnsi(result.stdout + result.stderr); - assert.ok( - combined.includes("context") || combined.includes("--context"), - `expected context-required error, got:\n${combined.slice(0, 500)}`, - ); + assert.ok(!result.timedOut, "process should not hang"); + assert.strictEqual(result.code, 1, `expected exit 1, got ${result.code}`); - assertNoCrashMarkers(combined); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } + const combined = stripAnsi(result.stdout + result.stderr); + assert.ok( + combined.includes("context") || combined.includes("--context"), + `expected context-required error, got:\n${combined.slice(0, 500)}`, + ); + + assertNoCrashMarkers(combined); }); // --------------------------------------------------------------------------- // 15. gsd headless --timeout with invalid value exits 1 // --------------------------------------------------------------------------- -test("gsd headless --timeout with invalid value exits 1", async () => { +test("gsd headless --timeout with invalid value exits 1", async (t) => { const tmpDir = mkdtempSync(join(tmpdir(), "gsd-e2e-bad-timeout-")); - try { - const result = await runGsd( - ["headless", "--timeout", "not-a-number", "auto"], - 10_000, - {}, - tmpDir, - ); + t.after(() => { rmSync(tmpDir, { recursive: true, force: true }); }); - assert.ok(!result.timedOut, "process should not hang"); - assert.strictEqual(result.code, 1, `expected exit 1, got ${result.code}`); + const result = await runGsd( + ["headless", "--timeout", "not-a-number", "auto"], + 10_000, + {}, + tmpDir, + ); - const combined = stripAnsi(result.stdout + result.stderr); - assert.ok( - combined.includes("timeout") || combined.includes("positive integer"), - `expected timeout validation error, got:\n${combined.slice(0, 500)}`, - ); + assert.ok(!result.timedOut, "process should not hang"); + assert.strictEqual(result.code, 1, `expected exit 1, got ${result.code}`); - assertNoCrashMarkers(combined); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } + const combined = stripAnsi(result.stdout + result.stderr); + assert.ok( + combined.includes("timeout") || combined.includes("positive integer"), + `expected timeout validation error, got:\n${combined.slice(0, 500)}`, + ); + + assertNoCrashMarkers(combined); }); // --------------------------------------------------------------------------- // 16. gsd headless --timeout with negative value exits 1 // --------------------------------------------------------------------------- -test("gsd headless --timeout with negative value exits 1", async () => { +test("gsd headless --timeout with negative value exits 1", async (t) => { const tmpDir = mkdtempSync(join(tmpdir(), "gsd-e2e-neg-timeout-")); - try { - const result = await runGsd( - ["headless", "--timeout", "-5000", "auto"], - 10_000, - {}, - tmpDir, - ); + t.after(() => { rmSync(tmpDir, { recursive: true, force: true }); }); - assert.ok(!result.timedOut, "process should not hang"); - assert.strictEqual(result.code, 1, `expected exit 1, got ${result.code}`); + const result = await runGsd( + ["headless", "--timeout", "-5000", "auto"], + 10_000, + {}, + tmpDir, + ); - const combined = stripAnsi(result.stdout + result.stderr); - assert.ok( - combined.includes("timeout") || combined.includes("positive integer"), - `expected timeout validation error, got:\n${combined.slice(0, 500)}`, - ); + assert.ok(!result.timedOut, "process should not hang"); + assert.strictEqual(result.code, 1, `expected exit 1, got ${result.code}`); - assertNoCrashMarkers(combined); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } + const combined = stripAnsi(result.stdout + result.stderr); + assert.ok( + combined.includes("timeout") || combined.includes("positive integer"), + `expected timeout validation error, got:\n${combined.slice(0, 500)}`, + ); + + assertNoCrashMarkers(combined); }); -test("gsd headless query returns JSON from the built CLI", async () => { +test("gsd headless query returns JSON from the built CLI", async (t) => { const tmpDir = createTempGitRepo("gsd-e2e-query-"); - try { - mkdirSync(join(tmpDir, ".gsd", "milestones"), { recursive: true }); + t.after(() => { rmSync(tmpDir, { recursive: true, force: true }); }); - // Cold packaged startup in a fresh temp repo is now regularly >10s because - // the built CLI loads bundled TS resources through jiti before answering. - // This command is still healthy; it just needs a realistic timeout budget. - const result = await runGsd(["headless", "query"], 30_000, {}, tmpDir); + mkdirSync(join(tmpDir, ".gsd", "milestones"), { recursive: true }); - assert.ok(!result.timedOut, "process should not hang"); - assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`); + // Cold packaged startup in a fresh temp repo is now regularly >10s because + // the built CLI loads bundled TS resources through jiti before answering. + // This command is still healthy; it just needs a realistic timeout budget. + const result = await runGsd(["headless", "query"], 30_000, {}, tmpDir); - const combined = stripAnsi(result.stdout + result.stderr); - assertNoCrashMarkers(combined); + assert.ok(!result.timedOut, "process should not hang"); + assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`); - const snapshot = JSON.parse(result.stdout); - assert.equal(typeof snapshot.state?.phase, "string", "query output should include state.phase"); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } + const combined = stripAnsi(result.stdout + result.stderr); + assertNoCrashMarkers(combined); + + const snapshot = JSON.parse(result.stdout); + assert.equal(typeof snapshot.state?.phase, "string", "query output should include state.phase"); }); -test("gsd worktree list loads the built worktree CLI without module errors", async () => { +test("gsd worktree list loads the built worktree CLI without module errors", async (t) => { const tmpDir = createTempGitRepo("gsd-e2e-worktree-"); - try { - // Cold packaged startup in a fresh temp repo is now regularly >10s because - // the built CLI loads bundled TS resources through jiti before listing. - const result = await runGsd(["worktree", "list"], 30_000, {}, tmpDir); + t.after(() => { rmSync(tmpDir, { recursive: true, force: true }); }); - assert.ok(!result.timedOut, "process should not hang"); - assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`); + // Cold packaged startup in a fresh temp repo is now regularly >10s because + // the built CLI loads bundled TS resources through jiti before listing. + const result = await runGsd(["worktree", "list"], 30_000, {}, tmpDir); - const combined = stripAnsi(result.stdout + result.stderr); - assertNoCrashMarkers(combined); - assert.ok( - combined.includes("No worktrees") || combined.includes("Worktrees"), - `expected worktree CLI output, got:\n${combined.slice(0, 500)}`, - ); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } + assert.ok(!result.timedOut, "process should not hang"); + assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`); + + const combined = stripAnsi(result.stdout + result.stderr); + assertNoCrashMarkers(combined); + assert.ok( + combined.includes("No worktrees") || combined.includes("Worktrees"), + `expected worktree CLI output, got:\n${combined.slice(0, 500)}`, + ); }); // =========================================================================== diff --git a/src/tests/integration/pack-install.test.ts b/src/tests/integration/pack-install.test.ts index 4abd4cbfb..40b764d4b 100644 --- a/src/tests/integration/pack-install.test.ts +++ b/src/tests/integration/pack-install.test.ts @@ -97,79 +97,79 @@ function listTarEntries(tarballPath: string): Promise { // 1. npm pack produces valid tarball with correct file layout // ═══════════════════════════════════════════════════════════════════════════ -test("npm pack produces tarball with required files", async () => { +test("npm pack produces tarball with required files", async (t) => { const sandbox = createNpmSandbox("gsd-pack-test-"); const tarballPath = packTarball(sandbox); assert.ok(existsSync(tarballPath), "tarball created"); - try { - const files = await listTarEntries(tarballPath); - - // Critical files must be present - assert.ok(files.some(f => f.includes("dist/loader.js")), "tarball contains dist/loader.js"); - assert.ok(files.some(f => f.includes("dist/cli.js")), "tarball contains dist/cli.js"); - assert.ok(files.some(f => f.includes("dist/app-paths.js")), "tarball contains dist/app-paths.js"); - assert.ok(files.some(f => f.includes("dist/wizard.js")), "tarball contains dist/wizard.js"); - assert.ok(files.some(f => f.includes("dist/resource-loader.js")), "tarball contains dist/resource-loader.js"); - assert.ok(files.some(f => f.includes("pkg/package.json")), "tarball contains pkg/package.json"); - assert.ok(files.some(f => f.includes("src/resources/extensions/gsd/index.ts")), "tarball contains bundled gsd extension"); - assert.ok(files.some(f => f.includes("scripts/postinstall.js")), "tarball contains postinstall script"); - - // pkg/package.json must have piConfig - const pkgJson = readFileSync(join(projectRoot, "pkg", "package.json"), "utf-8"); - const pkg = JSON.parse(pkgJson); - assert.equal(pkg.piConfig?.name, "gsd", "pkg/package.json piConfig.name is gsd"); - assert.equal(pkg.piConfig?.configDir, ".gsd", "pkg/package.json piConfig.configDir is .gsd"); - } finally { + t.after(() => { rmSync(tarballPath, { force: true }); rmSync(sandbox.rootDir, { recursive: true, force: true }); - } + }); + + const files = await listTarEntries(tarballPath); + + // Critical files must be present + assert.ok(files.some(f => f.includes("dist/loader.js")), "tarball contains dist/loader.js"); + assert.ok(files.some(f => f.includes("dist/cli.js")), "tarball contains dist/cli.js"); + assert.ok(files.some(f => f.includes("dist/app-paths.js")), "tarball contains dist/app-paths.js"); + assert.ok(files.some(f => f.includes("dist/wizard.js")), "tarball contains dist/wizard.js"); + assert.ok(files.some(f => f.includes("dist/resource-loader.js")), "tarball contains dist/resource-loader.js"); + assert.ok(files.some(f => f.includes("pkg/package.json")), "tarball contains pkg/package.json"); + assert.ok(files.some(f => f.includes("src/resources/extensions/gsd/index.ts")), "tarball contains bundled gsd extension"); + assert.ok(files.some(f => f.includes("scripts/postinstall.js")), "tarball contains postinstall script"); + + // pkg/package.json must have piConfig + const pkgJson = readFileSync(join(projectRoot, "pkg", "package.json"), "utf-8"); + const pkg = JSON.parse(pkgJson); + assert.equal(pkg.piConfig?.name, "gsd", "pkg/package.json piConfig.name is gsd"); + assert.equal(pkg.piConfig?.configDir, ".gsd", "pkg/package.json piConfig.configDir is .gsd"); }); // ═══════════════════════════════════════════════════════════════════════════ // 2. npm pack → install → gsd binary resolves // ═══════════════════════════════════════════════════════════════════════════ -test("tarball installs and gsd binary resolves", async () => { +test("tarball installs and gsd binary resolves", async (t) => { const sandbox = createNpmSandbox("gsd-install-test-"); const tarballPath = packTarball(sandbox); - try { - // Install from tarball into a temp prefix - execFileSync("npm", ["install", "--prefix", sandbox.installPrefix, tarballPath, "--no-save"], { - env: sandbox.env, - stdio: ["ignore", "ignore", "pipe"], - }); - - // Verify the gsd bin exists in the installed package - const binName = process.platform === "win32" ? "gsd.cmd" : "gsd"; - const installedBin = join(sandbox.installPrefix, "node_modules", ".bin", binName); - assert.ok(existsSync(installedBin), `gsd binary exists in node_modules/.bin/ (${binName})`); - - // Verify loader.js is executable (has shebang) - const installedLoader = join(sandbox.installPrefix, "node_modules", "gsd-pi", "dist", "loader.js"); - const loaderContent = readFileSync(installedLoader, "utf-8"); - if (process.platform !== "win32") { - assert.ok(loaderContent.startsWith("#!/usr/bin/env node"), "loader.js has node shebang"); - } - - // Verify bundled resources are present - const installedGsdExt = join( - sandbox.installPrefix, - "node_modules", - "gsd-pi", - "src", - "resources", - "extensions", - "gsd", - "index.ts", - ); - assert.ok(existsSync(installedGsdExt), "bundled gsd extension present in installed package"); - } finally { + t.after(() => { rmSync(tarballPath, { force: true }); rmSync(sandbox.rootDir, { recursive: true, force: true }); + }); + + // Install from tarball into a temp prefix + execFileSync("npm", ["install", "--prefix", sandbox.installPrefix, tarballPath, "--no-save"], { + env: sandbox.env, + stdio: ["ignore", "ignore", "pipe"], + }); + + // Verify the gsd bin exists in the installed package + const binName = process.platform === "win32" ? "gsd.cmd" : "gsd"; + const installedBin = join(sandbox.installPrefix, "node_modules", ".bin", binName); + assert.ok(existsSync(installedBin), `gsd binary exists in node_modules/.bin/ (${binName})`); + + // Verify loader.js is executable (has shebang) + const installedLoader = join(sandbox.installPrefix, "node_modules", "gsd-pi", "dist", "loader.js"); + const loaderContent = readFileSync(installedLoader, "utf-8"); + if (process.platform !== "win32") { + assert.ok(loaderContent.startsWith("#!/usr/bin/env node"), "loader.js has node shebang"); } + + // Verify bundled resources are present + const installedGsdExt = join( + sandbox.installPrefix, + "node_modules", + "gsd-pi", + "src", + "resources", + "extensions", + "gsd", + "index.ts", + ); + assert.ok(existsSync(installedGsdExt), "bundled gsd extension present in installed package"); }); // ═══════════════════════════════════════════════════════════════════════════ @@ -230,7 +230,7 @@ test("gsd launches and loads extensions without errors", async () => { ); }); -test("gsd exits early with a clear message when synced resources are newer than the binary", async () => { +test("gsd exits early with a clear message when synced resources are newer than the binary", async (t) => { const fakeHome = mkdtempSync(join(tmpdir(), "gsd-version-skew-")); const fakeAgentDir = join(fakeHome, ".gsd", "agent"); mkdirSync(fakeAgentDir, { recursive: true }); @@ -239,38 +239,36 @@ test("gsd exits early with a clear message when synced resources are newer than JSON.stringify({ gsdVersion: "999.0.0" }), ); - try { - const result = await new Promise<{ code: number | null; stderr: string }>((resolve) => { - let stderr = ""; - const child = spawn("node", ["dist/loader.js"], { - cwd: projectRoot, - env: { - ...process.env, - HOME: fakeHome, - BRAVE_API_KEY: "test", - BRAVE_ANSWERS_KEY: "test", - CONTEXT7_API_KEY: "test", - JINA_API_KEY: "test", - TAVILY_API_KEY: "test", - }, - stdio: ["pipe", "pipe", "pipe"], - }); + t.after(() => { rmSync(fakeHome, { recursive: true, force: true }); }); - child.stderr.on("data", (data: Buffer) => { - stderr += data.toString(); - }); - - child.stdin.end(); - child.on("close", (code) => { - resolve({ code, stderr }); - }); + const result = await new Promise<{ code: number | null; stderr: string }>((resolve) => { + let stderr = ""; + const child = spawn("node", ["dist/loader.js"], { + cwd: projectRoot, + env: { + ...process.env, + HOME: fakeHome, + BRAVE_API_KEY: "test", + BRAVE_ANSWERS_KEY: "test", + CONTEXT7_API_KEY: "test", + JINA_API_KEY: "test", + TAVILY_API_KEY: "test", + }, + stdio: ["pipe", "pipe", "pipe"], }); - assert.equal(result.code, 1, "startup exits with code 1 on version skew"); - assert.match(result.stderr, /Version mismatch detected/, "prints a friendly skew header"); - assert.match(result.stderr, /npm install -g gsd-pi@latest|gsd update/, "prints upgrade guidance"); - assert.doesNotMatch(result.stderr, /\[gsd\] Extension load error/, "fails before extension loading"); - } finally { - rmSync(fakeHome, { recursive: true, force: true }); - } + child.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + child.stdin.end(); + child.on("close", (code) => { + resolve({ code, stderr }); + }); + }); + + assert.equal(result.code, 1, "startup exits with code 1 on version skew"); + assert.match(result.stderr, /Version mismatch detected/, "prints a friendly skew header"); + assert.match(result.stderr, /npm install -g gsd-pi@latest|gsd update/, "prints upgrade guidance"); + assert.doesNotMatch(result.stderr, /\[gsd\] Extension load error/, "fails before extension loading"); }); diff --git a/src/tests/integration/web-mode-assembled.test.ts b/src/tests/integration/web-mode-assembled.test.ts index 5e658ce51..d476c7c89 100644 --- a/src/tests/integration/web-mode-assembled.test.ts +++ b/src/tests/integration/web-mode-assembled.test.ts @@ -223,7 +223,7 @@ async function readSseEvents(response: Response, count: number, perReadTimeoutMs // Assembled lifecycle test // --------------------------------------------------------------------------- -test("assembled lifecycle: boot → onboard → prompt → streaming text → tool execution → blocking UI request → UI response → turn boundary", async () => { +test("assembled lifecycle: boot → onboard → prompt → streaming text → tool execution → blocking UI request → UI response → turn boundary", async (t) => { const fixture = makeWorkspaceFixture(); const authStorage = AuthStorage.inMemory({}); const sessionPath = createSessionFile(fixture.projectCwd, fixture.sessionsDir, "sess-assembled", "Assembled Lifecycle Session"); @@ -353,231 +353,231 @@ test("assembled lifecycle: boot → onboard → prompt → streaming text → to validateApiKey: async () => ({ ok: true, message: "openai credentials validated" }), }); - try { - // ----------------------------------------------------------------------- - // Stage 1: Boot — verify bridge ready, onboarding locked - // ----------------------------------------------------------------------- - const bootResponse = await bootRoute.GET(); - assert.equal(bootResponse.status, 200, "boot endpoint should respond 200"); - const bootPayload = (await bootResponse.json()) as any; - assert.equal(bootPayload.bridge.phase, "ready", "bridge should be ready after boot"); - assert.equal(bootPayload.onboarding.locked, true, "onboarding should be locked before setup"); - assert.equal(bootPayload.onboarding.lockReason, "required_setup", "lock reason should be required_setup"); - assert.equal(spawnCount, 1, "bridge should have spawned once during boot"); - - // Verify prompt is blocked while locked - const blockedPrompt = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "prompt", message: "should be rejected" }), - }), - ); - assert.equal(blockedPrompt.status, 423, "prompt should be locked (423) before onboarding"); - - // ----------------------------------------------------------------------- - // Stage 2: Onboard — save API key, unlock workspace - // ----------------------------------------------------------------------- - const onboardResponse = await onboardingRoute.POST( - new Request("http://localhost/api/onboarding", { - method: "POST", - body: JSON.stringify({ - action: "save_api_key", - providerId: "openai", - apiKey: "sk-assembled-test-key", - }), - }), - ); - assert.equal(onboardResponse.status, 200, "onboarding save_api_key should succeed"); - const onboardPayload = (await onboardResponse.json()) as any; - assert.equal(onboardPayload.onboarding.locked, false, "onboarding should be unlocked after setup"); - assert.equal(onboardPayload.onboarding.lockReason, null, "lock reason should be null after setup"); - assert.equal(onboardPayload.onboarding.bridgeAuthRefresh.phase, "succeeded", "bridge auth refresh should succeed"); - assert.equal(spawnCount, 2, "bridge should have been restarted (spawned again) during auth refresh"); - - // ----------------------------------------------------------------------- - // Stage 3: Subscribe SSE + send prompt - // ----------------------------------------------------------------------- - const sseResponse = await eventsRoute.GET( - new Request("http://localhost/api/session/events", { signal: AbortSignal.timeout(10_000) }), - ); - assert.equal(sseResponse.status, 200, "SSE endpoint should respond 200"); - assert.equal( - sseResponse.headers.get("content-type"), - "text/event-stream; charset=utf-8", - "SSE should have correct content type", - ); - - // Start reading SSE events in background (reads until count or timeout) - const phase1EventsPromise = readSseEvents(sseResponse, 15, 3_000); - - // Send the prompt — triggers fake child's streaming event sequence - const promptResponse = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "prompt", message: "deploy the application" }), - }), - ); - assert.equal(promptResponse.status, 200, "prompt should succeed after onboarding"); - const promptPayload = (await promptResponse.json()) as any; - assert.equal(promptPayload.success, true, "prompt RPC response should indicate success"); - assert.equal(promptPayload.command, "prompt", "prompt RPC response should echo command type"); - - // Collect Phase 1 SSE events - const phase1Events = await phase1EventsPromise; - await waitForMicrotasks(); - - // ----------------------------------------------------------------------- - // Stage 4: Verify streaming events arrived via SSE - // ----------------------------------------------------------------------- - const nonStatusEvents = phase1Events.filter((e) => e.type !== "bridge_status"); - const eventTypes = nonStatusEvents.map((e) => e.type); - - const messageUpdate = nonStatusEvents.find((e) => e.type === "message_update"); - assert.ok( - messageUpdate, - `message_update event should arrive via SSE (got types: ${eventTypes.join(", ")})`, - ); - assert.equal( - messageUpdate.assistantMessageEvent.type, - "text_delta", - "message_update should contain a text_delta", - ); - assert.equal( - messageUpdate.assistantMessageEvent.delta, - "Deploying to production...", - "text_delta should carry the expected content", - ); - - const toolStart = nonStatusEvents.find((e) => e.type === "tool_execution_start"); - assert.ok( - toolStart, - `tool_execution_start event should arrive via SSE (got types: ${eventTypes.join(", ")})`, - ); - assert.equal(toolStart.toolCallId, "tc-deploy-1", "tool start should have correct toolCallId"); - assert.equal(toolStart.toolName, "bash", "tool start should identify the tool name"); - - const toolEnd = nonStatusEvents.find((e) => e.type === "tool_execution_end"); - assert.ok( - toolEnd, - `tool_execution_end event should arrive via SSE (got types: ${eventTypes.join(", ")})`, - ); - assert.equal(toolEnd.toolCallId, "tc-deploy-1", "tool end should match the tool start"); - assert.equal(toolEnd.isError, false, "tool execution should not be an error"); - - const uiRequest = nonStatusEvents.find((e) => e.type === "extension_ui_request"); - assert.ok( - uiRequest, - `extension_ui_request event should arrive via SSE (got types: ${eventTypes.join(", ")})`, - ); - assert.equal(uiRequest.id, "ui-confirm-deploy", "UI request should have the expected id"); - assert.equal(uiRequest.method, "confirm", "UI request should be a confirm dialog"); - assert.equal(uiRequest.title, "Confirm deployment", "UI request should have the expected title"); - assert.equal( - uiRequest.message, - "Proceed with deploying to production?", - "UI request should have the expected message", - ); - - // Verify correct event ordering: message_update → tool_start → tool_end → ui_request - const msgIdx = nonStatusEvents.indexOf(messageUpdate); - const toolStartIdx = nonStatusEvents.indexOf(toolStart); - const toolEndIdx = nonStatusEvents.indexOf(toolEnd); - const uiReqIdx = nonStatusEvents.indexOf(uiRequest); - assert.ok(msgIdx < toolStartIdx, "message_update should precede tool_execution_start"); - assert.ok(toolStartIdx < toolEndIdx, "tool_execution_start should precede tool_execution_end"); - assert.ok(toolEndIdx < uiReqIdx, "tool_execution_end should precede extension_ui_request"); - - // Verify bridge_status events were also delivered (proves SSE fanout is working) - const statusEvents = phase1Events.filter((e) => e.type === "bridge_status"); - assert.ok(statusEvents.length >= 1, "at least one bridge_status event should arrive via SSE"); - - // ----------------------------------------------------------------------- - // Stage 5: Respond to UI request — prove the round-trip - // ----------------------------------------------------------------------- - const sseResponse2 = await eventsRoute.GET( - new Request("http://localhost/api/session/events", { signal: AbortSignal.timeout(10_000) }), - ); - - // Start reading Phase 2 events in background - const phase2EventsPromise = readSseEvents(sseResponse2, 10, 3_000); - - // Send the UI response - const uiResponseResult = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ - type: "extension_ui_response", - id: "ui-confirm-deploy", - value: true, - }), - }), - ); - assert.equal(uiResponseResult.status, 202, "extension_ui_response should return 202 (fire-and-forget)"); - - // Wait for microtasks to let the stdin write propagate - await waitForMicrotasks(); - - // Verify the UI response reached the fake child's stdin (round-trip proof) - assert.ok(receivedUiResponse, "UI response should have reached the fake child via bridge stdin"); - assert.equal(receivedUiResponse.id, "ui-confirm-deploy", "UI response id should match the request"); - assert.equal(receivedUiResponse.value, true, "UI response value should be delivered intact"); - - // Collect Phase 2 SSE events (agent_end + turn_end) - const phase2Events = await phase2EventsPromise; - await waitForMicrotasks(); - - // ----------------------------------------------------------------------- - // Stage 6: Verify turn boundary events - // ----------------------------------------------------------------------- - const phase2NonStatus = phase2Events.filter((e) => e.type !== "bridge_status"); - const phase2Types = phase2NonStatus.map((e) => e.type); - - const agentEnd = phase2NonStatus.find((e) => e.type === "agent_end"); - assert.ok( - agentEnd, - `agent_end event should arrive via SSE after UI response (got types: ${phase2Types.join(", ")})`, - ); - - const turnEnd = phase2NonStatus.find((e) => e.type === "turn_end"); - assert.ok( - turnEnd, - `turn_end event should arrive via SSE after UI response (got types: ${phase2Types.join(", ")})`, - ); - - // Verify agent_end precedes turn_end - const agentEndIdx = phase2NonStatus.indexOf(agentEnd); - const turnEndIdx = phase2NonStatus.indexOf(turnEnd); - assert.ok(agentEndIdx < turnEndIdx, "agent_end should precede turn_end"); - - // ----------------------------------------------------------------------- - // Summary assertion: the complete assembled pipeline is proven - // ----------------------------------------------------------------------- - const allEventTypes = [ - ...nonStatusEvents.map((e) => e.type), - ...phase2NonStatus.map((e) => e.type), - ]; - const requiredTypes = [ - "message_update", - "tool_execution_start", - "tool_execution_end", - "extension_ui_request", - "agent_end", - "turn_end", - ]; - for (const required of requiredTypes) { - assert.ok( - allEventTypes.includes(required), - `complete pipeline must include ${required} (got: ${allEventTypes.join(", ")})`, - ); - } - } finally { + t.after(async () => { onboarding.resetOnboardingServiceForTests(); await bridge.resetBridgeServiceForTests(); fixture.cleanup(); + }); + + // ----------------------------------------------------------------------- + // Stage 1: Boot — verify bridge ready, onboarding locked + // ----------------------------------------------------------------------- + const bootResponse = await bootRoute.GET(); + assert.equal(bootResponse.status, 200, "boot endpoint should respond 200"); + const bootPayload = (await bootResponse.json()) as any; + assert.equal(bootPayload.bridge.phase, "ready", "bridge should be ready after boot"); + assert.equal(bootPayload.onboarding.locked, true, "onboarding should be locked before setup"); + assert.equal(bootPayload.onboarding.lockReason, "required_setup", "lock reason should be required_setup"); + assert.equal(spawnCount, 1, "bridge should have spawned once during boot"); + + // Verify prompt is blocked while locked + const blockedPrompt = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "prompt", message: "should be rejected" }), + }), + ); + assert.equal(blockedPrompt.status, 423, "prompt should be locked (423) before onboarding"); + + // ----------------------------------------------------------------------- + // Stage 2: Onboard — save API key, unlock workspace + // ----------------------------------------------------------------------- + const onboardResponse = await onboardingRoute.POST( + new Request("http://localhost/api/onboarding", { + method: "POST", + body: JSON.stringify({ + action: "save_api_key", + providerId: "openai", + apiKey: "sk-assembled-test-key", + }), + }), + ); + assert.equal(onboardResponse.status, 200, "onboarding save_api_key should succeed"); + const onboardPayload = (await onboardResponse.json()) as any; + assert.equal(onboardPayload.onboarding.locked, false, "onboarding should be unlocked after setup"); + assert.equal(onboardPayload.onboarding.lockReason, null, "lock reason should be null after setup"); + assert.equal(onboardPayload.onboarding.bridgeAuthRefresh.phase, "succeeded", "bridge auth refresh should succeed"); + assert.equal(spawnCount, 2, "bridge should have been restarted (spawned again) during auth refresh"); + + // ----------------------------------------------------------------------- + // Stage 3: Subscribe SSE + send prompt + // ----------------------------------------------------------------------- + const sseResponse = await eventsRoute.GET( + new Request("http://localhost/api/session/events", { signal: AbortSignal.timeout(10_000) }), + ); + assert.equal(sseResponse.status, 200, "SSE endpoint should respond 200"); + assert.equal( + sseResponse.headers.get("content-type"), + "text/event-stream; charset=utf-8", + "SSE should have correct content type", + ); + + // Start reading SSE events in background (reads until count or timeout) + const phase1EventsPromise = readSseEvents(sseResponse, 15, 3_000); + + // Send the prompt — triggers fake child's streaming event sequence + const promptResponse = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "prompt", message: "deploy the application" }), + }), + ); + assert.equal(promptResponse.status, 200, "prompt should succeed after onboarding"); + const promptPayload = (await promptResponse.json()) as any; + assert.equal(promptPayload.success, true, "prompt RPC response should indicate success"); + assert.equal(promptPayload.command, "prompt", "prompt RPC response should echo command type"); + + // Collect Phase 1 SSE events + const phase1Events = await phase1EventsPromise; + await waitForMicrotasks(); + + // ----------------------------------------------------------------------- + // Stage 4: Verify streaming events arrived via SSE + // ----------------------------------------------------------------------- + const nonStatusEvents = phase1Events.filter((e) => e.type !== "bridge_status"); + const eventTypes = nonStatusEvents.map((e) => e.type); + + const messageUpdate = nonStatusEvents.find((e) => e.type === "message_update"); + assert.ok( + messageUpdate, + `message_update event should arrive via SSE (got types: ${eventTypes.join(", ")})`, + ); + assert.equal( + messageUpdate.assistantMessageEvent.type, + "text_delta", + "message_update should contain a text_delta", + ); + assert.equal( + messageUpdate.assistantMessageEvent.delta, + "Deploying to production...", + "text_delta should carry the expected content", + ); + + const toolStart = nonStatusEvents.find((e) => e.type === "tool_execution_start"); + assert.ok( + toolStart, + `tool_execution_start event should arrive via SSE (got types: ${eventTypes.join(", ")})`, + ); + assert.equal(toolStart.toolCallId, "tc-deploy-1", "tool start should have correct toolCallId"); + assert.equal(toolStart.toolName, "bash", "tool start should identify the tool name"); + + const toolEnd = nonStatusEvents.find((e) => e.type === "tool_execution_end"); + assert.ok( + toolEnd, + `tool_execution_end event should arrive via SSE (got types: ${eventTypes.join(", ")})`, + ); + assert.equal(toolEnd.toolCallId, "tc-deploy-1", "tool end should match the tool start"); + assert.equal(toolEnd.isError, false, "tool execution should not be an error"); + + const uiRequest = nonStatusEvents.find((e) => e.type === "extension_ui_request"); + assert.ok( + uiRequest, + `extension_ui_request event should arrive via SSE (got types: ${eventTypes.join(", ")})`, + ); + assert.equal(uiRequest.id, "ui-confirm-deploy", "UI request should have the expected id"); + assert.equal(uiRequest.method, "confirm", "UI request should be a confirm dialog"); + assert.equal(uiRequest.title, "Confirm deployment", "UI request should have the expected title"); + assert.equal( + uiRequest.message, + "Proceed with deploying to production?", + "UI request should have the expected message", + ); + + // Verify correct event ordering: message_update → tool_start → tool_end → ui_request + const msgIdx = nonStatusEvents.indexOf(messageUpdate); + const toolStartIdx = nonStatusEvents.indexOf(toolStart); + const toolEndIdx = nonStatusEvents.indexOf(toolEnd); + const uiReqIdx = nonStatusEvents.indexOf(uiRequest); + assert.ok(msgIdx < toolStartIdx, "message_update should precede tool_execution_start"); + assert.ok(toolStartIdx < toolEndIdx, "tool_execution_start should precede tool_execution_end"); + assert.ok(toolEndIdx < uiReqIdx, "tool_execution_end should precede extension_ui_request"); + + // Verify bridge_status events were also delivered (proves SSE fanout is working) + const statusEvents = phase1Events.filter((e) => e.type === "bridge_status"); + assert.ok(statusEvents.length >= 1, "at least one bridge_status event should arrive via SSE"); + + // ----------------------------------------------------------------------- + // Stage 5: Respond to UI request — prove the round-trip + // ----------------------------------------------------------------------- + const sseResponse2 = await eventsRoute.GET( + new Request("http://localhost/api/session/events", { signal: AbortSignal.timeout(10_000) }), + ); + + // Start reading Phase 2 events in background + const phase2EventsPromise = readSseEvents(sseResponse2, 10, 3_000); + + // Send the UI response + const uiResponseResult = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ + type: "extension_ui_response", + id: "ui-confirm-deploy", + value: true, + }), + }), + ); + assert.equal(uiResponseResult.status, 202, "extension_ui_response should return 202 (fire-and-forget)"); + + // Wait for microtasks to let the stdin write propagate + await waitForMicrotasks(); + + // Verify the UI response reached the fake child's stdin (round-trip proof) + assert.ok(receivedUiResponse, "UI response should have reached the fake child via bridge stdin"); + assert.equal(receivedUiResponse.id, "ui-confirm-deploy", "UI response id should match the request"); + assert.equal(receivedUiResponse.value, true, "UI response value should be delivered intact"); + + // Collect Phase 2 SSE events (agent_end + turn_end) + const phase2Events = await phase2EventsPromise; + await waitForMicrotasks(); + + // ----------------------------------------------------------------------- + // Stage 6: Verify turn boundary events + // ----------------------------------------------------------------------- + const phase2NonStatus = phase2Events.filter((e) => e.type !== "bridge_status"); + const phase2Types = phase2NonStatus.map((e) => e.type); + + const agentEnd = phase2NonStatus.find((e) => e.type === "agent_end"); + assert.ok( + agentEnd, + `agent_end event should arrive via SSE after UI response (got types: ${phase2Types.join(", ")})`, + ); + + const turnEnd = phase2NonStatus.find((e) => e.type === "turn_end"); + assert.ok( + turnEnd, + `turn_end event should arrive via SSE after UI response (got types: ${phase2Types.join(", ")})`, + ); + + // Verify agent_end precedes turn_end + const agentEndIdx = phase2NonStatus.indexOf(agentEnd); + const turnEndIdx = phase2NonStatus.indexOf(turnEnd); + assert.ok(agentEndIdx < turnEndIdx, "agent_end should precede turn_end"); + + // ----------------------------------------------------------------------- + // Summary assertion: the complete assembled pipeline is proven + // ----------------------------------------------------------------------- + const allEventTypes = [ + ...nonStatusEvents.map((e) => e.type), + ...phase2NonStatus.map((e) => e.type), + ]; + const requiredTypes = [ + "message_update", + "tool_execution_start", + "tool_execution_end", + "extension_ui_request", + "agent_end", + "turn_end", + ]; + for (const required of requiredTypes) { + assert.ok( + allEventTypes.includes(required), + `complete pipeline must include ${required} (got: ${allEventTypes.join(", ")})`, + ); } }); -test("assembled settings controls keep retry visibility and daily-use mutations authoritative", async () => { +test("assembled settings controls keep retry visibility and daily-use mutations authoritative", async (t) => { const fixture = makeWorkspaceFixture(); const sessionPath = createSessionFile(fixture.projectCwd, fixture.sessionsDir, "sess-settings", "Settings Session"); const bridgeCommands: any[] = []; @@ -696,90 +696,90 @@ test("assembled settings controls keep retry visibility and daily-use mutations } as any), }); - try { - const bootResponse = await bootRoute.GET(); - assert.equal(bootResponse.status, 200); - const bootPayload = (await bootResponse.json()) as any; - assert.equal(bootPayload.bridge.sessionState.autoRetryEnabled, false); - assert.equal(bootPayload.bridge.sessionState.retryInProgress, true); - assert.equal(bootPayload.bridge.sessionState.retryAttempt, 2); - - const steeringResponse = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "set_steering_mode", mode: "one-at-a-time" }), - }), - ); - assert.equal(steeringResponse.status, 200); - const steeringBody = (await steeringResponse.json()) as any; - assert.equal(steeringBody.success, true); - - const followUpResponse = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "set_follow_up_mode", mode: "one-at-a-time" }), - }), - ); - assert.equal(followUpResponse.status, 502); - const followUpBody = (await followUpResponse.json()) as any; - assert.equal(followUpBody.success, false); - assert.match(followUpBody.error, /follow-up mode rejected/i); - - const autoCompactionResponse = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "set_auto_compaction", enabled: true }), - }), - ); - assert.equal(autoCompactionResponse.status, 200); - const autoCompactionBody = (await autoCompactionResponse.json()) as any; - assert.equal(autoCompactionBody.success, true); - - const autoRetryResponse = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "set_auto_retry", enabled: true }), - }), - ); - assert.equal(autoRetryResponse.status, 200); - const autoRetryBody = (await autoRetryResponse.json()) as any; - assert.equal(autoRetryBody.success, true); - - const abortRetryResponse = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "abort_retry" }), - }), - ); - assert.equal(abortRetryResponse.status, 200); - const abortRetryBody = (await abortRetryResponse.json()) as any; - assert.equal(abortRetryBody.success, true); - - await waitForMicrotasks(); - - const refreshedBootResponse = await bootRoute.GET(); - assert.equal(refreshedBootResponse.status, 200); - const refreshedBootPayload = (await refreshedBootResponse.json()) as any; - assert.equal(refreshedBootPayload.bridge.sessionState.steeringMode, "one-at-a-time"); - assert.equal(refreshedBootPayload.bridge.sessionState.followUpMode, "all"); - assert.equal(refreshedBootPayload.bridge.sessionState.autoCompactionEnabled, true); - assert.equal(refreshedBootPayload.bridge.sessionState.autoRetryEnabled, true); - assert.equal(refreshedBootPayload.bridge.sessionState.retryInProgress, false); - assert.equal(refreshedBootPayload.bridge.sessionState.retryAttempt, 0); - - assert.deepEqual( - bridgeCommands.filter((entry) => entry.type !== "get_state").map((entry) => entry.type), - ["set_steering_mode", "set_follow_up_mode", "set_auto_compaction", "set_auto_retry", "abort_retry"], - "settings parity must route through the live bridge instead of browser-local toggles", - ); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); onboarding.resetOnboardingServiceForTests(); fixture.cleanup(); - } + }); + + const bootResponse = await bootRoute.GET(); + assert.equal(bootResponse.status, 200); + const bootPayload = (await bootResponse.json()) as any; + assert.equal(bootPayload.bridge.sessionState.autoRetryEnabled, false); + assert.equal(bootPayload.bridge.sessionState.retryInProgress, true); + assert.equal(bootPayload.bridge.sessionState.retryAttempt, 2); + + const steeringResponse = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "set_steering_mode", mode: "one-at-a-time" }), + }), + ); + assert.equal(steeringResponse.status, 200); + const steeringBody = (await steeringResponse.json()) as any; + assert.equal(steeringBody.success, true); + + const followUpResponse = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "set_follow_up_mode", mode: "one-at-a-time" }), + }), + ); + assert.equal(followUpResponse.status, 502); + const followUpBody = (await followUpResponse.json()) as any; + assert.equal(followUpBody.success, false); + assert.match(followUpBody.error, /follow-up mode rejected/i); + + const autoCompactionResponse = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "set_auto_compaction", enabled: true }), + }), + ); + assert.equal(autoCompactionResponse.status, 200); + const autoCompactionBody = (await autoCompactionResponse.json()) as any; + assert.equal(autoCompactionBody.success, true); + + const autoRetryResponse = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "set_auto_retry", enabled: true }), + }), + ); + assert.equal(autoRetryResponse.status, 200); + const autoRetryBody = (await autoRetryResponse.json()) as any; + assert.equal(autoRetryBody.success, true); + + const abortRetryResponse = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "abort_retry" }), + }), + ); + assert.equal(abortRetryResponse.status, 200); + const abortRetryBody = (await abortRetryResponse.json()) as any; + assert.equal(abortRetryBody.success, true); + + await waitForMicrotasks(); + + const refreshedBootResponse = await bootRoute.GET(); + assert.equal(refreshedBootResponse.status, 200); + const refreshedBootPayload = (await refreshedBootResponse.json()) as any; + assert.equal(refreshedBootPayload.bridge.sessionState.steeringMode, "one-at-a-time"); + assert.equal(refreshedBootPayload.bridge.sessionState.followUpMode, "all"); + assert.equal(refreshedBootPayload.bridge.sessionState.autoCompactionEnabled, true); + assert.equal(refreshedBootPayload.bridge.sessionState.autoRetryEnabled, true); + assert.equal(refreshedBootPayload.bridge.sessionState.retryInProgress, false); + assert.equal(refreshedBootPayload.bridge.sessionState.retryAttempt, 0); + + assert.deepEqual( + bridgeCommands.filter((entry) => entry.type !== "get_state").map((entry) => entry.type), + ["set_steering_mode", "set_follow_up_mode", "set_auto_compaction", "set_auto_retry", "abort_retry"], + "settings parity must route through the live bridge instead of browser-local toggles", + ); }); -test("assembled recovery route exposes actionable browser diagnostics without raw transcript leakage", async () => { +test("assembled recovery route exposes actionable browser diagnostics without raw transcript leakage", async (t) => { const fixture = makeWorkspaceFixture(); const sessionPath = createSessionFile(fixture.projectCwd, fixture.sessionsDir, "sess-recovery", "Recovery Session"); @@ -873,27 +873,27 @@ test("assembled recovery route exposes actionable browser diagnostics without ra }), }); - try { - const response = await recoveryRoute.GET(); - assert.equal(response.status, 200); - const payload = (await response.json()) as any; - - assert.equal(payload.status, "ready"); - assert.equal(payload.bridge.retry.inProgress, true); - assert.equal(payload.bridge.retry.attempt, 2); - assert.equal(payload.bridge.authRefresh.phase, "failed"); - assert.ok(payload.actions.browser.some((action: { id: string }) => action.id === "refresh_diagnostics")); - assert.ok(payload.actions.browser.some((action: { id: string }) => action.id === "open_retry_controls")); - assert.ok(payload.actions.browser.some((action: { id: string }) => action.id === "open_auth_controls")); - assert.equal(payload.interruptedRun.detected, true); - assert.doesNotMatch(JSON.stringify(payload), /sk-assembled-recovery-secret-0001|sk-assembled-auth-secret-0002/); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); fixture.cleanup(); - } + }); + + const response = await recoveryRoute.GET(); + assert.equal(response.status, 200); + const payload = (await response.json()) as any; + + assert.equal(payload.status, "ready"); + assert.equal(payload.bridge.retry.inProgress, true); + assert.equal(payload.bridge.retry.attempt, 2); + assert.equal(payload.bridge.authRefresh.phase, "failed"); + assert.ok(payload.actions.browser.some((action: { id: string }) => action.id === "refresh_diagnostics")); + assert.ok(payload.actions.browser.some((action: { id: string }) => action.id === "open_retry_controls")); + assert.ok(payload.actions.browser.some((action: { id: string }) => action.id === "open_auth_controls")); + assert.equal(payload.interruptedRun.detected, true); + assert.doesNotMatch(JSON.stringify(payload), /sk-assembled-recovery-secret-0001|sk-assembled-auth-secret-0002/); }); -test("assembled slash-command behavior keeps built-ins safe while preserving GSD prompt commands", async () => { +test("assembled slash-command behavior keeps built-ins safe while preserving GSD prompt commands", async (t) => { const fixture = makeWorkspaceFixture(); const sessionPath = createSessionFile(fixture.projectCwd, fixture.sessionsDir, "sess-slash", "Slash Session"); const bridgeCommands: any[] = []; @@ -966,77 +966,77 @@ test("assembled slash-command behavior keeps built-ins safe while preserving GSD } as any), }); - try { - async function submitBrowserInput(input: string): Promise<{ outcome: any; status: number | null; body: any; notice: string | null }> { - const outcome = dispatchBrowserSlashCommand(input); - - if (outcome.kind === "prompt" || outcome.kind === "rpc") { - const response = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify(outcome.command), - }), - ); - return { - outcome, - status: response.status, - body: await response.json(), - notice: null, - }; - } - - const notice = getBrowserSlashCommandTerminalNotice(outcome)?.message ?? null; - return { - outcome, - status: null, - body: null, - notice, - }; - } - - const builtInExecution = await submitBrowserInput("/new"); - assert.equal(builtInExecution.outcome.kind, "rpc"); - assert.equal(builtInExecution.status, 200); - assert.equal(builtInExecution.body.command, "new_session"); - - const builtInSurface = await submitBrowserInput("/model"); - assert.equal(builtInSurface.outcome.kind, "surface"); - assert.equal(builtInSurface.outcome.surface, "model"); - assert.equal(builtInSurface.status, null); - - const builtInNameSurface = await submitBrowserInput("/name Ship It"); - assert.equal(builtInNameSurface.outcome.kind, "surface"); - assert.equal(builtInNameSurface.outcome.surface, "name"); - assert.equal(builtInNameSurface.status, null); - - const builtInReject = await submitBrowserInput("/share"); - assert.equal(builtInReject.outcome.kind, "reject"); - assert.match(builtInReject.notice ?? "", /blocked instead of falling through to the model/i); - assert.equal(builtInReject.status, null); - - // /gsd status is now a browser surface (S02), verify that - const gsdSurface = await submitBrowserInput("/gsd status"); - assert.equal(gsdSurface.outcome.kind, "surface"); - assert.equal(gsdSurface.outcome.surface, "gsd-status"); - assert.equal(gsdSurface.status, null); - - // /gsd auto is a passthrough subcommand — reaches the bridge as a prompt - const gsdPrompt = await submitBrowserInput("/gsd auto"); - assert.equal(gsdPrompt.outcome.kind, "prompt"); - assert.equal(gsdPrompt.status, 200); - assert.equal(gsdPrompt.body.command, "prompt"); - - const sentTypes = bridgeCommands.map((command) => command.type); - assert.deepEqual( - sentTypes.filter((type) => type !== "get_state"), - ["new_session", "prompt"], - "only browser-executable slash commands should reach the live bridge; built-in surfaces/rejects must stay out of prompt text", - ); - const promptCommand = bridgeCommands.find((command) => command.type === "prompt"); - assert.equal(promptCommand?.message, "/gsd auto", "GSD passthrough commands must stay on the extension prompt path"); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); onboarding.resetOnboardingServiceForTests(); fixture.cleanup(); + }); + + async function submitBrowserInput(input: string): Promise<{ outcome: any; status: number | null; body: any; notice: string | null }> { + const outcome = dispatchBrowserSlashCommand(input); + + if (outcome.kind === "prompt" || outcome.kind === "rpc") { + const response = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify(outcome.command), + }), + ); + return { + outcome, + status: response.status, + body: await response.json(), + notice: null, + }; + } + + const notice = getBrowserSlashCommandTerminalNotice(outcome)?.message ?? null; + return { + outcome, + status: null, + body: null, + notice, + }; } + + const builtInExecution = await submitBrowserInput("/new"); + assert.equal(builtInExecution.outcome.kind, "rpc"); + assert.equal(builtInExecution.status, 200); + assert.equal(builtInExecution.body.command, "new_session"); + + const builtInSurface = await submitBrowserInput("/model"); + assert.equal(builtInSurface.outcome.kind, "surface"); + assert.equal(builtInSurface.outcome.surface, "model"); + assert.equal(builtInSurface.status, null); + + const builtInNameSurface = await submitBrowserInput("/name Ship It"); + assert.equal(builtInNameSurface.outcome.kind, "surface"); + assert.equal(builtInNameSurface.outcome.surface, "name"); + assert.equal(builtInNameSurface.status, null); + + const builtInReject = await submitBrowserInput("/share"); + assert.equal(builtInReject.outcome.kind, "reject"); + assert.match(builtInReject.notice ?? "", /blocked instead of falling through to the model/i); + assert.equal(builtInReject.status, null); + + // /gsd status is now a browser surface (S02), verify that + const gsdSurface = await submitBrowserInput("/gsd status"); + assert.equal(gsdSurface.outcome.kind, "surface"); + assert.equal(gsdSurface.outcome.surface, "gsd-status"); + assert.equal(gsdSurface.status, null); + + // /gsd auto is a passthrough subcommand — reaches the bridge as a prompt + const gsdPrompt = await submitBrowserInput("/gsd auto"); + assert.equal(gsdPrompt.outcome.kind, "prompt"); + assert.equal(gsdPrompt.status, 200); + assert.equal(gsdPrompt.body.command, "prompt"); + + const sentTypes = bridgeCommands.map((command) => command.type); + assert.deepEqual( + sentTypes.filter((type) => type !== "get_state"), + ["new_session", "prompt"], + "only browser-executable slash commands should reach the live bridge; built-in surfaces/rejects must stay out of prompt text", + ); + const promptCommand = bridgeCommands.find((command) => command.type === "prompt"); + assert.equal(promptCommand?.message, "/gsd auto", "GSD passthrough commands must stay on the extension prompt path"); }); diff --git a/src/tests/integration/web-mode-onboarding.test.ts b/src/tests/integration/web-mode-onboarding.test.ts index 58370a925..a3c9943a9 100644 --- a/src/tests/integration/web-mode-onboarding.test.ts +++ b/src/tests/integration/web-mode-onboarding.test.ts @@ -295,7 +295,7 @@ function configureBridgeRuntime( } -test("successful browser onboarding restarts the stale bridge child and unlocks the first prompt", async () => { +test("successful browser onboarding restarts the stale bridge child and unlocks the first prompt", async (t) => { const fixture = makeWorkspaceFixture(); const authStorage = AuthStorage.inMemory({}); const harness = configureBridgeRuntime(fixture, authStorage); @@ -304,65 +304,65 @@ test("successful browser onboarding restarts the stale bridge child and unlocks validateApiKey: async () => ({ ok: true, message: "openai credentials validated" }), }); - try { - const bootResponse = await bootRoute.GET(); - assert.equal(bootResponse.status, 200); - const bootPayload = (await bootResponse.json()) as any; - assert.equal(bootPayload.onboarding.locked, true); - assert.equal(bootPayload.onboarding.lockReason, "required_setup"); - assert.equal(harness.spawnCalls, 1); - assert.equal(harness.generations[0]?.authVisibleAtStart, false); - - const blockedPrompt = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "prompt", message: "should stay locked" }), - }), - ); - assert.equal(blockedPrompt.status, 423); - const blockedPayload = (await blockedPrompt.json()) as any; - assert.equal(blockedPayload.code, "onboarding_locked"); - assert.equal(blockedPayload.details.reason, "required_setup"); - assert.equal(harness.promptCount, 0); - - const validationResponse = await onboardingRoute.POST( - new Request("http://localhost/api/onboarding", { - method: "POST", - body: JSON.stringify({ - action: "save_api_key", - providerId: "openai", - apiKey: "sk-valid-123456", - }), - }), - ); - assert.equal(validationResponse.status, 200); - const validationPayload = (await validationResponse.json()) as any; - assert.equal(validationPayload.onboarding.locked, false); - assert.equal(validationPayload.onboarding.lockReason, null); - assert.equal(validationPayload.onboarding.bridgeAuthRefresh.phase, "succeeded"); - assert.equal(harness.spawnCalls, 2); - assert.equal(harness.generations[1]?.authVisibleAtStart, true); - - const firstPrompt = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "prompt", message: "first unlocked prompt" }), - }), - ); - assert.equal(firstPrompt.status, 200); - const firstPromptPayload = (await firstPrompt.json()) as any; - assert.equal(firstPromptPayload.success, true); - assert.equal(firstPromptPayload.command, "prompt"); - assert.equal(harness.promptCount, 1); - assert.deepEqual(harness.generations[1]?.promptMessages, ["first unlocked prompt"]); - } finally { + t.after(async () => { onboarding.resetOnboardingServiceForTests(); await bridge.resetBridgeServiceForTests(); fixture.cleanup(); - } + }); + + const bootResponse = await bootRoute.GET(); + assert.equal(bootResponse.status, 200); + const bootPayload = (await bootResponse.json()) as any; + assert.equal(bootPayload.onboarding.locked, true); + assert.equal(bootPayload.onboarding.lockReason, "required_setup"); + assert.equal(harness.spawnCalls, 1); + assert.equal(harness.generations[0]?.authVisibleAtStart, false); + + const blockedPrompt = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "prompt", message: "should stay locked" }), + }), + ); + assert.equal(blockedPrompt.status, 423); + const blockedPayload = (await blockedPrompt.json()) as any; + assert.equal(blockedPayload.code, "onboarding_locked"); + assert.equal(blockedPayload.details.reason, "required_setup"); + assert.equal(harness.promptCount, 0); + + const validationResponse = await onboardingRoute.POST( + new Request("http://localhost/api/onboarding", { + method: "POST", + body: JSON.stringify({ + action: "save_api_key", + providerId: "openai", + apiKey: "sk-valid-123456", + }), + }), + ); + assert.equal(validationResponse.status, 200); + const validationPayload = (await validationResponse.json()) as any; + assert.equal(validationPayload.onboarding.locked, false); + assert.equal(validationPayload.onboarding.lockReason, null); + assert.equal(validationPayload.onboarding.bridgeAuthRefresh.phase, "succeeded"); + assert.equal(harness.spawnCalls, 2); + assert.equal(harness.generations[1]?.authVisibleAtStart, true); + + const firstPrompt = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "prompt", message: "first unlocked prompt" }), + }), + ); + assert.equal(firstPrompt.status, 200); + const firstPromptPayload = (await firstPrompt.json()) as any; + assert.equal(firstPromptPayload.success, true); + assert.equal(firstPromptPayload.command, "prompt"); + assert.equal(harness.promptCount, 1); + assert.deepEqual(harness.generations[1]?.promptMessages, ["first unlocked prompt"]); }); -test("refresh failures keep the workspace locked and expose the failed bridge-refresh reason", async () => { +test("refresh failures keep the workspace locked and expose the failed bridge-refresh reason", async (t) => { const fixture = makeWorkspaceFixture(); const authStorage = AuthStorage.inMemory({}); const harness = configureBridgeRuntime(fixture, authStorage, { failRestart: true }); @@ -371,56 +371,56 @@ test("refresh failures keep the workspace locked and expose the failed bridge-re validateApiKey: async () => ({ ok: true, message: "openai credentials validated" }), }); - try { - const bootResponse = await bootRoute.GET(); - assert.equal(bootResponse.status, 200); - assert.equal(harness.spawnCalls, 1); - - const validationResponse = await onboardingRoute.POST( - new Request("http://localhost/api/onboarding", { - method: "POST", - body: JSON.stringify({ - action: "save_api_key", - providerId: "openai", - apiKey: "sk-valid-123456", - }), - }), - ); - assert.equal(validationResponse.status, 503); - const validationPayload = (await validationResponse.json()) as any; - assert.equal(validationPayload.onboarding.required.satisfied, true); - assert.equal(validationPayload.onboarding.locked, true); - assert.equal(validationPayload.onboarding.lockReason, "bridge_refresh_failed"); - assert.equal(validationPayload.onboarding.lastValidation.status, "succeeded"); - assert.equal(validationPayload.onboarding.bridgeAuthRefresh.phase, "failed"); - assert.match(validationPayload.onboarding.bridgeAuthRefresh.error, /could not attach/i); - assert.equal(harness.spawnCalls, 2); - assert.equal(harness.generations[1]?.authVisibleAtStart, true); - - const blockedPrompt = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "prompt", message: "still locked after failed refresh" }), - }), - ); - assert.equal(blockedPrompt.status, 423); - const blockedPayload = (await blockedPrompt.json()) as any; - assert.equal(blockedPayload.code, "onboarding_locked"); - assert.equal(blockedPayload.details.reason, "bridge_refresh_failed"); - assert.equal(harness.promptCount, 0); - - const failedBootResponse = await bootRoute.GET(); - assert.equal(failedBootResponse.status, 200); - const failedBootPayload = (await failedBootResponse.json()) as any; - assert.equal(failedBootPayload.onboarding.locked, true); - assert.equal(failedBootPayload.onboarding.lockReason, "bridge_refresh_failed"); - assert.equal(failedBootPayload.onboarding.bridgeAuthRefresh.phase, "failed"); - assert.match(failedBootPayload.onboarding.bridgeAuthRefresh.error, /could not attach/i); - } finally { + t.after(async () => { onboarding.resetOnboardingServiceForTests(); await bridge.resetBridgeServiceForTests(); fixture.cleanup(); - } + }); + + const bootResponse = await bootRoute.GET(); + assert.equal(bootResponse.status, 200); + assert.equal(harness.spawnCalls, 1); + + const validationResponse = await onboardingRoute.POST( + new Request("http://localhost/api/onboarding", { + method: "POST", + body: JSON.stringify({ + action: "save_api_key", + providerId: "openai", + apiKey: "sk-valid-123456", + }), + }), + ); + assert.equal(validationResponse.status, 503); + const validationPayload = (await validationResponse.json()) as any; + assert.equal(validationPayload.onboarding.required.satisfied, true); + assert.equal(validationPayload.onboarding.locked, true); + assert.equal(validationPayload.onboarding.lockReason, "bridge_refresh_failed"); + assert.equal(validationPayload.onboarding.lastValidation.status, "succeeded"); + assert.equal(validationPayload.onboarding.bridgeAuthRefresh.phase, "failed"); + assert.match(validationPayload.onboarding.bridgeAuthRefresh.error, /could not attach/i); + assert.equal(harness.spawnCalls, 2); + assert.equal(harness.generations[1]?.authVisibleAtStart, true); + + const blockedPrompt = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "prompt", message: "still locked after failed refresh" }), + }), + ); + assert.equal(blockedPrompt.status, 423); + const blockedPayload = (await blockedPrompt.json()) as any; + assert.equal(blockedPayload.code, "onboarding_locked"); + assert.equal(blockedPayload.details.reason, "bridge_refresh_failed"); + assert.equal(harness.promptCount, 0); + + const failedBootResponse = await bootRoute.GET(); + assert.equal(failedBootResponse.status, 200); + const failedBootPayload = (await failedBootResponse.json()) as any; + assert.equal(failedBootPayload.onboarding.locked, true); + assert.equal(failedBootPayload.onboarding.lockReason, "bridge_refresh_failed"); + assert.equal(failedBootPayload.onboarding.bridgeAuthRefresh.phase, "failed"); + assert.match(failedBootPayload.onboarding.bridgeAuthRefresh.error, /could not attach/i); }); test("fresh gsd --web browser onboarding stays locked on failed validation and unlocks after a successful retry", async (t) => { @@ -434,76 +434,76 @@ test("fresh gsd --web browser onboarding stays locked on failed validation and u const browserLogPath = join(tempRoot, "browser-open.log") let port: number | null = null - try { - const launch = await launchPackagedWebHost({ - launchCwd: repoRoot, - tempHome, - browserLogPath, - env: { - GSD_WEB_TEST_FAKE_API_KEY_VALIDATION: "1", - ANTHROPIC_API_KEY: "", - OPENAI_API_KEY: "", - GOOGLE_API_KEY: "", - }, - }) - port = launch.port - - assert.equal(launch.exitCode, 0, `expected the web launcher to exit cleanly:\n${launch.stderr}`) - assert.match(launch.stderr, /status=started/, "expected a started diagnostic line on stderr") - - const auth = runtimeAuthHeaders(launch) - await waitForHttpOk(`${launch.url}/api/boot`, undefined, auth) - - // 1. Boot reports locked before any credentials are saved - const bootBefore = await fetch(`${launch.url}/api/boot`, { - method: "GET", - headers: { Accept: "application/json", ...auth }, - signal: AbortSignal.timeout(10_000), - }) - assert.equal(bootBefore.ok, true, `expected boot endpoint to respond successfully: ${bootBefore.status}`) - const bootBeforePayload = await bootBefore.json() as any - assert.equal(bootBeforePayload.onboarding.locked, true) - assert.equal(bootBeforePayload.onboarding.lockReason, "required_setup") - - // 2. Invalid key → stays locked with failed validation - const invalidValidation = await fetch(`${launch.url}/api/onboarding`, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json", ...auth }, - body: JSON.stringify({ action: "save_api_key", providerId: "openai", apiKey: "invalid-demo-key" }), - signal: AbortSignal.timeout(10_000), - }) - assert.equal(invalidValidation.status, 422) - const invalidPayload = await invalidValidation.json() as any - assert.equal(invalidPayload.onboarding.locked, true) - assert.equal(invalidPayload.onboarding.lastValidation.status, "failed") - assert.match(invalidPayload.onboarding.lastValidation.message ?? "", /rejected/i) - - // 3. Valid key → unlocks - const validValidation = await fetch(`${launch.url}/api/onboarding`, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json", ...auth }, - body: JSON.stringify({ action: "save_api_key", providerId: "openai", apiKey: "valid-demo-key" }), - signal: AbortSignal.timeout(60_000), - }) - assert.equal(validValidation.status, 200, `expected successful retry to unlock onboarding: ${validValidation.status}`) - const validPayload = await validValidation.json() as any - assert.equal(validPayload.onboarding.locked, false) - assert.equal(validPayload.onboarding.bridgeAuthRefresh.phase, "succeeded") - - // 4. Boot confirms unlocked - const bootAfter = await fetch(`${launch.url}/api/boot`, { - method: "GET", - headers: { Accept: "application/json", ...auth }, - signal: AbortSignal.timeout(10_000), - }) - assert.equal(bootAfter.ok, true) - const bootAfterPayload = await bootAfter.json() as any - assert.equal(bootAfterPayload.onboarding.locked, false) - assert.equal(bootAfterPayload.onboarding.lockReason, null) - } finally { + t.after(async () => { if (port !== null) { - await killProcessOnPort(port) + await killProcessOnPort(port) } rmSync(tempRoot, { recursive: true, force: true }) - } + }); + + const launch = await launchPackagedWebHost({ + launchCwd: repoRoot, + tempHome, + browserLogPath, + env: { + GSD_WEB_TEST_FAKE_API_KEY_VALIDATION: "1", + ANTHROPIC_API_KEY: "", + OPENAI_API_KEY: "", + GOOGLE_API_KEY: "", + }, + }) + port = launch.port + + assert.equal(launch.exitCode, 0, `expected the web launcher to exit cleanly:\n${launch.stderr}`) + assert.match(launch.stderr, /status=started/, "expected a started diagnostic line on stderr") + + const auth = runtimeAuthHeaders(launch) + await waitForHttpOk(`${launch.url}/api/boot`, undefined, auth) + + // 1. Boot reports locked before any credentials are saved + const bootBefore = await fetch(`${launch.url}/api/boot`, { + method: "GET", + headers: { Accept: "application/json", ...auth }, + signal: AbortSignal.timeout(10_000), + }) + assert.equal(bootBefore.ok, true, `expected boot endpoint to respond successfully: ${bootBefore.status}`) + const bootBeforePayload = await bootBefore.json() as any + assert.equal(bootBeforePayload.onboarding.locked, true) + assert.equal(bootBeforePayload.onboarding.lockReason, "required_setup") + + // 2. Invalid key → stays locked with failed validation + const invalidValidation = await fetch(`${launch.url}/api/onboarding`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json", ...auth }, + body: JSON.stringify({ action: "save_api_key", providerId: "openai", apiKey: "invalid-demo-key" }), + signal: AbortSignal.timeout(10_000), + }) + assert.equal(invalidValidation.status, 422) + const invalidPayload = await invalidValidation.json() as any + assert.equal(invalidPayload.onboarding.locked, true) + assert.equal(invalidPayload.onboarding.lastValidation.status, "failed") + assert.match(invalidPayload.onboarding.lastValidation.message ?? "", /rejected/i) + + // 3. Valid key → unlocks + const validValidation = await fetch(`${launch.url}/api/onboarding`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json", ...auth }, + body: JSON.stringify({ action: "save_api_key", providerId: "openai", apiKey: "valid-demo-key" }), + signal: AbortSignal.timeout(60_000), + }) + assert.equal(validValidation.status, 200, `expected successful retry to unlock onboarding: ${validValidation.status}`) + const validPayload = await validValidation.json() as any + assert.equal(validPayload.onboarding.locked, false) + assert.equal(validPayload.onboarding.bridgeAuthRefresh.phase, "succeeded") + + // 4. Boot confirms unlocked + const bootAfter = await fetch(`${launch.url}/api/boot`, { + method: "GET", + headers: { Accept: "application/json", ...auth }, + signal: AbortSignal.timeout(10_000), + }) + assert.equal(bootAfter.ok, true) + const bootAfterPayload = await bootAfter.json() as any + assert.equal(bootAfterPayload.onboarding.locked, false) + assert.equal(bootAfterPayload.onboarding.lockReason, null) }) diff --git a/src/tests/provider.test.ts b/src/tests/provider.test.ts index 85a7b99e8..8631aaf76 100644 --- a/src/tests/provider.test.ts +++ b/src/tests/provider.test.ts @@ -52,20 +52,18 @@ function makeTmpAuth(data: Record = {}): { authPath: string; cl // 1. resolveSearchProvider — 8 scenarios // ═══════════════════════════════════════════════════════════════════════════ -test('resolveSearchProvider returns tavily when only TAVILY_API_KEY is set', async () => { +test('resolveSearchProvider returns tavily when only TAVILY_API_KEY is set', async (t) => { const { resolveSearchProvider } = await import( '../resources/extensions/search-the-web/provider.ts' ) const { authPath, cleanup } = makeTmpAuth() - try { - withEnv({ TAVILY_API_KEY: 'tvly-test', BRAVE_API_KEY: undefined }, () => { - // Override preference read to use our temp auth (auto) - const result = resolveSearchProvider('auto') - assert.equal(result, 'tavily') - }) - } finally { - cleanup() - } + t.after(() => { cleanup() }); + + withEnv({ TAVILY_API_KEY: 'tvly-test', BRAVE_API_KEY: undefined }, () => { + // Override preference read to use our temp auth (auto) + const result = resolveSearchProvider('auto') + assert.equal(result, 'tavily') + }) }) test('resolveSearchProvider returns brave when only BRAVE_API_KEY is set', async () => { @@ -148,69 +146,61 @@ test('resolveSearchProvider falls back to other provider when preferred key miss // 2. Preference get/set round-trip // ═══════════════════════════════════════════════════════════════════════════ -test('getSearchProviderPreference returns auto when no preference stored', async () => { +test('getSearchProviderPreference returns auto when no preference stored', async (t) => { const { getSearchProviderPreference } = await import( '../resources/extensions/search-the-web/provider.ts' ) const { authPath, cleanup } = makeTmpAuth() - try { - const pref = getSearchProviderPreference(authPath) - assert.equal(pref, 'auto') - } finally { - cleanup() - } + t.after(() => { cleanup() }); + + const pref = getSearchProviderPreference(authPath) + assert.equal(pref, 'auto') }) -test('getSearchProviderPreference reads from auth.json via AuthStorage', async () => { +test('getSearchProviderPreference reads from auth.json via AuthStorage', async (t) => { const { getSearchProviderPreference } = await import( '../resources/extensions/search-the-web/provider.ts' ) const { authPath, cleanup } = makeTmpAuth({ search_provider: { type: 'api_key', key: 'tavily' }, }) - try { - const pref = getSearchProviderPreference(authPath) - assert.equal(pref, 'tavily') - } finally { - cleanup() - } + t.after(() => { cleanup() }); + + const pref = getSearchProviderPreference(authPath) + assert.equal(pref, 'tavily') }) -test('setSearchProviderPreference writes to auth.json via AuthStorage', async () => { +test('setSearchProviderPreference writes to auth.json via AuthStorage', async (t) => { const { getSearchProviderPreference, setSearchProviderPreference } = await import( '../resources/extensions/search-the-web/provider.ts' ) const { authPath, cleanup } = makeTmpAuth() - try { - setSearchProviderPreference('brave', authPath) - const pref = getSearchProviderPreference(authPath) - assert.equal(pref, 'brave') + t.after(() => { cleanup() }); - // Round-trip: change to tavily - setSearchProviderPreference('tavily', authPath) - assert.equal(getSearchProviderPreference(authPath), 'tavily') + setSearchProviderPreference('brave', authPath) + const pref = getSearchProviderPreference(authPath) + assert.equal(pref, 'brave') - // Round-trip: change to auto - setSearchProviderPreference('auto', authPath) - assert.equal(getSearchProviderPreference(authPath), 'auto') - } finally { - cleanup() - } + // Round-trip: change to tavily + setSearchProviderPreference('tavily', authPath) + assert.equal(getSearchProviderPreference(authPath), 'tavily') + + // Round-trip: change to auto + setSearchProviderPreference('auto', authPath) + assert.equal(getSearchProviderPreference(authPath), 'auto') }) -test('getSearchProviderPreference returns auto for invalid stored value', async () => { +test('getSearchProviderPreference returns auto for invalid stored value', async (t) => { const { getSearchProviderPreference } = await import( '../resources/extensions/search-the-web/provider.ts' ) const { authPath, cleanup } = makeTmpAuth({ search_provider: { type: 'api_key', key: 'google' }, }) - try { - const pref = getSearchProviderPreference(authPath) - assert.equal(pref, 'auto', 'invalid stored value falls back to auto') - } finally { - cleanup() - } + t.after(() => { cleanup() }); + + const pref = getSearchProviderPreference(authPath) + assert.equal(pref, 'auto', 'invalid stored value falls back to auto') }) // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/tests/resource-loader.test.ts b/src/tests/resource-loader.test.ts index 77437e3ab..12622a1ad 100644 --- a/src/tests/resource-loader.test.ts +++ b/src/tests/resource-loader.test.ts @@ -49,85 +49,81 @@ test("getExtensionKey normalizes top-level .ts and .js entry names to the same k ); }); -test("hasStaleCompiledExtensionSiblings only flags top-level .ts/.js sibling pairs", async () => { +test("hasStaleCompiledExtensionSiblings only flags top-level .ts/.js sibling pairs", async (t) => { const { hasStaleCompiledExtensionSiblings } = await import("../resource-loader.ts"); const tmp = mkdtempSync(join(tmpdir(), "gsd-resource-loader-")); const extensionsDir = join(tmp, "extensions"); - try { - mkdirSync(join(extensionsDir, "gsd"), { recursive: true }); - writeFileSync(join(extensionsDir, "gsd", "index.ts"), "export {};\n"); - assert.equal(hasStaleCompiledExtensionSiblings(extensionsDir), false); + t.after(() => { rmSync(tmp, { recursive: true, force: true }); }); - writeFileSync(join(extensionsDir, "ask-user-questions.js"), "export {};\n"); - assert.equal(hasStaleCompiledExtensionSiblings(extensionsDir), false); + mkdirSync(join(extensionsDir, "gsd"), { recursive: true }); + writeFileSync(join(extensionsDir, "gsd", "index.ts"), "export {};\n"); + assert.equal(hasStaleCompiledExtensionSiblings(extensionsDir), false); - writeFileSync(join(extensionsDir, "ask-user-questions.ts"), "export {};\n"); - assert.equal(hasStaleCompiledExtensionSiblings(extensionsDir), true); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + writeFileSync(join(extensionsDir, "ask-user-questions.js"), "export {};\n"); + assert.equal(hasStaleCompiledExtensionSiblings(extensionsDir), false); + + writeFileSync(join(extensionsDir, "ask-user-questions.ts"), "export {};\n"); + assert.equal(hasStaleCompiledExtensionSiblings(extensionsDir), true); }); -test("buildResourceLoader excludes duplicate top-level pi extensions when bundled resources use .js", async () => { +test("buildResourceLoader excludes duplicate top-level pi extensions when bundled resources use .js", async (t) => { const tmp = mkdtempSync(join(tmpdir(), "gsd-resource-loader-home-")); const piExtensionsDir = join(tmp, ".pi", "agent", "extensions"); const fakeAgentDir = join(tmp, ".gsd", "agent"); const restoreHomeEnv = overrideHomeEnv(tmp); - try { - mkdirSync(piExtensionsDir, { recursive: true }); - writeFileSync(join(piExtensionsDir, "ask-user-questions.ts"), "export {};\n"); - writeFileSync(join(piExtensionsDir, "custom-extension.ts"), "export {};\n"); - - const { buildResourceLoader } = await import("../resource-loader.ts"); - const loader = buildResourceLoader(fakeAgentDir) as { additionalExtensionPaths?: string[] }; - const additionalExtensionPaths = loader.additionalExtensionPaths ?? []; - - assert.equal( - additionalExtensionPaths.some((entryPath) => entryPath.endsWith("ask-user-questions.ts")), - false, - "bundled compiled extensions should suppress duplicate pi top-level .ts siblings", - ); - assert.equal( - additionalExtensionPaths.some((entryPath) => entryPath.endsWith("custom-extension.ts")), - true, - "non-duplicate pi extensions should still load", - ); - } finally { + t.after(() => { restoreHomeEnv(); rmSync(tmp, { recursive: true, force: true }); - } + }); + + mkdirSync(piExtensionsDir, { recursive: true }); + writeFileSync(join(piExtensionsDir, "ask-user-questions.ts"), "export {};\n"); + writeFileSync(join(piExtensionsDir, "custom-extension.ts"), "export {};\n"); + + const { buildResourceLoader } = await import("../resource-loader.ts"); + const loader = buildResourceLoader(fakeAgentDir) as { additionalExtensionPaths?: string[] }; + const additionalExtensionPaths = loader.additionalExtensionPaths ?? []; + + assert.equal( + additionalExtensionPaths.some((entryPath) => entryPath.endsWith("ask-user-questions.ts")), + false, + "bundled compiled extensions should suppress duplicate pi top-level .ts siblings", + ); + assert.equal( + additionalExtensionPaths.some((entryPath) => entryPath.endsWith("custom-extension.ts")), + true, + "non-duplicate pi extensions should still load", + ); }); -test("initResources prunes stale top-level extension siblings next to bundled compiled extensions", async () => { +test("initResources prunes stale top-level extension siblings next to bundled compiled extensions", async (t) => { const { initResources } = await import("../resource-loader.ts"); const tmp = mkdtempSync(join(tmpdir(), "gsd-resource-loader-sync-")); const fakeAgentDir = join(tmp, "agent"); const bundledTsPath = join(fakeAgentDir, "extensions", "ask-user-questions.ts"); const bundledJsPath = join(fakeAgentDir, "extensions", "ask-user-questions.js"); - try { - initResources(fakeAgentDir); + t.after(() => { rmSync(tmp, { recursive: true, force: true }); }); - const bundledPath = existsSync(bundledJsPath) - ? bundledJsPath - : bundledTsPath; - const staleSiblingPath = bundledPath.endsWith(".js") - ? bundledTsPath - : bundledJsPath; + initResources(fakeAgentDir); - assert.equal(existsSync(bundledPath), true, "bundled top-level extension should exist"); + const bundledPath = existsSync(bundledJsPath) + ? bundledJsPath + : bundledTsPath; + const staleSiblingPath = bundledPath.endsWith(".js") + ? bundledTsPath + : bundledJsPath; - // Simulate a stale opposite-format sibling left from a previous sync/build mismatch. - writeFileSync(staleSiblingPath, "export {};\n"); - assert.equal(existsSync(staleSiblingPath), true); + assert.equal(existsSync(bundledPath), true, "bundled top-level extension should exist"); - initResources(fakeAgentDir); + // Simulate a stale opposite-format sibling left from a previous sync/build mismatch. + writeFileSync(staleSiblingPath, "export {};\n"); + assert.equal(existsSync(staleSiblingPath), true); - assert.equal(existsSync(staleSiblingPath), false, "stale top-level sibling should be removed during sync"); - assert.equal(existsSync(bundledPath), true, "bundled extension should remain after cleanup"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + initResources(fakeAgentDir); + + assert.equal(existsSync(staleSiblingPath), false, "stale top-level sibling should be removed during sync"); + assert.equal(existsSync(bundledPath), true, "bundled extension should remain after cleanup"); }); diff --git a/src/tests/resource-sync-staleness.test.ts b/src/tests/resource-sync-staleness.test.ts index 9f5b8e67d..56681018d 100644 --- a/src/tests/resource-sync-staleness.test.ts +++ b/src/tests/resource-sync-staleness.test.ts @@ -12,7 +12,7 @@ import { tmpdir } from "node:os"; * with a broken import to persist at ~/.gsd/agent/extensions/). */ -test("resource manifest includes contentHash", async () => { +test("resource manifest includes contentHash", async (t) => { // We can't easily call initResources directly because it depends on // module-level resolved paths. Instead, verify the manifest schema // by simulating what writeManagedResourceManifest produces. @@ -25,15 +25,13 @@ test("resource manifest includes contentHash", async () => { const tmpDir = mkdtempSync(join(tmpdir(), "gsd-resource-test-")); const manifestPath = join(tmpDir, "managed-resources.json"); - try { - writeFileSync(manifestPath, JSON.stringify(manifest)); - const read = JSON.parse(readFileSync(manifestPath, "utf-8")); - assert.equal(read.gsdVersion, "2.28.0"); - assert.equal(read.contentHash, "abc123def456"); - assert.equal(typeof read.syncedAt, "number"); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } + t.after(() => { rmSync(tmpDir, { recursive: true, force: true }); }); + + writeFileSync(manifestPath, JSON.stringify(manifest)); + const read = JSON.parse(readFileSync(manifestPath, "utf-8")); + assert.equal(read.gsdVersion, "2.28.0"); + assert.equal(read.contentHash, "abc123def456"); + assert.equal(typeof read.syncedAt, "number"); }); test("missing contentHash in manifest triggers re-sync (upgrade path)", () => { diff --git a/src/tests/search-loop-guard.test.ts b/src/tests/search-loop-guard.test.ts index 6413bef32..be4c7023a 100644 --- a/src/tests/search-loop-guard.test.ts +++ b/src/tests/search-loop-guard.test.ts @@ -116,83 +116,83 @@ async function callSearch( * state (lastSearchKey, consecutiveDupeCount) starts fresh here. */ -test("search loop guard fires after MAX_CONSECUTIVE_DUPES duplicates", async () => { +test("search loop guard fires after MAX_CONSECUTIVE_DUPES duplicates", async (t) => { process.env.BRAVE_API_KEY = "test-key-loop-guard"; delete process.env.TAVILY_API_KEY; delete process.env.OLLAMA_API_KEY; const restoreFetch = mockFetch(makeBraveResponse()); - try { - const pi = createMockPI(); - registerSearchTool(pi as any); - const tool = pi.getRegisteredTool(); - assert.ok(tool, "search tool should be registered"); - - const execute = tool.execute.bind(tool); - - // Calls 1–3: below threshold, should return search results (not an error) - for (let i = 1; i <= 3; i++) { - const result = await callSearch(execute, "loop test query", `call-${i}`); - assert.notEqual(result.isError, true, `call ${i} should not trigger loop guard`); - } - - // Call 4: hits the threshold — guard fires - const result4 = await callSearch(execute, "loop test query", "call-4"); - assert.equal(result4.isError, true, "call 4 should trigger the loop guard"); - assert.equal(result4.details?.errorKind, "search_loop"); - assert.ok( - result4.content[0].text.includes("Search loop detected"), - "error message should mention search loop" - ); - } finally { + t.after(() => { restoreFetch(); restoreSearchEnv(); + }); + + const pi = createMockPI(); + registerSearchTool(pi as any); + const tool = pi.getRegisteredTool(); + assert.ok(tool, "search tool should be registered"); + + const execute = tool.execute.bind(tool); + + // Calls 1–3: below threshold, should return search results (not an error) + for (let i = 1; i <= 3; i++) { + const result = await callSearch(execute, "loop test query", `call-${i}`); + assert.notEqual(result.isError, true, `call ${i} should not trigger loop guard`); } + + // Call 4: hits the threshold — guard fires + const result4 = await callSearch(execute, "loop test query", "call-4"); + assert.equal(result4.isError, true, "call 4 should trigger the loop guard"); + assert.equal(result4.details?.errorKind, "search_loop"); + assert.ok( + result4.content[0].text.includes("Search loop detected"), + "error message should mention search loop" + ); }); -test("search loop guard resets at session_start boundary", async () => { +test("search loop guard resets at session_start boundary", async (t) => { process.env.BRAVE_API_KEY = "test-key-loop-guard-session"; delete process.env.TAVILY_API_KEY; delete process.env.OLLAMA_API_KEY; const restoreFetch = mockFetch(makeBraveResponse()); const query = "session boundary query"; - try { - const pi = createMockPI(); - const mockCtx = { - hasUI: false, - ui: { notify() {} }, - }; - searchExtension(pi as any); - await pi.fire("session_start", {}, mockCtx); - - const tool = pi.getRegisteredTool(); - assert.ok(tool, "search tool should be registered"); - const execute = tool.execute.bind(tool); - - // Trigger guard in session 1 - for (let i = 1; i <= 4; i++) { - await callSearch(execute, query, `s1-call-${i}`); - } - const guardResult = await callSearch(execute, query, "s1-call-5"); - assert.equal(guardResult.isError, true, "session 1 should be guarded"); - assert.equal(guardResult.details?.errorKind, "search_loop"); - - // New session should clear guard state - await pi.fire("session_start", {}, mockCtx); - const firstCallSession2 = await callSearch(execute, query, "s2-call-1"); - assert.notEqual( - firstCallSession2.isError, - true, - "first identical query in a new session should not be blocked by prior session state", - ); - } finally { + t.after(() => { restoreFetch(); restoreSearchEnv(); + }); + + const pi = createMockPI(); + const mockCtx = { + hasUI: false, + ui: { notify() {} }, + }; + searchExtension(pi as any); + await pi.fire("session_start", {}, mockCtx); + + const tool = pi.getRegisteredTool(); + assert.ok(tool, "search tool should be registered"); + const execute = tool.execute.bind(tool); + + // Trigger guard in session 1 + for (let i = 1; i <= 4; i++) { + await callSearch(execute, query, `s1-call-${i}`); } + const guardResult = await callSearch(execute, query, "s1-call-5"); + assert.equal(guardResult.isError, true, "session 1 should be guarded"); + assert.equal(guardResult.details?.errorKind, "search_loop"); + + // New session should clear guard state + await pi.fire("session_start", {}, mockCtx); + const firstCallSession2 = await callSearch(execute, query, "s2-call-1"); + assert.notEqual( + firstCallSession2.isError, + true, + "first identical query in a new session should not be blocked by prior session state", + ); }); -test("search loop guard stays armed after firing — subsequent duplicates immediately re-trigger (#1671)", async () => { +test("search loop guard stays armed after firing — subsequent duplicates immediately re-trigger (#1671)", async (t) => { process.env.BRAVE_API_KEY = "test-key-loop-guard-2"; delete process.env.TAVILY_API_KEY; delete process.env.OLLAMA_API_KEY; @@ -201,42 +201,42 @@ test("search loop guard stays armed after firing — subsequent duplicates immed // Use a unique query so module-level state from previous test doesn't interfere const query = "persistent loop query"; - try { - const pi = createMockPI(); - registerSearchTool(pi as any); - const tool = pi.getRegisteredTool(); - const execute = tool.execute.bind(tool); - - // Exhaust the initial window (calls 1–3 succeed, call 4 fires guard) - for (let i = 1; i <= 3; i++) { - await callSearch(execute, query, `call-${i}`); - } - const guardFirst = await callSearch(execute, query, "call-4"); - assert.equal(guardFirst.isError, true, "call 4 should trigger the loop guard"); - - // Key regression test: call 5 (and beyond) must ALSO trigger the guard. - // The original bug reset state on trigger, so call 5 was treated as a fresh - // first search and the loop restarted. - const guardSecond = await callSearch(execute, query, "call-5"); - assert.equal( - guardSecond.isError, true, - "call 5 should STILL trigger the loop guard (guard must stay armed after firing)" - ); - assert.equal(guardSecond.details?.errorKind, "search_loop"); - - // Call 6 as well — guard should keep firing - const guardThird = await callSearch(execute, query, "call-6"); - assert.equal( - guardThird.isError, true, - "call 6 should STILL trigger the loop guard" - ); - } finally { + t.after(() => { restoreFetch(); restoreSearchEnv(); + }); + + const pi = createMockPI(); + registerSearchTool(pi as any); + const tool = pi.getRegisteredTool(); + const execute = tool.execute.bind(tool); + + // Exhaust the initial window (calls 1–3 succeed, call 4 fires guard) + for (let i = 1; i <= 3; i++) { + await callSearch(execute, query, `call-${i}`); } + const guardFirst = await callSearch(execute, query, "call-4"); + assert.equal(guardFirst.isError, true, "call 4 should trigger the loop guard"); + + // Key regression test: call 5 (and beyond) must ALSO trigger the guard. + // The original bug reset state on trigger, so call 5 was treated as a fresh + // first search and the loop restarted. + const guardSecond = await callSearch(execute, query, "call-5"); + assert.equal( + guardSecond.isError, true, + "call 5 should STILL trigger the loop guard (guard must stay armed after firing)" + ); + assert.equal(guardSecond.details?.errorKind, "search_loop"); + + // Call 6 as well — guard should keep firing + const guardThird = await callSearch(execute, query, "call-6"); + assert.equal( + guardThird.isError, true, + "call 6 should STILL trigger the loop guard" + ); }); -test("search loop guard resets cleanly when a different query is issued", async () => { +test("search loop guard resets cleanly when a different query is issued", async (t) => { process.env.BRAVE_API_KEY = "test-key-loop-guard-3"; delete process.env.TAVILY_API_KEY; delete process.env.OLLAMA_API_KEY; @@ -245,25 +245,25 @@ test("search loop guard resets cleanly when a different query is issued", async const queryA = "query alpha reset test"; const queryB = "query beta reset test"; - try { - const pi = createMockPI(); - registerSearchTool(pi as any); - const tool = pi.getRegisteredTool(); - const execute = tool.execute.bind(tool); - - // Trigger guard for queryA - for (let i = 1; i <= 4; i++) { - await callSearch(execute, queryA, `call-a-${i}`); - } - - // Issue a different query — should succeed (resets the duplicate counter) - const resultB = await callSearch(execute, queryB, "call-b-1"); - assert.notEqual( - resultB.isError, true, - "a different query after guard should not be treated as a loop" - ); - } finally { + t.after(() => { restoreFetch(); restoreSearchEnv(); + }); + + const pi = createMockPI(); + registerSearchTool(pi as any); + const tool = pi.getRegisteredTool(); + const execute = tool.execute.bind(tool); + + // Trigger guard for queryA + for (let i = 1; i <= 4; i++) { + await callSearch(execute, queryA, `call-a-${i}`); } + + // Issue a different query — should succeed (resets the duplicate counter) + const resultB = await callSearch(execute, queryB, "call-b-1"); + assert.notEqual( + resultB.isError, true, + "a different query after guard should not be treated as a loop" + ); }); diff --git a/src/tests/search-provider-command.test.ts b/src/tests/search-provider-command.test.ts index 9540a5c02..0df49f87c 100644 --- a/src/tests/search-provider-command.test.ts +++ b/src/tests/search-provider-command.test.ts @@ -118,79 +118,73 @@ async function loadCommand(): Promise { // 1. Direct arg — tavily // ═══════════════════════════════════════════════════════════════════════════ -test('direct arg "tavily" sets preference and notifies', async () => { +test('direct arg "tavily" sets preference and notifies', async (t) => { const { setSearchProviderPreference, getSearchProviderPreference } = await import( '../resources/extensions/search-the-web/provider.ts' ) const cmd = await loadCommand() const { authPath, cleanup } = makeTmpAuth() - try { - await withEnv({ TAVILY_API_KEY: 'tvly-test', BRAVE_API_KEY: undefined }, async () => { - // Pre-set to auto so we can verify the change - setSearchProviderPreference('auto', authPath) + t.after(() => { cleanup() }); - const ctx = makeMockCtx() - await cmd.handler('tavily', ctx) + await withEnv({ TAVILY_API_KEY: 'tvly-test', BRAVE_API_KEY: undefined }, async () => { + // Pre-set to auto so we can verify the change + setSearchProviderPreference('auto', authPath) - // No select UI shown - assert.equal(ctx.ui.selectCalls.length, 0, 'should not show select UI for direct arg') + const ctx = makeMockCtx() + await cmd.handler('tavily', ctx) - // Notification sent - assert.equal(ctx.ui.notifyCalls.length, 1, 'should notify once') - assert.match(ctx.ui.notifyCalls[0].message, /Search provider set to tavily/, 'notification should confirm provider set') - assert.match(ctx.ui.notifyCalls[0].message, /Effective provider: tavily/, 'notification should show effective provider') - }) - } finally { - cleanup() - } + // No select UI shown + assert.equal(ctx.ui.selectCalls.length, 0, 'should not show select UI for direct arg') + + // Notification sent + assert.equal(ctx.ui.notifyCalls.length, 1, 'should notify once') + assert.match(ctx.ui.notifyCalls[0].message, /Search provider set to tavily/, 'notification should confirm provider set') + assert.match(ctx.ui.notifyCalls[0].message, /Effective provider: tavily/, 'notification should show effective provider') + }) }) // ═══════════════════════════════════════════════════════════════════════════ // 2. Direct arg — brave // ═══════════════════════════════════════════════════════════════════════════ -test('direct arg "brave" sets preference and notifies', async () => { +test('direct arg "brave" sets preference and notifies', async (t) => { const cmd = await loadCommand() const { authPath, cleanup } = makeTmpAuth() - try { - await withEnv({ TAVILY_API_KEY: undefined, BRAVE_API_KEY: 'BSA-test' }, async () => { - const ctx = makeMockCtx() - await cmd.handler('brave', ctx) + t.after(() => { cleanup() }); - assert.equal(ctx.ui.selectCalls.length, 0) - assert.equal(ctx.ui.notifyCalls.length, 1) - assert.match(ctx.ui.notifyCalls[0].message, /Search provider set to brave/) - assert.match(ctx.ui.notifyCalls[0].message, /Effective provider: brave/) - }) - } finally { - cleanup() - } + await withEnv({ TAVILY_API_KEY: undefined, BRAVE_API_KEY: 'BSA-test' }, async () => { + const ctx = makeMockCtx() + await cmd.handler('brave', ctx) + + assert.equal(ctx.ui.selectCalls.length, 0) + assert.equal(ctx.ui.notifyCalls.length, 1) + assert.match(ctx.ui.notifyCalls[0].message, /Search provider set to brave/) + assert.match(ctx.ui.notifyCalls[0].message, /Effective provider: brave/) + }) }) // ═══════════════════════════════════════════════════════════════════════════ // 3. Direct arg — auto // ═══════════════════════════════════════════════════════════════════════════ -test('direct arg "auto" sets preference and notifies', async () => { +test('direct arg "auto" sets preference and notifies', async (t) => { const cmd = await loadCommand() const { authPath, cleanup } = makeTmpAuth() - try { - await withEnv({ TAVILY_API_KEY: 'tvly-test', BRAVE_API_KEY: 'BSA-test' }, async () => { - const ctx = makeMockCtx() - await cmd.handler('auto', ctx) + t.after(() => { cleanup() }); - assert.equal(ctx.ui.selectCalls.length, 0) - assert.equal(ctx.ui.notifyCalls.length, 1) - assert.match(ctx.ui.notifyCalls[0].message, /Search provider set to auto/) - // auto with both keys → tavily - assert.match(ctx.ui.notifyCalls[0].message, /Effective provider: tavily/) - }) - } finally { - cleanup() - } + await withEnv({ TAVILY_API_KEY: 'tvly-test', BRAVE_API_KEY: 'BSA-test' }, async () => { + const ctx = makeMockCtx() + await cmd.handler('auto', ctx) + + assert.equal(ctx.ui.selectCalls.length, 0) + assert.equal(ctx.ui.notifyCalls.length, 1) + assert.match(ctx.ui.notifyCalls[0].message, /Search provider set to auto/) + // auto with both keys → tavily + assert.match(ctx.ui.notifyCalls[0].message, /Effective provider: tavily/) + }) }) // ═══════════════════════════════════════════════════════════════════════════ @@ -227,29 +221,27 @@ test('no arg shows select UI with 3 options, user picks brave', async () => { // 5. Cancel (select returns undefined) — no side effects // ═══════════════════════════════════════════════════════════════════════════ -test('cancel (select returns undefined) produces no side effects', async () => { +test('cancel (select returns undefined) produces no side effects', async (t) => { const { getSearchProviderPreference, setSearchProviderPreference } = await import( '../resources/extensions/search-the-web/provider.ts' ) const cmd = await loadCommand() const { authPath, cleanup } = makeTmpAuth() - try { - await withEnv({ TAVILY_API_KEY: 'tvly-test', BRAVE_API_KEY: undefined }, async () => { - setSearchProviderPreference('tavily', authPath) + t.after(() => { cleanup() }); - // selectReturn = undefined simulates Esc - const ctx = makeMockCtx(undefined) - await cmd.handler('', ctx) + await withEnv({ TAVILY_API_KEY: 'tvly-test', BRAVE_API_KEY: undefined }, async () => { + setSearchProviderPreference('tavily', authPath) - // Select was called - assert.equal(ctx.ui.selectCalls.length, 1) - // No notification (no side effects) - assert.equal(ctx.ui.notifyCalls.length, 0, 'cancel should produce no notification') - }) - } finally { - cleanup() - } + // selectReturn = undefined simulates Esc + const ctx = makeMockCtx(undefined) + await cmd.handler('', ctx) + + // Select was called + assert.equal(ctx.ui.selectCalls.length, 1) + // No notification (no side effects) + assert.equal(ctx.ui.notifyCalls.length, 0, 'cancel should produce no notification') + }) }) // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/tests/search-tavily.test.ts b/src/tests/search-tavily.test.ts index 456abb7a4..3365d3550 100644 --- a/src/tests/search-tavily.test.ts +++ b/src/tests/search-tavily.test.ts @@ -83,120 +83,120 @@ function mockFetch(responseBody: unknown, status = 200) { // Test: executeTavilySearch produces correct CachedSearchResult shape // ============================================================================= -test("executeTavilySearch sends POST to Tavily API and produces CachedSearchResult", async () => { +test("executeTavilySearch sends POST to Tavily API and produces CachedSearchResult", async (t) => { // Set TAVILY_API_KEY for this test const origKey = process.env.TAVILY_API_KEY; process.env.TAVILY_API_KEY = "tvly-test-key-12345"; const { captured, restore } = mockFetch(makeTavilyResponse()); - try { - // Dynamic import to get the module-level function - // We need to call it through the module — but executeTavilySearch is not exported. - // Instead, we test through the tool's execute path by importing the module fresh. - // Since executeTavilySearch is a private function, we test it indirectly through - // the request captured by our mock fetch. - - // Import the normalization helpers to verify the mapping - const { normalizeTavilyResult } = await import("../resources/extensions/search-the-web/tavily.ts"); - - // Simulate what executeTavilySearch does: build request, call fetch, map response - const requestBody: Record = { - query: "test query", - max_results: 10, - search_depth: "basic", - }; - - const response = await globalThis.fetch("https://api.tavily.com/search", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": "Bearer tvly-test-key-12345", - }, - body: JSON.stringify(requestBody), - }); - - const data = await response.json() as { results: Array<{ title: string; url: string; content: string; score: number; published_date?: string }> }; - - // Verify request shape - assert.equal(captured.url, "https://api.tavily.com/search", "request URL"); - assert.equal(captured.method, "POST", "HTTP method"); - assert.equal(captured.headers?.["Content-Type"], "application/json", "Content-Type header"); - assert.equal(captured.headers?.["Authorization"], "Bearer tvly-test-key-12345", "Authorization header"); - assert.deepEqual(captured.body, requestBody, "request body"); - - // Verify response mapping - const mapped = data.results.map(normalizeTavilyResult); - assert.equal(mapped.length, 2); - assert.equal(mapped[0].title, "First Result"); - assert.equal(mapped[0].url, "https://example.com/first"); - assert.equal(mapped[0].description, "Description of first result."); - assert.ok(mapped[0].age, "Published date should produce an age string"); - assert.equal(mapped[1].title, "Second Result"); - assert.equal(mapped[1].age, undefined, "No published_date → no age"); - } finally { + t.after(() => { restore(); if (origKey !== undefined) process.env.TAVILY_API_KEY = origKey; else delete process.env.TAVILY_API_KEY; - } + }); + + // Dynamic import to get the module-level function + // We need to call it through the module — but executeTavilySearch is not exported. + // Instead, we test through the tool's execute path by importing the module fresh. + // Since executeTavilySearch is a private function, we test it indirectly through + // the request captured by our mock fetch. + + // Import the normalization helpers to verify the mapping + const { normalizeTavilyResult } = await import("../resources/extensions/search-the-web/tavily.ts"); + + // Simulate what executeTavilySearch does: build request, call fetch, map response + const requestBody: Record = { + query: "test query", + max_results: 10, + search_depth: "basic", + }; + + const response = await globalThis.fetch("https://api.tavily.com/search", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer tvly-test-key-12345", + }, + body: JSON.stringify(requestBody), + }); + + const data = await response.json() as { results: Array<{ title: string; url: string; content: string; score: number; published_date?: string }> }; + + // Verify request shape + assert.equal(captured.url, "https://api.tavily.com/search", "request URL"); + assert.equal(captured.method, "POST", "HTTP method"); + assert.equal(captured.headers?.["Content-Type"], "application/json", "Content-Type header"); + assert.equal(captured.headers?.["Authorization"], "Bearer tvly-test-key-12345", "Authorization header"); + assert.deepEqual(captured.body, requestBody, "request body"); + + // Verify response mapping + const mapped = data.results.map(normalizeTavilyResult); + assert.equal(mapped.length, 2); + assert.equal(mapped[0].title, "First Result"); + assert.equal(mapped[0].url, "https://example.com/first"); + assert.equal(mapped[0].description, "Description of first result."); + assert.ok(mapped[0].age, "Published date should produce an age string"); + assert.equal(mapped[1].title, "Second Result"); + assert.equal(mapped[1].age, undefined, "No published_date → no age"); }); // ============================================================================= // Test: Provider branching — resolveSearchProvider returns correct provider // ============================================================================= -test("resolveSearchProvider returns 'tavily' when TAVILY_API_KEY is set and BRAVE_API_KEY is not", () => { +test("resolveSearchProvider returns 'tavily' when TAVILY_API_KEY is set and BRAVE_API_KEY is not", (t) => { const origTavily = process.env.TAVILY_API_KEY; const origBrave = process.env.BRAVE_API_KEY; process.env.TAVILY_API_KEY = "tvly-test-key"; delete process.env.BRAVE_API_KEY; - try { - const provider = resolveSearchProvider(); - assert.equal(provider, "tavily"); - } finally { + t.after(() => { if (origTavily !== undefined) process.env.TAVILY_API_KEY = origTavily; else delete process.env.TAVILY_API_KEY; if (origBrave !== undefined) process.env.BRAVE_API_KEY = origBrave; else delete process.env.BRAVE_API_KEY; - } + }); + + const provider = resolveSearchProvider(); + assert.equal(provider, "tavily"); }); -test("resolveSearchProvider returns 'brave' when only BRAVE_API_KEY is set", () => { +test("resolveSearchProvider returns 'brave' when only BRAVE_API_KEY is set", (t) => { const origTavily = process.env.TAVILY_API_KEY; const origBrave = process.env.BRAVE_API_KEY; delete process.env.TAVILY_API_KEY; process.env.BRAVE_API_KEY = "BSA-test-key"; - try { - const provider = resolveSearchProvider(); - assert.equal(provider, "brave"); - } finally { + t.after(() => { if (origTavily !== undefined) process.env.TAVILY_API_KEY = origTavily; else delete process.env.TAVILY_API_KEY; if (origBrave !== undefined) process.env.BRAVE_API_KEY = origBrave; else delete process.env.BRAVE_API_KEY; - } + }); + + const provider = resolveSearchProvider(); + assert.equal(provider, "brave"); }); -test("resolveSearchProvider returns null when neither key is set", () => { +test("resolveSearchProvider returns null when neither key is set", (t) => { const origTavily = process.env.TAVILY_API_KEY; const origBrave = process.env.BRAVE_API_KEY; delete process.env.TAVILY_API_KEY; delete process.env.BRAVE_API_KEY; - try { - const provider = resolveSearchProvider(); - assert.equal(provider, null); - } finally { + t.after(() => { if (origTavily !== undefined) process.env.TAVILY_API_KEY = origTavily; else delete process.env.BRAVE_API_KEY; if (origBrave !== undefined) process.env.BRAVE_API_KEY = origBrave; else delete process.env.BRAVE_API_KEY; - } + }); + + const provider = resolveSearchProvider(); + assert.equal(provider, null); }); // ============================================================================= @@ -245,7 +245,7 @@ test("no-key error message contains both TAVILY_API_KEY and BRAVE_API_KEY", () = // Test: Tavily answer mapping — answer field flows through as summary text // ============================================================================= -test("Tavily answer field maps to summaryText in CachedSearchResult", async () => { +test("Tavily answer field maps to summaryText in CachedSearchResult", async (t) => { const origKey = process.env.TAVILY_API_KEY; process.env.TAVILY_API_KEY = "tvly-test-key"; @@ -255,29 +255,29 @@ test("Tavily answer field maps to summaryText in CachedSearchResult", async () = const { captured, restore } = mockFetch(responseWithAnswer); - try { - const response = await globalThis.fetch("https://api.tavily.com/search", { - method: "POST", - headers: { "Content-Type": "application/json", "Authorization": "Bearer tvly-test-key" }, - body: JSON.stringify({ query: "what is typescript", max_results: 10, search_depth: "basic", include_answer: true }), - }); - - const data = await response.json() as { answer?: string }; - - // Verify the answer is present - assert.equal(data.answer, "TypeScript is a typed superset of JavaScript that compiles to plain JavaScript."); - - // Verify the request included include_answer - assert.equal(captured.body?.include_answer, true); - - // The answer should flow to summaryText (not summarizerKey) - const summaryText = data.answer || undefined; - assert.ok(summaryText, "Answer should be truthy and used as summaryText"); - } finally { + t.after(() => { restore(); if (origKey !== undefined) process.env.TAVILY_API_KEY = origKey; else delete process.env.TAVILY_API_KEY; - } + }); + + const response = await globalThis.fetch("https://api.tavily.com/search", { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": "Bearer tvly-test-key" }, + body: JSON.stringify({ query: "what is typescript", max_results: 10, search_depth: "basic", include_answer: true }), + }); + + const data = await response.json() as { answer?: string }; + + // Verify the answer is present + assert.equal(data.answer, "TypeScript is a typed superset of JavaScript that compiles to plain JavaScript."); + + // Verify the request included include_answer + assert.equal(captured.body?.include_answer, true); + + // The answer should flow to summaryText (not summarizerKey) + const summaryText = data.answer || undefined; + assert.ok(summaryText, "Answer should be truthy and used as summaryText"); }); // ============================================================================= @@ -305,40 +305,40 @@ test("freshness='week' maps to time_range='week' in Tavily request body", () => // Test: Domain mapping — include_domains, not site: prefix // ============================================================================= -test("Tavily domain filter uses include_domains, not site: prefix in query", async () => { +test("Tavily domain filter uses include_domains, not site: prefix in query", async (t) => { const origKey = process.env.TAVILY_API_KEY; process.env.TAVILY_API_KEY = "tvly-test-key"; const { captured, restore } = mockFetch(makeTavilyResponse()); - try { - // Simulate what executeTavilySearch builds for domain filtering - const domain = "example.com"; - const query = "typescript tutorial"; - - const requestBody: Record = { - query, // Note: NO site: prefix - max_results: 10, - search_depth: "basic", - include_domains: [domain], - }; - - await globalThis.fetch("https://api.tavily.com/search", { - method: "POST", - headers: { "Content-Type": "application/json", "Authorization": "Bearer tvly-test-key" }, - body: JSON.stringify(requestBody), - }); - - // Verify domain passed as include_domains, not in query - assert.deepEqual(captured.body?.include_domains, ["example.com"]); - assert.equal(captured.body?.query, "typescript tutorial", "Query must NOT contain site: prefix for Tavily"); - assert.ok( - !(captured.body?.query as string).includes("site:"), - "Query must not include site: prefix for Tavily path" - ); - } finally { + t.after(() => { restore(); if (origKey !== undefined) process.env.TAVILY_API_KEY = origKey; else delete process.env.TAVILY_API_KEY; - } + }); + + // Simulate what executeTavilySearch builds for domain filtering + const domain = "example.com"; + const query = "typescript tutorial"; + + const requestBody: Record = { + query, // Note: NO site: prefix + max_results: 10, + search_depth: "basic", + include_domains: [domain], + }; + + await globalThis.fetch("https://api.tavily.com/search", { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": "Bearer tvly-test-key" }, + body: JSON.stringify(requestBody), + }); + + // Verify domain passed as include_domains, not in query + assert.deepEqual(captured.body?.include_domains, ["example.com"]); + assert.equal(captured.body?.query, "typescript tutorial", "Query must NOT contain site: prefix for Tavily"); + assert.ok( + !(captured.body?.query as string).includes("site:"), + "Query must not include site: prefix for Tavily path" + ); }); diff --git a/src/tests/secret-scan.test.ts b/src/tests/secret-scan.test.ts index c4b446cd5..7ac9701f0 100644 --- a/src/tests/secret-scan.test.ts +++ b/src/tests/secret-scan.test.ts @@ -26,24 +26,24 @@ function scanContent( const dir = mkdtempSync(join(tmpdir(), "secret-scan-test-")); try { // Initialize a git repo so `git diff --cached` works - spawnSync("git", ["init"], { cwd: dir }); - spawnSync("git", ["config", "user.email", "test@test.com"], { cwd: dir }); - spawnSync("git", ["config", "user.name", "Test"], { cwd: dir }); + spawnSync("git", ["init"], { cwd: dir }); + spawnSync("git", ["config", "user.email", "test@test.com"], { cwd: dir }); + spawnSync("git", ["config", "user.name", "Test"], { cwd: dir }); - // Write and stage the file - const filePath = join(dir, filename); - const parentDir = join(dir, ...filename.split("/").slice(0, -1)); - if (filename.includes("/")) { - mkdirSync(parentDir, { recursive: true }); - } - writeFileSync(filePath, content); - spawnSync("git", ["add", filename], { cwd: dir }); + // Write and stage the file + const filePath = join(dir, filename); + const parentDir = join(dir, ...filename.split("/").slice(0, -1)); + if (filename.includes("/")) { + mkdirSync(parentDir, { recursive: true }); + } + writeFileSync(filePath, content); + spawnSync("git", ["add", filename], { cwd: dir }); - const result = spawnSync("bash", [scanScript], { - cwd: dir, - encoding: "utf-8", - env: { ...process.env, TERM: "dumb" }, - }); + const result = spawnSync("bash", [scanScript], { + cwd: dir, + encoding: "utf-8", + env: { ...process.env, TERM: "dumb" }, + }); return { status: result.status ?? 1, @@ -153,19 +153,17 @@ test("skips package-lock.json", { skip: isWindows }, () => { assert.equal(result.status, 0, `should pass (lockfile skip): ${result.stdout}`); }); -test("reports no files cleanly", { skip: isWindows }, () => { +test("reports no files cleanly", { skip: isWindows }, (t) => { const dir = mkdtempSync(join(tmpdir(), "secret-scan-empty-")); - try { - spawnSync("git", ["init"], { cwd: dir }); - const result = spawnSync("bash", [scanScript], { - cwd: dir, - encoding: "utf-8", - }); - assert.equal(result.status, 0); - assert.match(result.stdout, /no files to scan/); - } finally { - rmSync(dir, { recursive: true, force: true }); - } + t.after(() => { rmSync(dir, { recursive: true, force: true }); }); + + spawnSync("git", ["init"], { cwd: dir }); + const result = spawnSync("bash", [scanScript], { + cwd: dir, + encoding: "utf-8", + }); + assert.equal(result.status, 0); + assert.match(result.stdout, /no files to scan/); }); // ── Multiple findings ──────────────────────────────────────────────── @@ -186,34 +184,32 @@ test("reports multiple secrets in one file", { skip: isWindows }, () => { // ── CI mode (--diff) ───────────────────────────────────────────────── -test("CI mode scans diff against ref", { skip: isWindows }, () => { +test("CI mode scans diff against ref", { skip: isWindows }, (t) => { const dir = mkdtempSync(join(tmpdir(), "secret-scan-ci-")); - try { - spawnSync("git", ["init"], { cwd: dir }); - spawnSync("git", ["config", "user.email", "test@test.com"], { cwd: dir }); - spawnSync("git", ["config", "user.name", "Test"], { cwd: dir }); + t.after(() => { rmSync(dir, { recursive: true, force: true }); }); - // Create initial commit - writeFileSync(join(dir, "clean.ts"), "const x = 1;"); - spawnSync("git", ["add", "."], { cwd: dir }); - spawnSync("git", ["commit", "-m", "init"], { cwd: dir }); + spawnSync("git", ["init"], { cwd: dir }); + spawnSync("git", ["config", "user.email", "test@test.com"], { cwd: dir }); + spawnSync("git", ["config", "user.name", "Test"], { cwd: dir }); - // Add a file with a secret on a new commit - writeFileSync( - join(dir, "leaked.ts"), - 'const key = "AKIAIOSFODNN7EXAMPLE";', - ); - spawnSync("git", ["add", "."], { cwd: dir }); - spawnSync("git", ["commit", "-m", "add leak"], { cwd: dir }); + // Create initial commit + writeFileSync(join(dir, "clean.ts"), "const x = 1;"); + spawnSync("git", ["add", "."], { cwd: dir }); + spawnSync("git", ["commit", "-m", "init"], { cwd: dir }); - const result = spawnSync("bash", [scanScript, "--diff", "HEAD~1"], { - cwd: dir, - encoding: "utf-8", - }); + // Add a file with a secret on a new commit + writeFileSync( + join(dir, "leaked.ts"), + 'const key = "AKIAIOSFODNN7EXAMPLE";', + ); + spawnSync("git", ["add", "."], { cwd: dir }); + spawnSync("git", ["commit", "-m", "add leak"], { cwd: dir }); - assert.equal(result.status, 1, `CI mode should detect: ${result.stdout}`); - assert.match(result.stdout, /AWS Access Key/); - } finally { - rmSync(dir, { recursive: true, force: true }); - } + const result = spawnSync("bash", [scanScript, "--diff", "HEAD~1"], { + cwd: dir, + encoding: "utf-8", + }); + + assert.equal(result.status, 1, `CI mode should detect: ${result.stdout}`); + assert.match(result.stdout, /AWS Access Key/); }); diff --git a/src/tests/terminal-cmux.test.ts b/src/tests/terminal-cmux.test.ts index 97e89d096..dadb3629f 100644 --- a/src/tests/terminal-cmux.test.ts +++ b/src/tests/terminal-cmux.test.ts @@ -8,7 +8,7 @@ test("isCmuxTerminal detects cmux env vars", () => { assert.equal(isCmuxTerminal({ TERM_PROGRAM: "ghostty" } as NodeJS.ProcessEnv), false); }); -test("detectCapabilities treats cmux as kitty-capable", () => { +test("detectCapabilities treats cmux as kitty-capable", (t) => { const originalEnv = process.env; process.env = { ...originalEnv, @@ -16,15 +16,15 @@ test("detectCapabilities treats cmux as kitty-capable", () => { CMUX_SURFACE_ID: "surface:2", TERM_PROGRAM: "ghostty", }; - try { - resetCapabilitiesCache(); - assert.deepEqual(detectCapabilities(), { - images: "kitty", - trueColor: true, - hyperlinks: true, - }); - } finally { + t.after(() => { process.env = originalEnv; resetCapabilitiesCache(); - } + }); + + resetCapabilitiesCache(); + assert.deepEqual(detectCapabilities(), { + images: "kitty", + trueColor: true, + hyperlinks: true, + }); }); diff --git a/src/tests/tool-bootstrap.test.ts b/src/tests/tool-bootstrap.test.ts index ef5f20315..8a98fd068 100644 --- a/src/tests/tool-bootstrap.test.ts +++ b/src/tests/tool-bootstrap.test.ts @@ -16,18 +16,16 @@ function makeExecutable(dir: string, name: string, content = "#!/bin/sh\nexit 0\ return file; } -test("resolveToolFromPath finds fd via fdfind fallback", () => { +test("resolveToolFromPath finds fd via fdfind fallback", (t) => { const tmp = mkdtempSync(join(tmpdir(), "gsd-tool-bootstrap-resolve-")); - try { - makeExecutable(tmp, "fdfind"); - const resolved = resolveToolFromPath("fd", tmp); - assert.equal(resolved, join(tmp, "fdfind")); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + t.after(() => { rmSync(tmp, { recursive: true, force: true }); }); + + makeExecutable(tmp, "fdfind"); + const resolved = resolveToolFromPath("fd", tmp); + assert.equal(resolved, join(tmp, "fdfind")); }); -test("ensureManagedTools provisions fd and rg into managed bin dir", () => { +test("ensureManagedTools provisions fd and rg into managed bin dir", (t) => { const tmp = mkdtempSync(join(tmpdir(), "gsd-tool-bootstrap-provision-")); const sourceBin = join(tmp, "source-bin"); const targetBin = join(tmp, "target-bin"); @@ -35,23 +33,21 @@ test("ensureManagedTools provisions fd and rg into managed bin dir", () => { mkdirSync(sourceBin, { recursive: true }); mkdirSync(targetBin, { recursive: true }); - try { - makeExecutable(sourceBin, "fdfind"); - makeExecutable(sourceBin, "rg"); + t.after(() => { rmSync(tmp, { recursive: true, force: true }); }); - const provisioned = ensureManagedTools(targetBin, sourceBin); + makeExecutable(sourceBin, "fdfind"); + makeExecutable(sourceBin, "rg"); - assert.equal(provisioned.length, 2); - assert.ok(existsSync(join(targetBin, FD_TARGET))); - assert.ok(existsSync(join(targetBin, RG_TARGET))); - assert.ok(lstatSync(join(targetBin, FD_TARGET)).isSymbolicLink() || lstatSync(join(targetBin, FD_TARGET)).isFile()); - assert.ok(lstatSync(join(targetBin, RG_TARGET)).isSymbolicLink() || lstatSync(join(targetBin, RG_TARGET)).isFile()); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + const provisioned = ensureManagedTools(targetBin, sourceBin); + + assert.equal(provisioned.length, 2); + assert.ok(existsSync(join(targetBin, FD_TARGET))); + assert.ok(existsSync(join(targetBin, RG_TARGET))); + assert.ok(lstatSync(join(targetBin, FD_TARGET)).isSymbolicLink() || lstatSync(join(targetBin, FD_TARGET)).isFile()); + assert.ok(lstatSync(join(targetBin, RG_TARGET)).isSymbolicLink() || lstatSync(join(targetBin, RG_TARGET)).isFile()); }); -test("ensureManagedTools copies executable when symlink target already exists as a broken link", () => { +test("ensureManagedTools copies executable when symlink target already exists as a broken link", (t) => { const tmp = mkdtempSync(join(tmpdir(), "gsd-tool-bootstrap-copy-")); const sourceBin = join(tmp, "source-bin"); const targetBin = join(tmp, "target-bin"); @@ -60,17 +56,15 @@ test("ensureManagedTools copies executable when symlink target already exists as mkdirSync(sourceBin, { recursive: true }); mkdirSync(targetBin, { recursive: true }); - try { - makeExecutable(sourceBin, "fdfind", "#!/bin/sh\necho fd\n"); - makeExecutable(sourceBin, "rg", "#!/bin/sh\necho rg\n"); - symlinkSync(join(tmp, "missing-target"), targetFd); + t.after(() => { rmSync(tmp, { recursive: true, force: true }); }); - const provisioned = ensureManagedTools(targetBin, sourceBin); + makeExecutable(sourceBin, "fdfind", "#!/bin/sh\necho fd\n"); + makeExecutable(sourceBin, "rg", "#!/bin/sh\necho rg\n"); + symlinkSync(join(tmp, "missing-target"), targetFd); - assert.equal(provisioned.length, 2); - assert.ok(lstatSync(targetFd).isFile(), "fd fallback should replace broken symlink with a copied file"); - assert.match(readFileSync(targetFd, "utf8"), /echo fd/); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + const provisioned = ensureManagedTools(targetBin, sourceBin); + + assert.equal(provisioned.length, 2); + assert.ok(lstatSync(targetFd).isFile(), "fd fallback should replace broken symlink with a copied file"); + assert.match(readFileSync(targetFd, "utf8"), /echo fd/); }); diff --git a/src/tests/ttsr-rule-loader.test.ts b/src/tests/ttsr-rule-loader.test.ts index 8ae300c21..272397522 100644 --- a/src/tests/ttsr-rule-loader.test.ts +++ b/src/tests/ttsr-rule-loader.test.ts @@ -33,23 +33,22 @@ function writeRule(dir: string, name: string, frontmatter: string, body: string) // Project-local rule loading // ═══════════════════════════════════════════════════════════════════════════ -test('loads rule from project .gsd/rules/', () => { +test('loads rule from project .gsd/rules/', (t) => { const { cwd, projectDir, cleanup } = makeTmpProject() - try { + t.after(() => { cleanup() }); + writeRule(projectDir, 'no-console', 'condition:\n - "console\\.log"', 'Do not use console.log.') const rules = loadRules(cwd) const projectRule = rules.find(r => r.name === 'no-console') assert.ok(projectRule) assert.deepEqual(projectRule.condition, ['console\\.log']) assert.equal(projectRule.content, 'Do not use console.log.') - } finally { - cleanup() - } }) -test('parses scope and globs from frontmatter', () => { +test('parses scope and globs from frontmatter', (t) => { const { cwd, projectDir, cleanup } = makeTmpProject() - try { + t.after(() => { cleanup() }); + writeRule( projectDir, 'scoped-rule', @@ -61,69 +60,56 @@ test('parses scope and globs from frontmatter', () => { assert.ok(rule) assert.deepEqual(rule.scope, ['tool:edit', 'text']) assert.deepEqual(rule.globs, ['*.ts']) - } finally { - cleanup() - } }) -test('skips files without valid frontmatter', () => { +test('skips files without valid frontmatter', (t) => { const { cwd, projectDir, cleanup } = makeTmpProject() - try { + t.after(() => { cleanup() }); + mkdirSync(projectDir, { recursive: true }) writeFileSync(join(projectDir, 'broken.md'), 'No frontmatter here.') const rules = loadRules(cwd) assert.equal(rules.filter(r => r.name === 'broken').length, 0) - } finally { - cleanup() - } }) -test('skips rules with no condition', () => { +test('skips rules with no condition', (t) => { const { cwd, projectDir, cleanup } = makeTmpProject() - try { + t.after(() => { cleanup() }); + writeRule(projectDir, 'no-condition', 'scope:\n - "text"', 'Missing condition field.') const rules = loadRules(cwd) assert.equal(rules.filter(r => r.name === 'no-condition').length, 0) - } finally { - cleanup() - } }) -test('returns empty array when .gsd/rules/ does not exist', () => { +test('returns empty array when .gsd/rules/ does not exist', (t) => { const { cwd, cleanup } = makeTmpProject() - try { + t.after(() => { cleanup() }); + // cwd exists but no .gsd/rules/ dir const rules = loadRules(cwd) // May include global rules from homedir — just verify no crash assert.ok(Array.isArray(rules)) - } finally { - cleanup() - } }) -test('loads multiple rules from same directory', () => { +test('loads multiple rules from same directory', (t) => { const { cwd, projectDir, cleanup } = makeTmpProject() - try { + t.after(() => { cleanup() }); + writeRule(projectDir, 'rule-a', 'condition:\n - "alpha"', 'Alpha rule.') writeRule(projectDir, 'rule-b', 'condition:\n - "beta"', 'Beta rule.') const rules = loadRules(cwd) const names = rules.map(r => r.name) assert.ok(names.includes('rule-a')) assert.ok(names.includes('rule-b')) - } finally { - cleanup() - } }) -test('handles quoted values in frontmatter', () => { +test('handles quoted values in frontmatter', (t) => { const { cwd, projectDir, cleanup } = makeTmpProject() - try { + t.after(() => { cleanup() }); + writeRule(projectDir, 'quoted', 'condition:\n - "console\\.log"\n - \'debugger\'', 'Quoted values.') const rules = loadRules(cwd) const rule = rules.find(r => r.name === 'quoted') assert.ok(rule) assert.deepEqual(rule.condition, ['console\\.log', 'debugger']) - } finally { - cleanup() - } }) diff --git a/src/tests/update-check.test.ts b/src/tests/update-check.test.ts index 1275b1356..caa712533 100644 --- a/src/tests/update-check.test.ts +++ b/src/tests/update-check.test.ts @@ -41,51 +41,43 @@ test('compareSemver handles versions with different segment counts', () => { // readUpdateCache / writeUpdateCache // --------------------------------------------------------------------------- -test('readUpdateCache returns null for nonexistent file', () => { +test('readUpdateCache returns null for nonexistent file', (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-cache-')) - try { - const result = readUpdateCache(join(tmp, 'nonexistent')) - assert.equal(result, null) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); + + const result = readUpdateCache(join(tmp, 'nonexistent')) + assert.equal(result, null) }) -test('readUpdateCache returns null for malformed JSON', () => { +test('readUpdateCache returns null for malformed JSON', (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-cache-')) - try { - const cachePath = join(tmp, '.update-check') - writeFileSync(cachePath, 'not json') - const result = readUpdateCache(cachePath) - assert.equal(result, null) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); + + const cachePath = join(tmp, '.update-check') + writeFileSync(cachePath, 'not json') + const result = readUpdateCache(cachePath) + assert.equal(result, null) }) -test('writeUpdateCache + readUpdateCache round-trips correctly', () => { +test('writeUpdateCache + readUpdateCache round-trips correctly', (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-cache-')) - try { - const cachePath = join(tmp, '.update-check') - const cache = { lastCheck: Date.now(), latestVersion: '3.0.0' } - writeUpdateCache(cache, cachePath) - const result = readUpdateCache(cachePath) - assert.deepEqual(result, cache) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); + + const cachePath = join(tmp, '.update-check') + const cache = { lastCheck: Date.now(), latestVersion: '3.0.0' } + writeUpdateCache(cache, cachePath) + const result = readUpdateCache(cachePath) + assert.deepEqual(result, cache) }) -test('writeUpdateCache creates parent directories', () => { +test('writeUpdateCache creates parent directories', (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-cache-')) - try { - const cachePath = join(tmp, 'nested', 'dir', '.update-check') - writeUpdateCache({ lastCheck: Date.now(), latestVersion: '1.0.0' }, cachePath) - const raw = readFileSync(cachePath, 'utf-8') - assert.ok(raw.includes('1.0.0')) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); + + const cachePath = join(tmp, 'nested', 'dir', '.update-check') + writeUpdateCache({ lastCheck: Date.now(), latestVersion: '1.0.0' }, cachePath) + const raw = readFileSync(cachePath, 'utf-8') + assert.ok(raw.includes('1.0.0')) }) // --------------------------------------------------------------------------- @@ -108,105 +100,105 @@ function startMockRegistry(responseBody: object, statusCode = 200): Promise<{ ur }) } -test('checkForUpdates calls onUpdate when newer version is available', async () => { +test('checkForUpdates calls onUpdate when newer version is available', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-')) const registry = await startMockRegistry({ version: '99.0.0' }) - try { - let called = false - let reportedCurrent = '' - let reportedLatest = '' - - await checkForUpdates({ - currentVersion: '1.0.0', - cachePath: join(tmp, '.update-check'), - registryUrl: registry.url, - checkIntervalMs: 0, - fetchTimeoutMs: 5000, - onUpdate: (current, latest) => { - called = true - reportedCurrent = current - reportedLatest = latest - }, - }) - - assert.ok(called, 'onUpdate should have been called') - assert.equal(reportedCurrent, '1.0.0') - assert.equal(reportedLatest, '99.0.0') - } finally { + t.after(async () => { await registry.close() rmSync(tmp, { recursive: true, force: true }) - } + }); + + let called = false + let reportedCurrent = '' + let reportedLatest = '' + + await checkForUpdates({ + currentVersion: '1.0.0', + cachePath: join(tmp, '.update-check'), + registryUrl: registry.url, + checkIntervalMs: 0, + fetchTimeoutMs: 5000, + onUpdate: (current, latest) => { + called = true + reportedCurrent = current + reportedLatest = latest + }, + }) + + assert.ok(called, 'onUpdate should have been called') + assert.equal(reportedCurrent, '1.0.0') + assert.equal(reportedLatest, '99.0.0') }) -test('checkForUpdates does not call onUpdate when already on latest', async () => { +test('checkForUpdates does not call onUpdate when already on latest', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-')) const registry = await startMockRegistry({ version: '1.0.0' }) - try { - let called = false - - await checkForUpdates({ - currentVersion: '1.0.0', - cachePath: join(tmp, '.update-check'), - registryUrl: registry.url, - checkIntervalMs: 0, - fetchTimeoutMs: 5000, - onUpdate: () => { called = true }, - }) - - assert.ok(!called, 'onUpdate should not be called when versions match') - } finally { + t.after(async () => { await registry.close() rmSync(tmp, { recursive: true, force: true }) - } + }); + + let called = false + + await checkForUpdates({ + currentVersion: '1.0.0', + cachePath: join(tmp, '.update-check'), + registryUrl: registry.url, + checkIntervalMs: 0, + fetchTimeoutMs: 5000, + onUpdate: () => { called = true }, + }) + + assert.ok(!called, 'onUpdate should not be called when versions match') }) -test('checkForUpdates does not call onUpdate when current is ahead', async () => { +test('checkForUpdates does not call onUpdate when current is ahead', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-')) const registry = await startMockRegistry({ version: '1.0.0' }) - try { - let called = false - - await checkForUpdates({ - currentVersion: '2.0.0', - cachePath: join(tmp, '.update-check'), - registryUrl: registry.url, - checkIntervalMs: 0, - fetchTimeoutMs: 5000, - onUpdate: () => { called = true }, - }) - - assert.ok(!called, 'onUpdate should not be called when current is ahead') - } finally { + t.after(async () => { await registry.close() rmSync(tmp, { recursive: true, force: true }) - } + }); + + let called = false + + await checkForUpdates({ + currentVersion: '2.0.0', + cachePath: join(tmp, '.update-check'), + registryUrl: registry.url, + checkIntervalMs: 0, + fetchTimeoutMs: 5000, + onUpdate: () => { called = true }, + }) + + assert.ok(!called, 'onUpdate should not be called when current is ahead') }) -test('checkForUpdates writes cache after successful fetch', async () => { +test('checkForUpdates writes cache after successful fetch', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-')) const cachePath = join(tmp, '.update-check') const registry = await startMockRegistry({ version: '5.0.0' }) - try { - await checkForUpdates({ - currentVersion: '1.0.0', - cachePath, - registryUrl: registry.url, - checkIntervalMs: 0, - fetchTimeoutMs: 5000, - onUpdate: () => {}, - }) - - const cache = readUpdateCache(cachePath) - assert.ok(cache, 'cache should exist after fetch') - assert.equal(cache!.latestVersion, '5.0.0') - assert.ok(cache!.lastCheck > 0) - } finally { + t.after(async () => { await registry.close() rmSync(tmp, { recursive: true, force: true }) - } + }); + + await checkForUpdates({ + currentVersion: '1.0.0', + cachePath, + registryUrl: registry.url, + checkIntervalMs: 0, + fetchTimeoutMs: 5000, + onUpdate: () => {}, + }) + + const cache = readUpdateCache(cachePath) + assert.ok(cache, 'cache should exist after fetch') + assert.equal(cache!.latestVersion, '5.0.0') + assert.ok(cache!.lastCheck > 0) }) -test('checkForUpdates uses cache and skips fetch when checked recently', async () => { +test('checkForUpdates uses cache and skips fetch when checked recently', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-')) const cachePath = join(tmp, '.update-check') // Write a fresh cache entry @@ -214,114 +206,112 @@ test('checkForUpdates uses cache and skips fetch when checked recently', async ( // Start server that would return a different version — should NOT be reached const registry = await startMockRegistry({ version: '20.0.0' }) - try { - let reportedLatest = '' - - await checkForUpdates({ - currentVersion: '1.0.0', - cachePath, - registryUrl: registry.url, - checkIntervalMs: 60 * 60 * 1000, // 1 hour - fetchTimeoutMs: 5000, - onUpdate: (_current, latest) => { reportedLatest = latest }, - }) - - // Should use cached version (10.0.0), not the server's (20.0.0) - assert.equal(reportedLatest, '10.0.0') - } finally { + t.after(async () => { await registry.close() rmSync(tmp, { recursive: true, force: true }) - } + }); + + let reportedLatest = '' + + await checkForUpdates({ + currentVersion: '1.0.0', + cachePath, + registryUrl: registry.url, + checkIntervalMs: 60 * 60 * 1000, // 1 hour + fetchTimeoutMs: 5000, + onUpdate: (_current, latest) => { reportedLatest = latest }, + }) + + // Should use cached version (10.0.0), not the server's (20.0.0) + assert.equal(reportedLatest, '10.0.0') }) -test('checkForUpdates skips notification when cache is fresh and versions match', async () => { +test('checkForUpdates skips notification when cache is fresh and versions match', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-')) const cachePath = join(tmp, '.update-check') writeUpdateCache({ lastCheck: Date.now(), latestVersion: '1.0.0' }, cachePath) - try { - let called = false + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - await checkForUpdates({ - currentVersion: '1.0.0', - cachePath, - checkIntervalMs: 60 * 60 * 1000, - fetchTimeoutMs: 5000, - onUpdate: () => { called = true }, - }) + let called = false - assert.ok(!called, 'onUpdate should not be called when cached version matches current') - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + await checkForUpdates({ + currentVersion: '1.0.0', + cachePath, + checkIntervalMs: 60 * 60 * 1000, + fetchTimeoutMs: 5000, + onUpdate: () => { called = true }, + }) + + assert.ok(!called, 'onUpdate should not be called when cached version matches current') }) -test('checkForUpdates handles server error gracefully', async () => { +test('checkForUpdates handles server error gracefully', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-')) const registry = await startMockRegistry({}, 500) - try { - let called = false - - await checkForUpdates({ - currentVersion: '1.0.0', - cachePath: join(tmp, '.update-check'), - registryUrl: registry.url, - checkIntervalMs: 0, - fetchTimeoutMs: 5000, - onUpdate: () => { called = true }, - }) - - assert.ok(!called, 'onUpdate should not be called on server error') - } finally { + t.after(async () => { await registry.close() rmSync(tmp, { recursive: true, force: true }) - } + }); + + let called = false + + await checkForUpdates({ + currentVersion: '1.0.0', + cachePath: join(tmp, '.update-check'), + registryUrl: registry.url, + checkIntervalMs: 0, + fetchTimeoutMs: 5000, + onUpdate: () => { called = true }, + }) + + assert.ok(!called, 'onUpdate should not be called on server error') }) -test('checkForUpdates handles network timeout gracefully', async () => { +test('checkForUpdates handles network timeout gracefully', async (t) => { // Start a server that never responds const server = createServer(() => { /* intentionally never respond */ }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) const addr = server.address() as { port: number } const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-')) - try { - let called = false - - await checkForUpdates({ - currentVersion: '1.0.0', - cachePath: join(tmp, '.update-check'), - registryUrl: `http://127.0.0.1:${addr.port}`, - checkIntervalMs: 0, - fetchTimeoutMs: 500, // Very short timeout - onUpdate: () => { called = true }, - }) - - assert.ok(!called, 'onUpdate should not be called on timeout') - } finally { + t.after(async () => { await new Promise((r) => server.close(() => r())) rmSync(tmp, { recursive: true, force: true }) - } + }); + + let called = false + + await checkForUpdates({ + currentVersion: '1.0.0', + cachePath: join(tmp, '.update-check'), + registryUrl: `http://127.0.0.1:${addr.port}`, + checkIntervalMs: 0, + fetchTimeoutMs: 500, // Very short timeout + onUpdate: () => { called = true }, + }) + + assert.ok(!called, 'onUpdate should not be called on timeout') }) -test('checkForUpdates handles missing version field in response', async () => { +test('checkForUpdates handles missing version field in response', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-')) const registry = await startMockRegistry({ name: 'gsd-pi' }) // no version field - try { - let called = false - - await checkForUpdates({ - currentVersion: '1.0.0', - cachePath: join(tmp, '.update-check'), - registryUrl: registry.url, - checkIntervalMs: 0, - fetchTimeoutMs: 5000, - onUpdate: () => { called = true }, - }) - - assert.ok(!called, 'onUpdate should not be called when response has no version') - } finally { + t.after(async () => { await registry.close() rmSync(tmp, { recursive: true, force: true }) - } + }); + + let called = false + + await checkForUpdates({ + currentVersion: '1.0.0', + cachePath: join(tmp, '.update-check'), + registryUrl: registry.url, + checkIntervalMs: 0, + fetchTimeoutMs: 5000, + onUpdate: () => { called = true }, + }) + + assert.ok(!called, 'onUpdate should not be called when response has no version') }) diff --git a/src/tests/web-bridge-contract.test.ts b/src/tests/web-bridge-contract.test.ts index cf85c2d85..1e8218526 100644 --- a/src/tests/web-bridge-contract.test.ts +++ b/src/tests/web-bridge-contract.test.ts @@ -259,7 +259,7 @@ async function readSseEvents(response: Response, count: number): Promise return events; } -test("/api/boot returns current-project workspace data, resumable sessions, onboarding seam, and bridge snapshot", async () => { +test("/api/boot returns current-project workspace data, resumable sessions, onboarding seam, and bridge snapshot", async (t) => { const fixture = makeWorkspaceFixture(); const sessionPath = createSessionFile(fixture.projectCwd, fixture.sessionsDir, "sess-boot", "Resume Me"); const harness = createHarness((command, current) => { @@ -304,39 +304,39 @@ test("/api/boot returns current-project workspace data, resumable sessions, onbo getOnboardingNeeded: () => false, }); - try { - const response = await bootRoute.GET(); - assert.equal(response.status, 200); - const payload = await response.json() as any; - - assert.equal(payload.project.cwd, fixture.projectCwd); - assert.equal(payload.project.sessionsDir, fixture.sessionsDir); - assert.equal(payload.workspace.active.milestoneId, "M001"); - assert.equal(payload.workspace.active.sliceId, "S01"); - assert.equal(payload.workspace.active.taskId, "T01"); - assert.equal(payload.onboardingNeeded, false); - assert.equal(payload.resumableSessions.length, 1); - assert.equal(payload.resumableSessions[0].id, "sess-boot"); - assert.equal(payload.resumableSessions[0].path, sessionPath); - assert.equal(payload.resumableSessions[0].isActive, true); - assert.equal("firstMessage" in payload.resumableSessions[0], false); - assert.equal("allMessagesText" in payload.resumableSessions[0], false); - assert.equal("parentSessionPath" in payload.resumableSessions[0], false); - assert.equal("depth" in payload.resumableSessions[0], false); - assert.equal(payload.bridge.phase, "ready"); - assert.equal(payload.bridge.activeSessionId, "sess-boot"); - assert.equal(payload.bridge.sessionState.sessionId, "sess-boot"); - assert.equal(payload.bridge.sessionState.autoRetryEnabled, false); - assert.equal(payload.bridge.sessionState.retryInProgress, false); - assert.equal(payload.bridge.sessionState.retryAttempt, 0); - assert.equal(harness.spawnCalls, 1); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); fixture.cleanup(); - } + }); + + const response = await bootRoute.GET(); + assert.equal(response.status, 200); + const payload = await response.json() as any; + + assert.equal(payload.project.cwd, fixture.projectCwd); + assert.equal(payload.project.sessionsDir, fixture.sessionsDir); + assert.equal(payload.workspace.active.milestoneId, "M001"); + assert.equal(payload.workspace.active.sliceId, "S01"); + assert.equal(payload.workspace.active.taskId, "T01"); + assert.equal(payload.onboardingNeeded, false); + assert.equal(payload.resumableSessions.length, 1); + assert.equal(payload.resumableSessions[0].id, "sess-boot"); + assert.equal(payload.resumableSessions[0].path, sessionPath); + assert.equal(payload.resumableSessions[0].isActive, true); + assert.equal("firstMessage" in payload.resumableSessions[0], false); + assert.equal("allMessagesText" in payload.resumableSessions[0], false); + assert.equal("parentSessionPath" in payload.resumableSessions[0], false); + assert.equal("depth" in payload.resumableSessions[0], false); + assert.equal(payload.bridge.phase, "ready"); + assert.equal(payload.bridge.activeSessionId, "sess-boot"); + assert.equal(payload.bridge.sessionState.sessionId, "sess-boot"); + assert.equal(payload.bridge.sessionState.autoRetryEnabled, false); + assert.equal(payload.bridge.sessionState.retryInProgress, false); + assert.equal(payload.bridge.sessionState.retryAttempt, 0); + assert.equal(harness.spawnCalls, 1); }); -test("/api/boot uses the authoritative auto helper by default and stays snapshot-shaped", async () => { +test("/api/boot uses the authoritative auto helper by default and stays snapshot-shaped", async (t) => { const fixture = makeWorkspaceFixture(); const sessionPath = createSessionFile(fixture.projectCwd, fixture.sessionsDir, "sess-auto", "Authoritative Auto"); const authoritativeAuto = { @@ -394,27 +394,27 @@ test("/api/boot uses the authoritative auto helper by default and stays snapshot getOnboardingNeeded: () => false, }); - try { - const response = await bootRoute.GET(); - assert.equal(response.status, 200); - const payload = await response.json() as any; - - assert.deepEqual( - Object.keys(payload).sort(), - ["auto", "bridge", "onboarding", "onboardingNeeded", "project", "projectDetection", "resumableSessions", "workspace"], - "/api/boot must remain snapshot-shaped while auto truth becomes authoritative", - ); - assert.deepEqual(payload.auto, authoritativeAuto, "default boot path should read authoritative auto dashboard data"); - assert.notEqual(payload.auto.startTime, 0, "authoritative auto helper must replace the all-zero fallback payload"); - assert.equal("recovery" in payload, false, "/api/boot should not grow a recovery diagnostics payload in T01"); - assert.equal("liveState" in payload, false, "/api/boot should not expose live invalidation payloads directly"); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); fixture.cleanup(); - } + }); + + const response = await bootRoute.GET(); + assert.equal(response.status, 200); + const payload = await response.json() as any; + + assert.deepEqual( + Object.keys(payload).sort(), + ["auto", "bridge", "onboarding", "onboardingNeeded", "project", "projectDetection", "resumableSessions", "workspace"], + "/api/boot must remain snapshot-shaped while auto truth becomes authoritative", + ); + assert.deepEqual(payload.auto, authoritativeAuto, "default boot path should read authoritative auto dashboard data"); + assert.notEqual(payload.auto.startTime, 0, "authoritative auto helper must replace the all-zero fallback payload"); + assert.equal("recovery" in payload, false, "/api/boot should not grow a recovery diagnostics payload in T01"); + assert.equal("liveState" in payload, false, "/api/boot should not expose live invalidation payloads directly"); }); -test("bridge service is a singleton for the project runtime and /api/session/command forwards real RPC responses", async () => { +test("bridge service is a singleton for the project runtime and /api/session/command forwards real RPC responses", async (t) => { const fixture = makeWorkspaceFixture(); const sessionPath = createSessionFile(fixture.projectCwd, fixture.sessionsDir, "sess-shared", "Shared Session"); const harness = createHarness((command, current) => { @@ -459,40 +459,40 @@ test("bridge service is a singleton for the project runtime and /api/session/com getOnboardingNeeded: () => false, }); - try { - const serviceA = bridge.getProjectBridgeService(); - const serviceB = bridge.getProjectBridgeService(); - assert.strictEqual(serviceA, serviceB); - - const first = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "get_state" }), - }), - ); - const firstBody = await first.json() as any; - assert.equal(first.status, 200); - assert.equal(firstBody.success, true); - assert.equal(firstBody.command, "get_state"); - assert.equal(firstBody.data.sessionId, "sess-shared"); - - const second = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "get_state" }), - }), - ); - const secondBody = await second.json() as any; - assert.equal(second.status, 200); - assert.equal(secondBody.data.sessionId, "sess-shared"); - assert.equal(harness.spawnCalls, 1); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); fixture.cleanup(); - } + }); + + const serviceA = bridge.getProjectBridgeService(); + const serviceB = bridge.getProjectBridgeService(); + assert.strictEqual(serviceA, serviceB); + + const first = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "get_state" }), + }), + ); + const firstBody = await first.json() as any; + assert.equal(first.status, 200); + assert.equal(firstBody.success, true); + assert.equal(firstBody.command, "get_state"); + assert.equal(firstBody.data.sessionId, "sess-shared"); + + const second = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "get_state" }), + }), + ); + const secondBody = await second.json() as any; + assert.equal(second.status, 200); + assert.equal(secondBody.data.sessionId, "sess-shared"); + assert.equal(harness.spawnCalls, 1); }); -test("/api/session/events streams bridge status, agent events, and extension_ui_request payloads over SSE", async () => { +test("/api/session/events streams bridge status, agent events, and extension_ui_request payloads over SSE", async (t) => { const fixture = makeWorkspaceFixture(); const sessionPath = createSessionFile(fixture.projectCwd, fixture.sessionsDir, "sess-events", "Events Session"); const harness = createHarness((command, current) => { @@ -537,38 +537,38 @@ test("/api/session/events streams bridge status, agent events, and extension_ui_ getOnboardingNeeded: () => false, }); - try { - const controller = new AbortController(); - const response = await eventsRoute.GET( - new Request("http://localhost/api/session/events", { signal: controller.signal }), - ); - - harness.emit({ type: "agent_start" }); - harness.emit({ - type: "extension_ui_request", - id: "ui-1", - method: "confirm", - title: "Need approval", - message: "Continue?", - }); - - const events = await readSseEvents(response, 3); - assert.equal(events[0].type, "bridge_status"); - assert.equal(events[0].bridge.connectionCount, 1); - assert.ok(events.some((event) => event.type === "agent_start")); - assert.ok(events.some((event) => event.type === "extension_ui_request")); - - assert.equal(bridge.getProjectBridgeService().getSnapshot().connectionCount, 1); - controller.abort(); - await waitForMicrotasks(); - assert.equal(bridge.getProjectBridgeService().getSnapshot().connectionCount, 0); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); fixture.cleanup(); - } + }); + + const controller = new AbortController(); + const response = await eventsRoute.GET( + new Request("http://localhost/api/session/events", { signal: controller.signal }), + ); + + harness.emit({ type: "agent_start" }); + harness.emit({ + type: "extension_ui_request", + id: "ui-1", + method: "confirm", + title: "Need approval", + message: "Continue?", + }); + + const events = await readSseEvents(response, 3); + assert.equal(events[0].type, "bridge_status"); + assert.equal(events[0].bridge.connectionCount, 1); + assert.ok(events.some((event) => event.type === "agent_start")); + assert.ok(events.some((event) => event.type === "extension_ui_request")); + + assert.equal(bridge.getProjectBridgeService().getSnapshot().connectionCount, 1); + controller.abort(); + await waitForMicrotasks(); + assert.equal(bridge.getProjectBridgeService().getSnapshot().connectionCount, 0); }); -test("bridge command/runtime failures are inspectable and redact secret material", async () => { +test("bridge command/runtime failures are inspectable and redact secret material", async (t) => { const fixture = makeWorkspaceFixture(); const sessionPath = createSessionFile(fixture.projectCwd, fixture.sessionsDir, "sess-failure", "Failure Session"); @@ -631,33 +631,33 @@ test("bridge command/runtime failures are inspectable and redact secret material getOnboardingNeeded: () => false, }); - try { - const response = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "bash", command: "echo test" }), - }), - ); - const body = await response.json() as any; - - assert.equal(response.status, 502); - assert.equal(body.success, false); - assert.match(body.error, /authentication failed/i); - assert.doesNotMatch(body.error, /sk-test-command-secret-9999/); - - harness.stderr("fatal runtime error: sk-after-attach-12345"); - harness.exit(1); - await waitForMicrotasks(); - - const snapshot = bridge.getProjectBridgeService().getSnapshot(); - assert.equal(snapshot.phase, "failed"); - assert.equal(snapshot.lastError?.afterSessionAttachment, true); - assert.doesNotMatch(snapshot.lastError?.message ?? "", /sk-after-attach-12345|sk-test-command-secret-9999/); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); onboarding.resetOnboardingServiceForTests(); fixture.cleanup(); - } + }); + + const response = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "bash", command: "echo test" }), + }), + ); + const body = await response.json() as any; + + assert.equal(response.status, 502); + assert.equal(body.success, false); + assert.match(body.error, /authentication failed/i); + assert.doesNotMatch(body.error, /sk-test-command-secret-9999/); + + harness.stderr("fatal runtime error: sk-after-attach-12345"); + harness.exit(1); + await waitForMicrotasks(); + + const snapshot = bridge.getProjectBridgeService().getSnapshot(); + assert.equal(snapshot.phase, "failed"); + assert.equal(snapshot.lastError?.afterSessionAttachment, true); + assert.doesNotMatch(snapshot.lastError?.message ?? "", /sk-after-attach-12345|sk-test-command-secret-9999/); }); // --------------------------------------------------------------------------- @@ -665,7 +665,7 @@ test("bridge command/runtime failures are inspectable and redact secret material // (Fixes #1936: /api/boot returns 500 when readdirSync is missing) // --------------------------------------------------------------------------- -test("/api/boot lists sessions from the real filesystem via readdirSync (#1936)", async () => { +test("/api/boot lists sessions from the real filesystem via readdirSync (#1936)", async (t) => { const fixture = makeWorkspaceFixture(); const sessionPath = createSessionFile(fixture.projectCwd, fixture.sessionsDir, "sess-fs", "FS Session"); const harness = createHarness((command, current) => { @@ -712,24 +712,24 @@ test("/api/boot lists sessions from the real filesystem via readdirSync (#1936)" getOnboardingNeeded: () => false, }); - try { - const response = await bootRoute.GET(); - assert.equal(response.status, 200, "/api/boot must not return 500 — readdirSync must be available"); - const payload = await response.json() as any; - - // The real listProjectSessions should have found the session file via readdirSync - assert.ok( - Array.isArray(payload.resumableSessions), - "boot payload must include resumableSessions array", - ); - assert.equal( - payload.resumableSessions.length, - 1, - "readdirSync-based session listing must find the test session file", - ); - assert.equal(payload.resumableSessions[0].id, "sess-fs"); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); fixture.cleanup(); - } + }); + + const response = await bootRoute.GET(); + assert.equal(response.status, 200, "/api/boot must not return 500 — readdirSync must be available"); + const payload = await response.json() as any; + + // The real listProjectSessions should have found the session file via readdirSync + assert.ok( + Array.isArray(payload.resumableSessions), + "boot payload must include resumableSessions array", + ); + assert.equal( + payload.resumableSessions.length, + 1, + "readdirSync-based session listing must find the test session file", + ); + assert.equal(payload.resumableSessions[0].id, "sess-fs"); }); diff --git a/src/tests/web-bridge-terminal-contract.test.ts b/src/tests/web-bridge-terminal-contract.test.ts index 8ac38db2d..af604cace 100644 --- a/src/tests/web-bridge-terminal-contract.test.ts +++ b/src/tests/web-bridge-terminal-contract.test.ts @@ -143,7 +143,7 @@ function createHarness(onCommand: (command: any, harness: ReturnType { +test("/api/bridge-terminal/stream attaches to the main bridge runtime and forwards native terminal output", async (t) => { const fixture = makeWorkspaceFixture(); const harness = createHarness((command, current) => { if (command.type === "get_state") { @@ -197,25 +197,25 @@ test("/api/bridge-terminal/stream attaches to the main bridge runtime and forwar spawn: harness.spawn, }); - try { - const response = await streamRoute.GET( - new Request("http://localhost/api/bridge-terminal/stream?cols=132&rows=41"), - ); - - const events = await readSseEvents(response, 2); - assert.equal(events[0].type, "connected"); - assert.equal(events[1].type, "output"); - assert.match(events[1].data, /native main session/); - - assert.ok(harness.commands.some((command) => command.type === "terminal_resize" && command.cols === 132 && command.rows === 41)); - assert.ok(harness.commands.some((command) => command.type === "terminal_redraw")); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); fixture.cleanup(); - } + }); + + const response = await streamRoute.GET( + new Request("http://localhost/api/bridge-terminal/stream?cols=132&rows=41"), + ); + + const events = await readSseEvents(response, 2); + assert.equal(events[0].type, "connected"); + assert.equal(events[1].type, "output"); + assert.match(events[1].data, /native main session/); + + assert.ok(harness.commands.some((command) => command.type === "terminal_resize" && command.cols === 132 && command.rows === 41)); + assert.ok(harness.commands.some((command) => command.type === "terminal_redraw")); }); -test("bridge-terminal input and resize routes forward browser terminal traffic onto the authoritative bridge session", async () => { +test("bridge-terminal input and resize routes forward browser terminal traffic onto the authoritative bridge session", async (t) => { const fixture = makeWorkspaceFixture(); const harness = createHarness((command, current) => { if (command.type === "get_state") { @@ -266,32 +266,32 @@ test("bridge-terminal input and resize routes forward browser terminal traffic o spawn: harness.spawn, }); - try { - const inputResponse = await inputRoute.POST( - new Request("http://localhost/api/bridge-terminal/input", { - method: "POST", - body: JSON.stringify({ data: "hello from xterm" }), - }), - ); - assert.equal(inputResponse.status, 200); - - const resizeResponse = await resizeRoute.POST( - new Request("http://localhost/api/bridge-terminal/resize", { - method: "POST", - body: JSON.stringify({ cols: 140, rows: 48 }), - }), - ); - assert.equal(resizeResponse.status, 200); - - assert.ok(harness.commands.some((command) => command.type === "terminal_input" && command.data === "hello from xterm")); - assert.ok(harness.commands.some((command) => command.type === "terminal_resize" && command.cols === 140 && command.rows === 48)); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); fixture.cleanup(); - } + }); + + const inputResponse = await inputRoute.POST( + new Request("http://localhost/api/bridge-terminal/input", { + method: "POST", + body: JSON.stringify({ data: "hello from xterm" }), + }), + ); + assert.equal(inputResponse.status, 200); + + const resizeResponse = await resizeRoute.POST( + new Request("http://localhost/api/bridge-terminal/resize", { + method: "POST", + body: JSON.stringify({ cols: 140, rows: 48 }), + }), + ); + assert.equal(resizeResponse.status, 200); + + assert.ok(harness.commands.some((command) => command.type === "terminal_input" && command.data === "hello from xterm")); + assert.ok(harness.commands.some((command) => command.type === "terminal_resize" && command.cols === 140 && command.rows === 48)); }); -test("session_state_changed from the native main-session TUI refreshes bridge state and emits matching live invalidations", async () => { +test("session_state_changed from the native main-session TUI refreshes bridge state and emits matching live invalidations", async (t) => { const fixture = makeWorkspaceFixture(); const sessionAPath = join(fixture.sessionsDir, "sess-a.jsonl"); const sessionBPath = join(fixture.sessionsDir, "sess-b.jsonl"); @@ -338,30 +338,30 @@ test("session_state_changed from the native main-session TUI refreshes bridge st spawn: harness.spawn, }); - try { - const service = bridge.getProjectBridgeService(); - const unsubscribe = service.subscribe((event) => { - seenEvents.push(event as { type?: string; reason?: string }); - }); - - await service.ensureStarted(); - activeSessionId = "sess-b"; - activeSessionFile = sessionBPath; - harness.emit({ type: "session_state_changed", reason: "switch_session" }); - - await waitFor(() => { - const snapshot = service.getSnapshot(); - return snapshot.activeSessionId === "sess-b" ? snapshot : null; - }); - - assert.ok( - seenEvents.some((event) => event.type === "live_state_invalidation" && event.reason === "switch_session"), - "switch_session live_state_invalidation should be emitted when the native TUI changes the active session", - ); - - unsubscribe(); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); fixture.cleanup(); - } + }); + + const service = bridge.getProjectBridgeService(); + const unsubscribe = service.subscribe((event) => { + seenEvents.push(event as { type?: string; reason?: string }); + }); + + await service.ensureStarted(); + activeSessionId = "sess-b"; + activeSessionFile = sessionBPath; + harness.emit({ type: "session_state_changed", reason: "switch_session" }); + + await waitFor(() => { + const snapshot = service.getSnapshot(); + return snapshot.activeSessionId === "sess-b" ? snapshot : null; + }); + + assert.ok( + seenEvents.some((event) => event.type === "live_state_invalidation" && event.reason === "switch_session"), + "switch_session live_state_invalidation should be emitted when the native TUI changes the active session", + ); + + unsubscribe(); }); diff --git a/src/tests/web-cli-entry.test.ts b/src/tests/web-cli-entry.test.ts index 09eafb3f4..022431168 100644 --- a/src/tests/web-cli-entry.test.ts +++ b/src/tests/web-cli-entry.test.ts @@ -17,89 +17,83 @@ function makeFixture(paths: string[]): string { return root; } -test("resolveGsdCliEntry prefers the built loader for packaged standalone interactive sessions", () => { +test("resolveGsdCliEntry prefers the built loader for packaged standalone interactive sessions", (t) => { const packageRoot = makeFixture([ "dist/loader.js", "src/loader.ts", "src/resources/extensions/gsd/tests/resolve-ts.mjs", ]); - try { - const entry = resolveGsdCliEntry({ - packageRoot, - cwd: "/tmp/project-a", - execPath: "/custom/node", - hostKind: "packaged-standalone", - mode: "interactive", - }); + t.after(() => { rmSync(packageRoot, { recursive: true, force: true }); }); - assert.deepEqual(entry, { - command: "/custom/node", - args: [join(packageRoot, "dist", "loader.js")], - cwd: "/tmp/project-a", - }); - } finally { - rmSync(packageRoot, { recursive: true, force: true }); - } + const entry = resolveGsdCliEntry({ + packageRoot, + cwd: "/tmp/project-a", + execPath: "/custom/node", + hostKind: "packaged-standalone", + mode: "interactive", + }); + + assert.deepEqual(entry, { + command: "/custom/node", + args: [join(packageRoot, "dist", "loader.js")], + cwd: "/tmp/project-a", + }); }); -test("resolveGsdCliEntry prefers the source loader for source-dev interactive sessions", () => { +test("resolveGsdCliEntry prefers the source loader for source-dev interactive sessions", (t) => { const packageRoot = makeFixture([ "dist/loader.js", "src/loader.ts", "src/resources/extensions/gsd/tests/resolve-ts.mjs", ]); - try { - const entry = resolveGsdCliEntry({ - packageRoot, - cwd: "/tmp/project-b", - execPath: "/custom/node", - hostKind: "source-dev", - mode: "interactive", - }); + t.after(() => { rmSync(packageRoot, { recursive: true, force: true }); }); - assert.deepEqual(entry, { - command: "/custom/node", - args: [ - "--import", - pathToFileURL(join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs")).href, - "--experimental-strip-types", - join(packageRoot, "src", "loader.ts"), - ], - cwd: "/tmp/project-b", - }); - } finally { - rmSync(packageRoot, { recursive: true, force: true }); - } + const entry = resolveGsdCliEntry({ + packageRoot, + cwd: "/tmp/project-b", + execPath: "/custom/node", + hostKind: "source-dev", + mode: "interactive", + }); + + assert.deepEqual(entry, { + command: "/custom/node", + args: [ + "--import", + pathToFileURL(join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs")).href, + "--experimental-strip-types", + join(packageRoot, "src", "loader.ts"), + ], + cwd: "/tmp/project-b", + }); }); -test("resolveGsdCliEntry appends rpc arguments for bridge sessions", () => { +test("resolveGsdCliEntry appends rpc arguments for bridge sessions", (t) => { const packageRoot = makeFixture(["dist/loader.js"]); - try { - const entry = resolveGsdCliEntry({ - packageRoot, - cwd: "/tmp/project-c", - execPath: "/custom/node", - hostKind: "packaged-standalone", - mode: "rpc", - sessionDir: "/tmp/.gsd/sessions/project-c", - }); + t.after(() => { rmSync(packageRoot, { recursive: true, force: true }); }); - assert.deepEqual(entry, { - command: "/custom/node", - args: [ - join(packageRoot, "dist", "loader.js"), - "--mode", - "rpc", - "--continue", - "--session-dir", - "/tmp/.gsd/sessions/project-c", - ], - cwd: "/tmp/project-c", - }); - } finally { - rmSync(packageRoot, { recursive: true, force: true }); - } + const entry = resolveGsdCliEntry({ + packageRoot, + cwd: "/tmp/project-c", + execPath: "/custom/node", + hostKind: "packaged-standalone", + mode: "rpc", + sessionDir: "/tmp/.gsd/sessions/project-c", + }); + + assert.deepEqual(entry, { + command: "/custom/node", + args: [ + join(packageRoot, "dist", "loader.js"), + "--mode", + "rpc", + "--continue", + "--session-dir", + "/tmp/.gsd/sessions/project-c", + ], + cwd: "/tmp/project-c", + }); }); diff --git a/src/tests/web-live-interaction-contract.test.ts b/src/tests/web-live-interaction-contract.test.ts index 432c7d238..4418abb63 100644 --- a/src/tests/web-live-interaction-contract.test.ts +++ b/src/tests/web-live-interaction-contract.test.ts @@ -373,7 +373,7 @@ function routeEvent(state: MinimalLiveState, event: any): MinimalLiveState { // Tests // --------------------------------------------------------------------------- -test("(a) SSE emits extension_ui_request with method 'select' → typed payload with options and allowMultiple", async () => { +test("(a) SSE emits extension_ui_request with method 'select' → typed payload with options and allowMultiple", async (t) => { const fixture = makeWorkspaceFixture(); const sessionPath = createSessionFile(fixture.projectCwd, fixture.sessionsDir, "sess-ui", "UI Session"); const harness = createHarness((command, current) => { @@ -392,46 +392,46 @@ test("(a) SSE emits extension_ui_request with method 'select' → typed payload setupBridge(harness, fixture); - try { - const controller = new AbortController(); - const response = await eventsRoute.GET( - new Request("http://localhost/api/session/events", { signal: controller.signal }), - ); - - harness.emit({ - type: "extension_ui_request", - id: "req-select-1", - method: "select", - title: "Choose a file", - options: ["file-a.ts", "file-b.ts", "file-c.ts"], - allowMultiple: true, - }); - - const events = await readSseEvents(response, 2); // bridge_status + the UI request - controller.abort(); - await waitForMicrotasks(); - - const uiEvent = events.find((e) => e.type === "extension_ui_request"); - assert.ok(uiEvent, "extension_ui_request event received via SSE"); - assert.equal(uiEvent.id, "req-select-1"); - assert.equal(uiEvent.method, "select"); - assert.equal(uiEvent.title, "Choose a file"); - assert.deepEqual(uiEvent.options, ["file-a.ts", "file-b.ts", "file-c.ts"]); - assert.equal(uiEvent.allowMultiple, true); - - // Verify store routing: select is a blocking method → should queue - let state = createMinimalLiveState(); - state = routeEvent(state, uiEvent); - assert.equal(state.pendingUiRequests.length, 1); - assert.equal(state.pendingUiRequests[0].id, "req-select-1"); - assert.equal(state.pendingUiRequests[0].method, "select"); - assert.deepEqual(state.pendingUiRequests[0].options, ["file-a.ts", "file-b.ts", "file-c.ts"]); - assert.equal(state.pendingUiRequests[0].allowMultiple, true); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); onboarding.resetOnboardingServiceForTests(); fixture.cleanup(); - } + }); + + const controller = new AbortController(); + const response = await eventsRoute.GET( + new Request("http://localhost/api/session/events", { signal: controller.signal }), + ); + + harness.emit({ + type: "extension_ui_request", + id: "req-select-1", + method: "select", + title: "Choose a file", + options: ["file-a.ts", "file-b.ts", "file-c.ts"], + allowMultiple: true, + }); + + const events = await readSseEvents(response, 2); // bridge_status + the UI request + controller.abort(); + await waitForMicrotasks(); + + const uiEvent = events.find((e) => e.type === "extension_ui_request"); + assert.ok(uiEvent, "extension_ui_request event received via SSE"); + assert.equal(uiEvent.id, "req-select-1"); + assert.equal(uiEvent.method, "select"); + assert.equal(uiEvent.title, "Choose a file"); + assert.deepEqual(uiEvent.options, ["file-a.ts", "file-b.ts", "file-c.ts"]); + assert.equal(uiEvent.allowMultiple, true); + + // Verify store routing: select is a blocking method → should queue + let state = createMinimalLiveState(); + state = routeEvent(state, uiEvent); + assert.equal(state.pendingUiRequests.length, 1); + assert.equal(state.pendingUiRequests[0].id, "req-select-1"); + assert.equal(state.pendingUiRequests[0].method, "select"); + assert.deepEqual(state.pendingUiRequests[0].options, ["file-a.ts", "file-b.ts", "file-c.ts"]); + assert.equal(state.pendingUiRequests[0].allowMultiple, true); }); test("(b) Multiple concurrent UI requests queue correctly keyed by id", async () => { @@ -480,7 +480,7 @@ test("(b) Multiple concurrent UI requests queue correctly keyed by id", async () assert.equal(state.pendingUiRequests[3].prefill, "initial text"); }); -test("(c) Responding to a UI request posts extension_ui_response with correct id and value to the bridge", async () => { +test("(c) Responding to a UI request posts extension_ui_response with correct id and value to the bridge", async (t) => { const fixture = makeWorkspaceFixture(); const sessionPath = createSessionFile(fixture.projectCwd, fixture.sessionsDir, "sess-respond", "Respond Session"); const harness = createHarness((command, current) => { @@ -499,33 +499,33 @@ test("(c) Responding to a UI request posts extension_ui_response with correct id setupBridge(harness, fixture); - try { - // Post an extension_ui_response via the command route - const response = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "extension_ui_response", id: "req-42", value: "option-b" }), - }), - ); - - // extension_ui_response returns { ok: true } (202) because it's fire-and-forget - assert.equal(response.status, 202); - - await waitForMicrotasks(); - - // Verify the command was written to the bridge's stdin - const uiResponseCmd = harness.commands.find((c) => c.type === "extension_ui_response"); - assert.ok(uiResponseCmd, "extension_ui_response was sent to the bridge"); - assert.equal(uiResponseCmd.id, "req-42"); - assert.equal(uiResponseCmd.value, "option-b"); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); onboarding.resetOnboardingServiceForTests(); fixture.cleanup(); - } + }); + + // Post an extension_ui_response via the command route + const response = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "extension_ui_response", id: "req-42", value: "option-b" }), + }), + ); + + // extension_ui_response returns { ok: true } (202) because it's fire-and-forget + assert.equal(response.status, 202); + + await waitForMicrotasks(); + + // Verify the command was written to the bridge's stdin + const uiResponseCmd = harness.commands.find((c) => c.type === "extension_ui_response"); + assert.ok(uiResponseCmd, "extension_ui_response was sent to the bridge"); + assert.equal(uiResponseCmd.id, "req-42"); + assert.equal(uiResponseCmd.value, "option-b"); }); -test("(d) Dismissing a UI request posts cancelled: true and removes from pending", async () => { +test("(d) Dismissing a UI request posts cancelled: true and removes from pending", async (t) => { const fixture = makeWorkspaceFixture(); const sessionPath = createSessionFile(fixture.projectCwd, fixture.sessionsDir, "sess-dismiss", "Dismiss Session"); const harness = createHarness((command, current) => { @@ -543,48 +543,48 @@ test("(d) Dismissing a UI request posts cancelled: true and removes from pending setupBridge(harness, fixture); - try { - // Post a cancel response - const response = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "extension_ui_response", id: "req-99", cancelled: true }), - }), - ); - - assert.equal(response.status, 202); - await waitForMicrotasks(); - - const cancelCmd = harness.commands.find((c) => c.type === "extension_ui_response" && c.cancelled === true); - assert.ok(cancelCmd, "cancellation extension_ui_response was sent to the bridge"); - assert.equal(cancelCmd.id, "req-99"); - assert.equal(cancelCmd.cancelled, true); - - // Verify store routing: removing from pending queue - let state = createMinimalLiveState(); - state = routeEvent(state, { - type: "extension_ui_request", - id: "req-99", - method: "confirm", - title: "Confirm?", - message: "Really?", - }); - assert.equal(state.pendingUiRequests.length, 1); - - // Simulate removal (mirrors store's dismissUiRequest behavior) - state = { - ...state, - pendingUiRequests: state.pendingUiRequests.filter((r: any) => r.id !== "req-99"), - }; - assert.equal(state.pendingUiRequests.length, 0); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); onboarding.resetOnboardingServiceForTests(); fixture.cleanup(); - } + }); + + // Post a cancel response + const response = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "extension_ui_response", id: "req-99", cancelled: true }), + }), + ); + + assert.equal(response.status, 202); + await waitForMicrotasks(); + + const cancelCmd = harness.commands.find((c) => c.type === "extension_ui_response" && c.cancelled === true); + assert.ok(cancelCmd, "cancellation extension_ui_response was sent to the bridge"); + assert.equal(cancelCmd.id, "req-99"); + assert.equal(cancelCmd.cancelled, true); + + // Verify store routing: removing from pending queue + let state = createMinimalLiveState(); + state = routeEvent(state, { + type: "extension_ui_request", + id: "req-99", + method: "confirm", + title: "Confirm?", + message: "Really?", + }); + assert.equal(state.pendingUiRequests.length, 1); + + // Simulate removal (mirrors store's dismissUiRequest behavior) + state = { + ...state, + pendingUiRequests: state.pendingUiRequests.filter((r: any) => r.id !== "req-99"), + }; + assert.equal(state.pendingUiRequests.length, 0); }); -test("(e) SSE emits message_update with text delta → streamingAssistantText accumulates", async () => { +test("(e) SSE emits message_update with text delta → streamingAssistantText accumulates", async (t) => { let state = createMinimalLiveState(); state = routeEvent(state, { @@ -625,31 +625,31 @@ test("(e) SSE emits message_update with text delta → streamingAssistantText ac setupBridge(harness, fixture); - try { - const controller = new AbortController(); - const response = await eventsRoute.GET( - new Request("http://localhost/api/session/events", { signal: controller.signal }), - ); - - harness.emit({ - type: "message_update", - message: { role: "assistant", content: [] }, - assistantMessageEvent: { type: "text_delta", delta: "streamed text", contentIndex: 0, partial: {} }, - }); - - const events = await readSseEvents(response, 2); // bridge_status + message_update - controller.abort(); - await waitForMicrotasks(); - - const msgEvent = events.find((e) => e.type === "message_update"); - assert.ok(msgEvent, "message_update event received via SSE"); - assert.equal(msgEvent.assistantMessageEvent.type, "text_delta"); - assert.equal(msgEvent.assistantMessageEvent.delta, "streamed text"); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); onboarding.resetOnboardingServiceForTests(); fixture.cleanup(); - } + }); + + const controller = new AbortController(); + const response = await eventsRoute.GET( + new Request("http://localhost/api/session/events", { signal: controller.signal }), + ); + + harness.emit({ + type: "message_update", + message: { role: "assistant", content: [] }, + assistantMessageEvent: { type: "text_delta", delta: "streamed text", contentIndex: 0, partial: {} }, + }); + + const events = await readSseEvents(response, 2); // bridge_status + message_update + controller.abort(); + await waitForMicrotasks(); + + const msgEvent = events.find((e) => e.type === "message_update"); + assert.ok(msgEvent, "message_update event received via SSE"); + assert.equal(msgEvent.assistantMessageEvent.type, "text_delta"); + assert.equal(msgEvent.assistantMessageEvent.delta, "streamed text"); }); test("(f) agent_end moves streaming text to transcript and resets streaming text", async () => { @@ -813,7 +813,7 @@ test("(g-2) tool_execution_start/end update activeToolExecution", async () => { assert.equal(state.activeToolExecution, null); }); -test("(h) steer and abort commands post the correct RPC command type", async () => { +test("(h) steer and abort commands post the correct RPC command type", async (t) => { const fixture = makeWorkspaceFixture(); const sessionPath = createSessionFile(fixture.projectCwd, fixture.sessionsDir, "sess-steer", "Steer Session"); const harness = createHarness((command, current) => { @@ -853,43 +853,43 @@ test("(h) steer and abort commands post the correct RPC command type", async () setupBridge(harness, fixture); - try { - // Send steer command - const steerResponse = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "steer", message: "focus on the login flow" }), - }), - ); - assert.equal(steerResponse.status, 200); - const steerBody = await steerResponse.json() as any; - assert.equal(steerBody.success, true); - assert.equal(steerBody.command, "steer"); - - // Verify steer command reached the bridge with the correct shape - const steerCmd = harness.commands.find((c) => c.type === "steer"); - assert.ok(steerCmd, "steer command was sent to the bridge"); - assert.equal(steerCmd.message, "focus on the login flow"); - - // Send abort command - const abortResponse = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "abort" }), - }), - ); - assert.equal(abortResponse.status, 200); - const abortBody = await abortResponse.json() as any; - assert.equal(abortBody.success, true); - assert.equal(abortBody.command, "abort"); - - const abortCmd = harness.commands.find((c) => c.type === "abort"); - assert.ok(abortCmd, "abort command was sent to the bridge"); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); onboarding.resetOnboardingServiceForTests(); fixture.cleanup(); - } + }); + + // Send steer command + const steerResponse = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "steer", message: "focus on the login flow" }), + }), + ); + assert.equal(steerResponse.status, 200); + const steerBody = await steerResponse.json() as any; + assert.equal(steerBody.success, true); + assert.equal(steerBody.command, "steer"); + + // Verify steer command reached the bridge with the correct shape + const steerCmd = harness.commands.find((c) => c.type === "steer"); + assert.ok(steerCmd, "steer command was sent to the bridge"); + assert.equal(steerCmd.message, "focus on the login flow"); + + // Send abort command + const abortResponse = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "abort" }), + }), + ); + assert.equal(abortResponse.status, 200); + const abortBody = await abortResponse.json() as any; + assert.equal(abortBody.success, true); + assert.equal(abortBody.command, "abort"); + + const abortCmd = harness.commands.find((c) => c.type === "abort"); + assert.ok(abortCmd, "abort command was sent to the bridge"); }); test("(failure-path) UI response errors are visible as lastClientError and pending requests persist on failure", async () => { @@ -920,7 +920,7 @@ test("(failure-path) UI response errors are visible as lastClientError and pendi assert.equal(successState.pendingUiRequests.length, 0, "request removed on success"); }); -test("(session-controls) browser session RPCs round-trip through /api/session/command", async () => { +test("(session-controls) browser session RPCs round-trip through /api/session/command", async (t) => { const fixture = makeWorkspaceFixture(); const activeSessionPath = createSessionFile(fixture.projectCwd, fixture.sessionsDir, "sess-session", "Session Surface"); const nextSessionPath = createSessionFile(fixture.projectCwd, fixture.sessionsDir, "sess-next", "Next Session"); @@ -1036,85 +1036,85 @@ test("(session-controls) browser session RPCs round-trip through /api/session/co setupBridge(harness, fixture); - try { - const sessionResponse = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "get_session_stats" }), - }), - ); - assert.equal(sessionResponse.status, 200); - const sessionBody = await sessionResponse.json() as any; - assert.equal(sessionBody.success, true); - assert.equal(sessionBody.command, "get_session_stats"); - assert.equal(sessionBody.data.sessionId, "sess-session"); - assert.equal(sessionBody.data.tokens.total, 4600); - - const exportResponse = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "export_html", outputPath: exportPath }), - }), - ); - assert.equal(exportResponse.status, 200); - const exportBody = await exportResponse.json() as any; - assert.equal(exportBody.success, true); - assert.equal(exportBody.data.path, exportPath); - - const switchResponse = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "switch_session", sessionPath: nextSessionPath }), - }), - ); - assert.equal(switchResponse.status, 200); - const switchBody = await switchResponse.json() as any; - assert.equal(switchBody.success, true); - assert.equal(switchBody.data.cancelled, false); - - const forkMessagesResponse = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "get_fork_messages" }), - }), - ); - assert.equal(forkMessagesResponse.status, 200); - const forkMessagesBody = await forkMessagesResponse.json() as any; - assert.equal(forkMessagesBody.success, true); - assert.deepEqual(forkMessagesBody.data.messages, forkMessages); - - const forkResponse = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "fork", entryId: "entry-2" }), - }), - ); - assert.equal(forkResponse.status, 200); - const forkBody = await forkResponse.json() as any; - assert.equal(forkBody.success, true); - assert.equal(forkBody.data.cancelled, false); - assert.equal(forkBody.data.text, "Fix the slash-command dispatcher"); - - const compactResponse = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "compact", customInstructions: "Preserve blockers and current task state" }), - }), - ); - assert.equal(compactResponse.status, 200); - const compactBody = await compactResponse.json() as any; - assert.equal(compactBody.success, true); - assert.equal(compactBody.data.summary, "Compacted summary"); - assert.equal(compactBody.data.tokensBefore, 14200); - - assert.deepEqual( - harness.commands.filter((command) => command.type !== "get_state").map((command) => command.type), - ["get_session_stats", "export_html", "switch_session", "get_fork_messages", "fork", "compact"], - "browser session controls should hit the live command route with the expected RPC sequence", - ); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); onboarding.resetOnboardingServiceForTests(); fixture.cleanup(); - } + }); + + const sessionResponse = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "get_session_stats" }), + }), + ); + assert.equal(sessionResponse.status, 200); + const sessionBody = await sessionResponse.json() as any; + assert.equal(sessionBody.success, true); + assert.equal(sessionBody.command, "get_session_stats"); + assert.equal(sessionBody.data.sessionId, "sess-session"); + assert.equal(sessionBody.data.tokens.total, 4600); + + const exportResponse = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "export_html", outputPath: exportPath }), + }), + ); + assert.equal(exportResponse.status, 200); + const exportBody = await exportResponse.json() as any; + assert.equal(exportBody.success, true); + assert.equal(exportBody.data.path, exportPath); + + const switchResponse = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "switch_session", sessionPath: nextSessionPath }), + }), + ); + assert.equal(switchResponse.status, 200); + const switchBody = await switchResponse.json() as any; + assert.equal(switchBody.success, true); + assert.equal(switchBody.data.cancelled, false); + + const forkMessagesResponse = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "get_fork_messages" }), + }), + ); + assert.equal(forkMessagesResponse.status, 200); + const forkMessagesBody = await forkMessagesResponse.json() as any; + assert.equal(forkMessagesBody.success, true); + assert.deepEqual(forkMessagesBody.data.messages, forkMessages); + + const forkResponse = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "fork", entryId: "entry-2" }), + }), + ); + assert.equal(forkResponse.status, 200); + const forkBody = await forkResponse.json() as any; + assert.equal(forkBody.success, true); + assert.equal(forkBody.data.cancelled, false); + assert.equal(forkBody.data.text, "Fix the slash-command dispatcher"); + + const compactResponse = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "compact", customInstructions: "Preserve blockers and current task state" }), + }), + ); + assert.equal(compactResponse.status, 200); + const compactBody = await compactResponse.json() as any; + assert.equal(compactBody.success, true); + assert.equal(compactBody.data.summary, "Compacted summary"); + assert.equal(compactBody.data.tokensBefore, 14200); + + assert.deepEqual( + harness.commands.filter((command) => command.type !== "get_state").map((command) => command.type), + ["get_session_stats", "export_html", "switch_session", "get_fork_messages", "fork", "compact"], + "browser session controls should hit the live command route with the expected RPC sequence", + ); }); diff --git a/src/tests/web-live-state-contract.test.ts b/src/tests/web-live-state-contract.test.ts index 0edf91425..c2b1f7ecc 100644 --- a/src/tests/web-live-state-contract.test.ts +++ b/src/tests/web-live-state-contract.test.ts @@ -355,7 +355,7 @@ async function readSseEventsUntil( throw new Error("Timed out waiting for the expected SSE contract events"); } -test("/api/session/events exposes explicit live_state_invalidation events for agent and auto recovery boundaries", async () => { +test("/api/session/events exposes explicit live_state_invalidation events for agent and auto recovery boundaries", async (t) => { const fixture = makeWorkspaceFixture(); const sessionPath = createSessionFile( fixture.projectCwd, @@ -381,55 +381,55 @@ test("/api/session/events exposes explicit live_state_invalidation events for ag setupBridge(harness, fixture); - try { - const controller = new AbortController(); - const response = await eventsRoute.GET( - new Request("http://localhost/api/session/events", { signal: controller.signal }), - ); - - harness.emit({ type: "agent_end" }); - harness.emit({ type: "auto_retry_start", attempt: 1, maxAttempts: 3, delayMs: 250, errorMessage: "retry me" }); - harness.emit({ type: "auto_retry_end", success: false, attempt: 1, finalError: "still failing" }); - harness.emit({ type: "auto_compaction_start", reason: "threshold" }); - harness.emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false }); - - const events = await readSseEventsUntil( - response, - (seen) => seen.filter((event) => event.type === "live_state_invalidation").length >= 5, - ); - const invalidations = events.filter((event) => event.type === "live_state_invalidation"); - - assert.deepEqual( - invalidations.map((event) => ({ - reason: event.reason, - source: event.source, - workspaceIndexCacheInvalidated: event.workspaceIndexCacheInvalidated, - })), - [ - { reason: "agent_end", source: "bridge_event", workspaceIndexCacheInvalidated: true }, - { reason: "auto_retry_start", source: "bridge_event", workspaceIndexCacheInvalidated: false }, - { reason: "auto_retry_end", source: "bridge_event", workspaceIndexCacheInvalidated: false }, - { reason: "auto_compaction_start", source: "bridge_event", workspaceIndexCacheInvalidated: false }, - { reason: "auto_compaction_end", source: "bridge_event", workspaceIndexCacheInvalidated: false }, - ], - "live_state_invalidation reasons/sources should stay inspectable on /api/session/events", - ); - assert.deepEqual(invalidations[0].domains, ["auto", "workspace", "recovery"]); - assert.deepEqual(invalidations[1].domains, ["auto", "recovery"]); - assert.deepEqual(invalidations[2].domains, ["auto", "recovery"]); - assert.deepEqual(invalidations[3].domains, ["auto", "recovery"]); - assert.deepEqual(invalidations[4].domains, ["auto", "recovery"]); - - controller.abort(); - await waitForMicrotasks(); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); onboarding.resetOnboardingServiceForTests(); fixture.cleanup(); - } + }); + + const controller = new AbortController(); + const response = await eventsRoute.GET( + new Request("http://localhost/api/session/events", { signal: controller.signal }), + ); + + harness.emit({ type: "agent_end" }); + harness.emit({ type: "auto_retry_start", attempt: 1, maxAttempts: 3, delayMs: 250, errorMessage: "retry me" }); + harness.emit({ type: "auto_retry_end", success: false, attempt: 1, finalError: "still failing" }); + harness.emit({ type: "auto_compaction_start", reason: "threshold" }); + harness.emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false }); + + const events = await readSseEventsUntil( + response, + (seen) => seen.filter((event) => event.type === "live_state_invalidation").length >= 5, + ); + const invalidations = events.filter((event) => event.type === "live_state_invalidation"); + + assert.deepEqual( + invalidations.map((event) => ({ + reason: event.reason, + source: event.source, + workspaceIndexCacheInvalidated: event.workspaceIndexCacheInvalidated, + })), + [ + { reason: "agent_end", source: "bridge_event", workspaceIndexCacheInvalidated: true }, + { reason: "auto_retry_start", source: "bridge_event", workspaceIndexCacheInvalidated: false }, + { reason: "auto_retry_end", source: "bridge_event", workspaceIndexCacheInvalidated: false }, + { reason: "auto_compaction_start", source: "bridge_event", workspaceIndexCacheInvalidated: false }, + { reason: "auto_compaction_end", source: "bridge_event", workspaceIndexCacheInvalidated: false }, + ], + "live_state_invalidation reasons/sources should stay inspectable on /api/session/events", + ); + assert.deepEqual(invalidations[0].domains, ["auto", "workspace", "recovery"]); + assert.deepEqual(invalidations[1].domains, ["auto", "recovery"]); + assert.deepEqual(invalidations[2].domains, ["auto", "recovery"]); + assert.deepEqual(invalidations[3].domains, ["auto", "recovery"]); + assert.deepEqual(invalidations[4].domains, ["auto", "recovery"]); + + controller.abort(); + await waitForMicrotasks(); }); -test("workspace cache only busts on real boundaries and session mutations emit targeted invalidations", async () => { +test("workspace cache only busts on real boundaries and session mutations emit targeted invalidations", async (t) => { const fixture = makeWorkspaceFixture(); const activeSessionPath = createSessionFile( fixture.projectCwd, @@ -489,99 +489,99 @@ test("workspace cache only busts on real boundaries and session mutations emit t }, }); - try { - const service = bridge.getProjectBridgeService(); - await service.ensureStarted(); - const seenEvents: any[] = []; - const unsubscribe = service.subscribe((event) => { - seenEvents.push(event); - }); - - await bridge.collectBootPayload(); - await bridge.collectBootPayload(); - assert.equal(workspaceIndexCalls, 1, "boot snapshot should stay cached before any invalidation boundary fires"); - - harness.emit({ type: "agent_end" }); - await waitForMicrotasks(); - await bridge.collectBootPayload(); - assert.equal(workspaceIndexCalls, 2, "agent_end should invalidate the cached workspace snapshot"); - - harness.emit({ type: "auto_retry_start", attempt: 1, maxAttempts: 3, delayMs: 100, errorMessage: "retry me" }); - await waitForMicrotasks(); - await bridge.collectBootPayload(); - assert.equal(workspaceIndexCalls, 2, "auto_retry_start should not invalidate the workspace snapshot cache"); - - harness.emit({ type: "auto_compaction_start", reason: "threshold" }); - await waitForMicrotasks(); - await bridge.collectBootPayload(); - assert.equal(workspaceIndexCalls, 2, "auto_compaction_start should not invalidate the workspace snapshot cache"); - - const switchResponse = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "switch_session", sessionPath: otherSessionPath }), - }), - ); - assert.equal(switchResponse.status, 200); - - const newSessionResponse = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "new_session" }), - }), - ); - assert.equal(newSessionResponse.status, 200); - - const forkResponse = await commandRoute.POST( - new Request("http://localhost/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "fork", entryId: "entry-1" }), - }), - ); - assert.equal(forkResponse.status, 200); - - const renameResponse = await manageRoute.POST( - new Request("http://localhost/api/session/manage", { - method: "POST", - body: JSON.stringify({ - action: "rename", - sessionPath: otherSessionPath, - name: "Renamed Session", - }), - }), - ); - const renamePayload = await renameResponse.json() as any; - assert.equal(renameResponse.status, 200); - assert.equal(renamePayload.success, true); - assert.equal(renamePayload.mutation, "session_file"); - - await waitForMicrotasks(); - - const invalidations = seenEvents.filter((event) => event.type === "live_state_invalidation"); - const reasons = invalidations.map((event) => event.reason); - assert.ok(reasons.includes("agent_end"), "missing agent_end live_state_invalidation trigger"); - assert.ok(reasons.includes("auto_retry_start"), "missing auto_retry_start live_state_invalidation trigger"); - assert.ok(reasons.includes("auto_compaction_start"), "missing auto_compaction_start live_state_invalidation trigger"); - assert.ok(reasons.includes("switch_session"), "missing switch_session live_state_invalidation trigger"); - assert.ok(reasons.includes("new_session"), "missing new_session live_state_invalidation trigger"); - assert.ok(reasons.includes("fork"), "missing fork live_state_invalidation trigger"); - - const switchInvalidation = invalidations.find((event) => event.reason === "switch_session"); - assert.ok(switchInvalidation, "switch_session should emit a targeted freshness event"); - assert.deepEqual(switchInvalidation.domains, ["resumable_sessions", "recovery"]); - assert.equal(switchInvalidation.workspaceIndexCacheInvalidated, false); - - const renameInvalidation = invalidations.find( - (event) => event.reason === "set_session_name" && event.source === "session_manage", - ); - assert.ok(renameInvalidation, "inactive rename should emit an inspectable set_session_name invalidation"); - assert.deepEqual(renameInvalidation.domains, ["resumable_sessions"]); - assert.equal(renameInvalidation.workspaceIndexCacheInvalidated, false); - - unsubscribe(); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); onboarding.resetOnboardingServiceForTests(); fixture.cleanup(); - } + }); + + const service = bridge.getProjectBridgeService(); + await service.ensureStarted(); + const seenEvents: any[] = []; + const unsubscribe = service.subscribe((event) => { + seenEvents.push(event); + }); + + await bridge.collectBootPayload(); + await bridge.collectBootPayload(); + assert.equal(workspaceIndexCalls, 1, "boot snapshot should stay cached before any invalidation boundary fires"); + + harness.emit({ type: "agent_end" }); + await waitForMicrotasks(); + await bridge.collectBootPayload(); + assert.equal(workspaceIndexCalls, 2, "agent_end should invalidate the cached workspace snapshot"); + + harness.emit({ type: "auto_retry_start", attempt: 1, maxAttempts: 3, delayMs: 100, errorMessage: "retry me" }); + await waitForMicrotasks(); + await bridge.collectBootPayload(); + assert.equal(workspaceIndexCalls, 2, "auto_retry_start should not invalidate the workspace snapshot cache"); + + harness.emit({ type: "auto_compaction_start", reason: "threshold" }); + await waitForMicrotasks(); + await bridge.collectBootPayload(); + assert.equal(workspaceIndexCalls, 2, "auto_compaction_start should not invalidate the workspace snapshot cache"); + + const switchResponse = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "switch_session", sessionPath: otherSessionPath }), + }), + ); + assert.equal(switchResponse.status, 200); + + const newSessionResponse = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "new_session" }), + }), + ); + assert.equal(newSessionResponse.status, 200); + + const forkResponse = await commandRoute.POST( + new Request("http://localhost/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "fork", entryId: "entry-1" }), + }), + ); + assert.equal(forkResponse.status, 200); + + const renameResponse = await manageRoute.POST( + new Request("http://localhost/api/session/manage", { + method: "POST", + body: JSON.stringify({ + action: "rename", + sessionPath: otherSessionPath, + name: "Renamed Session", + }), + }), + ); + const renamePayload = await renameResponse.json() as any; + assert.equal(renameResponse.status, 200); + assert.equal(renamePayload.success, true); + assert.equal(renamePayload.mutation, "session_file"); + + await waitForMicrotasks(); + + const invalidations = seenEvents.filter((event) => event.type === "live_state_invalidation"); + const reasons = invalidations.map((event) => event.reason); + assert.ok(reasons.includes("agent_end"), "missing agent_end live_state_invalidation trigger"); + assert.ok(reasons.includes("auto_retry_start"), "missing auto_retry_start live_state_invalidation trigger"); + assert.ok(reasons.includes("auto_compaction_start"), "missing auto_compaction_start live_state_invalidation trigger"); + assert.ok(reasons.includes("switch_session"), "missing switch_session live_state_invalidation trigger"); + assert.ok(reasons.includes("new_session"), "missing new_session live_state_invalidation trigger"); + assert.ok(reasons.includes("fork"), "missing fork live_state_invalidation trigger"); + + const switchInvalidation = invalidations.find((event) => event.reason === "switch_session"); + assert.ok(switchInvalidation, "switch_session should emit a targeted freshness event"); + assert.deepEqual(switchInvalidation.domains, ["resumable_sessions", "recovery"]); + assert.equal(switchInvalidation.workspaceIndexCacheInvalidated, false); + + const renameInvalidation = invalidations.find( + (event) => event.reason === "set_session_name" && event.source === "session_manage", + ); + assert.ok(renameInvalidation, "inactive rename should emit an inspectable set_session_name invalidation"); + assert.deepEqual(renameInvalidation.domains, ["resumable_sessions"]); + assert.equal(renameInvalidation.workspaceIndexCacheInvalidated, false); + + unsubscribe(); }); diff --git a/src/tests/web-mode-cli.test.ts b/src/tests/web-mode-cli.test.ts index 179bd6566..c1e0ffe6f 100644 --- a/src/tests/web-mode-cli.test.ts +++ b/src/tests/web-mode-cli.test.ts @@ -35,57 +35,55 @@ test('web mode launcher defines or imports a browser opener', () => { assert.match(source, /openBrowser/) }) -test('cli.ts branches to web mode before interactive startup and preserves cwd-scoped launch inputs', async () => { +test('cli.ts branches to web mode before interactive startup and preserves cwd-scoped launch inputs', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-cli-')) const cwd = join(tmp, 'project space') mkdirSync(cwd, { recursive: true }) let launchInputs: { cwd: string; projectSessionsDir: string; agentDir: string } | undefined - try { - const cliSource = readFileSync(join(projectRoot, 'src', 'cli.ts'), 'utf-8') - const branchIndex = cliSource.indexOf('const webBranch = await runWebCliBranch') - const modelRegistryIndex = cliSource.indexOf('const modelRegistry =') - assert.ok(branchIndex !== -1, 'cli.ts contains an explicit web branch handoff') - assert.ok(modelRegistryIndex !== -1, 'cli.ts still contains the model-registry startup path') - assert.ok(branchIndex < modelRegistryIndex, 'web branch runs before interactive startup state is constructed') + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - const result = await cliWeb.runWebCliBranch(cliWeb.parseCliArgs(['node', 'dist/loader.js', '--web']), { - cwd: () => cwd, - runWebMode: async (options) => { - launchInputs = options - return { - mode: 'web', - ok: true, - cwd: options.cwd, - projectSessionsDir: options.projectSessionsDir, - host: '127.0.0.1', - port: 43123, - url: 'http://127.0.0.1:43123', - hostKind: 'source-dev', - hostPath: '/tmp/fake-web/package.json', - hostRoot: '/tmp/fake-web', - } - }, - }) + const cliSource = readFileSync(join(projectRoot, 'src', 'cli.ts'), 'utf-8') + const branchIndex = cliSource.indexOf('const webBranch = await runWebCliBranch') + const modelRegistryIndex = cliSource.indexOf('const modelRegistry =') + assert.ok(branchIndex !== -1, 'cli.ts contains an explicit web branch handoff') + assert.ok(modelRegistryIndex !== -1, 'cli.ts still contains the model-registry startup path') + assert.ok(branchIndex < modelRegistryIndex, 'web branch runs before interactive startup state is constructed') - assert.equal(result.handled, true) - if (!result.handled) throw new Error('expected --web branch to be handled') - assert.equal(result.exitCode, 0) - assert.deepEqual(launchInputs, { - cwd, - projectSessionsDir: cliWeb.getProjectSessionsDir(cwd), - agentDir: join(process.env.HOME || '', '.gsd', 'agent'), - host: undefined, - port: undefined, - allowedOrigins: undefined, - }) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + const result = await cliWeb.runWebCliBranch(cliWeb.parseCliArgs(['node', 'dist/loader.js', '--web']), { + cwd: () => cwd, + runWebMode: async (options) => { + launchInputs = options + return { + mode: 'web', + ok: true, + cwd: options.cwd, + projectSessionsDir: options.projectSessionsDir, + host: '127.0.0.1', + port: 43123, + url: 'http://127.0.0.1:43123', + hostKind: 'source-dev', + hostPath: '/tmp/fake-web/package.json', + hostRoot: '/tmp/fake-web', + } + }, + }) + + assert.equal(result.handled, true) + if (!result.handled) throw new Error('expected --web branch to be handled') + assert.equal(result.exitCode, 0) + assert.deepEqual(launchInputs, { + cwd, + projectSessionsDir: cliWeb.getProjectSessionsDir(cwd), + agentDir: join(process.env.HOME || '', '.gsd', 'agent'), + host: undefined, + port: undefined, + allowedOrigins: undefined, + }) }) -test('launchWebMode prefers the packaged standalone host and opens the resolved URL', async () => { +test('launchWebMode prefers the packaged standalone host and opens the resolved URL', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-host-')) const standaloneRoot = join(tmp, 'dist', 'web', 'standalone') const serverPath = join(standaloneRoot, 'server.js') @@ -103,163 +101,155 @@ test('launchWebMode prefers the packaged standalone host and opens the resolved const pidFilePath = join(tmp, 'web-server.pid') - try { - const status = await webMode.launchWebMode( - { - cwd: '/tmp/current-project', - projectSessionsDir: '/tmp/.gsd/sessions/--tmp-current-project--', - agentDir: '/tmp/.gsd/agent', - packageRoot: tmp, - }, - { - initResources: () => { - initResourcesCalled = true - }, - resolvePort: async () => 45123, - execPath: '/custom/node', - env: { TEST_ENV: '1' }, - spawn: (command, args, options) => { - spawnInvocation = { command, args, options: options as Record } - return { - pid: 99999, - once: () => undefined, - unref: () => { - unrefCalled = true - }, - } as any - }, - waitForBootReady: async () => undefined, - openBrowser: (url) => { - openedUrl = url - }, - pidFilePath, - writePidFile: (path, pid) => { - writtenPid = { path, pid } - webMode.writePidFile(path, pid) - }, - stderr: { - write(chunk: string) { - stderrOutput += chunk - return true - }, - }, - }, - ) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - assert.equal(status.ok, true) - if (!status.ok) throw new Error('expected successful web launch status') - assert.equal(status.hostKind, 'packaged-standalone') - assert.equal(status.hostPath, serverPath) - assert.equal(status.url, 'http://127.0.0.1:45123') - assert.equal(initResourcesCalled, true) - assert.equal(unrefCalled, true) - // The browser URL now includes a random auth token as a fragment - assert.match(openedUrl, /^http:\/\/127\.0\.0\.1:45123\/#token=[a-f0-9]{64}$/) - // Extract the auth token the launcher generated so we can verify it was - // passed consistently to both the env and the browser URL. - const authToken = openedUrl.replace('http://127.0.0.1:45123/#token=', '') - assert.deepEqual(spawnInvocation, { - command: '/custom/node', - args: [serverPath], - options: { - cwd: standaloneRoot, - detached: true, - stdio: 'ignore', - env: { - TEST_ENV: '1', - HOSTNAME: '127.0.0.1', - PORT: '45123', - GSD_WEB_HOST: '127.0.0.1', - GSD_WEB_PORT: '45123', - GSD_WEB_AUTH_TOKEN: authToken, - GSD_WEB_PROJECT_CWD: '/tmp/current-project', - GSD_WEB_PROJECT_SESSIONS_DIR: '/tmp/.gsd/sessions/--tmp-current-project--', - GSD_WEB_PACKAGE_ROOT: tmp, - GSD_WEB_HOST_KIND: 'packaged-standalone', + const status = await webMode.launchWebMode( + { + cwd: '/tmp/current-project', + projectSessionsDir: '/tmp/.gsd/sessions/--tmp-current-project--', + agentDir: '/tmp/.gsd/agent', + packageRoot: tmp, + }, + { + initResources: () => { + initResourcesCalled = true + }, + resolvePort: async () => 45123, + execPath: '/custom/node', + env: { TEST_ENV: '1' }, + spawn: (command, args, options) => { + spawnInvocation = { command, args, options: options as Record } + return { + pid: 99999, + once: () => undefined, + unref: () => { + unrefCalled = true + }, + } as any + }, + waitForBootReady: async () => undefined, + openBrowser: (url) => { + openedUrl = url + }, + pidFilePath, + writePidFile: (path, pid) => { + writtenPid = { path, pid } + webMode.writePidFile(path, pid) + }, + stderr: { + write(chunk: string) { + stderrOutput += chunk + return true }, }, - }) - assert.match(stderrOutput, /status=started/) - assert.match(stderrOutput, /port=45123/) - // PID file must be written with the spawned process's PID - assert.deepEqual(writtenPid, { path: pidFilePath, pid: 99999 }) - assert.equal(webMode.readPidFile(pidFilePath), 99999) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + }, + ) + + assert.equal(status.ok, true) + if (!status.ok) throw new Error('expected successful web launch status') + assert.equal(status.hostKind, 'packaged-standalone') + assert.equal(status.hostPath, serverPath) + assert.equal(status.url, 'http://127.0.0.1:45123') + assert.equal(initResourcesCalled, true) + assert.equal(unrefCalled, true) + // The browser URL now includes a random auth token as a fragment + assert.match(openedUrl, /^http:\/\/127\.0\.0\.1:45123\/#token=[a-f0-9]{64}$/) + // Extract the auth token the launcher generated so we can verify it was + // passed consistently to both the env and the browser URL. + const authToken = openedUrl.replace('http://127.0.0.1:45123/#token=', '') + assert.deepEqual(spawnInvocation, { + command: '/custom/node', + args: [serverPath], + options: { + cwd: standaloneRoot, + detached: true, + stdio: 'ignore', + env: { + TEST_ENV: '1', + HOSTNAME: '127.0.0.1', + PORT: '45123', + GSD_WEB_HOST: '127.0.0.1', + GSD_WEB_PORT: '45123', + GSD_WEB_AUTH_TOKEN: authToken, + GSD_WEB_PROJECT_CWD: '/tmp/current-project', + GSD_WEB_PROJECT_SESSIONS_DIR: '/tmp/.gsd/sessions/--tmp-current-project--', + GSD_WEB_PACKAGE_ROOT: tmp, + GSD_WEB_HOST_KIND: 'packaged-standalone', + }, + }, + }) + assert.match(stderrOutput, /status=started/) + assert.match(stderrOutput, /port=45123/) + // PID file must be written with the spawned process's PID + assert.deepEqual(writtenPid, { path: pidFilePath, pid: 99999 }) + assert.equal(webMode.readPidFile(pidFilePath), 99999) }) -test('stopWebMode kills process by PID and removes PID file', () => { +test('stopWebMode kills process by PID and removes PID file', (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-stop-')) const pidFilePath = join(tmp, 'web-server.pid') let stderrOutput = '' let killedPid: number | undefined - try { - webMode.writePidFile(pidFilePath, 12345) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - const result = webMode.stopWebMode({ - pidFilePath, - readPidFile: webMode.readPidFile, - deletePidFile: webMode.deletePidFile, - stderr: { write: (chunk: string) => { stderrOutput += chunk; return true } }, - // Override process.kill to avoid killing a real process in tests - }) + webMode.writePidFile(pidFilePath, 12345) - // Since PID 12345 is almost certainly dead, stopWebMode should succeed by treating ESRCH as "already gone" - assert.equal(result.ok, true) - assert.match(stderrOutput, /pid=12345/) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + const result = webMode.stopWebMode({ + pidFilePath, + readPidFile: webMode.readPidFile, + deletePidFile: webMode.deletePidFile, + stderr: { write: (chunk: string) => { stderrOutput += chunk; return true } }, + // Override process.kill to avoid killing a real process in tests + }) + + // Since PID 12345 is almost certainly dead, stopWebMode should succeed by treating ESRCH as "already gone" + assert.equal(result.ok, true) + assert.match(stderrOutput, /pid=12345/) }) -test('stopWebMode reports error when no PID file exists', () => { +test('stopWebMode reports error when no PID file exists', (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-stop-nopid-')) const pidFilePath = join(tmp, 'web-server.pid') let stderrOutput = '' - try { - const result = webMode.stopWebMode({ - pidFilePath, - readPidFile: webMode.readPidFile, - deletePidFile: webMode.deletePidFile, - stderr: { write: (chunk: string) => { stderrOutput += chunk; return true } }, - }) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - assert.equal(result.ok, false) - assert.equal(result.reason, 'no-pid-file') - assert.match(stderrOutput, /not running/) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + const result = webMode.stopWebMode({ + pidFilePath, + readPidFile: webMode.readPidFile, + deletePidFile: webMode.deletePidFile, + stderr: { write: (chunk: string) => { stderrOutput += chunk; return true } }, + }) + + assert.equal(result.ok, false) + assert.equal(result.reason, 'no-pid-file') + assert.match(stderrOutput, /not running/) }) -test('runWebCliBranch handles "web stop" subcommand without --web flag', async () => { +test('runWebCliBranch handles "web stop" subcommand without --web flag', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-branch-stop-')) const pidFilePath = join(tmp, 'web-server.pid') let stderrOutput = '' - try { - const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', 'web', 'stop']) - assert.equal(flags.web, undefined) - assert.deepEqual(flags.messages, ['web', 'stop']) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - const result = await cliWeb.runWebCliBranch(flags, { - stopWebMode: (deps) => { - return webMode.stopWebMode({ ...deps, pidFilePath }) - }, - stderr: { write: (chunk: string) => { stderrOutput += chunk; return true } }, - }) + const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', 'web', 'stop']) + assert.equal(flags.web, undefined) + assert.deepEqual(flags.messages, ['web', 'stop']) - assert.equal(result.handled, true) - if (!result.handled) throw new Error('expected web stop to be handled') - assert.equal(result.exitCode, 1) // no PID file — expected failure - if (result.action !== 'stop') throw new Error('expected action=stop') - assert.equal(result.stopResult.ok, false) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + const result = await cliWeb.runWebCliBranch(flags, { + stopWebMode: (deps) => { + return webMode.stopWebMode({ ...deps, pidFilePath }) + }, + stderr: { write: (chunk: string) => { stderrOutput += chunk; return true } }, + }) + + assert.equal(result.handled, true) + if (!result.handled) throw new Error('expected web stop to be handled') + assert.equal(result.exitCode, 1) // no PID file — expected failure + if (result.action !== 'stop') throw new Error('expected action=stop') + assert.equal(result.stopResult.ok, false) }) // ─── Path argument tests ────────────────────────────────────────────── @@ -284,116 +274,110 @@ test('parseCliArgs does not capture --web followed by a flag as path', () => { assert.equal(flags.model, 'test') }) -test('gsd web is handled as web start with path', async () => { +test('gsd web is handled as web start with path', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-path-')) const projectDir = join(tmp, 'my-project') mkdirSync(projectDir, { recursive: true }) let launchedCwd = '' - try { - const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', 'web', projectDir]) - assert.deepEqual(flags.messages, ['web', projectDir]) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - const result = await cliWeb.runWebCliBranch(flags, { - runWebMode: async (options) => { - launchedCwd = options.cwd - return { - mode: 'web', - ok: true, - cwd: options.cwd, - projectSessionsDir: options.projectSessionsDir, - host: '127.0.0.1', - port: 43124, - url: 'http://127.0.0.1:43124', - hostKind: 'source-dev', - hostPath: '/tmp/fake-web/package.json', - hostRoot: '/tmp/fake-web', - } - }, - }) + const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', 'web', projectDir]) + assert.deepEqual(flags.messages, ['web', projectDir]) - assert.equal(result.handled, true) - if (!result.handled) throw new Error('expected web branch to be handled') - assert.equal(result.exitCode, 0) - assert.equal(launchedCwd, projectDir) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + const result = await cliWeb.runWebCliBranch(flags, { + runWebMode: async (options) => { + launchedCwd = options.cwd + return { + mode: 'web', + ok: true, + cwd: options.cwd, + projectSessionsDir: options.projectSessionsDir, + host: '127.0.0.1', + port: 43124, + url: 'http://127.0.0.1:43124', + hostKind: 'source-dev', + hostPath: '/tmp/fake-web/package.json', + hostRoot: '/tmp/fake-web', + } + }, + }) + + assert.equal(result.handled, true) + if (!result.handled) throw new Error('expected web branch to be handled') + assert.equal(result.exitCode, 0) + assert.equal(launchedCwd, projectDir) }) -test('gsd web start resolves path and launches', async () => { +test('gsd web start resolves path and launches', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-start-path-')) const projectDir = join(tmp, 'another-project') mkdirSync(projectDir, { recursive: true }) let launchedCwd = '' - try { - const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', 'web', 'start', projectDir]) - assert.deepEqual(flags.messages, ['web', 'start', projectDir]) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - const result = await cliWeb.runWebCliBranch(flags, { - runWebMode: async (options) => { - launchedCwd = options.cwd - return { - mode: 'web', - ok: true, - cwd: options.cwd, - projectSessionsDir: options.projectSessionsDir, - host: '127.0.0.1', - port: 43125, - url: 'http://127.0.0.1:43125', - hostKind: 'source-dev', - hostPath: '/tmp/fake-web/package.json', - hostRoot: '/tmp/fake-web', - } - }, - }) + const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', 'web', 'start', projectDir]) + assert.deepEqual(flags.messages, ['web', 'start', projectDir]) - assert.equal(result.handled, true) - if (!result.handled) throw new Error('expected web branch to be handled') - assert.equal(result.exitCode, 0) - assert.equal(launchedCwd, projectDir) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + const result = await cliWeb.runWebCliBranch(flags, { + runWebMode: async (options) => { + launchedCwd = options.cwd + return { + mode: 'web', + ok: true, + cwd: options.cwd, + projectSessionsDir: options.projectSessionsDir, + host: '127.0.0.1', + port: 43125, + url: 'http://127.0.0.1:43125', + hostKind: 'source-dev', + hostPath: '/tmp/fake-web/package.json', + hostRoot: '/tmp/fake-web', + } + }, + }) + + assert.equal(result.handled, true) + if (!result.handled) throw new Error('expected web branch to be handled') + assert.equal(result.exitCode, 0) + assert.equal(launchedCwd, projectDir) }) -test('gsd --web resolves path and launches', async () => { +test('gsd --web resolves path and launches', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-flag-path-')) const projectDir = join(tmp, 'flagged-project') mkdirSync(projectDir, { recursive: true }) let launchedCwd = '' - try { - const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', '--web', projectDir]) - assert.equal(flags.web, true) - assert.equal(flags.webPath, projectDir) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - const result = await cliWeb.runWebCliBranch(flags, { - runWebMode: async (options) => { - launchedCwd = options.cwd - return { - mode: 'web', - ok: true, - cwd: options.cwd, - projectSessionsDir: options.projectSessionsDir, - host: '127.0.0.1', - port: 43126, - url: 'http://127.0.0.1:43126', - hostKind: 'source-dev', - hostPath: '/tmp/fake-web/package.json', - hostRoot: '/tmp/fake-web', - } - }, - }) + const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', '--web', projectDir]) + assert.equal(flags.web, true) + assert.equal(flags.webPath, projectDir) - assert.equal(result.handled, true) - if (!result.handled) throw new Error('expected web branch to be handled') - assert.equal(result.exitCode, 0) - assert.equal(launchedCwd, projectDir) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + const result = await cliWeb.runWebCliBranch(flags, { + runWebMode: async (options) => { + launchedCwd = options.cwd + return { + mode: 'web', + ok: true, + cwd: options.cwd, + projectSessionsDir: options.projectSessionsDir, + host: '127.0.0.1', + port: 43126, + url: 'http://127.0.0.1:43126', + hostKind: 'source-dev', + hostPath: '/tmp/fake-web/package.json', + hostRoot: '/tmp/fake-web', + } + }, + }) + + assert.equal(result.handled, true) + if (!result.handled) throw new Error('expected web branch to be handled') + assert.equal(result.exitCode, 0) + assert.equal(launchedCwd, projectDir) }) test('gsd --web fails with clear error', async () => { @@ -414,81 +398,75 @@ test('gsd --web fails with clear error', async () => { assert.match(stderrOutput, /does not exist/) }) -test('launch failure surfaces status and reason before browser open', async () => { +test('launch failure surfaces status and reason before browser open', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-missing-host-')) let openedUrl = '' let stderrOutput = '' - try { - const status = await webMode.launchWebMode( - { - cwd: '/tmp/current-project', - projectSessionsDir: '/tmp/.gsd/sessions/--tmp-current-project--', - agentDir: '/tmp/.gsd/agent', - packageRoot: tmp, - }, - { - openBrowser: (url) => { - openedUrl = url - }, - stderr: { - write(chunk: string) { - stderrOutput += chunk - return true - }, - }, - }, - ) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - assert.equal(status.ok, false) - if (status.ok) throw new Error('expected failed web launch status') - assert.equal(status.hostPath, null) - assert.equal(status.url, null) - assert.equal(openedUrl, '') - assert.match(status.failureReason, /host bootstrap not found/) - assert.match(stderrOutput, /status=failed/) - assert.match(stderrOutput, /reason=host bootstrap not found/) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + const status = await webMode.launchWebMode( + { + cwd: '/tmp/current-project', + projectSessionsDir: '/tmp/.gsd/sessions/--tmp-current-project--', + agentDir: '/tmp/.gsd/agent', + packageRoot: tmp, + }, + { + openBrowser: (url) => { + openedUrl = url + }, + stderr: { + write(chunk: string) { + stderrOutput += chunk + return true + }, + }, + }, + ) + + assert.equal(status.ok, false) + if (status.ok) throw new Error('expected failed web launch status') + assert.equal(status.hostPath, null) + assert.equal(status.url, null) + assert.equal(openedUrl, '') + assert.match(status.failureReason, /host bootstrap not found/) + assert.match(stderrOutput, /status=failed/) + assert.match(stderrOutput, /reason=host bootstrap not found/) }) // ─── Instance registry tests ───────────────────────────────────────── -test('registerInstance and readInstanceRegistry round-trip', () => { +test('registerInstance and readInstanceRegistry round-trip', (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-registry-')) const registryPath = join(tmp, 'web-instances.json') - try { - webMode.registerInstance('/tmp/project-a', { pid: 1001, port: 3000, url: 'http://127.0.0.1:3000' }, registryPath) - webMode.registerInstance('/tmp/project-b', { pid: 1002, port: 3001, url: 'http://127.0.0.1:3001' }, registryPath) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - const registry = webMode.readInstanceRegistry(registryPath) - assert.equal(Object.keys(registry).length, 2) - assert.equal(registry[resolve('/tmp/project-a')]?.pid, 1001) - assert.equal(registry[resolve('/tmp/project-b')]?.port, 3001) - assert.ok(registry[resolve('/tmp/project-a')]?.startedAt) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + webMode.registerInstance('/tmp/project-a', { pid: 1001, port: 3000, url: 'http://127.0.0.1:3000' }, registryPath) + webMode.registerInstance('/tmp/project-b', { pid: 1002, port: 3001, url: 'http://127.0.0.1:3001' }, registryPath) + + const registry = webMode.readInstanceRegistry(registryPath) + assert.equal(Object.keys(registry).length, 2) + assert.equal(registry[resolve('/tmp/project-a')]?.pid, 1001) + assert.equal(registry[resolve('/tmp/project-b')]?.port, 3001) + assert.ok(registry[resolve('/tmp/project-a')]?.startedAt) }) -test('unregisterInstance removes a single entry', () => { +test('unregisterInstance removes a single entry', (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-unreg-')) const registryPath = join(tmp, 'web-instances.json') - try { - webMode.registerInstance('/tmp/project-a', { pid: 1001, port: 3000, url: 'http://127.0.0.1:3000' }, registryPath) - webMode.registerInstance('/tmp/project-b', { pid: 1002, port: 3001, url: 'http://127.0.0.1:3001' }, registryPath) - webMode.unregisterInstance('/tmp/project-a', registryPath) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - const registry = webMode.readInstanceRegistry(registryPath) - assert.equal(Object.keys(registry).length, 1) - assert.equal(registry[resolve('/tmp/project-a')], undefined) - assert.equal(registry[resolve('/tmp/project-b')]?.pid, 1002) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + webMode.registerInstance('/tmp/project-a', { pid: 1001, port: 3000, url: 'http://127.0.0.1:3000' }, registryPath) + webMode.registerInstance('/tmp/project-b', { pid: 1002, port: 3001, url: 'http://127.0.0.1:3001' }, registryPath) + webMode.unregisterInstance('/tmp/project-a', registryPath) + + const registry = webMode.readInstanceRegistry(registryPath) + assert.equal(Object.keys(registry).length, 1) + assert.equal(registry[resolve('/tmp/project-a')], undefined) + assert.equal(registry[resolve('/tmp/project-b')]?.pid, 1002) }) test('stopWebMode with projectCwd reports not-found when not in registry', () => { @@ -525,153 +503,137 @@ test('gsd web stop all is parsed and dispatched', async () => { assert.equal(stopOptions?.projectCwd, undefined) }) -test('gsd web stop is parsed and dispatched with resolved path', async () => { +test('gsd web stop is parsed and dispatched with resolved path', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-stop-path-')) let stopOptions: { projectCwd?: string; all?: boolean } | undefined - try { - const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', 'web', 'stop', tmp]) - const result = await cliWeb.runWebCliBranch(flags, { - cwd: () => '/', - stopWebMode: (_deps, opts) => { - stopOptions = opts - return { ok: true, stoppedCount: 1 } - }, - stderr: { write: () => true }, - }) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - assert.equal(result.handled, true) - if (!result.handled) throw new Error('expected handled') - assert.equal(result.exitCode, 0) - assert.equal(stopOptions?.projectCwd, tmp) - assert.equal(stopOptions?.all, false) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', 'web', 'stop', tmp]) + const result = await cliWeb.runWebCliBranch(flags, { + cwd: () => '/', + stopWebMode: (_deps, opts) => { + stopOptions = opts + return { ok: true, stoppedCount: 1 } + }, + stderr: { write: () => true }, + }) + + assert.equal(result.handled, true) + if (!result.handled) throw new Error('expected handled') + assert.equal(result.exitCode, 0) + assert.equal(stopOptions?.projectCwd, tmp) + assert.equal(stopOptions?.all, false) }) // ─── Context-aware launch detection tests ────────────────────────────── -test('resolveContextAwareCwd returns project cwd when inside a project under dev root', () => { +test('resolveContextAwareCwd returns project cwd when inside a project under dev root', (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-ctx-aware-')) const devRoot = join(tmp, 'devroot') const projectA = join(devRoot, 'projectA') const prefsPath = join(tmp, 'web-preferences.json') - try { - mkdirSync(projectA, { recursive: true }) - writeFileSync(prefsPath, JSON.stringify({ devRoot })) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - const result = cliWeb.resolveContextAwareCwd(projectA, prefsPath) - assert.equal(result, projectA) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + mkdirSync(projectA, { recursive: true }) + writeFileSync(prefsPath, JSON.stringify({ devRoot })) + + const result = cliWeb.resolveContextAwareCwd(projectA, prefsPath) + assert.equal(result, projectA) }) -test('resolveContextAwareCwd returns cwd unchanged when AT dev root', () => { +test('resolveContextAwareCwd returns cwd unchanged when AT dev root', (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-ctx-aware-')) const devRoot = join(tmp, 'devroot') const prefsPath = join(tmp, 'web-preferences.json') - try { - mkdirSync(devRoot, { recursive: true }) - writeFileSync(prefsPath, JSON.stringify({ devRoot })) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - const result = cliWeb.resolveContextAwareCwd(devRoot, prefsPath) - assert.equal(result, devRoot) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + mkdirSync(devRoot, { recursive: true }) + writeFileSync(prefsPath, JSON.stringify({ devRoot })) + + const result = cliWeb.resolveContextAwareCwd(devRoot, prefsPath) + assert.equal(result, devRoot) }) -test('resolveContextAwareCwd returns cwd unchanged when no dev root configured', () => { +test('resolveContextAwareCwd returns cwd unchanged when no dev root configured', (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-ctx-aware-')) const prefsPath = join(tmp, 'web-preferences.json') const cwd = join(tmp, 'somedir') - try { - mkdirSync(cwd, { recursive: true }) - writeFileSync(prefsPath, JSON.stringify({ theme: 'dark' })) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - const result = cliWeb.resolveContextAwareCwd(cwd, prefsPath) - assert.equal(result, cwd) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + mkdirSync(cwd, { recursive: true }) + writeFileSync(prefsPath, JSON.stringify({ theme: 'dark' })) + + const result = cliWeb.resolveContextAwareCwd(cwd, prefsPath) + assert.equal(result, cwd) }) -test('resolveContextAwareCwd returns cwd unchanged when prefs file missing', () => { +test('resolveContextAwareCwd returns cwd unchanged when prefs file missing', (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-ctx-aware-')) const prefsPath = join(tmp, 'nonexistent-prefs.json') const cwd = join(tmp, 'somedir') - try { - mkdirSync(cwd, { recursive: true }) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - const result = cliWeb.resolveContextAwareCwd(cwd, prefsPath) - assert.equal(result, cwd) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + mkdirSync(cwd, { recursive: true }) + + const result = cliWeb.resolveContextAwareCwd(cwd, prefsPath) + assert.equal(result, cwd) }) -test('resolveContextAwareCwd returns cwd unchanged when dev root path is stale', () => { +test('resolveContextAwareCwd returns cwd unchanged when dev root path is stale', (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-ctx-aware-')) const prefsPath = join(tmp, 'web-preferences.json') const cwd = join(tmp, 'somedir') const staleDevRoot = join(tmp, 'nonexistent-devroot') - try { - mkdirSync(cwd, { recursive: true }) - writeFileSync(prefsPath, JSON.stringify({ devRoot: staleDevRoot })) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - const result = cliWeb.resolveContextAwareCwd(cwd, prefsPath) - assert.equal(result, cwd) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + mkdirSync(cwd, { recursive: true }) + writeFileSync(prefsPath, JSON.stringify({ devRoot: staleDevRoot })) + + const result = cliWeb.resolveContextAwareCwd(cwd, prefsPath) + assert.equal(result, cwd) }) -test('resolveContextAwareCwd resolves nested cwd to one-level-deep project', () => { +test('resolveContextAwareCwd resolves nested cwd to one-level-deep project', (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-ctx-aware-')) const devRoot = join(tmp, 'devroot') const projectA = join(devRoot, 'projectA') const nested = join(projectA, 'src', 'components', 'deep') const prefsPath = join(tmp, 'web-preferences.json') - try { - mkdirSync(nested, { recursive: true }) - writeFileSync(prefsPath, JSON.stringify({ devRoot })) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - const result = cliWeb.resolveContextAwareCwd(nested, prefsPath) - assert.equal(result, projectA) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + mkdirSync(nested, { recursive: true }) + writeFileSync(prefsPath, JSON.stringify({ devRoot })) + + const result = cliWeb.resolveContextAwareCwd(nested, prefsPath) + assert.equal(result, projectA) }) -test('resolveContextAwareCwd returns cwd unchanged when outside dev root', () => { +test('resolveContextAwareCwd returns cwd unchanged when outside dev root', (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-ctx-aware-')) const devRoot = join(tmp, 'devroot') const outsideDir = join(tmp, 'elsewhere') const prefsPath = join(tmp, 'web-preferences.json') - try { - mkdirSync(devRoot, { recursive: true }) - mkdirSync(outsideDir, { recursive: true }) - writeFileSync(prefsPath, JSON.stringify({ devRoot })) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - const result = cliWeb.resolveContextAwareCwd(outsideDir, prefsPath) - assert.equal(result, outsideDir) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + mkdirSync(devRoot, { recursive: true }) + mkdirSync(outsideDir, { recursive: true }) + writeFileSync(prefsPath, JSON.stringify({ devRoot })) + + const result = cliWeb.resolveContextAwareCwd(outsideDir, prefsPath) + assert.equal(result, outsideDir) }) // ─── Stale instance cleanup tests ───────────────────────────────────── -test('launchWebMode kills stale instance for same cwd before spawning', async () => { +test('launchWebMode kills stale instance for same cwd before spawning', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-stale-')) const standaloneRoot = join(tmp, 'dist', 'web', 'standalone') const serverPath = join(standaloneRoot, 'server.js') @@ -688,54 +650,52 @@ test('launchWebMode kills stale instance for same cwd before spawning', async () let stderrOutput = '' let spawnCalled = false - try { - const status = await webMode.launchWebMode( - { - cwd, - projectSessionsDir: '/tmp/.gsd/sessions/stale', - agentDir: '/tmp/.gsd/agent', - packageRoot: tmp, - }, - { - initResources: () => {}, - resolvePort: async () => 45200, - execPath: '/custom/node', - env: { TEST_ENV: '1' }, - spawn: (command, args, options) => { - spawnCalled = true - return { - pid: 88888, - once: () => undefined, - unref: () => {}, - } as any - }, - waitForBootReady: async () => undefined, - openBrowser: () => {}, - pidFilePath, - writePidFile: webMode.writePidFile, - registryPath, - stderr: { - write(chunk: string) { - stderrOutput += chunk - return true - }, - }, - }, - ) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - assert.equal(status.ok, true) - assert.equal(spawnCalled, true) - // Stale instance for same cwd should have been cleaned up - assert.match(stderrOutput, /Cleaning up stale/) - // New instance should be registered - const registry = webMode.readInstanceRegistry(registryPath) - assert.equal(registry[resolve(cwd)]?.pid, 88888) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + const status = await webMode.launchWebMode( + { + cwd, + projectSessionsDir: '/tmp/.gsd/sessions/stale', + agentDir: '/tmp/.gsd/agent', + packageRoot: tmp, + }, + { + initResources: () => {}, + resolvePort: async () => 45200, + execPath: '/custom/node', + env: { TEST_ENV: '1' }, + spawn: (command, args, options) => { + spawnCalled = true + return { + pid: 88888, + once: () => undefined, + unref: () => {}, + } as any + }, + waitForBootReady: async () => undefined, + openBrowser: () => {}, + pidFilePath, + writePidFile: webMode.writePidFile, + registryPath, + stderr: { + write(chunk: string) { + stderrOutput += chunk + return true + }, + }, + }, + ) + + assert.equal(status.ok, true) + assert.equal(spawnCalled, true) + // Stale instance for same cwd should have been cleaned up + assert.match(stderrOutput, /Cleaning up stale/) + // New instance should be registered + const registry = webMode.readInstanceRegistry(registryPath) + assert.equal(registry[resolve(cwd)]?.pid, 88888) }) -test('launchWebMode does not log cleanup when no stale instance exists', async () => { +test('launchWebMode does not log cleanup when no stale instance exists', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-no-stale-')) const standaloneRoot = join(tmp, 'dist', 'web', 'standalone') const serverPath = join(standaloneRoot, 'server.js') @@ -747,42 +707,40 @@ test('launchWebMode does not log cleanup when no stale instance exists', async ( let stderrOutput = '' - try { - const status = await webMode.launchWebMode( - { - cwd: '/tmp/clean-project', - projectSessionsDir: '/tmp/.gsd/sessions/clean', - agentDir: '/tmp/.gsd/agent', - packageRoot: tmp, - }, - { - initResources: () => {}, - resolvePort: async () => 45201, - execPath: '/custom/node', - env: { TEST_ENV: '1' }, - spawn: () => ({ - pid: 88889, - once: () => undefined, - unref: () => {}, - } as any), - waitForBootReady: async () => undefined, - openBrowser: () => {}, - pidFilePath, - writePidFile: webMode.writePidFile, - registryPath, - stderr: { - write(chunk: string) { - stderrOutput += chunk - return true - }, + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); + + const status = await webMode.launchWebMode( + { + cwd: '/tmp/clean-project', + projectSessionsDir: '/tmp/.gsd/sessions/clean', + agentDir: '/tmp/.gsd/agent', + packageRoot: tmp, + }, + { + initResources: () => {}, + resolvePort: async () => 45201, + execPath: '/custom/node', + env: { TEST_ENV: '1' }, + spawn: () => ({ + pid: 88889, + once: () => undefined, + unref: () => {}, + } as any), + waitForBootReady: async () => undefined, + openBrowser: () => {}, + pidFilePath, + writePidFile: webMode.writePidFile, + registryPath, + stderr: { + write(chunk: string) { + stderrOutput += chunk + return true }, }, - ) + }, + ) - assert.equal(status.ok, true) - // No cleanup message when no stale instance exists - assert.equal(stderrOutput.includes('Cleaning up stale'), false) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + assert.equal(status.ok, true) + // No cleanup message when no stale instance exists + assert.equal(stderrOutput.includes('Cleaning up stale'), false) }) diff --git a/src/tests/web-mode-network-flags.test.ts b/src/tests/web-mode-network-flags.test.ts index 216f269ce..29a57f542 100644 --- a/src/tests/web-mode-network-flags.test.ts +++ b/src/tests/web-mode-network-flags.test.ts @@ -65,7 +65,7 @@ test('parseCliArgs does not set network flags when not provided', () => { // ─── launchWebMode env forwarding ──────────────────────────────────── -test('launchWebMode forwards custom host, port, and allowed origins to subprocess env', async () => { +test('launchWebMode forwards custom host, port, and allowed origins to subprocess env', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-net-')) const standaloneRoot = join(tmp, 'dist', 'web', 'standalone') const serverPath = join(standaloneRoot, 'server.js') @@ -74,47 +74,45 @@ test('launchWebMode forwards custom host, port, and allowed origins to subproces let spawnEnv: Record | undefined - try { - const status = await webMode.launchWebMode( - { - cwd: '/tmp/project', - projectSessionsDir: '/tmp/.gsd/sessions', - agentDir: '/tmp/.gsd/agent', - packageRoot: tmp, - host: '0.0.0.0', - port: 8080, - allowedOrigins: ['http://192.168.1.10:8080', 'http://tailscale-host:8080'], - }, - { - initResources: () => {}, - spawn: (_command, _args, options) => { - spawnEnv = (options as { env: Record }).env - return { pid: 99999, once: () => undefined, unref: () => {} } as any - }, - waitForBootReady: async () => undefined, - openBrowser: () => {}, - stderr: { write: () => true }, - }, - ) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - assert.equal(status.ok, true) - if (!status.ok) throw new Error('expected success') - assert.equal(status.host, '0.0.0.0') - assert.equal(status.port, 8080) - assert.equal(status.url, 'http://0.0.0.0:8080') + const status = await webMode.launchWebMode( + { + cwd: '/tmp/project', + projectSessionsDir: '/tmp/.gsd/sessions', + agentDir: '/tmp/.gsd/agent', + packageRoot: tmp, + host: '0.0.0.0', + port: 8080, + allowedOrigins: ['http://192.168.1.10:8080', 'http://tailscale-host:8080'], + }, + { + initResources: () => {}, + spawn: (_command, _args, options) => { + spawnEnv = (options as { env: Record }).env + return { pid: 99999, once: () => undefined, unref: () => {} } as any + }, + waitForBootReady: async () => undefined, + openBrowser: () => {}, + stderr: { write: () => true }, + }, + ) - assert.ok(spawnEnv) - assert.equal(spawnEnv!.HOSTNAME, '0.0.0.0') - assert.equal(spawnEnv!.PORT, '8080') - assert.equal(spawnEnv!.GSD_WEB_HOST, '0.0.0.0') - assert.equal(spawnEnv!.GSD_WEB_PORT, '8080') - assert.equal(spawnEnv!.GSD_WEB_ALLOWED_ORIGINS, 'http://192.168.1.10:8080,http://tailscale-host:8080') - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + assert.equal(status.ok, true) + if (!status.ok) throw new Error('expected success') + assert.equal(status.host, '0.0.0.0') + assert.equal(status.port, 8080) + assert.equal(status.url, 'http://0.0.0.0:8080') + + assert.ok(spawnEnv) + assert.equal(spawnEnv!.HOSTNAME, '0.0.0.0') + assert.equal(spawnEnv!.PORT, '8080') + assert.equal(spawnEnv!.GSD_WEB_HOST, '0.0.0.0') + assert.equal(spawnEnv!.GSD_WEB_PORT, '8080') + assert.equal(spawnEnv!.GSD_WEB_ALLOWED_ORIGINS, 'http://192.168.1.10:8080,http://tailscale-host:8080') }) -test('launchWebMode omits GSD_WEB_ALLOWED_ORIGINS when none provided', async () => { +test('launchWebMode omits GSD_WEB_ALLOWED_ORIGINS when none provided', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-no-origins-')) const standaloneRoot = join(tmp, 'dist', 'web', 'standalone') const serverPath = join(standaloneRoot, 'server.js') @@ -123,79 +121,75 @@ test('launchWebMode omits GSD_WEB_ALLOWED_ORIGINS when none provided', async () let spawnEnv: Record | undefined - try { - await webMode.launchWebMode( - { - cwd: '/tmp/project', - projectSessionsDir: '/tmp/.gsd/sessions', - agentDir: '/tmp/.gsd/agent', - packageRoot: tmp, - }, - { - initResources: () => {}, - resolvePort: async () => 45000, - env: { CLEAN_ENV: '1' }, - spawn: (_command, _args, options) => { - spawnEnv = (options as { env: Record }).env - return { pid: 99999, once: () => undefined, unref: () => {} } as any - }, - waitForBootReady: async () => undefined, - openBrowser: () => {}, - stderr: { write: () => true }, - }, - ) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - assert.ok(spawnEnv) - assert.equal(spawnEnv!.GSD_WEB_ALLOWED_ORIGINS, undefined) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + await webMode.launchWebMode( + { + cwd: '/tmp/project', + projectSessionsDir: '/tmp/.gsd/sessions', + agentDir: '/tmp/.gsd/agent', + packageRoot: tmp, + }, + { + initResources: () => {}, + resolvePort: async () => 45000, + env: { CLEAN_ENV: '1' }, + spawn: (_command, _args, options) => { + spawnEnv = (options as { env: Record }).env + return { pid: 99999, once: () => undefined, unref: () => {} } as any + }, + waitForBootReady: async () => undefined, + openBrowser: () => {}, + stderr: { write: () => true }, + }, + ) + + assert.ok(spawnEnv) + assert.equal(spawnEnv!.GSD_WEB_ALLOWED_ORIGINS, undefined) }) // ─── runWebCliBranch end-to-end forwarding ─────────────────────────── -test('runWebCliBranch forwards --host, --port, --allowed-origins to launchWebMode', async () => { +test('runWebCliBranch forwards --host, --port, --allowed-origins to launchWebMode', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-branch-flags-')) const projectDir = join(tmp, 'project') mkdirSync(projectDir, { recursive: true }) let receivedOptions: Record | undefined - try { - const flags = cliWeb.parseCliArgs([ - 'node', 'dist/loader.js', '--web', projectDir, - '--host', '0.0.0.0', - '--port', '9000', - '--allowed-origins', 'http://my-host:9000', - ]) + t.after(() => { rmSync(tmp, { recursive: true, force: true }) }); - const result = await cliWeb.runWebCliBranch(flags, { - runWebMode: async (options) => { - receivedOptions = options as unknown as Record - return { - mode: 'web' as const, - ok: true as const, - cwd: options.cwd, - projectSessionsDir: options.projectSessionsDir, - host: '0.0.0.0', - port: 9000, - url: 'http://0.0.0.0:9000', - hostKind: 'source-dev' as const, - hostPath: '/tmp/fake-web/package.json', - hostRoot: '/tmp/fake-web', - } - }, - stderr: { write: () => true }, - }) + const flags = cliWeb.parseCliArgs([ + 'node', 'dist/loader.js', '--web', projectDir, + '--host', '0.0.0.0', + '--port', '9000', + '--allowed-origins', 'http://my-host:9000', + ]) - assert.equal(result.handled, true) - if (!result.handled) throw new Error('expected handled') - assert.equal(result.exitCode, 0) - assert.ok(receivedOptions) - assert.equal(receivedOptions!.host, '0.0.0.0') - assert.equal(receivedOptions!.port, 9000) - assert.deepEqual(receivedOptions!.allowedOrigins, ['http://my-host:9000']) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } + const result = await cliWeb.runWebCliBranch(flags, { + runWebMode: async (options) => { + receivedOptions = options as unknown as Record + return { + mode: 'web' as const, + ok: true as const, + cwd: options.cwd, + projectSessionsDir: options.projectSessionsDir, + host: '0.0.0.0', + port: 9000, + url: 'http://0.0.0.0:9000', + hostKind: 'source-dev' as const, + hostPath: '/tmp/fake-web/package.json', + hostRoot: '/tmp/fake-web', + } + }, + stderr: { write: () => true }, + }) + + assert.equal(result.handled, true) + if (!result.handled) throw new Error('expected handled') + assert.equal(result.exitCode, 0) + assert.ok(receivedOptions) + assert.equal(receivedOptions!.host, '0.0.0.0') + assert.equal(receivedOptions!.port, 9000) + assert.deepEqual(receivedOptions!.allowedOrigins, ['http://my-host:9000']) }) diff --git a/src/tests/web-multi-project-contract.test.ts b/src/tests/web-multi-project-contract.test.ts index 25ac4e02d..e3dc12660 100644 --- a/src/tests/web-multi-project-contract.test.ts +++ b/src/tests/web-multi-project-contract.test.ts @@ -230,7 +230,7 @@ function createHarness(sessionId: string) { // Tests — multi-project bridge coexistence // --------------------------------------------------------------------------- -test("multi-project: getProjectBridgeServiceForCwd returns distinct instances for different project paths", async () => { +test("multi-project: getProjectBridgeServiceForCwd returns distinct instances for different project paths", async (t) => { const fixtureA = makeWorkspaceFixture("A"); const fixtureB = makeWorkspaceFixture("B"); @@ -247,23 +247,23 @@ test("multi-project: getProjectBridgeServiceForCwd returns distinct instances fo getOnboardingNeeded: () => false, }); - try { - const bridgeA = bridge.getProjectBridgeServiceForCwd(fixtureA.projectCwd); - const bridgeB = bridge.getProjectBridgeServiceForCwd(fixtureB.projectCwd); - assert.notStrictEqual(bridgeA, bridgeB, "bridges for different paths must be distinct instances"); - - const snapA = bridgeA.getSnapshot(); - const snapB = bridgeB.getSnapshot(); - assert.equal(snapA.projectCwd, fixtureA.projectCwd); - assert.equal(snapB.projectCwd, fixtureB.projectCwd); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); fixtureA.cleanup(); fixtureB.cleanup(); - } + }); + + const bridgeA = bridge.getProjectBridgeServiceForCwd(fixtureA.projectCwd); + const bridgeB = bridge.getProjectBridgeServiceForCwd(fixtureB.projectCwd); + assert.notStrictEqual(bridgeA, bridgeB, "bridges for different paths must be distinct instances"); + + const snapA = bridgeA.getSnapshot(); + const snapB = bridgeB.getSnapshot(); + assert.equal(snapA.projectCwd, fixtureA.projectCwd); + assert.equal(snapB.projectCwd, fixtureB.projectCwd); }); -test("multi-project: getProjectBridgeServiceForCwd returns same instance for same path", async () => { +test("multi-project: getProjectBridgeServiceForCwd returns same instance for same path", async (t) => { const fixtureA = makeWorkspaceFixture("idempotent"); bridge.configureBridgeServiceForTests({ @@ -279,17 +279,17 @@ test("multi-project: getProjectBridgeServiceForCwd returns same instance for sam getOnboardingNeeded: () => false, }); - try { - const first = bridge.getProjectBridgeServiceForCwd(fixtureA.projectCwd); - const second = bridge.getProjectBridgeServiceForCwd(fixtureA.projectCwd); - assert.strictEqual(first, second, "same path must return the same instance"); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); fixtureA.cleanup(); - } + }); + + const first = bridge.getProjectBridgeServiceForCwd(fixtureA.projectCwd); + const second = bridge.getProjectBridgeServiceForCwd(fixtureA.projectCwd); + assert.strictEqual(first, second, "same path must return the same instance"); }); -test("multi-project: each bridge receives commands independently", async () => { +test("multi-project: each bridge receives commands independently", async (t) => { const fixtureA = makeWorkspaceFixture("cmd-A"); const fixtureB = makeWorkspaceFixture("cmd-B"); const sessionPathA = createSessionFile(fixtureA.projectCwd, fixtureA.sessionsDir, "sess-A", "Session A"); @@ -320,43 +320,43 @@ test("multi-project: each bridge receives commands independently", async () => { getOnboardingNeeded: () => false, }); - try { - const bridgeA = bridge.getProjectBridgeServiceForCwd(fixtureA.projectCwd); - const bridgeB = bridge.getProjectBridgeServiceForCwd(fixtureB.projectCwd); - - // Start both bridges - await bridgeA.ensureStarted(); - await bridgeB.ensureStarted(); - - // Send get_state to bridge A - const responseA = await bridgeA.sendInput({ type: "get_state" } as any); - assert.equal(responseA?.success, true); - assert.equal((responseA as any).data.sessionId, "sess-A"); - - // Send get_state to bridge B - const responseB = await bridgeB.sendInput({ type: "get_state" } as any); - assert.equal(responseB?.success, true); - assert.equal((responseB as any).data.sessionId, "sess-B"); - - // Each harness only got its own commands - assert.ok(harnessA.commands.length >= 1, "harness A received commands"); - assert.ok(harnessB.commands.length >= 1, "harness B received commands"); - assert.ok( - harnessA.commands.every((c: any) => c.type === "get_state"), - "harness A only got get_state commands", - ); - assert.ok( - harnessB.commands.every((c: any) => c.type === "get_state"), - "harness B only got get_state commands", - ); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); fixtureA.cleanup(); fixtureB.cleanup(); - } + }); + + const bridgeA = bridge.getProjectBridgeServiceForCwd(fixtureA.projectCwd); + const bridgeB = bridge.getProjectBridgeServiceForCwd(fixtureB.projectCwd); + + // Start both bridges + await bridgeA.ensureStarted(); + await bridgeB.ensureStarted(); + + // Send get_state to bridge A + const responseA = await bridgeA.sendInput({ type: "get_state" } as any); + assert.equal(responseA?.success, true); + assert.equal((responseA as any).data.sessionId, "sess-A"); + + // Send get_state to bridge B + const responseB = await bridgeB.sendInput({ type: "get_state" } as any); + assert.equal(responseB?.success, true); + assert.equal((responseB as any).data.sessionId, "sess-B"); + + // Each harness only got its own commands + assert.ok(harnessA.commands.length >= 1, "harness A received commands"); + assert.ok(harnessB.commands.length >= 1, "harness B received commands"); + assert.ok( + harnessA.commands.every((c: any) => c.type === "get_state"), + "harness A only got get_state commands", + ); + assert.ok( + harnessB.commands.every((c: any) => c.type === "get_state"), + "harness B only got get_state commands", + ); }); -test("multi-project: SSE subscribers are isolated per bridge", async () => { +test("multi-project: SSE subscribers are isolated per bridge", async (t) => { const fixtureA = makeWorkspaceFixture("sse-A"); const fixtureB = makeWorkspaceFixture("sse-B"); @@ -375,52 +375,52 @@ test("multi-project: SSE subscribers are isolated per bridge", async () => { getOnboardingNeeded: () => false, }); - try { - const bridgeA = bridge.getProjectBridgeServiceForCwd(fixtureA.projectCwd); - const bridgeB = bridge.getProjectBridgeServiceForCwd(fixtureB.projectCwd); - - const eventsA: any[] = []; - const eventsB: any[] = []; - - const unsubA = bridgeA.subscribe((event) => eventsA.push(event)); - const unsubB = bridgeB.subscribe((event) => eventsB.push(event)); - - // Subscribe fires an initial bridge_status event for each - const initialA = eventsA.length; - const initialB = eventsB.length; - - // Start bridge A so it has a child process - await bridgeA.ensureStarted(); - await waitForMicrotasks(); - - // Filter to only non-bridge_status events that we emit manually - const agentEventsA: any[] = []; - const agentEventsB: any[] = []; - - const unsubA2 = bridgeA.subscribe((event) => { - if (event.type !== "bridge_status") agentEventsA.push(event); - }); - const unsubB2 = bridgeB.subscribe((event) => { - if (event.type !== "bridge_status") agentEventsB.push(event); - }); - - // Emit an agent event on bridge A's child process - harnessA.emit({ type: "agent_start" }); - await waitForMicrotasks(); - - // Bridge A's subscriber should see it; bridge B's should not - assert.ok(agentEventsA.length > 0, "bridge A subscriber should see agent_start"); - assert.equal(agentEventsB.length, 0, "bridge B subscriber should NOT see events from bridge A"); - - unsubA(); - unsubB(); - unsubA2(); - unsubB2(); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); fixtureA.cleanup(); fixtureB.cleanup(); - } + }); + + const bridgeA = bridge.getProjectBridgeServiceForCwd(fixtureA.projectCwd); + const bridgeB = bridge.getProjectBridgeServiceForCwd(fixtureB.projectCwd); + + const eventsA: any[] = []; + const eventsB: any[] = []; + + const unsubA = bridgeA.subscribe((event) => eventsA.push(event)); + const unsubB = bridgeB.subscribe((event) => eventsB.push(event)); + + // Subscribe fires an initial bridge_status event for each + const initialA = eventsA.length; + const initialB = eventsB.length; + + // Start bridge A so it has a child process + await bridgeA.ensureStarted(); + await waitForMicrotasks(); + + // Filter to only non-bridge_status events that we emit manually + const agentEventsA: any[] = []; + const agentEventsB: any[] = []; + + const unsubA2 = bridgeA.subscribe((event) => { + if (event.type !== "bridge_status") agentEventsA.push(event); + }); + const unsubB2 = bridgeB.subscribe((event) => { + if (event.type !== "bridge_status") agentEventsB.push(event); + }); + + // Emit an agent event on bridge A's child process + harnessA.emit({ type: "agent_start" }); + await waitForMicrotasks(); + + // Bridge A's subscriber should see it; bridge B's should not + assert.ok(agentEventsA.length > 0, "bridge A subscriber should see agent_start"); + assert.equal(agentEventsB.length, 0, "bridge B subscriber should NOT see events from bridge A"); + + unsubA(); + unsubB(); + unsubA2(); + unsubB2(); }); test("multi-project: resolveProjectCwd reads ?project= from request URL", () => { @@ -430,7 +430,7 @@ test("multi-project: resolveProjectCwd reads ?project= from request URL", () => assert.equal(result, "/tmp/my-project"); }); -test("multi-project: resolveProjectCwd falls back to GSD_WEB_PROJECT_CWD when no ?project= present", () => { +test("multi-project: resolveProjectCwd falls back to GSD_WEB_PROJECT_CWD when no ?project= present", (t) => { bridge.configureBridgeServiceForTests({ env: { ...process.env, @@ -443,17 +443,15 @@ test("multi-project: resolveProjectCwd falls back to GSD_WEB_PROJECT_CWD when no getOnboardingNeeded: () => false, }); - try { - const result = bridge.resolveProjectCwd( - new Request("http://localhost/api/boot"), - ); - assert.equal(result, "/fallback/path"); - } finally { - bridge.configureBridgeServiceForTests(null); - } + t.after(() => { bridge.configureBridgeServiceForTests(null); }); + + const result = bridge.resolveProjectCwd( + new Request("http://localhost/api/boot"), + ); + assert.equal(result, "/fallback/path"); }); -test("multi-project: getProjectBridgeService backward compat shim works", async () => { +test("multi-project: getProjectBridgeService backward compat shim works", async (t) => { const fixture = makeWorkspaceFixture("compat"); const harness = createHarness("sess-compat"); @@ -470,23 +468,23 @@ test("multi-project: getProjectBridgeService backward compat shim works", async getOnboardingNeeded: () => false, }); - try { - const service = bridge.getProjectBridgeService(); - assert.ok(service, "getProjectBridgeService() should return a BridgeService"); - const snapshot = service.getSnapshot(); - assert.equal(snapshot.projectCwd, fixture.projectCwd, "backward compat shim should use env-resolved projectCwd"); - assert.equal(snapshot.phase, "idle"); - - // Same instance as getProjectBridgeServiceForCwd with the same path - const directService = bridge.getProjectBridgeServiceForCwd(fixture.projectCwd); - assert.strictEqual(service, directService, "backward compat shim should return same instance as direct lookup"); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); fixture.cleanup(); - } + }); + + const service = bridge.getProjectBridgeService(); + assert.ok(service, "getProjectBridgeService() should return a BridgeService"); + const snapshot = service.getSnapshot(); + assert.equal(snapshot.projectCwd, fixture.projectCwd, "backward compat shim should use env-resolved projectCwd"); + assert.equal(snapshot.phase, "idle"); + + // Same instance as getProjectBridgeServiceForCwd with the same path + const directService = bridge.getProjectBridgeServiceForCwd(fixture.projectCwd); + assert.strictEqual(service, directService, "backward compat shim should return same instance as direct lookup"); }); -test("multi-project: resetBridgeServiceForTests clears all registry entries", async () => { +test("multi-project: resetBridgeServiceForTests clears all registry entries", async (t) => { const fixtureA = makeWorkspaceFixture("reset-A"); const fixtureB = makeWorkspaceFixture("reset-B"); @@ -503,38 +501,38 @@ test("multi-project: resetBridgeServiceForTests clears all registry entries", as getOnboardingNeeded: () => false, }); - try { - // Create two bridge instances - const beforeA = bridge.getProjectBridgeServiceForCwd(fixtureA.projectCwd); - const beforeB = bridge.getProjectBridgeServiceForCwd(fixtureB.projectCwd); - assert.notStrictEqual(beforeA, beforeB); - - // Reset clears the registry - await bridge.resetBridgeServiceForTests(); - - // Re-configure after reset (reset clears overrides too) - bridge.configureBridgeServiceForTests({ - env: { - ...process.env, - GSD_WEB_PROJECT_CWD: fixtureA.projectCwd, - GSD_WEB_PROJECT_SESSIONS_DIR: fixtureA.sessionsDir, - GSD_WEB_PACKAGE_ROOT: repoRoot, - }, - spawn: createHarness("unused").spawn, - indexWorkspace: async () => fakeWorkspaceIndex(), - getAutoDashboardData: () => fakeAutoDashboardData(), - getOnboardingNeeded: () => false, - }); - - // Should get new instances - const afterA = bridge.getProjectBridgeServiceForCwd(fixtureA.projectCwd); - const afterB = bridge.getProjectBridgeServiceForCwd(fixtureB.projectCwd); - assert.notStrictEqual(afterA, beforeA, "reset must create fresh instances for path A"); - assert.notStrictEqual(afterB, beforeB, "reset must create fresh instances for path B"); - assert.notStrictEqual(afterA, afterB, "new instances should still be distinct"); - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests(); fixtureA.cleanup(); fixtureB.cleanup(); - } + }); + + // Create two bridge instances + const beforeA = bridge.getProjectBridgeServiceForCwd(fixtureA.projectCwd); + const beforeB = bridge.getProjectBridgeServiceForCwd(fixtureB.projectCwd); + assert.notStrictEqual(beforeA, beforeB); + + // Reset clears the registry + await bridge.resetBridgeServiceForTests(); + + // Re-configure after reset (reset clears overrides too) + bridge.configureBridgeServiceForTests({ + env: { + ...process.env, + GSD_WEB_PROJECT_CWD: fixtureA.projectCwd, + GSD_WEB_PROJECT_SESSIONS_DIR: fixtureA.sessionsDir, + GSD_WEB_PACKAGE_ROOT: repoRoot, + }, + spawn: createHarness("unused").spawn, + indexWorkspace: async () => fakeWorkspaceIndex(), + getAutoDashboardData: () => fakeAutoDashboardData(), + getOnboardingNeeded: () => false, + }); + + // Should get new instances + const afterA = bridge.getProjectBridgeServiceForCwd(fixtureA.projectCwd); + const afterB = bridge.getProjectBridgeServiceForCwd(fixtureB.projectCwd); + assert.notStrictEqual(afterA, beforeA, "reset must create fresh instances for path A"); + assert.notStrictEqual(afterB, beforeB, "reset must create fresh instances for path B"); + assert.notStrictEqual(afterA, afterB, "new instances should still be distinct"); }); diff --git a/src/tests/web-onboarding-contract.test.ts b/src/tests/web-onboarding-contract.test.ts index d757d9f6a..aedb3e1ce 100644 --- a/src/tests/web-onboarding-contract.test.ts +++ b/src/tests/web-onboarding-contract.test.ts @@ -304,60 +304,60 @@ function configureBridgeFixture(fixture: { projectCwd: string; sessionsDir: stri return harness; } -test("boot and onboarding routes expose locked required state plus explicitly skippable optional setup when auth is missing", async () => { +test("boot and onboarding routes expose locked required state plus explicitly skippable optional setup when auth is missing", async (t) => { const fixture = makeWorkspaceFixture(); clearOnboardingEnv(); const authStorage = AuthStorage.inMemory({}); configureBridgeFixture(fixture, "sess-missing-auth"); onboarding.configureOnboardingServiceForTests({ authStorage, getEnvApiKey: noEnvApiKey }); - try { - const bootResponse = await bootRoute.GET(projectRequest(fixture.projectCwd, "/api/boot")); - assert.equal(bootResponse.status, 200); - const bootPayload = (await bootResponse.json()) as any; - - assert.equal(bootPayload.onboardingNeeded, true); - assert.equal(bootPayload.onboarding.status, "blocked"); - assert.equal(bootPayload.onboarding.locked, true); - assert.equal(bootPayload.onboarding.lockReason, "required_setup"); - assert.equal(bootPayload.onboarding.bridgeAuthRefresh.phase, "idle"); - assert.equal(bootPayload.onboarding.required.satisfied, false); - assert.equal(bootPayload.onboarding.required.satisfiedBy, null); - assert.equal(bootPayload.onboarding.optional.skippable, true); - assert.ok(bootPayload.onboarding.optional.sections.every((section: any) => section.blocking === false)); - - const providerIds = bootPayload.onboarding.required.providers.map((provider: any) => provider.id); - assert.deepEqual(providerIds, [ - "anthropic", - "openai", - "github-copilot", - "openai-codex", - "google-gemini-cli", - "google-antigravity", - "google", - "groq", - "xai", - "openrouter", - "mistral", - ]); - const anthropicProvider = bootPayload.onboarding.required.providers.find((provider: any) => provider.id === "anthropic"); - assert.equal(anthropicProvider.supports.apiKey, true); - assert.equal(anthropicProvider.supports.oauthAvailable, true); - - const onboardingResponse = await onboardingRoute.GET(projectRequest(fixture.projectCwd, "/api/onboarding")); - assert.equal(onboardingResponse.status, 200); - const onboardingPayload = (await onboardingResponse.json()) as any; - assert.equal(onboardingPayload.onboarding.locked, true); - assert.equal(onboardingPayload.onboarding.optional.skippable, true); - } finally { + t.after(async () => { onboarding.resetOnboardingServiceForTests(); await bridge.resetBridgeServiceForTests(); restoreOnboardingEnv(); fixture.cleanup(); - } + }); + + const bootResponse = await bootRoute.GET(projectRequest(fixture.projectCwd, "/api/boot")); + assert.equal(bootResponse.status, 200); + const bootPayload = (await bootResponse.json()) as any; + + assert.equal(bootPayload.onboardingNeeded, true); + assert.equal(bootPayload.onboarding.status, "blocked"); + assert.equal(bootPayload.onboarding.locked, true); + assert.equal(bootPayload.onboarding.lockReason, "required_setup"); + assert.equal(bootPayload.onboarding.bridgeAuthRefresh.phase, "idle"); + assert.equal(bootPayload.onboarding.required.satisfied, false); + assert.equal(bootPayload.onboarding.required.satisfiedBy, null); + assert.equal(bootPayload.onboarding.optional.skippable, true); + assert.ok(bootPayload.onboarding.optional.sections.every((section: any) => section.blocking === false)); + + const providerIds = bootPayload.onboarding.required.providers.map((provider: any) => provider.id); + assert.deepEqual(providerIds, [ + "anthropic", + "openai", + "github-copilot", + "openai-codex", + "google-gemini-cli", + "google-antigravity", + "google", + "groq", + "xai", + "openrouter", + "mistral", + ]); + const anthropicProvider = bootPayload.onboarding.required.providers.find((provider: any) => provider.id === "anthropic"); + assert.equal(anthropicProvider.supports.apiKey, true); + assert.equal(anthropicProvider.supports.oauthAvailable, true); + + const onboardingResponse = await onboardingRoute.GET(projectRequest(fixture.projectCwd, "/api/onboarding")); + assert.equal(onboardingResponse.status, 200); + const onboardingPayload = (await onboardingResponse.json()) as any; + assert.equal(onboardingPayload.onboarding.locked, true); + assert.equal(onboardingPayload.onboarding.optional.skippable, true); }); -test("runtime env-backed auth unlocks boot onboarding state and reports the environment source", async () => { +test("runtime env-backed auth unlocks boot onboarding state and reports the environment source", async (t) => { const fixture = makeWorkspaceFixture(); clearOnboardingEnv(); const authStorage = AuthStorage.inMemory({}); @@ -369,36 +369,36 @@ test("runtime env-backed auth unlocks boot onboarding state and reports the envi getEnvApiKey: (provider: string) => (provider === "github-copilot" ? process.env.GITHUB_TOKEN : undefined), }); - try { - const bootResponse = await bootRoute.GET(projectRequest(fixture.projectCwd, "/api/boot")); - assert.equal(bootResponse.status, 200); - const bootPayload = (await bootResponse.json()) as any; - - assert.equal(bootPayload.onboardingNeeded, false); - assert.equal(bootPayload.onboarding.locked, false); - assert.equal(bootPayload.onboarding.lockReason, null); - assert.equal(bootPayload.onboarding.bridgeAuthRefresh.phase, "idle"); - assert.deepEqual(bootPayload.onboarding.required.satisfiedBy, { - providerId: "github-copilot", - source: "environment", - }); - const copilotProvider = bootPayload.onboarding.required.providers.find((provider: any) => provider.id === "github-copilot"); - assert.equal(copilotProvider.configured, true); - assert.equal(copilotProvider.configuredVia, "environment"); - } finally { + t.after(async () => { if (previousGithubToken === undefined) { - delete process.env.GITHUB_TOKEN; + delete process.env.GITHUB_TOKEN; } else { - process.env.GITHUB_TOKEN = previousGithubToken; + process.env.GITHUB_TOKEN = previousGithubToken; } onboarding.resetOnboardingServiceForTests(); await bridge.resetBridgeServiceForTests(); restoreOnboardingEnv(); fixture.cleanup(); - } + }); + + const bootResponse = await bootRoute.GET(projectRequest(fixture.projectCwd, "/api/boot")); + assert.equal(bootResponse.status, 200); + const bootPayload = (await bootResponse.json()) as any; + + assert.equal(bootPayload.onboardingNeeded, false); + assert.equal(bootPayload.onboarding.locked, false); + assert.equal(bootPayload.onboarding.lockReason, null); + assert.equal(bootPayload.onboarding.bridgeAuthRefresh.phase, "idle"); + assert.deepEqual(bootPayload.onboarding.required.satisfiedBy, { + providerId: "github-copilot", + source: "environment", + }); + const copilotProvider = bootPayload.onboarding.required.providers.find((provider: any) => provider.id === "github-copilot"); + assert.equal(copilotProvider.configured, true); + assert.equal(copilotProvider.configuredVia, "environment"); }); -test("failed API-key validation stays locked, redacts the error, and is reflected in boot state without persisting auth", async () => { +test("failed API-key validation stays locked, redacts the error, and is reflected in boot state without persisting auth", async (t) => { const fixture = makeWorkspaceFixture(); clearOnboardingEnv(); const authStorage = AuthStorage.inMemory({}); @@ -412,89 +412,89 @@ test("failed API-key validation stays locked, redacts the error, and is reflecte }), }); - try { - const validationResponse = await onboardingRoute.POST( - projectRequest(fixture.projectCwd, "/api/onboarding", { - method: "POST", - body: JSON.stringify({ - action: "save_api_key", - providerId: "openai", - apiKey: "sk-test-secret-123456", - }), - }), - ); - - assert.equal(validationResponse.status, 422); - const validationPayload = (await validationResponse.json()) as any; - assert.equal(validationPayload.onboarding.locked, true); - assert.equal(validationPayload.onboarding.required.satisfied, false); - assert.equal(validationPayload.onboarding.lastValidation.status, "failed"); - assert.equal(validationPayload.onboarding.lastValidation.providerId, "openai"); - assert.equal(validationPayload.onboarding.lastValidation.persisted, false); - assert.equal(validationPayload.onboarding.lockReason, "required_setup"); - assert.equal(validationPayload.onboarding.bridgeAuthRefresh.phase, "idle"); - assert.match(validationPayload.onboarding.lastValidation.message, /OpenAI rejected/i); - assert.doesNotMatch(validationPayload.onboarding.lastValidation.message, /sk-test-secret-123456/); - assert.equal(authStorage.hasAuth("openai"), false); - - const bootResponse = await bootRoute.GET(projectRequest(fixture.projectCwd, "/api/boot")); - assert.equal(bootResponse.status, 200); - const bootPayload = (await bootResponse.json()) as any; - assert.equal(bootPayload.onboarding.locked, true); - assert.equal(bootPayload.onboarding.lastValidation.status, "failed"); - assert.doesNotMatch(bootPayload.onboarding.lastValidation.message, /sk-test-secret-123456/); - } finally { + t.after(async () => { onboarding.resetOnboardingServiceForTests(); await bridge.resetBridgeServiceForTests(); restoreOnboardingEnv(); fixture.cleanup(); - } + }); + + const validationResponse = await onboardingRoute.POST( + projectRequest(fixture.projectCwd, "/api/onboarding", { + method: "POST", + body: JSON.stringify({ + action: "save_api_key", + providerId: "openai", + apiKey: "sk-test-secret-123456", + }), + }), + ); + + assert.equal(validationResponse.status, 422); + const validationPayload = (await validationResponse.json()) as any; + assert.equal(validationPayload.onboarding.locked, true); + assert.equal(validationPayload.onboarding.required.satisfied, false); + assert.equal(validationPayload.onboarding.lastValidation.status, "failed"); + assert.equal(validationPayload.onboarding.lastValidation.providerId, "openai"); + assert.equal(validationPayload.onboarding.lastValidation.persisted, false); + assert.equal(validationPayload.onboarding.lockReason, "required_setup"); + assert.equal(validationPayload.onboarding.bridgeAuthRefresh.phase, "idle"); + assert.match(validationPayload.onboarding.lastValidation.message, /OpenAI rejected/i); + assert.doesNotMatch(validationPayload.onboarding.lastValidation.message, /sk-test-secret-123456/); + assert.equal(authStorage.hasAuth("openai"), false); + + const bootResponse = await bootRoute.GET(projectRequest(fixture.projectCwd, "/api/boot")); + assert.equal(bootResponse.status, 200); + const bootPayload = (await bootResponse.json()) as any; + assert.equal(bootPayload.onboarding.locked, true); + assert.equal(bootPayload.onboarding.lastValidation.status, "failed"); + assert.doesNotMatch(bootPayload.onboarding.lastValidation.message, /sk-test-secret-123456/); }); -test("direct prompt commands cannot bypass onboarding while required setup is still locked", async () => { +test("direct prompt commands cannot bypass onboarding while required setup is still locked", async (t) => { const fixture = makeWorkspaceFixture(); clearOnboardingEnv(); const authStorage = AuthStorage.inMemory({}); const harness = configureBridgeFixture(fixture, "sess-command-locked"); onboarding.configureOnboardingServiceForTests({ authStorage, getEnvApiKey: noEnvApiKey }); - try { - const response = await commandRoute.POST( - projectRequest(fixture.projectCwd, "/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "prompt", message: "hello from bypass attempt" }), - }), - ); - - assert.equal(response.status, 423); - const payload = (await response.json()) as any; - assert.equal(payload.success, false); - assert.equal(payload.command, "prompt"); - assert.equal(payload.code, "onboarding_locked"); - assert.equal(payload.details.reason, "required_setup"); - assert.equal(payload.details.onboarding.locked, true); - assert.equal(harness.spawnCalls, 0); - - const stateResponse = await commandRoute.POST( - projectRequest(fixture.projectCwd, "/api/session/command", { - method: "POST", - body: JSON.stringify({ type: "get_state" }), - }), - ); - assert.equal(stateResponse.status, 200); - const statePayload = (await stateResponse.json()) as any; - assert.equal(statePayload.success, true); - assert.equal(statePayload.command, "get_state"); - assert.equal(harness.spawnCalls, 1); - } finally { + t.after(async () => { onboarding.resetOnboardingServiceForTests(); await bridge.resetBridgeServiceForTests(); restoreOnboardingEnv(); fixture.cleanup(); - } + }); + + const response = await commandRoute.POST( + projectRequest(fixture.projectCwd, "/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "prompt", message: "hello from bypass attempt" }), + }), + ); + + assert.equal(response.status, 423); + const payload = (await response.json()) as any; + assert.equal(payload.success, false); + assert.equal(payload.command, "prompt"); + assert.equal(payload.code, "onboarding_locked"); + assert.equal(payload.details.reason, "required_setup"); + assert.equal(payload.details.onboarding.locked, true); + assert.equal(harness.spawnCalls, 0); + + const stateResponse = await commandRoute.POST( + projectRequest(fixture.projectCwd, "/api/session/command", { + method: "POST", + body: JSON.stringify({ type: "get_state" }), + }), + ); + assert.equal(stateResponse.status, 200); + const statePayload = (await stateResponse.json()) as any; + assert.equal(statePayload.success, true); + assert.equal(statePayload.command, "get_state"); + assert.equal(harness.spawnCalls, 1); }); -test("bridge auth refresh failures remain inspectable and keep the workspace locked after credentials validate", async () => { +test("bridge auth refresh failures remain inspectable and keep the workspace locked after credentials validate", async (t) => { const fixture = makeWorkspaceFixture(); clearOnboardingEnv(); const authStorage = AuthStorage.inMemory({}); @@ -508,43 +508,43 @@ test("bridge auth refresh failures remain inspectable and keep the workspace loc }, }); - try { - const validationResponse = await onboardingRoute.POST( - projectRequest(fixture.projectCwd, "/api/onboarding", { - method: "POST", - body: JSON.stringify({ - action: "save_api_key", - providerId: "openai", - apiKey: "sk-valid-123456", - }), - }), - ); - - assert.equal(validationResponse.status, 503); - const validationPayload = (await validationResponse.json()) as any; - assert.equal(validationPayload.onboarding.required.satisfied, true); - assert.equal(validationPayload.onboarding.locked, true); - assert.equal(validationPayload.onboarding.lockReason, "bridge_refresh_failed"); - assert.equal(validationPayload.onboarding.lastValidation.status, "succeeded"); - assert.equal(validationPayload.onboarding.bridgeAuthRefresh.phase, "failed"); - assert.match(validationPayload.onboarding.bridgeAuthRefresh.error, /bridge restart failed/i); - assert.doesNotMatch(validationPayload.onboarding.bridgeAuthRefresh.error, /sk-refresh-secret-123456/); - assert.equal(authStorage.hasAuth("openai"), true); - - const bootResponse = await bootRoute.GET(projectRequest(fixture.projectCwd, "/api/boot")); - const bootPayload = (await bootResponse.json()) as any; - assert.equal(bootPayload.onboarding.locked, true); - assert.equal(bootPayload.onboarding.lockReason, "bridge_refresh_failed"); - assert.equal(bootPayload.onboarding.bridgeAuthRefresh.phase, "failed"); - } finally { + t.after(async () => { onboarding.resetOnboardingServiceForTests(); await bridge.resetBridgeServiceForTests(); restoreOnboardingEnv(); fixture.cleanup(); - } + }); + + const validationResponse = await onboardingRoute.POST( + projectRequest(fixture.projectCwd, "/api/onboarding", { + method: "POST", + body: JSON.stringify({ + action: "save_api_key", + providerId: "openai", + apiKey: "sk-valid-123456", + }), + }), + ); + + assert.equal(validationResponse.status, 503); + const validationPayload = (await validationResponse.json()) as any; + assert.equal(validationPayload.onboarding.required.satisfied, true); + assert.equal(validationPayload.onboarding.locked, true); + assert.equal(validationPayload.onboarding.lockReason, "bridge_refresh_failed"); + assert.equal(validationPayload.onboarding.lastValidation.status, "succeeded"); + assert.equal(validationPayload.onboarding.bridgeAuthRefresh.phase, "failed"); + assert.match(validationPayload.onboarding.bridgeAuthRefresh.error, /bridge restart failed/i); + assert.doesNotMatch(validationPayload.onboarding.bridgeAuthRefresh.error, /sk-refresh-secret-123456/); + assert.equal(authStorage.hasAuth("openai"), true); + + const bootResponse = await bootRoute.GET(projectRequest(fixture.projectCwd, "/api/boot")); + const bootPayload = (await bootResponse.json()) as any; + assert.equal(bootPayload.onboarding.locked, true); + assert.equal(bootPayload.onboarding.lockReason, "bridge_refresh_failed"); + assert.equal(bootPayload.onboarding.bridgeAuthRefresh.phase, "failed"); }); -test("successful API-key validation persists the credential and unlocks onboarding", async () => { +test("successful API-key validation persists the credential and unlocks onboarding", async (t) => { const fixture = makeWorkspaceFixture(); clearOnboardingEnv(); const authStorage = AuthStorage.inMemory({}); @@ -555,47 +555,47 @@ test("successful API-key validation persists the credential and unlocks onboardi validateApiKey: async () => ({ ok: true, message: "openai credentials validated" }), }); - try { - const validationResponse = await onboardingRoute.POST( - projectRequest(fixture.projectCwd, "/api/onboarding", { - method: "POST", - body: JSON.stringify({ - action: "save_api_key", - providerId: "openai", - apiKey: "sk-valid-123456", - }), - }), - ); - - assert.equal(validationResponse.status, 200); - const validationPayload = (await validationResponse.json()) as any; - assert.equal(validationPayload.onboarding.locked, false); - assert.deepEqual(validationPayload.onboarding.required.satisfiedBy, { - providerId: "openai", - source: "auth_file", - }); - assert.equal(validationPayload.onboarding.lastValidation.status, "succeeded"); - assert.equal(validationPayload.onboarding.lastValidation.persisted, true); - assert.equal(validationPayload.onboarding.lockReason, null); - assert.equal(validationPayload.onboarding.bridgeAuthRefresh.phase, "succeeded"); - assert.equal(authStorage.hasAuth("openai"), true); - assert.equal(harness.spawnCalls, 1); - - const bootResponse = await bootRoute.GET(projectRequest(fixture.projectCwd, "/api/boot")); - const bootPayload = (await bootResponse.json()) as any; - assert.equal(bootPayload.onboarding.locked, false); - assert.equal(bootPayload.onboarding.lockReason, null); - assert.equal(bootPayload.onboarding.bridgeAuthRefresh.phase, "succeeded"); - assert.equal(bootPayload.onboardingNeeded, false); - } finally { + t.after(async () => { onboarding.resetOnboardingServiceForTests(); await bridge.resetBridgeServiceForTests(); restoreOnboardingEnv(); fixture.cleanup(); - } + }); + + const validationResponse = await onboardingRoute.POST( + projectRequest(fixture.projectCwd, "/api/onboarding", { + method: "POST", + body: JSON.stringify({ + action: "save_api_key", + providerId: "openai", + apiKey: "sk-valid-123456", + }), + }), + ); + + assert.equal(validationResponse.status, 200); + const validationPayload = (await validationResponse.json()) as any; + assert.equal(validationPayload.onboarding.locked, false); + assert.deepEqual(validationPayload.onboarding.required.satisfiedBy, { + providerId: "openai", + source: "auth_file", + }); + assert.equal(validationPayload.onboarding.lastValidation.status, "succeeded"); + assert.equal(validationPayload.onboarding.lastValidation.persisted, true); + assert.equal(validationPayload.onboarding.lockReason, null); + assert.equal(validationPayload.onboarding.bridgeAuthRefresh.phase, "succeeded"); + assert.equal(authStorage.hasAuth("openai"), true); + assert.equal(harness.spawnCalls, 1); + + const bootResponse = await bootRoute.GET(projectRequest(fixture.projectCwd, "/api/boot")); + const bootPayload = (await bootResponse.json()) as any; + assert.equal(bootPayload.onboarding.locked, false); + assert.equal(bootPayload.onboarding.lockReason, null); + assert.equal(bootPayload.onboarding.bridgeAuthRefresh.phase, "succeeded"); + assert.equal(bootPayload.onboardingNeeded, false); }); -test("logout_provider removes saved auth, refreshes the bridge, and relocks onboarding when it was the only provider", async () => { +test("logout_provider removes saved auth, refreshes the bridge, and relocks onboarding when it was the only provider", async (t) => { const fixture = makeWorkspaceFixture(); clearOnboardingEnv(); const authStorage = AuthStorage.inMemory({ @@ -604,47 +604,47 @@ test("logout_provider removes saved auth, refreshes the bridge, and relocks onbo const harness = configureBridgeFixture(fixture, "sess-logout-success"); onboarding.configureOnboardingServiceForTests({ authStorage, getEnvApiKey: noEnvApiKey }); - try { - const bootBefore = await bootRoute.GET(projectRequest(fixture.projectCwd, "/api/boot")); - const bootBeforePayload = (await bootBefore.json()) as any; - assert.equal(bootBeforePayload.onboarding.locked, false); - assert.equal(bootBeforePayload.onboarding.required.satisfiedBy.providerId, "openai"); - assert.equal(harness.spawnCalls, 1); - - const logoutResponse = await onboardingRoute.POST( - projectRequest(fixture.projectCwd, "/api/onboarding", { - method: "POST", - body: JSON.stringify({ - action: "logout_provider", - providerId: "openai", - }), - }), - ); - - assert.equal(logoutResponse.status, 200); - const logoutPayload = (await logoutResponse.json()) as any; - assert.equal(logoutPayload.onboarding.locked, true); - assert.equal(logoutPayload.onboarding.lockReason, "required_setup"); - assert.equal(logoutPayload.onboarding.bridgeAuthRefresh.phase, "succeeded"); - assert.equal(logoutPayload.onboarding.lastValidation, null); - assert.equal(authStorage.hasAuth("openai"), false); - assert.equal(harness.spawnCalls, 2); - - const bootAfter = await bootRoute.GET(projectRequest(fixture.projectCwd, "/api/boot")); - const bootAfterPayload = (await bootAfter.json()) as any; - assert.equal(bootAfterPayload.onboarding.locked, true); - assert.equal(bootAfterPayload.onboarding.lockReason, "required_setup"); - assert.equal(bootAfterPayload.onboarding.bridgeAuthRefresh.phase, "succeeded"); - assert.equal(bootAfterPayload.onboarding.required.satisfied, false); - } finally { + t.after(async () => { onboarding.resetOnboardingServiceForTests(); await bridge.resetBridgeServiceForTests(); restoreOnboardingEnv(); fixture.cleanup(); - } + }); + + const bootBefore = await bootRoute.GET(projectRequest(fixture.projectCwd, "/api/boot")); + const bootBeforePayload = (await bootBefore.json()) as any; + assert.equal(bootBeforePayload.onboarding.locked, false); + assert.equal(bootBeforePayload.onboarding.required.satisfiedBy.providerId, "openai"); + assert.equal(harness.spawnCalls, 1); + + const logoutResponse = await onboardingRoute.POST( + projectRequest(fixture.projectCwd, "/api/onboarding", { + method: "POST", + body: JSON.stringify({ + action: "logout_provider", + providerId: "openai", + }), + }), + ); + + assert.equal(logoutResponse.status, 200); + const logoutPayload = (await logoutResponse.json()) as any; + assert.equal(logoutPayload.onboarding.locked, true); + assert.equal(logoutPayload.onboarding.lockReason, "required_setup"); + assert.equal(logoutPayload.onboarding.bridgeAuthRefresh.phase, "succeeded"); + assert.equal(logoutPayload.onboarding.lastValidation, null); + assert.equal(authStorage.hasAuth("openai"), false); + assert.equal(harness.spawnCalls, 2); + + const bootAfter = await bootRoute.GET(projectRequest(fixture.projectCwd, "/api/boot")); + const bootAfterPayload = (await bootAfter.json()) as any; + assert.equal(bootAfterPayload.onboarding.locked, true); + assert.equal(bootAfterPayload.onboarding.lockReason, "required_setup"); + assert.equal(bootAfterPayload.onboarding.bridgeAuthRefresh.phase, "succeeded"); + assert.equal(bootAfterPayload.onboarding.required.satisfied, false); }); -test("logout_provider fails clearly for environment-backed auth that the browser cannot remove", async () => { +test("logout_provider fails clearly for environment-backed auth that the browser cannot remove", async (t) => { const fixture = makeWorkspaceFixture(); clearOnboardingEnv(); const authStorage = AuthStorage.inMemory({}); @@ -656,38 +656,38 @@ test("logout_provider fails clearly for environment-backed auth that the browser getEnvApiKey: (provider: string) => (provider === "github-copilot" ? process.env.GITHUB_TOKEN : undefined), }); - try { - const bootBefore = await bootRoute.GET(projectRequest(fixture.projectCwd, "/api/boot")); - const bootBeforePayload = (await bootBefore.json()) as any; - assert.equal(bootBeforePayload.onboarding.locked, false); - assert.equal(bootBeforePayload.onboarding.required.satisfiedBy.providerId, "github-copilot"); - assert.equal(bootBeforePayload.onboarding.required.satisfiedBy.source, "environment"); - - const logoutResponse = await onboardingRoute.POST( - projectRequest(fixture.projectCwd, "/api/onboarding", { - method: "POST", - body: JSON.stringify({ - action: "logout_provider", - providerId: "github-copilot", - }), - }), - ); - - assert.equal(logoutResponse.status, 400); - const logoutPayload = (await logoutResponse.json()) as any; - assert.match(logoutPayload.error, /cannot be logged out from the browser surface/i); - assert.equal(logoutPayload.onboarding.locked, false); - assert.equal(logoutPayload.onboarding.required.satisfiedBy.providerId, "github-copilot"); - assert.equal(logoutPayload.onboarding.required.satisfiedBy.source, "environment"); - } finally { + t.after(async () => { if (previousGithubToken === undefined) { - delete process.env.GITHUB_TOKEN; + delete process.env.GITHUB_TOKEN; } else { - process.env.GITHUB_TOKEN = previousGithubToken; + process.env.GITHUB_TOKEN = previousGithubToken; } onboarding.resetOnboardingServiceForTests(); await bridge.resetBridgeServiceForTests(); restoreOnboardingEnv(); fixture.cleanup(); - } + }); + + const bootBefore = await bootRoute.GET(projectRequest(fixture.projectCwd, "/api/boot")); + const bootBeforePayload = (await bootBefore.json()) as any; + assert.equal(bootBeforePayload.onboarding.locked, false); + assert.equal(bootBeforePayload.onboarding.required.satisfiedBy.providerId, "github-copilot"); + assert.equal(bootBeforePayload.onboarding.required.satisfiedBy.source, "environment"); + + const logoutResponse = await onboardingRoute.POST( + projectRequest(fixture.projectCwd, "/api/onboarding", { + method: "POST", + body: JSON.stringify({ + action: "logout_provider", + providerId: "github-copilot", + }), + }), + ); + + assert.equal(logoutResponse.status, 400); + const logoutPayload = (await logoutResponse.json()) as any; + assert.match(logoutPayload.error, /cannot be logged out from the browser surface/i); + assert.equal(logoutPayload.onboarding.locked, false); + assert.equal(logoutPayload.onboarding.required.satisfiedBy.providerId, "github-copilot"); + assert.equal(logoutPayload.onboarding.required.satisfiedBy.source, "environment"); }); diff --git a/src/tests/web-recovery-diagnostics-contract.test.ts b/src/tests/web-recovery-diagnostics-contract.test.ts index b3cace09d..f3b2de070 100644 --- a/src/tests/web-recovery-diagnostics-contract.test.ts +++ b/src/tests/web-recovery-diagnostics-contract.test.ts @@ -209,7 +209,7 @@ function fakeSessionState(sessionId: string, sessionPath?: string) { } } -test("/api/recovery returns structured recovery diagnostics and redacts secrets", async () => { +test("/api/recovery returns structured recovery diagnostics and redacts secrets", async (t) => { const fixture = makeRecoveryFixture() const sessionPath = createRecoverySessionFile(fixture.projectCwd, fixture.sessionsDir, "sess-recovery") const harness = createHarness((command, current) => { @@ -247,39 +247,39 @@ test("/api/recovery returns structured recovery diagnostics and redacts secrets" }), }) - try { - const response = await recoveryRoute.GET() - assert.equal(response.status, 200) - const payload = await response.json() as any - - assert.equal(payload.status, "ready") - assert.equal(payload.project.activeSessionPath, sessionPath) - assert.equal(payload.project.activeSessionId, "sess-recovery") - assert.equal(payload.bridge.retry.inProgress, true) - assert.equal(payload.bridge.retry.attempt, 2) - assert.equal(payload.bridge.authRefresh.phase, "failed") - assert.match(payload.bridge.authRefresh.label, /failed/i) - assert.ok(typeof payload.doctor.total === "number") - assert.ok(Array.isArray(payload.doctor.codes)) - assert.ok(typeof payload.validation.total === "number") - assert.equal(payload.interruptedRun.detected, true) - assert.match(payload.interruptedRun.lastError ?? "", /\[redacted\]/) - assert.deepEqual( - payload.actions.browser.map((action: { id: string }) => action.id), - ["refresh_diagnostics", "refresh_workspace", "open_retry_controls", "open_resume_controls", "open_auth_controls"], - ) - assert.ok(payload.actions.commands.some((entry: { command: string }) => entry.command.includes("/gsd doctor"))) - - const serialized = JSON.stringify(payload) - assert.doesNotMatch(serialized, /sk-test-recovery-secret-9999|sk-onboarding-secret-1234/) - assert.doesNotMatch(serialized, /Crash Recovery Briefing|Completed Tool Calls|toolCallId/) - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests() fixture.cleanup() - } + }); + + const response = await recoveryRoute.GET() + assert.equal(response.status, 200) + const payload = await response.json() as any + + assert.equal(payload.status, "ready") + assert.equal(payload.project.activeSessionPath, sessionPath) + assert.equal(payload.project.activeSessionId, "sess-recovery") + assert.equal(payload.bridge.retry.inProgress, true) + assert.equal(payload.bridge.retry.attempt, 2) + assert.equal(payload.bridge.authRefresh.phase, "failed") + assert.match(payload.bridge.authRefresh.label, /failed/i) + assert.ok(typeof payload.doctor.total === "number") + assert.ok(Array.isArray(payload.doctor.codes)) + assert.ok(typeof payload.validation.total === "number") + assert.equal(payload.interruptedRun.detected, true) + assert.match(payload.interruptedRun.lastError ?? "", /\[redacted\]/) + assert.deepEqual( + payload.actions.browser.map((action: { id: string }) => action.id), + ["refresh_diagnostics", "refresh_workspace", "open_retry_controls", "open_resume_controls", "open_auth_controls"], + ) + assert.ok(payload.actions.commands.some((entry: { command: string }) => entry.command.includes("/gsd doctor"))) + + const serialized = JSON.stringify(payload) + assert.doesNotMatch(serialized, /sk-test-recovery-secret-9999|sk-onboarding-secret-1234/) + assert.doesNotMatch(serialized, /Crash Recovery Briefing|Completed Tool Calls|toolCallId/) }) -test("/api/recovery prefers the current-project resumable session when the live bridge session is out of scope", async () => { +test("/api/recovery prefers the current-project resumable session when the live bridge session is out of scope", async (t) => { const fixture = makeRecoveryFixture() const sessionPath = createRecoverySessionFile(fixture.projectCwd, fixture.sessionsDir, "sess-recovery") const externalSessionPath = join(fixture.projectCwd, "..", "agent-sessions", "2026-03-15T03-40-00-000Z_sess-external.jsonl") @@ -308,26 +308,26 @@ test("/api/recovery prefers the current-project resumable session when the live getOnboardingState: async () => readyOnboardingState(), }) - try { - const response = await recoveryRoute.GET() - assert.equal(response.status, 200) - const payload = await response.json() as any - - assert.equal(payload.project.activeSessionPath, sessionPath) - assert.equal(payload.project.activeSessionId, "sess-recovery") - assert.equal(payload.interruptedRun.detected, true) - assert.match(payload.interruptedRun.lastError ?? "", /\[redacted\]/) - assert.deepEqual( - payload.actions.browser.map((action: { id: string }) => action.id), - ["refresh_diagnostics", "refresh_workspace", "open_retry_controls", "open_resume_controls"], - ) - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests() fixture.cleanup() - } + }); + + const response = await recoveryRoute.GET() + assert.equal(response.status, 200) + const payload = await response.json() as any + + assert.equal(payload.project.activeSessionPath, sessionPath) + assert.equal(payload.project.activeSessionId, "sess-recovery") + assert.equal(payload.interruptedRun.detected, true) + assert.match(payload.interruptedRun.lastError ?? "", /\[redacted\]/) + assert.deepEqual( + payload.actions.browser.map((action: { id: string }) => action.id), + ["refresh_diagnostics", "refresh_workspace", "open_retry_controls", "open_resume_controls"], + ) }) -test("/api/recovery returns a structured empty-project payload without leaking raw diagnostics", async () => { +test("/api/recovery returns a structured empty-project payload without leaking raw diagnostics", async (t) => { const fixture = makeEmptyProjectFixture() const harness = createHarness((command, current) => { if (command.type === "get_state") { @@ -359,22 +359,22 @@ test("/api/recovery returns a structured empty-project payload without leaking r getOnboardingState: async () => readyOnboardingState(), }) - try { - const response = await recoveryRoute.GET() - assert.equal(response.status, 200) - const payload = await response.json() as any - - assert.ok(["ready", "unavailable"].includes(payload.status)) - assert.equal(payload.project.activeScope, null) - assert.equal(payload.validation.total, 0) - assert.ok(typeof payload.doctor.total === "number") - assert.ok(typeof payload.interruptedRun.available === "boolean") - assert.deepEqual( - payload.actions.browser.map((action: { id: string }) => action.id), - ["refresh_diagnostics", "refresh_workspace"], - ) - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests() fixture.cleanup() - } + }); + + const response = await recoveryRoute.GET() + assert.equal(response.status, 200) + const payload = await response.json() as any + + assert.ok(["ready", "unavailable"].includes(payload.status)) + assert.equal(payload.project.activeScope, null) + assert.equal(payload.validation.total, 0) + assert.ok(typeof payload.doctor.total === "number") + assert.ok(typeof payload.interruptedRun.available === "boolean") + assert.deepEqual( + payload.actions.browser.map((action: { id: string }) => action.id), + ["refresh_diagnostics", "refresh_workspace"], + ) }) diff --git a/src/tests/web-session-parity-contract.test.ts b/src/tests/web-session-parity-contract.test.ts index 0b52a6504..5b5fa628d 100644 --- a/src/tests/web-session-parity-contract.test.ts +++ b/src/tests/web-session-parity-contract.test.ts @@ -234,7 +234,7 @@ function configureBridgeFixture( }) } -test("/api/session/browser stays current-project scoped and carries threaded/search metadata outside /api/boot", async () => { +test("/api/session/browser stays current-project scoped and carries threaded/search metadata outside /api/boot", async (t) => { const fixture = makeWorkspaceFixture() const rootPath = createSessionFile({ projectCwd: fixture.projectCwd, @@ -313,48 +313,48 @@ test("/api/session/browser stays current-project scoped and carries threaded/sea configureBridgeFixture(fixture, harness) - try { - const response = await browserRoute.GET(new Request("http://localhost/api/session/browser")) - assert.equal(response.status, 200) - const payload = await response.json() as any - - assert.equal(payload.project.scope, "current_project") - assert.equal(payload.project.cwd, fixture.projectCwd) - assert.equal(payload.project.sessionsDir, fixture.sessionsDir) - assert.equal(payload.project.activeSessionPath, childPath) - assert.equal(payload.totalSessions, 3) - assert.equal(payload.returnedSessions, 3) - assert.equal(payload.sessions.some((session: any) => session.path === outsidePath), false) - - const child = payload.sessions.find((session: any) => session.id === "sess-child") - assert.ok(child) - assert.equal(child.parentSessionPath, rootPath) - assert.equal(child.firstMessage, "Investigate the branch rename") - assert.equal(child.isActive, true) - assert.equal(child.depth, 1) - assert.deepEqual(child.ancestorHasNextSibling, [false]) - assert.equal("allMessagesText" in child, false) - - const searchResponse = await browserRoute.GET( - new Request("http://localhost/api/session/browser?query=api-session-browser&sortMode=relevance&nameFilter=named"), - ) - assert.equal(searchResponse.status, 200) - const searchPayload = await searchResponse.json() as any - - assert.equal(searchPayload.totalSessions, 3) - assert.equal(searchPayload.returnedSessions, 1) - assert.equal(searchPayload.query.sortMode, "relevance") - assert.equal(searchPayload.query.nameFilter, "named") - assert.equal(searchPayload.sessions[0].id, "sess-named") - assert.equal(searchPayload.sessions[0].name, "Release Notes") - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests() onboarding.resetOnboardingServiceForTests() fixture.cleanup() - } + }); + + const response = await browserRoute.GET(new Request("http://localhost/api/session/browser")) + assert.equal(response.status, 200) + const payload = await response.json() as any + + assert.equal(payload.project.scope, "current_project") + assert.equal(payload.project.cwd, fixture.projectCwd) + assert.equal(payload.project.sessionsDir, fixture.sessionsDir) + assert.equal(payload.project.activeSessionPath, childPath) + assert.equal(payload.totalSessions, 3) + assert.equal(payload.returnedSessions, 3) + assert.equal(payload.sessions.some((session: any) => session.path === outsidePath), false) + + const child = payload.sessions.find((session: any) => session.id === "sess-child") + assert.ok(child) + assert.equal(child.parentSessionPath, rootPath) + assert.equal(child.firstMessage, "Investigate the branch rename") + assert.equal(child.isActive, true) + assert.equal(child.depth, 1) + assert.deepEqual(child.ancestorHasNextSibling, [false]) + assert.equal("allMessagesText" in child, false) + + const searchResponse = await browserRoute.GET( + new Request("http://localhost/api/session/browser?query=api-session-browser&sortMode=relevance&nameFilter=named"), + ) + assert.equal(searchResponse.status, 200) + const searchPayload = await searchResponse.json() as any + + assert.equal(searchPayload.totalSessions, 3) + assert.equal(searchPayload.returnedSessions, 1) + assert.equal(searchPayload.query.sortMode, "relevance") + assert.equal(searchPayload.query.nameFilter, "named") + assert.equal(searchPayload.sessions[0].id, "sess-named") + assert.equal(searchPayload.sessions[0].name, "Release Notes") }) -test("/api/session/manage renames the active session through bridge-aware RPC instead of mutating the file directly", async () => { +test("/api/session/manage renames the active session through bridge-aware RPC instead of mutating the file directly", async (t) => { const fixture = makeWorkspaceFixture() const activePath = createSessionFile({ projectCwd: fixture.projectCwd, @@ -415,35 +415,35 @@ test("/api/session/manage renames the active session through bridge-aware RPC in } as any), }) - try { - const response = await manageRoute.POST( - new Request("http://localhost/api/session/manage", { - method: "POST", - body: JSON.stringify({ - action: "rename", - sessionPath: activePath, - name: "Active Renamed", - }), - }), - ) - const payload = await response.json() as any - await waitForMicrotasks() - - assert.equal(response.status, 200) - assert.equal(payload.success, true) - assert.equal(payload.sessionPath, activePath) - assert.equal(payload.isActiveSession, true) - assert.equal(payload.mutation, "rpc") - assert.ok(harness.commands.some((command) => command.type === "set_session_name" && command.name === "Active Renamed")) - assert.equal(getLatestSessionName(activePath), "Before Active Rename") - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests() onboarding.resetOnboardingServiceForTests() fixture.cleanup() - } + }); + + const response = await manageRoute.POST( + new Request("http://localhost/api/session/manage", { + method: "POST", + body: JSON.stringify({ + action: "rename", + sessionPath: activePath, + name: "Active Renamed", + }), + }), + ) + const payload = await response.json() as any + await waitForMicrotasks() + + assert.equal(response.status, 200) + assert.equal(payload.success, true) + assert.equal(payload.sessionPath, activePath) + assert.equal(payload.isActiveSession, true) + assert.equal(payload.mutation, "rpc") + assert.ok(harness.commands.some((command) => command.type === "set_session_name" && command.name === "Active Renamed")) + assert.equal(getLatestSessionName(activePath), "Before Active Rename") }) -test("/api/session/manage renames inactive sessions via authoritative session-file mutation and rejects out-of-scope paths", async () => { +test("/api/session/manage renames inactive sessions via authoritative session-file mutation and rejects out-of-scope paths", async (t) => { const fixture = makeWorkspaceFixture() const activePath = createSessionFile({ projectCwd: fixture.projectCwd, @@ -520,122 +520,118 @@ test("/api/session/manage renames inactive sessions via authoritative session-fi } as any), }) - try { - const renameResponse = await manageRoute.POST( - new Request("http://localhost/api/session/manage", { - method: "POST", - body: JSON.stringify({ - action: "rename", - sessionPath: inactivePath, - name: "Inactive Renamed", - }), - }), - ) - const renamePayload = await renameResponse.json() as any - - assert.equal(renameResponse.status, 200) - assert.equal(renamePayload.success, true) - assert.equal(renamePayload.isActiveSession, false) - assert.equal(renamePayload.mutation, "session_file") - assert.equal(getLatestSessionName(inactivePath), "Inactive Renamed") - assert.equal(harness.commands.some((command) => command.type === "set_session_name"), false) - - const outsideResponse = await manageRoute.POST( - new Request("http://localhost/api/session/manage", { - method: "POST", - body: JSON.stringify({ - action: "rename", - sessionPath: outsidePath, - name: "Should Fail", - }), - }), - ) - const outsidePayload = await outsideResponse.json() as any - - assert.equal(outsideResponse.status, 404) - assert.equal(outsidePayload.success, false) - assert.equal(outsidePayload.code, "not_found") - assert.equal(getLatestSessionName(outsidePath), "Outside Session") - } finally { + t.after(async () => { await bridge.resetBridgeServiceForTests() onboarding.resetOnboardingServiceForTests() fixture.cleanup() - } + }); + + const renameResponse = await manageRoute.POST( + new Request("http://localhost/api/session/manage", { + method: "POST", + body: JSON.stringify({ + action: "rename", + sessionPath: inactivePath, + name: "Inactive Renamed", + }), + }), + ) + const renamePayload = await renameResponse.json() as any + + assert.equal(renameResponse.status, 200) + assert.equal(renamePayload.success, true) + assert.equal(renamePayload.isActiveSession, false) + assert.equal(renamePayload.mutation, "session_file") + assert.equal(getLatestSessionName(inactivePath), "Inactive Renamed") + assert.equal(harness.commands.some((command) => command.type === "set_session_name"), false) + + const outsideResponse = await manageRoute.POST( + new Request("http://localhost/api/session/manage", { + method: "POST", + body: JSON.stringify({ + action: "rename", + sessionPath: outsidePath, + name: "Should Fail", + }), + }), + ) + const outsidePayload = await outsideResponse.json() as any + + assert.equal(outsideResponse.status, 404) + assert.equal(outsidePayload.success, false) + assert.equal(outsidePayload.code, "not_found") + assert.equal(getLatestSessionName(outsidePath), "Outside Session") }) -test("/api/git returns a current-project-scoped repo summary and ignores changes outside the current project subtree", async () => { +test("/api/git returns a current-project-scoped repo summary and ignores changes outside the current project subtree", async (t) => { const root = mkdtempSync(join(tmpdir(), "gsd-web-git-summary-")) const repoRoot = join(root, "repo") const projectCwd = join(repoRoot, "apps", "current-project") const docsDir = join(repoRoot, "docs") - try { - mkdirSync(projectCwd, { recursive: true }) - mkdirSync(docsDir, { recursive: true }) + t.after(() => { rmSync(root, { recursive: true, force: true }) }); - writeFileSync(join(projectCwd, "staged.txt"), "baseline staged\n") - writeFileSync(join(projectCwd, "dirty.txt"), "baseline dirty\n") - writeFileSync(join(docsDir, "outside.txt"), "baseline outside\n") + mkdirSync(projectCwd, { recursive: true }) + mkdirSync(docsDir, { recursive: true }) - git(repoRoot, ["init"]) - git(repoRoot, ["config", "user.name", "GSD Test"]) - git(repoRoot, ["config", "user.email", "gsd-test@example.com"]) - git(repoRoot, ["add", "."]) - git(repoRoot, ["commit", "-m", "initial"]) + writeFileSync(join(projectCwd, "staged.txt"), "baseline staged\n") + writeFileSync(join(projectCwd, "dirty.txt"), "baseline dirty\n") + writeFileSync(join(docsDir, "outside.txt"), "baseline outside\n") - writeFileSync(join(projectCwd, "staged.txt"), "baseline staged\nnext staged line\n") - git(repoRoot, ["add", "apps/current-project/staged.txt"]) - writeFileSync(join(projectCwd, "dirty.txt"), "baseline dirty\nnext dirty line\n") - writeFileSync(join(projectCwd, "untracked.txt"), "brand new\n") - writeFileSync(join(docsDir, "outside.txt"), "baseline outside\noutside change\n") + git(repoRoot, ["init"]) + git(repoRoot, ["config", "user.name", "GSD Test"]) + git(repoRoot, ["config", "user.email", "gsd-test@example.com"]) + git(repoRoot, ["add", "."]) + git(repoRoot, ["commit", "-m", "initial"]) - const authoritativeRepoRoot = resolve(git(projectCwd, ["rev-parse", "--show-toplevel"])) + writeFileSync(join(projectCwd, "staged.txt"), "baseline staged\nnext staged line\n") + git(repoRoot, ["add", "apps/current-project/staged.txt"]) + writeFileSync(join(projectCwd, "dirty.txt"), "baseline dirty\nnext dirty line\n") + writeFileSync(join(projectCwd, "untracked.txt"), "brand new\n") + writeFileSync(join(docsDir, "outside.txt"), "baseline outside\noutside change\n") - await withProjectGitEnv(projectCwd, async () => { - const response = await gitRoute.GET() - assert.equal(response.status, 200) + const authoritativeRepoRoot = resolve(git(projectCwd, ["rev-parse", "--show-toplevel"])) - const payload = await response.json() as any - assert.equal(payload.kind, "repo") - assert.equal(payload.project.scope, "current_project") - assert.equal(payload.project.cwd, projectCwd) - assert.equal(payload.project.repoRoot, authoritativeRepoRoot) - assert.equal(payload.project.repoRelativePath, "apps/current-project") - assert.equal(payload.hasChanges, true) - assert.equal(payload.counts.changed, 3) - assert.equal(payload.counts.staged, 1) - assert.equal(payload.counts.dirty, 1) - assert.equal(payload.counts.untracked, 1) - assert.equal(payload.counts.conflicts, 0) - assert.equal(payload.changedFiles.some((file: any) => file.repoPath === "docs/outside.txt"), false) - assert.deepEqual( - payload.changedFiles.map((file: any) => file.path).sort(), - ["dirty.txt", "staged.txt", "untracked.txt"], - ) - }) - } finally { - rmSync(root, { recursive: true, force: true }) - } + await withProjectGitEnv(projectCwd, async () => { + const response = await gitRoute.GET() + assert.equal(response.status, 200) + + const payload = await response.json() as any + assert.equal(payload.kind, "repo") + assert.equal(payload.project.scope, "current_project") + assert.equal(payload.project.cwd, projectCwd) + assert.equal(payload.project.repoRoot, authoritativeRepoRoot) + assert.equal(payload.project.repoRelativePath, "apps/current-project") + assert.equal(payload.hasChanges, true) + assert.equal(payload.counts.changed, 3) + assert.equal(payload.counts.staged, 1) + assert.equal(payload.counts.dirty, 1) + assert.equal(payload.counts.untracked, 1) + assert.equal(payload.counts.conflicts, 0) + assert.equal(payload.changedFiles.some((file: any) => file.repoPath === "docs/outside.txt"), false) + assert.deepEqual( + payload.changedFiles.map((file: any) => file.path).sort(), + ["dirty.txt", "staged.txt", "untracked.txt"], + ) + }) }) -test("/api/git exposes an explicit not-a-repo state instead of failing silently", async () => { +test("/api/git exposes an explicit not-a-repo state instead of failing silently", async (t) => { const projectCwd = mkdtempSync(join(tmpdir(), "gsd-web-not-repo-")) - try { - await withProjectGitEnv(projectCwd, async () => { - const response = await gitRoute.GET() - assert.equal(response.status, 200) + t.after(() => { rmSync(projectCwd, { recursive: true, force: true }) }); - const payload = await response.json() as any - assert.equal(payload.kind, "not_repo") - assert.equal(payload.project.scope, "current_project") - assert.equal(payload.project.cwd, projectCwd) - assert.equal(payload.project.repoRoot, null) - assert.match(payload.message, /not inside a Git repository/i) - }) - } finally { - rmSync(projectCwd, { recursive: true, force: true }) - } + await withProjectGitEnv(projectCwd, async () => { + const response = await gitRoute.GET() + assert.equal(response.status, 200) + + const payload = await response.json() as any + assert.equal(payload.kind, "not_repo") + assert.equal(payload.project.scope, "current_project") + assert.equal(payload.project.cwd, projectCwd) + assert.equal(payload.project.repoRoot, null) + assert.match(payload.message, /not inside a Git repository/i) + }) }) test("browser session, settings, and git surfaces keep inspectable browse/manage/state markers on the shared surface", () => { diff --git a/src/tests/web-state-surfaces-contract.test.ts b/src/tests/web-state-surfaces-contract.test.ts index d69390036..d8fc6b556 100644 --- a/src/tests/web-state-surfaces-contract.test.ts +++ b/src/tests/web-state-surfaces-contract.test.ts @@ -26,90 +26,86 @@ function makeGsdFixture(): { root: string; gsdDir: string; cleanup: () => void } } // ─── Group 1: Workspace index — risk/depends/demo fields ───────────── -test("indexWorkspace extracts risk, depends, and demo from roadmap", async () => { +test("indexWorkspace extracts risk, depends, and demo from roadmap", async (t) => { const { root, gsdDir, cleanup } = makeGsdFixture(); - try { - const milestoneDir = join(gsdDir, "milestones", "M001"); - const sliceDir = join(milestoneDir, "slices", "S01"); - const tasksDir = join(sliceDir, "tasks"); - mkdirSync(tasksDir, { recursive: true }); + t.after(() => { cleanup(); }); - writeFileSync( - join(milestoneDir, "M001-ROADMAP.md"), - [ - "# M001: Test Milestone", - "", - "## Slices", - "- [ ] **S01: Feature slice** `risk:high` `depends:[S00]`", - " > After this: users can see the dashboard", - ].join("\n"), - ); + const milestoneDir = join(gsdDir, "milestones", "M001"); + const sliceDir = join(milestoneDir, "slices", "S01"); + const tasksDir = join(sliceDir, "tasks"); + mkdirSync(tasksDir, { recursive: true }); - writeFileSync( - join(sliceDir, "S01-PLAN.md"), - [ - "# S01: Feature slice", - "", - "**Goal:** Build the feature", - "**Demo:** Dashboard renders", - "", - "## Tasks", - "- [ ] **T01: Build thing** `est:30m`", - " Do the work.", - ].join("\n"), - ); + writeFileSync( + join(milestoneDir, "M001-ROADMAP.md"), + [ + "# M001: Test Milestone", + "", + "## Slices", + "- [ ] **S01: Feature slice** `risk:high` `depends:[S00]`", + " > After this: users can see the dashboard", + ].join("\n"), + ); - writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01: Build thing\n\n## Steps\n- do it\n"); + writeFileSync( + join(sliceDir, "S01-PLAN.md"), + [ + "# S01: Feature slice", + "", + "**Goal:** Build the feature", + "**Demo:** Dashboard renders", + "", + "## Tasks", + "- [ ] **T01: Build thing** `est:30m`", + " Do the work.", + ].join("\n"), + ); - const index = await workspaceIndex.indexWorkspace(root); + writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01: Build thing\n\n## Steps\n- do it\n"); - assert.equal(index.milestones.length, 1); - assert.equal(index.milestones[0].id, "M001"); + const index = await workspaceIndex.indexWorkspace(root); - const slice = index.milestones[0].slices[0]; - assert.equal(slice.id, "S01"); - assert.equal(slice.risk, "high"); - assert.deepEqual(slice.depends, ["S00"]); - assert.equal(slice.demo, "users can see the dashboard"); - assert.equal(slice.done, false); - assert.equal(slice.tasks.length, 1); - assert.equal(slice.tasks[0].id, "T01"); - assert.equal(slice.tasks[0].done, false); - } finally { - cleanup(); - } + assert.equal(index.milestones.length, 1); + assert.equal(index.milestones[0].id, "M001"); + + const slice = index.milestones[0].slices[0]; + assert.equal(slice.id, "S01"); + assert.equal(slice.risk, "high"); + assert.deepEqual(slice.depends, ["S00"]); + assert.equal(slice.demo, "users can see the dashboard"); + assert.equal(slice.done, false); + assert.equal(slice.tasks.length, 1); + assert.equal(slice.tasks[0].id, "T01"); + assert.equal(slice.tasks[0].done, false); }); -test("indexWorkspace handles slices without risk/depends/demo", async () => { +test("indexWorkspace handles slices without risk/depends/demo", async (t) => { const { root, gsdDir, cleanup } = makeGsdFixture(); - try { - const milestoneDir = join(gsdDir, "milestones", "M001"); - const sliceDir = join(milestoneDir, "slices", "S01"); - mkdirSync(join(sliceDir, "tasks"), { recursive: true }); + t.after(() => { cleanup(); }); - writeFileSync( - join(milestoneDir, "M001-ROADMAP.md"), - "# M001: Minimal\n\n## Slices\n- [x] **S01: Done slice**\n", - ); + const milestoneDir = join(gsdDir, "milestones", "M001"); + const sliceDir = join(milestoneDir, "slices", "S01"); + mkdirSync(join(sliceDir, "tasks"), { recursive: true }); - writeFileSync( - join(sliceDir, "S01-PLAN.md"), - "# S01: Done slice\n\n**Goal:** Done\n\n## Tasks\n", - ); + writeFileSync( + join(milestoneDir, "M001-ROADMAP.md"), + "# M001: Minimal\n\n## Slices\n- [x] **S01: Done slice**\n", + ); - const index = await workspaceIndex.indexWorkspace(root); + writeFileSync( + join(sliceDir, "S01-PLAN.md"), + "# S01: Done slice\n\n**Goal:** Done\n\n## Tasks\n", + ); - const slice = index.milestones[0].slices[0]; - // Parser defaults risk to "low" when not specified, demo to "" when no blockquote - assert.equal(slice.risk, "low"); - assert.deepEqual(slice.depends, []); - assert.equal(slice.demo, ""); - assert.equal(slice.done, true); - } finally { - cleanup(); - } + const index = await workspaceIndex.indexWorkspace(root); + + const slice = index.milestones[0].slices[0]; + // Parser defaults risk to "low" when not specified, demo to "" when no blockquote + assert.equal(slice.risk, "low"); + assert.deepEqual(slice.depends, []); + assert.equal(slice.demo, ""); + assert.equal(slice.done, true); }); // ─── Group 2: Shared status helpers ────────────────────────────────── @@ -195,174 +191,174 @@ test("getTaskStatus returns correct statuses", () => { }); // ─── Group 3: Files API — tree listing ─────────────────────────────── -test("files API returns tree listing of .gsd/ directory", async () => { +test("files API returns tree listing of .gsd/ directory", async (t) => { const { root, gsdDir, cleanup } = makeGsdFixture(); const origEnv = process.env.GSD_WEB_PROJECT_CWD; - try { - process.env.GSD_WEB_PROJECT_CWD = root; - - // Create some files - writeFileSync(join(gsdDir, "STATE.md"), "# State\nactive"); - writeFileSync(join(gsdDir, "PROJECT.md"), "# Project"); - const msDir = join(gsdDir, "milestones", "M001"); - mkdirSync(msDir, { recursive: true }); - writeFileSync(join(msDir, "M001-ROADMAP.md"), "# Roadmap"); - - const request = new Request("http://localhost:3000/api/files"); - const response = await filesRoute.GET(request); - assert.equal(response.status, 200); - - const data = await response.json(); - assert.ok(Array.isArray(data.tree)); - assert.ok(data.tree.length > 0); - - // Should have files at root level - const names = data.tree.map((n: { name: string }) => n.name); - assert.ok(names.includes("STATE.md"), `Expected STATE.md in tree, got: ${names}`); - assert.ok(names.includes("PROJECT.md"), `Expected PROJECT.md in tree, got: ${names}`); - assert.ok(names.includes("milestones"), `Expected milestones in tree, got: ${names}`); - - // milestones should be a directory with children - const milestones = data.tree.find((n: { name: string }) => n.name === "milestones"); - assert.equal(milestones.type, "directory"); - assert.ok(Array.isArray(milestones.children)); - assert.ok(milestones.children.length > 0); - } finally { + t.after(() => { process.env.GSD_WEB_PROJECT_CWD = origEnv; cleanup(); - } + }); + + process.env.GSD_WEB_PROJECT_CWD = root; + + // Create some files + writeFileSync(join(gsdDir, "STATE.md"), "# State\nactive"); + writeFileSync(join(gsdDir, "PROJECT.md"), "# Project"); + const msDir = join(gsdDir, "milestones", "M001"); + mkdirSync(msDir, { recursive: true }); + writeFileSync(join(msDir, "M001-ROADMAP.md"), "# Roadmap"); + + const request = new Request("http://localhost:3000/api/files"); + const response = await filesRoute.GET(request); + assert.equal(response.status, 200); + + const data = await response.json(); + assert.ok(Array.isArray(data.tree)); + assert.ok(data.tree.length > 0); + + // Should have files at root level + const names = data.tree.map((n: { name: string }) => n.name); + assert.ok(names.includes("STATE.md"), `Expected STATE.md in tree, got: ${names}`); + assert.ok(names.includes("PROJECT.md"), `Expected PROJECT.md in tree, got: ${names}`); + assert.ok(names.includes("milestones"), `Expected milestones in tree, got: ${names}`); + + // milestones should be a directory with children + const milestones = data.tree.find((n: { name: string }) => n.name === "milestones"); + assert.equal(milestones.type, "directory"); + assert.ok(Array.isArray(milestones.children)); + assert.ok(milestones.children.length > 0); }); // ─── Group 4: Files API — file content ─────────────────────────────── -test("files API returns file content for valid path", async () => { +test("files API returns file content for valid path", async (t) => { const { root, gsdDir, cleanup } = makeGsdFixture(); const origEnv = process.env.GSD_WEB_PROJECT_CWD; - try { - process.env.GSD_WEB_PROJECT_CWD = root; - - const fileContent = "# State\n\nCurrent milestone: M001"; - writeFileSync(join(gsdDir, "STATE.md"), fileContent); - - const request = new Request("http://localhost:3000/api/files?path=STATE.md"); - const response = await filesRoute.GET(request); - assert.equal(response.status, 200); - - const data = await response.json(); - assert.equal(data.content, fileContent); - } finally { + t.after(() => { process.env.GSD_WEB_PROJECT_CWD = origEnv; cleanup(); - } + }); + + process.env.GSD_WEB_PROJECT_CWD = root; + + const fileContent = "# State\n\nCurrent milestone: M001"; + writeFileSync(join(gsdDir, "STATE.md"), fileContent); + + const request = new Request("http://localhost:3000/api/files?path=STATE.md"); + const response = await filesRoute.GET(request); + assert.equal(response.status, 200); + + const data = await response.json(); + assert.equal(data.content, fileContent); }); -test("files API returns content for nested files", async () => { +test("files API returns content for nested files", async (t) => { const { root, gsdDir, cleanup } = makeGsdFixture(); const origEnv = process.env.GSD_WEB_PROJECT_CWD; - try { - process.env.GSD_WEB_PROJECT_CWD = root; - - const msDir = join(gsdDir, "milestones", "M001"); - mkdirSync(msDir, { recursive: true }); - writeFileSync(join(msDir, "M001-ROADMAP.md"), "# Roadmap content"); - - const request = new Request( - "http://localhost:3000/api/files?path=milestones/M001/M001-ROADMAP.md", - ); - const response = await filesRoute.GET(request); - assert.equal(response.status, 200); - - const data = await response.json(); - assert.equal(data.content, "# Roadmap content"); - } finally { + t.after(() => { process.env.GSD_WEB_PROJECT_CWD = origEnv; cleanup(); - } + }); + + process.env.GSD_WEB_PROJECT_CWD = root; + + const msDir = join(gsdDir, "milestones", "M001"); + mkdirSync(msDir, { recursive: true }); + writeFileSync(join(msDir, "M001-ROADMAP.md"), "# Roadmap content"); + + const request = new Request( + "http://localhost:3000/api/files?path=milestones/M001/M001-ROADMAP.md", + ); + const response = await filesRoute.GET(request); + assert.equal(response.status, 200); + + const data = await response.json(); + assert.equal(data.content, "# Roadmap content"); }); // ─── Group 5: Files API — security: path traversal rejection ───────── -test("files API rejects path traversal with ../", async () => { +test("files API rejects path traversal with ../", async (t) => { const { root, cleanup } = makeGsdFixture(); const origEnv = process.env.GSD_WEB_PROJECT_CWD; - try { - process.env.GSD_WEB_PROJECT_CWD = root; - - const request = new Request( - "http://localhost:3000/api/files?path=../etc/passwd", - ); - const response = await filesRoute.GET(request); - assert.equal(response.status, 400); - - const data = await response.json(); - assert.ok(data.error, "Expected error message in response"); - } finally { + t.after(() => { process.env.GSD_WEB_PROJECT_CWD = origEnv; cleanup(); - } + }); + + process.env.GSD_WEB_PROJECT_CWD = root; + + const request = new Request( + "http://localhost:3000/api/files?path=../etc/passwd", + ); + const response = await filesRoute.GET(request); + assert.equal(response.status, 400); + + const data = await response.json(); + assert.ok(data.error, "Expected error message in response"); }); -test("files API rejects absolute paths", async () => { +test("files API rejects absolute paths", async (t) => { const { root, cleanup } = makeGsdFixture(); const origEnv = process.env.GSD_WEB_PROJECT_CWD; - try { - process.env.GSD_WEB_PROJECT_CWD = root; - - const request = new Request( - "http://localhost:3000/api/files?path=/etc/passwd", - ); - const response = await filesRoute.GET(request); - assert.equal(response.status, 400); - - const data = await response.json(); - assert.ok(data.error); - } finally { + t.after(() => { process.env.GSD_WEB_PROJECT_CWD = origEnv; cleanup(); - } + }); + + process.env.GSD_WEB_PROJECT_CWD = root; + + const request = new Request( + "http://localhost:3000/api/files?path=/etc/passwd", + ); + const response = await filesRoute.GET(request); + assert.equal(response.status, 400); + + const data = await response.json(); + assert.ok(data.error); }); -test("files API returns 404 for missing files", async () => { +test("files API returns 404 for missing files", async (t) => { const { root, cleanup } = makeGsdFixture(); const origEnv = process.env.GSD_WEB_PROJECT_CWD; - try { - process.env.GSD_WEB_PROJECT_CWD = root; - - const request = new Request( - "http://localhost:3000/api/files?path=nonexistent.md", - ); - const response = await filesRoute.GET(request); - assert.equal(response.status, 404); - - const data = await response.json(); - assert.ok(data.error); - } finally { + t.after(() => { process.env.GSD_WEB_PROJECT_CWD = origEnv; cleanup(); - } + }); + + process.env.GSD_WEB_PROJECT_CWD = root; + + const request = new Request( + "http://localhost:3000/api/files?path=nonexistent.md", + ); + const response = await filesRoute.GET(request); + assert.equal(response.status, 404); + + const data = await response.json(); + assert.ok(data.error); }); -test("files API returns empty tree when .gsd/ does not exist", async () => { +test("files API returns empty tree when .gsd/ does not exist", async (t) => { const root = mkdtempSync(join(tmpdir(), "gsd-state-surfaces-empty-")); const origEnv = process.env.GSD_WEB_PROJECT_CWD; - try { - process.env.GSD_WEB_PROJECT_CWD = root; - - const request = new Request("http://localhost:3000/api/files"); - const response = await filesRoute.GET(request); - assert.equal(response.status, 200); - - const data = await response.json(); - assert.deepEqual(data.tree, []); - } finally { + t.after(() => { process.env.GSD_WEB_PROJECT_CWD = origEnv; rmSync(root, { recursive: true, force: true }); - } + }); + + process.env.GSD_WEB_PROJECT_CWD = root; + + const request = new Request("http://localhost:3000/api/files"); + const response = await filesRoute.GET(request); + assert.equal(response.status, 200); + + const data = await response.json(); + assert.deepEqual(data.tree, []); }); // ─── Group 6: Mock-free invariant — no static mock data ────────────── diff --git a/src/tests/web-workflow-action-execution.test.ts b/src/tests/web-workflow-action-execution.test.ts index d06c44182..3cc052a39 100644 --- a/src/tests/web-workflow-action-execution.test.ts +++ b/src/tests/web-workflow-action-execution.test.ts @@ -29,7 +29,7 @@ test("derivePendingWorkflowCommandLabel falls back to the command type when no i assert.equal(label, "/abort") }) -test("navigateToGSDView dispatches the shared browser navigation event", () => { +test("navigateToGSDView dispatches the shared browser navigation event", (t) => { const originalWindow = (globalThis as { window?: EventTarget }).window const fakeWindow = new EventTarget() const seen: string[] = [] @@ -40,16 +40,14 @@ test("navigateToGSDView dispatches the shared browser navigation event", () => { ;(globalThis as { window?: EventTarget }).window = fakeWindow - try { - navigateToGSDView("power") - } finally { - ;(globalThis as { window?: EventTarget }).window = originalWindow - } + t.after(() => { ;(globalThis as { window?: EventTarget }).window = originalWindow }); + + navigateToGSDView("power") assert.deepEqual(seen, ["power"]) }) -test("executeWorkflowActionInPowerMode calls dispatch and navigates to the appropriate view", async () => { +test("executeWorkflowActionInPowerMode calls dispatch and navigates to the appropriate view", async (t) => { const originalWindow = (globalThis as { window?: EventTarget }).window const originalLocalStorage = (globalThis as any).localStorage const fakeWindow = new EventTarget() @@ -63,18 +61,18 @@ test("executeWorkflowActionInPowerMode calls dispatch and navigates to the appro ;(globalThis as { window?: EventTarget }).window = fakeWindow ;(globalThis as any).localStorage = { getItem: () => null, setItem: () => {} } - try { - executeWorkflowActionInPowerMode({ - dispatch: async () => { - dispatchCalled = true - }, - }) - // dispatch is fire-and-forget, give it a tick to resolve - await new Promise((resolve) => setTimeout(resolve, 10)) - } finally { + t.after(() => { ;(globalThis as { window?: EventTarget }).window = originalWindow ;(globalThis as any).localStorage = originalLocalStorage - } + }); + + executeWorkflowActionInPowerMode({ + dispatch: async () => { + dispatchCalled = true + }, + }) + // dispatch is fire-and-forget, give it a tick to resolve + await new Promise((resolve) => setTimeout(resolve, 10)) assert.equal(dispatchCalled, true, "dispatch should have been called") assert.ok(seenViews.length > 0, "should navigate to a view") diff --git a/src/tests/welcome-screen.test.ts b/src/tests/welcome-screen.test.ts index 347f4fda9..cfea992c5 100644 --- a/src/tests/welcome-screen.test.ts +++ b/src/tests/welcome-screen.test.ts @@ -51,20 +51,20 @@ test('renders cwd hint', () => { assert.ok(out.includes('/gsd to begin'), 'hint line missing') }) -test('skips when not a TTY', () => { +test('skips when not a TTY', (t) => { const chunks: string[] = [] const original = process.stderr.write.bind(process.stderr) ;(process.stderr as any).write = (chunk: string) => { chunks.push(chunk); return true } const origIsTTY = (process.stderr as any).isTTY ;(process.stderr as any).isTTY = false - try { - printWelcomeScreen({ version: '1.0.0' }) - assert.equal(chunks.join(''), '', 'should produce no output when not TTY') - } finally { + t.after(() => { ;(process.stderr as any).write = original ;(process.stderr as any).isTTY = origIsTTY - } + }); + + printWelcomeScreen({ version: '1.0.0' }) + assert.equal(chunks.join(''), '', 'should produce no output when not TTY') }) test('renders without model or provider', () => {