fix(android): persist startup and runtime exit diagnostics

This commit is contained in:
edde746
2026-07-24 03:56:40 +02:00
parent 9f2e050797
commit fb45ff44f3
11 changed files with 1539 additions and 124 deletions
@@ -0,0 +1,60 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/base_shared_preferences_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../test_helpers/prefs.dart';
void main() {
setUp(resetSharedPreferencesForTest);
tearDown(BaseSharedPreferencesService.resetForTesting);
test('failed shared cache load is coalesced but a later call retries', () async {
final firstLoad = Completer<SharedPreferencesWithCache>();
final originalError = StateError('preferences unavailable');
final originalStackTrace = StackTrace.current;
var loadCount = 0;
BaseSharedPreferencesService.setCacheLoaderForTesting(() {
loadCount++;
if (loadCount == 1) return firstLoad.future;
return SharedPreferencesWithCache.create(cacheOptions: const SharedPreferencesWithCacheOptions());
});
final firstCaller = BaseSharedPreferencesService.sharedCache();
final concurrentCaller = BaseSharedPreferencesService.sharedCache();
expect(identical(firstCaller, concurrentCaller), isTrue);
expect(loadCount, 1);
firstLoad.completeError(originalError, originalStackTrace);
Object? caughtError;
StackTrace? caughtStackTrace;
try {
await firstCaller;
} catch (error, stackTrace) {
caughtError = error;
caughtStackTrace = stackTrace;
}
expect(identical(caughtError, originalError), isTrue);
expect(caughtStackTrace.toString(), originalStackTrace.toString());
final recovered = await BaseSharedPreferencesService.sharedCache();
expect(loadCount, 2);
await recovered.setBool('recovered', true);
expect(recovered.getBool('recovered'), isTrue);
});
test('reset restores the production cache loader', () async {
BaseSharedPreferencesService.setCacheLoaderForTesting(
() => Future<SharedPreferencesWithCache>.error(StateError('injected failure')),
);
BaseSharedPreferencesService.resetForTesting();
final cache = await BaseSharedPreferencesService.sharedCache();
await cache.setString('loader', 'production');
expect(cache.getString('loader'), 'production');
});
}
+107
View File
@@ -0,0 +1,107 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/main.dart';
void main() {
testWidgets('renders a Flutter frame before starting the initialization gate', (tester) async {
final completion = Completer<int>();
var bootstrapWasMounted = false;
await tester.pumpWidget(
StartupBootstrap<int>(
initialize: () {
bootstrapWasMounted = find.byKey(startupBootstrapProgressKey).evaluate().isNotEmpty;
return completion.future;
},
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
),
);
expect(bootstrapWasMounted, isTrue);
expect(find.byKey(startupBootstrapProgressKey), findsOneWidget);
completion.complete(1);
await tester.pump();
});
testWidgets('replaces bootstrap UI with the initialized app on success', (tester) async {
final completion = Completer<int>();
await tester.pumpWidget(
StartupBootstrap<int>(
initialize: () => completion.future,
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
),
);
completion.complete(7);
await tester.pump();
expect(find.text('ready 7'), findsOneWidget);
expect(find.byKey(startupBootstrapProgressKey), findsNothing);
});
testWidgets('shows a localized recoverable failure instead of removing Flutter UI', (tester) async {
await tester.pumpWidget(
StartupBootstrap<int>(
initialize: () async => throw StateError('database unavailable'),
buildApp: (_, value) => Text('ready $value'),
),
);
await tester.pump();
expect(find.byKey(startupBootstrapFailureKey), findsOneWidget);
expect(find.text('Error'), findsOneWidget);
expect(find.text('Retry'), findsOneWidget);
});
testWidgets('retry clears the failed generation and can commit a later success', (tester) async {
final retryCompletion = Completer<int>();
var attempts = 0;
await tester.pumpWidget(
StartupBootstrap<int>(
initialize: () {
attempts++;
if (attempts == 1) return Future<int>.error(StateError('first attempt'));
return retryCompletion.future;
},
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
),
);
await tester.pump();
await tester.tap(find.byKey(startupBootstrapRetryKey));
await tester.pump();
expect(attempts, 2);
expect(find.byKey(startupBootstrapProgressKey), findsOneWidget);
retryCompletion.complete(42);
await tester.pump();
expect(find.text('ready 42'), findsOneWidget);
expect(find.byKey(startupBootstrapFailureKey), findsNothing);
});
testWidgets('discards a completion from a disposed bootstrap generation', (tester) async {
final completion = Completer<int>();
final discarded = <int>[];
await tester.pumpWidget(
StartupBootstrap<int>(
initialize: () => completion.future,
buildApp: (_, value) => Text('ready $value'),
discard: discarded.add,
),
);
await tester.pumpWidget(const SizedBox.shrink());
completion.complete(9);
await tester.pump();
expect(discarded, [9]);
expect(find.text('ready 9'), findsNothing);
});
}
@@ -0,0 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/android_exit_diagnostics.dart';
import 'package:plezy/utils/app_logger.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('startup phase vocabulary is fixed and elapsed logging is monotonic', () async {
expect(AndroidStartupPhase.values.map((phase) => phase.id), [
'native_on_create',
'dart_main',
'runApp',
'first_frame',
'database_open_started',
'database_ready',
'credentials_loaded',
'binding_started',
'binding_settled',
'main_screen',
]);
expect(AndroidUiState.values.map((state) => state.id), ['main_screen', 'player']);
MemoryLogOutput.clearLogs();
AndroidExitDiagnostics.markTelemetryReady();
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.databaseOpenStarted);
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.databaseReady);
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.credentialsLoaded);
await Future<void>.delayed(Duration.zero);
final messages = MemoryLogOutput.getLogs()
.map((entry) => entry.message)
.where((message) => message.startsWith('Startup phase:'))
.toList()
.reversed
.toList();
expect(messages, hasLength(3));
expect(messages[0], contains('phase=database_open_started'));
expect(messages[1], contains('phase=database_ready'));
expect(messages[2], contains('phase=credentials_loaded'));
final elapsed = messages
.map((message) => int.parse(RegExp(r'elapsedMs=(\d+)').firstMatch(message)!.group(1)!))
.toList();
expect(elapsed[1], greaterThanOrEqualTo(elapsed[0]));
expect(elapsed[2], greaterThanOrEqualTo(elapsed[1]));
});
}