fix(startup): report and repair a failed launch instead of showing "Error"

Since 2.10.0 the whole app sits behind one all-or-nothing initialization
gate, and that gate discarded the only evidence of its own failure. It
caught the error, logged nothing but `error.runtimeType`, rendered an
icon plus the word "Error" plus Retry, and never reported the error
because catching it kept the crash reporter from ever seeing it. There
is no log file on any platform, the buffer is in memory only, a
double-clicked Windows release build has no console, and the log viewer
lives in Settings, behind the gate that just failed. #1732 is the result:
a Windows 11 user whose app will not boot and who cannot produce a single
byte of diagnostic detail.

The gate now names its phases. Each step is wrapped so a throw carries
the phase it came from, replacing a `Future.wait` that discarded every
error but the first and could not attribute it to any of four concurrent
steps. The failure screen renders the phase, the exception type, the
message and an expandable stack, plus copy and upload actions that reuse
the existing log-relay flow. The record is persisted next to the database
so the next successful launch can surface it in Settings > Logs, and it
is reported to the crash reporter explicitly.

Only preferences and the database still gate the launch. Window chrome,
locale, crash-reporting init, TV/performance detection, the image-cache
budget and download storage are best-effort and time-bounded, so a
stalled platform thread degrades instead of holding the splash forever.
Sentry no longer receives the startup work as its `appRunner`: that made
a startup failure indistinguishable from a Sentry failure, and the guard
would then have re-run migrations and the database open a second time.

The two remaining fatal steps become recoverable. Preference reads
tolerate a value whose stored type no longer matches, dropping the key
and defaulting instead of failing the boot. A store that cannot be parsed
is detected before either desktop plugin backend can memoise it, which is
what makes an in-process repair possible at all. Repair is never
automatic: it states what it will cost, salvages the credential-vault key
and every tracker and Seerr session it can validate out of the damaged
bytes, reseeds them, and moves the original aside rather than deleting
it. Servers and profiles survive a salvaged key because their tokens are
ciphertext in the database; tracker and Seerr sessions are plaintext
preference entries, so the copy says they may still need reconnecting.

Nothing derived from the store reaches a diagnostic. `FormatException`
prints an excerpt of whatever it failed to parse, and during startup that
document holds the vault key, refresh tokens and session cookies while
the redaction manager still has nothing registered, so the wrapper keeps
only the cause's type and offset and the record is an allowlist of
already-redacted fields. The quarantined copy is labelled as containing
credentials, is never offered for upload, and can be deleted from the
dialog.

Also self-heals orphaned WAL/SHM sidecars on desktop rather than only
tvOS, makes every `createTable` migration step idempotent, keeps MSVC
link by-products out of the Windows bundle, and asserts bundle contents
in CI.

