diff --git a/browse/test/sidebar-agent.test.ts b/browse/test/sidebar-agent.test.ts index 2c8d49e9..ee77a33b 100644 --- a/browse/test/sidebar-agent.test.ts +++ b/browse/test/sidebar-agent.test.ts @@ -67,6 +67,74 @@ function writeToInbox( return finalFile; } +/** Shorten paths — same logic as sidebar-agent.ts shorten() */ +function shorten(str: string): string { + return str + .replace(/\/Users\/[^/]+/g, '~') + .replace(/\/conductor\/workspaces\/[^/]+\/[^/]+/g, '') + .replace(/\.claude\/skills\/gstack\//g, '') + .replace(/browse\/dist\/browse/g, '$B'); +} + +/** describeToolCall — replicated from sidebar-agent.ts for unit testing */ +function describeToolCall(tool: string, input: any): string { + if (!input) return ''; + + if (tool === 'Bash' && input.command) { + const cmd = input.command; + const browseMatch = cmd.match(/\$B\s+(\w+)|browse[^\s]*\s+(\w+)/); + if (browseMatch) { + const browseCmd = browseMatch[1] || browseMatch[2]; + const args = cmd.split(/\s+/).slice(2).join(' '); + switch (browseCmd) { + case 'goto': return `Opening ${args.replace(/['"]/g, '')}`; + case 'snapshot': return args.includes('-i') ? 'Scanning for interactive elements' : args.includes('-D') ? 'Checking what changed' : 'Taking a snapshot of the page'; + case 'screenshot': return `Saving screenshot${args ? ` to ${shorten(args)}` : ''}`; + case 'click': return `Clicking ${args}`; + case 'fill': { const parts = args.split(/\s+/); return `Typing "${parts.slice(1).join(' ')}" into ${parts[0]}`; } + case 'text': return 'Reading page text'; + case 'html': return args ? `Reading HTML of ${args}` : 'Reading full page HTML'; + case 'links': return 'Finding all links on the page'; + case 'forms': return 'Looking for forms'; + case 'console': return 'Checking browser console for errors'; + case 'network': return 'Checking network requests'; + case 'url': return 'Checking current URL'; + case 'back': return 'Going back'; + case 'forward': return 'Going forward'; + case 'reload': return 'Reloading the page'; + case 'scroll': return args ? `Scrolling to ${args}` : 'Scrolling down'; + case 'wait': return `Waiting for ${args}`; + case 'inspect': return args ? `Inspecting CSS of ${args}` : 'Getting CSS for last picked element'; + case 'style': return `Changing CSS: ${args}`; + case 'cleanup': return 'Removing page clutter (ads, popups, banners)'; + case 'prettyscreenshot': return 'Taking a clean screenshot'; + case 'css': return `Checking CSS property: ${args}`; + case 'is': return `Checking if element is ${args}`; + case 'diff': return `Comparing ${args}`; + case 'responsive': return 'Taking screenshots at mobile, tablet, and desktop sizes'; + case 'status': return 'Checking browser status'; + case 'tabs': return 'Listing open tabs'; + case 'focus': return 'Bringing browser to front'; + case 'select': return `Selecting option in ${args}`; + case 'hover': return `Hovering over ${args}`; + case 'viewport': return `Setting viewport to ${args}`; + case 'upload': return `Uploading file to ${args.split(/\s+/)[0]}`; + default: return `Running browse ${browseCmd} ${args}`.trim(); + } + } + if (cmd.includes('git ')) return `Running: ${shorten(cmd)}`; + let short = shorten(cmd); + return short.length > 100 ? short.slice(0, 100) + '…' : short; + } + + if (tool === 'Read' && input.file_path) return `Reading ${shorten(input.file_path)}`; + if (tool === 'Edit' && input.file_path) return `Editing ${shorten(input.file_path)}`; + if (tool === 'Write' && input.file_path) return `Writing ${shorten(input.file_path)}`; + if (tool === 'Grep' && input.pattern) return `Searching for "${input.pattern}"`; + if (tool === 'Glob' && input.pattern) return `Finding files matching ${input.pattern}`; + try { return shorten(JSON.stringify(input)).slice(0, 80); } catch { return ''; } +} + // ─── Test setup ────────────────────────────────────────────────── let tmpDir: string; @@ -197,3 +265,288 @@ describe('writeToInbox', () => { expect(files.length).toBe(2); }); }); + +// ─── describeToolCall (verbose narration) ──────────────────────── + +describe('describeToolCall', () => { + // Browse navigation commands + test('goto → plain English with URL', () => { + const result = describeToolCall('Bash', { command: '$B goto https://example.com' }); + expect(result).toBe('Opening https://example.com'); + }); + + test('goto strips quotes from URL', () => { + const result = describeToolCall('Bash', { command: '$B goto "https://example.com"' }); + expect(result).toBe('Opening https://example.com'); + }); + + test('url → checking current URL', () => { + expect(describeToolCall('Bash', { command: '$B url' })).toBe('Checking current URL'); + }); + + test('back/forward/reload → plain English', () => { + expect(describeToolCall('Bash', { command: '$B back' })).toBe('Going back'); + expect(describeToolCall('Bash', { command: '$B forward' })).toBe('Going forward'); + expect(describeToolCall('Bash', { command: '$B reload' })).toBe('Reloading the page'); + }); + + // Snapshot variants + test('snapshot -i → scanning for interactive elements', () => { + expect(describeToolCall('Bash', { command: '$B snapshot -i' })).toBe('Scanning for interactive elements'); + }); + + test('snapshot -D → checking what changed', () => { + expect(describeToolCall('Bash', { command: '$B snapshot -D' })).toBe('Checking what changed'); + }); + + test('snapshot (plain) → taking a snapshot', () => { + expect(describeToolCall('Bash', { command: '$B snapshot' })).toBe('Taking a snapshot of the page'); + }); + + // Interaction commands + test('click → clicking element', () => { + expect(describeToolCall('Bash', { command: '$B click @e3' })).toBe('Clicking @e3'); + }); + + test('fill → typing into element', () => { + expect(describeToolCall('Bash', { command: '$B fill @e4 "hello world"' })).toBe('Typing ""hello world"" into @e4'); + }); + + test('scroll with selector → scrolling to element', () => { + expect(describeToolCall('Bash', { command: '$B scroll .footer' })).toBe('Scrolling to .footer'); + }); + + test('scroll without args → scrolling down', () => { + expect(describeToolCall('Bash', { command: '$B scroll' })).toBe('Scrolling down'); + }); + + // Reading commands + test('text → reading page text', () => { + expect(describeToolCall('Bash', { command: '$B text' })).toBe('Reading page text'); + }); + + test('html with selector → reading HTML of element', () => { + expect(describeToolCall('Bash', { command: '$B html .header' })).toBe('Reading HTML of .header'); + }); + + test('html without selector → reading full page HTML', () => { + expect(describeToolCall('Bash', { command: '$B html' })).toBe('Reading full page HTML'); + }); + + test('links → finding all links', () => { + expect(describeToolCall('Bash', { command: '$B links' })).toBe('Finding all links on the page'); + }); + + test('console → checking console', () => { + expect(describeToolCall('Bash', { command: '$B console' })).toBe('Checking browser console for errors'); + }); + + // Inspector commands + test('inspect with selector → inspecting CSS', () => { + expect(describeToolCall('Bash', { command: '$B inspect .header' })).toBe('Inspecting CSS of .header'); + }); + + test('inspect without args → getting last picked element', () => { + expect(describeToolCall('Bash', { command: '$B inspect' })).toBe('Getting CSS for last picked element'); + }); + + test('style → changing CSS', () => { + expect(describeToolCall('Bash', { command: '$B style .header color red' })).toBe('Changing CSS: .header color red'); + }); + + test('cleanup → removing page clutter', () => { + expect(describeToolCall('Bash', { command: '$B cleanup --all' })).toBe('Removing page clutter (ads, popups, banners)'); + }); + + // Visual commands + test('screenshot → saving screenshot', () => { + expect(describeToolCall('Bash', { command: '$B screenshot /tmp/shot.png' })).toBe('Saving screenshot to /tmp/shot.png'); + }); + + test('screenshot without path', () => { + expect(describeToolCall('Bash', { command: '$B screenshot' })).toBe('Saving screenshot'); + }); + + test('responsive → multi-size screenshots', () => { + expect(describeToolCall('Bash', { command: '$B responsive' })).toBe('Taking screenshots at mobile, tablet, and desktop sizes'); + }); + + // Non-browse tools + test('Read tool → reading file', () => { + expect(describeToolCall('Read', { file_path: '/Users/foo/project/src/app.ts' })).toBe('Reading ~/project/src/app.ts'); + }); + + test('Grep tool → searching for pattern', () => { + expect(describeToolCall('Grep', { pattern: 'handleClick' })).toBe('Searching for "handleClick"'); + }); + + test('Glob tool → finding files', () => { + expect(describeToolCall('Glob', { pattern: '**/*.tsx' })).toBe('Finding files matching **/*.tsx'); + }); + + test('Edit tool → editing file', () => { + expect(describeToolCall('Edit', { file_path: '/Users/foo/src/main.ts' })).toBe('Editing ~/src/main.ts'); + }); + + // Edge cases + test('null input → empty string', () => { + expect(describeToolCall('Bash', null)).toBe(''); + }); + + test('unknown browse command → generic description', () => { + expect(describeToolCall('Bash', { command: '$B newtab https://foo.com' })).toContain('newtab'); + }); + + test('non-browse bash → shortened command', () => { + expect(describeToolCall('Bash', { command: 'echo hello' })).toBe('echo hello'); + }); + + test('full browse binary path recognized', () => { + const result = describeToolCall('Bash', { command: '/Users/garrytan/.claude/skills/gstack/browse/dist/browse goto https://example.com' }); + expect(result).toBe('Opening https://example.com'); + }); + + test('tab command → switching tab', () => { + expect(describeToolCall('Bash', { command: '$B tab 2' })).toContain('tab'); + }); +}); + +// ─── Per-tab agent concurrency (source code validation) ────────── + +describe('per-tab agent concurrency', () => { + const serverSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'server.ts'), 'utf-8'); + const agentSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'sidebar-agent.ts'), 'utf-8'); + + test('server has per-tab agent state map', () => { + expect(serverSrc).toContain('tabAgents'); + expect(serverSrc).toContain('TabAgentState'); + expect(serverSrc).toContain('getTabAgent'); + }); + + test('server returns per-tab agent status in /sidebar-chat', () => { + expect(serverSrc).toContain('getTabAgentStatus'); + expect(serverSrc).toContain('tabAgentStatus'); + }); + + test('spawnClaude accepts forTabId parameter', () => { + const spawnFn = serverSrc.slice( + serverSrc.indexOf('function spawnClaude('), + serverSrc.indexOf('\nfunction ', serverSrc.indexOf('function spawnClaude(') + 1), + ); + expect(spawnFn).toContain('forTabId'); + expect(spawnFn).toContain('tabState.status'); + }); + + test('sidebar-command endpoint uses per-tab agent state', () => { + expect(serverSrc).toContain('msgTabId'); + expect(serverSrc).toContain('tabState.status'); + expect(serverSrc).toContain('tabState.queue'); + }); + + test('agent event handler resets per-tab state', () => { + expect(serverSrc).toContain('eventTabId'); + expect(serverSrc).toContain('tabState.status = \'idle\''); + }); + + test('agent event handler processes per-tab queue', () => { + // After agent_done, should process next message from THIS tab's queue + expect(serverSrc).toContain('tabState.queue.length > 0'); + expect(serverSrc).toContain('tabState.queue.shift'); + }); + + test('sidebar-agent uses per-tab processing set', () => { + expect(agentSrc).toContain('processingTabs'); + expect(agentSrc).not.toContain('isProcessing'); + }); + + test('sidebar-agent sends tabId with all events', () => { + // sendEvent should accept tabId parameter + expect(agentSrc).toContain('async function sendEvent(event: Record, tabId?: number)'); + // askClaude should extract tabId from queue entry + expect(agentSrc).toContain('const { prompt, args, stateFile, cwd, tabId }'); + }); + + test('sidebar-agent allows concurrent agents across tabs', () => { + // poll() should not block globally — it should check per-tab + expect(agentSrc).toContain('processingTabs.has(tid)'); + // askClaude should be fire-and-forget (no await blocking the loop) + expect(agentSrc).toContain('askClaude(entry).catch'); + }); + + test('queue entries include tabId', () => { + const spawnFn = serverSrc.slice( + serverSrc.indexOf('function spawnClaude('), + serverSrc.indexOf('\nfunction ', serverSrc.indexOf('function spawnClaude(') + 1), + ); + expect(spawnFn).toContain('tabId: agentTabId'); + }); + + test('health check monitors all per-tab agents', () => { + expect(serverSrc).toContain('for (const [tid, state] of tabAgents)'); + }); +}); + +describe('BROWSE_TAB tab pinning (cross-tab isolation)', () => { + const serverSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'server.ts'), 'utf-8'); + const agentSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'sidebar-agent.ts'), 'utf-8'); + const cliSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'cli.ts'), 'utf-8'); + + test('sidebar-agent passes BROWSE_TAB env var to claude process', () => { + // The env block should include BROWSE_TAB set to the tab ID + expect(agentSrc).toContain('BROWSE_TAB'); + expect(agentSrc).toContain('String(tid)'); + }); + + test('CLI reads BROWSE_TAB and sends tabId in command body', () => { + expect(cliSrc).toContain('process.env.BROWSE_TAB'); + expect(cliSrc).toContain('tabId: parseInt(browseTab'); + }); + + test('handleCommand accepts tabId from request body', () => { + const handleFn = serverSrc.slice( + serverSrc.indexOf('async function handleCommand('), + serverSrc.indexOf('\nasync function ', serverSrc.indexOf('async function handleCommand(') + 1) > 0 + ? serverSrc.indexOf('\nasync function ', serverSrc.indexOf('async function handleCommand(') + 1) + : serverSrc.indexOf('\n// ', serverSrc.indexOf('async function handleCommand(') + 200), + ); + // Should destructure tabId from body + expect(handleFn).toContain('tabId'); + // Should save and restore the active tab + expect(handleFn).toContain('savedTabId'); + expect(handleFn).toContain('browserManager.switchTab(tabId)'); + }); + + test('handleCommand restores active tab after command (success path)', () => { + // On success, should restore savedTabId + const handleFn = serverSrc.slice( + serverSrc.indexOf('async function handleCommand('), + serverSrc.length, + ); + // Count restore calls — should appear in both success and error paths + const restoreCount = (handleFn.match(/browserManager\.switchTab\(savedTabId\)/g) || []).length; + expect(restoreCount).toBeGreaterThanOrEqual(2); // success + error paths + }); + + test('handleCommand restores active tab on error path', () => { + // The catch block should also restore + const catchBlock = serverSrc.slice( + serverSrc.indexOf('} catch (err: any) {', serverSrc.indexOf('async function handleCommand(')), + ); + expect(catchBlock).toContain('switchTab(savedTabId)'); + }); + + test('tab pinning only activates when tabId is provided', () => { + const handleFn = serverSrc.slice( + serverSrc.indexOf('async function handleCommand('), + serverSrc.indexOf('try {', serverSrc.indexOf('async function handleCommand(') + 1), + ); + // Should check tabId is not undefined/null before switching + expect(handleFn).toContain('tabId !== undefined'); + expect(handleFn).toContain('tabId !== null'); + }); + + test('CLI only sends tabId when BROWSE_TAB is set', () => { + // Should conditionally include tabId in the body + expect(cliSrc).toContain('browseTab ? { tabId:'); + }); +}); diff --git a/browse/test/sidebar-ux.test.ts b/browse/test/sidebar-ux.test.ts new file mode 100644 index 00000000..bc595e7a --- /dev/null +++ b/browse/test/sidebar-ux.test.ts @@ -0,0 +1,676 @@ +/** + * 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 + */ + +import { describe, test, expect } from 'bun:test'; +import * as fs from 'fs'; +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 narration instructions', () => { + const promptSection = serverSrc.slice( + serverSrc.indexOf('const systemPrompt = ['), + serverSrc.indexOf("].join('\\n');", serverSrc.indexOf('const systemPrompt = [')) + 15, + ); + expect(promptSection).toContain('Narrate'); + expect(promptSection).toContain('plain English'); + }); + + 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 adds (session ended) notice', () => { + expect(js).toContain('(session ended)'); + }); + + 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 20 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 + // Slightly higher limit because of per-tab instruction line + expect(lines).toBeLessThan(20); + }); + + 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()') + 800, + ); + 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'); + + test('browser-tabs container exists', () => { + expect(html).toContain('id="browser-tabs"'); + }); + + test('browser-tabs hidden by default', () => { + const match = html.match(/id="browser-tabs"[^>]*/); + expect(match).not.toBeNull(); + expect(match![0]).toContain('display:none'); + }); +}); + +// ─── Bidirectional tab sync ────────────────────────────────────── + +describe('sidebar→browser tab switch', () => { + const bmSrc = fs.readFileSync(path.join(ROOT, 'src', 'browser-manager.ts'), 'utf-8'); + + test('switchTab calls bringToFront so browser visually switches', () => { + const switchFn = bmSrc.slice( + bmSrc.indexOf('switchTab(id: number)'), + bmSrc.indexOf('switchTab(id: number)') + 400, + ); + expect(switchFn).toContain('bringToFront'); + }); +}); + +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)'); + }); + + test('syncActiveTabByUrl updates activeTabId when URL matches a different tab', () => { + const fn = bmSrc.slice( + bmSrc.indexOf('syncActiveTabByUrl('), + bmSrc.indexOf('syncActiveTabByUrl(') + 1200, + ); + expect(fn).toContain('this.activeTabId = id'); + // Exact match + expect(fn).toContain('pageUrl === activeUrl'); + // Fuzzy match (origin+pathname) + expect(fn).toContain('activeOriginPath'); + expect(fn).toContain('fuzzyId'); + }); + + test('context.on("page") tracks user-created tabs', () => { + expect(bmSrc).toContain("context.on('page'"); + expect(bmSrc).toContain('this.pages.set(id, page)'); + // Should log when new tab detected + expect(bmSrc).toContain('New tab detected'); + }); + + test('page close handler removes tab from pages map', () => { + expect(bmSrc).toContain("page.on('close'"); + expect(bmSrc).toContain('this.pages.delete(id)'); + expect(bmSrc).toContain('Tab closed'); + }); + + test('syncActiveTabByUrl skips when only 1 tab (no ambiguity)', () => { + const fn = bmSrc.slice( + bmSrc.indexOf('syncActiveTabByUrl('), + bmSrc.indexOf('syncActiveTabByUrl(') + 600, + ); + 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'") + 500, + ); + 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 + }); + + 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)', () => { + const css = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.css'), 'utf-8'); + + test('browser-tabs styles exist', () => { + expect(css).toContain('.browser-tabs'); + expect(css).toContain('.browser-tab'); + expect(css).toContain('.browser-tab.active'); + }); + + test('tab bar is horizontally scrollable', () => { + const barStyle = css.slice( + css.indexOf('.browser-tabs {'), + css.indexOf('}', css.indexOf('.browser-tabs {')) + 1, + ); + expect(barStyle).toContain('overflow-x: auto'); + }); + + test('active tab is visually distinct', () => { + const activeStyle = css.slice( + css.indexOf('.browser-tab.active {'), + css.indexOf('}', css.indexOf('.browser-tab.active {')) + 1, + ); + expect(activeStyle).toContain('--bg-surface'); + expect(activeStyle).toContain('--text-body'); + }); +}); + +// ─── 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('innerHTML'); + }); + + 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)', () => { + const css = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.css'), 'utf-8'); + + test('stop button style exists', () => { + expect(css).toContain('.stop-btn'); + }); + + test('stop button uses error color', () => { + const stopBtnSection = css.slice( + css.indexOf('.stop-btn {'), + css.indexOf('}', css.indexOf('.stop-btn {')) + 1, + ); + expect(stopBtnSection).toContain('--error'); + }); + + test('experimental-banner no longer uses amber warning colors', () => { + const bannerSection = css.slice( + css.indexOf('.experimental-banner {'), + css.indexOf('}', css.indexOf('.experimental-banner {')) + 1, + ); + // Should not be amber/warning anymore + expect(bannerSection).not.toContain('245, 158, 11, 0.15'); + expect(bannerSection).not.toContain('#F59E0B'); + }); + + test('tool description uses system font not mono', () => { + const toolSection = css.slice( + css.indexOf('.agent-tool {'), + css.indexOf('}', css.indexOf('.agent-tool {')) + 1, + ); + expect(toolSection).toContain('font-system'); + expect(toolSection).not.toContain('font-mono'); + }); +});