chore: checkpoint before DAG views UX overhaul

This commit is contained in:
zenchantlive 2026-02-22 20:43:59 -08:00
parent 5695125a75
commit a03def1ca1
125 changed files with 40711 additions and 581 deletions

View file

@ -0,0 +1,165 @@
/**
* REGRESSION TEST: ActivityPanel SSE Data Parsing
*
* Bug: ActivityPanel was checking `data?.event` but SSE sends activity event directly.
* The toActivitySseFrame function sends `data: ${JSON.stringify(event.event)}` which
* means the parsed data IS the activity event, not wrapped in { event: {...} }.
*
* This test ensures:
* 1. toActivitySseFrame produces the correct format
* 2. ActivityPanel parsing logic matches the SSE frame format
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { ActivityEventBus, toActivitySseFrame } from '../../src/lib/realtime';
import type { ActivityEvent } from '../../src/lib/activity';
function createTestActivityEvent(overrides: Partial<ActivityEvent> = {}): ActivityEvent {
return {
id: 'test-123',
kind: 'created',
beadId: 'bead-001',
beadTitle: 'Test Bead',
projectId: 'C:/test/project',
projectName: 'test-project',
timestamp: new Date().toISOString(),
actor: 'test-user',
payload: {},
...overrides,
};
}
test('toActivitySseFrame sends activity event directly (not wrapped)', () => {
const activityEvent = createTestActivityEvent();
const frame = toActivitySseFrame({ id: 1, event: activityEvent });
// Frame should have correct event type
assert.ok(frame.includes('event: activity'), 'Frame should have event: activity');
// Frame should have correct ID
assert.ok(frame.includes('id: 1'), 'Frame should have correct SSE ID');
// Parse the data field to verify structure
const dataMatch = frame.match(/data: (.+)\n\n/);
assert.ok(dataMatch, 'Frame should have data field');
const parsedData = JSON.parse(dataMatch![1]);
// CRITICAL: The parsed data IS the activity event directly
// NOT wrapped in { event: {...} }
assert.equal(parsedData.id, 'test-123', 'Parsed data should be the activity event itself');
assert.equal(parsedData.kind, 'created', 'Parsed data should have kind');
assert.equal(parsedData.beadId, 'bead-001', 'Parsed data should have beadId');
assert.equal(parsedData.beadTitle, 'Test Bead', 'Parsed data should have beadTitle');
// CRITICAL: parsedData.event should be UNDEFINED
assert.equal(parsedData.event, undefined, 'parsedData.event should NOT exist');
});
test('ActivityPanel SSE handler should check data.beadId not data.event', () => {
// Simulate what ActivityPanel receives
const sseFrame = toActivitySseFrame({
id: 1,
event: createTestActivityEvent({
id: 'act-001',
kind: 'closed',
beadId: 'bead-002',
beadTitle: 'Closed Bead',
payload: { from: 'open', to: 'closed', message: 'Done' },
}),
});
// Simulate ActivityPanel's parsing logic (CORRECT version)
const dataMatch = sseFrame.match(/data: (.+)\n\n/);
assert.ok(dataMatch, 'Frame should have data field');
const data = JSON.parse(dataMatch![1]);
// OLD BUG: if (data?.event) { ... } - this would be FALSE
const oldBuggyCheck = data?.event;
assert.equal(oldBuggyCheck, undefined, 'Old buggy check data?.event should be undefined');
// CORRECT: Check for activity event properties directly
const correctCheck = data?.beadId;
assert.ok(correctCheck, 'Correct check data?.beadId should exist');
// Verify the event can be used directly
assert.equal(data.kind, 'closed', 'Should be able to access event properties directly');
});
test('ActivityEventBus emits and serializes correctly', () => {
const bus = new ActivityEventBus();
let receivedFrame: string | null = null;
const unsubscribe = bus.subscribe((dispatched) => {
receivedFrame = toActivitySseFrame(dispatched);
});
bus.emit(createTestActivityEvent({
id: 'emit-test',
kind: 'status_changed',
beadId: 'bead-003',
beadTitle: 'Status Changed',
payload: { from: 'open', to: 'in_progress' },
}));
unsubscribe();
assert.ok(receivedFrame, 'Should have received frame');
// Parse and verify
const frame = receivedFrame as string;
const dataMatch = frame.match(/data: (.+)\n\n/);
assert.ok(dataMatch, 'Frame should have data field');
const parsedData = JSON.parse(dataMatch![1]);
assert.equal(parsedData.kind, 'status_changed', 'Should have correct kind');
assert.equal(parsedData.beadId, 'bead-003', 'Should have correct beadId');
assert.equal(parsedData.event, undefined, 'Should NOT have nested event property');
});
test('REGRESSION: ActivityPanel must NOT use data.event pattern', () => {
/**
* This test documents the exact bug that was fixed:
*
* BEFORE (BUG):
* const data = JSON.parse(event.data);
* if (data?.event) {
* setActivities(prev => [data.event, ...prev]);
* }
*
* AFTER (FIX):
* const data = JSON.parse(event.data);
* if (data?.beadId) {
* setActivities(prev => [data, ...prev]);
* }
*
* The bug caused ActivityPanel to never update because data.event
* was always undefined (the event IS the data, not nested).
*/
const sampleSseData = JSON.stringify(createTestActivityEvent({
id: 'regression-test',
kind: 'comment_added',
beadId: 'bead-999',
beadTitle: 'Regression Test Bead',
actor: 'test-user',
payload: { message: 'Test comment' },
}));
const data = JSON.parse(sampleSseData);
// Document the bug: data.event does NOT exist
assert.equal(data.event, undefined, 'BUG: data.event is undefined');
// Document the fix: check data.beadId instead
assert.ok(data.beadId, 'FIX: data.beadId exists');
// The activity event to add is data itself, not data.event
const activityToAdd = data; // NOT data.event
assert.equal(activityToAdd.kind, 'comment_added', 'Activity is data directly');
});

