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:
parent
b935ab01b2
commit
cb5dcdd666
4 changed files with 164 additions and 14 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
128
test/history_service_test.dart
Normal file
128
test/history_service_test.dart
Normal 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']);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue