2026-07-23 22:43:07 +02:00
|
|
|
import 'dart:async';
|
2026-07-24 07:41:01 +02:00
|
|
|
import 'dart:io';
|
2026-08-08 07:14:36 +02:00
|
|
|
import 'dart:typed_data';
|
2026-07-23 22:43:07 +02:00
|
|
|
|
2026-07-25 16:16:04 +02:00
|
|
|
import 'package:drift/drift.dart' show ApplyInterceptor, QueryExecutor, QueryExecutorUser, QueryInterceptor;
|
2026-07-24 07:41:01 +02:00
|
|
|
import 'package:drift/native.dart';
|
2026-07-23 22:43:07 +02:00
|
|
|
import 'package:flutter/material.dart';
|
2026-08-08 07:14:36 +02:00
|
|
|
import 'package:flutter/rendering.dart';
|
2026-07-23 22:43:07 +02:00
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
2026-07-24 07:41:01 +02:00
|
|
|
import 'package:plezy/database/app_database.dart';
|
|
|
|
|
import 'package:plezy/database/download_operations.dart';
|
|
|
|
|
import 'package:plezy/database/tvos_database_recovery_store.dart';
|
2026-08-20 20:40:14 +02:00
|
|
|
import 'package:plezy/i18n/strings.g.dart';
|
2026-07-23 22:43:07 +02:00
|
|
|
import 'package:plezy/main.dart';
|
2026-07-24 07:41:01 +02:00
|
|
|
import 'package:plezy/media/ids.dart';
|
|
|
|
|
import 'package:plezy/models/download_models.dart';
|
2026-08-08 07:14:36 +02:00
|
|
|
import 'package:plezy/providers/theme_provider.dart';
|
2026-07-25 16:16:04 +02:00
|
|
|
import 'package:plezy/services/base_shared_preferences_service.dart';
|
2026-07-31 07:05:46 +02:00
|
|
|
import 'package:plezy/services/prefs_recovery.dart';
|
2026-08-08 07:14:36 +02:00
|
|
|
import 'package:plezy/services/settings_service.dart' as settings;
|
2026-07-31 07:05:46 +02:00
|
|
|
import 'package:plezy/services/startup_diagnostics.dart';
|
2026-08-08 07:14:36 +02:00
|
|
|
import 'package:plezy/theme/mono_theme.dart';
|
2026-08-20 20:40:14 +02:00
|
|
|
import 'package:plezy/utils/dialogs.dart';
|
|
|
|
|
import 'package:plezy/utils/layout_constants.dart';
|
2026-07-31 07:05:46 +02:00
|
|
|
import 'package:plezy/widgets/startup_failure_view.dart';
|
2026-07-25 16:16:04 +02:00
|
|
|
|
2026-07-26 03:45:07 +02:00
|
|
|
import 'test_helpers/download_fixtures.dart';
|
2026-07-25 16:16:04 +02:00
|
|
|
import 'test_helpers/prefs.dart';
|
|
|
|
|
|
|
|
|
|
final class _OpenTrackingInterceptor extends QueryInterceptor {
|
2026-07-27 17:44:52 +02:00
|
|
|
_OpenTrackingInterceptor({this.failure, this.updateFailure});
|
2026-07-25 16:16:04 +02:00
|
|
|
|
|
|
|
|
final Object? failure;
|
2026-07-27 17:44:52 +02:00
|
|
|
final Object? updateFailure;
|
2026-07-25 16:16:04 +02:00
|
|
|
var ensureOpenCalls = 0;
|
|
|
|
|
var ensureOpenCompleted = false;
|
|
|
|
|
var closed = false;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<bool> ensureOpen(QueryExecutor executor, QueryExecutorUser user) async {
|
|
|
|
|
ensureOpenCalls++;
|
|
|
|
|
final failure = this.failure;
|
|
|
|
|
if (failure != null) throw failure;
|
|
|
|
|
|
|
|
|
|
final result = await executor.ensureOpen(user);
|
|
|
|
|
ensureOpenCompleted = true;
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 17:44:52 +02:00
|
|
|
@override
|
|
|
|
|
Future<int> runUpdate(QueryExecutor executor, String statement, List<Object?> args) {
|
|
|
|
|
final updateFailure = this.updateFailure;
|
|
|
|
|
if (updateFailure != null) throw updateFailure;
|
|
|
|
|
return executor.runUpdate(statement, args);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 16:16:04 +02:00
|
|
|
@override
|
|
|
|
|
Future<void> close(QueryExecutor inner) async {
|
|
|
|
|
await inner.close();
|
|
|
|
|
closed = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-23 22:43:07 +02:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-08 07:14:36 +02:00
|
|
|
// The window behind Flutter already holds the launch colour: on Android
|
|
|
|
|
// `MainActivity` restores the persisted one and the television resource
|
|
|
|
|
// qualifier pins the default to black. Anything the loading frame paints
|
|
|
|
|
// over it lasts the whole gate, so #1833 saw a near-white #F7F7F8 sheet in
|
|
|
|
|
// place of a black TV splash.
|
|
|
|
|
const windowKey = Key('window');
|
|
|
|
|
const launchScreen = Color(0xFF123456);
|
|
|
|
|
|
|
|
|
|
Future<int> windowPixel(WidgetTester tester) async {
|
|
|
|
|
final boundary = tester.renderObject<RenderRepaintBoundary>(find.byKey(windowKey));
|
|
|
|
|
late ByteData bytes;
|
|
|
|
|
await tester.runAsync(() async {
|
|
|
|
|
final image = await boundary.toImage();
|
|
|
|
|
bytes = (await image.toByteData())!;
|
|
|
|
|
image.dispose();
|
|
|
|
|
});
|
|
|
|
|
// Top-left corner in rawRgba: outside the centred progress indicator.
|
|
|
|
|
return Color.fromARGB(bytes.getUint8(3), bytes.getUint8(0), bytes.getUint8(1), bytes.getUint8(2)).toARGB32();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget overLaunchScreen(Widget child) => RepaintBoundary(
|
|
|
|
|
key: windowKey,
|
|
|
|
|
child: ColoredBox(color: launchScreen, child: child),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
ThemeData bootstrapTheme(WidgetTester tester) => Theme.of(tester.element(find.byKey(startupBootstrapProgressKey)));
|
|
|
|
|
|
|
|
|
|
testWidgets('the loading frame leaves the platform launch screen visible', (tester) async {
|
|
|
|
|
final completion = Completer<int>();
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
overLaunchScreen(
|
|
|
|
|
StartupBootstrap<int>(
|
|
|
|
|
initialize: () => completion.future,
|
|
|
|
|
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
|
|
|
|
lightTheme: monoTheme(dark: false),
|
|
|
|
|
darkTheme: monoTheme(dark: true),
|
|
|
|
|
transparentWhileLoading: true,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(find.byKey(startupBootstrapProgressKey), findsOneWidget);
|
|
|
|
|
expect(await windowPixel(tester), launchScreen.toARGB32());
|
|
|
|
|
|
|
|
|
|
completion.complete(1);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('the loading frame paints a background where nothing is behind Flutter', (tester) async {
|
|
|
|
|
final completion = Completer<int>();
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
overLaunchScreen(
|
|
|
|
|
StartupBootstrap<int>(
|
|
|
|
|
initialize: () => completion.future,
|
|
|
|
|
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
|
|
|
|
lightTheme: monoTheme(dark: false),
|
|
|
|
|
darkTheme: monoTheme(dark: true),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(await windowPixel(tester), monoTheme(dark: false).scaffoldBackgroundColor.toARGB32());
|
|
|
|
|
|
|
|
|
|
completion.complete(1);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('the failure screen stays opaque over the launch screen', (tester) async {
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
overLaunchScreen(
|
|
|
|
|
StartupBootstrap<int>(
|
|
|
|
|
initialize: () async => throw StateError('database unavailable'),
|
|
|
|
|
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
|
|
|
|
lightTheme: monoTheme(dark: false),
|
|
|
|
|
darkTheme: monoTheme(dark: true),
|
|
|
|
|
transparentWhileLoading: true,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
expect(find.byKey(startupBootstrapFailureKey), findsOneWidget);
|
|
|
|
|
expect(await windowPixel(tester), monoTheme(dark: false).scaffoldBackgroundColor.toARGB32());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('adopts the persisted theme instead of platform brightness', (tester) async {
|
|
|
|
|
// What a Fire TV or Shield reports: no system dark-mode toggle, so
|
|
|
|
|
// ThemeMode.system resolves light while the app's own TV default is OLED.
|
|
|
|
|
tester.platformDispatcher.platformBrightnessTestValue = Brightness.light;
|
|
|
|
|
addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue);
|
|
|
|
|
|
|
|
|
|
final resolved = Completer<StartupThemeResolution>();
|
|
|
|
|
final completion = Completer<int>();
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
StartupBootstrap<int>(
|
|
|
|
|
initialize: () => completion.future,
|
|
|
|
|
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
|
|
|
|
lightTheme: monoTheme(dark: false),
|
|
|
|
|
darkTheme: monoTheme(dark: true),
|
|
|
|
|
resolveTheme: () => resolved.future,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(bootstrapTheme(tester).scaffoldBackgroundColor, monoTheme(dark: false).scaffoldBackgroundColor);
|
|
|
|
|
|
|
|
|
|
resolved.complete((
|
|
|
|
|
themeMode: ThemeProvider.materialThemeModeFor(settings.ThemeMode.oled),
|
|
|
|
|
darkTheme: ThemeProvider.darkThemeFor(settings.ThemeMode.oled),
|
|
|
|
|
));
|
|
|
|
|
await tester.pump();
|
|
|
|
|
await tester.pump(const Duration(milliseconds: 400));
|
|
|
|
|
|
|
|
|
|
expect(bootstrapTheme(tester).brightness, Brightness.dark);
|
|
|
|
|
expect(bootstrapTheme(tester).scaffoldBackgroundColor, const Color(0xFF000000));
|
|
|
|
|
|
|
|
|
|
completion.complete(1);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('a theme preference that cannot be read does not block the gate', (tester) async {
|
|
|
|
|
final completion = Completer<int>();
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
StartupBootstrap<int>(
|
|
|
|
|
initialize: () => completion.future,
|
|
|
|
|
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
|
|
|
|
resolveTheme: () async => throw const FormatException('unreadable'),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
expect(find.byKey(startupBootstrapProgressKey), findsOneWidget);
|
|
|
|
|
|
|
|
|
|
completion.complete(3);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
expect(find.text('ready 3'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-23 22:43:07 +02:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-31 07:05:46 +02:00
|
|
|
testWidgets('names the failing phase instead of showing a bare error', (tester) async {
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
StartupBootstrap<int>(
|
|
|
|
|
initialize: () async => throw const StartupPhaseException(StartupPhase.database, FormatException('boom')),
|
|
|
|
|
buildApp: (_, value) => Text('ready $value'),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
expect(find.byKey(startupBootstrapFailureKey), findsOneWidget);
|
|
|
|
|
expect(find.byKey(startupBootstrapRetryKey), findsOneWidget);
|
|
|
|
|
expect(find.byKey(startupFailureCopyKey), findsOneWidget);
|
|
|
|
|
// The phase and concrete type are what turn "Error" into a report.
|
|
|
|
|
expect(find.textContaining('database'), findsOneWidget);
|
|
|
|
|
expect(find.textContaining('FormatException'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('expands the full detail block on request', (tester) async {
|
2026-07-23 22:43:07 +02:00
|
|
|
await tester.pumpWidget(
|
|
|
|
|
StartupBootstrap<int>(
|
|
|
|
|
initialize: () async => throw StateError('database unavailable'),
|
|
|
|
|
buildApp: (_, value) => Text('ready $value'),
|
2026-07-31 07:05:46 +02:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
expect(find.byKey(startupFailureDetailsKey), findsNothing);
|
|
|
|
|
await tester.tap(find.text('Show details'));
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
expect(find.byKey(startupFailureDetailsKey), findsOneWidget);
|
|
|
|
|
expect(find.textContaining('database unavailable'), findsWidgets);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('offers a repair only for a repairable failure', (tester) async {
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
StartupBootstrap<int>(
|
|
|
|
|
initialize: () async => throw StateError('unrelated'),
|
|
|
|
|
buildApp: (_, value) => Text('ready $value'),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
expect(find.byKey(startupFailureRepairKey), findsNothing);
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
StartupBootstrap<int>(
|
|
|
|
|
key: const Key('repairable'),
|
|
|
|
|
initialize: () async => throw CorruptPreferenceStoreException(const FormatException('bad'), StackTrace.current),
|
|
|
|
|
buildApp: (_, value) => Text('ready $value'),
|
2026-07-23 22:43:07 +02:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump();
|
2026-07-31 07:05:46 +02:00
|
|
|
expect(find.byKey(startupFailureRepairKey), findsOneWidget);
|
|
|
|
|
});
|
2026-07-23 22:43:07 +02:00
|
|
|
|
2026-07-31 07:05:46 +02:00
|
|
|
testWidgets('re-runs initialization after a successful repair', (tester) async {
|
|
|
|
|
var attempts = 0;
|
|
|
|
|
var repairCalls = 0;
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
StartupBootstrap<int>(
|
|
|
|
|
initialize: () async {
|
|
|
|
|
attempts++;
|
|
|
|
|
if (attempts == 1) {
|
|
|
|
|
throw CorruptPreferenceStoreException(const FormatException('bad'), StackTrace.current);
|
|
|
|
|
}
|
|
|
|
|
return 7;
|
|
|
|
|
},
|
|
|
|
|
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
|
|
|
|
repair: (_, _, _) async {
|
|
|
|
|
repairCalls++;
|
2026-08-01 02:53:29 +02:00
|
|
|
return StartupRepairResult.retry;
|
2026-07-31 07:05:46 +02:00
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.byKey(startupFailureRepairKey));
|
|
|
|
|
await tester.pump();
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
expect(repairCalls, 1);
|
|
|
|
|
expect(attempts, 2);
|
|
|
|
|
expect(find.text('ready 7'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('a repair that reports no change does not retry', (tester) async {
|
|
|
|
|
var attempts = 0;
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
StartupBootstrap<int>(
|
|
|
|
|
initialize: () async {
|
|
|
|
|
attempts++;
|
|
|
|
|
throw CorruptPreferenceStoreException(const FormatException('bad'), StackTrace.current);
|
|
|
|
|
},
|
|
|
|
|
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
2026-08-01 02:53:29 +02:00
|
|
|
repair: (_, _, _) async => StartupRepairResult.none,
|
2026-07-31 07:05:46 +02:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.byKey(startupFailureRepairKey));
|
|
|
|
|
await tester.pump();
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
expect(attempts, 1);
|
2026-07-23 22:43:07 +02:00
|
|
|
expect(find.byKey(startupBootstrapFailureKey), findsOneWidget);
|
2026-07-31 07:05:46 +02:00
|
|
|
});
|
|
|
|
|
|
2026-08-01 02:53:29 +02:00
|
|
|
testWidgets('a repair that needs a restart parks the app instead of retrying', (tester) async {
|
|
|
|
|
// The repair seeds the salvaged credentials straight to disk and leaves
|
|
|
|
|
// this process's store closed, because the plugin still holds the bad
|
|
|
|
|
// document. Re-running the gate would reopen onto that stale map and the
|
|
|
|
|
// first write would flush it over the seed, destroying the vault key and
|
|
|
|
|
// orphaning every ciphertext token in the database (#1732).
|
|
|
|
|
var attempts = 0;
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
StartupBootstrap<int>(
|
|
|
|
|
initialize: () async {
|
|
|
|
|
attempts++;
|
|
|
|
|
throw CorruptPreferenceStoreException(const FormatException('bad'), StackTrace.current);
|
|
|
|
|
},
|
|
|
|
|
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
|
|
|
|
repair: (_, _, _) async => StartupRepairResult.restart,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.byKey(startupFailureRepairKey));
|
|
|
|
|
await tester.pump();
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
expect(attempts, 1);
|
|
|
|
|
// Withdrawn, not disabled: both are the actions that would touch the store.
|
|
|
|
|
expect(find.byKey(startupBootstrapRetryKey), findsNothing);
|
|
|
|
|
expect(find.byKey(startupFailureRepairKey), findsNothing);
|
|
|
|
|
// And the user is actually told what to do about it.
|
|
|
|
|
expect(find.byKey(startupFailureRestartKey), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('the restart state survives and still allows uploading the diagnostic', (tester) async {
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
StartupBootstrap<int>(
|
|
|
|
|
initialize: () async => throw CorruptPreferenceStoreException(const FormatException('bad'), StackTrace.current),
|
|
|
|
|
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
|
|
|
|
repair: (_, _, _) async => StartupRepairResult.restart,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
await tester.tap(find.byKey(startupFailureRepairKey));
|
|
|
|
|
await tester.pump();
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
// Copy and Upload stay live — the whole point of the failure screen is
|
|
|
|
|
// that a stuck user can still get the diagnostic out.
|
|
|
|
|
expect(find.byKey(startupFailureCopyKey), findsOneWidget);
|
|
|
|
|
expect(find.byKey(startupFailureUploadKey), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-20 20:40:14 +02:00
|
|
|
testWidgets('repair receives a context below the bootstrap MaterialApp that can show its dialogs', (tester) async {
|
|
|
|
|
// Regression: _repair used to pass the gate State's own context, which
|
|
|
|
|
// sits above the bootstrap MaterialApp — no Navigator, no
|
|
|
|
|
// MaterialLocalizations — so the consent dialog threw and the Repair
|
|
|
|
|
// button on the startup-failure screen could never work.
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
StartupBootstrap<int>(
|
|
|
|
|
initialize: () async => throw CorruptPreferenceStoreException(const FormatException('bad'), StackTrace.current),
|
|
|
|
|
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
|
|
|
|
repair: (context, _, _) async {
|
|
|
|
|
final confirmed = await showConfirmDialog(
|
|
|
|
|
context,
|
|
|
|
|
title: t.startup.repairTitle,
|
|
|
|
|
message: 'the cost',
|
|
|
|
|
confirmText: t.startup.repairConfirm,
|
|
|
|
|
);
|
|
|
|
|
return confirmed ? StartupRepairResult.retry : StartupRepairResult.none;
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.byKey(startupFailureRepairKey));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(tester.takeException(), isNull);
|
|
|
|
|
expect(find.text(t.startup.repairTitle), findsOneWidget);
|
|
|
|
|
|
|
|
|
|
// The dialog is live, not just painted: cancelling resolves the repair
|
|
|
|
|
// and hands the failure screen back.
|
|
|
|
|
await tester.tap(find.text(t.common.cancel));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(find.text(t.startup.repairTitle), findsNothing);
|
|
|
|
|
expect(find.byKey(startupBootstrapFailureKey), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('a repair that throws reports the failure via the bootstrap snackbar without throwing', (tester) async {
|
|
|
|
|
// Regression: the catch used the same above-MaterialApp context for
|
|
|
|
|
// showErrorSnackBar, so reporting the repair failure threw a second
|
|
|
|
|
// exception (no ScaffoldMessenger). The gate's own MaterialApp must carry
|
|
|
|
|
// the snackbar — rootScaffoldMessengerKey is not mounted during bootstrap.
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
StartupBootstrap<int>(
|
|
|
|
|
initialize: () async => throw CorruptPreferenceStoreException(const FormatException('bad'), StackTrace.current),
|
|
|
|
|
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
|
|
|
|
repair: (_, _, _) async => throw StateError('repair blew up'),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.byKey(startupFailureRepairKey));
|
|
|
|
|
await tester.pump();
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
expect(tester.takeException(), isNull);
|
|
|
|
|
expect(find.text(t.startup.repairFailed), findsOneWidget);
|
|
|
|
|
expect(find.byKey(startupBootstrapFailureKey), findsOneWidget);
|
|
|
|
|
|
|
|
|
|
// Let the snackbar's dismiss timer elapse so the test ends clean.
|
|
|
|
|
await tester.pump(AppDurations.snackBarLong);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-31 07:37:20 +02:00
|
|
|
testWidgets('persists the failure so it can be reported once the reporter is up', (tester) async {
|
|
|
|
|
// Not reported inline: the earliest gate phases run before crash
|
|
|
|
|
// reporting exists, so an inline capture would reach a no-op hub and be
|
|
|
|
|
// discarded. The record is held for `flushPendingStartupFailure` (#1732).
|
|
|
|
|
StartupDiagnosticsStore.resetForTesting();
|
|
|
|
|
addTearDown(StartupDiagnosticsStore.resetForTesting);
|
2026-07-31 07:05:46 +02:00
|
|
|
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
StartupBootstrap<int>(
|
|
|
|
|
initialize: () async => throw const StartupPhaseException(StartupPhase.storage, 'nope'),
|
|
|
|
|
buildApp: (_, value) => Text('ready $value'),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
2026-07-31 07:37:20 +02:00
|
|
|
expect(StartupDiagnosticsStore.pending?.phase, StartupPhase.storage);
|
|
|
|
|
expect(StartupDiagnosticsStore.pending?.reported, isFalse);
|
2026-07-23 22:43:07 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
2026-07-24 07:41:01 +02:00
|
|
|
|
2026-07-25 16:16:04 +02:00
|
|
|
test('storage-full lazy database open discards native work before retrying', () async {
|
|
|
|
|
resetSharedPreferencesForTest();
|
|
|
|
|
final tempDir = await Directory.systemTemp.createTemp('plezy_startup_storage_full_');
|
|
|
|
|
final file = File('${tempDir.path}/plezy_downloads.db');
|
|
|
|
|
final prefs = await BaseSharedPreferencesService.sharedCache();
|
|
|
|
|
final failedOpen = _OpenTrackingInterceptor(
|
|
|
|
|
failure: const FileSystemException('write failed: No space left on device'),
|
2026-07-24 07:41:01 +02:00
|
|
|
);
|
2026-07-25 16:16:04 +02:00
|
|
|
final successfulOpen = _OpenTrackingInterceptor();
|
|
|
|
|
AppDatabase? seeded;
|
|
|
|
|
AppDatabase? resultDatabase;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
seeded = AppDatabase.forTesting(NativeDatabase(file));
|
|
|
|
|
await seeded.insertDownload(
|
|
|
|
|
serverId: ServerId('srv'),
|
|
|
|
|
ratingKey: 'active',
|
|
|
|
|
globalKey: 'srv:active',
|
|
|
|
|
type: 'movie',
|
|
|
|
|
status: DownloadStatus.downloading.index,
|
|
|
|
|
);
|
|
|
|
|
await seeded.updateBgTaskId('srv:active', 'native-task');
|
|
|
|
|
await seeded.addToQueue(mediaGlobalKey: 'srv:active');
|
|
|
|
|
await seeded.insertDownload(
|
|
|
|
|
serverId: ServerId('srv'),
|
|
|
|
|
ratingKey: 'complete',
|
|
|
|
|
globalKey: 'srv:complete',
|
|
|
|
|
type: 'movie',
|
|
|
|
|
status: DownloadStatus.completed.index,
|
|
|
|
|
);
|
|
|
|
|
await seeded.close();
|
|
|
|
|
seeded = null;
|
2026-07-24 07:41:01 +02:00
|
|
|
|
2026-07-25 16:16:04 +02:00
|
|
|
var openAttempts = 0;
|
|
|
|
|
var recoveries = 0;
|
|
|
|
|
final bootstrap = await openAppDatabaseWithDownloadRecovery(
|
|
|
|
|
openDatabase: () {
|
|
|
|
|
return AppDatabase.open(
|
|
|
|
|
isTvos: false,
|
|
|
|
|
databaseFile: file,
|
|
|
|
|
preferences: prefs,
|
|
|
|
|
executorFactory: (databaseFile) {
|
|
|
|
|
openAttempts++;
|
|
|
|
|
final interceptor = openAttempts == 1 ? failedOpen : successfulOpen;
|
|
|
|
|
return NativeDatabase(databaseFile).interceptWith(interceptor);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
recoverNativeDownloads: () async {
|
|
|
|
|
expect(failedOpen.closed, isTrue);
|
|
|
|
|
recoveries++;
|
|
|
|
|
},
|
|
|
|
|
storageFullMessage: 'Storage full',
|
|
|
|
|
);
|
|
|
|
|
resultDatabase = bootstrap.database;
|
|
|
|
|
|
|
|
|
|
final active = await resultDatabase.getDownloadedMedia('srv:active');
|
|
|
|
|
final complete = await resultDatabase.getDownloadedMedia('srv:complete');
|
|
|
|
|
expect(bootstrap.recoveryOutcome, TvosDatabaseRecoveryOutcome.notApplicable);
|
|
|
|
|
expect(openAttempts, 2);
|
|
|
|
|
expect(recoveries, 1);
|
|
|
|
|
expect(failedOpen.ensureOpenCalls, 1);
|
|
|
|
|
expect(successfulOpen.ensureOpenCalls, greaterThanOrEqualTo(1));
|
|
|
|
|
expect(successfulOpen.ensureOpenCompleted, isTrue);
|
|
|
|
|
expect(active?.status, DownloadStatus.failed.index);
|
|
|
|
|
expect(active?.bgTaskId, isNull);
|
|
|
|
|
expect(active?.errorMessage, 'Storage full');
|
|
|
|
|
expect(complete?.status, DownloadStatus.completed.index);
|
|
|
|
|
expect(await resultDatabase.select(resultDatabase.downloadQueue).get(), isEmpty);
|
|
|
|
|
expect(await resultDatabase.customSelect('SELECT 1').get(), isNotEmpty);
|
|
|
|
|
} finally {
|
|
|
|
|
await resultDatabase?.close();
|
|
|
|
|
await seeded?.close();
|
|
|
|
|
await tempDir.delete(recursive: true);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-07-27 17:44:52 +02:00
|
|
|
|
|
|
|
|
test('post-recovery download failure closes the reopened database before rethrowing', () async {
|
|
|
|
|
resetSharedPreferencesForTest();
|
|
|
|
|
final tempDir = await Directory.systemTemp.createTemp('plezy_startup_recovery_update_failure_');
|
|
|
|
|
final file = File('${tempDir.path}/plezy_downloads.db');
|
|
|
|
|
final prefs = await BaseSharedPreferencesService.sharedCache();
|
|
|
|
|
final failedOpen = _OpenTrackingInterceptor(
|
|
|
|
|
failure: const FileSystemException('write failed: No space left on device'),
|
|
|
|
|
);
|
|
|
|
|
final updateError = StateError('injected post-recovery update failure');
|
|
|
|
|
final reopened = _OpenTrackingInterceptor(updateFailure: updateError);
|
|
|
|
|
AppDatabase? seeded;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
seeded = AppDatabase.forTesting(NativeDatabase(file));
|
|
|
|
|
await seeded.insertDownload(
|
|
|
|
|
serverId: ServerId('srv'),
|
|
|
|
|
ratingKey: 'active',
|
|
|
|
|
globalKey: 'srv:active',
|
|
|
|
|
type: 'movie',
|
|
|
|
|
status: DownloadStatus.downloading.index,
|
|
|
|
|
);
|
|
|
|
|
await seeded.close();
|
|
|
|
|
seeded = null;
|
|
|
|
|
|
|
|
|
|
var openAttempts = 0;
|
|
|
|
|
final open = openAppDatabaseWithDownloadRecovery(
|
|
|
|
|
openDatabase: () {
|
|
|
|
|
return AppDatabase.open(
|
|
|
|
|
isTvos: false,
|
|
|
|
|
databaseFile: file,
|
|
|
|
|
preferences: prefs,
|
|
|
|
|
executorFactory: (databaseFile) {
|
|
|
|
|
openAttempts++;
|
|
|
|
|
final interceptor = openAttempts == 1 ? failedOpen : reopened;
|
|
|
|
|
return NativeDatabase(databaseFile).interceptWith(interceptor);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
recoverNativeDownloads: () async {},
|
|
|
|
|
storageFullMessage: 'Storage full',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await expectLater(open, throwsA(same(updateError)));
|
|
|
|
|
expect(openAttempts, 2);
|
|
|
|
|
expect(reopened.closed, isTrue);
|
|
|
|
|
} finally {
|
|
|
|
|
await seeded?.close();
|
|
|
|
|
await tempDir.delete(recursive: true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-25 16:16:04 +02:00
|
|
|
test('non-storage lazy database-open errors bypass download recovery', () async {
|
|
|
|
|
resetSharedPreferencesForTest();
|
|
|
|
|
final tempDir = await Directory.systemTemp.createTemp('plezy_startup_open_error_');
|
|
|
|
|
final file = File('${tempDir.path}/plezy_downloads.db');
|
|
|
|
|
final prefs = await BaseSharedPreferencesService.sharedCache();
|
|
|
|
|
final error = StateError('injected database setup failure');
|
|
|
|
|
final failedOpen = _OpenTrackingInterceptor(failure: error);
|
2026-07-24 07:41:01 +02:00
|
|
|
var openAttempts = 0;
|
|
|
|
|
var recoveries = 0;
|
|
|
|
|
|
2026-07-25 16:16:04 +02:00
|
|
|
try {
|
|
|
|
|
final open = openAppDatabaseWithDownloadRecovery(
|
|
|
|
|
openDatabase: () {
|
|
|
|
|
return AppDatabase.open(
|
|
|
|
|
isTvos: false,
|
|
|
|
|
databaseFile: file,
|
|
|
|
|
preferences: prefs,
|
|
|
|
|
executorFactory: (databaseFile) {
|
|
|
|
|
openAttempts++;
|
|
|
|
|
return NativeDatabase(databaseFile).interceptWith(failedOpen);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
recoverNativeDownloads: () async {
|
|
|
|
|
recoveries++;
|
|
|
|
|
},
|
|
|
|
|
storageFullMessage: 'Storage full',
|
|
|
|
|
);
|
2026-07-24 07:41:01 +02:00
|
|
|
|
2026-07-25 16:16:04 +02:00
|
|
|
await expectLater(open, throwsA(same(error)));
|
|
|
|
|
expect(failedOpen.ensureOpenCalls, 1);
|
|
|
|
|
expect(failedOpen.closed, isTrue);
|
|
|
|
|
expect(openAttempts, 1);
|
|
|
|
|
expect(recoveries, 0);
|
|
|
|
|
} finally {
|
|
|
|
|
await tempDir.delete(recursive: true);
|
|
|
|
|
}
|
2026-07-24 07:41:01 +02:00
|
|
|
});
|
2026-07-23 22:43:07 +02:00
|
|
|
}
|