Files
shorebird-updater/shorebird_code_push/test/integration/all_test.dart
T
Eric Seidel 96fd32796e feat: stage 2 integration tests — fake patch server + golden path + rollback regression (#355)
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.
2026-05-06 08:11:47 -07:00

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