Add realtime watcher+SSE transport with tests and lock-retry read path

This commit is contained in:
zenchantlive 2026-02-11 21:05:27 -08:00
parent cc616c1543
commit 3f2ae384f5
15 changed files with 727 additions and 75 deletions

View file

@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { GET as eventsGet } from '../../src/app/api/events/route';
import { getIssuesWatchManager } from '../../src/lib/watcher';
test.after(async () => {
await getIssuesWatchManager().stopAll();
});
test('events route returns SSE response with expected headers', async () => {
const response = await eventsGet(new Request('http://localhost/api/events?projectRoot=C:/Repo/Test'));
assert.equal(response.status, 200);
assert.equal(response.headers.get('content-type')?.includes('text/event-stream'), true);
assert.equal(response.headers.get('cache-control')?.includes('no-cache'), true);
const reader = response.body?.getReader();
if (reader) {
await reader.cancel();
}
});
test('events route emits initial connected frame', async () => {
const response = await eventsGet(new Request('http://localhost/api/events?projectRoot=C:/Repo/Test'));
const reader = response.body?.getReader();
assert.equal(Boolean(reader), true);
const first = await reader!.read();
const chunk = new TextDecoder().decode(first.value);
assert.equal(chunk.includes(': connected'), true);
await reader!.cancel();
});

View file

@ -0,0 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ProjectEventCoalescer } from '../../src/lib/coalescer';
test('coalescer emits latest payload once per project within debounce window', async () => {
const flushed: Array<{ projectRoot: string; payload: { value: string } }> = [];
const coalescer = new ProjectEventCoalescer<{ value: string }>(20, (event) => {
flushed.push(event);
});
coalescer.queue('C:/Repo/One', { value: 'first' });
coalescer.queue('c:\\repo\\one', { value: 'second' });
await new Promise((resolve) => setTimeout(resolve, 45));
assert.equal(flushed.length, 1);
assert.equal(flushed[0].payload.value, 'second');
});
test('coalescer keeps distinct projects separated', async () => {
const flushed: Array<{ projectRoot: string; payload: { value: string } }> = [];
const coalescer = new ProjectEventCoalescer<{ value: string }>(20, (event) => {
flushed.push(event);
});
coalescer.queue('C:/Repo/One', { value: 'one' });
coalescer.queue('D:/Repo/Two', { value: 'two' });
await new Promise((resolve) => setTimeout(resolve, 45));
assert.equal(flushed.length, 2);
});

View file

@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { readTextFileWithRetry } from '../../src/lib/read-text-retry';
test('readTextFileWithRetry reads file content', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-retry-read-'));
const target = path.join(root, 'sample.txt');
await fs.writeFile(target, 'ok', 'utf8');
const content = await readTextFileWithRetry(target);
assert.equal(content, 'ok');
});
test('readTextFileWithRetry does not retry non-retryable errors', async () => {
await assert.rejects(
() => readTextFileWithRetry('C:/definitely/missing/file.txt', { retries: 3, delayMs: 1 }),
(error: unknown) => {
const code = (error as NodeJS.ErrnoException).code;
assert.equal(code, 'ENOENT');
return true;
},
);
});

View file

@ -0,0 +1,46 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { IssuesEventBus, toSseFrame } from '../../src/lib/realtime';
test('IssuesEventBus emits monotonically increasing IDs', () => {
const bus = new IssuesEventBus();
const seen: number[] = [];
const unsubscribe = bus.subscribe((event) => seen.push(event.id));
bus.emit('C:/Repo/One');
bus.emit('C:/Repo/One');
unsubscribe();
assert.deepEqual(seen, [1, 2]);
});
test('IssuesEventBus filters by project root', () => {
const bus = new IssuesEventBus();
const one: number[] = [];
const two: number[] = [];
const stopOne = bus.subscribe((event) => one.push(event.id), { projectRoot: 'C:/Repo/One' });
const stopTwo = bus.subscribe((event) => two.push(event.id), { projectRoot: 'D:/Repo/Two' });
bus.emit('c:\\repo\\one');
bus.emit('D:/Repo/Two');
stopOne();
stopTwo();
assert.deepEqual(one, [1]);
assert.deepEqual(two, [2]);
});
test('toSseFrame includes id, event name, and data payload', () => {
const frame = toSseFrame({
id: 9,
projectRoot: 'C:\\Repo\\One',
kind: 'changed',
at: '2026-02-12T01:00:00.000Z',
});
assert.equal(frame.includes('id: 9'), true);
assert.equal(frame.includes('event: issues'), true);
assert.equal(frame.includes('"projectRoot":"C:\\\\Repo\\\\One"'), true);
});

45
tests/lib/watcher.test.ts Normal file
View file

@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { IssuesEventBus } from '../../src/lib/realtime';
import { IssuesWatchManager } from '../../src/lib/watcher';
test('IssuesWatchManager startWatch is idempotent per project', async () => {
const bus = new IssuesEventBus();
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 20 });
manager.startWatch('C:/Repo/One');
manager.startWatch('c:\\repo\\one');
assert.equal(manager.getWatchedProjectCount(), 1);
await manager.stopAll();
});
test('IssuesWatchManager emits event after file change in watched .beads path', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-'));
const beadsDir = path.join(root, '.beads');
const issuesPath = path.join(beadsDir, 'issues.jsonl');
await fs.mkdir(beadsDir, { recursive: true });
await fs.writeFile(issuesPath, '', 'utf8');
const bus = new IssuesEventBus();
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 40 });
const events: string[] = [];
const stop = bus.subscribe((event) => {
events.push(event.projectRoot);
});
manager.startWatch(root);
await fs.writeFile(issuesPath, `${JSON.stringify({ id: 'bb-1', title: 'watch' })}\n`, 'utf8');
await new Promise((resolve) => setTimeout(resolve, 220));
stop();
await manager.stopAll();
assert.equal(events.length >= 1, true);
});