diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index f9f3317b51..21df4feb34 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -798,19 +798,31 @@ export class BrowserManager { const page = this.pages.get(tabId); if (!page) throw new Error(`Tab ${tabId} not found`); + // Capture BEFORE close(): the page 'close' event handler wired in + // wirePageEvents() can fire while page.close() is awaited. It removes + // the tab from the maps and reassigns activeTabId (to 0 when no tabs + // remain), so a post-close `tabId === this.activeTabId` check is + // order-dependent — whether the event dispatches before or after + // close() resolves varies across Playwright/Chromium versions and + // machines, and losing the race means the last-tab auto-create below + // never runs, leaving the manager with zero tabs. + const wasActive = tabId === this.activeTabId; + await page.close(); this.pages.delete(tabId); this.tabSessions.delete(tabId); this.tabOwnership.delete(tabId); // Switch to another tab if we closed the active one - if (tabId === this.activeTabId) { + if (wasActive) { const remaining = [...this.pages.keys()]; - if (remaining.length > 0) { - this.activeTabId = remaining[remaining.length - 1]; - } else { + if (remaining.length === 0) { // No tabs left — create a new blank one await this.newTab(); + } else if (!this.pages.has(this.activeTabId)) { + // The 'close' handler may have already switched to a valid tab; + // only reassign when activeTabId no longer points at a live tab. + this.activeTabId = remaining[remaining.length - 1]; } } } diff --git a/browse/test/batch.test.ts b/browse/test/batch.test.ts index 3d904a1a97..a6ee8a2b0e 100644 --- a/browse/test/batch.test.ts +++ b/browse/test/batch.test.ts @@ -42,9 +42,14 @@ beforeAll(async () => { // The test needs to start a server. Let's use the existing server infrastructure. }); -afterAll(() => { +afterAll(async () => { try { testServer.server.stop(); } catch {} - setTimeout(() => process.exit(0), 500); + // Close only this file's own browser — never process.exit(): bun test runs + // all files in one process, so a delayed exit kills the whole suite + // (see test/no-suicide-exit.test.ts). close() can hang when the browser + // already died, and its internal 5s timeout ties bun's 5s hook timeout — + // so race it at 3s and abandon; the child is reaped at process exit. + try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {} }); // We need a running browse server for HTTP tests. diff --git a/browse/test/commands.test.ts b/browse/test/commands.test.ts index 9382cb27ed..bfc25c9c76 100644 --- a/browse/test/commands.test.ts +++ b/browse/test/commands.test.ts @@ -5,7 +5,7 @@ * A real browse server is started and commands are sent via the CLI HTTP interface. */ -import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { describe, test, expect, beforeAll, beforeEach, afterAll } from 'bun:test'; import { startTestServer } from './test-server'; import { BrowserManager } from '../src/browser-manager'; import { resolveServerScript } from '../src/cli'; @@ -94,11 +94,14 @@ beforeAll(async () => { await bm.launch(); }); -afterAll(() => { - // Force kill browser instead of graceful close (avoids hang) +afterAll(async () => { try { testServer.server.stop(); } catch {} - // bm.close() can hang — just let process exit handle it - setTimeout(() => process.exit(0), 500); + // Close only this file's own browser — never process.exit(): bun test runs + // all files in one process, so a delayed exit kills the whole suite + // (see test/no-suicide-exit.test.ts). close() can hang when the browser + // already died, and its internal 5s timeout ties bun's 5s hook timeout — + // so race it at 3s and abandon; the child is reaped at process exit. + try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {} }); // ─── Navigation ───────────────────────────────────────────────── @@ -135,7 +138,12 @@ describe('Navigation', () => { // ─── Content Extraction ───────────────────────────────────────── describe('Content extraction', () => { - beforeAll(async () => { + // beforeEach, NOT beforeAll: bun <1.3 runs every describe-scoped beforeAll + // eagerly at file start (in file order), so a beforeAll goto here is + // clobbered by later describes' beforeAll gotos before any test runs. + // beforeEach is scoped correctly on all bun versions, and a goto against + // the local fixture server costs only a few ms per test. + beforeEach(async () => { await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); }); @@ -193,7 +201,9 @@ describe('Content extraction', () => { // ─── JavaScript / CSS / Attrs ─────────────────────────────────── describe('Inspection', () => { - beforeAll(async () => { + // beforeEach, NOT beforeAll — see 'Content extraction' note (bun <1.3 + // runs describe-scoped beforeAll hooks eagerly at file start). + beforeEach(async () => { await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); }); @@ -1071,7 +1081,9 @@ describe('Dialog handling', () => { // ─── Element State Checks (is) ───────────────────────────────── describe('Element state checks', () => { - beforeAll(async () => { + // beforeEach, NOT beforeAll — see 'Content extraction' note (bun <1.3 + // runs describe-scoped beforeAll hooks eagerly at file start). + beforeEach(async () => { await handleWriteCommand('goto', [baseUrl + '/states.html'], bm); }); diff --git a/browse/test/compare-board.test.ts b/browse/test/compare-board.test.ts index 0a453a43a8..b9007af7e0 100644 --- a/browse/test/compare-board.test.ts +++ b/browse/test/compare-board.test.ts @@ -69,10 +69,15 @@ beforeAll(async () => { await handleWriteCommand('goto', [boardUrl], bm); }); -afterAll(() => { +afterAll(async () => { try { server.stop(); } catch {} fs.rmSync(tmpDir, { recursive: true, force: true }); - setTimeout(() => process.exit(0), 500); + // Close only this file's own browser — never process.exit(): bun test runs + // all files in one process, so a delayed exit kills the whole suite + // (see test/no-suicide-exit.test.ts). close() can hang when the browser + // already died, and its internal 5s timeout ties bun's 5s hook timeout — + // so race it at 3s and abandon; the child is reaped at process exit. + try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {} }); // ─── DOM Structure ────────────────────────────────────────────── diff --git a/browse/test/content-security.test.ts b/browse/test/content-security.test.ts index 8c760460dc..264019d764 100644 --- a/browse/test/content-security.test.ts +++ b/browse/test/content-security.test.ts @@ -124,6 +124,17 @@ describe('Content filter hooks', () => { clearContentFilters(); }); + // Restore module-load state when this describe is done. bun test runs + // every file in one process, so the content-security module instance is + // SHARED across test files — leaving the registry empty here breaks + // security-integration.test.ts, whose pipeline test pins that the + // built-in urlBlocklistFilter is registered (content-security.ts + // registers it at module load). + afterAll(() => { + clearContentFilters(); + registerContentFilter(urlBlocklistFilter); + }); + test('URL blocklist detects requestbin', () => { const result = urlBlocklistFilter('', 'https://requestbin.com/r/abc', 'text'); expect(result.safe).toBe(false); @@ -422,9 +433,14 @@ describe('Hidden element stripping', () => { await bm.launch(); }); - afterAll(() => { + afterAll(async () => { try { testServer.server.stop(); } catch {} - setTimeout(() => process.exit(0), 500); + // Close only this file's own browser — never process.exit(): bun test + // runs all files in one process, so a delayed exit kills the whole suite + // (see test/no-suicide-exit.test.ts). close() can hang when the browser + // already died, and its internal 5s timeout ties bun's 5s hook timeout — + // so race it at 3s and abandon; the child is reaped at process exit. + try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {} }); test('detects CSS-hidden elements on injection-hidden page', async () => { diff --git a/browse/test/dual-listener.test.ts b/browse/test/dual-listener.test.ts index 3ce04c1b7d..f7c527bab8 100644 --- a/browse/test/dual-listener.test.ts +++ b/browse/test/dual-listener.test.ts @@ -220,7 +220,9 @@ describe('/command tunnel command allowlist', () => { 'return handleCommand(body, tokenInfo)' ); expect(commandBlock).toContain("surface === 'tunnel'"); - expect(commandBlock).toContain('canDispatchOverTunnel(body?.command)'); + // Args-aware since the --out (disk write) tunnel ban: the dispatch gate + // takes both the command and its args. + expect(commandBlock).toContain('canDispatchOverTunnel(body?.command, body?.args)'); expect(commandBlock).toContain('disallowed_command'); expect(commandBlock).toContain('is not allowed over the tunnel surface'); expect(commandBlock).toContain('status: 403'); diff --git a/browse/test/gstack-config.test.ts b/browse/test/gstack-config.test.ts index e342a50b74..670c1b092f 100644 --- a/browse/test/gstack-config.test.ts +++ b/browse/test/gstack-config.test.ts @@ -15,12 +15,20 @@ const SCRIPT = join(import.meta.dir, '..', '..', 'bin', 'gstack-config'); let stateDir: string; function run(args: string[] = [], extraEnv: Record = {}) { + // The script resolves GSTACK_STATE_ROOT > GSTACK_HOME > GSTACK_STATE_DIR. + // Scrub the two higher-priority overrides from the inherited env so a + // leaked GSTACK_HOME/GSTACK_STATE_ROOT from another test file (or the + // operator shell) can never redirect reads/writes away from this test's + // temp stateDir. + const env: Record = { + ...process.env, + GSTACK_STATE_DIR: stateDir, + ...extraEnv, + }; + if (!('GSTACK_STATE_ROOT' in extraEnv)) delete env.GSTACK_STATE_ROOT; + if (!('GSTACK_HOME' in extraEnv)) delete env.GSTACK_HOME; const result = Bun.spawnSync(['bash', SCRIPT, ...args], { - env: { - ...process.env, - GSTACK_STATE_DIR: stateDir, - ...extraEnv, - }, + env, stdout: 'pipe', stderr: 'pipe', }); diff --git a/browse/test/gstack-update-check.test.ts b/browse/test/gstack-update-check.test.ts index 0edd366e49..73ffecb692 100644 --- a/browse/test/gstack-update-check.test.ts +++ b/browse/test/gstack-update-check.test.ts @@ -22,6 +22,14 @@ function run(extraEnv: Record = {}, args: string[] = []) { ...process.env, GSTACK_DIR: gstackDir, GSTACK_STATE_DIR: stateDir, + // gstack-config resolves its state dir as GSTACK_STATE_ROOT > + // GSTACK_HOME > GSTACK_STATE_DIR. Other test files in the same bun + // process set process.env.GSTACK_HOME mid-suite (and operator shells + // may export it too); a leaked value would win over GSTACK_STATE_DIR + // above and point gstack-config at the wrong config.yaml, making the + // update_check config tests order-dependent. Pin all three. + GSTACK_HOME: stateDir, + GSTACK_STATE_ROOT: stateDir, GSTACK_REMOTE_URL: `file://${join(gstackDir, 'REMOTE_VERSION')}`, ...extraEnv, }, diff --git a/browse/test/handoff.test.ts b/browse/test/handoff.test.ts index e6754637f5..9708233e1c 100644 --- a/browse/test/handoff.test.ts +++ b/browse/test/handoff.test.ts @@ -26,9 +26,14 @@ beforeAll(async () => { await bm.launch(); }); -afterAll(() => { +afterAll(async () => { try { testServer.server.stop(); } catch {} - setTimeout(() => process.exit(0), 500); + // Close only this file's own browser — never process.exit(): bun test runs + // all files in one process, so a delayed exit kills the whole suite + // (see test/no-suicide-exit.test.ts). close() can hang when the browser + // already died, and its internal 5s timeout ties bun's 5s hook timeout — + // so race it at 3s and abandon; the child is reaped at process exit. + try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {} }); // ─── Unit Tests: Failure Tracking (no browser needed) ──────────── diff --git a/browse/test/security-live-playwright.test.ts b/browse/test/security-live-playwright.test.ts index c75a115d30..b46e4b8c9b 100644 --- a/browse/test/security-live-playwright.test.ts +++ b/browse/test/security-live-playwright.test.ts @@ -56,9 +56,14 @@ describe('defense-in-depth — live Playwright fixture', () => { await bm.launch(); }); - afterAll(() => { + afterAll(async () => { try { testServer.server.stop(); } catch {} - setTimeout(() => process.exit(0), 500); + // Close only this file's own browser — never process.exit(): bun test + // runs all files in one process, so a delayed exit kills the whole suite + // (see test/no-suicide-exit.test.ts). close() can hang when the browser + // already died, and its internal 5s timeout ties bun's 5s hook timeout — + // so race it at 3s and abandon; the child is reaped at process exit. + try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {} }); test('L2 — content-security.ts hidden-element stripper detects the .sneaky div', async () => { diff --git a/browse/test/security-sidepanel-dom.test.ts b/browse/test/security-sidepanel-dom.test.ts deleted file mode 100644 index 4ae34d5f92..0000000000 --- a/browse/test/security-sidepanel-dom.test.ts +++ /dev/null @@ -1,360 +0,0 @@ -/** - * Sidepanel DOM test — verifies the extension's sidepanel.html/.js/.css - * actually render and react to security events correctly when loaded in - * a real Chromium. - * - * Uses Playwright + BrowserManager. The extension sidepanel is loaded via - * file:// with a stubbed window.fetch that simulates the browse server - * returning /health + /sidebar-chat responses. We inject security_event - * entries via the stubbed /sidebar-chat response and assert: - * - * * Banner renders (display: block, not display: none) - * * Title + subtitle text reflects domain + layer - * * Layer scores appear in the expandable details - * * Shield icon data-status attr flips based on /health.security.status - * * Escape key dismisses the banner - * * Expand button toggles aria-expanded + layer list visibility - * - * All 83 prior security tests cover the JS behavior in isolation; this - * test covers the integration: sidepanel.html + sidepanel.js + sidepanel.css - * + real DOM + real event dispatch. - * - * Runs in ~2s. Gate tier. Skipped if Playwright isn't available. - */ - -import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; -import * as fs from 'fs'; -import * as path from 'path'; -import { chromium, type Browser, type Page } from 'playwright'; - -const EXTENSION_DIR = path.resolve(import.meta.dir, '..', '..', 'extension'); -const SIDEPANEL_URL = `file://${EXTENSION_DIR}/sidepanel.html`; - -/** - * Eager check — does Playwright have chromium installed on disk? - * test.skipIf() is evaluated at file-registration time (before beforeAll), - * so a runtime probe of `browser` state wouldn't work — all tests would - * unconditionally get registered as `skip: true`. We need a sync check. - */ -const CHROMIUM_AVAILABLE = (() => { - try { - const exe = chromium.executablePath(); - return !!exe && fs.existsSync(exe); - } catch { - return false; - } -})(); - -/** - * Seed the sidepanel so it thinks it's connected + poll-ready before - * sidepanel.js runs its connection flow. We stub chrome.runtime, chrome.tabs, - * and window.fetch so the sidepanel code paths behave as if a real browse - * server is responding. - */ -async function installStubsBeforeLoad(page: Page, scenario: { - healthSecurity?: { status: 'protected' | 'degraded' | 'inactive'; layers?: any }; - securityEntries?: any[]; -}): Promise { - await page.addInitScript((params: any) => { - // Stub chrome.runtime for the background-service-worker connection flow. - // sendMessage supports both callback and Promise style — sidepanel.js - // uses both patterns depending on the call site. - (window as any).chrome = { - runtime: { - sendMessage: (_req: any, cb: any) => { - const payload = { connected: true, port: 34567 }; - if (typeof cb === 'function') { - setTimeout(() => cb(payload), 0); - return undefined; - } - return Promise.resolve(payload); - }, - lastError: null, - onMessage: { addListener: () => {} }, - }, - tabs: { - query: (_q: any, cb: any) => setTimeout(() => cb([{ id: 1, url: 'https://example.com' }]), 0), - onActivated: { addListener: () => {} }, - onUpdated: { addListener: () => {} }, - }, - }; - - // Stub EventSource — connectSSE() throws without this because file:// - // can't actually open an SSE connection to http://127.0.0.1. - (window as any).EventSource = class { - constructor() {} - addEventListener() {} - close() {} - }; - - // Stub fetch. - const scenarioRef = params; - const origFetch = window.fetch; - window.fetch = async function (input: any, init?: any) { - const url = String(input); - if (url.endsWith('/health')) { - return new Response(JSON.stringify({ - status: 'healthy', - token: 'test-token', - mode: 'headed', - agent: { status: 'idle', runningFor: null, queueLength: 0 }, - session: null, - security: scenarioRef.healthSecurity ?? { status: 'degraded', layers: {}, lastUpdated: '' }, - }), { status: 200, headers: { 'Content-Type': 'application/json' } }); - } - if (url.includes('/sidebar-chat')) { - return new Response(JSON.stringify({ - entries: scenarioRef.securityEntries ?? [], - total: (scenarioRef.securityEntries ?? []).length, - agentStatus: 'idle', - activeTabId: 1, - security: scenarioRef.healthSecurity ?? { status: 'degraded', layers: {} }, - }), { status: 200, headers: { 'Content-Type': 'application/json' } }); - } - if (url.includes('/sidebar-tabs')) { - return new Response(JSON.stringify({ tabs: [] }), { status: 200 }); - } - if (url.includes('/sidebar-activity')) { - return new Response('{}', { status: 200 }); - } - // Fall through for anything else we didn't scenario. - if (typeof origFetch === 'function') return origFetch(input, init); - return new Response('{}', { status: 200 }); - } as any; - }, scenario); -} - -let browser: Browser | null = null; - -beforeAll(async () => { - if (!CHROMIUM_AVAILABLE) return; - browser = await chromium.launch({ headless: true }); -}, 30000); - -afterAll(async () => { - if (browser) { - try { await browser.close(); } catch {} - } -}); - -describe('sidepanel security DOM', () => { - test.skipIf(!CHROMIUM_AVAILABLE)('shield icon reflects /health.security.status', async () => { - const context = await browser!.newContext(); - const page = await context.newPage(); - await installStubsBeforeLoad(page, { - healthSecurity: { - status: 'protected', - layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' }, - }, - }); - await page.goto(SIDEPANEL_URL); - // sidepanel.js updates the shield after the first /health call - // succeeds. Give it a tick. - await page.waitForFunction( - () => document.getElementById('security-shield')?.getAttribute('data-status') === 'protected', - { timeout: 5000 }, - ); - const status = await page.$eval('#security-shield', (el) => el.getAttribute('data-status')); - expect(status).toBe('protected'); - // aria-label carries human-readable state - const aria = await page.$eval('#security-shield', (el) => el.getAttribute('aria-label')); - expect(aria).toContain('protected'); - await context.close(); - }, 15000); - - test.skipIf(!CHROMIUM_AVAILABLE)('shield flips to degraded when classifier warmup is incomplete', async () => { - const context = await browser!.newContext(); - const page = await context.newPage(); - await installStubsBeforeLoad(page, { - healthSecurity: { - status: 'degraded', - layers: { testsavant: 'off', transcript: 'ok', canary: 'ok' }, - }, - }); - await page.goto(SIDEPANEL_URL); - await page.waitForFunction( - () => document.getElementById('security-shield')?.getAttribute('data-status') === 'degraded', - { timeout: 5000 }, - ); - const status = await page.$eval('#security-shield', (el) => el.getAttribute('data-status')); - expect(status).toBe('degraded'); - await context.close(); - }, 15000); - - test.skipIf(!CHROMIUM_AVAILABLE)('security_event entry triggers banner render with domain + layer scores', async () => { - const securityEntry = { - id: 1, - ts: '2026-04-20T00:00:00Z', - role: 'agent', - type: 'security_event', - verdict: 'block', - reason: 'canary_leaked', - layer: 'canary', - confidence: 1.0, - domain: 'attacker.example.com', - channel: 'tool_use:Bash', - signals: [ - { layer: 'testsavant_content', confidence: 0.92 }, - { layer: 'transcript_classifier', confidence: 0.78 }, - ], - }; - - const context = await browser!.newContext(); - const page = await context.newPage(); - await installStubsBeforeLoad(page, { - healthSecurity: { - status: 'protected', - layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' }, - }, - securityEntries: [securityEntry], - }); - await page.goto(SIDEPANEL_URL); - - // The banner should become visible once /sidebar-chat poll delivers the - // security_event entry and addChatEntry routes it to showSecurityBanner. - await page.waitForSelector('#security-banner', { state: 'visible', timeout: 5000 }); - const displayed = await page.$eval('#security-banner', (el) => - window.getComputedStyle(el).display !== 'none', - ); - expect(displayed).toBe(true); - - // Subtitle includes the attack domain - const subtitleText = await page.textContent('#security-banner-subtitle'); - expect(subtitleText).toContain('attacker.example.com'); - expect(subtitleText).toContain('prompt injection detected'); - - // Layer list was populated — primary layer (canary) always renders; - // signals array brings in the additional ML layers - const layers = await page.$$eval('.security-banner-layer', (els) => - els.map((el) => el.textContent), - ); - expect(layers.length).toBeGreaterThanOrEqual(1); - // Canary row expected - expect(layers.join(' ')).toMatch(/Canary|canary/); - - await context.close(); - }, 15000); - - test.skipIf(!CHROMIUM_AVAILABLE)('expand button toggles aria-expanded + reveals details', async () => { - const entry = { - id: 1, - ts: '2026-04-20T00:00:00Z', - role: 'agent', - type: 'security_event', - verdict: 'block', - reason: 'ensemble_agreement', - layer: 'testsavant_content', - confidence: 0.88, - domain: 'example.com', - signals: [ - { layer: 'testsavant_content', confidence: 0.88 }, - { layer: 'transcript_classifier', confidence: 0.71 }, - ], - }; - const context = await browser!.newContext(); - const page = await context.newPage(); - await installStubsBeforeLoad(page, { - healthSecurity: { status: 'protected', layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' } }, - securityEntries: [entry], - }); - await page.goto(SIDEPANEL_URL); - await page.waitForSelector('#security-banner', { state: 'visible', timeout: 5000 }); - - // Initially collapsed - const initialAria = await page.$eval('#security-banner-expand', (el) => - el.getAttribute('aria-expanded'), - ); - expect(initialAria).toBe('false'); - const initialHidden = await page.$eval('#security-banner-details', (el) => - (el as HTMLElement).hidden, - ); - expect(initialHidden).toBe(true); - - // Click expand - await page.click('#security-banner-expand'); - const expandedAria = await page.$eval('#security-banner-expand', (el) => - el.getAttribute('aria-expanded'), - ); - expect(expandedAria).toBe('true'); - const expandedHidden = await page.$eval('#security-banner-details', (el) => - (el as HTMLElement).hidden, - ); - expect(expandedHidden).toBe(false); - - await context.close(); - }, 15000); - - test.skipIf(!CHROMIUM_AVAILABLE)('Escape key dismisses an open banner', async () => { - const entry = { - id: 1, - ts: '2026-04-20T00:00:00Z', - role: 'agent', - type: 'security_event', - verdict: 'block', - reason: 'canary_leaked', - layer: 'canary', - confidence: 1.0, - domain: 'evil.example.com', - }; - const context = await browser!.newContext(); - const page = await context.newPage(); - await installStubsBeforeLoad(page, { - healthSecurity: { status: 'protected', layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' } }, - securityEntries: [entry], - }); - await page.goto(SIDEPANEL_URL); - await page.waitForSelector('#security-banner', { state: 'visible', timeout: 5000 }); - - // Hit Escape — should hide the banner - await page.keyboard.press('Escape'); - // Wait a tick for the event handler to run - await page.waitForFunction( - () => { - const el = document.getElementById('security-banner'); - return el ? window.getComputedStyle(el).display === 'none' : false; - }, - { timeout: 2000 }, - ); - const stillVisible = await page.$eval('#security-banner', (el) => - window.getComputedStyle(el).display !== 'none', - ); - expect(stillVisible).toBe(false); - await context.close(); - }, 15000); - - test.skipIf(!CHROMIUM_AVAILABLE)('close button dismisses banner', async () => { - const entry = { - id: 1, - ts: '2026-04-20T00:00:00Z', - role: 'agent', - type: 'security_event', - verdict: 'block', - reason: 'canary_leaked', - layer: 'canary', - confidence: 1.0, - domain: 'evil.example.com', - }; - const context = await browser!.newContext(); - const page = await context.newPage(); - await installStubsBeforeLoad(page, { - healthSecurity: { status: 'protected', layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' } }, - securityEntries: [entry], - }); - await page.goto(SIDEPANEL_URL); - await page.waitForSelector('#security-banner', { state: 'visible', timeout: 5000 }); - - await page.click('#security-banner-close'); - await page.waitForFunction( - () => { - const el = document.getElementById('security-banner'); - return el ? window.getComputedStyle(el).display === 'none' : false; - }, - { timeout: 2000 }, - ); - const displayed = await page.$eval('#security-banner', (el) => - window.getComputedStyle(el).display !== 'none', - ); - expect(displayed).toBe(false); - await context.close(); - }, 15000); -}); diff --git a/browse/test/sidebar-integration.test.ts b/browse/test/sidebar-integration.test.ts deleted file mode 100644 index d7a27fea71..0000000000 --- a/browse/test/sidebar-integration.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -/** - * Layer 2: Server HTTP integration tests for sidebar endpoints. - * Starts the browse server as a subprocess (no browser via BROWSE_HEADLESS_SKIP), - * exercises sidebar HTTP endpoints with fetch(). No Chrome, no Claude, no sidebar-agent. - */ - -import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; -import { spawn, type Subprocess } from 'bun'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - -let serverProc: Subprocess | null = null; -let serverPort: number = 0; -let authToken: string = ''; -let tmpDir: string = ''; -let stateFile: string = ''; -let queueFile: string = ''; - -async function api(pathname: string, opts: RequestInit & { noAuth?: boolean } = {}): Promise { - const { noAuth, ...fetchOpts } = opts; - const headers: Record = { - 'Content-Type': 'application/json', - ...(fetchOpts.headers as Record || {}), - }; - if (!noAuth && !headers['Authorization'] && authToken) { - headers['Authorization'] = `Bearer ${authToken}`; - } - return fetch(`http://127.0.0.1:${serverPort}${pathname}`, { ...fetchOpts, headers }); -} - -beforeAll(async () => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sidebar-integ-')); - stateFile = path.join(tmpDir, 'browse.json'); - queueFile = path.join(tmpDir, 'sidebar-queue.jsonl'); - - // Ensure queue dir exists - fs.mkdirSync(path.dirname(queueFile), { recursive: true }); - - const serverScript = path.resolve(__dirname, '..', 'src', 'server.ts'); - serverProc = spawn(['bun', 'run', serverScript], { - env: { - ...process.env, - BROWSE_STATE_FILE: stateFile, - BROWSE_HEADLESS_SKIP: '1', - BROWSE_PORT: '0', - SIDEBAR_QUEUE_PATH: queueFile, - BROWSE_IDLE_TIMEOUT: '300', - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - // Wait for state file - const deadline = Date.now() + 15000; - while (Date.now() < deadline) { - if (fs.existsSync(stateFile)) { - try { - const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); - if (state.port && state.token) { - serverPort = state.port; - authToken = state.token; - break; - } - } catch {} - } - await new Promise(r => setTimeout(r, 100)); - } - if (!serverPort) throw new Error('Server did not start in time'); -}, 20000); - -afterAll(() => { - if (serverProc) { try { serverProc.kill(); } catch {} } - try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} -}); - -// Reset state between tests — creates a fresh session, clears all queues -async function resetState() { - await api('/sidebar-session/new', { method: 'POST' }); - fs.writeFileSync(queueFile, ''); -} - -describe('sidebar auth', () => { - test('rejects request without auth token', async () => { - const resp = await api('/sidebar-command', { - method: 'POST', - noAuth: true, - body: JSON.stringify({ message: 'test' }), - }); - expect(resp.status).toBe(401); - }); - - test('rejects request with wrong token', async () => { - const resp = await api('/sidebar-command', { - method: 'POST', - headers: { 'Authorization': 'Bearer wrong-token' }, - body: JSON.stringify({ message: 'test' }), - }); - expect(resp.status).toBe(401); - }); - - test('accepts request with correct token', async () => { - const resp = await api('/sidebar-command', { - method: 'POST', - body: JSON.stringify({ message: 'hello' }), - }); - expect(resp.status).toBe(200); - // Clean up - await api('/sidebar-agent/kill', { method: 'POST' }); - }); -}); - -describe('sidebar-command → queue', () => { - test('writes queue entry with activeTabUrl', async () => { - await resetState(); - - const resp = await api('/sidebar-command', { - method: 'POST', - body: JSON.stringify({ - message: 'what is on this page?', - activeTabUrl: 'https://example.com/test-page', - }), - }); - expect(resp.status).toBe(200); - const data = await resp.json(); - expect(data.ok).toBe(true); - - // Give server a moment to write queue - await new Promise(r => setTimeout(r, 100)); - - const content = fs.readFileSync(queueFile, 'utf-8').trim(); - const lines = content.split('\n').filter(Boolean); - expect(lines.length).toBeGreaterThan(0); - const entry = JSON.parse(lines[lines.length - 1]); - // Active tab URL is carried on the queue entry metadata (entry.pageUrl), - // NOT inlined into the prompt. The system prompt deliberately tells - // Claude to run `browse url` instead of trusting any URL in the prompt - // body — that's the prompt-injection-via-URL defense. See spawnClaude - // in browse/src/server.ts. - expect(entry.pageUrl).toBe('https://example.com/test-page'); - - await api('/sidebar-agent/kill', { method: 'POST' }); - }); - - test('falls back when activeTabUrl is null', async () => { - await resetState(); - - await api('/sidebar-command', { - method: 'POST', - body: JSON.stringify({ message: 'test', activeTabUrl: null }), - }); - await new Promise(r => setTimeout(r, 100)); - - const lines = fs.readFileSync(queueFile, 'utf-8').trim().split('\n').filter(Boolean); - expect(lines.length).toBeGreaterThan(0); - const entry = JSON.parse(lines[lines.length - 1]); - // No browser → playwright URL is 'about:blank' - expect(entry.pageUrl).toBe('about:blank'); - - await api('/sidebar-agent/kill', { method: 'POST' }); - }); - - test('rejects chrome:// activeTabUrl and falls back', async () => { - await resetState(); - - await api('/sidebar-command', { - method: 'POST', - body: JSON.stringify({ message: 'test', activeTabUrl: 'chrome://extensions' }), - }); - await new Promise(r => setTimeout(r, 100)); - - const lines = fs.readFileSync(queueFile, 'utf-8').trim().split('\n').filter(Boolean); - expect(lines.length).toBeGreaterThan(0); - const entry = JSON.parse(lines[lines.length - 1]); - expect(entry.pageUrl).toBe('about:blank'); - - await api('/sidebar-agent/kill', { method: 'POST' }); - }); - - test('rejects empty message', async () => { - const resp = await api('/sidebar-command', { - method: 'POST', - body: JSON.stringify({ message: '' }), - }); - expect(resp.status).toBe(400); - }); -}); - -describe('sidebar-agent/event → chat buffer', () => { - test('agent events appear in /sidebar-chat', async () => { - await resetState(); - - // Post pre-processed agent event. The server's processAgentEvent - // handles the simplified types that sidebar-agent.ts emits (text, - // text_delta, tool_use, result, agent_error, security_event), NOT - // the raw Claude streaming format — pre-processing lives in - // sidebar-agent.ts, not in the server. - await api('/sidebar-agent/event', { - method: 'POST', - body: JSON.stringify({ - type: 'text', - text: 'Hello from mock agent', - }), - }); - - const chatData = await (await api('/sidebar-chat?after=0')).json(); - const textEntry = chatData.entries.find((e: any) => e.type === 'text'); - expect(textEntry).toBeDefined(); - expect(textEntry.text).toBe('Hello from mock agent'); - }); - - test('agent_done transitions status to idle', async () => { - await resetState(); - // Start a command so agent is processing - await api('/sidebar-command', { - method: 'POST', - body: JSON.stringify({ message: 'test' }), - }); - - // Verify processing - let session = await (await api('/sidebar-session')).json(); - expect(session.agent.status).toBe('processing'); - - // Send agent_done - await api('/sidebar-agent/event', { - method: 'POST', - body: JSON.stringify({ type: 'agent_done' }), - }); - - session = await (await api('/sidebar-session')).json(); - expect(session.agent.status).toBe('idle'); - }); -}); - -describe('message queuing', () => { - test('queues message when agent is processing', async () => { - await resetState(); - - // First message starts processing - await api('/sidebar-command', { - method: 'POST', - body: JSON.stringify({ message: 'first' }), - }); - - // Second message gets queued - const resp = await api('/sidebar-command', { - method: 'POST', - body: JSON.stringify({ message: 'second' }), - }); - const data = await resp.json(); - expect(data.ok).toBe(true); - expect(data.queued).toBe(true); - expect(data.position).toBe(1); - - await api('/sidebar-agent/kill', { method: 'POST' }); - }); - - test('returns 429 when queue is full', async () => { - await resetState(); - - // First message starts processing - await api('/sidebar-command', { - method: 'POST', - body: JSON.stringify({ message: 'first' }), - }); - - // Fill queue (max 5) - for (let i = 0; i < 5; i++) { - await api('/sidebar-command', { - method: 'POST', - body: JSON.stringify({ message: `fill-${i}` }), - }); - } - - // 7th message should be rejected - const resp = await api('/sidebar-command', { - method: 'POST', - body: JSON.stringify({ message: 'overflow' }), - }); - expect(resp.status).toBe(429); - - await api('/sidebar-agent/kill', { method: 'POST' }); - }); -}); - -describe('chat clear', () => { - test('clears chat buffer', async () => { - await resetState(); - // Add some entries - await api('/sidebar-agent/event', { - method: 'POST', - body: JSON.stringify({ type: 'text', text: 'to be cleared' }), - }); - - await api('/sidebar-chat/clear', { method: 'POST' }); - - const data = await (await api('/sidebar-chat?after=0')).json(); - expect(data.entries.length).toBe(0); - expect(data.total).toBe(0); - }); -}); - -describe('agent kill', () => { - test('kill adds error entry and returns to idle', async () => { - await resetState(); - - // Start a command so agent is processing - await api('/sidebar-command', { - method: 'POST', - body: JSON.stringify({ message: 'kill me' }), - }); - - let session = await (await api('/sidebar-session')).json(); - expect(session.agent.status).toBe('processing'); - - // Kill the agent - const killResp = await api('/sidebar-agent/kill', { method: 'POST' }); - expect(killResp.status).toBe(200); - - // Check chat for error entry - const chatData = await (await api('/sidebar-chat?after=0')).json(); - const errorEntry = chatData.entries.find((e: any) => e.error === 'Killed by user'); - expect(errorEntry).toBeDefined(); - - // Agent should be idle (no queue items to auto-process) - session = await (await api('/sidebar-session')).json(); - expect(session.agent.status).toBe('idle'); - }); -}); diff --git a/browse/test/sidebar-tabs.test.ts b/browse/test/sidebar-tabs.test.ts index 91d50dcef2..94253ab5cf 100644 --- a/browse/test/sidebar-tabs.test.ts +++ b/browse/test/sidebar-tabs.test.ts @@ -157,7 +157,9 @@ describe('sidepanel-terminal.js: eager auto-connect + injection API', () => { test('forceRestart helper closes ws, disposes xterm, returns to IDLE', () => { expect(TERM_JS).toContain('function forceRestart'); const fn = TERM_JS.slice(TERM_JS.indexOf('function forceRestart')); - expect(fn).toContain('ws && ws.close()'); + // close() carries an intentional-restart close code so the agent's + // close handler can distinguish user restarts from network drops. + expect(fn).toContain("ws && ws.close(4001, 'intentional-restart')"); expect(fn).toContain('term.dispose()'); expect(fn).toContain('STATE.IDLE'); expect(fn).toContain('tryAutoConnect()'); @@ -222,8 +224,17 @@ describe('cli.ts: sidebar-agent is no longer spawned', () => { }); test('Terminal-agent spawn survives', () => { - expect(CLI_SRC).toContain('terminal-agent.ts'); - expect(CLI_SRC).toMatch(/Bun\.spawn\(\['bun',\s*'run',\s*termAgentScript\]/); + // v1.44 moved the raw Bun.spawn into the shared spawnTerminalAgent + // helper (terminal-agent-control.ts) so cli.ts, the supervisor respawn + // loop, and the watchdog all share identity-based process control. + // cli.ts must still route through that helper. + expect(CLI_SRC).toContain('spawnTerminalAgent'); + const CONTROL_SRC = fs.readFileSync( + path.join(import.meta.dir, '../src/terminal-agent-control.ts'), + 'utf-8', + ); + expect(CONTROL_SRC).toContain('terminal-agent.ts'); + expect(CONTROL_SRC).toMatch(/\.spawn\(\['bun',\s*'run',\s*script\]/); }); }); diff --git a/browse/test/sidebar-ux.test.ts b/browse/test/sidebar-ux.test.ts index 74ced5efd0..c97412349a 100644 --- a/browse/test/sidebar-ux.test.ts +++ b/browse/test/sidebar-ux.test.ts @@ -1,10 +1,23 @@ /** - * Tests for sidebar UX changes: - * - System prompt does not bake in page URL (navigation fix) - * - --resume is never used (stale context fix) - * - /sidebar-chat response includes agentStatus - * - Sidebar HTML has updated banner, placeholder, stop button - * - Narration instructions present in system prompt + * Structural tests for the sidebar's surviving UX surfaces: + * - Quick-action toolbar (cleanup via PTY injection, screenshot, cookies) + * - CSP fallback basic picker (content.js) + inspector allowlist + * - Deterministic cleanup heuristics (write-commands.ts) + * - Welcome page + sidebar auto-open + arrow hint signal chain + * - Connection/auth race prevention + startup health check + * - browser-manager tab tracking + no-focus-steal invariants + * - Server shutdown teardown of the terminal-agent + * + * History: this file used to also pin the chat-queue architecture + * (sidebar-agent.ts, /sidebar-command, /sidebar-chat, /sidebar-tabs, + * per-tab chat context, stop button, chat polling, processAgentEvent, + * pickSidebarModel). That entire path was deliberately ripped in PR #1216 + * (v1.14.0.0) when the interactive claude PTY (terminal-agent.ts) proved + * strictly more capable — see docs/designs/SIDEBAR_MESSAGE_FLOW.md. The + * stale blocks kept "passing" only because a teardown bug made `bun test` + * exit 0 before reporting; once that was fixed (PR #2172) they surfaced as + * failures and were removed. The rip itself is pinned as absence tests in + * browse/test/sidebar-tabs.test.ts. */ import { describe, test, expect } from 'bun:test'; @@ -13,361 +26,6 @@ import * as path from 'path'; const ROOT = path.resolve(__dirname, '..'); -// ─── System prompt tests (server.ts spawnClaude) ───────────────── - -describe('sidebar system prompt (server.ts)', () => { - const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); - - test('system prompt does not bake in page URL', () => { - // The old prompt had: `The user is currently viewing: ${pageUrl}` - // The new prompt should NOT contain this pattern - // Extract the systemPrompt array from spawnClaude - const promptSection = serverSrc.slice( - serverSrc.indexOf('const systemPrompt = ['), - serverSrc.indexOf("].join('\\n');", serverSrc.indexOf('const systemPrompt = [')) + 15, - ); - expect(promptSection).not.toContain('currently viewing'); - expect(promptSection).not.toContain('${pageUrl}'); - }); - - test('system prompt tells agent to check URL before acting', () => { - const promptSection = serverSrc.slice( - serverSrc.indexOf('const systemPrompt = ['), - serverSrc.indexOf("].join('\\n');", serverSrc.indexOf('const systemPrompt = [')) + 15, - ); - expect(promptSection).toContain('NEVER'); - expect(promptSection).toContain('navigate back'); - expect(promptSection).toContain('NEVER assume'); - expect(promptSection).toContain('url`'); - }); - - test('system prompt includes conciseness and stop instructions', () => { - const promptSection = serverSrc.slice( - serverSrc.indexOf('const systemPrompt = ['), - serverSrc.indexOf("].join('\\n');", serverSrc.indexOf('const systemPrompt = [')) + 15, - ); - expect(promptSection).toContain('CONCISE'); - expect(promptSection).toContain('STOP'); - }); - - test('--resume is never used in spawnClaude args', () => { - // Extract the spawnClaude function - const fnStart = serverSrc.indexOf('function spawnClaude('); - const fnEnd = serverSrc.indexOf('\nfunction ', fnStart + 1); - const fnBody = serverSrc.slice(fnStart, fnEnd); - // Should not push --resume to args - expect(fnBody).not.toContain("'--resume'"); - expect(fnBody).not.toContain('"--resume"'); - }); - - test('system prompt includes inspect and style commands', () => { - const promptSection = serverSrc.slice( - serverSrc.indexOf('const systemPrompt = ['), - serverSrc.indexOf("].join('\\n');", serverSrc.indexOf('const systemPrompt = [')) + 15, - ); - expect(promptSection).toContain('inspect'); - expect(promptSection).toContain('style'); - expect(promptSection).toContain('cleanup'); - }); -}); - -// ─── /sidebar-chat response includes agentStatus ───────────────── - -describe('/sidebar-chat agentStatus', () => { - const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); - - test('sidebar-chat response includes agentStatus field', () => { - // Find the GET /sidebar-chat handler — look for the data response, not the auth error - const handlerStart = serverSrc.indexOf("url.pathname === '/sidebar-chat'"); - // Find the response that returns entries + total (skip the auth error response) - const entriesResponse = serverSrc.indexOf('{ entries, total', handlerStart); - expect(entriesResponse).toBeGreaterThan(handlerStart); - const responseLine = serverSrc.slice(entriesResponse, entriesResponse + 100); - expect(responseLine).toContain('agentStatus'); - }); -}); - -// ─── Sidebar HTML tests ────────────────────────────────────────── - -describe('sidebar HTML (sidepanel.html)', () => { - const html = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.html'), 'utf-8'); - - test('banner says "Browser co-pilot" not "Standalone mode"', () => { - expect(html).toContain('Browser co-pilot'); - expect(html).not.toContain('Standalone mode'); - }); - - test('input placeholder says "Ask about this page"', () => { - expect(html).toContain('Ask about this page'); - expect(html).not.toContain('Message Claude Code'); - }); - - test('stop button exists with id stop-agent-btn', () => { - expect(html).toContain('id="stop-agent-btn"'); - expect(html).toContain('class="stop-btn"'); - }); - - test('stop button is hidden by default', () => { - // The stop button should have style="display: none;" initially - const stopBtnMatch = html.match(/id="stop-agent-btn"[^>]*/); - expect(stopBtnMatch).not.toBeNull(); - expect(stopBtnMatch![0]).toContain('display: none'); - }); -}); - -// ─── Sidebar JS tests ─────────────────────────────────────────── - -describe('sidebar JS (sidepanel.js)', () => { - const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8'); - - test('stopAgent function exists', () => { - expect(js).toContain('async function stopAgent()'); - }); - - test('stopAgent calls /sidebar-agent/stop endpoint', () => { - expect(js).toContain('/sidebar-agent/stop'); - }); - - test('stop button click handler is wired up', () => { - expect(js).toContain("getElementById('stop-agent-btn')"); - expect(js).toContain('stopAgent'); - }); - - test('updateStopButton function exists', () => { - expect(js).toContain('function updateStopButton('); - }); - - test('agent_start shows stop button', () => { - // Find the agent_start handler and verify it calls updateStopButton(true) - const startHandler = js.slice( - js.indexOf("entry.type === 'agent_start'"), - js.indexOf("entry.type === 'agent_done'"), - ); - expect(startHandler).toContain('updateStopButton(true)'); - }); - - test('agent_done hides stop button', () => { - const doneHandler = js.slice( - js.indexOf("entry.type === 'agent_done'"), - js.indexOf("entry.type === 'agent_error'"), - ); - expect(doneHandler).toContain('updateStopButton(false)'); - }); - - test('agent_error hides stop button', () => { - const errorIdx = js.indexOf("entry.type === 'agent_error'"); - const errorHandler = js.slice(errorIdx, errorIdx + 500); - expect(errorHandler).toContain('updateStopButton(false)'); - }); - - test('orphaned thinking cleanup checks agentStatus from server', () => { - // After polling, if agentStatus !== processing, thinking dots are removed - expect(js).toContain("data.agentStatus !== 'processing'"); - }); - - test('orphaned thinking cleanup removes thinking dots silently', () => { - // Thinking dots are removed when agent is idle — no "(session ended)" - // notice, which was removed as noisy false-positive UX - expect(js).toContain('thinking.remove()'); - }); - - test('sendMessage renders user bubble + thinking dots optimistically', () => { - // sendMessage should create user bubble and agent-thinking BEFORE the server responds - const sendFn = js.slice(js.indexOf('async function sendMessage()'), js.indexOf('async function sendMessage()') + 2000); - expect(sendFn).toContain('chat-bubble user'); - expect(sendFn).toContain('agent-thinking'); - expect(sendFn).toContain('lastOptimisticMsg'); - }); - - test('fast polling during agent execution (300ms), slow when idle (1000ms)', () => { - expect(js).toContain('FAST_POLL_MS'); - expect(js).toContain('SLOW_POLL_MS'); - expect(js).toContain('startFastPoll'); - expect(js).toContain('stopFastPoll'); - // Fast = 300ms - expect(js).toContain('300'); - // Slow = 1000ms - expect(js).toContain('1000'); - }); - - test('agent_done calls stopFastPoll', () => { - const doneHandler = js.slice( - js.indexOf("entry.type === 'agent_done'"), - js.indexOf("entry.type === 'agent_error'"), - ); - expect(doneHandler).toContain('stopFastPoll'); - }); - - test('duplicate user bubble prevention via lastOptimisticMsg', () => { - expect(js).toContain('lastOptimisticMsg'); - // When polled message matches optimistic, skip rendering - expect(js).toContain('lastOptimisticMsg === entry.message'); - }); -}); - -// ─── Sidebar agent queue poll (sidebar-agent.ts) ───────────────── - -describe('sidebar agent queue poll (sidebar-agent.ts)', () => { - const agentSrc = fs.readFileSync(path.join(ROOT, 'src', 'sidebar-agent.ts'), 'utf-8'); - - test('queue poll interval is 200ms or less for fast TTFO', () => { - const match = agentSrc.match(/const POLL_MS\s*=\s*(\d+)/); - expect(match).not.toBeNull(); - const pollMs = parseInt(match![1], 10); - expect(pollMs).toBeLessThanOrEqual(200); - }); -}); - -// ─── System prompt size (TTFO optimization) ────────────────────── - -describe('system prompt size', () => { - const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); - - test('system prompt is compact (under 30 lines)', () => { - const start = serverSrc.indexOf('const systemPrompt = ['); - const end = serverSrc.indexOf("].join('\\n');", start); - const promptBlock = serverSrc.slice(start, end); - const lines = promptBlock.split('\n').length; - // Compact prompt = fewer input tokens = faster first response - // Higher limit accommodates security lines (prompt injection defense, allowed commands) - expect(lines).toBeLessThan(30); - }); - - test('system prompt does not contain verbose narration examples', () => { - // We trimmed examples to reduce token count. The agent gets the - // instruction to narrate, not 6 examples of how. - const start = serverSrc.indexOf('const systemPrompt = ['); - const end = serverSrc.indexOf("].join('\\n');", start); - const promptBlock = serverSrc.slice(start, end); - expect(promptBlock).not.toContain('Examples of good narration'); - expect(promptBlock).not.toContain('I can see a login form'); - }); -}); - -// ─── TTFO latency chain invariants ────────────────────────────── - -describe('TTFO latency chain', () => { - const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8'); - const agentSrc = fs.readFileSync(path.join(ROOT, 'src', 'sidebar-agent.ts'), 'utf-8'); - - test('optimistic render happens BEFORE chrome.runtime.sendMessage', () => { - // In sendMessage(), the bubble + thinking dots must be created - // before the async POST to the server - const sendFn = js.slice( - js.indexOf('async function sendMessage()'), - js.indexOf('async function sendMessage()') + 3000, - ); - const optimisticIdx = sendFn.indexOf('agent-thinking'); - const sendIdx = sendFn.indexOf('chrome.runtime.sendMessage'); - expect(optimisticIdx).toBeGreaterThan(0); - expect(sendIdx).toBeGreaterThan(0); - expect(optimisticIdx).toBeLessThan(sendIdx); - }); - - test('sendMessage calls startFastPoll before server request', () => { - const sendFn = js.slice( - js.indexOf('async function sendMessage()'), - js.indexOf('async function sendMessage()') + 3000, - ); - const fastPollIdx = sendFn.indexOf('startFastPoll'); - const sendIdx = sendFn.indexOf('chrome.runtime.sendMessage'); - expect(fastPollIdx).toBeGreaterThan(0); - expect(fastPollIdx).toBeLessThan(sendIdx); - }); - - test('agent_start from server does not duplicate thinking dots', () => { - // When we already showed dots optimistically, agent_start from - // the poll should skip creating a second set - const startHandler = js.slice( - js.indexOf("entry.type === 'agent_start'"), - js.indexOf("entry.type === 'agent_done'"), - ); - expect(startHandler).toContain('agent-thinking'); - // Should check if thinking already exists and skip - expect(startHandler).toContain("getElementById('agent-thinking')"); - }); - - test('FAST_POLL_MS is strictly less than SLOW_POLL_MS', () => { - const fastMatch = js.match(/FAST_POLL_MS\s*=\s*(\d+)/); - const slowMatch = js.match(/SLOW_POLL_MS\s*=\s*(\d+)/); - expect(fastMatch).not.toBeNull(); - expect(slowMatch).not.toBeNull(); - expect(parseInt(fastMatch![1], 10)).toBeLessThan(parseInt(slowMatch![1], 10)); - }); - - test('stopAgent also calls stopFastPoll', () => { - const stopFn = js.slice( - js.indexOf('async function stopAgent()'), - js.indexOf('async function stopAgent()') + 1000, - ); - expect(stopFn).toContain('stopFastPoll'); - }); -}); - -// ─── Browser tab bar ──────────────────────────────────────────── - -describe('browser tab bar (server.ts)', () => { - const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); - - test('/sidebar-tabs endpoint exists', () => { - expect(serverSrc).toContain("/sidebar-tabs'"); - expect(serverSrc).toContain('getTabListWithTitles'); - }); - - test('/sidebar-tabs/switch endpoint exists', () => { - expect(serverSrc).toContain("/sidebar-tabs/switch'"); - expect(serverSrc).toContain('switchTab'); - }); - - test('/sidebar-tabs requires auth', () => { - // Find the handler and verify auth check - const handlerIdx = serverSrc.indexOf("/sidebar-tabs'"); - const handlerBlock = serverSrc.slice(handlerIdx, handlerIdx + 300); - expect(handlerBlock).toContain('validateAuth'); - }); -}); - -describe('browser tab bar (sidepanel.js)', () => { - const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8'); - - test('pollTabs function exists and calls /sidebar-tabs', () => { - expect(js).toContain('async function pollTabs()'); - expect(js).toContain('/sidebar-tabs'); - }); - - test('renderTabBar function exists', () => { - expect(js).toContain('function renderTabBar(tabs)'); - }); - - test('tab bar hidden when only 1 tab', () => { - const renderFn = js.slice( - js.indexOf('function renderTabBar('), - js.indexOf('function renderTabBar(') + 600, - ); - expect(renderFn).toContain('tabs.length <= 1'); - expect(renderFn).toContain("display = 'none'"); - }); - - test('switchBrowserTab calls /sidebar-tabs/switch', () => { - expect(js).toContain('async function switchBrowserTab('); - expect(js).toContain('/sidebar-tabs/switch'); - }); - - test('tab polling interval is set on connection', () => { - expect(js).toContain('tabPollInterval'); - expect(js).toContain('setInterval(pollTabs'); - }); - - test('tab polling cleaned up on disconnect', () => { - expect(js).toContain('clearInterval(tabPollInterval)'); - }); - - test('only re-renders when tabs change (diff check)', () => { - expect(js).toContain('lastTabJson'); - expect(js).toContain('json === lastTabJson'); - }); -}); - describe('browser tab bar (sidepanel.html)', () => { const html = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.html'), 'utf-8'); @@ -397,8 +55,6 @@ describe('sidebar→browser tab switch', () => { describe('browser→sidebar tab sync', () => { const bmSrc = fs.readFileSync(path.join(ROOT, 'src', 'browser-manager.ts'), 'utf-8'); - const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); - const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8'); test('syncActiveTabByUrl method exists on BrowserManager', () => { expect(bmSrc).toContain('syncActiveTabByUrl(activeUrl: string)'); @@ -438,46 +94,16 @@ describe('browser→sidebar tab sync', () => { expect(fn).toContain('this.pages.size <= 1'); }); - test('/sidebar-tabs reads activeUrl param and calls syncActiveTabByUrl', () => { - const handler = serverSrc.slice( - serverSrc.indexOf("/sidebar-tabs'"), - serverSrc.indexOf("/sidebar-tabs'") + 700, - ); - expect(handler).toContain("get('activeUrl')"); - expect(handler).toContain('syncActiveTabByUrl'); - }); - - test('/sidebar-command syncs activeTabUrl BEFORE reading tabId', () => { - // The server must call syncActiveTabByUrl before getActiveTabId - // so the agent targets the correct tab - const cmdIdx = serverSrc.indexOf("url.pathname === '/sidebar-command'"); - const handler = serverSrc.slice(cmdIdx, cmdIdx + 1200); - const syncIdx = handler.indexOf('syncActiveTabByUrl'); - const getIdIdx = handler.indexOf('getActiveTabId'); - expect(syncIdx).toBeGreaterThan(0); - expect(getIdIdx).toBeGreaterThan(syncIdx); // sync happens BEFORE reading ID - }); + // NOTE: the /sidebar-tabs + /sidebar-command server consumers of + // syncActiveTabByUrl and the sidepanel chat-tab handlers were removed + // with the chat-queue rip (PR #1216). The BrowserManager primitives above + // survive (tab tracking feeds active-tab.json for the PTY claude). test('background.js listens for chrome.tabs.onActivated', () => { const bgSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8'); expect(bgSrc).toContain('chrome.tabs.onActivated.addListener'); expect(bgSrc).toContain('browserTabActivated'); }); - - test('sidepanel handles browserTabActivated message instantly', () => { - expect(js).toContain("msg.type === 'browserTabActivated'"); - // Should call switchChatTab for instant context swap - expect(js).toContain('switchChatTab'); - }); - - test('pollTabs sends Chrome active tab URL to server', () => { - const pollFn = js.slice( - js.indexOf('async function pollTabs()'), - js.indexOf('async function pollTabs()') + 800, - ); - expect(pollFn).toContain('chrome.tabs.query'); - expect(pollFn).toContain('activeUrl='); - }); }); describe('browser tab bar (sidepanel.css)', () => { @@ -507,138 +133,6 @@ describe('browser tab bar (sidepanel.css)', () => { }); }); -// ─── Event relay (processAgentEvent) ──────────────────────────── - -describe('processAgentEvent handles sidebar-agent event types', () => { - const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); - - // Extract processAgentEvent function body - const fnStart = serverSrc.indexOf('function processAgentEvent('); - const fnEnd = serverSrc.indexOf('\nfunction ', fnStart + 1); - const fnBody = serverSrc.slice(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 2000); - - test('handles tool_use events directly (not raw Claude stream format)', () => { - // Must handle { type: 'tool_use', tool, input } from sidebar-agent - expect(fnBody).toContain("event.type === 'tool_use'"); - expect(fnBody).toContain('event.tool'); - expect(fnBody).toContain('event.input'); - }); - - test('handles text_delta events directly', () => { - expect(fnBody).toContain("event.type === 'text_delta'"); - expect(fnBody).toContain('event.text'); - }); - - test('handles text events directly', () => { - expect(fnBody).toContain("event.type === 'text'"); - }); - - test('handles result events', () => { - expect(fnBody).toContain("event.type === 'result'"); - }); - - test('handles agent_error events', () => { - expect(fnBody).toContain("event.type === 'agent_error'"); - expect(fnBody).toContain('event.error'); - }); - - test('does NOT re-parse raw Claude stream events (no content_block_start)', () => { - // sidebar-agent.ts already transforms these. Server should not duplicate. - expect(fnBody).not.toContain('content_block_start'); - expect(fnBody).not.toContain('content_block_delta'); - expect(fnBody).not.toContain("event.type === 'assistant'"); - }); - - test('all event types call addChatEntry with role: agent', () => { - // Every addChatEntry in processAgentEvent should have role: 'agent' - const addCalls = fnBody.match(/addChatEntry\(\{[^}]+\}\)/g) || []; - for (const call of addCalls) { - expect(call).toContain("role: 'agent'"); - } - }); -}); - -// ─── Per-tab chat context ──────────────────────────────────────── - -describe('per-tab chat context (server.ts)', () => { - const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); - - test('/sidebar-chat accepts tabId query param', () => { - const handler = serverSrc.slice( - serverSrc.indexOf("/sidebar-chat'"), - serverSrc.indexOf("/sidebar-chat'") + 600, - ); - expect(handler).toContain('tabId'); - }); - - test('addChatEntry takes a tabId parameter', () => { - // addChatEntry should route entries to the correct tab's buffer - expect(serverSrc).toContain('tabId'); - // Look for tabId in addChatEntry function - const fnIdx = serverSrc.indexOf('function addChatEntry('); - if (fnIdx > -1) { - const fnBody = serverSrc.slice(fnIdx, fnIdx + 300); - expect(fnBody).toContain('tabId'); - } - }); - - test('spawnClaude passes active tab ID to queue entry', () => { - const spawnFn = serverSrc.slice( - serverSrc.indexOf('function spawnClaude('), - serverSrc.indexOf('\nfunction ', serverSrc.indexOf('function spawnClaude(') + 1), - ); - expect(spawnFn).toContain('tabId'); - }); - - test('tab isolation uses BROWSE_TAB env var instead of system prompt hack', () => { - const agentSrc = fs.readFileSync(path.join(ROOT, 'src', 'sidebar-agent.ts'), 'utf-8'); - // Agent passes BROWSE_TAB env var to claude (not a system prompt instruction) - expect(agentSrc).toContain('BROWSE_TAB'); - // Server handleCommand reads tabId from body and pins to that tab - expect(serverSrc).toContain('savedTabId'); - expect(serverSrc).toContain('switchTab(tabId)'); - }); -}); - -describe('per-tab chat context (sidepanel.js)', () => { - const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8'); - - test('tracks activeTabId for chat context', () => { - expect(js).toContain('activeTabId'); - }); - - test('pollChat sends tabId to server', () => { - const pollFn = js.slice( - js.indexOf('async function pollChat()'), - js.indexOf('async function pollChat()') + 600, - ); - expect(pollFn).toContain('tabId'); - }); - - test('switching tabs swaps displayed chat', () => { - // When tab changes, old chat is saved and new tab's chat is shown - expect(js).toContain('switchChatTab'); - }); - - test('switchChatTab saves current tab DOM and restores new tab', () => { - const fn = js.slice( - js.indexOf('function switchChatTab('), - js.indexOf('function switchChatTab(') + 800, - ); - expect(fn).toContain('chatDomByTab'); - expect(fn).toContain('createDocumentFragment'); - }); - - test('sendMessage includes tabId in message', () => { - const sendFn = js.slice( - js.indexOf('async function sendMessage()'), - js.indexOf('async function sendMessage()') + 2000, - ); - expect(sendFn).toContain('tabId'); - expect(sendFn).toContain('sidebarActiveTabId'); - }); -}); - // ─── Sidebar CSS tests ────────────────────────────────────────── describe('sidebar CSS (sidepanel.css)', () => { @@ -712,10 +206,14 @@ describe('CSP fallback basic picker', () => { expect(contentSrc).toContain('getBoundingClientRect()'); }); - test('content.js contains CSSOM iteration with cross-origin try/catch', () => { + test('content.js contains CSSOM iteration guarded against cross-origin sheets', () => { expect(contentSrc).toContain('document.styleSheets'); expect(contentSrc).toContain('cssRules'); - expect(contentSrc).toContain('cross-origin'); + // Cross-origin stylesheets throw DOMException on cssRules access. The + // iteration must swallow exactly that (typed catch, not a bare catch {} + // — see the slop-scan philosophy in CLAUDE.md). + expect(contentSrc).toContain('(same-origin only)'); + expect(contentSrc).toMatch(/catch \(e\) \{ if \(!\(e instanceof DOMException\)\) throw e; \}/); }); test('content.js saves and restores outline on elements', () => { @@ -772,31 +270,28 @@ describe('cleanup and screenshot buttons', () => { expect(html).toContain('quick-actions'); }); - test('cleanup button sends smart prompt to sidebar agent (not just deterministic selectors)', () => { - // Should use /sidebar-command endpoint (agent-based) not just /command (deterministic) + test('cleanup button injects smart prompt into the live PTY (not just deterministic selectors)', () => { + // Cleanup pipes a prompt into the running claude PTY via + // gstackInjectToTerminal (the chat-queue POST to /sidebar-command was + // ripped in PR #1216 — the live REPL is the only execution surface). const cleanupFn = js.slice( js.indexOf('async function runCleanup('), js.indexOf('async function runScreenshot('), ); - expect(cleanupFn).toContain('sidebar-command'); + expect(cleanupFn).toContain('gstackInjectToTerminal'); expect(cleanupFn).toContain('cleanupPrompt'); // Should include both deterministic first pass AND agent snapshot analysis expect(cleanupFn).toContain('cleanup --all'); expect(cleanupFn).toContain('snapshot -i'); - // Should instruct agent to KEEP site branding - expect(cleanupFn).toContain('KEEP'); - expect(cleanupFn).toContain('header/masthead/logo'); + // Should instruct claude to keep site branding + expect(cleanupFn).toContain('Keep the site'); + expect(cleanupFn).toContain('header/masthead'); }); test('sidepanel.js screenshot handler POSTs to /command with screenshot', () => { expect(js).toContain("command: 'screenshot'"); }); - test('sidepanel.js has notification rendering for type notification', () => { - expect(js).toContain("entry.type === 'notification'"); - expect(js).toContain('chat-notification'); - }); - test('sidepanel.css contains inspector-action-btn styles', () => { expect(css).toContain('.inspector-action-btn'); expect(css).toContain('.inspector-action-btn.loading'); @@ -941,69 +436,12 @@ describe('chat toolbar buttons disabled state', () => { }); }); -// ─── Chat message dedup ───────────────────────────────────────── - -describe('chat message dedup (prevents repeat rendering)', () => { - const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8'); - - test('renderedEntryIds Set exists for dedup tracking', () => { - expect(js).toContain('const renderedEntryIds = new Set()'); - }); - - test('addChatEntry checks entry.id against renderedEntryIds', () => { - const addFn = js.slice( - js.indexOf('function addChatEntry(entry)'), - js.indexOf('\n // User messages', js.indexOf('function addChatEntry(entry)')), - ); - expect(addFn).toContain('renderedEntryIds.has(entry.id)'); - expect(addFn).toContain('renderedEntryIds.add(entry.id)'); - // Should return early (skip) if already rendered - expect(addFn).toContain('return'); - }); - - test('addChatEntry skips dedup for entries without id (local notifications)', () => { - const addFn = js.slice( - js.indexOf('function addChatEntry(entry)'), - js.indexOf('\n // User messages', js.indexOf('function addChatEntry(entry)')), - ); - // Should only check dedup when entry.id is defined - expect(addFn).toContain('entry.id !== undefined'); - }); +// ─── Focus stealing prevention ────────────────────────────────── - test('clear chat resets renderedEntryIds', () => { - expect(js).toContain('renderedEntryIds.clear()'); - }); -}); - -// ─── Agent conciseness and focus stealing ─────────────────────── - -describe('sidebar agent conciseness + no focus stealing', () => { +describe('tab switching does not steal focus', () => { const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); const bmSrc = fs.readFileSync(path.join(ROOT, 'src', 'browser-manager.ts'), 'utf-8'); - test('system prompt tells agent to STOP when task is done', () => { - const promptSection = serverSrc.slice( - serverSrc.indexOf('const systemPrompt = ['), - serverSrc.indexOf("].join('\\n');", serverSrc.indexOf('const systemPrompt = [')), - ); - expect(promptSection).toContain('STOP'); - expect(promptSection).toContain('CONCISE'); - expect(promptSection).toContain('Do NOT keep exploring'); - }); - - test('sidebar agent auto-routes model based on message type', () => { - // Model router exists and defaults to opus for analysis tasks - expect(serverSrc).toContain('function pickSidebarModel('); - expect(serverSrc).toContain("return 'opus'"); - expect(serverSrc).toContain("return 'sonnet'"); - // spawnClaude uses the router, not a hardcoded model - const spawnFn = serverSrc.slice( - serverSrc.indexOf('function spawnClaude('), - serverSrc.indexOf('\nfunction ', serverSrc.indexOf('function spawnClaude(') + 1), - ); - expect(spawnFn).toContain('pickSidebarModel(userMessage)'); - }); - test('switchTab has bringToFront option', () => { expect(bmSrc).toContain('bringToFront?: boolean'); expect(bmSrc).toContain('bringToFront !== false'); @@ -1028,14 +466,14 @@ describe('LLM-based cleanup (smart agent cleanup)', () => { const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8'); const wcSrc = fs.readFileSync(path.join(ROOT, 'src', 'write-commands.ts'), 'utf-8'); - test('cleanup button uses /sidebar-command not /command', () => { + test('cleanup button does not bypass the agent with a direct /command POST', () => { const cleanupFn = js.slice( js.indexOf('async function runCleanup('), js.indexOf('async function runScreenshot('), ); - // Should POST to sidebar-command (agent) not /command (deterministic) - expect(cleanupFn).toContain('/sidebar-command'); - // Should NOT directly call the cleanup command endpoint + // The smart cleanup goes through the claude PTY, never a raw + // deterministic /command fetch. (The PTY-injection wiring itself is + // pinned in sidebar-tabs.test.ts.) expect(cleanupFn).not.toMatch(/fetch.*\/command['"]/); }); @@ -1056,7 +494,7 @@ describe('LLM-based cleanup (smart agent cleanup)', () => { // Agent should take a snapshot to see what deterministic pass missed expect(cleanupFn).toContain('snapshot -i'); // Agent should analyze what remains - expect(cleanupFn).toContain('identify remaining non-content'); + expect(cleanupFn).toContain('identify any remaining'); }); test('cleanup prompt lists specific clutter categories for agent', () => { @@ -1065,13 +503,13 @@ describe('LLM-based cleanup (smart agent cleanup)', () => { js.indexOf('async function runScreenshot('), ); // Should guide the agent on what to look for - expect(cleanupFn).toContain('Ad placeholder'); - expect(cleanupFn).toContain('ADVERTISEMENT'); - expect(cleanupFn).toContain('Cookie'); - expect(cleanupFn).toContain('Audio/podcast'); - expect(cleanupFn).toContain('Sidebar widget'); - expect(cleanupFn).toContain('Social share'); - expect(cleanupFn).toContain('Floating chat'); + expect(cleanupFn).toContain('cookie/consent banners'); + expect(cleanupFn).toContain('newsletter popups'); + expect(cleanupFn).toContain('login walls'); + expect(cleanupFn).toContain('video autoplay'); + expect(cleanupFn).toContain('sidebar'); + expect(cleanupFn).toContain('share'); + expect(cleanupFn).toContain('floating chat'); }); test('cleanup prompt instructs agent to preserve site identity', () => { @@ -1080,11 +518,11 @@ describe('LLM-based cleanup (smart agent cleanup)', () => { js.indexOf('async function runScreenshot('), ); // Must keep the site looking like itself - expect(cleanupFn).toContain('KEEP'); - expect(cleanupFn).toContain('header/masthead/logo'); - expect(cleanupFn).toContain('article headline'); + expect(cleanupFn).toContain('Keep the site'); + expect(cleanupFn).toContain('header/masthead'); + expect(cleanupFn).toContain('headline'); expect(cleanupFn).toContain('article body'); - expect(cleanupFn).toContain('author byline'); + expect(cleanupFn).toContain('byline'); }); test('cleanup prompt instructs agent to unlock scrolling', () => { @@ -1093,7 +531,7 @@ describe('LLM-based cleanup (smart agent cleanup)', () => { js.indexOf('async function runScreenshot('), ); expect(cleanupFn).toContain('unlock scrolling'); - expect(cleanupFn).toContain('overflow'); + expect(cleanupFn).toContain('scroll-locked'); }); test('cleanup prompt instructs agent to use $B eval for removal', () => { @@ -1103,15 +541,7 @@ describe('LLM-based cleanup (smart agent cleanup)', () => { ); // Agent should use $B eval to hide elements via JavaScript expect(cleanupFn).toContain('$B eval'); - expect(cleanupFn).toContain("display="); - }); - - test('cleanup shows notification while agent works', () => { - const cleanupFn = js.slice( - js.indexOf('async function runCleanup('), - js.indexOf('async function runScreenshot('), - ); - expect(cleanupFn).toContain('agent is analyzing'); + expect(cleanupFn).toContain('hide each'); }); test('cleanup removes loading state after short delay (agent is async)', () => { @@ -1343,12 +773,17 @@ describe('sidebar arrow hint hide flow (4-step signal chain)', () => { // Step 1: sidepanel sends sidebarOpened when connected test('step 1: sidepanel sends sidebarOpened message on connect', () => { expect(spSrc).toContain("{ type: 'sidebarOpened' }"); - // Should be in updateConnection, after setConnState('connected') + // Should be in updateConnection, after setConnState('connected'). + // Window is generous: updateConnection also exposes the PTY bootstrap + // globals (gstackServerPort/gstackAuthToken) before the connected branch. const connectFn = spSrc.slice( spSrc.indexOf('function updateConnection('), - spSrc.indexOf('function updateConnection(') + 800, + spSrc.indexOf('function updateConnection(') + 2500, ); - expect(connectFn).toContain('sidebarOpened'); + const connectedIdx = connectFn.indexOf("setConnState('connected')"); + const openedIdx = connectFn.indexOf('sidebarOpened'); + expect(connectedIdx).toBeGreaterThan(0); + expect(openedIdx).toBeGreaterThan(connectedIdx); }); // Step 2: background.js accepts and relays sidebarOpened @@ -1465,13 +900,16 @@ describe('sidebar debug visibility when stuck', () => { describe('BROWSE_NO_AUTOSTART (sidebar headless prevention)', () => { const cliSrc = fs.readFileSync(path.join(ROOT, 'src', 'cli.ts'), 'utf-8'); - const agentSrc = fs.readFileSync(path.join(ROOT, 'src', 'sidebar-agent.ts'), 'utf-8'); + const termAgentSrc = fs.readFileSync(path.join(ROOT, 'src', 'terminal-agent.ts'), 'utf-8'); test('cli.ts checks BROWSE_NO_AUTOSTART before starting a new server', () => { - // ensureServer must check this env var BEFORE calling startServer() + // ensureServer must check this env var BEFORE spawning a server. + // (Anchor on the open paren — both functions grew parameters.) + const ensureStart = cliSrc.indexOf('async function ensureServer('); + const ensureEnd = cliSrc.indexOf('\nasync function ', ensureStart + 1); const ensureServerFn = cliSrc.slice( - cliSrc.indexOf('async function ensureServer()'), - cliSrc.indexOf('async function startServer()'), + ensureStart, + ensureEnd > ensureStart ? ensureEnd : undefined, ); expect(ensureServerFn).toContain('BROWSE_NO_AUTOSTART'); expect(ensureServerFn).toContain('process.exit(1)'); @@ -1482,18 +920,21 @@ describe('BROWSE_NO_AUTOSTART (sidebar headless prevention)', () => { expect(cliSrc).toContain('BROWSE_NO_AUTOSTART is set'); }); - test('sidebar-agent.ts sets BROWSE_NO_AUTOSTART=1', () => { - expect(agentSrc).toContain("BROWSE_NO_AUTOSTART: '1'"); + test('terminal-agent.ts sets BROWSE_NO_AUTOSTART=1 for the claude PTY', () => { + // The PTY claude must reuse THIS headed server, never race to spawn + // its own. (sidebar-agent.ts, the original setter, was ripped in + // PR #1216 — the PTY agent inherited the same env contract.) + expect(termAgentSrc).toContain("BROWSE_NO_AUTOSTART: '1'"); }); - test('sidebar-agent.ts sets BROWSE_PORT for headed server reuse', () => { - expect(agentSrc).toContain('BROWSE_PORT'); + test('terminal-agent.ts sets BROWSE_PORT for headed server reuse', () => { + expect(termAgentSrc).toContain('BROWSE_PORT'); }); test('BROWSE_NO_AUTOSTART check happens before lock acquisition', () => { // The guard must be BEFORE the lock acquisition. If it's after, // we'd acquire a lock and then exit, leaving a stale lock file. - const ensureServerStart = cliSrc.indexOf('async function ensureServer()'); + const ensureServerStart = cliSrc.indexOf('async function ensureServer('); const noAutoStart = cliSrc.indexOf('BROWSE_NO_AUTOSTART', ensureServerStart); const lockAcquisition = cliSrc.indexOf('Acquire lock', ensureServerStart); expect(noAutoStart).toBeGreaterThan(0); @@ -1502,92 +943,6 @@ describe('BROWSE_NO_AUTOSTART (sidebar headless prevention)', () => { }); }); -// ─── Tool-result file filtering (sidebar-agent.ts) ────────────── - -describe('sidebar-agent hides internal tool-result reads', () => { - const agentSrc = fs.readFileSync(path.join(ROOT, 'src', 'sidebar-agent.ts'), 'utf-8'); - - test('describeToolCall returns empty for tool-results paths', () => { - expect(agentSrc).toContain("input.file_path.includes('/tool-results/')"); - }); - - test('describeToolCall returns empty for .claude/projects paths', () => { - expect(agentSrc).toContain("input.file_path.includes('/.claude/projects/')"); - }); - - test('empty description causes early return (no event sent)', () => { - // describeToolCall returns '' for internal reads, which means - // summarizeToolInput returns '', which means event.input is '' - const readHandler = agentSrc.slice( - agentSrc.indexOf("if (tool === 'Read'"), - agentSrc.indexOf("if (tool === 'Edit'"), - ); - expect(readHandler).toContain("return ''"); - }); -}); - -// ─── Sidebar skips empty tool_use entries (sidepanel.js) ──────── - -describe('sidebar skips empty tool_use descriptions', () => { - const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8'); - - test('tool_use with no input returns early', () => { - const toolUseHandler = js.slice( - js.indexOf("entry.type === 'tool_use'"), - js.indexOf("entry.type === 'tool_use'") + 400, - ); - expect(toolUseHandler).toContain("if (!toolInput) return"); - }); -}); - -// ─── Tool calls collapse into "See reasoning" on agent_done ───── - -describe('tool calls collapse into reasoning disclosure', () => { - const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8'); - const css = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.css'), 'utf-8'); - - test('agent_done wraps tool calls in
element', () => { - const doneHandler = js.slice( - js.indexOf("entry.type === 'agent_done'"), - js.indexOf("entry.type === 'agent_done'") + 1200, - ); - expect(doneHandler).toContain("createElement('details')"); - expect(doneHandler).toContain('agent-reasoning'); - }); - - test('disclosure summary shows step count', () => { - const doneHandler = js.slice( - js.indexOf("entry.type === 'agent_done'"), - js.indexOf("entry.type === 'agent_done'") + 1200, - ); - expect(doneHandler).toContain('See reasoning'); - expect(doneHandler).toContain('tools.length'); - }); - - test('disclosure inserts before text response', () => { - const doneHandler = js.slice( - js.indexOf("entry.type === 'agent_done'"), - js.indexOf("entry.type === 'agent_done'") + 1200, - ); - // Tool calls should appear before the text answer, not after - expect(doneHandler).toContain("querySelector('.agent-text')"); - expect(doneHandler).toContain('insertBefore(details, textEl)'); - }); - - test('CSS styles the reasoning disclosure', () => { - expect(css).toContain('.agent-reasoning'); - expect(css).toContain('.agent-reasoning summary'); - // Starts collapsed (no [open] by default) - expect(css).toContain('.agent-reasoning[open]'); - }); - - test('disclosure uses custom triangle markers', () => { - // No default list-style, custom ▶/▼ via ::before - expect(css).toContain('list-style: none'); - expect(css).toMatch(/agent-reasoning summary::before/); - }); -}); - // ─── Idle timeout disabled in headed mode (server.ts) ─────────── // // The original 'idle check skips in headed mode' string-grep test was deleted @@ -1596,31 +951,30 @@ describe('tool calls collapse into reasoning disclosure', () => { // Behavioral coverage lives in browse/test/server-factory.test.ts under the // 'idle timer + onDisconnect dual-instance fix' describe block, which // exercises the headed/headless/tunnel branches of idleCheckTick directly. +// The companion '/sidebar-command resets idle timer' test went with the +// chat-queue rip (PR #1216) — /command and /batch reset the timer and are +// covered by that factory suite. -describe('idle timeout behavior (server.ts)', () => { - const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); - - test('sidebar-command resets idle timer', () => { - const sidebarCmd = serverSrc.slice( - serverSrc.indexOf("url.pathname === '/sidebar-command'"), - serverSrc.indexOf("url.pathname === '/sidebar-command'") + 300, - ); - expect(sidebarCmd).toContain('resetIdleTimer'); - }); -}); - -// ─── Shutdown kills sidebar-agent daemon (server.ts) ──────────── +// ─── Shutdown kills the terminal-agent (server.ts) ────────────── describe('shutdown cleanup (server.ts)', () => { const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); - test('shutdown kills sidebar-agent daemon process', () => { + test('shutdown kills the terminal-agent via identity-based kill (no pkill)', () => { + // v1.44+ identity-based teardown: only the PID recorded by THIS + // daemon's agent is signaled. The pre-v1.44 `pkill -f terminal-agent` + // regex killed sibling gstack sessions on the same host (also pinned + // by browse/test/terminal-agent-pid-identity.test.ts). const shutdownFn = serverSrc.slice( - serverSrc.indexOf('async function shutdown()'), - serverSrc.indexOf('async function shutdown()') + 800, + serverSrc.indexOf('async function shutdown('), + serverSrc.indexOf('async function shutdown(') + 1200, ); - expect(shutdownFn).toContain('sidebar-agent'); - expect(shutdownFn).toContain('pkill'); + expect(shutdownFn).toContain('killAgentByRecord'); + expect(shutdownFn).toContain('readAgentRecord'); + // No pkill CALL — the word may appear in the explanatory comment, so + // match invocation shapes only. The repo-wide reintroduction tripwire + // is browse/test/terminal-agent-pid-identity.test.ts. + expect(shutdownFn).not.toMatch(/(?:spawnSync|execSync|\$)\(\s*['"`]pkill/); }); }); @@ -1641,29 +995,3 @@ describe('cookie import button (sidebar)', () => { }); }); -// ─── Model routing (server.ts) ────────────────────────────────── - -describe('sidebar model routing (server.ts)', () => { - const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); - - test('pickSidebarModel routes actions to sonnet', () => { - expect(serverSrc).toContain("return 'sonnet'"); - }); - - test('pickSidebarModel routes analysis to opus', () => { - expect(serverSrc).toContain("return 'opus'"); - }); - - test('analysis words override action verbs', () => { - // ANALYSIS_WORDS check comes before ACTION_PATTERNS - const routerFn = serverSrc.slice( - serverSrc.indexOf('function pickSidebarModel('), - serverSrc.indexOf('function pickSidebarModel(') + 600, - ); - const analysisCheck = routerFn.indexOf('ANALYSIS_WORDS'); - const actionCheck = routerFn.indexOf('ACTION_PATTERNS'); - expect(analysisCheck).toBeGreaterThan(0); - expect(actionCheck).toBeGreaterThan(0); - expect(analysisCheck).toBeLessThan(actionCheck); - }); -}); diff --git a/browse/test/snapshot.test.ts b/browse/test/snapshot.test.ts index 17b26c3d4e..d3c012eaff 100644 --- a/browse/test/snapshot.test.ts +++ b/browse/test/snapshot.test.ts @@ -31,9 +31,14 @@ beforeAll(async () => { await bm.launch(); }); -afterAll(() => { +afterAll(async () => { try { testServer.server.stop(); } catch {} - setTimeout(() => process.exit(0), 500); + // Close only this file's own browser — never process.exit(): bun test runs + // all files in one process, so a delayed exit kills the whole suite + // (see test/no-suicide-exit.test.ts). close() can hang when the browser + // already died, and its internal 5s timeout ties bun's 5s hook timeout — + // so race it at 3s and abandon; the child is reaped at process exit. + try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {} }); // ─── Snapshot Output ──────────────────────────────────────────── diff --git a/browse/test/terminal-agent-integration.test.ts b/browse/test/terminal-agent-integration.test.ts index cdcbe8de56..b3c8ac6046 100644 --- a/browse/test/terminal-agent-integration.test.ts +++ b/browse/test/terminal-agent-integration.test.ts @@ -22,6 +22,26 @@ import * as os from 'os'; const AGENT_SCRIPT = path.join(import.meta.dir, '../src/terminal-agent.ts'); const BASH = '/bin/bash'; +// Bun.spawn PTY support (the `terminal` spawn option) is required for the +// agent to stream PTY output. On older Bun (< 1.3) the option is silently +// ignored — proc.terminal is undefined, terminal.write() no-ops, and the +// round-trip can never produce output. Feature-detect instead of version- +// sniffing, and SKIP (not fake-pass) the output-dependent test on runtimes +// that genuinely lack the capability. All auth/handshake/control-frame +// tests still run everywhere — they don't need a live PTY. +const BUN_HAS_PTY = (() => { + try { + const probe = (Bun as any).spawn(['/bin/sh', '-c', 'exit 0'], { + terminal: { rows: 2, cols: 2, data() {} }, + }); + const has = !!probe.terminal; + try { probe.kill(); } catch {} + return has; + } catch { + return false; + } +})(); + let stateDir: string; let agentProc: any; let agentPort: number; @@ -128,7 +148,11 @@ describe('terminal-agent: /ws gates', () => { }); describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () => { - test('binary writes go to PTY stdin, output streams back', async () => { + // Skipped when the Bun runtime lacks PTY spawn support (see BUN_HAS_PTY). + // Explicit 15s timeout: the test legitimately waits up to 5s for the WS + // open plus up to 5s for PTY output, which overflows bun's 5s default and + // leaves stale assertions that get mis-attributed to the NEXT test. + test.skipIf(!BUN_HAS_PTY)('binary writes go to PTY stdin, output streams back', async () => { const cookie = 'rt-token-must-be-at-least-seventeen-chars-long'; const granted = await grantToken(cookie); expect(granted.status).toBe(200); @@ -180,7 +204,7 @@ describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () = try { ws.close(); } catch {} // Give cleanup a moment. await Bun.sleep(200); - }); + }, 15000); test('Sec-WebSocket-Protocol auth path: browser-style upgrade with token in protocol', async () => { // This is the path the actual browser extension takes. Cross-port @@ -199,32 +223,57 @@ describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () = const token = 'sec-protocol-token-must-be-at-least-seventeen-chars'; await grantToken(token); - // We exercise the protocol path by raw-handshaking via fetch+Upgrade, - // because Bun's test-client WebSocket constructor doesn't propagate + // We exercise the protocol path with a raw TCP handshake, because + // Bun's test-client WebSocket constructor doesn't propagate // `protocols` cleanly when also passed `headers` (the constructor - // detects the third-arg form unreliably). Real browsers (Chromium) - // use the standard protocols arg fine — the server-side handler is - // identical either way, so this test still locks the load-bearing - // invariant: the agent accepts a token via Sec-WebSocket-Protocol - // and echoes the protocol back so a browser would accept the upgrade. + // detects the third-arg form unreliably), and fetch()+Upgrade headers + // never resolves for a 101 response on Bun < 1.3 (it only completes + // for ordinary statuses, so the success path hangs). Real browsers + // (Chromium) use the standard protocols arg fine — the server-side + // handler is identical either way, so this test still locks the + // load-bearing invariant: the agent accepts a token via + // Sec-WebSocket-Protocol and echoes the protocol back so a browser + // would accept the upgrade. const handshakeKey = 'dGhlIHNhbXBsZSBub25jZQ=='; - const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, { - headers: { - 'Connection': 'Upgrade', - 'Upgrade': 'websocket', - 'Sec-WebSocket-Version': '13', - 'Sec-WebSocket-Key': handshakeKey, - 'Sec-WebSocket-Protocol': `gstack-pty.${token}`, - 'Origin': 'chrome-extension://test-extension-id', - }, + const requestHead = [ + 'GET /ws HTTP/1.1', + `Host: 127.0.0.1:${agentPort}`, + 'Connection: Upgrade', + 'Upgrade: websocket', + 'Sec-WebSocket-Version: 13', + `Sec-WebSocket-Key: ${handshakeKey}`, + `Sec-WebSocket-Protocol: gstack-pty.${token}`, + 'Origin: chrome-extension://test-extension-id', + '', + '', + ].join('\r\n'); + + const net = await import('node:net'); + const responseHead = await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + const socket = net.connect(agentPort, '127.0.0.1', () => socket.write(requestHead)); + const finish = () => { + clearTimeout(timer); + socket.destroy(); + resolve(Buffer.concat(chunks).toString('utf8')); + }; + const timer = setTimeout(finish, 3000); + socket.on('data', (d: Buffer) => { + chunks.push(d); + // Response head complete once we see the blank line. + if (Buffer.concat(chunks).toString('utf8').includes('\r\n\r\n')) finish(); + }); + socket.on('error', (e: Error) => { clearTimeout(timer); reject(e); }); + socket.on('close', finish); }); // 101 Switching Protocols + protocol echoed back = browser would accept. // 401/403/anything else = browser would close the connection immediately // (the bug we hit in manual dogfood). - expect(resp.status).toBe(101); - expect(resp.headers.get('upgrade')?.toLowerCase()).toBe('websocket'); - expect(resp.headers.get('sec-websocket-protocol')).toBe(`gstack-pty.${token}`); + const statusLine = responseHead.split('\r\n')[0] || ''; + expect(statusLine).toContain('101'); + expect(responseHead.toLowerCase()).toContain('upgrade: websocket'); + expect(responseHead.toLowerCase()).toContain(`sec-websocket-protocol: gstack-pty.${token}`.toLowerCase()); }); test('Sec-WebSocket-Protocol auth: rejects unknown token even with valid Origin', async () => { diff --git a/browse/test/terminal-agent.test.ts b/browse/test/terminal-agent.test.ts index d908052d21..18064ce017 100644 --- a/browse/test/terminal-agent.test.ts +++ b/browse/test/terminal-agent.test.ts @@ -144,17 +144,33 @@ describe('Source-level guard: terminal-agent', () => { test('lazy spawn: claude PTY is spawned in message handler, not on upgrade', () => { // The whole point of lazy-spawn (codex finding #8) is that the WS - // upgrade itself does NOT call spawnClaude. Spawn happens on first - // message frame. + // upgrade itself does NOT spawn claude. Spawn happens on first + // message frame (binary input or the v1.44 explicit `start` frame), + // routed through the maybeSpawnPty helper, which is the only caller + // of spawnClaude. const upgradeBlock = AGENT_SRC.slice( AGENT_SRC.indexOf("if (url.pathname === '/ws')"), AGENT_SRC.indexOf("websocket: {"), ); expect(upgradeBlock).not.toContain('spawnClaude('); + expect(upgradeBlock).not.toContain('maybeSpawnPty('); // Spawn must be invoked from the message handler (lazy on first byte). const messageHandler = AGENT_SRC.slice(AGENT_SRC.indexOf('message(ws, raw)')); - expect(messageHandler).toContain('spawnClaude('); + expect(messageHandler).toContain('maybeSpawnPty('); expect(messageHandler).toContain('!session.spawned'); + // The open() upgrade handler must not spawn — it only creates the + // (spawned: false) session record or re-attaches a detached one. + const openBlock = AGENT_SRC.slice( + AGENT_SRC.indexOf('open(ws)'), + AGENT_SRC.indexOf('message(ws, raw)'), + ); + expect(openBlock).not.toContain('spawnClaude('); + expect(openBlock).not.toContain('maybeSpawnPty('); + // And the helper itself is where spawnClaude actually happens, gated + // on session.spawned so it stays a single-shot lazy spawn. + const helperBlock = AGENT_SRC.slice(AGENT_SRC.indexOf('function maybeSpawnPty')); + expect(helperBlock).toContain('spawnClaude('); + expect(helperBlock).toContain('if (session.spawned) return true;'); }); test('process.on uncaughtException + unhandledRejection handlers exist', () => { diff --git a/browse/test/watchdog.test.ts b/browse/test/watchdog.test.ts index 42faa262a1..c9b2d8ad68 100644 --- a/browse/test/watchdog.test.ts +++ b/browse/test/watchdog.test.ts @@ -46,13 +46,19 @@ afterEach(async () => { serverProc = null; }); -function spawnServer(env: Record, port: number): Subprocess { +// No fixed BROWSE_PORT: these tests never dial the server's HTTP port — +// they only watch stdout markers and process liveness. BROWSE_PORT=0 lets +// the server pick a random free port (its default), so concurrent runs of +// this file (sibling worktrees, pr-test-env checkouts) and leftover +// servers from interrupted runs can't collide on a hardcoded port and +// kill the fresh spawn with EADDRINUSE before the 2s liveness check. +function spawnServer(env: Record): Subprocess { const stateFile = path.join(tmpDir, 'browse-state.json'); return spawn(['bun', 'run', SERVER_SCRIPT], { env: { ...process.env, BROWSE_STATE_FILE: stateFile, - BROWSE_PORT: String(port), + BROWSE_PORT: '0', ...env, }, stdio: ['ignore', 'pipe', 'pipe'], @@ -97,7 +103,7 @@ async function readStdoutUntil( describe('parent-process watchdog (v0.18.1.0)', () => { test('BROWSE_PARENT_PID=0 disables the watchdog', async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-pid0-')); - serverProc = spawnServer({ BROWSE_PARENT_PID: '0' }, 34901); + serverProc = spawnServer({ BROWSE_PARENT_PID: '0' }); const out = await readStdoutUntil( serverProc, @@ -115,10 +121,7 @@ describe('parent-process watchdog (v0.18.1.0)', () => { // Pass a bogus parent PID to prove BROWSE_HEADED takes precedence. // If the server-side guard regresses, the watchdog would try to poll // this PID and eventually fire on the "dead parent." - serverProc = spawnServer( - { BROWSE_HEADED: '1', BROWSE_PARENT_PID: '999999' }, - 34902, - ); + serverProc = spawnServer({ BROWSE_HEADED: '1', BROWSE_PARENT_PID: '999999' }); const out = await readStdoutUntil( serverProc, @@ -137,7 +140,7 @@ describe('parent-process watchdog (v0.18.1.0)', () => { const parentPid = parentProc.pid!; // Default headless: no BROWSE_HEADED, real parent PID — watchdog active. - serverProc = spawnServer({ BROWSE_PARENT_PID: String(parentPid) }, 34903); + serverProc = spawnServer({ BROWSE_PARENT_PID: String(parentPid) }); const serverPid = serverProc.pid!; // Give the server a moment to start and register the watchdog interval. diff --git a/design/test/daemon.test.ts b/design/test/daemon.test.ts index 65c5d7a097..4e1518fcbd 100644 --- a/design/test/daemon.test.ts +++ b/design/test/daemon.test.ts @@ -361,16 +361,27 @@ describe("daemon /shutdown", () => { await fetchHandler( req("POST", `/boards/${board.id}/api/feedback`, { regenerated: false }), ); - // Now non-done count is 0 — handler should return shuttingDown:true. - // We DON'T let the real gracefulShutdown timer fire (it calls process.exit - // after 50ms which would tear down the test runner); instead we just - // observe the immediate response. - const r = await fetchHandler(req("POST", "/shutdown")); - expect(r.status).toBe(200); - const body = (await r.json()) as any; - expect(body.shuttingDown).toBe(true); - // Reset state for subsequent tests; the shutdown timer will be a no-op - // because the next resetForTest flips shuttingDown back to false. + // The handler arms setTimeout(gracefulShutdown, 50), and gracefulShutdown + // arms setTimeout(process.exit, 50). bun test runs ALL files in one + // process, so letting that exit fire would kill the whole suite ~100ms + // later (exit 0, no summary — see test/no-suicide-exit.test.ts). Stub + // process.exit, wait past both timers so they fire harmlessly while + // stubbed, then restore. (resetForTest does NOT defuse the timers: the + // exit callback is unconditional.) + const origExit = process.exit; + (process as any).exit = (() => undefined) as any; + try { + const r = await fetchHandler(req("POST", "/shutdown")); + expect(r.status).toBe(200); + const body = (await r.json()) as any; + expect(body.shuttingDown).toBe(true); + // Let both 50ms timers (gracefulShutdown, then its process.exit) fire + // against the stub before restoring the real process.exit. + await new Promise((resolve) => setTimeout(resolve, 200)); + } finally { + (process as any).exit = origExit; + } + // Reset state for subsequent tests (gracefulShutdown set shuttingDown). resetDaemon(); }); }); diff --git a/design/test/feedback-roundtrip.test.ts b/design/test/feedback-roundtrip.test.ts index e8d63db233..eb27d6dcdf 100644 --- a/design/test/feedback-roundtrip.test.ts +++ b/design/test/feedback-roundtrip.test.ts @@ -22,6 +22,16 @@ import * as fs from 'fs'; import * as path from 'path'; let bm: BrowserManager; + +// The command handlers take (command, args, session: TabSession, bm) — mirror +// the real call sites (browse/src/cli.ts, browse/test/commands.test.ts) by +// resolving the active TabSession from the manager on every call. Passing the +// manager itself where a session is expected breaks as soon as a handler uses +// a session method the manager doesn't delegate (e.g. clearLoadedHtml). +const writeCmd = (cmd: string, args: string[]) => + handleWriteCommand(cmd, args, bm.getActiveSession(), bm); +const readCmd = (cmd: string, args: string[]) => + handleReadCommand(cmd, args, bm.getActiveSession(), bm); let baseUrl: string; let server: ReturnType; let tmpDir: string; @@ -121,10 +131,15 @@ beforeAll(async () => { await bm.launch(); }); -afterAll(() => { +afterAll(async () => { try { server.stop(); } catch {} fs.rmSync(tmpDir, { recursive: true, force: true }); - setTimeout(() => process.exit(0), 500); + // Close only this file's own browser — never process.exit(): bun test runs + // all files in one process, so a delayed exit kills the whole suite + // (see test/no-suicide-exit.test.ts). close() can hang when the browser + // already died, and its internal 5s timeout ties bun's 5s hook timeout — + // so race it at 3s and abandon; the child is reaped at process exit. + try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {} }); // ─── The critical test: browser click → file on disk ───────────── @@ -137,32 +152,32 @@ describe('Submit: browser click → feedback.json on disk', () => { serverState = 'serving'; // Navigate to the board (board JS uses relative URLs + location.protocol detect) - await handleWriteCommand('goto', [baseUrl], bm); + await writeCmd('goto', [baseUrl]); // Verify the board detects HTTP mode (so postFeedback will actually fetch // instead of falling into the file:// DOM-only path) - const httpDetected = await handleReadCommand('js', [ + const httpDetected = await readCmd('js', [ "location.protocol === 'http:' || location.protocol === 'https:'" - ], bm); + ]); expect(httpDetected).toBe('true'); // User picks variant A, rates it 5 stars - await handleReadCommand('js', [ + await readCmd('js', [ 'document.querySelectorAll("input[name=\\"preferred\\"]")[0].click()' - ], bm); - await handleReadCommand('js', [ + ]); + await readCmd('js', [ 'document.querySelectorAll(".stars")[0].querySelectorAll(".star")[4].click()' - ], bm); + ]); // User adds overall feedback - await handleReadCommand('js', [ + await readCmd('js', [ 'document.getElementById("overall-feedback").value = "Ship variant A"' - ], bm); + ]); // User clicks Submit - await handleReadCommand('js', [ + await readCmd('js', [ 'document.getElementById("submit-btn").click()' - ], bm); + ]); // Wait a beat for the async POST to complete await new Promise(r => setTimeout(r, 300)); @@ -184,21 +199,21 @@ describe('Submit: browser click → feedback.json on disk', () => { await new Promise(r => setTimeout(r, 500)); // After submit, the page should be read-only - const submitBtnExists = await handleReadCommand('js', [ + const submitBtnExists = await readCmd('js', [ 'document.getElementById("submit-btn").style.display' - ], bm); + ]); // submit button is hidden after post-submit lifecycle expect(submitBtnExists).toBe('none'); - const successVisible = await handleReadCommand('js', [ + const successVisible = await readCmd('js', [ 'document.getElementById("success-msg").style.display' - ], bm); + ]); expect(successVisible).toBe('block'); // Success message should mention /design-shotgun - const successText = await handleReadCommand('js', [ + const successText = await readCmd('js', [ 'document.getElementById("success-msg").textContent' - ], bm); + ]); expect(successText).toContain('design-shotgun'); }); }); @@ -211,17 +226,17 @@ describe('Regenerate: browser click → feedback-pending.json on disk', () => { serverState = 'serving'; // Fresh page - await handleWriteCommand('goto', [baseUrl], bm); + await writeCmd('goto', [baseUrl]); // User clicks "Totally different" chiclet - await handleReadCommand('js', [ + await readCmd('js', [ 'document.querySelector(".regen-chiclet[data-action=\\"different\\"]").click()' - ], bm); + ]); // User clicks Regenerate - await handleReadCommand('js', [ + await readCmd('js', [ 'document.getElementById("regen-btn").click()' - ], bm); + ]); // Wait for async POST await new Promise(r => setTimeout(r, 300)); @@ -244,12 +259,12 @@ describe('Regenerate: browser click → feedback-pending.json on disk', () => { if (fs.existsSync(pendingPath)) fs.unlinkSync(pendingPath); serverState = 'serving'; - await handleWriteCommand('goto', [baseUrl], bm); + await writeCmd('goto', [baseUrl]); // Click "More like this" on variant B (index 1) - await handleReadCommand('js', [ + await readCmd('js', [ 'document.querySelectorAll(".more-like-this")[1].click()' - ], bm); + ]); await new Promise(r => setTimeout(r, 300)); @@ -263,21 +278,21 @@ describe('Regenerate: browser click → feedback-pending.json on disk', () => { test('board shows spinner after regenerate (user stays on same tab)', async () => { serverState = 'serving'; - await handleWriteCommand('goto', [baseUrl], bm); + await writeCmd('goto', [baseUrl]); - await handleReadCommand('js', [ + await readCmd('js', [ 'document.querySelector(".regen-chiclet[data-action=\\"different\\"]").click()' - ], bm); - await handleReadCommand('js', [ + ]); + await readCmd('js', [ 'document.getElementById("regen-btn").click()' - ], bm); + ]); await new Promise(r => setTimeout(r, 300)); // Board should show "Generating new designs..." text - const bodyText = await handleReadCommand('js', [ + const bodyText = await readCmd('js', [ 'document.body.textContent' - ], bm); + ]); expect(bodyText).toContain('Generating new designs'); }); }); @@ -291,15 +306,15 @@ describe('Full regeneration round-trip: regen → reload → submit', () => { if (fs.existsSync(feedbackPath)) fs.unlinkSync(feedbackPath); serverState = 'serving'; - await handleWriteCommand('goto', [baseUrl], bm); + await writeCmd('goto', [baseUrl]); // Step 1: User clicks Regenerate - await handleReadCommand('js', [ + await readCmd('js', [ 'document.querySelector(".regen-chiclet[data-action=\\"match\\"]").click()' - ], bm); - await handleReadCommand('js', [ + ]); + await readCmd('js', [ 'document.getElementById("regen-btn").click()' - ], bm); + ]); await new Promise(r => setTimeout(r, 300)); @@ -329,21 +344,21 @@ describe('Full regeneration round-trip: regen → reload → submit', () => { expect(serverState).toBe('serving'); // Step 4: Board auto-refreshes (simulated by navigating again) - await handleWriteCommand('goto', [baseUrl], bm); + await writeCmd('goto', [baseUrl]); // Verify the board is fresh (no prior picks) - const status = await handleReadCommand('js', [ + const status = await readCmd('js', [ 'document.getElementById("status").textContent' - ], bm); + ]); expect(status).toBe(''); // Step 5: User picks variant C on round 2 and submits - await handleReadCommand('js', [ + await readCmd('js', [ 'document.querySelectorAll("input[name=\\"preferred\\"]")[2].click()' - ], bm); - await handleReadCommand('js', [ + ]); + await readCmd('js', [ 'document.getElementById("submit-btn").click()' - ], bm); + ]); await new Promise(r => setTimeout(r, 300)); diff --git a/design/test/variants-retry-after.test.ts b/design/test/variants-retry-after.test.ts index 2060791d53..60df45f2f0 100644 --- a/design/test/variants-retry-after.test.ts +++ b/design/test/variants-retry-after.test.ts @@ -72,7 +72,11 @@ describe("generateVariant Retry-After handling", () => { test("HTTP-date: honors a future date with no extra leading exponential", async () => { const calls: CallRecord[] = []; - const future = new Date(Date.now() + 3000).toUTCString(); + // HTTP-date has whole-second granularity: toUTCString() truncates the ms + // component, which would shave up to 999ms off the intended wait and flake + // the >= 2500 floor. Align the base to the next whole second so the + // serialized date is a full 3s (up to 3.999s) in the future. + const future = new Date(Math.ceil(Date.now() / 1000) * 1000 + 3000).toUTCString(); const fetchFn = makeStubFetch([rateLimited(future), successResponse()], calls); const result = await generateVariant( diff --git a/test/codex-resume-flag-semantics.test.ts b/test/codex-resume-flag-semantics.test.ts index 9075e04897..da866418b0 100644 --- a/test/codex-resume-flag-semantics.test.ts +++ b/test/codex-resume-flag-semantics.test.ts @@ -15,8 +15,21 @@ import { describe, test, expect } from 'bun:test'; import { spawnSync } from 'child_process'; +// "On PATH" is not enough: a broken install (e.g. the npm wrapper missing its +// platform binary @openai/codex-darwin-arm64) resolves via `which` but can't +// answer any subcommand, so every assertion would fail on wrapper stack traces +// instead of real flag-semantics regressions. Probe `codex --version` and only +// run when the CLI actually executes; a working codex still gets the full +// assertions. const codexPath = spawnSync('which', ['codex'], { encoding: 'utf-8' }).stdout.trim(); -const codexAvailable = codexPath.length > 0; +const codexRuns = + codexPath.length > 0 && + spawnSync('codex', ['--version'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10_000, + }).status === 0; +const codexAvailable = codexRuns; describe.skipIf(!codexAvailable)( 'codex exec resume — flag semantics (live CLI smoke; closes #1270 regex-only gap)', diff --git a/test/gbrain-detect-install.test.ts b/test/gbrain-detect-install.test.ts index b9c82c1550..fea588916a 100644 --- a/test/gbrain-detect-install.test.ts +++ b/test/gbrain-detect-install.test.ts @@ -25,7 +25,15 @@ const INSTALL = path.join(ROOT, 'bin', 'gstack-gbrain-install'); // dirs — this keeps `gbrain` out of PATH deterministically across dev machines // while still finding jq, git, curl, sed, cat, etc. Each test can prepend a // fake-gbrain dir when it wants to simulate presence. -const SAFE_PATH = '/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin'; +// +// One wrinkle: bin/gstack-gbrain-detect's shebang is `#!/usr/bin/env -S bun run`, +// so the child's PATH must contain `bun`. On dev machines bun usually lives in +// ~/.bun/bin, which we deliberately exclude (it also holds a real `gbrain` from +// `bun link`). Expose ONLY the running bun interpreter through a shim dir, +// appended last so system dirs still win for everything else. +const BUN_SHIM_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'bun-shim-')); +fs.symlinkSync(process.execPath, path.join(BUN_SHIM_DIR, 'bun')); +const SAFE_PATH = `/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin:${BUN_SHIM_DIR}`; let tmpHome: string; let tmpHomeReal: string; diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 2fb783ffd0..8d23b4be03 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -177,6 +177,13 @@ describe('gen-skill-docs', () => { // whose plain `description:` scalar contains an interior ": " (read as a nested // mapping). Parse EVERY generated frontmatter block with a strict YAML parser, // not just string-check that name:/description: exist. + // + // Bun.YAML landed in bun 1.3; older local bun has no in-process strict YAML + // parser (the repo is deliberately zero-dep for YAML). CI pins + // `bun-version: latest`, so coverage is guaranteed there — on an old local + // bun we skip rather than false-fail. + const hasBunYaml = typeof (Bun as any).YAML?.parse === 'function'; + function frontmatterBlock(content: string): string { expect(content.startsWith('---\n')).toBe(true); const end = content.indexOf('\n---', 4); @@ -184,7 +191,7 @@ describe('gen-skill-docs', () => { return content.slice(4, end); } - test('every generated SKILL.md frontmatter parses as strict YAML', () => { + test.skipIf(!hasBunYaml)('every generated SKILL.md frontmatter parses as strict YAML', () => { for (const skill of CLAUDE_GENERATED_SKILLS) { const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8'); const fm = frontmatterBlock(content); @@ -196,7 +203,7 @@ describe('gen-skill-docs', () => { } }); - test('every generated Codex (.agents/skills) frontmatter parses as strict YAML', () => { + test.skipIf(!hasBunYaml)('every generated Codex (.agents/skills) frontmatter parses as strict YAML', () => { const agentsDir = path.join(ROOT, '.agents', 'skills'); if (!fs.existsSync(agentsDir)) return; // skip if external hosts not generated for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) { diff --git a/test/gstack-memory-helpers.test.ts b/test/gstack-memory-helpers.test.ts index 7b356a99c7..9f190732fe 100644 --- a/test/gstack-memory-helpers.test.ts +++ b/test/gstack-memory-helpers.test.ts @@ -249,11 +249,14 @@ body // ── withErrorContext ─────────────────────────────────────────────────────── describe("withErrorContext", () => { - let savedHome: string | undefined; + // Captured ONCE at collection time, NOT in beforeEach. Saving inside + // beforeEach clobbers the real value with the previous test's temp dir + // after test #1, so the afterAll restore leaked a dead temp path into + // process.env for every test file that ran after this one. + const savedHome = process.env.GSTACK_HOME; let testHome: string; beforeEach(() => { - savedHome = process.env.GSTACK_HOME; testHome = mkdtempSync(join(tmpdir(), "gstack-test-home-")); process.env.GSTACK_HOME = testHome; }); @@ -312,18 +315,20 @@ describe("withErrorContext", () => { // ── detectEngineTier ─────────────────────────────────────────────────────── describe("detectEngineTier", () => { - let savedHome: string | undefined; - let savedGbrainHome: string | undefined; - let savedRealHome: string | undefined; - let savedPath: string | undefined; + // Captured ONCE at collection time, NOT in beforeEach. Saving inside + // beforeEach clobbers the real values with the previous test's temp dirs + // after test #1, so the afterAll restore leaked dead gstack-test-engine-* + // temp paths into GSTACK_HOME / GBRAIN_HOME / HOME for every test file + // that ran after this one (broke gstack-config + make-pdf format-gate in + // full-suite runs). + const savedHome = process.env.GSTACK_HOME; + const savedGbrainHome = process.env.GBRAIN_HOME; + const savedRealHome = process.env.HOME; + const savedPath = process.env.PATH; let testHome: string; let testGbrainHome: string; beforeEach(() => { - savedHome = process.env.GSTACK_HOME; - savedGbrainHome = process.env.GBRAIN_HOME; - savedRealHome = process.env.HOME; - savedPath = process.env.PATH; testHome = mkdtempSync(join(tmpdir(), "gstack-test-engine-")); testGbrainHome = mkdtempSync(join(tmpdir(), "gstack-test-gbrain-")); process.env.GSTACK_HOME = testHome; diff --git a/test/no-suicide-exit.test.ts b/test/no-suicide-exit.test.ts new file mode 100644 index 0000000000..a820daebd5 --- /dev/null +++ b/test/no-suicide-exit.test.ts @@ -0,0 +1,49 @@ +/** + * Guard: no test file may schedule a delayed process.exit(). + * + * `bun test` runs EVERY test file in one process. The pattern of arming a + * 500ms timer in afterAll whose callback calls process.exit(0) — once used + * in several browse/design tests as a "bm.close() can hang" workaround — + * assumes each file gets its own process. It doesn't: the armed timer fires + * 500ms later, mid-way through a LATER test file, and kills the entire + * suite with exit code 0 and no summary. The truncated run silently masks + * every downstream failure (observed: only ~16 of 434 files ran, shell + * exit 0). + * + * This test statically scans every *.test.ts in the repo and fails if any + * schedules process.exit via setTimeout. Teardown must only release the + * file's own resources (e.g. `await bm.close()` — BrowserManager.close() + * is already time-boxed internally) — never terminate the shared runner. + * + * If a future test legitimately needs this pattern inside a child-process + * script (template literal passed to `bun -e`), split the child script + * into a fixture file instead of exempting it here. + */ +import { test, expect } from 'bun:test'; +import fs from 'node:fs'; +import path from 'node:path'; + +const repoRoot = path.resolve(import.meta.dir, '..'); + +// Matches a setTimeout whose arrow callback (with or without an argument) +// immediately calls process.exit. Doesn't match its own escaped source text +// (the backslashes in this regex literal prevent a literal-text match). +const DELAYED_EXIT = /setTimeout\(\s*(?:\(\s*\)|\(?\w+\)?)\s*=>\s*process\.exit\(/; + +test('no test file schedules a delayed process.exit (kills the whole bun test run)', () => { + const glob = new Bun.Glob('**/*.test.ts'); + const violations: string[] = []; + + for (const rel of glob.scanSync({ cwd: repoRoot })) { + if (rel.includes('node_modules/')) continue; + const source = fs.readFileSync(path.join(repoRoot, rel), 'utf-8'); + const lines = source.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (DELAYED_EXIT.test(lines[i])) { + violations.push(`${rel}:${i + 1}: ${lines[i].trim()}`); + } + } + } + + expect(violations).toEqual([]); +}); diff --git a/test/openclaw-native-skills.test.ts b/test/openclaw-native-skills.test.ts index 009b5e22c5..539a677e73 100644 --- a/test/openclaw-native-skills.test.ts +++ b/test/openclaw-native-skills.test.ts @@ -18,8 +18,14 @@ function extractFrontmatter(content: string): string { return content.slice(4, fmEnd); } +// Bun.YAML landed in bun 1.3; older local bun has no in-process strict YAML +// parser (the repo is deliberately zero-dep for YAML). CI pins +// `bun-version: latest`, so coverage is guaranteed there — on an old local +// bun we skip rather than false-fail. +const hasBunYaml = typeof (Bun as any).YAML?.parse === 'function'; + describe('OpenClaw native skills', () => { - test('frontmatter parses as YAML and keeps only name + description', () => { + test.skipIf(!hasBunYaml)('frontmatter parses as YAML and keeps only name + description', () => { for (const skill of OPENCLAW_NATIVE_SKILLS) { const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8'); const frontmatter = extractFrontmatter(content); diff --git a/test/user-slug-fallback.test.ts b/test/user-slug-fallback.test.ts index 1d8c3f9253..c7cc68f48d 100644 --- a/test/user-slug-fallback.test.ts +++ b/test/user-slug-fallback.test.ts @@ -35,6 +35,12 @@ function runConfig(args: string[], extraEnv: Record = {}): { std encoding: 'utf-8', env: { ...process.env, + // HOME isolation: endpoint_hash() reads $HOME/.claude.json for the + // gbrain MCP URL. Pointing HOME at the empty TMP_HOME makes it + // deterministically 'local' regardless of the developer's real + // ~/.claude.json (which would otherwise change the persisted key + // namespace to user_slug_at_). + HOME: TMP_HOME, ...extraEnv, }, timeout: 5000, @@ -92,7 +98,9 @@ describe('resolve-user-slug fallback chain', () => { const configFile = join(TMP_HOME, 'config.yaml'); expect(existsSync(configFile)).toBe(true); const content = readFileSync(configFile, 'utf-8'); - expect(content).toMatch(/^user_slug_at_[a-f0-9]+:\s+persisttest/m); + // HOME is isolated to the empty TMP_HOME, so endpoint_hash() is + // deterministically the literal 'local' on every machine. + expect(content).toMatch(/^user_slug_at_local:\s+persisttest/m); }); test('subsequent calls return same slug (stable across sessions)', () => {