Files
clide/test/kernel/file_log_sink_test.dart
jpmschweitzerandClaude Opus 4.8 85cc34e09c feat(log): crash-survivable FileLogSink + dev/prod verbosity toggle (T-432)
First increment of the observability epic (T-425), the productive pivot after
the ConPTY freeze refused to reproduce on CI: if we can't reproduce it, make
the next occurrence leave evidence.

- FileLogSink (lib/kernel/src/file_log_sink.dart): synchronous, crash-survivable
  LogSink. Appends each record as one JSON line to a size-rotated file; fsyncs
  warn/error + risky-source (pty/ffi/conpty/watchdog) records immediately so the
  last breadcrumb is on disk before a hard death, batches the rest on a timer.
  Never throws. Flutter-free → unit-tested under dart test against a temp dir.
- logDirectory() (paths.dart): persistent per-platform log dir (LOCALAPPDATA /
  ~/Library/Logs / $XDG_STATE_HOME) — durable across reboot, unlike the
  ephemeral socketDirectory.
- resolveLogLevel() (log.dart): the requested dev/prod toggle. CLIDE_LOG
  dart-define → CLIDE_LOG env → app.log.level setting → warn(release)/info(debug).
  Lenient parse; an invalid source falls through.
- Boot wiring (facade.boot + main.dart): FileLogSink leads the sink chain (so a
  crash records before the volatile stderr/ring sinks) and the resolved level
  sets Logger.minLevel.

Tests: FileLogSink (JSON shape, error/stack, rotation cap, append-across-restart,
timer-cancel), resolveLogLevel precedence + fall-through, logDirectory per-OS.
Coverage gate 95.11%.

Follow-ups under T-425: live toggle CLI/command/chip (T-433), FFI breadcrumbs
(T-434), watchdog isolate (T-435), CI artifact wiring (T-436).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:29:41 +02:00

109 lines
4.2 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter_test/flutter_test.dart';
LogRecord _rec(LogLevel level, String src, String msg, {Object? error, StackTrace? stack}) =>
LogRecord(level: level, source: src, message: msg, timestamp: DateTime.utc(2026, 6, 15, 12), error: error, stackTrace: stack);
void main() {
late Directory dir;
setUp(() => dir = Directory.systemTemp.createTempSync('clide-filelog-'));
tearDown(() {
if (dir.existsSync()) dir.deleteSync(recursive: true);
});
File active() => File('${dir.path}${Platform.pathSeparator}clide.log');
File archive(int i) => File('${dir.path}${Platform.pathSeparator}clide.$i.log');
group('FileLogSink', () {
test('appends one JSON line per record with the expected shape', () async {
final sink = FileLogSink(dir: dir, startFlushTimer: false);
sink(_rec(LogLevel.info, 'boot', 'hello'));
sink(_rec(LogLevel.warn, 'pty', 'spawned', error: 'note'));
await sink.close();
final lines = active().readAsLinesSync();
expect(lines, hasLength(2));
final a = jsonDecode(lines[0]) as Map<String, Object?>;
expect(a['lvl'], 'info');
expect(a['src'], 'boot');
expect(a['msg'], 'hello');
expect(a['ts'], '2026-06-15T12:00:00.000Z');
expect(a.containsKey('err'), isFalse);
final b = jsonDecode(lines[1]) as Map<String, Object?>;
expect(b['lvl'], 'warn');
expect(b['err'], 'note');
});
test('encodes error + stack trace fields when present', () async {
final sink = FileLogSink(dir: dir, startFlushTimer: false);
final st = StackTrace.current;
sink(_rec(LogLevel.error, 'ffi', 'boom', error: 'EBADF', stack: st));
await sink.close();
final rec = jsonDecode(active().readAsLinesSync().single) as Map<String, Object?>;
expect(rec['err'], 'EBADF');
expect(rec['stack'], st.toString());
});
test('creates the log directory if it does not exist', () async {
final nested = Directory('${dir.path}${Platform.pathSeparator}a${Platform.pathSeparator}b');
final sink = FileLogSink(dir: nested, startFlushTimer: false);
sink(_rec(LogLevel.info, 's', 'm'));
await sink.close();
expect(File('${nested.path}${Platform.pathSeparator}clide.log').existsSync(), isTrue);
});
test('rotates past maxBytes and caps archives at maxFiles', () async {
// ~80-byte lines, 100-byte cap → a rotation every couple of records.
final sink = FileLogSink(dir: dir, maxBytes: 100, maxFiles: 2, startFlushTimer: false);
for (var i = 0; i < 6; i++) {
sink(_rec(LogLevel.info, 's', 'msg$i'));
}
await sink.close();
expect(active().existsSync(), isTrue);
expect(archive(1).existsSync(), isTrue);
// maxFiles=2 keeps active + .1 only — .2 must never appear.
expect(archive(2).existsSync(), isFalse);
// The newest record is in the active file.
expect(active().readAsStringSync(), contains('msg5'));
});
test('append mode preserves an existing log across sink restarts', () async {
final first = FileLogSink(dir: dir, startFlushTimer: false);
first(_rec(LogLevel.info, 's', 'before'));
await first.close();
final second = FileLogSink(dir: dir, startFlushTimer: false);
second(_rec(LogLevel.info, 's', 'after'));
await second.close();
final lines = active().readAsLinesSync();
expect(lines, hasLength(2));
expect((jsonDecode(lines[0]) as Map)['msg'], 'before');
expect((jsonDecode(lines[1]) as Map)['msg'], 'after');
});
test('close cancels the flush timer cleanly (no pending-timer leak)', () async {
final sink = FileLogSink(dir: dir, flushInterval: const Duration(milliseconds: 10));
sink(_rec(LogLevel.info, 's', 'm'));
await sink.close();
// Reaching here without the test runner flagging a pending timer is the
// assertion; also confirm a post-close write is a no-op, not a throw.
sink(_rec(LogLevel.info, 's', 'after-close'));
expect(active().readAsLinesSync(), hasLength(1));
});
test('activePath points at the live file', () {
final sink = FileLogSink(dir: dir, startFlushTimer: false);
expect(sink.activePath, active().path);
});
});
}