Merge remote-tracking branch 'origin/main' into garrytan/merge-open-prs

This commit is contained in:
Garry Tan
2026-03-26 22:29:30 -06:00
18 changed files with 1478 additions and 173 deletions
+226
View File
@@ -0,0 +1,226 @@
/**
* Layer 3: Sidebar agent round-trip tests.
* Starts server + sidebar-agent together. Mocks the `claude` binary with a shell
* script that outputs canned stream-json. Verifies events flow end-to-end:
* POST /sidebar-command → queue → sidebar-agent → mock claude → events → /sidebar-chat
*/
import { describe, test, expect, beforeAll, afterAll } 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 agentProc: Subprocess | null = null;
let serverPort: number = 0;
let authToken: string = '';
let tmpDir: string = '';
let stateFile: string = '';
let queueFile: string = '';
let mockBinDir: string = '';
async function api(pathname: string, opts: RequestInit = {}): Promise<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(opts.headers as Record<string, string> || {}),
};
if (!headers['Authorization'] && authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
return fetch(`http://127.0.0.1:${serverPort}${pathname}`, { ...opts, headers });
}
async function resetState() {
await api('/sidebar-session/new', { method: 'POST' });
fs.writeFileSync(queueFile, '');
}
async function pollChatUntil(
predicate: (entries: any[]) => boolean,
timeoutMs = 10000,
): Promise<any[]> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const resp = await api('/sidebar-chat?after=0');
const data = await resp.json();
if (predicate(data.entries)) return data.entries;
await new Promise(r => setTimeout(r, 300));
}
// Return whatever we have on timeout
const resp = await api('/sidebar-chat?after=0');
return (await resp.json()).entries;
}
function writeMockClaude(script: string) {
const mockPath = path.join(mockBinDir, 'claude');
fs.writeFileSync(mockPath, script, { mode: 0o755 });
}
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sidebar-roundtrip-'));
stateFile = path.join(tmpDir, 'browse.json');
queueFile = path.join(tmpDir, 'sidebar-queue.jsonl');
mockBinDir = path.join(tmpDir, 'bin');
fs.mkdirSync(mockBinDir, { recursive: true });
fs.mkdirSync(path.dirname(queueFile), { recursive: true });
// Write default mock claude that outputs canned events
writeMockClaude(`#!/bin/bash
echo '{"type":"system","session_id":"mock-session-123"}'
echo '{"type":"assistant","message":{"content":[{"type":"text","text":"I can see the page. It looks like a test fixture."}]}}'
echo '{"type":"result","result":"Done."}'
`);
// Start server (no browser)
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 server
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');
// Start sidebar-agent with mock claude on PATH
const agentScript = path.resolve(__dirname, '..', 'src', 'sidebar-agent.ts');
agentProc = spawn(['bun', 'run', agentScript], {
env: {
...process.env,
PATH: `${mockBinDir}:${process.env.PATH}`,
BROWSE_SERVER_PORT: String(serverPort),
BROWSE_STATE_FILE: stateFile,
SIDEBAR_QUEUE_PATH: queueFile,
SIDEBAR_AGENT_TIMEOUT: '10000',
BROWSE_BIN: 'browse', // doesn't matter, mock claude doesn't use it
},
stdio: ['ignore', 'pipe', 'pipe'],
});
// Give sidebar-agent time to start polling
await new Promise(r => setTimeout(r, 1000));
}, 20000);
afterAll(() => {
if (agentProc) { try { agentProc.kill(); } catch {} }
if (serverProc) { try { serverProc.kill(); } catch {} }
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
});
describe('sidebar-agent round-trip', () => {
test('full message round-trip with mock claude', async () => {
await resetState();
// Send a command
const resp = await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({
message: 'what is on this page?',
activeTabUrl: 'https://example.com/test',
}),
});
expect(resp.status).toBe(200);
// Wait for mock claude to process and events to arrive
const entries = await pollChatUntil(
(entries) => entries.some((e: any) => e.type === 'agent_done'),
15000,
);
// Verify the flow: user message → agent_start → text → agent_done
const userEntry = entries.find((e: any) => e.role === 'user');
expect(userEntry).toBeDefined();
expect(userEntry.message).toBe('what is on this page?');
// The mock claude outputs text — check for any agent text entry
const textEntries = entries.filter((e: any) => e.role === 'agent' && (e.type === 'text' || e.type === 'result'));
expect(textEntries.length).toBeGreaterThan(0);
const doneEntry = entries.find((e: any) => e.type === 'agent_done');
expect(doneEntry).toBeDefined();
// Agent should be back to idle
const session = await (await api('/sidebar-session')).json();
expect(session.agent.status).toBe('idle');
}, 20000);
test('claude crash produces agent_error', async () => {
await resetState();
// Replace mock claude with one that crashes
writeMockClaude(`#!/bin/bash
echo '{"type":"system","session_id":"crash-test"}' >&2
exit 1
`);
await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({ message: 'crash test' }),
});
// Wait for agent_done (sidebar-agent sends agent_done even on crash via proc.on('close'))
const entries = await pollChatUntil(
(entries) => entries.some((e: any) => e.type === 'agent_done' || e.type === 'agent_error'),
15000,
);
// Agent should recover to idle
const session = await (await api('/sidebar-session')).json();
expect(session.agent.status).toBe('idle');
// Restore working mock
writeMockClaude(`#!/bin/bash
echo '{"type":"assistant","message":{"content":[{"type":"text","text":"recovered"}]}}'
`);
}, 20000);
test('sequential queue drain', async () => {
await resetState();
// Restore working mock
writeMockClaude(`#!/bin/bash
echo '{"type":"assistant","message":{"content":[{"type":"text","text":"response to: '"'"'$*'"'"'"}]}}'
`);
// Send two messages rapidly — first processes, second queues
await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({ message: 'first message' }),
});
await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({ message: 'second message' }),
});
// Wait for both to complete (two agent_done events)
const entries = await pollChatUntil(
(entries) => entries.filter((e: any) => e.type === 'agent_done').length >= 2,
20000,
);
// Both user messages should be in chat
const userEntries = entries.filter((e: any) => e.role === 'user');
expect(userEntries.length).toBeGreaterThanOrEqual(2);
}, 25000);
});
+320
View File
@@ -0,0 +1,320 @@
/**
* 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<Response> {
const { noAuth, ...fetchOpts } = opts;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(fetchOpts.headers as Record<string, string> || {}),
};
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]);
expect(entry.pageUrl).toBe('https://example.com/test-page');
expect(entry.prompt).toContain('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 mock agent events using Claude's streaming format
await api('/sidebar-agent/event', {
method: 'POST',
body: JSON.stringify({
type: 'assistant',
message: { content: [{ 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');
});
});
+96
View File
@@ -0,0 +1,96 @@
/**
* Layer 1: Unit tests for sidebar utilities.
* Tests pure functions — no server, no processes, no network.
*/
import { describe, test, expect } from 'bun:test';
import { sanitizeExtensionUrl } from '../src/sidebar-utils';
describe('sanitizeExtensionUrl', () => {
test('passes valid http URL', () => {
expect(sanitizeExtensionUrl('http://example.com')).toBe('http://example.com/');
});
test('passes valid https URL', () => {
expect(sanitizeExtensionUrl('https://example.com/page?q=1')).toBe('https://example.com/page?q=1');
});
test('rejects chrome:// URLs', () => {
expect(sanitizeExtensionUrl('chrome://extensions')).toBeNull();
});
test('rejects chrome-extension:// URLs', () => {
expect(sanitizeExtensionUrl('chrome-extension://abcdef/popup.html')).toBeNull();
});
test('rejects javascript: URLs', () => {
expect(sanitizeExtensionUrl('javascript:alert(1)')).toBeNull();
});
test('rejects file:// URLs', () => {
expect(sanitizeExtensionUrl('file:///etc/passwd')).toBeNull();
});
test('rejects data: URLs', () => {
expect(sanitizeExtensionUrl('data:text/html,<h1>hi</h1>')).toBeNull();
});
test('strips raw control characters from URL', () => {
// URL constructor percent-encodes \x00 as %00, which is safe
// The regex strips any remaining raw control chars after .href normalization
const result = sanitizeExtensionUrl('https://example.com/\x00page\x1f');
expect(result).not.toBeNull();
expect(result!).not.toMatch(/[\x00-\x1f\x7f]/);
});
test('strips newlines (prompt injection vector)', () => {
const result = sanitizeExtensionUrl('https://evil.com/%0AUser:%20ignore');
// URL constructor normalizes %0A, control char stripping removes any raw newlines
expect(result).not.toBeNull();
expect(result!).not.toContain('\n');
});
test('truncates URLs longer than 2048 chars', () => {
const longUrl = 'https://example.com/' + 'a'.repeat(3000);
const result = sanitizeExtensionUrl(longUrl);
expect(result).not.toBeNull();
expect(result!.length).toBeLessThanOrEqual(2048);
});
test('returns null for null input', () => {
expect(sanitizeExtensionUrl(null)).toBeNull();
});
test('returns null for undefined input', () => {
expect(sanitizeExtensionUrl(undefined)).toBeNull();
});
test('returns null for empty string', () => {
expect(sanitizeExtensionUrl('')).toBeNull();
});
test('returns null for invalid URL string', () => {
expect(sanitizeExtensionUrl('not a url at all')).toBeNull();
});
test('does not crash on weird input', () => {
expect(sanitizeExtensionUrl(':///')).toBeNull();
expect(sanitizeExtensionUrl(' ')).toBeNull();
expect(sanitizeExtensionUrl('\x00\x01\x02')).toBeNull();
});
test('preserves query parameters and fragments', () => {
const url = 'https://example.com/search?q=test&page=2#results';
expect(sanitizeExtensionUrl(url)).toBe(url);
});
test('preserves port numbers', () => {
expect(sanitizeExtensionUrl('http://localhost:3000/api')).toBe('http://localhost:3000/api');
});
test('handles URL with auth (user:pass@host)', () => {
const result = sanitizeExtensionUrl('https://user:pass@example.com/');
expect(result).not.toBeNull();
expect(result).toContain('example.com');
});
});