Refs #1732
This commit is contained in:
edde746
2026-07-31 21:45:32 +02:00
parent 7c515bf8fa
commit 7f0cad339c
43 changed files with 3453 additions and 165 deletions
+99
View File
@@ -134,6 +134,105 @@ class _AppDatabaseTestSuite {
);
});
test('desktop open removes orphaned sidecars when the main database is absent', () async {
await db.close();
resetSharedPreferencesForTest();
final tempDir = await Directory.systemTemp.createTemp('plezy_db_orphaned_sidecars_test_');
final file = File('${tempDir.path}/plezy_downloads.db');
final wal = File('${file.path}-wal');
final shm = File('${file.path}-shm');
AppDatabase? opened;
try {
await wal.writeAsBytes([1, 2, 3]);
await shm.writeAsBytes([4, 5, 6]);
final prefs = await BaseSharedPreferencesService.sharedCache();
final bootstrap = await AppDatabase.open(
isTvos: false,
databaseFile: file,
preferences: prefs,
executorFactory: (_) => NativeDatabase.memory(),
);
opened = bootstrap.database;
expect(await wal.exists(), isFalse);
expect(await shm.exists(), isFalse);
} finally {
await opened?.close();
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
db = AppDatabase.forTesting(NativeDatabase.memory());
}
});
test('desktop open preserves sidecars when the main database exists', () async {
await db.close();
resetSharedPreferencesForTest();
final tempDir = await Directory.systemTemp.createTemp('plezy_db_live_sidecars_test_');
final file = File('${tempDir.path}/plezy_downloads.db');
final wal = File('${file.path}-wal');
final shm = File('${file.path}-shm');
AppDatabase? opened;
try {
await file.writeAsBytes([0x50, 0x4c, 0x45, 0x5a, 0x59]);
await wal.writeAsBytes([1, 2, 3]);
await shm.writeAsBytes([4, 5, 6]);
final prefs = await BaseSharedPreferencesService.sharedCache();
final bootstrap = await AppDatabase.open(
isTvos: false,
databaseFile: file,
preferences: prefs,
executorFactory: (_) => NativeDatabase.memory(),
);
opened = bootstrap.database;
expect(await wal.readAsBytes(), [1, 2, 3]);
expect(await shm.readAsBytes(), [4, 5, 6]);
} finally {
await opened?.close();
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
db = AppDatabase.forTesting(NativeDatabase.memory());
}
});
test('retried v14 migration tolerates an existing connections table', () async {
await db.close();
final tempDir = await Directory.systemTemp.createTemp('plezy_db_existing_connections_test_');
final file = File('${tempDir.path}/plezy_downloads.db');
AppDatabase? seeded;
AppDatabase? reopened;
try {
seeded = AppDatabase.forTesting(NativeDatabase(file));
await seeded.select(seeded.apiCache).get();
final connectionTableSql =
(await seeded
.customSelect("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'connections'")
.getSingle())
.read<String>('sql');
await _createSchemaV13Fixture(seeded);
await seeded.customStatement(connectionTableSql);
await seeded.close();
seeded = null;
reopened = AppDatabase.forTesting(NativeDatabase(file));
expect(await reopened.select(reopened.connections).get(), isEmpty);
} finally {
await reopened?.close();
await seeded?.close();
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
db = AppDatabase.forTesting(NativeDatabase.memory());
}
});
test('retried v14 migration tolerates existing indices', () async {
await db.close();
final tempDir = await Directory.systemTemp.createTemp('plezy_db_migration_test_');
@@ -7,9 +7,12 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:plezy/focus/focusable_action_bar.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/screens/settings/logs_screen.dart';
import 'package:plezy/services/log_upload_service.dart';
import 'package:plezy/services/startup_diagnostics.dart';
import 'package:plezy/utils/app_logger.dart';
import 'package:plezy/utils/media_server_http_client.dart';
@@ -157,4 +160,70 @@ void main() {
expect(clipboardText, capability);
expect(tester.takeException(), isNull);
});
group('previous startup failure', () {
late StartupFailureRecord record;
Future<void> pumpLogs(WidgetTester tester) async {
PackageInfo.setMockInitialValues(
appName: 'Plezy',
packageName: 'com.plezy.test',
version: '2.11.0',
buildNumber: '124',
buildSignature: '',
);
await tester.pumpWidget(
TranslationProvider(
child: InputModeTracker(child: MaterialApp(home: const LogsScreen())),
),
);
await tester.pumpAndSettle();
}
setUp(() {
StartupDiagnosticsStore.resetForTesting();
record = StartupFailureRecord.fromError(
phase: StartupPhase.database,
error: StateError('sqlite could not open'),
stackTrace: StackTrace.empty,
appVersion: '2.11.0+124',
platform: 'windows 11',
);
});
tearDown(StartupDiagnosticsStore.resetForTesting);
testWidgets('renders nothing when no launch has failed', (tester) async {
await pumpLogs(tester);
expect(find.byKey(previousStartupFailureKey), findsNothing);
});
testWidgets('shows the recorded failure so the user can see and act on it', (tester) async {
// The failing process left no in-memory log at all; this banner is the
// only surface that failure ever reaches (#1732).
StartupDiagnosticsStore.setPendingForTesting(record);
await pumpLogs(tester);
expect(find.byKey(previousStartupFailureKey), findsOneWidget);
expect(find.textContaining('sqlite could not open'), findsOneWidget);
expect(find.textContaining('Phase: database'), findsOneWidget);
});
testWidgets('enables upload and copy with a record but an empty log buffer', (tester) async {
MemoryLogOutput.clearLogs();
StartupDiagnosticsStore.setPendingForTesting(record);
await pumpLogs(tester);
// Gating these on the log buffer alone would show the record and then
// refuse to let the user do anything with it.
final bar = tester.widget<FocusableActionBar>(find.byType(FocusableActionBar));
for (final tooltip in [t.logs.uploadLogs, t.logs.copyLogs, t.logs.clearLogs]) {
final action = bar.actions.singleWhere((candidate) => candidate.tooltip == tooltip);
expect(action.onPressed, isNotNull, reason: tooltip);
}
});
});
}
+311
View File
@@ -0,0 +1,311 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/prefs_recovery.dart';
import 'package:plezy/services/sensitive_prefs.dart';
import 'package:plezy/utils/log_redaction_manager.dart';
/// A 32-byte key, the only length `AesGcm.with256bits()` accepts.
final String _validVaultKey = base64Encode(List<int>.generate(32, (i) => i));
String _traktSession({String access = 'trakt-access-token', String refresh = 'trakt-refresh-token'}) => jsonEncode({
'access_token': access,
'refresh_token': refresh,
'expires_at': 4102444800,
'username': 'someone',
'scope': 'public',
'created_at': 1700000000,
});
String _seerrSession({String cookie = 'seerr-connect-sid'}) => jsonEncode({
'base_url': 'https://seerr.example.com',
'method': 'local',
'identifier': 'user@example.com',
'secret': 'enc:v1:{"c":"AA==","n":"AA==","m":"AA=="}',
'cookie': cookie,
'user_id': 1,
'permissions': 2,
'display_name': 'Someone',
'instance_label': 'Seerr',
'created_at': 1700000000,
});
void main() {
late Directory tempDir;
late File store;
setUp(() async {
LogRedactionManager.clearTrackedValues();
tempDir = await Directory.systemTemp.createTemp('plezy-prefs-recovery');
store = File('${tempDir.path}/shared_preferences.json');
});
tearDown(() async {
LogRedactionManager.clearTrackedValues();
if (await tempDir.exists()) await tempDir.delete(recursive: true);
});
group('salvage', () {
test('recovers a vault key from a truncated document', () {
// Exactly the #1732 shape: a non-atomic write cut short, so the object
// never closes and `json.decode` cannot help.
final raw = '{"theme":"dark","$credentialVaultKeyPref":"$_validVaultKey","library_den';
expect(PrefsRecovery.salvage(raw).vaultKey, _validVaultKey);
});
test('rejects a vault key that is not 32 bytes', () {
final raw = jsonEncode({credentialVaultKeyPref: base64Encode(List<int>.filled(16, 7))});
final salvaged = PrefsRecovery.salvage(raw);
// A short key would make every later decrypt throw, which is worse than
// reporting the key as lost.
expect(salvaged.vaultKey, isNull);
expect(salvaged.losses, 1);
});
test('recovers profile-scoped tracker and Seerr sessions', () {
final raw = jsonEncode({
'user_abc_trakt_session': _traktSession(),
'user_abc_seerr_session': _seerrSession(),
'theme': 'dark',
});
final salvaged = PrefsRecovery.salvage(raw);
expect(salvaged.sessions.keys, containsAll(['user_abc_trakt_session', 'user_abc_seerr_session']));
expect(salvaged.losses, 0);
});
test('counts an undecodable session as lost rather than reseeding garbage', () {
final salvaged = PrefsRecovery.salvage(jsonEncode({'trakt_session': '{"access_token":"only-half'}));
expect(salvaged.sessions, isEmpty);
expect(salvaged.losses, 1);
});
test('normalises a legacy flutter-prefixed key onto the async key', () {
// A store damaged mid-migration holds credentials only under the legacy
// spelling, because `SharedPreferences.getInstance()` runs first.
final raw = jsonEncode({
'${legacyKeyPrefix}trakt_session': _traktSession(),
'$legacyKeyPrefix$credentialVaultKeyPref': _validVaultKey,
});
final salvaged = PrefsRecovery.salvage(raw);
expect(salvaged.vaultKey, _validVaultKey);
expect(salvaged.sessions.keys, ['trakt_session']);
});
test('prefers the unprefixed entry over the legacy one regardless of order', () {
final documents = [
jsonEncode({
'${legacyKeyPrefix}trakt_session': _traktSession(access: 'legacy-access'),
'trakt_session': _traktSession(access: 'current-access'),
}),
jsonEncode({
'trakt_session': _traktSession(access: 'current-access'),
'${legacyKeyPrefix}trakt_session': _traktSession(access: 'legacy-access'),
}),
];
for (final raw in documents) {
expect(PrefsRecovery.salvage(raw).sessions['trakt_session'], contains('current-access'));
}
});
test('registers each secret individually, not just the whole payload', () {
final raw = jsonEncode({
'trakt_session': _traktSession(access: 'aaa-access-secret', refresh: 'bbb-refresh-secret'),
'seerr_session': _seerrSession(cookie: 'ccc-cookie-secret'),
legacyPlexTokenPref: 'ddd-plex-secret',
});
PrefsRecovery.salvage(raw);
// A bare token quoted on its own must redact too; registering only the
// encoded blob would leave these exposed.
for (final secret in ['aaa-access-secret', 'bbb-refresh-secret', 'ccc-cookie-secret', 'ddd-plex-secret']) {
expect(LogRedactionManager.redact('value=$secret'), isNot(contains(secret)), reason: secret);
}
});
test('registers the salvaged vault key for redaction', () {
PrefsRecovery.salvage(jsonEncode({credentialVaultKeyPref: _validVaultKey}));
expect(LogRedactionManager.redact('key=$_validVaultKey'), isNot(contains(_validVaultKey)));
});
});
group('assertStoreReadable', () {
test('accepts a well-formed document', () async {
await store.writeAsString(
jsonEncode({
'theme': 'dark',
'count': 3,
'list': <String>['a'],
}),
);
await expectLater(PrefsRecovery.assertStoreReadable(storeFileOverride: store), completes);
});
test('accepts a missing or empty store', () async {
await expectLater(PrefsRecovery.assertStoreReadable(storeFileOverride: store), completes);
await store.writeAsString('');
await expectLater(PrefsRecovery.assertStoreReadable(storeFileOverride: store), completes);
});
test('rejects a truncated document', () async {
await store.writeAsString('{"theme":"dark"');
await expectLater(
PrefsRecovery.assertStoreReadable(storeFileOverride: store),
throwsA(isA<CorruptPreferenceStoreException>()),
);
});
test('rejects a null value before the plugin can memoise it', () async {
// Valid JSON, so `json.decode` succeeds and the plugin caches the lazy
// cast; the TypeError only lands later in `Map<String, Object>.from`.
// Detecting it after that point would leave the bad document memoised
// and a repair unable to reopen onto a clean store.
await store.writeAsString('{"theme":"dark","broken":null}');
await expectLater(
PrefsRecovery.assertStoreReadable(storeFileOverride: store),
throwsA(isA<CorruptPreferenceStoreException>()),
);
});
test('rejects a document that is not a JSON object', () async {
await store.writeAsString('[1,2,3]');
await expectLater(
PrefsRecovery.assertStoreReadable(storeFileOverride: store),
throwsA(isA<CorruptPreferenceStoreException>()),
);
});
});
group('quarantine', () {
test('moves the damaged file aside and keeps its bytes', () async {
final raw = '{"$credentialVaultKeyPref":"$_validVaultKey","trunc';
await store.writeAsString(raw);
final result = await PrefsRecovery.quarantine(storeFileOverride: store);
expect(await store.exists(), isFalse);
expect(result.backupPath, isNotNull);
expect(await File(result.backupPath!).readAsString(), raw);
expect(result.salvaged.vaultKey, _validVaultKey);
});
test('deleteBackup removes the credential-bearing copy', () async {
await store.writeAsString('{"broken');
final result = await PrefsRecovery.quarantine(storeFileOverride: store);
await PrefsRecovery.deleteBackup(result.backupPath!);
expect(await File(result.backupPath!).exists(), isFalse);
});
});
group('backupStore', () {
test('copies without disturbing the live store', () async {
await store.writeAsString('{"theme":"dark"}');
final path = await PrefsRecovery.backupStore(storeFileOverride: store);
expect(await store.exists(), isTrue);
expect(await File(path!).readAsString(), '{"theme":"dark"}');
});
});
group('seedStore', () {
test('writes only the salvaged credentials and leaves no staging file', () async {
final salvaged = PrefsRecovery.salvage(
jsonEncode({credentialVaultKeyPref: _validVaultKey, 'trakt_session': _traktSession(), 'theme': 'dark'}),
);
expect(await PrefsRecovery.seedStore(salvaged, storeFileOverride: store), isTrue);
final written = jsonDecode(await store.readAsString()) as Map<String, dynamic>;
expect(written[credentialVaultKeyPref], _validVaultKey);
expect(written.containsKey('trakt_session'), isTrue);
// Ordinary settings are not salvaged, so a reseed must not invent them.
expect(written.containsKey('theme'), isFalse);
expect(await File('${store.path}.seed').exists(), isFalse);
});
test('the seeded store passes the preflight it will face on restart', () async {
final salvaged = PrefsRecovery.salvage(jsonEncode({credentialVaultKeyPref: _validVaultKey}));
await PrefsRecovery.seedStore(salvaged, storeFileOverride: store);
await expectLater(PrefsRecovery.assertStoreReadable(storeFileOverride: store), completes);
});
test('reports false when there is nothing to seed', () async {
expect(await PrefsRecovery.seedStore(SalvagedPrefsCredentials.empty, storeFileOverride: store), isFalse);
expect(await store.exists(), isFalse);
});
});
group('CorruptPreferenceStoreException', () {
test('never renders the document the parser choked on', () {
// `FormatException.toString()` prints an excerpt of `source` around
// `offset`. During startup that source is the credential store.
final source = '{"$credentialVaultKeyPref":"$_validVaultKey","truncated';
late final FormatException raw;
try {
jsonDecode(source);
fail('expected a FormatException');
} on FormatException catch (error) {
raw = error;
}
expect(raw.toString(), contains(_validVaultKey), reason: 'precondition: the raw error does leak the key');
final wrapped = CorruptPreferenceStoreException(raw, StackTrace.current);
expect(wrapped.toString(), isNot(contains(_validVaultKey)));
expect(wrapped.toString(), contains('FormatException'));
expect(wrapped.causeType, 'FormatException');
});
});
group('UnreadableSensitivePreferenceException', () {
test('names the key but carries no value', () {
final exception = UnreadableSensitivePreferenceException(credentialVaultKeyPref, TypeError());
expect(exception.key, credentialVaultKeyPref);
expect(exception.toString(), contains(credentialVaultKeyPref));
});
});
group('sensitive key registry', () {
test('covers every credential slot, scoped and unscoped', () {
for (final key in [
credentialVaultKeyPref,
legacyPlexTokenPref,
'trakt_session',
'user_abc_mal_session',
'user_abc_anilist_session',
'user_abc_simkl_session',
'seerr_session',
'user_abc_seerr_session',
]) {
expect(isSensitivePrefKey(key), isTrue, reason: key);
}
});
test('does not claim ordinary preferences', () {
for (final key in ['theme', 'library_density', 'custom_relay_url', 'session_count']) {
expect(isSensitivePrefKey(key), isFalse, reason: key);
}
});
});
}
+192
View File
@@ -0,0 +1,192 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/sensitive_prefs.dart';
import 'package:plezy/services/startup_diagnostics.dart';
import 'package:plezy/utils/log_redaction_manager.dart';
StartupFailureRecord _record({
StartupPhase? phase = StartupPhase.database,
Object error = const FormatException('boom'),
StackTrace? stackTrace,
}) => StartupFailureRecord.fromError(
phase: phase,
error: error,
stackTrace: stackTrace,
appVersion: '2.11.0+124',
platform: 'windows 11',
);
void main() {
late Directory tempDir;
setUp(() async {
LogRedactionManager.clearTrackedValues();
StartupDiagnosticsStore.resetForTesting();
tempDir = await Directory.systemTemp.createTemp('plezy-startup-diagnostics');
StartupDiagnosticsStore.debugDirectoryOverride = tempDir;
});
tearDown(() async {
StartupDiagnosticsStore.resetForTesting();
LogRedactionManager.clearTrackedValues();
if (await tempDir.exists()) await tempDir.delete(recursive: true);
});
group('StartupPhaseException', () {
test('unwraps to the real cause and reports the phase', () {
const inner = FormatException('inner');
const wrapped = StartupPhaseException(StartupPhase.storage, inner);
expect(StartupPhaseException.unwrap(wrapped), same(inner));
expect(StartupPhaseException.phaseOf(wrapped), StartupPhase.storage);
expect(StartupPhaseException.unwrap(inner), same(inner));
});
test('a record built from a wrapper describes the cause, not the wrapper', () {
final record = StartupFailureRecord.fromError(
error: const StartupPhaseException(StartupPhase.database, FormatException('inner')),
stackTrace: StackTrace.empty,
appVersion: 'v',
platform: 'p',
);
expect(record.phase, StartupPhase.database);
expect(record.errorType, 'FormatException');
});
});
group('describeErrorSafely', () {
test('drops the source excerpt a FormatException carries', () {
final key = base64Encode(List<int>.generate(32, (i) => i));
final source = '{"$credentialVaultKeyPref":"$key","truncated';
late final FormatException raw;
try {
jsonDecode(source);
fail('expected a FormatException');
} on FormatException catch (error) {
raw = error;
}
expect(raw.toString(), contains(key), reason: 'precondition: the raw error does leak the key');
final described = StartupFailureRecord.describeErrorSafely(raw);
expect(described, isNot(contains(key)));
expect(described, contains('offset'));
});
test('a record never persists the leaked excerpt', () async {
final key = base64Encode(List<int>.generate(32, (i) => i));
late final FormatException raw;
try {
jsonDecode('{"$credentialVaultKeyPref":"$key","truncated');
fail('expected a FormatException');
} on FormatException catch (error) {
raw = error;
}
final record = _record(error: raw);
await StartupDiagnosticsStore.record(record);
final onDisk = await File('${tempDir.path}/${StartupDiagnosticsStore.fileName}').readAsString();
expect(record.describe(), isNot(contains(key)));
expect(onDisk, isNot(contains(key)));
});
test('leaves other error types alone', () {
expect(StartupFailureRecord.describeErrorSafely(StateError('plain')), contains('plain'));
});
});
group('record contents', () {
test('redacts registered secrets out of the message', () {
LogRedactionManager.registerToken('super-secret-token');
final record = _record(error: StateError('failed with super-secret-token'));
expect(record.message, isNot(contains('super-secret-token')));
});
test('describe() carries the phase, type and build for a bug report', () {
final text = _record(error: StateError('nope')).describe();
expect(text, contains('Phase: database'));
expect(text, contains('Error: StateError'));
expect(text, contains('2.11.0+124'));
expect(text, contains('windows 11'));
});
test('headline stays one line', () {
expect(_record().headline, startsWith('[database] FormatException'));
});
});
group('persistence', () {
test('round-trips a record through disk', () async {
final original = _record(error: StateError('disk failure'), stackTrace: StackTrace.fromString('#0 frame'));
await StartupDiagnosticsStore.record(original);
StartupDiagnosticsStore.resetForTesting();
StartupDiagnosticsStore.debugDirectoryOverride = tempDir;
final restored = await StartupDiagnosticsStore.consumePrevious();
expect(restored, isNotNull);
expect(restored!.phase, StartupPhase.database);
expect(restored.errorType, 'StateError');
expect(restored.message, contains('disk failure'));
expect(restored.stackTrace, contains('#0 frame'));
});
test('consuming deletes the file but keeps the record available in-session', () async {
await StartupDiagnosticsStore.record(_record());
StartupDiagnosticsStore.resetForTesting();
StartupDiagnosticsStore.debugDirectoryOverride = tempDir;
await StartupDiagnosticsStore.consumePrevious();
// Deleted so one stale failure cannot follow the user forever, but held
// in memory so Settings > Logs can still show and upload it.
expect(await File('${tempDir.path}/${StartupDiagnosticsStore.fileName}').exists(), isFalse);
expect(StartupDiagnosticsStore.pending, isNotNull);
});
test('consuming nothing yields null', () async {
expect(await StartupDiagnosticsStore.consumePrevious(), isNull);
expect(StartupDiagnosticsStore.pending, isNull);
});
test('a malformed record is ignored rather than thrown', () async {
await File('${tempDir.path}/${StartupDiagnosticsStore.fileName}').writeAsString('not json');
expect(await StartupDiagnosticsStore.consumePrevious(), isNull);
});
test('clear removes both the file and the pending record', () async {
await StartupDiagnosticsStore.record(_record());
await StartupDiagnosticsStore.clear();
expect(await File('${tempDir.path}/${StartupDiagnosticsStore.fileName}').exists(), isFalse);
expect(StartupDiagnosticsStore.pending, isNull);
});
test('an unwritable location degrades instead of failing the failure path', () async {
StartupDiagnosticsStore.debugDirectoryOverride = Directory('${tempDir.path}/missing/deeper');
await expectLater(StartupDiagnosticsStore.record(_record()), completes);
// Still exposed in-session even when it could not be written.
expect(StartupDiagnosticsStore.pending, isNotNull);
});
});
group('phase ids', () {
test('round-trip through their stable wire form', () {
for (final phase in StartupPhase.values) {
expect(StartupPhase.fromId(phase.id), phase, reason: phase.name);
}
expect(StartupPhase.fromId('nonexistent'), isNull);
expect(StartupPhase.fromId(null), isNull);
});
});
}
+124 -4
View File
@@ -12,6 +12,9 @@ import 'package:plezy/main.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/models/download_models.dart';
import 'package:plezy/services/base_shared_preferences_service.dart';
import 'package:plezy/services/prefs_recovery.dart';
import 'package:plezy/services/startup_diagnostics.dart';
import 'package:plezy/widgets/startup_failure_view.dart';
import 'test_helpers/download_fixtures.dart';
import 'test_helpers/prefs.dart';
@@ -89,18 +92,135 @@ void main() {
expect(find.byKey(startupBootstrapProgressKey), findsNothing);
});
testWidgets('shows a localized recoverable failure instead of removing Flutter UI', (tester) async {
testWidgets('names the failing phase instead of showing a bare error', (tester) async {
await tester.pumpWidget(
StartupBootstrap<int>(
initialize: () async => throw StateError('database unavailable'),
initialize: () async => throw const StartupPhaseException(StartupPhase.database, FormatException('boom')),
buildApp: (_, value) => Text('ready $value'),
reportFailure: (_, _, _) async {},
),
);
await tester.pump();
expect(find.byKey(startupBootstrapFailureKey), findsOneWidget);
expect(find.text('Error'), findsOneWidget);
expect(find.text('Retry'), 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 {
await tester.pumpWidget(
StartupBootstrap<int>(
initialize: () async => throw StateError('database unavailable'),
buildApp: (_, value) => Text('ready $value'),
reportFailure: (_, _, _) async {},
),
);
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'),
reportFailure: (_, _, _) async {},
),
);
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'),
reportFailure: (_, _, _) async {},
),
);
await tester.pump();
expect(find.byKey(startupFailureRepairKey), findsOneWidget);
});
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')),
reportFailure: (_, _, _) async {},
repair: (_, _, _) async {
repairCalls++;
return true;
},
),
);
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')),
reportFailure: (_, _, _) async {},
repair: (_, _, _) async => false,
),
);
await tester.pump();
await tester.tap(find.byKey(startupFailureRepairKey));
await tester.pump();
await tester.pump();
expect(attempts, 1);
expect(find.byKey(startupBootstrapFailureKey), findsOneWidget);
});
testWidgets('reports the failure to the crash reporter', (tester) async {
StartupFailureRecord? reported;
await tester.pumpWidget(
StartupBootstrap<int>(
initialize: () async => throw const StartupPhaseException(StartupPhase.storage, 'nope'),
buildApp: (_, value) => Text('ready $value'),
reportFailure: (record, _, _) async => reported = record,
),
);
await tester.pump();
// The gate catches the error, so nothing else would ever see it.
expect(reported?.phase, StartupPhase.storage);
});
testWidgets('retry clears the failed generation and can commit a later success', (tester) async {
@@ -0,0 +1,121 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/main.dart';
import 'package:plezy/services/prefs_recovery.dart';
Future<void> _openDialog(
WidgetTester tester,
PrefsRepairOutcome outcome, {
Future<void> Function(String path)? deleteBackup,
}) async {
await tester.pumpWidget(
TranslationProvider(
child: MaterialApp(
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () =>
showRepairOutcomeDialog(context, outcome, deleteBackup: deleteBackup ?? PrefsRecovery.deleteBackup),
child: const Text('open'),
),
),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
void main() {
setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
late Directory tempDir;
late File backup;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('plezy-repair-outcome');
backup = File('${tempDir.path}/shared_preferences.corrupt-x.json');
await backup.writeAsString('{"credential_vault_key_v1":"secret"}');
});
tearDown(() async {
if (await tempDir.exists()) await tempDir.delete(recursive: true);
});
testWidgets('warns that the backup holds credentials and must not be shared', (tester) async {
await _openDialog(
tester,
PrefsRepairOutcome(backupPath: backup.path, vaultKeySalvaged: true, sessionsSalvaged: 0, sessionsLost: 0),
);
expect(find.text(t.startup.backupTitle), findsOneWidget);
expect(find.text(t.startup.backupWarning), findsOneWidget);
expect(find.text(backup.path), findsOneWidget);
});
testWidgets('deleting the backup removes the file and stops showing its path', (tester) async {
final deleted = <String>[];
await _openDialog(
tester,
PrefsRepairOutcome(backupPath: backup.path, vaultKeySalvaged: true, sessionsSalvaged: 0, sessionsLost: 0),
// The widget-test binding's fake-async zone never completes a `dart:io`
// future, so the real delete is covered in prefs_recovery_test.dart and
// this test owns the UI state that follows it.
deleteBackup: (path) async => deleted.add(path),
);
await tester.tap(find.text(t.startup.deleteBackup));
await tester.pumpAndSettle();
expect(deleted, [backup.path]);
// Regression: the flag lived inside the StatefulBuilder closure, so the
// rebuild it triggered reset it and the sensitive path stayed on screen.
expect(find.text(t.startup.backupDeleted), findsOneWidget);
expect(find.text(backup.path), findsNothing);
expect(find.text(t.startup.deleteBackup), findsNothing);
});
testWidgets('says sign-ins are kept only when the vault key survived', (tester) async {
await _openDialog(
tester,
PrefsRepairOutcome(backupPath: null, vaultKeySalvaged: true, sessionsSalvaged: 2, sessionsLost: 0),
);
expect(find.text(t.startup.repairKeptSignIns), findsOneWidget);
expect(find.text(t.startup.repairLostSignIns), findsNothing);
});
testWidgets('states the full credential loss when the vault key is gone', (tester) async {
await _openDialog(
tester,
PrefsRepairOutcome(backupPath: null, vaultKeySalvaged: false, sessionsSalvaged: 0, sessionsLost: 3),
);
expect(find.text(t.startup.repairLostSignIns), findsOneWidget);
// Tracker/Seerr sessions are plaintext preference entries, so they are
// reported separately from the vault-protected server tokens.
expect(find.text(t.startup.repairLostSessions), findsOneWidget);
});
testWidgets('asks for a restart when the store could not be reopened', (tester) async {
await _openDialog(
tester,
PrefsRepairOutcome(
backupPath: null,
vaultKeySalvaged: true,
sessionsSalvaged: 1,
sessionsLost: 0,
requiresRestart: true,
),
);
expect(find.text(t.startup.repairNeedsRestart), findsOneWidget);
expect(find.text(t.startup.repairSucceeded), findsNothing);
});
}