beadboard/tests/lib/watcher.test.ts
zenchantlive 4ee550c333 feat(telemetry): complete bb-buff.1.3 - Backend Liveness Refactor
STORY:
The session backend needed to aggregate agent health from a live
telemetry stream rather than static bead metadata. This refactor
makes liveness signals real-time and accurate.

COLLABORATION:
We extended the ActivityEvent model with a native 'heartbeat' kind,
updated extendActivityLease() to emit through the activity bus, and
refactored getAgentLivenessMap() to prioritize heartbeat activity
history over stale bead metadata.

DELIVERABLES:
- ActivityEvent extended with 'heartbeat' kind
- extendActivityLease() emits heartbeats through activity bus
- getAgentLivenessMap() prefers telemetry over static metadata
- Registry APIs support projectRoot injection for testing
- Tests verify preference logic via TDD

VERIFICATION:
- 93/93 tests PASSING
- Heartbeat override verified in isolated temp projects

CLOSES: bb-buff.1.3
BLOCKS: bb-buff.3.2, bb-buff.3.3, bb-buff.2.1
2026-02-15 21:14:05 -08:00

160 lines
5.5 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { execSync } from 'node:child_process';
import { IssuesEventBus, ActivityEventBus } 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 });
await manager.startWatch('C:/Repo/One');
await 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);
});
await 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);
});
test('IssuesWatchManager emits telemetry event after beads.db change (not issues)', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-db-'));
const beadsDir = path.join(root, '.beads');
const dbPath = path.join(beadsDir, 'beads.db');
await fs.mkdir(beadsDir, { recursive: true });
await fs.writeFile(dbPath, 'seed', 'utf8');
const bus = new IssuesEventBus();
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 40 });
const events: Array<{ kind: string; changedPath?: string }> = [];
const stop = bus.subscribe((event) => {
events.push({ kind: event.kind, changedPath: event.changedPath });
});
await manager.startWatch(root);
await fs.writeFile(dbPath, `seed-${Date.now()}`, 'utf8');
await new Promise((resolve) => setTimeout(resolve, 220));
stop();
await manager.stopAll();
// REGRESSION: beads.db should emit 'telemetry', not 'issues'
// This prevents the "typing interrupt" refresh loop during agent heartbeats
assert.equal(events.length >= 1, true, 'Expected at least one event');
const dbEvents = events.filter(e => e.changedPath?.includes('beads.db'));
assert.ok(dbEvents.length > 0, 'Expected beads.db change event');
for (const event of dbEvents) {
assert.equal(event.kind, 'telemetry', `beads.db change should emit 'telemetry', got '${event.kind}'. This prevents refresh loops during agent heartbeats.`);
}
});
test('IssuesWatchManager emits event after beads.db-wal change', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-wal-'));
const beadsDir = path.join(root, '.beads');
const walPath = path.join(beadsDir, 'beads.db-wal');
await fs.mkdir(beadsDir, { recursive: true });
await fs.writeFile(walPath, 'seed', '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);
});
await manager.startWatch(root);
await fs.writeFile(walPath, `seed-${Date.now()}`, 'utf8');
await new Promise((resolve) => setTimeout(resolve, 220));
stop();
await manager.stopAll();
assert.equal(events.length >= 1, true);
});
test('IssuesWatchManager emits ActivityEvent on issue change', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-activity-'));
const beadsDir = path.join(root, '.beads');
await fs.mkdir(beadsDir, { recursive: true });
// Initialize bd in temp dir
execSync('bd init --prefix bb --force', { cwd: root, stdio: 'ignore' });
// Initial state: 1 issue via bd
execSync('bd create "Task A" --id bb-1', { cwd: root, stdio: 'ignore' });
execSync('bd update bb-1 --status open', { cwd: root, stdio: 'ignore' });
const issuesBus = new IssuesEventBus();
const activityBus = new ActivityEventBus();
const manager = new IssuesWatchManager({
eventBus: issuesBus,
activityBus,
debounceMs: 50
});
const activities: string[] = [];
const stop = activityBus.subscribe((e) => {
activities.push(`${e.event.kind}:${e.event.beadId}`);
});
// Start watching (should load initial snapshot silently)
await manager.startWatch(root);
// Wait for initial read to settle
await new Promise((resolve) => setTimeout(resolve, 100));
// Modify issue via bd: status change
execSync('bd update bb-1 --status in_progress', { cwd: root, stdio: 'ignore' });
// Wait for debounce + processing with retry loop
let found = false;
for (let i = 0; i < 10; i++) {
await new Promise((resolve) => setTimeout(resolve, 200));
if (activities.includes('status_changed:bb-1')) {
found = true;
break;
}
}
stop();
await manager.stopAll();
// Expect status_changed for bb-1
if (!found) {
console.error('WATCHER FAIL. Activities found:', JSON.stringify(activities, null, 2));
}
assert.ok(found, `Expected status_changed event. Got: ${activities.join(', ')}`);
});