96fd32796e
Lands the fake HTTP server, the patch fixture pipeline, and the first two scenarios that exercise the real FFI path end-to-end. Plus a test that would have caught the patch-to-release rollback bug (shorebirdtech/shorebird#3728). Architecture follows the principles surfaced in review: - Dart tests call the public `ShorebirdUpdater` API only — never the raw `Updater` FFI wrapper, never the engine API. - Engine API stays inside the `library_test_hooks` Rust crate. `shorebird_test_init` constructs `AppParameters` + stub `FileCallbacks` internally so Dart never sees those types. `shorebird_test_simulate_successful_launch` wraps the start/success protocol so the Dart layer never knows there's a protocol — it just knows "the engine reported a successful boot." - ffigen scans only the test_hooks header. The engine header (updater_engine.h) does not appear in the Dart bindings. - A `TestEngine` Dart helper concentrates engine-side simulation in one place; the test bodies stay focused on `ShorebirdUpdater`. Implementation choices worth flagging: - FFI calls run via `Isolate.run`. Synchronous run blocks the main isolate, deadlocking against the in-isolate shelf server. The test's IsolateRun callback re-opens the cdylib (cheap: dlopen is ref-counted) and resets `Updater.bindings` in the sub-isolate because Dart isolates do not share static fields. - `libapp_path` must be a real file on the desktop integration build: the non-Android non-iOS non-test `patch_base` reads it directly from disk. Tests that install a patch write the fixture's `base` bytes to `libapp.so` before init. - Fake server kept minimal: shelf, no Range support, no auth, no concurrency knobs. Stage 3+ scenarios (download cutoff, hash mismatch loop, etc.) extend it as needed. Three scenarios cover three reasons we wanted this suite: 1. `checkForUpdate returns upToDate when server has no patch` — baseline: confirms the harness boots cleanly and returns the expected enum. 2. `install a patch and boot from it` — golden path: check → update → simulateSuccessfulLaunch, then assert `readCurrentPatch` / `readNextPatch` / `checkForUpdate` transitions match the public API contract. 3. `checkForUpdate returns restartRequired after patch-to-release rollback` — regression for shorebirdtech/shorebird#3728. Pre-fix this returned `upToDate` and left no signal to prompt a restart. Verified locally: 232 Rust unit tests + 44 Dart tests (41 existing unit + 3 new integration) green; clippy/fmt/cspell clean.
192 lines
6.7 KiB
Dart
192 lines
6.7 KiB
Dart
// All updater integration tests live in this single file. This is
|
|
// deliberate: `package:test` parallelizes tests across files via
|
|
// isolates, but `dlopen` loads the test_hooks cdylib exactly once per
|
|
// process and the updater's `OnceCell<UpdateConfig>` is shared across
|
|
// every isolate. Splitting these tests across multiple files would
|
|
// require either a `dart_test.yaml` concurrency override scoped to
|
|
// `test/integration/`, or a subprocess-per-test runner.
|
|
//
|
|
// Same file = same isolate = serial = no contention. If the suite
|
|
// outgrows one file, see `docs/integration_tests.md` for the
|
|
// concurrency-override path.
|
|
//
|
|
// The unit tests under `test/src/` are unaffected: they only use
|
|
// `_MockUpdaterBindings` and never load the real cdylib.
|
|
|
|
// `setUpAll` shells out to `cargo build -p library_test_hooks`. On a
|
|
// cold checkout that compiles `updater` and its dependencies and can
|
|
// take a couple of minutes — well past `package:test`'s default 30s
|
|
// per-test timeout, which also covers `setUpAll`.
|
|
@Timeout(Duration(minutes: 10))
|
|
library;
|
|
|
|
import 'dart:io';
|
|
|
|
import 'package:shorebird_code_push/shorebird_code_push.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
import 'helpers/build.dart';
|
|
import 'helpers/fake_patch_server.dart';
|
|
import 'helpers/fixtures.dart';
|
|
import 'helpers/test_engine.dart';
|
|
|
|
void main() {
|
|
// Set in setUpAll exactly when the cdylib build/load failed. The
|
|
// pair (`skipReason`, `engine`) is contractually mutually exclusive:
|
|
// a null `skipReason` means `engine` is initialized, and tests
|
|
// early-return on a non-null `skipReason` before touching `engine`.
|
|
String? skipReason;
|
|
late final TestEngine engine;
|
|
|
|
setUpAll(() async {
|
|
try {
|
|
final path = await buildTestHooksCdylib();
|
|
engine = TestEngine.setup(path);
|
|
} on Object catch (e, st) {
|
|
skipReason = 'Could not build/load library_test_hooks cdylib.\n$e\n$st';
|
|
}
|
|
});
|
|
|
|
group('updater integration', () {
|
|
late Directory tmp;
|
|
late FakePatchServer server;
|
|
|
|
setUp(() async {
|
|
if (skipReason != null) return;
|
|
// Single shared process: each test starts from a clean updater +
|
|
// fresh tempdir + fresh fake server.
|
|
engine.reset();
|
|
tmp = Directory.systemTemp.createTempSync('updater-it-');
|
|
server = await FakePatchServer.start();
|
|
});
|
|
|
|
tearDown(() async {
|
|
if (skipReason != null) return;
|
|
await server.stop();
|
|
tmp.deleteSync(recursive: true);
|
|
});
|
|
|
|
test('checkForUpdate returns upToDate when server has no patch', () async {
|
|
// `markTestSkipped` only flags the test as skipped — it does
|
|
// not abort execution. Early-return after marking, otherwise
|
|
// the body below would touch the uninitialized `engine` when
|
|
// setUpAll could not build the cdylib (e.g., no Rust toolchain
|
|
// on the host).
|
|
final reason = skipReason;
|
|
if (reason != null) {
|
|
markTestSkipped(reason);
|
|
return;
|
|
}
|
|
|
|
server.respondWithNoUpdate();
|
|
engine.init(tmp: tmp, server: server);
|
|
|
|
final updater = engine.createUpdater();
|
|
expect(await updater.checkForUpdate(), UpdateStatus.upToDate);
|
|
expect(await updater.readCurrentPatch(), isNull);
|
|
expect(await updater.readNextPatch(), isNull);
|
|
expect(server.patchCheckCount, 1);
|
|
expect(server.downloadCount, 0);
|
|
});
|
|
|
|
test('install a patch and boot from it', () async {
|
|
final reason = skipReason;
|
|
if (reason != null) {
|
|
markTestSkipped(reason);
|
|
return;
|
|
}
|
|
|
|
server.enqueuePatch(helloTestsPatch);
|
|
engine.init(
|
|
tmp: tmp,
|
|
server: server,
|
|
libappBase: helloTestsPatch.base,
|
|
);
|
|
|
|
final updater = engine.createUpdater();
|
|
|
|
// Initial state: no patch, but server has one available.
|
|
expect(await updater.checkForUpdate(), UpdateStatus.outdated);
|
|
expect(await updater.readCurrentPatch(), isNull);
|
|
expect(await updater.readNextPatch(), isNull);
|
|
|
|
// Apply it. update() should complete without throwing.
|
|
await updater.update();
|
|
|
|
// Patch is staged but the running session hasn't booted it.
|
|
expect(await updater.checkForUpdate(), UpdateStatus.restartRequired);
|
|
expect(await updater.readCurrentPatch(), isNull);
|
|
expect(
|
|
(await updater.readNextPatch())?.number,
|
|
helloTestsPatch.number,
|
|
);
|
|
|
|
// Engine boots the new patch.
|
|
engine.simulateSuccessfulLaunch();
|
|
|
|
// Server still has patch 1 enqueued, but the updater knows it's
|
|
// already installed (`should_install_patch` returns
|
|
// PatchAlreadyInstalled), so checkForUpdate reports upToDate.
|
|
expect(await updater.checkForUpdate(), UpdateStatus.upToDate);
|
|
expect(
|
|
(await updater.readCurrentPatch())?.number,
|
|
helloTestsPatch.number,
|
|
);
|
|
expect(
|
|
(await updater.readNextPatch())?.number,
|
|
helloTestsPatch.number,
|
|
);
|
|
expect(server.downloadCount, 1);
|
|
});
|
|
|
|
// Regression for shorebirdtech/shorebird#3728 (and originally
|
|
// #3206). Pre-fix, after a patch-to-release rollback the
|
|
// updater's `current_boot_patch_number` would silently flip to 0
|
|
// because `try_fall_back_from_patch` cleared `last_booted_patch`
|
|
// for the patch the process was actually executing. The Dart
|
|
// layer compared `null != null` and reported `upToDate`, leaving
|
|
// callers no signal to prompt a restart. This test would have
|
|
// caught it.
|
|
test(
|
|
'checkForUpdate returns restartRequired after patch-to-release '
|
|
'rollback', () async {
|
|
final reason = skipReason;
|
|
if (reason != null) {
|
|
markTestSkipped(reason);
|
|
return;
|
|
}
|
|
|
|
// Phase 1: install + launch patch 1.
|
|
server.enqueuePatch(helloTestsPatch);
|
|
engine.init(
|
|
tmp: tmp,
|
|
server: server,
|
|
libappBase: helloTestsPatch.base,
|
|
);
|
|
final updater = engine.createUpdater();
|
|
await updater.update();
|
|
engine.simulateSuccessfulLaunch();
|
|
expect(
|
|
(await updater.readCurrentPatch())?.number,
|
|
helloTestsPatch.number,
|
|
);
|
|
|
|
// Phase 2: server rolls patch 1 back with no replacement.
|
|
server.respondWithRollback([helloTestsPatch.number]);
|
|
|
|
// Pre-fix: this returned upToDate. Post-fix: restartRequired —
|
|
// the running process is on patch 1 but the next boot will
|
|
// fall back to the base release.
|
|
expect(await updater.checkForUpdate(), UpdateStatus.restartRequired);
|
|
// current_boot_patch_number must keep reporting patch 1 — the
|
|
// process is still executing it.
|
|
expect(
|
|
(await updater.readCurrentPatch())?.number,
|
|
helloTestsPatch.number,
|
|
);
|
|
// next_boot_patch was cleared by the rollback.
|
|
expect(await updater.readNextPatch(), isNull);
|
|
});
|
|
});
|
|
}
|