fix(android): persist startup and runtime exit diagnostics
This commit is contained in:
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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]));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user