V3-12: crash reporting (code only)

shouldInitializeCrashReporting() and scrubExtra() are pure, fully unit-tested decision/scrubbing functions wired into sentry_flutter via beforeSend/beforeBreadcrumb. Config.crashReportingEnabled defaults off; the DSN is a compile-time --dart-define, not a preference. RecordingEngine gained an injected onUnexpectedStop callback, firing once when restoreAfterProcessDeath finds a trip still 'recording' at launch -- the process died without a user stop.

Marked partially done: the ticket's real acceptance criteria (a forced crash landing in a real Sentry project from a release build, inspecting a real payload) need a Sentry account and a release build this environment can't produce.
This commit is contained in:
2026-08-17 19:32:54 -05:00
parent 0e605ef174
commit be50448917
14 changed files with 443 additions and 9 deletions

View File

@@ -55,6 +55,19 @@ void main() {
});
});
group('crashReportingEnabled', () {
test('defaults to false', () async {
final config = await freshConfig();
expect(config.crashReportingEnabled, isFalse);
});
test('round-trips through set/get', () async {
final config = await freshConfig();
await config.setCrashReportingEnabled(true);
expect(config.crashReportingEnabled, isTrue);
});
});
group('uploadEndpoint', () {
test('defaults to empty, not null or a placeholder', () async {
final config = await freshConfig();

View File

@@ -0,0 +1,114 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/crash/crash_reporter.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
/// V3-12: a crash reporter in a location app is a privacy surface. Every test here
/// exists to make a specific claim provable rather than assumed -- "the scrubber strips
/// coordinates" and "disabled means never initialised" are both named directly in the
/// ticket's Tests section.
void main() {
group('shouldInitializeCrashReporting', () {
test('requires enabled, release, and a configured dsn all at once', () {
expect(
shouldInitializeCrashReporting(enabled: true, isDebug: false, dsn: 'https://x'),
isTrue,
);
});
test('the user toggle being off means the client is never initialised', () {
expect(
shouldInitializeCrashReporting(enabled: false, isDebug: false, dsn: 'https://x'),
isFalse,
);
});
test('debug builds send nothing, even if enabled', () {
expect(
shouldInitializeCrashReporting(enabled: true, isDebug: true, dsn: 'https://x'),
isFalse,
);
});
test('no dsn configured means no client, regardless of the toggle', () {
expect(
shouldInitializeCrashReporting(enabled: true, isDebug: false, dsn: ''),
isFalse,
);
});
});
group('scrubExtra', () {
test('strips every coordinate-shaped key', () {
final scrubbed = scrubExtra({
'latitude': 51.0447,
'longitude': -114.0719,
'lat': 51.0,
'lon': -114.0,
'startLat': 51.0,
'gps_coords': '51.0,-114.0',
'altitude': 1045.0,
'safe_field': 'kept',
})!;
expect(scrubbed.containsKey('safe_field'), isTrue);
expect(scrubbed, hasLength(1),
reason: 'no field describing a location may survive: $scrubbed');
});
test('strips device and trip identity', () {
final scrubbed = scrubExtra({
'device_id': 'abc-123',
'deviceId': 'abc-123',
'trip_id': 42,
'tripId': 42,
'screen': 'record',
})!;
expect(scrubbed, {'screen': 'record'});
});
test('matching is case-insensitive and matches substrings', () {
final scrubbed = scrubExtra({'Latitude': 1.0, 'nested.longitude': 2.0})!;
expect(scrubbed, isEmpty);
});
test('null in, null out', () {
expect(scrubExtra(null), isNull);
});
test('an already-clean map is returned unchanged in content', () {
final scrubbed = scrubExtra({'screen': 'settings', 'action': 'tap'})!;
expect(scrubbed, {'screen': 'settings', 'action': 'tap'});
});
});
group('event scrubbing end to end', () {
test('a representative event with coordinates in extra and a breadcrumb comes '
'out clean', () {
final event = SentryEvent(
// ignore: deprecated_member_use
extra: {'latitude': 51.0447, 'longitude': -114.0719, 'screen': 'record'},
breadcrumbs: [
Breadcrumb(
message: 'fix received',
data: {'lat': 51.0, 'lon': -114.0, 'accuracy': 5.0},
),
],
);
// Exercises the same path SentryOptions.beforeSend is configured with in
// maybeInitCrashReporting, without needing a real Sentry client.
// ignore: deprecated_member_use
final scrubbedExtra = scrubExtra(event.extra)!;
final scrubbedBreadcrumbData = scrubExtra(event.breadcrumbs!.single.data)!;
expect(scrubbedExtra.containsKey('latitude'), isFalse);
expect(scrubbedExtra.containsKey('longitude'), isFalse);
expect(scrubbedExtra['screen'], 'record');
expect(scrubbedBreadcrumbData.containsKey('lat'), isFalse);
expect(scrubbedBreadcrumbData.containsKey('lon'), isFalse);
expect(scrubbedBreadcrumbData['accuracy'], 5.0,
reason: 'accuracy is not a coordinate and carries no location on its own');
});
});
}

View File

@@ -336,6 +336,50 @@ void main() {
reason: 'the dead time is a real gap and must render as one');
});
test('a recording trip found at restart reports an unexpected stop (V3-12)',
() async {
await engine.start();
await ride(5);
await engine.dispose();
String? reportedReason;
final revived = RecordingEngine(
repository: repo,
locationSource: source,
clock: () => fakeNow,
onUnexpectedStop: (reason) => reportedReason = reason,
);
addTearDown(revived.dispose);
fakeNow = 20000;
await revived.restoreAfterProcessDeath();
expect(reportedReason, isNotNull,
reason: 'the recording stopped without anyone choosing that -- the exact '
'failure V3-12 exists to surface');
});
test('a cleanly-completed trip reports nothing at restart', () async {
await engine.start();
await ride(5);
await engine.stop();
await engine.dispose();
var called = false;
final revived = RecordingEngine(
repository: repo,
locationSource: source,
clock: () => fakeNow,
onUnexpectedStop: (_) => called = true,
);
addTearDown(revived.dispose);
await revived.restoreAfterProcessDeath();
expect(called, isFalse,
reason: 'a rider who pressed Stop is not a crash and must not be reported');
});
test('the dead time is never measured as distance', () async {
// The bug this guards is in the native app: after a crash the open segment is
// adopted rather than closed, so computeSummary -- the authoritative pass at trip

View File

@@ -90,10 +90,36 @@ void main() {
expect(config.mountedMode, isTrue);
});
testWidgets('the crash reporting switch writes through to Config (V3-12)',
(tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
final initial = tester
.widget<SwitchListTile>(find.byKey(const Key('crash-reporting-switch')))
.value;
expect(initial, isFalse, reason: 'off until a privacy policy exists');
await tester.tap(find.byKey(const Key('crash-reporting-switch')));
await tester.pumpAndSettle();
expect(config.crashReportingEnabled, isTrue);
});
/// The Sync section has been pushed below the default test viewport by every section
/// added above it since this suite was first written (V3-05, then V3-12) -- a
/// `ListView` does not mount elements outside its viewport/cache extent, so `find`
/// cannot see them until scrolled into view.
Future<void> scrollToSync(WidgetTester tester) async {
await tester.drag(find.byType(ListView).first, const Offset(0, -600));
await tester.pumpAndSettle();
}
group('upload endpoint', () {
testWidgets('a valid https URL is saved', (tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await scrollToSync(tester);
await tester.enterText(
find.byKey(const Key('endpoint-field')),
@@ -110,6 +136,7 @@ void main() {
'silently stored', (tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await scrollToSync(tester);
await tester.enterText(
find.byKey(const Key('endpoint-field')),
@@ -126,6 +153,7 @@ void main() {
testWidgets('a non-http scheme is rejected', (tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await scrollToSync(tester);
await tester.enterText(
find.byKey(const Key('endpoint-field')),
@@ -142,6 +170,7 @@ void main() {
await config.setUploadEndpoint('https://example.test/ingest');
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await scrollToSync(tester);
await tester.enterText(find.byKey(const Key('endpoint-field')), '');
await tester.tap(find.byKey(const Key('save-endpoint')));
@@ -156,13 +185,11 @@ void main() {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await scrollToSync(tester);
expect(find.text(config.deviceId), findsOneWidget);
expect(find.byKey(const Key('copy-device-id')), findsOneWidget);
// The list has grown past one screen (V3-05 added a section above this), so the
// control is not necessarily within the default test viewport.
await tester.drag(find.byType(ListView).first, const Offset(0, -400));
await tester.pumpAndSettle();
// Tapping must not throw even though no real clipboard exists in the test harness.
await tester.tap(find.byKey(const Key('copy-device-id')));
await tester.pumpAndSettle();