chore: checkpoint before DAG views UX overhaul
This commit is contained in:
parent
5695125a75
commit
a03def1ca1
125 changed files with 40711 additions and 581 deletions
165
tests/lib/realtime-activity-sse.test.ts
Normal file
165
tests/lib/realtime-activity-sse.test.ts
Normal 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');
|
||||
});
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue