diff --git a/browse/test/security-sidepanel-dom.test.ts b/browse/test/security-sidepanel-dom.test.ts deleted file mode 100644 index 4ae34d5f9..000000000 --- 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 d7a27fea7..000000000 --- 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 91d50dcef..94253ab5c 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 74ced5efd..c97412349 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 ───────────────────────────────────────── +// ─── Focus stealing prevention ────────────────────────────────── -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'); - }); - - 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); - }); -});