From 82708f140558620b8e40b39e07e3907c96128bf9 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 22 Mar 2026 21:39:41 -0700 Subject: [PATCH] test: add coverage for sidebar-agent, file-drop, and watch mode 33 new tests covering: - Sidebar agent queue parsing (valid/malformed/empty JSONL) - writeToInbox file drop (directory creation, atomic writes, JSON format) - Inbox command (display, sorting, --clear, malformed file handling) - Watch mode state machine (start/stop cycles, snapshots, duration) Co-Authored-By: Claude Opus 4.6 (1M context) --- browse/test/file-drop.test.ts | 271 ++++++++++++++++++++++++++++++ browse/test/sidebar-agent.test.ts | 199 ++++++++++++++++++++++ browse/test/watch.test.ts | 129 ++++++++++++++ 3 files changed, 599 insertions(+) create mode 100644 browse/test/file-drop.test.ts create mode 100644 browse/test/sidebar-agent.test.ts create mode 100644 browse/test/watch.test.ts diff --git a/browse/test/file-drop.test.ts b/browse/test/file-drop.test.ts new file mode 100644 index 00000000..b2b17905 --- /dev/null +++ b/browse/test/file-drop.test.ts @@ -0,0 +1,271 @@ +/** + * Tests for the inbox meta-command handler (file drop relay). + * + * Tests the inbox display, --clear flag, and edge cases by creating + * temp directories with test JSON files and calling handleMetaCommand. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { handleMetaCommand } from '../src/meta-commands'; +import { BrowserManager } from '../src/browser-manager'; + +let tmpDir: string; +let bm: BrowserManager; + +// We need a BrowserManager instance for handleMetaCommand, but inbox +// doesn't use it. We also need to mock git rev-parse to point to our +// temp directory. We'll test the inbox logic directly by manipulating +// the filesystem and using child_process.execSync override. + +// ─── Direct filesystem tests (bypassing handleMetaCommand) ────── +// The inbox handler in meta-commands.ts calls `git rev-parse --show-toplevel` +// to find the inbox directory. Since we can't easily mock that in unit tests, +// we test the inbox parsing logic directly. + +interface InboxMessage { + timestamp: string; + url: string; + userMessage: string; +} + +/** Replicate the inbox file reading logic from meta-commands.ts */ +function readInbox(inboxDir: string): InboxMessage[] { + if (!fs.existsSync(inboxDir)) return []; + + const files = fs.readdirSync(inboxDir) + .filter(f => f.endsWith('.json') && !f.startsWith('.')) + .sort() + .reverse(); + + if (files.length === 0) return []; + + const messages: InboxMessage[] = []; + for (const file of files) { + try { + const data = JSON.parse(fs.readFileSync(path.join(inboxDir, file), 'utf-8')); + messages.push({ + timestamp: data.timestamp || '', + url: data.page?.url || 'unknown', + userMessage: data.userMessage || '', + }); + } catch { + // Skip malformed files + } + } + return messages; +} + +/** Replicate the inbox formatting logic from meta-commands.ts */ +function formatInbox(messages: InboxMessage[]): string { + if (messages.length === 0) return 'Inbox empty.'; + + const lines: string[] = []; + lines.push(`SIDEBAR INBOX (${messages.length} message${messages.length === 1 ? '' : 's'})`); + lines.push('────────────────────────────────'); + + for (const msg of messages) { + const ts = msg.timestamp ? `[${msg.timestamp}]` : '[unknown]'; + lines.push(`${ts} ${msg.url}`); + lines.push(` "${msg.userMessage}"`); + lines.push(''); + } + + lines.push('────────────────────────────────'); + return lines.join('\n'); +} + +/** Replicate the --clear logic from meta-commands.ts */ +function clearInbox(inboxDir: string): number { + const files = fs.readdirSync(inboxDir) + .filter(f => f.endsWith('.json') && !f.startsWith('.')); + for (const file of files) { + try { fs.unlinkSync(path.join(inboxDir, file)); } catch {} + } + return files.length; +} + +function writeTestInboxFile( + inboxDir: string, + message: string, + pageUrl: string, + timestamp: string, +): string { + fs.mkdirSync(inboxDir, { recursive: true }); + const filename = `${timestamp.replace(/:/g, '-')}-observation.json`; + const filePath = path.join(inboxDir, filename); + fs.writeFileSync(filePath, JSON.stringify({ + type: 'observation', + timestamp, + page: { url: pageUrl, title: '' }, + userMessage: message, + sidebarSessionId: 'test-session', + }, null, 2)); + return filePath; +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'file-drop-test-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ─── Empty Inbox ───────────────────────────────────────────────── + +describe('inbox — empty states', () => { + test('no .context/sidebar-inbox directory returns empty', () => { + const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox'); + const messages = readInbox(inboxDir); + expect(messages.length).toBe(0); + expect(formatInbox(messages)).toBe('Inbox empty.'); + }); + + test('empty inbox directory returns empty', () => { + const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox'); + fs.mkdirSync(inboxDir, { recursive: true }); + const messages = readInbox(inboxDir); + expect(messages.length).toBe(0); + expect(formatInbox(messages)).toBe('Inbox empty.'); + }); + + test('directory with only dotfiles returns empty', () => { + const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox'); + fs.mkdirSync(inboxDir, { recursive: true }); + fs.writeFileSync(path.join(inboxDir, '.tmp-file.json'), '{}'); + const messages = readInbox(inboxDir); + expect(messages.length).toBe(0); + }); +}); + +// ─── Valid Messages ────────────────────────────────────────────── + +describe('inbox — valid messages', () => { + test('displays formatted output with timestamps and URLs', () => { + const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox'); + writeTestInboxFile(inboxDir, 'This button is broken', 'https://example.com/page', '2024-06-15T10:30:00.000Z'); + writeTestInboxFile(inboxDir, 'Login form fails', 'https://example.com/login', '2024-06-15T10:31:00.000Z'); + + const messages = readInbox(inboxDir); + expect(messages.length).toBe(2); + + const output = formatInbox(messages); + expect(output).toContain('SIDEBAR INBOX (2 messages)'); + expect(output).toContain('https://example.com/page'); + expect(output).toContain('https://example.com/login'); + expect(output).toContain('"This button is broken"'); + expect(output).toContain('"Login form fails"'); + expect(output).toContain('[2024-06-15T10:30:00.000Z]'); + expect(output).toContain('[2024-06-15T10:31:00.000Z]'); + }); + + test('single message uses singular form', () => { + const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox'); + writeTestInboxFile(inboxDir, 'Just one', 'https://example.com', '2024-06-15T10:30:00.000Z'); + + const messages = readInbox(inboxDir); + const output = formatInbox(messages); + expect(output).toContain('1 message)'); + expect(output).not.toContain('messages)'); + }); + + test('messages sorted newest first', () => { + const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox'); + writeTestInboxFile(inboxDir, 'older', 'https://example.com', '2024-06-15T10:00:00.000Z'); + writeTestInboxFile(inboxDir, 'newer', 'https://example.com', '2024-06-15T11:00:00.000Z'); + + const messages = readInbox(inboxDir); + // Filenames sort lexicographically, reversed = newest first + expect(messages[0].userMessage).toBe('newer'); + expect(messages[1].userMessage).toBe('older'); + }); +}); + +// ─── Malformed Files ───────────────────────────────────────────── + +describe('inbox — malformed files', () => { + test('malformed JSON files are skipped gracefully', () => { + const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox'); + fs.mkdirSync(inboxDir, { recursive: true }); + + // Write a valid message + writeTestInboxFile(inboxDir, 'valid message', 'https://example.com', '2024-06-15T10:30:00.000Z'); + + // Write a malformed JSON file + fs.writeFileSync( + path.join(inboxDir, '2024-06-15T10-35-00.000Z-observation.json'), + 'this is not valid json {{{', + ); + + const messages = readInbox(inboxDir); + expect(messages.length).toBe(1); + expect(messages[0].userMessage).toBe('valid message'); + }); + + test('JSON file missing fields uses defaults', () => { + const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox'); + fs.mkdirSync(inboxDir, { recursive: true }); + + // Write a JSON file with missing fields + fs.writeFileSync( + path.join(inboxDir, '2024-06-15T10-30-00.000Z-observation.json'), + JSON.stringify({ type: 'observation' }), + ); + + const messages = readInbox(inboxDir); + expect(messages.length).toBe(1); + expect(messages[0].timestamp).toBe(''); + expect(messages[0].url).toBe('unknown'); + expect(messages[0].userMessage).toBe(''); + }); +}); + +// ─── Clear Flag ────────────────────────────────────────────────── + +describe('inbox — --clear flag', () => { + test('files deleted after clear', () => { + const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox'); + writeTestInboxFile(inboxDir, 'message 1', 'https://example.com', '2024-06-15T10:30:00.000Z'); + writeTestInboxFile(inboxDir, 'message 2', 'https://example.com', '2024-06-15T10:31:00.000Z'); + + // Verify files exist + const filesBefore = fs.readdirSync(inboxDir).filter(f => f.endsWith('.json') && !f.startsWith('.')); + expect(filesBefore.length).toBe(2); + + // Clear + const cleared = clearInbox(inboxDir); + expect(cleared).toBe(2); + + // Verify files deleted + const filesAfter = fs.readdirSync(inboxDir).filter(f => f.endsWith('.json') && !f.startsWith('.')); + expect(filesAfter.length).toBe(0); + }); + + test('clear on empty directory does nothing', () => { + const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox'); + fs.mkdirSync(inboxDir, { recursive: true }); + + const cleared = clearInbox(inboxDir); + expect(cleared).toBe(0); + }); + + test('clear preserves dotfiles', () => { + const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox'); + fs.mkdirSync(inboxDir, { recursive: true }); + + // Write a dotfile and a regular file + fs.writeFileSync(path.join(inboxDir, '.keep'), ''); + writeTestInboxFile(inboxDir, 'to be cleared', 'https://example.com', '2024-06-15T10:30:00.000Z'); + + clearInbox(inboxDir); + + // Dotfile should remain + expect(fs.existsSync(path.join(inboxDir, '.keep'))).toBe(true); + // Regular file should be gone + const jsonFiles = fs.readdirSync(inboxDir).filter(f => f.endsWith('.json') && !f.startsWith('.')); + expect(jsonFiles.length).toBe(0); + }); +}); diff --git a/browse/test/sidebar-agent.test.ts b/browse/test/sidebar-agent.test.ts new file mode 100644 index 00000000..2c8d49e9 --- /dev/null +++ b/browse/test/sidebar-agent.test.ts @@ -0,0 +1,199 @@ +/** + * Tests for sidebar agent queue parsing and inbox writing. + * + * sidebar-agent.ts functions are not exported (it's an entry-point script), + * so we test the same logic inline: JSONL parsing, writeToInbox filesystem + * behavior, and edge cases. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// ─── Helpers: replicate sidebar-agent logic for unit testing ────── + +/** Parse a single JSONL line — same logic as sidebar-agent poll() */ +function parseQueueLine(line: string): any | null { + if (!line.trim()) return null; + try { + const entry = JSON.parse(line); + if (!entry.message && !entry.prompt) return null; + return entry; + } catch { + return null; + } +} + +/** Read all valid entries from a JSONL string — same as countLines + readLine loop */ +function parseQueueFile(content: string): any[] { + const entries: any[] = []; + const lines = content.split('\n').filter(Boolean); + for (const line of lines) { + const entry = parseQueueLine(line); + if (entry) entries.push(entry); + } + return entries; +} + +/** Write to inbox — extracted logic from sidebar-agent.ts writeToInbox() */ +function writeToInbox( + gitRoot: string, + message: string, + pageUrl?: string, + sessionId?: string, +): string | null { + if (!gitRoot) return null; + + const inboxDir = path.join(gitRoot, '.context', 'sidebar-inbox'); + fs.mkdirSync(inboxDir, { recursive: true }); + + const now = new Date(); + const timestamp = now.toISOString().replace(/:/g, '-'); + const filename = `${timestamp}-observation.json`; + const tmpFile = path.join(inboxDir, `.${filename}.tmp`); + const finalFile = path.join(inboxDir, filename); + + const inboxMessage = { + type: 'observation', + timestamp: now.toISOString(), + page: { url: pageUrl || 'unknown', title: '' }, + userMessage: message, + sidebarSessionId: sessionId || 'unknown', + }; + + fs.writeFileSync(tmpFile, JSON.stringify(inboxMessage, null, 2)); + fs.renameSync(tmpFile, finalFile); + return finalFile; +} + +// ─── Test setup ────────────────────────────────────────────────── + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sidebar-agent-test-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ─── Queue File Parsing ───────────────────────────────────────── + +describe('queue file parsing', () => { + test('valid JSONL line parsed correctly', () => { + const line = JSON.stringify({ message: 'hello', prompt: 'check this', pageUrl: 'https://example.com' }); + const entry = parseQueueLine(line); + expect(entry).not.toBeNull(); + expect(entry.message).toBe('hello'); + expect(entry.prompt).toBe('check this'); + expect(entry.pageUrl).toBe('https://example.com'); + }); + + test('malformed JSON line skipped without crash', () => { + const entry = parseQueueLine('this is not json {{{'); + expect(entry).toBeNull(); + }); + + test('valid JSON without message or prompt is skipped', () => { + const line = JSON.stringify({ foo: 'bar' }); + const entry = parseQueueLine(line); + expect(entry).toBeNull(); + }); + + test('empty file returns no entries', () => { + const entries = parseQueueFile(''); + expect(entries).toEqual([]); + }); + + test('file with blank lines returns no entries', () => { + const entries = parseQueueFile('\n\n\n'); + expect(entries).toEqual([]); + }); + + test('mixed valid and invalid lines', () => { + const content = [ + JSON.stringify({ message: 'first' }), + 'not json', + JSON.stringify({ unrelated: true }), + JSON.stringify({ message: 'second', prompt: 'do stuff' }), + ].join('\n'); + + const entries = parseQueueFile(content); + expect(entries.length).toBe(2); + expect(entries[0].message).toBe('first'); + expect(entries[1].message).toBe('second'); + }); +}); + +// ─── writeToInbox ──────────────────────────────────────────────── + +describe('writeToInbox', () => { + test('creates .context/sidebar-inbox/ directory', () => { + writeToInbox(tmpDir, 'test message'); + const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox'); + expect(fs.existsSync(inboxDir)).toBe(true); + expect(fs.statSync(inboxDir).isDirectory()).toBe(true); + }); + + test('writes valid JSON file', () => { + const filePath = writeToInbox(tmpDir, 'test message', 'https://example.com', 'session-123'); + expect(filePath).not.toBeNull(); + expect(fs.existsSync(filePath!)).toBe(true); + + const data = JSON.parse(fs.readFileSync(filePath!, 'utf-8')); + expect(data.type).toBe('observation'); + expect(data.userMessage).toBe('test message'); + expect(data.page.url).toBe('https://example.com'); + expect(data.sidebarSessionId).toBe('session-123'); + expect(data.timestamp).toBeTruthy(); + }); + + test('atomic write — final file exists, no .tmp left', () => { + const filePath = writeToInbox(tmpDir, 'atomic test'); + expect(filePath).not.toBeNull(); + expect(fs.existsSync(filePath!)).toBe(true); + + // Check no .tmp files remain in the inbox directory + const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox'); + const files = fs.readdirSync(inboxDir); + const tmpFiles = files.filter(f => f.endsWith('.tmp')); + expect(tmpFiles.length).toBe(0); + + // Final file should end with -observation.json + const jsonFiles = files.filter(f => f.endsWith('-observation.json') && !f.startsWith('.')); + expect(jsonFiles.length).toBe(1); + }); + + test('handles missing git root gracefully', () => { + const result = writeToInbox('', 'test'); + expect(result).toBeNull(); + }); + + test('defaults pageUrl to unknown when not provided', () => { + const filePath = writeToInbox(tmpDir, 'no url provided'); + expect(filePath).not.toBeNull(); + const data = JSON.parse(fs.readFileSync(filePath!, 'utf-8')); + expect(data.page.url).toBe('unknown'); + }); + + test('defaults sessionId to unknown when not provided', () => { + const filePath = writeToInbox(tmpDir, 'no session'); + expect(filePath).not.toBeNull(); + const data = JSON.parse(fs.readFileSync(filePath!, 'utf-8')); + expect(data.sidebarSessionId).toBe('unknown'); + }); + + test('multiple writes create separate files', () => { + writeToInbox(tmpDir, 'message 1'); + // Tiny delay to ensure different timestamps + const t = Date.now(); + while (Date.now() === t) {} // spin until next ms + writeToInbox(tmpDir, 'message 2'); + + const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox'); + const files = fs.readdirSync(inboxDir).filter(f => f.endsWith('.json') && !f.startsWith('.')); + expect(files.length).toBe(2); + }); +}); diff --git a/browse/test/watch.test.ts b/browse/test/watch.test.ts new file mode 100644 index 00000000..7e03ced7 --- /dev/null +++ b/browse/test/watch.test.ts @@ -0,0 +1,129 @@ +/** + * Tests for watch mode state machine in BrowserManager. + * + * Pure unit tests — no browser needed. Just instantiate BrowserManager + * and test the watch state methods (startWatch, stopWatch, addWatchSnapshot, + * isWatching). + */ + +import { describe, test, expect } from 'bun:test'; +import { BrowserManager } from '../src/browser-manager'; + +describe('watch mode — state machine', () => { + test('isWatching returns false by default', () => { + const bm = new BrowserManager(); + expect(bm.isWatching()).toBe(false); + }); + + test('startWatch sets isWatching to true', () => { + const bm = new BrowserManager(); + bm.startWatch(); + expect(bm.isWatching()).toBe(true); + }); + + test('stopWatch clears isWatching and returns snapshots', () => { + const bm = new BrowserManager(); + bm.startWatch(); + bm.addWatchSnapshot('snapshot-1'); + bm.addWatchSnapshot('snapshot-2'); + + const result = bm.stopWatch(); + expect(bm.isWatching()).toBe(false); + expect(result.snapshots).toEqual(['snapshot-1', 'snapshot-2']); + expect(result.snapshots.length).toBe(2); + }); + + test('stopWatch returns correct duration (approximately)', async () => { + const bm = new BrowserManager(); + bm.startWatch(); + + // Wait ~50ms to get a measurable duration + await new Promise(resolve => setTimeout(resolve, 50)); + + const result = bm.stopWatch(); + // Duration should be at least 40ms (allowing for timer imprecision) + expect(result.duration).toBeGreaterThanOrEqual(40); + // And less than 5 seconds (sanity check) + expect(result.duration).toBeLessThan(5000); + }); + + test('addWatchSnapshot stores snapshots', () => { + const bm = new BrowserManager(); + bm.startWatch(); + + bm.addWatchSnapshot('page A content'); + bm.addWatchSnapshot('page B content'); + bm.addWatchSnapshot('page C content'); + + const result = bm.stopWatch(); + expect(result.snapshots.length).toBe(3); + expect(result.snapshots[0]).toBe('page A content'); + expect(result.snapshots[1]).toBe('page B content'); + expect(result.snapshots[2]).toBe('page C content'); + }); + + test('stopWatch resets snapshots for next cycle', () => { + const bm = new BrowserManager(); + + // First cycle + bm.startWatch(); + bm.addWatchSnapshot('first-cycle-snapshot'); + const result1 = bm.stopWatch(); + expect(result1.snapshots.length).toBe(1); + + // Second cycle — should start fresh + bm.startWatch(); + const result2 = bm.stopWatch(); + expect(result2.snapshots.length).toBe(0); + }); + + test('multiple start/stop cycles work correctly', () => { + const bm = new BrowserManager(); + + // Cycle 1 + bm.startWatch(); + expect(bm.isWatching()).toBe(true); + bm.addWatchSnapshot('snap-1'); + const r1 = bm.stopWatch(); + expect(bm.isWatching()).toBe(false); + expect(r1.snapshots).toEqual(['snap-1']); + + // Cycle 2 + bm.startWatch(); + expect(bm.isWatching()).toBe(true); + bm.addWatchSnapshot('snap-2a'); + bm.addWatchSnapshot('snap-2b'); + const r2 = bm.stopWatch(); + expect(bm.isWatching()).toBe(false); + expect(r2.snapshots).toEqual(['snap-2a', 'snap-2b']); + + // Cycle 3 — no snapshots added + bm.startWatch(); + expect(bm.isWatching()).toBe(true); + const r3 = bm.stopWatch(); + expect(bm.isWatching()).toBe(false); + expect(r3.snapshots).toEqual([]); + }); + + test('stopWatch clears watchInterval if set', () => { + const bm = new BrowserManager(); + bm.startWatch(); + + // Simulate an interval being set (as the server does) + bm.watchInterval = setInterval(() => {}, 100000); + expect(bm.watchInterval).not.toBeNull(); + + bm.stopWatch(); + expect(bm.watchInterval).toBeNull(); + }); + + test('stopWatch without startWatch returns empty results', () => { + const bm = new BrowserManager(); + + // Calling stopWatch without startWatch should not throw + const result = bm.stopWatch(); + expect(result.snapshots).toEqual([]); + expect(result.duration).toBeLessThanOrEqual(Date.now()); // duration = now - 0 + expect(bm.isWatching()).toBe(false); + }); +});