mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-07 14:06:42 +02:00
82708f1405
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>
200 lines
6.8 KiB
TypeScript
200 lines
6.8 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|