0.8.0: pretty logger (Log) + crash capture

- Log: static entry + scoped Loggers (Log.tag), six levels, lazy messages,
  structured fields, ANSI ConsoleSink, DeveloperSink, MemorySink, NoopSink.
  Sinks compose via +; throwing sinks are isolated.
- Log.configure wires FlutterError/PlatformDispatcher/isolate errors through
  Log.e by default; log-then-rethrow deduped via Expando.
- UxKeyboard: migrate kDebugMode print() to Log.tag('KB').d lazily.
- Depend on package:clock for testable timestamps.
This commit is contained in:
agra
2026-04-24 15:06:16 +03:00
parent fc24035162
commit 6f73b53c5e
11 changed files with 883 additions and 10 deletions

View File

@@ -1,3 +1,18 @@
### 0.8.0
- `Log`: pretty, production-ready logger. Static entry (`Log.d/i/w/e/f/t`),
scoped loggers via `Log.tag('KB')`, lazy messages (`Log.d(() => expensive)`),
structured `fields`, ANSI-colored `ConsoleSink`, `DeveloperSink` for DevTools,
`MemorySink` ring buffer (tests + breadcrumbs), `NoopSink`. Sinks fan out via
`+` with per-sink `minLevel`; throwing sinks can't take down the pipeline.
- `Log.configure`: one-call setup that also wires `FlutterError.onError`,
`PlatformDispatcher.onError`, and isolate errors through `Log.e`. Pair of
log-then-rethrow is deduped via an `Expando` mark so crash handlers don't
double-report. Override the `captureCrashes` hook to customise or pass
`() {}` to opt out.
- `UxKeyboard`: adaptive-learning debug output now uses `Log.tag('KB').d`
instead of `print`, lazy-built so the formatted line is only constructed
when debug level is enabled.
### 0.7.0
- `AutoMap<K, V>`: insertion-ordered collection with O(1) lookup by key
**and** by index. Backed by a `List<V>` + `Map<K, int>` kept in sync by

View File

@@ -1 +1 @@
{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[],"linux":[],"windows":[],"web":[]},"dependencyGraph":[{"name":"ux","dependencies":[]}],"date_created":"2026-04-16 18:49:44.385595","version":"3.41.5","swift_package_manager_enabled":{"ios":false,"macos":false}}
{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[],"windows":[],"web":[]},"dependencyGraph":[{"name":"ux","dependencies":[]}],"date_created":"2026-04-24 12:21:00.100805","version":"3.41.5","swift_package_manager_enabled":{"ios":false,"macos":false}}

View File

@@ -5,6 +5,8 @@
import FlutterMacOS
import Foundation
import ux
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
UxPlugin.register(with: registry.registrar(forPlugin: "UxPlugin"))
}

View File

@@ -502,7 +502,7 @@ packages:
path: ".."
relative: true
source: path
version: "0.4.0"
version: "0.7.0"
vector_math:
dependency: transitive
description:

View File

