diff --git a/lib/history/history_db.dart b/lib/history/history_db.dart index ecf3f59..931caf4 100644 --- a/lib/history/history_db.dart +++ b/lib/history/history_db.dart @@ -13,6 +13,7 @@ import 'package:sqlite3/sqlite3.dart'; /// instead, same lib, no extra layer needed. class HistoryDb { static Database? _instance; + static bool _useInMemory = false; static String get _stateDir => '${Platform.environment['HOME']}/.local/state/layer_wizard'; static String get _dbPath => '$_stateDir/history.db'; @@ -25,15 +26,28 @@ class HistoryDb { open.overrideFor(OperatingSystem.linux, _openLinuxLibSqlite3); } + final db = _useInMemory ? sqlite3.openInMemory() : _openOnDisk(); + _migrate(db); + _instance = db; + return db; + } + + static Database _openOnDisk() { final dir = Directory(_stateDir); if (!dir.existsSync()) { dir.createSync(recursive: true); } + return sqlite3.open(_dbPath); + } - final db = sqlite3.open(_dbPath); - _migrate(db); - _instance = db; - return db; + /// Test-only: discards any open connection and makes the next + /// [database] call return a fresh in-memory database instead of touching + /// the real `~/.local/state/layer_wizard/history.db`. Call this from + /// `setUp` in every test that exercises [HistoryService]. + static void resetForTesting() { + _instance?.dispose(); + _instance = null; + _useInMemory = true; } static DynamicLibrary _openLinuxLibSqlite3() { diff --git a/lib/history/history_service.dart b/lib/history/history_service.dart index aa658a8..80552c5 100644 --- a/lib/history/history_service.dart +++ b/lib/history/history_service.dart @@ -27,12 +27,15 @@ class HistoryService { /// Compares the live `rpm-ostree status` against our DB and brings /// `current` (and, on first run, `last_known_safe`) up to date. Safe to /// call on every launch — a no-op if nothing has changed since last time. - Future reconcile() async { + /// + /// [deployments] overrides the live query — tests pass a fixed list so + /// they don't depend on `rpm-ostree` actually being present/accurate. + Future reconcile({List? deployments}) async { final db = _db; - final deployments = await OstreeService.getAllDeployments(); - final booted = deployments.firstWhere( + final deps = deployments ?? await OstreeService.getAllDeployments(); + final booted = deps.firstWhere( (d) => d.booted, - orElse: () => deployments.first, + orElse: () => deps.first, ); LayerNode node; diff --git a/test/history_service_test.dart b/test/history_service_test.dart new file mode 100644 index 0000000..4f11d61 --- /dev/null +++ b/test/history_service_test.dart @@ -0,0 +1,128 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:layer_wizard/history/history_db.dart'; +import 'package:layer_wizard/history/history_service.dart'; +import 'package:layer_wizard/ostree_service.dart'; + +DeploymentInfo _deployment({ + required String checksum, + bool booted = false, + bool staged = false, + String version = 'v1', + List requestedPackages = const [], +}) { + return DeploymentInfo( + version: version, + checksum: checksum, + containerImageRef: 'ostree-image-signed:docker://ghcr.io/ublue-os/aurora:stable', + requestedPackages: requestedPackages, + booted: booted, + staged: staged, + ); +} + +void main() { + final history = HistoryService.instance; + + setUp(() { + HistoryDb.resetForTesting(); + }); + + test('first reconcile bootstraps a root node and both pointers', () async { + final a = _deployment(checksum: 'aaa', booted: true); + + final current = await history.reconcile(deployments: [a]); + + expect(current.checksum, 'aaa'); + expect(current.parentId, isNull); + expect(current.source, 'detected'); + + final safe = await history.getLastKnownSafe(); + expect(safe?.checksum, 'aaa'); + }); + + test('reconcile is idempotent for an already-known checksum', () async { + final a = _deployment(checksum: 'aaa', booted: true); + await history.reconcile(deployments: [a]); + + await history.reconcile(deployments: [a]); + await history.reconcile(deployments: [a]); + + final all = await history.getAllNodes(); + expect(all, hasLength(1)); + }); + + test('recordInstall adds a child of current without moving current', () async { + final a = _deployment(checksum: 'aaa', booted: true); + final root = await history.reconcile(deployments: [a]); + + final staged = _deployment(checksum: 'bbb', staged: true, requestedPackages: ['vim']); + final node = await history.recordInstall(packageName: 'vim', staged: staged); + + expect(node.parentId, root.id); + expect(node.action, 'install'); + expect(node.source, 'layer_wizard'); + expect(node.packageName, 'vim'); + + final current = await history.getCurrent(); + expect(current?.id, root.id, reason: 'current must not move until the staged deployment is booted'); + }); + + test('rebooting into a staged install moves current without a duplicate row', () async { + final a = _deployment(checksum: 'aaa', booted: true); + await history.reconcile(deployments: [a]); + + final staged = _deployment(checksum: 'bbb', staged: true, requestedPackages: ['vim']); + final installed = await history.recordInstall(packageName: 'vim', staged: staged); + + final bootedAfterReboot = _deployment(checksum: 'bbb', booted: true, requestedPackages: ['vim']); + final current = await history.reconcile(deployments: [bootedAfterReboot]); + + expect(current.id, installed.id); + final all = await history.getAllNodes(); + expect(all, hasLength(2), reason: 'no new row — same checksum as the staged install'); + }); + + test('a manual change (unknown checksum) is recorded as detected, parented on current', () async { + final a = _deployment(checksum: 'aaa', booted: true); + final root = await history.reconcile(deployments: [a]); + + final manual = _deployment(checksum: 'ccc', booted: true, requestedPackages: ['htop']); + final current = await history.reconcile(deployments: [manual]); + + expect(current.checksum, 'ccc'); + expect(current.source, 'detected'); + expect(current.parentId, root.id); + + final all = await history.getAllNodes(); + expect(all, hasLength(2)); + }); + + test('markCurrentAsSafe moves last_known_safe to the current node', () async { + final a = _deployment(checksum: 'aaa', booted: true); + await history.reconcile(deployments: [a]); + + final b = _deployment(checksum: 'bbb', booted: true); + await history.reconcile(deployments: [b]); + + expect((await history.getLastKnownSafe())?.checksum, 'aaa'); + + await history.markCurrentAsSafe(); + + expect((await history.getLastKnownSafe())?.checksum, 'bbb'); + }); + + test('getChainToCurrent walks root to current in order', () async { + final a = _deployment(checksum: 'aaa', booted: true); + await history.reconcile(deployments: [a]); + + final staged = _deployment(checksum: 'bbb', staged: true, requestedPackages: ['vim']); + await history.recordInstall(packageName: 'vim', staged: staged); + + final bootedAfterReboot = _deployment(checksum: 'bbb', booted: true, requestedPackages: ['vim']); + await history.reconcile(deployments: [bootedAfterReboot]); + + final chain = await history.getChainToCurrent(); + + expect(chain.map((n) => n.checksum), ['aaa', 'bbb']); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 28e96b3..6acc480 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,12 +5,17 @@ import 'package:layer_wizard/main.dart'; void main() { testWidgets('app boots without crashing', (WidgetTester tester) async { - await tester.pumpWidget(const LayerWizardApp()); - // Intentionally a single pump, not pumpAndSettle: startup kicks off real - // async work (reading a file, running `rpm-ostree status`) behind a - // CircularProgressIndicator, which never "settles". This just checks - // the app renders its initial loading frame without throwing. - await tester.pump(); + // Startup kicks off real async work (reading a file, running + // `rpm-ostree status`, opening the history DB) behind a + // CircularProgressIndicator that never "settles" within this test, so + // this only checks the initial loading frame renders without throwing. + // runAsync is required because that startup work includes real + // dart:io Process/FFI calls — without it, flutter_test's fake-time zone + // flags a dangling Timer from the real I/O once the tree is disposed. + await tester.runAsync(() async { + await tester.pumpWidget(const LayerWizardApp()); + await tester.pump(); + }); expect(find.byType(CircularProgressIndicator), findsOneWidget); });