Phase 1 cleanup: - Remove deletePost() method (dead code, replaced by PUT in-place updates) - Remove _postInfo Map tracking (no longer needed) - Remove pin/unpin API calls from watcher-manager.js (incompatible with PUT updates) - Add JSDoc note on (edited) label limitation in _flushUpdate() - Add integration test: test/integration/poll-fallback.test.js - Fix addSession() lastOffset===0 falsy bug (0 was treated as 'no offset') - Fix pre-existing test failures: add lastOffset:0 where tests expect backlog reads - Fix pre-existing session-monitor test: create stub transcript files - Fix pre-existing status-formatter test: update indent check for blockquote format - Format plugin/ files with Prettier (pre-existing formatting drift)
270 lines
9.0 KiB
JavaScript
270 lines
9.0 KiB
JavaScript
'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')));
|
|
});
|
|
});
|
|
});
|