2026-02-11 21:05:27 -08:00
import test from 'node:test' ;
import assert from 'node:assert/strict' ;
import fs from 'node:fs/promises' ;
import path from 'node:path' ;
2026-02-15 21:14:05 -08:00
import os from 'node:os' ;
import { execSync } from 'node:child_process' ;
2026-02-11 21:05:27 -08:00
2026-02-14 00:21:25 -08:00
import { IssuesEventBus , ActivityEventBus } from '../../src/lib/realtime' ;
2026-02-11 21:05:27 -08:00
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 } ) ;
2026-02-14 00:21:25 -08:00
await manager . startWatch ( 'C:/Repo/One' ) ;
await manager . startWatch ( 'c:\\repo\\one' ) ;
2026-02-11 21:05:27 -08:00
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' ) ;
2026-02-22 20:43:59 -08:00
2026-02-11 21:05:27 -08:00
await fs . mkdir ( beadsDir , { recursive : true } ) ;
2026-02-22 20:43:59 -08:00
// Initialize bd in temp dir
execSync ( 'bd init --prefix bb --force' , { cwd : root , stdio : 'ignore' } ) ;
2026-02-11 21:05:27 -08:00
const bus = new IssuesEventBus ( ) ;
const manager = new IssuesWatchManager ( { eventBus : bus , debounceMs : 40 } ) ;
const events : string [ ] = [ ] ;
const stop = bus . subscribe ( ( event ) = > {
events . push ( event . projectRoot ) ;
} ) ;
2026-02-14 00:21:25 -08:00
await manager . startWatch ( root ) ;
2026-02-11 21:05:27 -08:00
2026-02-22 20:43:59 -08:00
// 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 ;
}
}
2026-02-11 21:05:27 -08:00
stop ( ) ;
await manager . stopAll ( ) ;
2026-02-22 20:43:59 -08:00
assert . equal ( found , true , 'Expected event from file change' ) ;
2026-02-11 21:05:27 -08:00
} ) ;
2026-02-14 00:21:25 -08:00
2026-02-15 21:14:05 -08:00
test ( 'IssuesWatchManager emits telemetry event after beads.db change (not issues)' , async ( ) = > {
2026-02-14 00:21:25 -08:00
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' ) ;
2026-02-22 20:43:59 -08:00
2026-02-14 00:21:25 -08:00
await fs . mkdir ( beadsDir , { recursive : true } ) ;
2026-02-22 20:43:59 -08:00
// 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' } ) ;
2026-02-14 00:21:25 -08:00
const bus = new IssuesEventBus ( ) ;
const manager = new IssuesWatchManager ( { eventBus : bus , debounceMs : 40 } ) ;
2026-02-15 21:14:05 -08:00
const events : Array < { kind : string ; changedPath? : string } > = [ ] ;
2026-02-14 00:21:25 -08:00
const stop = bus . subscribe ( ( event ) = > {
2026-02-15 21:14:05 -08:00
events . push ( { kind : event.kind , changedPath : event.changedPath } ) ;
2026-02-14 00:21:25 -08:00
} ) ;
await manager . startWatch ( root ) ;
2026-02-22 20:43:59 -08:00
// 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 ;
}
}
2026-02-14 00:21:25 -08:00
stop ( ) ;
await manager . stopAll ( ) ;
2026-02-15 21:14:05 -08:00
// 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. ` ) ;
}
2026-02-14 00:21:25 -08:00
} ) ;
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' ) ;
2026-02-22 20:43:59 -08:00
2026-02-14 00:21:25 -08:00
await fs . mkdir ( beadsDir , { recursive : true } ) ;
2026-02-22 20:43:59 -08:00
// 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' } ) ;
2026-02-14 00:21:25 -08:00
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 ) ;
2026-02-22 20:43:59 -08:00
// 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 ;
}
}
2026-02-14 00:21:25 -08:00
stop ( ) ;
await manager . stopAll ( ) ;
2026-02-22 20:43:59 -08:00
assert . equal ( found , true , 'Expected event from db-wal change' ) ;
2026-02-14 00:21:25 -08:00
} ) ;
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' ) ;
2026-02-22 20:43:59 -08:00
2026-02-14 00:21:25 -08:00
await fs . mkdir ( beadsDir , { recursive : true } ) ;
2026-02-22 20:43:59 -08:00
2026-02-15 21:14:05 -08:00
// 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' } ) ;
2026-02-14 00:21:25 -08:00
const issuesBus = new IssuesEventBus ( ) ;
const activityBus = new ActivityEventBus ( ) ;
2026-02-22 20:43:59 -08:00
const manager = new IssuesWatchManager ( {
eventBus : issuesBus ,
activityBus ,
debounceMs : 50
2026-02-14 00:21:25 -08:00
} ) ;
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 ) ;
2026-02-22 20:43:59 -08:00
2026-02-14 00:21:25 -08:00
// Wait for initial read to settle
await new Promise ( ( resolve ) = > setTimeout ( resolve , 100 ) ) ;
2026-02-15 21:14:05 -08:00
// Modify issue via bd: status change
execSync ( 'bd update bb-1 --status in_progress' , { cwd : root , stdio : 'ignore' } ) ;
2026-02-14 00:21:25 -08:00
2026-02-15 21:14:05 -08:00
// 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 ;
}
}
2026-02-14 00:21:25 -08:00
stop ( ) ;
await manager . stopAll ( ) ;
// Expect status_changed for bb-1
2026-02-15 21:14:05 -08:00
if ( ! found ) {
console . error ( 'WATCHER FAIL. Activities found:' , JSON . stringify ( activities , null , 2 ) ) ;
}
assert . ok ( found , ` Expected status_changed event. Got: ${ activities . join ( ', ' ) } ` ) ;
2026-02-14 00:21:25 -08:00
} ) ;