/** * Source-contract tests for the terminal-first browser sidepanel. * * The one-shot chat queue and sidebar-agent daemon were removed. These * checks intentionally cover the current PTY surface and its retained debug * tools without preserving obsolete chat implementation details. */ import { describe, test, expect } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; const BROWSE_ROOT = path.resolve(import.meta.dir, '..'); const REPO_ROOT = path.resolve(BROWSE_ROOT, '..'); const EXTENSION_ROOT = path.join(REPO_ROOT, 'extension'); const html = fs.readFileSync(path.join(EXTENSION_ROOT, 'sidepanel.html'), 'utf8'); const sidepanel = fs.readFileSync(path.join(EXTENSION_ROOT, 'sidepanel.js'), 'utf8'); const terminal = fs.readFileSync(path.join(EXTENSION_ROOT, 'sidepanel-terminal.js'), 'utf8'); const background = fs.readFileSync(path.join(EXTENSION_ROOT, 'background.js'), 'utf8'); function between(source: string, startMarker: string, endMarker: string): string { const start = source.indexOf(startMarker); if (start < 0) return ''; const end = source.indexOf(endMarker, start + startMarker.length); return end < 0 ? source.slice(start) : source.slice(start, end); } function withoutComments(source: string): string { return source .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/^\s*\/\/.*$/gm, ''); } describe('terminal-first sidepanel', () => { test('terminal is the sole active primary pane', () => { const activeMainIds = [...html.matchAll( / match[1]); expect(activeMainIds).toEqual(['tab-terminal']); expect(html).toContain('id="tab-terminal"'); expect(html).toContain('role="tabpanel" aria-label="Terminal"'); expect(html).not.toContain('id="tab-chat"'); expect(sidepanel).toContain("const PRIMARY_PANE_ID = 'tab-terminal';"); expect(sidepanel).toContain('document.getElementById(PRIMARY_PANE_ID).classList.add(\'active\')'); }); test('xterm, fit, and terminal bootstrap assets are shipped and ordered', () => { const assets = [ 'lib/xterm.css', 'lib/xterm.js', 'lib/xterm-addon-fit.js', 'sidepanel-terminal.js', ]; for (const asset of assets) { expect(fs.existsSync(path.join(EXTENSION_ROOT, asset))).toBe(true); expect(html).toContain(asset); } const scriptOrder = [ html.indexOf('lib/xterm.js'), html.indexOf('lib/xterm-addon-fit.js'), html.indexOf('sidepanel.js'), html.indexOf('sidepanel-terminal.js'), ]; expect(scriptOrder.every((index) => index >= 0)).toBe(true); expect(scriptOrder).toEqual([...scriptOrder].sort((left, right) => left - right)); for (const id of [ 'terminal-bootstrap', 'terminal-bootstrap-status', 'terminal-install-card', 'terminal-mount', 'terminal-ended', 'terminal-restart', 'terminal-restart-now', ]) { expect(html).toContain(`id="${id}"`); } expect(terminal).toContain("setState(STATE.IDLE, { message: 'Starting Claude Code...' })"); expect(terminal).toContain('tryAutoConnect();'); }); test('retired chat queue code and daemon stay removed', () => { const executableSidepanel = withoutComments(sidepanel); const executableTerminal = withoutComments(terminal); const removedFunctions = ['sendMessage', 'pollChat', 'switchChatTab']; expect(fs.existsSync(path.join(BROWSE_ROOT, 'src', 'sidebar-agent.ts'))).toBe(false); for (const name of removedFunctions) { const declaration = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\(`); expect(executableSidepanel).not.toMatch(declaration); expect(executableTerminal).not.toMatch(declaration); } expect(executableSidepanel).not.toContain('/sidebar-chat'); expect(executableSidepanel).not.toContain('/sidebar-command'); expect(html).not.toContain('id="chat-input"'); expect(html).not.toContain('id="chat-messages"'); expect(html).not.toContain('id="stop-agent-btn"'); }); }); describe('PTY lifecycle security', () => { test('bootstrap uses authenticated POST and a one-use WebSocket protocol token', () => { const connection = between(sidepanel, 'function updateConnection(', '// ─── Port Configuration'); const mint = between(terminal, 'async function mintSession()', 'function startReattachLoop('); expect(connection).toContain('window.gstackServerPort'); expect(connection).toContain('window.gstackAuthToken'); expect(mint).toContain('`http://127.0.0.1:${serverPort}/pty-session`'); expect(mint).toContain("method: 'POST'"); expect(mint).toContain("'Authorization': `Bearer ${token}`"); expect(mint).toContain("credentials: 'include'"); expect(terminal).toContain('const attachToken = minted.attachToken || minted.ptySessionToken'); expect(terminal).toContain( 'new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${attachToken}`])', ); expect(terminal).not.toContain('?token='); }); test('session identity is retained only for explicit pagehide disposal', () => { const disposal = sidepanel.slice(sidepanel.indexOf("window.addEventListener('pagehide'")); expect(terminal).toContain('currentSessionId = sessionId || null'); expect(terminal).toContain('window.gstackPtySession = currentSessionId'); expect(disposal).toContain('const sessionId = window.gstackPtySession'); expect(disposal).toContain('const authToken = window.gstackAuthToken'); expect(disposal).toContain('if (!sessionId || !authToken || !port) return'); expect(disposal).toContain('JSON.stringify({ sessionId, authToken })'); expect(disposal).toContain('navigator.sendBeacon(`http://127.0.0.1:${port}/pty-dispose`, blob)'); expect(disposal).not.toContain('?token='); }); test('tab state crosses the extension boundary only through the live PTY relay', () => { const push = between(background, 'async function pushTabState(', "chrome.tabs.onActivated.addListener"); const sidepanelRelay = between(sidepanel, "if (msg.type === 'browserTabState')", '// ─── v1.44 pagehide'); const terminalRelay = between( terminal, "document.addEventListener('gstack:tab-state'", '// Repaint after a debug-tab', ); expect(push).toContain("type: 'browserTabState'"); expect(push).toContain('...snapshot'); expect(background).toContain("pushTabState('activated')"); expect(background).toContain("pushTabState('created')"); expect(background).toContain("pushTabState('removed')"); expect(sidepanelRelay).toContain("new CustomEvent('gstack:tab-state'"); expect(sidepanelRelay).toContain('detail: { active: msg.active, tabs: msg.tabs, reason: msg.reason }'); expect(terminalRelay).toContain('if (!ws || ws.readyState !== WebSocket.OPEN) return'); expect(terminalRelay).toContain("type: 'tabState'"); expect(terminalRelay).toContain('active: ev.detail?.active'); expect(terminalRelay).toContain('tabs: ev.detail?.tabs'); }); test('page-derived inspector and cleanup prompts are scanned before PTY injection', () => { const inspectorSend = between(sidepanel, "inspectorSendBtn.addEventListener('click'", '// ─── Quick Action Helpers'); const cleanup = between(sidepanel, 'async function runCleanup(', 'async function runScreenshot('); for (const block of [inspectorSend, cleanup]) { const scan = block.indexOf('gstackScanForPTYInject'); const inject = block.indexOf('gstackInjectToTerminal'); expect(scan).toBeGreaterThan(0); expect(inject).toBeGreaterThan(scan); expect(block).toContain("verdict === 'BLOCK'"); expect(block).toContain("verdict === 'WARN'"); } }); }); describe('retained debug tools and quick actions', () => { test('activity, refs, and inspector remain hidden debug panels', () => { const debugNav = between(html, '