// Unit tests for ChainLog — the central error log that // `~/.fai/logs/studio-errors.log` is the operator-visible // surface of. The tests redirect the singleton at a temp file // via `ChainLog.testPathOverride` so the real log under HOME is // never touched. import 'dart:convert'; import 'dart:io'; import 'package:chain_studio/data/chain_log.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('ChainLog', () { late Directory tmp; late File logFile; setUp(() { tmp = Directory.systemTemp.createTempSync('chain_log_test_'); logFile = File('${tmp.path}/studio-errors.log'); ChainLog.testPathOverride = logFile.path; }); tearDown(() { ChainLog.testPathOverride = null; try { tmp.deleteSync(recursive: true); } catch (_) {/* best-effort */} }); test('append writes one JSON-shaped line per event', () async { await ChainLog.instance.error('flows.run', 'boom'); final lines = await ChainLog.instance.tail(); expect(lines, hasLength(1)); final entry = jsonDecode(lines.single) as Map; expect(entry['source'], 'flows.run'); expect(entry['error'], 'boom'); expect(entry['level'], 'error'); expect(entry['ts'], isA()); }); test('context field is preserved when supplied', () async { await ChainLog.instance.error( 'theme.plugin.load', Exception('endpoint unreachable'), context: 'capability=studio.theme.space', ); final lines = await ChainLog.instance.tail(); final entry = jsonDecode(lines.single) as Map; expect(entry['context'], 'capability=studio.theme.space'); expect(entry['error'], contains('endpoint unreachable')); }); test('tail returns oldest first', () async { await ChainLog.instance.error('a', '1'); await ChainLog.instance.error('b', '2'); await ChainLog.instance.error('c', '3'); final lines = await ChainLog.instance.tail(); expect(lines, hasLength(3)); final sources = lines.map((l) => jsonDecode(l)['source']).toList(); expect(sources, ['a', 'b', 'c']); }); test('tail caps output at maxLines and keeps the newest', () async { for (var i = 0; i < 20; i++) { await ChainLog.instance.error('burst', 'event-$i'); } final lines = await ChainLog.instance.tail(maxLines: 5); expect(lines, hasLength(5)); final last = jsonDecode(lines.last) as Map; expect(last['error'], 'event-19'); }); test('rotation moves the live log to .log.1 past 256 KiB', () async { // Pre-seed the active log just over the cap. The next // append should rotate, leaving the rotated file at // `.log.1` and a short fresh `.log`. logFile.parent.createSync(recursive: true); logFile.writeAsBytesSync(List.filled(257 * 1024, 0x20)); await ChainLog.instance.error('rotation', 'after-cap'); expect(File('${logFile.path}.1').existsSync(), isTrue); final newSize = logFile.lengthSync(); expect(newSize, lessThan(2 * 1024)); final newText = logFile.readAsStringSync(); expect(newText, contains('"source":"rotation"')); }); test('path getter returns the override when set', () { expect(ChainLog.instance.path, logFile.path); }); test('writes failing silently does not throw', () async { // Redirect to a deliberately impossible path. The error() // call must complete cleanly; the operator UI must never // crash because the disk is full / read-only / missing. ChainLog.testPathOverride = '/this/path/does/not/exist/ever/x.log'; await expectLater( ChainLog.instance.error('bad.path', 'boom'), completes, ); }); }); }