/// Tests for TranscriptReader (pure-Dart, no Flutter). /// /// Uses a snapshotted JSONL fixture written to a temp directory so tests are /// hermetic and never depend on real Claude transcript files. library; import 'dart:convert'; import 'dart:io'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; import 'package:test/test.dart'; // --------------------------------------------------------------------------- // Fixture helpers // --------------------------------------------------------------------------- /// Write [lines] to [file], each JSON-encoded, newline-terminated. void writeLines(File file, List> lines) { file.writeAsStringSync( '${lines.map(jsonEncode).join('\n')}\n', mode: FileMode.writeOnly, ); } /// Append [lines] to [file]. void appendLines(File file, List> lines) { file.writeAsStringSync( '${lines.map(jsonEncode).join('\n')}\n', mode: FileMode.append, ); } /// JSONL envelope skeleton with default sentinel values. Map envelope({ required String type, required String uuid, Map? message, String parentUuid = '', bool isSidechain = false, String version = '2.1.143', String timestamp = '2026-05-16T08:53:06.708Z', }) { return { 'type': type, 'uuid': uuid, 'parentUuid': parentUuid, 'isSidechain': isSidechain, 'version': version, 'timestamp': timestamp, if (message != null) 'message': message, }; } /// Build a `user` envelope whose content is a plain string. Map userText(String uuid, String text) { return envelope( type: 'user', uuid: uuid, message: {'role': 'user', 'content': text}, ); } /// Build a `user` envelope whose content is an array of text parts. Map userTextArray(String uuid, List parts) { return envelope( type: 'user', uuid: uuid, message: { 'role': 'user', 'content': [ for (final p in parts) {'type': 'text', 'text': p} ], }, ); } /// Build a `user` envelope containing a tool_result. Map userToolResult( String uuid, { required String toolUseId, required String content, bool isError = false, }) { return envelope( type: 'user', uuid: uuid, message: { 'role': 'user', 'content': [ { 'type': 'tool_result', 'tool_use_id': toolUseId, 'content': content, 'is_error': isError, } ], }, ); } /// Build an `assistant` envelope with a tool_use content block. Map assistantToolUse( String uuid, { required String id, required String name, required Map input, }) { return envelope( type: 'assistant', uuid: uuid, message: { 'role': 'assistant', 'content': [ {'type': 'tool_use', 'id': id, 'name': name, 'input': input}, ], }, ); } /// Build an `assistant` envelope with a text content block. Map assistantText(String uuid, String text) { return envelope( type: 'assistant', uuid: uuid, message: { 'role': 'assistant', 'content': [ {'type': 'text', 'text': text}, ], }, ); } /// Build an `assistant` envelope with a thinking content block. Map assistantThinking(String uuid, String thinking) { return envelope( type: 'assistant', uuid: uuid, message: { 'role': 'assistant', 'content': [ {'type': 'thinking', 'thinking': thinking}, ], }, ); } /// A skip-type record. Map skipRecord(String type, String uuid) { return {'type': type, 'uuid': uuid, 'timestamp': '2026-05-16T08:53:06.708Z'}; } // --------------------------------------------------------------------------- // Synchronous parse helper — uses the REAL TranscriptReader.parseLine // --------------------------------------------------------------------------- /// Parses [lines] synchronously via the real [TranscriptReader.parseLine] and /// returns every emitted [ConversationItem]. List parseAll( List> lines, { void Function(String)? onWarn, }) { final reader = TranscriptReader('/fake', onWarn: onWarn); return [for (final l in lines) ...reader.parseLine(jsonEncode(l))]; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- void main() { // ------------------------------------------------------------------------- group('ConversationItem model', () { test('UserMessage holds text and metadata', () { final item = UserMessage( uuid: 'u1', timestamp: _epoch, isSidechain: false, text: 'hello', ); expect(item.uuid, 'u1'); expect(item.text, 'hello'); expect(item.isSidechain, isFalse); expect(item.toString(), contains('UserMessage')); }); test('AssistantTextMessage holds text', () { final item = AssistantTextMessage( uuid: 'a1', timestamp: _epoch, isSidechain: false, text: 'world', ); expect(item.text, 'world'); }); test('AssistantThinkingMessage holds thinking', () { final item = AssistantThinkingMessage( uuid: 't1', timestamp: _epoch, isSidechain: false, thinking: 'pondering', ); expect(item.thinking, 'pondering'); }); test('AssistantToolUse holds name and input', () { final item = AssistantToolUse( uuid: 'tu1', timestamp: _epoch, isSidechain: false, toolUseId: 'toolu_001', name: 'Bash', input: const {'command': 'ls'}, ); expect(item.name, 'Bash'); expect(item.input['command'], 'ls'); expect(item.toString(), contains('Bash')); }); test('ToolResultMessage holds content and error flag', () { final item = ToolResultMessage( uuid: 'r1', timestamp: _epoch, isSidechain: false, toolUseId: 'toolu_001', content: 'ok', isError: false, ); expect(item.content, 'ok'); expect(item.isError, isFalse); }); }); // ------------------------------------------------------------------------- group('TranscriptReader — path munging', () { test('mungedDir replaces slashes with dashes and keeps leading dash', () { // The munging rule (path '/' -> '-') is verified directly here; the // real reader's directory resolution is exercised end-to-end by the // file-discovery / streaming tests below (via projectsBase). final munged = '/var/mnt/foo'.replaceAll('/', '-'); expect(munged, '-var-mnt-foo'); }); test('munged dir has leading dash for absolute paths', () { final munged = '/home/user/project'.replaceAll('/', '-'); expect(munged, startsWith('-')); }); }); // ------------------------------------------------------------------------- group('TranscriptReader — parse: skip types', () { test('skips attachment, system, last-prompt, permission-mode, file-history-snapshot, queue-operation', () { final skipTypes = [ 'attachment', 'system', 'last-prompt', 'permission-mode', 'file-history-snapshot', 'queue-operation', ]; for (final t in skipTypes) { final items = parseAll([skipRecord(t, 'skip-$t')]); expect(items, isEmpty, reason: 'type "$t" should be skipped'); } }); }); // ------------------------------------------------------------------------- group('TranscriptReader — parse: user messages', () { test('plain string content emits UserMessage', () { final items = parseAll([userText('u1', 'hello world')]); expect(items, hasLength(1)); expect(items.first, isA()); expect((items.first as UserMessage).text, 'hello world'); }); test('array content with text parts emits joined UserMessage', () { final items = parseAll([ userTextArray('u2', ['foo', 'bar']) ]); expect(items, hasLength(1)); final msg = items.first as UserMessage; expect(msg.text, 'foo\nbar'); }); test('array content with tool_result emits ToolResultMessage', () { final items = parseAll([ userToolResult('u3', toolUseId: 'toolu_abc', content: '{"ok":true}'), ]); expect(items, hasLength(1)); final res = items.first as ToolResultMessage; expect(res.toolUseId, 'toolu_abc'); expect(res.content, '{"ok":true}'); expect(res.isError, isFalse); }); test('tool_result with is_error=true sets isError', () { final items = parseAll([ userToolResult('u4', toolUseId: 'toolu_xyz', content: 'boom', isError: true), ]); final res = items.first as ToolResultMessage; expect(res.isError, isTrue); }); test('mixed array: text + tool_result emits both', () { final raw = envelope( type: 'user', uuid: 'u5', message: { 'role': 'user', 'content': [ {'type': 'text', 'text': 'see result'}, { 'type': 'tool_result', 'tool_use_id': 'toolu_mixed', 'content': 'done', 'is_error': false, }, ], }, ); final items = parseAll([raw]); // ToolResultMessage emitted first (order of content array), then UserMessage. expect(items, hasLength(2)); expect(items.whereType(), hasLength(1)); expect(items.whereType(), hasLength(1)); }); test('empty string content emits nothing', () { final items = parseAll([userText('u6', '')]); expect(items, isEmpty); }); test('uuid and timestamp are preserved', () { final items = parseAll([userText('uuid-abc', 'hi')]); expect(items.first.uuid, 'uuid-abc'); expect(items.first.timestamp, DateTime.parse('2026-05-16T08:53:06.708Z')); }); test('isSidechain flag is preserved', () { final raw = envelope( type: 'user', uuid: 'u7', isSidechain: true, message: {'role': 'user', 'content': 'side'}, ); final items = parseAll([raw]); expect(items.first.isSidechain, isTrue); }); }); // ------------------------------------------------------------------------- group('TranscriptReader — parse: assistant messages', () { test('text block emits AssistantTextMessage', () { final items = parseAll([assistantText('a1', 'here is the answer')]); expect(items, hasLength(1)); final msg = items.first as AssistantTextMessage; expect(msg.text, 'here is the answer'); }); test('thinking block emits AssistantThinkingMessage', () { final items = parseAll([assistantThinking('a2', 'I am thinking...')]); expect(items, hasLength(1)); expect(items.first, isA()); }); test('empty thinking block is not emitted', () { final raw = envelope( type: 'assistant', uuid: 'a3', message: { 'role': 'assistant', 'content': [ {'type': 'thinking', 'thinking': ''}, ], }, ); final items = parseAll([raw]); expect(items, isEmpty); }); test('tool_use block emits AssistantToolUse', () { final items = parseAll([ assistantToolUse( 'a4', id: 'toolu_001', name: 'Bash', input: {'command': 'ls -la', 'description': 'List files'}, ), ]); expect(items, hasLength(1)); final tu = items.first as AssistantToolUse; expect(tu.name, 'Bash'); expect(tu.toolUseId, 'toolu_001'); expect(tu.input['command'], 'ls -la'); }); test('mixed assistant turn emits multiple items in order', () { final raw = envelope( type: 'assistant', uuid: 'a5', message: { 'role': 'assistant', 'content': [ {'type': 'thinking', 'thinking': 'working...'}, {'type': 'text', 'text': 'I will run a command'}, { 'type': 'tool_use', 'id': 'toolu_002', 'name': 'Read', 'input': {'file_path': '/foo'} }, ], }, ); final items = parseAll([raw]); expect(items, hasLength(3)); expect(items[0], isA()); expect(items[1], isA()); expect(items[2], isA()); }); }); // ------------------------------------------------------------------------- group('TranscriptReader — parse: snapshot fixture', () { /// Canonical fixture: assistant text + tool_use + tool_result + skip-type. /// Simulates a real Claude Code conversation turn. test('parses snapshot fixture into correct ConversationItems', () { final fixture = _snapshotFixture(); final items = parseAll(fixture); // Expected items from fixture (see _snapshotFixture): // 1. UserMessage (user turn) // 2. AssistantThinkingMessage (thinking block) // 3. AssistantToolUse (Bash call) // 4. ToolResultMessage (tool result) // 5. AssistantTextMessage (final reply) // Skip types are not counted. expect(items, hasLength(5)); expect(items[0], isA()); expect((items[0] as UserMessage).text, 'what files are here?'); expect(items[1], isA()); expect((items[1] as AssistantThinkingMessage).thinking, contains('need to list')); expect(items[2], isA()); final tu = items[2] as AssistantToolUse; expect(tu.name, 'Bash'); expect(tu.input['command'], 'ls -la'); expect(items[3], isA()); final tr = items[3] as ToolResultMessage; expect(tr.toolUseId, tu.toolUseId); expect(tr.content, contains('file.dart')); expect(tr.isError, isFalse); expect(items[4], isA()); expect((items[4] as AssistantTextMessage).text, contains('file.dart')); }); }); // ------------------------------------------------------------------------- group('TranscriptReader — version drift-guard', () { test('known versions (1.x, 2.x) produce no warning', () { final warnings = []; parseAll( [userText('u1', 'hello'), assistantText('a1', 'world')], onWarn: warnings.add, ); expect(warnings, isEmpty); }); test('unknown major version warns and still emits parseable items', () { final warnings = []; final raw = envelope( type: 'user', uuid: 'u1', version: '99.0.1', message: {'role': 'user', 'content': 'future format'}, ); final items = parseAll([raw], onWarn: warnings.add); expect(warnings, hasLength(1)); expect(warnings.first, contains('99')); expect(warnings.first, contains('degrade gracefully')); // The item still parses — degrade, don't crash. expect(items, hasLength(1)); expect(items.first, isA()); }); test('multiple lines with unknown version warn once per line that has it', () { final warnings = []; final lines = [ envelope(type: 'user', uuid: 'u1', version: '5.0.0', message: {'role': 'user', 'content': 'a'}), envelope(type: 'user', uuid: 'u2', version: '5.0.0', message: {'role': 'user', 'content': 'b'}), ]; parseAll(lines, onWarn: warnings.add); expect(warnings, hasLength(2)); }); test('missing version field does not warn', () { final warnings = []; final raw = { 'type': 'user', 'uuid': 'u1', 'isSidechain': false, 'timestamp': '2026-05-16T08:53:06.708Z', 'message': {'role': 'user', 'content': 'no version'}, }; parseAll([raw], onWarn: warnings.add); expect(warnings, isEmpty); }); test('malformed JSON line is skipped without throwing', () { expect( TranscriptReader('/fake').parseLine('not json!!!'), isEmpty, ); }); }); // ------------------------------------------------------------------------- group('TranscriptReader — append streaming (filesystem)', () { late Directory tempBase; /// The workspace path used in all streaming tests. const workspace = '/test/workspace'; /// Resolves the munged project dir inside [base] for [workspacePath]. Directory mungedDir(Directory base, String workspacePath) { final munged = workspacePath.replaceAll('/', '-'); return Directory('${base.path}/$munged'); } setUp(() async { tempBase = await Directory.systemTemp.createTemp('transcript_reader_test_'); }); tearDown(() async { await tempBase.delete(recursive: true); }); test('initial lines are emitted on first poll', () async { final projectDir = mungedDir(tempBase, workspace); await projectDir.create(recursive: true); final sessionFile = File('${projectDir.path}/session-abc.jsonl'); writeLines(sessionFile, [ userText('u1', 'first message'), assistantText('a1', 'first reply'), ]); final reader = TranscriptReader( workspace, projectsBase: tempBase.path, pollInterval: const Duration(milliseconds: 20), ); final collected = []; final sub = reader.stream.listen(collected.add); // Allow a few poll cycles. await Future.delayed(const Duration(milliseconds: 150)); await sub.cancel(); await reader.dispose(); expect(collected.whereType(), hasLength(1)); expect(collected.whereType(), hasLength(1)); }); test('appended lines are emitted without replaying earlier lines', () async { final projectDir = mungedDir(tempBase, workspace); await projectDir.create(recursive: true); final sessionFile = File('${projectDir.path}/session-abc.jsonl'); writeLines(sessionFile, [userText('u1', 'initial')]); final reader = TranscriptReader( workspace, projectsBase: tempBase.path, pollInterval: const Duration(milliseconds: 20), ); final collected = []; final sub = reader.stream.listen(collected.add); // Let the reader consume the initial lines. await Future.delayed(const Duration(milliseconds: 100)); final countAfterInit = collected.length; // Append new lines. appendLines(sessionFile, [assistantText('a1', 'appended reply')]); // Let the reader pick up the append. await Future.delayed(const Duration(milliseconds: 100)); await sub.cancel(); await reader.dispose(); expect(collected.length, greaterThan(countAfterInit)); expect(collected.whereType(), hasLength(1)); // The initial UserMessage was already counted; no duplicates. expect(collected.whereType(), hasLength(1)); }); test('session switch: newer file triggers replay from new file start', () async { final projectDir = mungedDir(tempBase, workspace); await projectDir.create(recursive: true); final sessionFile = File('${projectDir.path}/session-abc.jsonl'); // Write session A. writeLines(sessionFile, [userText('u1', 'old session')]); final reader = TranscriptReader( workspace, projectsBase: tempBase.path, pollInterval: const Duration(milliseconds: 20), ); final collected = []; final sub = reader.stream.listen(collected.add); await Future.delayed(const Duration(milliseconds: 100)); // Create a newer session file (ensure mtime difference with touch-like approach). final newerFile = File('${projectDir.path}/session-xyz.jsonl'); writeLines(newerFile, [userText('u2', 'new session')]); // Force a newer mtime (sleep is disallowed in long loops but a short // fixed delay within a test is acceptable as a one-shot wait). await Future.delayed(const Duration(milliseconds: 100)); // Re-touch the newer file to guarantee mtime is after the old one. final now = DateTime.now(); await newerFile.setLastModified(now); await Future.delayed(const Duration(milliseconds: 200)); await sub.cancel(); await reader.dispose(); // The reader should have switched to the newer file. final userMessages = collected.whereType().map((m) => m.text).toList(); expect(userMessages, contains('new session')); }); test('skip types in a real file are not emitted', () async { final projectDir = mungedDir(tempBase, workspace); await projectDir.create(recursive: true); final sessionFile = File('${projectDir.path}/session-abc.jsonl'); writeLines(sessionFile, [ skipRecord('last-prompt', 'skip1'), skipRecord('attachment', 'skip2'), userText('u1', 'real message'), skipRecord('system', 'skip3'), ]); final reader = TranscriptReader( workspace, projectsBase: tempBase.path, pollInterval: const Duration(milliseconds: 20), ); final collected = []; final sub = reader.stream.listen(collected.add); await Future.delayed(const Duration(milliseconds: 150)); await sub.cancel(); await reader.dispose(); expect(collected, hasLength(1)); expect((collected.first as UserMessage).text, 'real message'); }); test('initial attach reads only the recent tail (initialTailBytes cap)', () async { final projectDir = mungedDir(tempBase, workspace); await projectDir.create(recursive: true); final sessionFile = File('${projectDir.path}/session-abc.jsonl'); // A long pre-existing transcript — the kind that froze the UI when // parsed in full on attach. writeLines(sessionFile, [ for (var i = 0; i < 200; i++) assistantText('a$i', 'reply number $i'), ]); // Cap the initial read well below the file size so only the last // records are within the tail window. final reader = TranscriptReader( workspace, projectsBase: tempBase.path, pollInterval: const Duration(milliseconds: 20), initialTailBytes: 256, ); final collected = []; final sub = reader.stream.listen(collected.add); await Future.delayed(const Duration(milliseconds: 200)); await sub.cancel(); await reader.dispose(); // Far fewer than the 200 records — only the recent tail was read. expect(collected, isNotEmpty); expect(collected.length, lessThan(200)); // The most recent record is always intact at the end of the file. expect( collected.whereType().last.text, 'reply number 199', ); }); }); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- final _epoch = DateTime.utc(2026, 5, 16, 8, 53, 6); /// Canonical snapshot fixture representing a full conversation turn. List> _snapshotFixture() { const toolUseId = 'toolu_fixture_001'; return [ // skip type — must not appear in output skipRecord('last-prompt', 'skip-0'), // user turn — plain string userText('u-001', 'what files are here?'), // skip type inside the sequence skipRecord('permission-mode', 'skip-1'), // assistant turn — thinking + tool_use envelope( type: 'assistant', uuid: 'a-001', message: { 'role': 'assistant', 'content': [ {'type': 'thinking', 'thinking': 'I need to list the directory to answer.'}, { 'type': 'tool_use', 'id': toolUseId, 'name': 'Bash', 'input': {'command': 'ls -la'} }, ], }, ), // tool result (user turn) userToolResult( 'u-002', toolUseId: toolUseId, content: 'total 4\n-rw-r--r-- file.dart', ), // assistant text reply assistantText('a-002', 'The directory contains file.dart.'), // another skip type at the end skipRecord('file-history-snapshot', 'skip-2'), ]; }