Add realtime watcher+SSE transport with tests and lock-retry read path
This commit is contained in:
parent
cc616c1543
commit
3f2ae384f5
15 changed files with 727 additions and 75 deletions
34
tests/api/events-route.test.ts
Normal file
34
tests/api/events-route.test.ts
Normal 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();
|
||||
});
|
||||
33
tests/lib/coalescer.test.ts
Normal file
33
tests/lib/coalescer.test.ts
Normal 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);
|
||||
});
|
||||
27
tests/lib/read-text-retry.test.ts
Normal file
27
tests/lib/read-text-retry.test.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
});
|
||||
46
tests/lib/realtime.test.ts
Normal file
46
tests/lib/realtime.test.ts
Normal 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
45
tests/lib/watcher.test.ts
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue