Files
ux/test/crash_test.dart
agra 1d00f16122 log: HttpSink + native crash capture (iOS/Android)
Three new pieces, all composable through the existing Log API
(`Log.configure(sink: ConsoleSink() + HttpSink(...))`) — no new
facade, no install side-effects.

  HttpSink (lib/src/log_http.dart)
    - Extends LogSink. Batches records and POSTs them as a JSON array
      to a configurable endpoint with bearer auth.
    - Defaults: batchSize=25, flushInterval=2s, queueCapacity=2000,
      initialBackoff=1s capped at maxBackoff=30s.
    - Drops oldest on queue overflow (single console warning).
    - Retries 5xx and network errors with exponential backoff; drops
      on 4xx with a single console warning.
    - Pluggable `HttpSender` typedef for tests; default uses
      dart:io.HttpClient.

  CrashPlugin (ios/Classes/CrashPlugin.swift,
               android/src/main/kotlin/.../CrashPlugin.kt)
    - Installs uncaught-exception handlers
      (NSSetUncaughtExceptionHandler / Thread.UncaughtExceptionHandler),
      chains to the prior handler so the platform's default kill path
      still runs.
    - Writes one JSON file per crash to <cacheDir>/ux_crashes/<uuid>.json.
      iOS captures NSException.name/reason/userInfo + call-stack symbols
      and return addresses. Android captures thread name, exception
      class, message, full stack (including cause chain).
    - Caps the directory at 50 files; drops oldest by mtime on overflow.
    - Exposes method channel `ux/crash` with drainPending / ackCrash /
      triggerTestCrash. Registered in UxPlugin on both platforms.

  UxCrash.drainAndReport (lib/src/crash.dart)
    - Pulls persisted crash records on boot, re-emits each via Log.f
      (tag `ux.crash`) so they flow out through whatever sink chain
      the app installed, then acks each id.
    - Tolerates MissingPluginException silently; PlatformException is
      logged as a single warn without throwing.

Tests:
  - log_http_test.dart: payload shape, batching, retry doubling on 5xx,
    drop on 4xx, queue overflow ordering, non-encodable field
    stringification, real loopback HTTP round-trip with the default
    sender.
  - log_http_e2e_test.dart: opt-in real-server round-trip gated by
    --dart-define=E2E_LOG_ENDPOINT/E2E_LOG_TOKEN.
  - crash_test.dart: drain + re-emit + ack across iOS and Android
    shapes, MissingPluginException tolerance, PlatformException
    warn-not-throw.
2026-05-11 12:07:26 +03:00

101 lines
3.1 KiB
Dart

import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ux/ux.dart';
void _noop() {}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final messenger = TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger;
const channel = MethodChannel('ux/crash');
setUp(() {
messenger.setMockMethodCallHandler(channel, null);
});
tearDown(() {
messenger.setMockMethodCallHandler(channel, null);
});
test('drainAndReport re-emits records as Log.f and acks each id', () async {
final sink = MemorySink();
Log.configure(sink: sink, minLevel: LogLevel.trace, captureCrashes: _noop);
final acked = <String>[];
messenger.setMockMethodCallHandler(channel, (call) async {
switch (call.method) {
case 'drainPending':
return <Map<Object?, Object?>>[
{
'id': 'A',
'platform': 'ios',
'name': 'NSInvalidArgumentException',
'reason': 'bad index',
'callStackSymbols': <Object?>['0 frame'],
'bundleId': 'im.bl.app',
},
{
'id': 'B',
'platform': 'android',
'type': 'java.lang.RuntimeException',
'message': 'boom',
'stack': 'at Foo.bar()\nat Baz.qux()',
'sdkInt': 34,
},
];
case 'ackCrash':
acked.add(call.arguments as String);
return null;
}
return null;
});
await UxCrash.drainAndReport();
final records = sink.snapshot();
expect(records.length, 2);
expect(records[0].level, LogLevel.fatal);
expect(records[0].tag, 'ux.crash');
expect(records[0].message, contains('NSInvalidArgumentException'));
expect(records[0].message, contains('bad index'));
expect(records[0].stackTrace.toString(), '0 frame');
expect(records[0].fields?['platform'], 'ios');
expect(records[0].fields?['bundleId'], 'im.bl.app');
expect(records[1].message, contains('java.lang.RuntimeException'));
expect(records[1].message, contains('boom'));
expect(records[1].stackTrace.toString(), contains('Foo.bar()'));
expect(records[1].fields?['sdkInt'], 34);
expect(acked, ['A', 'B']);
});
test('drainAndReport tolerates MissingPluginException', () async {
final sink = MemorySink();
Log.configure(sink: sink, minLevel: LogLevel.trace, captureCrashes: _noop);
messenger.setMockMethodCallHandler(channel, (call) async {
throw MissingPluginException();
});
await UxCrash.drainAndReport();
expect(sink.snapshot(), isEmpty);
});
test('drainAndReport logs but does not throw on channel errors', () async {
final sink = MemorySink();
Log.configure(sink: sink, minLevel: LogLevel.trace, captureCrashes: _noop);
messenger.setMockMethodCallHandler(channel, (call) async {
throw PlatformException(code: 'oops');
});
await UxCrash.drainAndReport();
expect(sink.snapshot().length, 1);
expect(sink.snapshot().single.level, LogLevel.warn);
expect(sink.snapshot().single.message, contains('drainPending'));
});
}