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) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-22 21:39:41 -07:00
parent 3ec3cb360f
commit 82708f1405
3 changed files with 599 additions and 0 deletions
+271
View File
@@ -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);
});
});
+199
View File
@@ -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);
});
});
+129
View File
@@ -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);
});
});