View file

@ -22,9 +22,11 @@ test('IssuesWatchManager startWatch is idempotent per project', async () => {
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');
// Initialize bd in temp dir
execSync('bd init --prefix bb --force', { cwd: root, stdio: 'ignore' });
const bus = new IssuesEventBus();
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 40 });
@ -36,21 +38,37 @@ test('IssuesWatchManager emits event after file change in watched .beads path',
await manager.startWatch(root);
await fs.writeFile(issuesPath, `${JSON.stringify({ id: 'bb-1', title: 'watch' })}\n`, 'utf8');
await new Promise((resolve) => setTimeout(resolve, 220));
// Wait for initial read to settle
await new Promise((resolve) => setTimeout(resolve, 100));
// Create issue via bd to trigger a valid mutation
execSync('bd create "Task watch" --id bb-1', { cwd: root, stdio: 'ignore' });
let found = false;
for (let i = 0; i < 10; i++) {
await new Promise((resolve) => setTimeout(resolve, 200));
if (events.length >= 1) {
found = true;
break;
}
}
stop();
await manager.stopAll();
assert.equal(events.length >= 1, true);
assert.equal(found, true, 'Expected event from file change');
});
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');
// Initialize bd to create valid db
execSync('bd init --prefix bb --force', { cwd: root, stdio: 'ignore' });
execSync('bd create "Task A" --id bb-1', { cwd: root, stdio: 'ignore' });
const bus = new IssuesEventBus();
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 40 });
@ -62,8 +80,18 @@ test('IssuesWatchManager emits telemetry event after beads.db change (not issues
await manager.startWatch(root);
await fs.writeFile(dbPath, `seed-${Date.now()}`, 'utf8');
await new Promise((resolve) => setTimeout(resolve, 220));
// Wait for initial read to settle
await new Promise((resolve) => setTimeout(resolve, 100));
// Touch beads.db directly without mutating issues to simulate a connection write/telemetry pulse
await fs.appendFile(dbPath, ' ', 'utf8');
for (let i = 0; i < 10; i++) {
await new Promise((resolve) => setTimeout(resolve, 200));
if (events.length >= 1) {
break;
}
}
stop();
await manager.stopAll();
@ -81,9 +109,14 @@ test('IssuesWatchManager emits telemetry event after beads.db change (not issues
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');
// 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' });
const bus = new IssuesEventBus();
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 40 });
@ -95,21 +128,33 @@ test('IssuesWatchManager emits event after beads.db-wal change', async () => {
await manager.startWatch(root);
await fs.writeFile(walPath, `seed-${Date.now()}`, 'utf8');
await new Promise((resolve) => setTimeout(resolve, 220));
// Wait for initial read to settle
await new Promise((resolve) => setTimeout(resolve, 100));
// Modify issue via bd: status change. This updates beads.db-wal
execSync('bd update bb-1 --status in_progress', { cwd: root, stdio: 'ignore' });
let found = false;
for (let i = 0; i < 10; i++) {
await new Promise((resolve) => setTimeout(resolve, 200));
if (events.length >= 1) {
found = true;
break;
}
}
stop();
await manager.stopAll();
assert.equal(events.length >= 1, true);
assert.equal(found, true, 'Expected event from db-wal change');
});
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' });
@ -119,10 +164,10 @@ test('IssuesWatchManager emits ActivityEvent on issue change', async () => {
const issuesBus = new IssuesEventBus();
const activityBus = new ActivityEventBus();
const manager = new IssuesWatchManager({
eventBus: issuesBus,
activityBus,
debounceMs: 50
const manager = new IssuesWatchManager({
eventBus: issuesBus,
activityBus,
debounceMs: 50
});
const activities: string[] = [];
@ -132,7 +177,7 @@ test('IssuesWatchManager emits ActivityEvent on issue change', async () => {
// Start watching (should load initial snapshot silently)
await manager.startWatch(root);
// Wait for initial read to settle
await new Promise((resolve) => setTimeout(resolve, 100));