From c928a7c6717bfc6da5b2009379f3fda11312b00f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 18 Oct 2026 11:11:42 +0300 Subject: [PATCH] feat(updater): back up the local database before an in-app update, refuse a newer schema without changing it, explain the channel row on package installs (#1497) --- docs/security.md | 4 + docs/user-guide.md | 6 ++ lib/core/storage/local_db.dart | 53 +++++++++++++ lib/core/storage/update_backup.dart | 72 +++++++++++++++++ lib/core/updater/app_updater_service.dart | 6 +- .../installers/update_install_context.dart | 16 ++++ lib/features/settings/preferences_dialog.dart | 15 +++- lib/features/updater/update_dialog.dart | 78 +++++++++++++++++++ .../storage/local_db_newer_schema_test.dart | 64 +++++++++++++++ test/core/storage/update_backup_test.dart | 78 +++++++++++++++++++ .../updater/app_updater_service_test.dart | 27 +++++++ .../preferences_master_detail_test.dart | 27 +++++++ test/features/updater/update_dialog_test.dart | 76 ++++++++++++++++++ 13 files changed, 518 insertions(+), 4 deletions(-) create mode 100644 lib/core/storage/update_backup.dart create mode 100644 test/core/storage/local_db_newer_schema_test.dart create mode 100644 test/core/storage/update_backup_test.dart diff --git a/docs/security.md b/docs/security.md index ae760465..2ac1e9c1 100644 --- a/docs/security.md +++ b/docs/security.md @@ -42,6 +42,10 @@ Credentials never reach the model: tools return only connection id, name, type, **Audit:** every call is logged (Preferences → MCP Server → Recent calls). +## Local database, updates and downgrades + +`querya.db` carries a schema version. Before an in-app update the file (and its `-wal`) is copied to `querya.db.pre--.bak`, the newest three copies are kept, and a failed copy asks before the update goes on (#1497). On open, a version **newer than the build knows** raises `LocalDbNewerSchemaException`: nothing is migrated, nothing is written, and the file keeps its version number (without this, sqflite would stamp the older number on it and an older build would carry on with tables it does not understand). The startup recovery screen shows the message. Restoring the pre-update copy is the supported way back; see the [user guide](user-guide.md#updates-betas-and-going-back). + ## Tests Automated tests use an **in-memory** secrets backend (see `test/flutter_test_config.dart`) so CI does not require a desktop keyring. diff --git a/docs/user-guide.md b/docs/user-guide.md index a0e8d654..fa5252dc 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -34,6 +34,12 @@ Command Palette (`Ctrl+P`) → **Export Team Profile…** writes a `team-profile - **Connection list and settings**: local SQLite (`querya.db` under the app support directory). - **Passwords / connection strings**: OS secure store (see [security.md](security.md)). +### Updates, betas and going back + +- **Release channel** (Preferences → General): *Stable* gets the releases marked **Latest** on GitHub; *Beta / Early Access* also gets pre-releases (`0.5.0-beta.2`) and always the highest version. A beta is replaced by the final release of the same version (`0.5.0-beta.2` → `0.5.0`). The channel only applies to the portable zip / AppImage, the Windows setup and the macOS app; a Snap, Flatpak, AUR, `.deb` or `.rpm` install is updated by its package manager, stable only, and the row is shown as such. +- **Before an in-app update** Querya copies `querya.db` next to itself as `querya.db.pre--.bak` (the newest 3 are kept). If the copy fails you are asked whether to install anyway. +- **Going back to an older version is not supported once a newer one has run.** A beta may change the format of the local data. An older build that finds data written by a newer one **refuses to open it and does not change it**: the startup screen names the newer format. To go back, close Querya, replace `querya.db` with the `.bak` made before the update (and delete any `querya.db-wal` / `-shm` next to it), then start the older version. Changes made since that copy are lost. + ## Preferences Open **Edit → Preferences…** to change options that apply across the app: diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 109548cf..a378e903 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -8,6 +8,7 @@ import 'package:querya_desktop/core/security/connection_environment.dart'; import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; import 'package:querya_desktop/core/storage/app_data_root.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; +import 'package:querya_desktop/core/storage/update_backup.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; const _dbName = 'querya.db'; @@ -40,6 +41,36 @@ String? _normalizeHistoryDatabaseName(String? databaseName) { return t; } +/// The local database was written by a newer Querya Desktop: its schema +/// version is above the one this build knows (#1497). +/// +/// Opening it would have let sqflite write the older version number into it +/// and carry on with tables it does not understand. The open is refused +/// instead, inside the open transaction, so nothing in the file changes. +class LocalDbNewerSchemaException implements Exception { + const LocalDbNewerSchemaException({ + required this.found, + required this.supported, + required this.path, + }); + + /// Schema version stored in the file. + final int found; + + /// Highest schema version this build reads. + final int supported; + + final String path; + + @override + String toString() => + 'The local data in $path was written by a newer version of Querya ' + 'Desktop (data format $found; this version reads up to $supported). ' + 'It has not been changed. Install the newer version again, or restore ' + 'a copy made before the update (querya.db.pre--.bak in the ' + 'same folder).'; +} + /// Local SQLite database for folders and connections. /// File: [applicationSupport]/querya_desktop/querya.db class LocalDb { @@ -84,6 +115,13 @@ class LocalDb { version: _dbVersion, onCreate: _onCreate, onUpgrade: _onUpgrade, + // A newer schema: refuse, so sqflite does not stamp the old version + // on it (the open transaction rolls back). + onDowngrade: (db, found, supported) => throw LocalDbNewerSchemaException( + found: found, + supported: supported, + path: _cachedDbPath!, + ), onOpen: (db) async { await db.execute('PRAGMA foreign_keys = ON'); await db.execute('PRAGMA journal_mode = WAL'); @@ -916,6 +954,21 @@ class LocalDb { } } + /// Copies `querya.db` to `querya.db.pre--.bak` before an in-app + /// update and keeps the newest [UpdateBackup.keep] copies (#1497). Closes + /// the database first; the next query opens it again. + Future backupBeforeUpdate({ + required String fromVersion, + required String toVersion, + }) async { + await close(); + return UpdateBackup.create( + dbPath: await databasePath(), + fromVersion: fromVersion, + toVersion: toVersion, + ); + } + /// Creates a timestamped backup copy of `querya.db` (and any sidecar files) /// in the same directory and returns the backup file path. Future backupDatabaseFile() async { diff --git a/lib/core/storage/update_backup.dart b/lib/core/storage/update_backup.dart new file mode 100644 index 00000000..00100a7b --- /dev/null +++ b/lib/core/storage/update_backup.dart @@ -0,0 +1,72 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// Copies of the local database made before an in-app update (#1497). +/// +/// An update can bring a newer database schema, and a later downgrade (a +/// beta promoted to an older stable, or an older build installed by hand) +/// cannot read it. The copy next to the database is the way back. +abstract final class UpdateBackup { + /// How many pre-update copies are kept; older ones are deleted. + static const keep = 3; + + /// `querya.db.pre--.bak` next to [dbPath]. + static String pathFor(String dbPath, String fromVersion, String toVersion) { + String safe(String v) => v.replaceAll(RegExp(r'[^0-9A-Za-z.+-]'), '_'); + return '$dbPath.pre-${safe(fromVersion)}-${safe(toVersion)}.bak'; + } + + /// Copies [dbPath] (and its `-wal`, if any) to [pathFor] and deletes all but + /// the newest [keep] copies. The database must be closed. Returns the copy. + static Future create({ + required String dbPath, + required String fromVersion, + required String toVersion, + int keep = UpdateBackup.keep, + }) async { + final db = File(dbPath); + if (!await db.exists()) { + throw FileSystemException('Database file does not exist', dbPath); + } + final backup = pathFor(dbPath, fromVersion, toVersion); + await db.copy(backup); + final wal = File('$dbPath-wal'); + final backupWal = File('$backup-wal'); + if (await wal.exists()) { + await wal.copy(backupWal.path); + } else if (await backupWal.exists()) { + await backupWal.delete(); // from an earlier copy with the same name + } + // A copy made just now is the newest even when the clock is coarse. + await File(backup).setLastModified(DateTime.now()); + await prune(dbPath, keep: keep); + return backup; + } + + /// The pre-update copies of [dbPath], newest first. + static Future> list(String dbPath) async { + final dir = Directory(p.dirname(dbPath)); + final prefix = '${p.basename(dbPath)}.pre-'; + final copies = []; + await for (final e in dir.list(followLinks: false)) { + final name = p.basename(e.path); + if (e is File && name.startsWith(prefix) && name.endsWith('.bak')) { + copies.add(e); + } + } + final stamps = {for (final f in copies) f.path: await f.lastModified()}; + copies.sort((a, b) => stamps[b.path]!.compareTo(stamps[a.path]!)); + return copies; + } + + /// Deletes all but the newest [keep] copies, with their `-wal` files. + static Future prune(String dbPath, {int keep = UpdateBackup.keep}) async { + final copies = await list(dbPath); + for (final old in copies.skip(keep)) { + await old.delete(); + final wal = File('${old.path}-wal'); + if (await wal.exists()) await wal.delete(); + } + } +} diff --git a/lib/core/updater/app_updater_service.dart b/lib/core/updater/app_updater_service.dart index fc462096..1e327d97 100644 --- a/lib/core/updater/app_updater_service.dart +++ b/lib/core/updater/app_updater_service.dart @@ -48,7 +48,11 @@ class AppUpdaterService { throw AppUpdaterException('Invalid current app version: $currentRaw'); } - final channel = await _settings.getUpdateChannel(); + // A package install only ever gets stable releases from its package + // manager, so it is not told about betas it cannot install (#1497). + final channel = UpdateInstallContext.currentIsPackageManagerInstall + ? UpdateChannel.stable + : await _settings.getUpdateChannel(); final manifest = await _releasesClient.fetchLatest(channel: channel); final candidateVersion = UpdateVersion.tryParse(manifest.version); if (candidateVersion == null) { diff --git a/lib/core/updater/installers/update_install_context.dart b/lib/core/updater/installers/update_install_context.dart index 7cf0f622..9cb4f579 100644 --- a/lib/core/updater/installers/update_install_context.dart +++ b/lib/core/updater/installers/update_install_context.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:path/path.dart' as p; import 'package:querya_desktop/core/platform/macos_sandbox.dart'; @@ -114,6 +115,21 @@ class UpdateInstallContext { return null; } + /// Installed by a package manager (Snap, Flatpak, AUR, `.deb`, `.rpm`): + /// it brings the updates, and they are always stable releases, so the + /// Release channel has no effect there (#1497). + bool get isPackageManagerInstall => + isSnap || isFlatpak || linuxSystemPackageManager != null; + + /// Forces [currentIsPackageManagerInstall] in tests; null detects. + @visibleForTesting + static bool? debugPackageManagerInstall; + + /// [isPackageManagerInstall] for this process. + static bool get currentIsPackageManagerInstall => + debugPackageManagerInstall ?? + UpdateInstallContext.current().isPackageManagerInstall; + bool get isManagedPackage => isSnap || isFlatpak || diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index c74dea06..78f52686 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/updater/installers/update_install_context.dart'; import 'package:querya_desktop/core/updater/update_manifest.dart'; import 'package:querya_desktop/features/settings/preferences_about_section.dart'; import 'package:querya_desktop/features/settings/preferences_appearance_section.dart'; @@ -576,11 +577,19 @@ class PreferencesDialogContentState ), const material.SizedBox(height: 16), PreferencesFieldRow( + key: const material.ValueKey('release_channel_row'), label: 'Release channel', - hint: - 'Stable receives thoroughly tested monthly updates. Beta receives early releases with experimental features.', + // On a package install the channel does nothing: the package + // manager brings the updates, stable only (#1497). + hint: UpdateInstallContext.currentIsPackageManagerInstall + ? 'Updates come from your package manager (stable releases ' + 'only), so the release channel does not apply to this install.' + : 'Stable receives thoroughly tested monthly updates. Beta receives early releases with experimental features.', control: PreferencesDropdownMenu( - value: _updateChannel, + enabled: !UpdateInstallContext.currentIsPackageManagerInstall, + value: UpdateInstallContext.currentIsPackageManagerInstall + ? UpdateChannel.stable + : _updateChannel, onSelected: (v) { if (v != null) unawaited(_setUpdateChannel(v)); }, diff --git a/lib/features/updater/update_dialog.dart b/lib/features/updater/update_dialog.dart index cde282ac..2b8c75dd 100644 --- a/lib/features/updater/update_dialog.dart +++ b/lib/features/updater/update_dialog.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart' as material; import 'package:url_launcher/url_launcher_string.dart'; import 'package:flutter/services.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/unsaved_work_registry.dart'; import 'package:querya_desktop/core/updater/app_updater_service.dart'; import 'package:querya_desktop/core/updater/sha256_checksums.dart'; @@ -87,6 +88,59 @@ Future showUnsavedUpdateRestartDialog(material.BuildContext context) { ); } +/// The local database could not be copied before the update (#1497): install +/// anyway, or cancel. +Future showUpdateBackupFailedDialog( + material.BuildContext context, + String error, +) { + return showAppDialog( + context: context, + builder: (ctx) { + return QueryaDialogCard( + constraints: const material.BoxConstraints(maxWidth: 440), + child: material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Could not back up local data').semiBold().large(), + const Gap(8), + Text( + 'Querya Desktop copies its local data (connections, history, ' + 'settings) before an update, so you can go back if the new ' + 'version changes it. The copy failed: $error', + ).muted().small(), + const Gap(20), + material.Align( + alignment: material.Alignment.centerRight, + child: material.Wrap( + alignment: material.WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + QueryaButton.ghost( + label: 'Cancel', + onPressed: () => material.Navigator.of(ctx).pop(false), + size: QueryaControlSize.lg, + ), + QueryaButton.destructive( + label: 'Install without a copy', + onPressed: () => material.Navigator.of(ctx).pop(true), + size: QueryaControlSize.lg, + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); +} + /// Update check / download / install pane. Test hooks are optional overrides. class UpdateDialogContent extends material.StatefulWidget { const UpdateDialogContent({ @@ -100,6 +154,7 @@ class UpdateDialogContent extends material.StatefulWidget { this.selectAsset, this.download, this.install, + this.backupLocalData, this.confirmRestartIfUnsaved, this.showCopyToast = true, }); @@ -114,6 +169,11 @@ class UpdateDialogContent extends material.StatefulWidget { final Future Function(UpdateAsset asset, UpdateManifest manifest)? download; final Future Function(File file)? install; + + /// Copies the local database before installing; defaults to + /// [LocalDb.backupBeforeUpdate]. + final Future Function(String fromVersion, String toVersion)? + backupLocalData; final Future Function(material.BuildContext context)? confirmRestartIfUnsaved; final bool showCopyToast; @@ -271,6 +331,24 @@ class UpdateDialogContentState extends material.State { if (!mounted || confirmed != true) return; } + // A copy of the local database first: the new version may change its + // schema, and an older one cannot read it back (#1497). + final from = _currentVersion.isEmpty ? 'unknown' : _currentVersion; + final to = _manifest?.version ?? 'unknown'; + // A test that replaces [install] and not this hook skips the copy. + final backup = widget.backupLocalData ?? + (widget.install == null + ? (String f, String t) => + LocalDb.instance.backupBeforeUpdate(fromVersion: f, toVersion: t) + : null); + try { + if (backup != null) await backup(from, to); + } catch (e) { + if (!mounted) return; + final proceed = await showUpdateBackupFailedDialog(context, '$e'); + if (!mounted || proceed != true) return; + } + try { if (widget.install != null) { await widget.install!(file); diff --git a/test/core/storage/local_db_newer_schema_test.dart b/test/core/storage/local_db_newer_schema_test.dart new file mode 100644 index 00000000..dc9f039a --- /dev/null +++ b/test/core/storage/local_db_newer_schema_test.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import '../../support/local_db_test_support.dart'; + +/// A database written by a newer Querya is refused and left as it is (#1497). +void main() { + late Directory dir; + + setUpAll(() async => dir = await initTestLocalDb('newer_schema_')); + tearDownAll(() => disposeTestLocalDb(dir)); + + Future versionOnDisk(String path) async { + final raw = await databaseFactoryFfi.openDatabase(path); + try { + return await raw.getVersion(); + } finally { + await raw.close(); + } + } + + test('a backup before an update, then the database opens again', () async { + final db = LocalDb.instance; + await db.setAppSetting('before_update', 'yes'); + + final copy = await db.backupBeforeUpdate( + fromVersion: '0.4.22', + toVersion: '0.5.0-beta.1', + ); + + expect(File(copy).existsSync(), isTrue); + expect(copy, endsWith('querya.db.pre-0.4.22-0.5.0-beta.1.bak')); + expect(await db.getAppSetting('before_update'), 'yes'); + }); + + test('a newer schema version is refused and not overwritten', () async { + final db = LocalDb.instance; + final path = await db.databasePath(); + final current = await versionOnDisk(path); + await db.close(); + + final raw = await databaseFactoryFfi.openDatabase(path); + await raw.setVersion(current + 7); + await raw.close(); + + await expectLater( + db.open(), + throwsA(isA() + .having((e) => e.found, 'found', current + 7) + .having((e) => e.supported, 'supported', current) + .having((e) => e.toString(), 'message', contains('newer version'))), + ); + expect(await versionOnDisk(path), current + 7, reason: 'left as it was'); + + // Back to the version this build knows, for the other tests. + final fix = await databaseFactoryFfi.openDatabase(path); + await fix.setVersion(current); + await fix.close(); + expect(await db.getAppSetting('before_update'), 'yes'); + }); +} diff --git a/test/core/storage/update_backup_test.dart b/test/core/storage/update_backup_test.dart new file mode 100644 index 00000000..45a37275 --- /dev/null +++ b/test/core/storage/update_backup_test.dart @@ -0,0 +1,78 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/storage/update_backup.dart'; + +/// Copies of the local database before an in-app update (#1497). +void main() { + late Directory dir; + late String dbPath; + + setUp(() { + dir = Directory.systemTemp.createTempSync('querya_update_backup_'); + dbPath = p.join(dir.path, 'querya.db'); + File(dbPath).writeAsStringSync('main'); + }); + tearDown(() => dir.deleteSync(recursive: true)); + + test('names the copy after both versions, next to the database', () async { + final copy = await UpdateBackup.create( + dbPath: dbPath, + fromVersion: '0.4.22', + toVersion: '0.5.0-beta.1', + ); + expect(copy, p.join(dir.path, 'querya.db.pre-0.4.22-0.5.0-beta.1.bak')); + expect(File(copy).readAsStringSync(), 'main'); + }); + + test('copies the WAL with it, and only when there is one', () async { + File('$dbPath-wal').writeAsStringSync('wal'); + final copy = await UpdateBackup.create( + dbPath: dbPath, + fromVersion: '1.0.0', + toVersion: '1.0.1', + ); + expect(File('$copy-wal').readAsStringSync(), 'wal'); + + File('$dbPath-wal').deleteSync(); + await UpdateBackup.create(dbPath: dbPath, fromVersion: '1.0.0', toVersion: '1.0.1'); + expect(File('$copy-wal').existsSync(), isFalse, reason: 'stale WAL removed'); + }); + + test('keeps the newest three and deletes the rest with their WAL', () async { + final made = []; + for (var i = 1; i <= 5; i++) { + if (i == 2) File('$dbPath-wal').writeAsStringSync('wal'); + if (i == 3) File('$dbPath-wal').deleteSync(); + made.add(await UpdateBackup.create( + dbPath: dbPath, + fromVersion: '0.4.$i', + toVersion: '0.4.${i + 1}', + )); + await Future.delayed(const Duration(milliseconds: 20)); + } + + final kept = (await UpdateBackup.list(dbPath)).map((f) => f.path).toList(); + expect(kept, [made[4], made[3], made[2]]); + expect(File(made[0]).existsSync(), isFalse); + expect(File(made[1]).existsSync(), isFalse); + expect(File('${made[1]}-wal').existsSync(), isFalse); + expect(File(dbPath).existsSync(), isTrue, reason: 'the database itself stays'); + }); + + test('odd characters in a version cannot leave the folder', () { + expect( + UpdateBackup.pathFor(dbPath, '../x', '1/2'), + p.join(dir.path, 'querya.db.pre-.._x-1_2.bak'), + ); + }); + + test('a missing database is an error, not an empty copy', () async { + File(dbPath).deleteSync(); + await expectLater( + UpdateBackup.create(dbPath: dbPath, fromVersion: 'a', toVersion: 'b'), + throwsA(isA()), + ); + }); +} diff --git a/test/core/updater/app_updater_service_test.dart b/test/core/updater/app_updater_service_test.dart index 9acccb6c..178439ee 100644 --- a/test/core/updater/app_updater_service_test.dart +++ b/test/core/updater/app_updater_service_test.dart @@ -11,6 +11,7 @@ import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/updater/app_updater_service.dart'; import 'package:querya_desktop/core/updater/github_releases_client.dart'; +import 'package:querya_desktop/core/updater/installers/update_install_context.dart'; import 'package:querya_desktop/core/updater/sha256_checksums.dart'; import 'package:querya_desktop/core/updater/update_manifest.dart'; @@ -154,6 +155,32 @@ void main() { service.dispose(); }); + test('a package install reads releases/latest even on the Beta channel (#1497)', + () async { + await AppSettings.instance.setUpdateChannel(UpdateChannel.dev); + UpdateInstallContext.debugPackageManagerInstall = true; + addTearDown(() => UpdateInstallContext.debugPackageManagerInstall = null); + + final client = MockClient((request) async { + expect(request.url.toString(), kGitHubReleasesLatestUrl); + return http.Response( + jsonEncode(_sampleRelease(version: '0.5.1')), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final service = AppUpdaterService( + releasesClient: GitHubReleasesClient(httpClient: client), + downloadClient: client, + packageInfoProvider: () async => _packageInfo('0.4.9'), + ); + + final result = await service.checkForUpdates(); + expect(result.availableUpdate?.version, '0.5.1'); + service.dispose(); + }); + test('background mode returns error message instead of throwing', () async { final client = MockClient((request) async { return http.Response('rate limited', 403); diff --git a/test/features/settings/preferences_master_detail_test.dart b/test/features/settings/preferences_master_detail_test.dart index 20ca31d9..fa682f74 100644 --- a/test/features/settings/preferences_master_detail_test.dart +++ b/test/features/settings/preferences_master_detail_test.dart @@ -7,6 +7,7 @@ import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/core/updater/installers/update_install_context.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; @@ -174,6 +175,32 @@ void main() { expect(position(PreferencesCategory.shortcuts).pixels, 100); }); + testWidgets('the release channel is usable on a portable install (#1497)', + (tester) async { + await tester.pumpWidget(buildTestApp()); + await tester.tap(find.text('Open Preferences')); + await tester.pumpAndSettle(); + + expect(find.byKey(const material.ValueKey('release_channel_row')), + findsOneWidget); + expect(find.textContaining('Updates come from your package manager'), + findsNothing); + }); + + testWidgets('on a package install the channel row says why it does nothing (#1497)', + (tester) async { + UpdateInstallContext.debugPackageManagerInstall = true; + addTearDown(() => UpdateInstallContext.debugPackageManagerInstall = null); + + await tester.pumpWidget(buildTestApp()); + await tester.tap(find.text('Open Preferences')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Updates come from your package manager'), + findsOneWidget); + expect(find.text('Stable (Recommended)'), findsOneWidget); + }); + testWidgets('search bar filters preferences and updates matches', (tester) async { await tester.pumpWidget(buildTestApp()); diff --git a/test/features/updater/update_dialog_test.dart b/test/features/updater/update_dialog_test.dart index a49a4c0b..11135e41 100644 --- a/test/features/updater/update_dialog_test.dart +++ b/test/features/updater/update_dialog_test.dart @@ -184,6 +184,82 @@ void main() { expect(downloaded, isFalse); }); + Future pumpInstallable( + WidgetTester tester, { + required Future Function(String, String) backup, + required Future Function(File) install, + }) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: SizedBox( + width: 800, + height: 700, + child: UpdateDialogContent( + initialManifest: _manifest, + isInstallBlocked: false, + hasUnsavedWork: () => false, + selectAsset: (_) => _asset, + download: (_, __) async => File('/tmp/querya-update.zip'), + backupLocalData: backup, + install: install, + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Download')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(find.text('Restart & Update Now')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + } + + testWidgets('the local data is copied before the update is installed (#1497)', + (tester) async { + final order = []; + String? to; + await pumpInstallable( + tester, + backup: (from, target) async { + order.add('backup'); + to = target; + }, + install: (_) async => order.add('install'), + ); + + expect(order, ['backup', 'install']); + expect(to, '0.5.1'); + }); + + testWidgets('a failed copy asks, and Cancel keeps the app as it is (#1497)', + (tester) async { + var installed = false; + await pumpInstallable( + tester, + backup: (_, __) async => throw const FileSystemException('disk full'), + install: (_) async => installed = true, + ); + + expect(find.text('Could not back up local data'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(installed, isFalse); + }); + + testWidgets('a failed copy can be overridden (#1497)', (tester) async { + var installed = false; + await pumpInstallable( + tester, + backup: (_, __) async => throw const FileSystemException('disk full'), + install: (_) async => installed = true, + ); + + await tester.tap(find.text('Install without a copy')); + await tester.pumpAndSettle(); + expect(installed, isTrue); + }); + testWidgets('dirty restart cancel does not install', (tester) async { var installed = false; await tester.pumpWidget(