'use strict'; /** * Integration tests for status-watcher.js * Tests JSONL file watching and event emission. */ const { describe, it, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('os'); const { StatusWatcher } = require('../../src/status-watcher'); function createTmpDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'sw-test-')); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function appendLine(file, obj) { fs.appendFileSync(file, JSON.stringify(obj) + '\n'); } describe('StatusWatcher', () => { let tmpDir; let watcher; beforeEach(() => { tmpDir = createTmpDir(); }); afterEach(() => { if (watcher) { watcher.stop(); watcher = null; } try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_e) { /* ignore */ } }); describe('session management', () => { it('addSession() registers a session', () => { watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); const file = path.join(tmpDir, 'test.jsonl'); fs.writeFileSync(file, ''); watcher.addSession('test:key', file); assert.ok(watcher.sessions.has('test:key')); assert.ok(watcher.fileToSession.has(file)); }); it('removeSession() cleans up', () => { watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); const file = path.join(tmpDir, 'test.jsonl'); fs.writeFileSync(file, ''); watcher.addSession('test:key', file); watcher.removeSession('test:key'); assert.ok(!watcher.sessions.has('test:key')); assert.ok(!watcher.fileToSession.has(file)); }); it('getSessionState() returns state', () => { watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); const file = path.join(tmpDir, 'test.jsonl'); fs.writeFileSync(file, ''); watcher.addSession('test:key', file); const state = watcher.getSessionState('test:key'); assert.ok(state); assert.equal(state.sessionKey, 'test:key'); }); }); describe('JSONL parsing', () => { it('reads existing content on addSession', () => { const file = path.join(tmpDir, 'test.jsonl'); fs.writeFileSync(file, ''); appendLine(file, { type: 'assistant', text: 'Hello world' }); appendLine(file, { type: 'tool_call', name: 'exec', id: '1' }); watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); // lastOffset: 0 explicitly reads from beginning (no lastOffset = skip to end) watcher.addSession('test:key', file, { lastOffset: 0 }); const state = watcher.getSessionState('test:key'); assert.ok(state.lines.length > 0); assert.ok(state.lines.some((l) => l.includes('exec'))); }); it('emits session-update when file changes', async () => { const file = path.join(tmpDir, 'test.jsonl'); fs.writeFileSync(file, ''); watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); watcher.start(); watcher.addSession('test:key', file); const updates = []; watcher.on('session-update', (key, state) => updates.push({ key, state })); await sleep(100); appendLine(file, { type: 'assistant', text: 'Starting task...' }); await sleep(500); assert.ok(updates.length > 0); assert.equal(updates[0].key, 'test:key'); }); it('parses tool_call and tool_result pairs', () => { const file = path.join(tmpDir, 'test.jsonl'); fs.writeFileSync(file, ''); appendLine(file, { type: 'tool_call', name: 'exec', id: '1' }); appendLine(file, { type: 'tool_result', name: 'exec', id: '1' }); watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); watcher.addSession('test:key', file, { lastOffset: 0 }); const state = watcher.getSessionState('test:key'); assert.equal(state.pendingToolCalls, 0); // Line should show [OK] const execLine = state.lines.find((l) => l.includes('exec')); assert.ok(execLine); assert.ok(execLine.includes('[OK]')); }); it('tracks pendingToolCalls correctly', () => { const file = path.join(tmpDir, 'test.jsonl'); fs.writeFileSync(file, ''); appendLine(file, { type: 'tool_call', name: 'exec', id: '1' }); appendLine(file, { type: 'tool_call', name: 'Read', id: '2' }); appendLine(file, { type: 'tool_result', name: 'exec', id: '1' }); watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); watcher.addSession('test:key', file, { lastOffset: 0 }); const state = watcher.getSessionState('test:key'); assert.equal(state.pendingToolCalls, 1); // Read still pending }); it('tracks token usage', () => { const file = path.join(tmpDir, 'test.jsonl'); fs.writeFileSync(file, ''); appendLine(file, { type: 'usage', input_tokens: 1000, output_tokens: 500 }); watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); watcher.addSession('test:key', file, { lastOffset: 0 }); const state = watcher.getSessionState('test:key'); assert.equal(state.tokenCount, 1500); }); it('detects file truncation (compaction)', () => { const file = path.join(tmpDir, 'test.jsonl'); // Write some content fs.writeFileSync( file, JSON.stringify({ type: 'assistant', text: 'Hello' }) + '\n' + JSON.stringify({ type: 'tool_call', name: 'exec', id: '1' }) + '\n', ); watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); watcher.addSession('test:key', file); const state = watcher.getSessionState('test:key'); const originalOffset = state.lastOffset; assert.ok(originalOffset > 0); // Truncate the file (simulate compaction) fs.writeFileSync(file, JSON.stringify({ type: 'assistant', text: 'Resumed' }) + '\n'); // Force a re-read watcher._readFile('test:key', state); // Offset should have reset assert.ok( state.lastOffset < originalOffset || state.lines.includes('[session compacted - continuing]'), ); }); it('handles malformed JSONL lines gracefully', () => { const file = path.join(tmpDir, 'test.jsonl'); fs.writeFileSync(file, 'not valid json\n{"type":"assistant","text":"ok"}\n'); watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); // Should not throw assert.doesNotThrow(() => { watcher.addSession('test:key', file, { lastOffset: 0 }); }); const state = watcher.getSessionState('test:key'); assert.ok(state); assert.ok(state.lines.some((l) => l.includes('ok'))); }); }); describe('idle detection', () => { it('emits session-idle after timeout with no activity', async () => { const file = path.join(tmpDir, 'test.jsonl'); fs.writeFileSync(file, ''); appendLine(file, { type: 'assistant', text: 'Starting...' }); watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 0.2 }); // 200ms // lastOffset: 0 so the pre-written content is read and idle timer is scheduled watcher.addSession('test:key', file, { lastOffset: 0 }); const idle = []; watcher.on('session-idle', (key) => idle.push(key)); await sleep(600); assert.ok(idle.length > 0); assert.equal(idle[0], 'test:key'); }); it('resets idle timer on new activity', async () => { const file = path.join(tmpDir, 'test.jsonl'); fs.writeFileSync(file, ''); watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 0.3 }); // 300ms watcher.start(); watcher.addSession('test:key', file); const idle = []; watcher.on('session-idle', (key) => idle.push(key)); // Write activity at 150ms (before 300ms timeout) await sleep(150); appendLine(file, { type: 'assistant', text: 'Still working...' }); // At 350ms: idle timer was reset, so no idle yet await sleep(200); assert.equal(idle.length, 0); // At 600ms: idle timer fires (150+300=450ms > 200+300=500ms... need to wait full 300ms) await sleep(400); assert.equal(idle.length, 1); }); }); describe('offset recovery', () => { it('resumes from saved offset', () => { const file = path.join(tmpDir, 'test.jsonl'); const line1 = JSON.stringify({ type: 'assistant', text: 'First' }) + '\n'; const line2 = JSON.stringify({ type: 'assistant', text: 'Second' }) + '\n'; fs.writeFileSync(file, line1 + line2); watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); // Start with offset at start of line2 (after line1) watcher.addSession('test:key', file, { lastOffset: line1.length }); const state = watcher.getSessionState('test:key'); // Should only have parsed line2 assert.ok(state.lines.some((l) => l.includes('Second'))); assert.ok(!state.lines.some((l) => l.includes('First'))); }); }); });