@@ -76,7 +76,7 @@ class AutoMap<K, V> extends Iterable<V> {
}
/// Adds each item. Use `..clear()..addAll(xs)` for replace-all semantics.
void addAll(Iterable<V> items) {
void addAll(Iterable<V> items) {
for (final v in items) {
add(v);
}

View File

@@ -5,6 +5,9 @@ import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:ux/src/log.dart';
final _kbLog = Log.tag('KB');
DynamicLibrary? _initLib() {
if (Platform.isIOS) return DynamicLibrary.process();
@@ -352,11 +355,12 @@ class UxKeyboard with ChangeNotifier {
_converged = maxError < _kAdaptThreshold;
if (kDebugMode) {
print('[KB] adapt #$_learnCount headStart=${(_headStart * 1000).toStringAsFixed(1)}ms '
_kbLog.d(
() => 'adapt #$_learnCount '
'headStart=${(_headStart * 1000).toStringAsFixed(1)}ms '
'maxErr=${(maxError * 100).toStringAsFixed(1)}% '
'${_converged ? "CONVERGED" : "learning"}');
}
'${_converged ? "CONVERGED" : "learning"}',
);
}
_obs.clear();

561
lib/src/log.dart Normal file
View File

@@ -0,0 +1,561 @@
/// Pretty, production-ready logger.
///
/// Static entry point [Log] for ad-hoc calls; [Log.tag] for scoped [Logger]s.
/// Sinks fan out via `+`. Crash capture via [Log.installCrashHandlers].
library;
import 'dart:async';
import 'dart:developer' as developer;
import 'dart:io' show stderr, stdout;
import 'dart:isolate';
import 'package:clock/clock.dart';
import 'package:flutter/foundation.dart';
/// Severity. Numeric values align with `package:logging` so DevTools filters
/// map 1:1.
enum LogLevel {
/// Finest-grained tracing. Typically disabled in every build.
trace(500, 'T'),
/// Developer diagnostics. Default minimum in debug builds.
debug(700, 'D'),
/// Normal operational events.
info(800, 'I'),
/// Something unexpected but recoverable. Default minimum in release.
warn(900, 'W'),
/// A failure that the app kept running through.
error(1000, 'E'),
/// A failure that the app may not recover from.
fatal(1200, 'F');
const LogLevel(this.value, this.letter);
/// Numeric severity matching `package:logging` / `dart:developer`.
final int value;
/// Single-character label used in console output.
final String letter;
}
/// A single log event passed to [LogSink.emit].
class LogRecord {
/// Creates a record. Callers are [Logger] / [Log]; most users never construct.
LogRecord({
required this.time,
required this.level,
required this.message,
this.tag,
this.error,
this.stackTrace,
this.fields,
});
/// Wall-clock time at emit, taken from `package:clock`.
final DateTime time;
/// Severity.
final LogLevel level;
/// Rendered message text. Lazy thunks are resolved before construction.
final String message;
/// Scope name from [Log.tag] / [Logger.tag], if any.
final String? tag;
/// Associated error object, if one was passed.
final Object? error;
/// Stack trace captured at the call site, if one was passed.
final StackTrace? stackTrace;
/// Structured key/value pairs for this record.
final Map<String, Object?>? fields;
}
/// Destination for emitted records. Implementations must not throw; they
/// swallow their own errors so one failing sink cannot take down the pipeline.
abstract class LogSink {
/// Subclass constructor.
const LogSink();
/// Records below this level are dropped before [emit].
LogLevel get minLevel;
/// Delivers [record]. Must not throw.
void emit(LogRecord record);
/// Awaits any pending delivery. Override for buffered sinks.
Future<void> flush() async {}
/// Fan out to `this` then `other`. Each sink applies its own [minLevel].
LogSink operator +(LogSink other) {
final left = this is _CompositeSink
? (this as _CompositeSink)._sinks
: [this];
final right = other is _CompositeSink ? other._sinks : [other];
return _CompositeSink([...left, ...right]);
}
}
class _CompositeSink extends LogSink {
_CompositeSink(this._sinks);
final List<LogSink> _sinks;
@override
LogLevel get minLevel => _sinks
.map((s) => s.minLevel)
.reduce((a, b) => a.value < b.value ? a : b);
@override
void emit(LogRecord r) {
for (final s in _sinks) {
if (r.level.value < s.minLevel.value) continue;
try {
s.emit(r);
} catch (_) {}
}
}
@override
Future<void> flush() async {
for (final s in _sinks) {
try {
await s.flush();
} catch (_) {}
}
}
}
/// Scoped logger. Obtain via [Log.tag].
class Logger {
Logger._(this.name);
/// Tag prefix applied to every record from this logger. Empty for the root.
final String name;
/// Returns a child logger whose [name] is `this.name.child`.
Logger tag(String child) => Logger._(name.isEmpty ? child : '$name.$child');
/// Logs at [LogLevel.trace].
void t(Object message, {Map<String, Object?>? fields}) =>
_log(LogLevel.trace, message, fields: fields);
/// Logs at [LogLevel.debug].
void d(Object message, {Map<String, Object?>? fields}) =>
_log(LogLevel.debug, message, fields: fields);
/// Logs at [LogLevel.info].
void i(Object message, {Map<String, Object?>? fields}) =>
_log(LogLevel.info, message, fields: fields);
/// Logs at [LogLevel.warn].
void w(
Object message, {
Map<String, Object?>? fields,
Object? error,
StackTrace? stackTrace,
}) => _log(
LogLevel.warn,
message,
fields: fields,
error: error,
stackTrace: stackTrace,
);
/// Logs at [LogLevel.error].
void e(
Object message, {
Map<String, Object?>? fields,
Object? error,
StackTrace? stackTrace,
}) => _log(
LogLevel.error,
message,
fields: fields,
error: error,
stackTrace: stackTrace,
);
/// Logs at [LogLevel.fatal].
void f(
Object message, {
Map<String, Object?>? fields,
Object? error,
StackTrace? stackTrace,
}) => _log(
LogLevel.fatal,
message,
fields: fields,
error: error,
stackTrace: stackTrace,
);
void _log(
LogLevel level,
Object message, {
Map<String, Object?>? fields,
Object? error,
StackTrace? stackTrace,
}) {
if (level.value < Log._minLevel.value) return;
final text = message is Function
? '${(message as Function)()}'
: '$message';
Log._markReported(error);
final record = LogRecord(
time: clock.now(),
level: level,
message: text,
tag: name.isEmpty ? null : name,
error: error,
stackTrace: stackTrace,
fields: fields,
);
try {
Log._sink.emit(record);
} catch (_) {}
}
}
/// Global entry point for logging.
class Log {
Log._();
static final Logger _root = Logger._('');
static LogSink _sink = const NoopSink();
static LogLevel _minLevel = kReleaseMode ? LogLevel.warn : LogLevel.debug;
static final Expando<bool> _reported = Expando<bool>('ux.log.reported');
/// The lowest level that will reach any sink.
static LogLevel get minLevel => _minLevel;
/// The sink currently receiving records (composite if `+` was used).
static LogSink get sink => _sink;
/// Configures the global logger.
///
/// By default, invokes [captureCrashes] so uncaught Flutter / async /
/// isolate errors flow through [e]. Pass a different hook (or `() {}` for
/// no-op) to customise: test environments or apps that own
/// `FlutterError.onError` should pass a no-op.
static void configure({
LogLevel? minLevel,
LogSink? sink,
VoidCallback captureCrashes = Log.captureCrashes,
}) {
if (minLevel != null) _minLevel = minLevel;
if (sink != null) _sink = sink;
captureCrashes();
}
/// Returns a scoped [Logger] that tags every record with [name].
static Logger tag(String name) => Logger._(name);
/// Logs at [LogLevel.trace].
static void t(Object message, {Map<String, Object?>? fields}) =>
_root.t(message, fields: fields);
/// Logs at [LogLevel.debug].
static void d(Object message, {Map<String, Object?>? fields}) =>
_root.d(message, fields: fields);
/// Logs at [LogLevel.info].
static void i(Object message, {Map<String, Object?>? fields}) =>
_root.i(message, fields: fields);
/// Logs at [LogLevel.warn].
static void w(
Object message, {
Map<String, Object?>? fields,
Object? error,
StackTrace? stackTrace,
}) => _root.w(
message,
fields: fields,
error: error,
stackTrace: stackTrace,
);
/// Logs at [LogLevel.error].
static void e(
Object message, {
Map<String, Object?>? fields,
Object? error,
StackTrace? stackTrace,
}) => _root.e(
message,
fields: fields,
error: error,
stackTrace: stackTrace,
);
/// Logs at [LogLevel.fatal].
static void f(
Object message, {
Map<String, Object?>? fields,
Object? error,
StackTrace? stackTrace,
}) => _root.f(
message,
fields: fields,
error: error,
stackTrace: stackTrace,
);
/// Awaits pending delivery on the active sink. Call before exiting the
/// isolate or after logging a fatal error that may terminate the app.
static Future<void> flush() => _sink.flush();
/// Routes [FlutterError.onError], [PlatformDispatcher]'s `onError`, and
/// isolate errors through [e]. Invoked by [configure] by default.
///
/// Errors seen previously by [e]/[w]/[f] (caught, logged, rethrown) are
/// suppressed here via an [Expando] mark, so `try {...} catch (err) { Log.e(
/// ..., error: err); rethrow; }` does not double-report.
static void captureCrashes() {
FlutterError.onError = (details) {
if (_isReported(details.exception)) return;
e(
details.exceptionAsString(),
error: details.exception,
stackTrace: details.stack,
fields: {
if (details.library != null) 'library': details.library,
if (details.context != null) 'context': details.context.toString(),
},
);
};
PlatformDispatcher.instance.onError = (error, stack) {
if (_isReported(error)) return true;
e('uncaught async', error: error, stackTrace: stack);
return true;
};
Isolate.current.addErrorListener(
RawReceivePort((dynamic pair) {
final list = pair as List;
e(
'isolate error: ${list[0]}',
stackTrace: StackTrace.fromString('${list[1]}'),
);
}).sendPort,
);
}
static bool _canTag(Object? o) =>
o != null && o is! num && o is! String && o is! bool;
static bool _isReported(Object? o) => _canTag(o) && _reported[o!] == true;
static void _markReported(Object? o) {
if (_canTag(o)) _reported[o!] = true;
}
}
// ─── sinks ────────────────────────────────────────────────────────────────
/// Discards everything. Default until [Log.configure] is called.
class NoopSink extends LogSink {
/// Creates a sink that drops every record.
const NoopSink();
@override
LogLevel get minLevel => LogLevel.fatal;
@override
void emit(LogRecord record) {}
}
/// ANSI-colored single-line output to stderr (or a custom writer).
class ConsoleSink extends LogSink {
/// Creates a console sink. Set [color] explicitly for tests or non-tty
/// writers; otherwise it's auto-detected from `stdout.supportsAnsiEscapes`.
/// [stackFrames] caps how many stack lines are printed per record.
ConsoleSink({
LogLevel minLevel = LogLevel.trace,
bool? color,
this.stackFrames = 8,
void Function(String line)? write,
}) : _minLevel = minLevel,
_color = color ?? _detectAnsi(),
_write = write ?? _defaultConsoleWrite;
final LogLevel _minLevel;
final bool _color;
/// Maximum stack-trace lines printed beneath an error. Extras are dropped.
final int stackFrames;
final void Function(String) _write;
@override
LogLevel get minLevel => _minLevel;
@override
void emit(LogRecord r) {
_write(_formatRecord(r, _color, stackFrames));
}
}
/// Forwards to `dart:developer.log`. Silent in release on non-web (VM service
/// not attached); active on web where it routes to the browser console.
class DeveloperSink extends LogSink {
/// Creates a sink that hands records to `dart:developer.log`.
DeveloperSink({LogLevel minLevel = LogLevel.trace}) : _minLevel = minLevel;
final LogLevel _minLevel;
@override
LogLevel get minLevel => _minLevel;
@override
void emit(LogRecord r) {
if (kReleaseMode && !kIsWeb) return;
developer.log(
r.message,
time: r.time,
level: r.level.value,
name: r.tag ?? 'ux',
error: r.error,
stackTrace: r.stackTrace,
);
}
}
/// In-memory ring buffer. Useful as breadcrumbs for a crash reporter or as a
/// deterministic sink in tests.
class MemorySink extends LogSink {
/// Creates a ring buffer that keeps the most recent [capacity] records.
MemorySink({this.capacity = 100, LogLevel minLevel = LogLevel.trace})
: _minLevel = minLevel;
/// Maximum number of records kept. Older records are evicted FIFO.
final int capacity;
final LogLevel _minLevel;
final List<LogRecord> _records = [];
@override
LogLevel get minLevel => _minLevel;
@override
void emit(LogRecord r) {
_records.add(r);
if (_records.length > capacity) _records.removeAt(0);
}
/// Unmodifiable view of the current buffer, oldest first.
List<LogRecord> snapshot() => List.unmodifiable(_records);
/// Drops all buffered records.
void clear() => _records.clear();
}
// ─── formatting ───────────────────────────────────────────────────────────
bool _detectAnsi() {
if (kIsWeb) return false;
try {
return stdout.supportsAnsiEscapes;
} catch (_) {
return false;
}
}
void _defaultConsoleWrite(String line) {
if (kIsWeb) {
developer.log(line, name: 'ux');
return;
}
stderr.writeln(line);
}
const _ansiReset = '\x1B[0m';
const _ansiDim = '\x1B[2m';
const _ansiBold = '\x1B[1m';
String _colored(String s, int code, bool on) =>
on ? '\x1B[${code}m$s$_ansiReset' : s;
String _dim(String s, bool on) => on ? '$_ansiDim$s$_ansiReset' : s;
String _bold(String s, bool on) => on ? '$_ansiBold$s$_ansiReset' : s;
int _levelColor(LogLevel l) {
switch (l) {
case LogLevel.trace:
return 90;
case LogLevel.debug:
return 36;
case LogLevel.info:
return 32;
case LogLevel.warn:
return 33;
case LogLevel.error:
return 31;
case LogLevel.fatal:
return 35;
}
}
String _formatTime(DateTime t) {
String two(int n) => n.toString().padLeft(2, '0');
String three(int n) => n.toString().padLeft(3, '0');
return '${two(t.hour)}:${two(t.minute)}:${two(t.second)}'
'.${three(t.millisecond)}';
}
String _formatFields(Map<String, Object?> fields) {
final buf = StringBuffer('{');
var first = true;
fields.forEach((k, v) {
if (!first) buf.write(', ');
buf
..write(k)
..write(': ')
..write(v);
first = false;
});
buf.write('}');
return buf.toString();
}
String _formatRecord(LogRecord r, bool color, int stackFrames) {
final buf = StringBuffer()
..write(_dim(_formatTime(r.time), color))
..write(' ')
..write(_colored(r.level.letter, _levelColor(r.level), color))
..write(' ');
if (r.tag != null) {
buf
..write(_bold(r.tag!, color))
..write(' ');
}
buf.write(r.message);
if (r.fields != null && r.fields!.isNotEmpty) {
buf
..write(' ')
..write(_dim(_formatFields(r.fields!), color));
}
if (r.error != null) {
buf
..write('\n └─ ')
..write(_colored('${r.error}', _levelColor(r.level), color));
}
if (r.stackTrace != null) {
final frames = r.stackTrace!
.toString()
.split('\n')
.where((l) => l.isNotEmpty)
.take(stackFrames);
for (final f in frames) {
buf
..write('\n ')
..write(_dim(f, color));
}
}
return buf.toString();
}

View File

@@ -12,4 +12,5 @@ export 'src/file.dart';
export 'src/keyboard.dart';
export 'src/auto_map.dart';
export 'src/sensor.dart';
export 'src/functional.dart';
export 'src/functional.dart';
export 'src/log.dart';

View File

@@ -122,7 +122,7 @@ packages:
source: hosted
version: "2.0.4"
clock:
dependency: transitive
dependency: "direct main"
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b

View File

@@ -3,7 +3,7 @@ description: >-
Flutter toolkit for fluid, native-feeling UIs. Includes frame-accurate
keyboard height tracking via FFI with interactive dismiss, bezier utilities,
and layout primitives.
version: 0.7.0
version: 0.8.0
homepage: https://swipelab.co/ux.html
repository: https://github.com/swipelab/ux
issue_tracker: https://github.com/swipelab/ux/issues
@@ -21,6 +21,7 @@ dependencies:
sdk: flutter
matcher: ^0.12.16
build: ^2.4.0
clock: ^1.1.1
dev_dependencies:
flutter_lints: ^6.0.0

289
test/log_test.dart Normal file
View File

@@ -0,0 +1,289 @@
import 'package:clock/clock.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ux/ux.dart';
void _noop() {}
MemorySink _installMemorySink({LogLevel minLevel = LogLevel.trace}) {
final sink = MemorySink();
Log.configure(minLevel: minLevel, sink: sink, captureCrashes: _noop);
return sink;
}
void main() {
group('LogLevel', () {
test('numeric values align with package:logging', () {
expect(LogLevel.trace.value, 500);
expect(LogLevel.debug.value, 700);
expect(LogLevel.info.value, 800);
expect(LogLevel.warn.value, 900);
expect(LogLevel.error.value, 1000);
expect(LogLevel.fatal.value, 1200);
});
test('letters are distinct and uppercase', () {
final letters = LogLevel.values.map((l) => l.letter).toSet();
expect(letters.length, LogLevel.values.length);
for (final l in letters) {
expect(l, l.toUpperCase());
}
});
});
group('MemorySink', () {
test('records events in order', () {
final sink = _installMemorySink();
Log.d('one');
Log.i('two');
Log.w('three');
expect(sink.snapshot().map((r) => r.message).toList(), [
'one',
'two',
'three',
]);
expect(sink.snapshot().map((r) => r.level).toList(), [
LogLevel.debug,
LogLevel.info,
LogLevel.warn,
]);
});
test('evicts oldest past capacity', () {
final sink = MemorySink(capacity: 3);
Log.configure(minLevel: LogLevel.trace, sink: sink);
for (var i = 0; i < 5; i++) {
Log.i('msg $i');
}
expect(sink.snapshot().map((r) => r.message).toList(), [
'msg 2',
'msg 3',
'msg 4',
]);
});
test('snapshot is unmodifiable', () {
final sink = _installMemorySink();
Log.i('x');
expect(() => sink.snapshot().clear(), throwsUnsupportedError);
});
test('clear empties the buffer', () {
final sink = _installMemorySink();
Log.i('x');
sink.clear();
expect(sink.snapshot(), isEmpty);
});
});
group('level filtering', () {
test('records below Log.minLevel are dropped', () {
final sink = _installMemorySink(minLevel: LogLevel.warn);
Log.t('trace');
Log.d('debug');
Log.i('info');
Log.w('warn');
Log.e('error');
expect(sink.snapshot().map((r) => r.level).toList(), [
LogLevel.warn,
LogLevel.error,
]);
});
test('per-sink minLevel filters independently', () {
final verbose = MemorySink();
final errorsOnly = MemorySink(minLevel: LogLevel.error);
Log.configure(minLevel: LogLevel.trace, sink: verbose + errorsOnly);
Log.i('info');
Log.e('error');
expect(verbose.snapshot().map((r) => r.message), ['info', 'error']);
expect(errorsOnly.snapshot().map((r) => r.message), ['error']);
});
});
group('lazy messages', () {
test('thunk is not invoked when filtered', () {
_installMemorySink(minLevel: LogLevel.warn);
var called = 0;
Log.d(() {
called++;
return 'expensive';
});
expect(called, 0);
});
test('thunk is invoked and rendered when enabled', () {
final sink = _installMemorySink();
var called = 0;
Log.d(() {
called++;
return 'computed';
});
expect(called, 1);
expect(sink.snapshot().single.message, 'computed');
});
});
group('tagging', () {
test('Log.tag sets the record tag', () {
final sink = _installMemorySink();
Log.tag('KB').i('hello');
expect(sink.snapshot().single.tag, 'KB');
});
test('root logger emits with null tag', () {
final sink = _installMemorySink();
Log.i('rootless');
expect(sink.snapshot().single.tag, isNull);
});
test('nested tag joins with a dot', () {
final sink = _installMemorySink();
Log.tag('A').tag('B').i('x');
expect(sink.snapshot().single.tag, 'A.B');
});
});
group('errors and stack traces', () {
test('error and stack pass through to the record', () {
final sink = _installMemorySink();
final err = Exception('boom');
final st = StackTrace.current;
Log.e('fail', error: err, stackTrace: st);
final r = sink.snapshot().single;
expect(r.error, same(err));
expect(r.stackTrace, same(st));
});
test('fields pass through', () {
final sink = _installMemorySink();
Log.i('hit', fields: {'userId': 42, 'path': '/x'});
expect(sink.snapshot().single.fields, {'userId': 42, 'path': '/x'});
});
});
group('dedupe via crash handler', () {
test('FlutterError.onError skips an error already seen by Log.e', () {
final prevFlutter = FlutterError.onError;
addTearDown(() => FlutterError.onError = prevFlutter);
final sink = MemorySink();
Log.configure(minLevel: LogLevel.trace, sink: sink);
final err = Exception('once');
Log.e('caught', error: err);
FlutterError.onError!(FlutterErrorDetails(exception: err));
expect(sink.snapshot().map((r) => r.message), ['caught']);
});
test('FlutterError.onError still reports a fresh error', () {
final prevFlutter = FlutterError.onError;
addTearDown(() => FlutterError.onError = prevFlutter);
final sink = MemorySink();
Log.configure(minLevel: LogLevel.trace, sink: sink);
FlutterError.onError!(FlutterErrorDetails(exception: Exception('new')));
expect(sink.snapshot().single.level, LogLevel.error);
expect(sink.snapshot().single.error, isA<Exception>());
});
});
group('clock', () {
test('record time comes from package:clock', () {
final sink = _installMemorySink();
final fixed = DateTime.utc(2026, 1, 2, 3, 4, 5, 678);
withClock(Clock.fixed(fixed), () {
Log.i('pinned');
});
expect(sink.snapshot().single.time, fixed);
});
});
group('composite sink', () {
test('chained + flattens into a single composite', () {
final a = MemorySink();
final b = MemorySink();
final c = MemorySink();
Log.configure(minLevel: LogLevel.trace, sink: a + b + c);
Log.i('fan');
expect(a.snapshot().single.message, 'fan');
expect(b.snapshot().single.message, 'fan');
expect(c.snapshot().single.message, 'fan');
});
test('a throwing sink does not stop others', () {
final recorded = MemorySink();
final bomb = _ThrowingSink();
Log.configure(minLevel: LogLevel.trace, sink: bomb + recorded);
Log.i('survive');
expect(recorded.snapshot().single.message, 'survive');
});
});
group('ConsoleSink formatting', () {
test('writes a single line with level letter and message', () {
final lines = <String>[];
Log.configure(
minLevel: LogLevel.trace,
sink: ConsoleSink(color: false, write: lines.add),
);
withClock(Clock.fixed(DateTime.utc(2026, 1, 1, 12, 34, 56, 789)), () {
Log.tag('T').i('hello');
});
expect(lines.single, contains('12:34:56.789'));
expect(lines.single, contains(' I '));
expect(lines.single, contains('T hello'));
});
test('renders fields inline', () {
final lines = <String>[];
Log.configure(
minLevel: LogLevel.trace,
sink: ConsoleSink(color: false, write: lines.add),
);
Log.i('hit', fields: {'n': 3});
expect(lines.single, contains('{n: 3}'));
});
test('renders error on a follow-up line', () {
final lines = <String>[];
Log.configure(
minLevel: LogLevel.trace,
sink: ConsoleSink(color: false, write: lines.add),
);
Log.e('oops', error: Exception('bad'));
expect(lines.single.split('\n').length, greaterThan(1));
expect(lines.single, contains('└─ Exception: bad'));
});
test('caps stack frames', () {
final lines = <String>[];
Log.configure(
minLevel: LogLevel.trace,
sink: ConsoleSink(color: false, stackFrames: 2, write: lines.add),
);
final longStack = StackTrace.fromString(
List.generate(10, (i) => '#$i frame$i').join('\n'),
);
Log.e('x', error: Exception('e'), stackTrace: longStack);
final stackLines = lines.single
.split('\n')
.where((l) => l.contains('#'))
.toList();
expect(stackLines.length, 2);
});
});
}
class _ThrowingSink extends LogSink {
@override
LogLevel get minLevel => LogLevel.trace;
@override
void emit(LogRecord record) => throw StateError('no');
}