Add automated tests for HistoryService instead of throwaway scripts

Gives HistoryDb an in-memory mode and reconcile() an injectable
deployments list so HistoryService can be exercised hermetically,
without touching the real history.db or shelling out to rpm-ostree.
Covers bootstrap, idempotent reconcile, install-then-reboot,
manual/detected changes, last_known_safe, and chain walking.

Also fixes the pre-existing widget boot-smoke test: startup now makes
a second real Process.run call (via HistoryService.reconcile), which
tipped flutter_test's fake-time zone into flagging a dangling Timer
from that real I/O. Wrapping the pump in tester.runAsync() is the
standard fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Philip Peterson 2026-10-07 13:30:07 -08:00
parent b935ab01b2
commit cb5dcdd666
4 changed files with 164 additions and 14 deletions

View file

@ -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() {

View file

@ -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<LayerNode> 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<LayerNode> reconcile({List<DeploymentInfo>? 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;

View file

@ -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<String> 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']);
});
}

View file

@ -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);
});