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.
This commit is contained in:
Eric Seidel
2026-05-06 08:11:47 -07:00
committed by GitHub
parent 8649c75206
commit 96fd32796e
10 changed files with 614 additions and 35 deletions
+2
View File
@@ -38,6 +38,7 @@ words:
- Decompressor
- dllexport
- dlopen
- downloadables
- EDQUOT
- EACCES
- embedders
@@ -55,6 +56,7 @@ words:
- libflutter
- libupdater
- logcat
- malloc
- memmap
- mmap
- mockall
@@ -26,6 +26,28 @@ extern "C" {
*/
SHOREBIRD_EXPORT void shorebird_test_reset(void);
/**
* Test-only convenience wrapper around `shorebird_init` that builds
* `AppParameters` and stub `FileCallbacks` internally. Dart tests
* pass plain C strings instead of needing bindings for those
* engine-API structs.
*/
SHOREBIRD_EXPORT
bool shorebird_test_init(const char *app_storage_dir,
const char *code_cache_dir,
const char *release_version,
const char *libapp_path,
const char *yaml);
/**
* Simulates the engine's successful boot of `next_boot_patch`.
* In production this is two engine actions (launch-start, then
* launch-success after Dart VM startup completes); the Dart layer
* has no concept of either, so we expose the combined outcome as a
* single semantic action: "the next patch booted cleanly."
*/
SHOREBIRD_EXPORT void shorebird_test_simulate_successful_launch(void);
#ifdef __cplusplus
} // extern "C"
#endif // __cplusplus
+63
View File
@@ -21,6 +21,10 @@
//! Production updater builds (the cdylib/staticlib that ships in the
//! engine) do not enable `test-hooks` and never link this crate.
use std::os::raw::c_char;
use updater::c_api::engine;
// Re-export the production C API. This makes the symbols part of this
// crate's public API surface, which prevents the linker from stripping
// them when producing the cdylib (an rlib's `#[no_mangle]` items are
@@ -39,3 +43,62 @@ pub use updater::c_api::engine::*;
pub extern "C" fn shorebird_test_reset() {
updater::testing_reset_config();
}
// Stub `FileCallbacks` mirroring the `#[cfg(test)]` `FileCallbacks::new`
// in `library/src/c_api/c_file.rs`. The test patch fixtures are
// self-contained zstd payloads that bipatch can apply against an empty
// source, so the read/seek callbacks never need to deliver real bytes.
extern "C" fn stub_open() -> *mut libc::c_void {
// `CFileProvider` only checks for null to detect open failure, so
// any non-null value works. `NonNull::dangling()` gives us one
// without tripping clippy's `manual_dangling_ptr` lint (which fires
// on integer-to-pointer casts like `1 as *mut _`).
std::ptr::NonNull::<libc::c_void>::dangling().as_ptr()
}
extern "C" fn stub_read(_handle: *mut libc::c_void, _buffer: *mut u8, _count: usize) -> usize {
0
}
extern "C" fn stub_seek(_handle: *mut libc::c_void, _offset: i64, _whence: i32) -> i64 {
0
}
extern "C" fn stub_close(_handle: *mut libc::c_void) {}
/// Test-only convenience wrapper around `shorebird_init` that builds
/// `AppParameters` and stub `FileCallbacks` internally. Dart tests
/// pass plain C strings instead of needing bindings for those
/// engine-API structs.
#[no_mangle]
pub extern "C" fn shorebird_test_init(
app_storage_dir: *const c_char,
code_cache_dir: *const c_char,
release_version: *const c_char,
libapp_path: *const c_char,
yaml: *const c_char,
) -> bool {
let libapp_paths = [libapp_path];
let params = engine::AppParameters {
release_version,
original_libapp_paths: libapp_paths.as_ptr(),
original_libapp_paths_size: 1,
app_storage_dir,
code_cache_dir,
};
let callbacks = engine::FileCallbacks {
open: stub_open,
read: stub_read,
seek: stub_seek,
close: stub_close,
};
engine::shorebird_init(&params, callbacks, yaml)
}
/// Simulates the engine's successful boot of `next_boot_patch`.
/// In production this is two engine actions (launch-start, then
/// launch-success after Dart VM startup completes); the Dart layer
/// has no concept of either, so we expose the combined outcome as a
/// single semantic action: "the next patch booted cleanly."
#[no_mangle]
pub extern "C" fn shorebird_test_simulate_successful_launch() {
engine::shorebird_report_launch_start();
engine::shorebird_report_launch_success();
}
@@ -12,6 +12,11 @@ output: "test/integration/generated/test_hooks_bindings.g.dart"
name: "TestHooksBindings"
headers:
entry-points:
# Only the test-only hooks defined in `library_test_hooks/src/lib.rs`.
# Tests must not bind directly to the engine API — its stability
# caveat would propagate into the Dart test surface. Where tests need
# engine-API behavior (init, launch reporting), library_test_hooks
# exposes a wrapper.
- "../library_test_hooks/include/library_test_hooks.h"
preamble: |
// ignore_for_file: unused_element, unused_field, type=lint
+3
View File
@@ -15,6 +15,9 @@ dependencies:
dev_dependencies:
ffigen: ">=8.0.2 <21.0.0"
mocktail: ^1.0.0
# Used by integration tests under test/integration/ for FakePatchServer.
# Not part of the package's public API or runtime dependencies.
shelf: ^1.4.0
test: ^1.19.2
very_good_analysis: ">=7.0.0 <11.0.0"
@@ -20,66 +20,172 @@
@Timeout(Duration(minutes: 10))
library;
import 'dart:ffi';
import 'dart:io';
import 'package:shorebird_code_push/src/generated/updater_bindings.g.dart';
import 'package:shorebird_code_push/src/updater.dart';
import 'package:shorebird_code_push/shorebird_code_push.dart';
import 'package:test/test.dart';
import 'generated/test_hooks_bindings.g.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`, `testHooks`) is contractually mutually
// exclusive: a null `skipReason` means `testHooks` is initialized,
// and tests early-return on a non-null `skipReason` before touching
// `testHooks`.
// 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 TestHooksBindings testHooks;
late final TestEngine engine;
setUpAll(() async {
try {
final path = await buildTestHooksCdylib();
final lib = DynamicLibrary.open(path);
// `Updater.bindings` is `@visibleForTesting` and the package's own
// unit tests reassign it. We do the same here, pointing the
// production code path at our test_hooks cdylib (which re-exports
// the production C API alongside the `shorebird_test_*` hooks).
Updater.bindings = UpdaterBindings(lib);
testHooks = TestHooksBindings(lib);
engine = TestEngine.setup(path);
} on Object catch (e, st) {
skipReason = 'Could not build/load library_test_hooks cdylib.\n$e\n$st';
}
});
group('library_test_hooks', () {
test('exposes both production and test-hook symbols', () {
// `markTestSkipped` only flags the test as skipped — it does not
// abort execution. Early-return after marking, otherwise the body
// below would touch the uninitialized `testHooks` when the
// setUpAll build couldn't run (e.g., no Rust toolchain on the
// host).
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;
}
// Test-only symbol layered on top of the production C API.
// Calling on a process with no prior init clears already-empty
// globals — should be a no-op, not a crash.
expect(testHooks.shorebird_test_reset, returnsNormally);
server.respondWithNoUpdate();
engine.init(tmp: tmp, server: server);
// Production symbols flow through the same library. With no
// init, `shorebird_current_boot_patch_number` returns the
// `log_on_error` default (0).
const updater = Updater();
expect(updater.currentPatchNumber(), 0);
expect(updater.nextPatchNumber(), 0);
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);
});
// Reset is still callable after exercising production symbols.
expect(testHooks.shorebird_test_reset, returnsNormally);
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);
});
});
}
@@ -2362,6 +2362,58 @@ class TestHooksBindings {
_lookup<ffi.NativeFunction<ffi.Void Function()>>('shorebird_test_reset');
late final _shorebird_test_reset =
_shorebird_test_resetPtr.asFunction<void Function()>();
/// Test-only convenience wrapper around `shorebird_init` that builds
/// `AppParameters` and stub `FileCallbacks` internally. Dart tests
/// pass plain C strings instead of needing bindings for those
/// engine-API structs.
bool shorebird_test_init(
ffi.Pointer<ffi.Char> app_storage_dir,
ffi.Pointer<ffi.Char> code_cache_dir,
ffi.Pointer<ffi.Char> release_version,
ffi.Pointer<ffi.Char> libapp_path,
ffi.Pointer<ffi.Char> yaml,
) {
return _shorebird_test_init(
app_storage_dir,
code_cache_dir,
release_version,
libapp_path,
yaml,
);
}
late final _shorebird_test_initPtr = _lookup<
ffi.NativeFunction<
ffi.Bool Function(
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>)>>('shorebird_test_init');
late final _shorebird_test_init = _shorebird_test_initPtr.asFunction<
bool Function(
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>)>();
/// Simulates the engine's successful boot of `next_boot_patch`.
/// In production this is two engine actions (launch-start, then
/// launch-success after Dart VM startup completes); the Dart layer
/// has no concept of either, so we expose the combined outcome as a
/// single semantic action: "the next patch booted cleanly."
void shorebird_test_simulate_successful_launch() {
return _shorebird_test_simulate_successful_launch();
}
late final _shorebird_test_simulate_successful_launchPtr =
_lookup<ffi.NativeFunction<ffi.Void Function()>>(
'shorebird_test_simulate_successful_launch');
late final _shorebird_test_simulate_successful_launch =
_shorebird_test_simulate_successful_launchPtr
.asFunction<void Function()>();
}
typedef __builtin_va_list = ffi.Pointer<ffi.Char>;
@@ -0,0 +1,124 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'fixtures.dart';
/// In-process fake of the Shorebird patches API for integration tests.
///
/// Binds to `127.0.0.1` on an ephemeral port. Tests configure the next
/// `/api/v1/patches/check` response, optionally register patch bytes
/// for download, and inspect counters / recorded events afterward.
///
/// This is intentionally minimal — no auth, no Range support, no
/// concurrency control. Stages 3+ (download cutoff / resume tests)
/// will extend it as needed.
class FakePatchServer {
FakePatchServer._(this._httpServer);
static Future<FakePatchServer> start() async {
// Handler captures the instance by reference so the closure
// resolves against live state. `late final` lets us reference
// `instance` in the closure body before it's assigned — the
// closure only runs once requests arrive, by which time we've
// assigned it below.
late final FakePatchServer instance;
final httpServer = await shelf_io.serve(
(Request request) => instance._handle(request),
'127.0.0.1',
0,
);
return instance = FakePatchServer._(httpServer);
}
final HttpServer _httpServer;
String get baseUrl => 'http://127.0.0.1:${_httpServer.port}';
/// Number of `/api/v1/patches/check` requests received.
int patchCheckCount = 0;
/// Number of GET requests served from the registered downloadables.
int downloadCount = 0;
/// Bodies of every `/api/v1/patches/events` request received,
/// decoded as JSON.
final List<Map<String, dynamic>> recordedEvents = [];
Map<String, dynamic>? _checkResponse;
final Map<String, Uint8List> _downloadables = {};
/// Schedules the next `/api/v1/patches/check` response to advertise
/// [patch] as available, and registers its bytes for download at a
/// generated URL under this server's base.
void enqueuePatch(PatchFixture patch) {
final path = '/patch/${patch.number}';
_downloadables[path] = patch.bytes;
_checkResponse = {
'patch_available': true,
'patch': {
'number': patch.number,
'hash': patch.hash,
'download_url': '$baseUrl$path',
},
};
}
/// Server has nothing new — `patch_available: false`, no rollback
/// signal, no patch.
void respondWithNoUpdate() {
_checkResponse = {'patch_available': false};
}
/// Server has rolled back the listed patch numbers with no
/// replacement. Used to exercise the patch-to-release rollback
/// path that produced shorebird #3728.
void respondWithRollback(List<int> rolledBackNumbers) {
_checkResponse = {
'patch_available': false,
'rolled_back_patch_numbers': rolledBackNumbers,
};
}
Future<void> stop() => _httpServer.close(force: true);
Future<Response> _handle(Request request) async {
final path = '/${request.url.path}';
if (request.method == 'POST' && path == '/api/v1/patches/check') {
patchCheckCount++;
final body = _checkResponse;
if (body == null) {
return Response.internalServerError(
body: 'no patch check response configured',
);
}
return Response.ok(
jsonEncode(body),
headers: {'content-type': 'application/json'},
);
}
if (request.method == 'POST' && path == '/api/v1/patches/events') {
final raw = await request.readAsString();
recordedEvents.add(jsonDecode(raw) as Map<String, dynamic>);
return Response.ok('');
}
if (request.method == 'GET') {
final bytes = _downloadables[path];
if (bytes != null) {
downloadCount++;
return Response.ok(
bytes,
headers: {
'content-type': 'application/octet-stream',
'content-length': '${bytes.length}',
},
);
}
}
return Response.notFound('${request.method} $path');
}
}
@@ -0,0 +1,74 @@
import 'dart:typed_data';
/// A precomputed bidiff patch artifact along with its hash, mirroring
/// the `PatchFixture` constants in `library/src/c_api/mod.rs`. The
/// bytes were generated with:
///
/// cargo run --bin string_patch -- "<base>" "<new>"
///
/// They are self-contained zstd payloads — applying them against an
/// empty source produces the `new` content, so tests using the stub
/// `FileCallbacks` from `shorebird_test_init` (which read 0 bytes
/// from the source apk) still get the right inflated bytes.
class PatchFixture {
PatchFixture({
required this.number,
required this.base,
required this.newContent,
required this.hash,
required this.bytes,
});
final int number;
/// The libapp bytes the patch was generated against. The integration
/// harness writes these to `libapp_path` before init — on non-test
/// desktop builds, `patch_base` reads that file directly when
/// applying the patch.
final Uint8List base;
final String newContent;
final String hash;
final Uint8List bytes;
}
/// `string_patch "hello world" "hello tests"`.
final helloTestsPatch = PatchFixture(
number: 1,
base: Uint8List.fromList('hello world'.codeUnits),
newContent: 'hello tests',
hash: 'bb8f1d041a5cdc259055afe9617136799543e0a7a86f86db82f8c1fadbd8cc45',
bytes: Uint8List.fromList(const [
40,
181,
47,
253,
0,
128,
177,
0,
0,
223,
177,
0,
0,
0,
16,
0,
0,
6,
0,
0,
0,
0,
0,
0,
5,
116,
101,
115,
116,
115,
0,
]),
);
@@ -0,0 +1,128 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'package:shorebird_code_push/shorebird_code_push.dart';
import 'package:shorebird_code_push/src/generated/updater_bindings.g.dart';
import 'package:shorebird_code_push/src/shorebird_updater_io.dart';
import 'package:shorebird_code_push/src/updater.dart';
import '../generated/test_hooks_bindings.g.dart';
import 'fake_patch_server.dart';
/// Stand-in for the Flutter engine in integration tests.
///
/// In production the engine is what loads `libupdater`, calls
/// `shorebird_init` at startup, and reports launch outcomes back to the
/// updater. None of that is visible at the Dart level. This class
/// concentrates everything an integration test needs to drive that
/// engine-side behavior into one place, so test bodies stay focused on
/// the public `ShorebirdUpdater` API.
class TestEngine {
TestEngine._(this._bindings, this._cdylibPath);
/// Loads the test_hooks cdylib, wires it into `shorebird_code_push`'s
/// `Updater.bindings` test seam, and returns a `TestEngine` ready to
/// drive scenarios. The path is retained so per-isolate FFI calls
/// (see [createUpdater]) can re-open the same library.
factory TestEngine.setup(String cdylibPath) {
final lib = DynamicLibrary.open(cdylibPath);
Updater.bindings = UpdaterBindings(lib);
return TestEngine._(TestHooksBindings(lib), cdylibPath);
}
final TestHooksBindings _bindings;
final String _cdylibPath;
/// Initializes the updater for a single test. Acts as the engine
/// would on app startup: configures storage paths, the release
/// version, and the `shorebird.yaml` (synthesized here from
/// `app_id` + the test's [server] base URL).
///
/// [libappBase] is written to `libapp_path` before init. On
/// non-test desktop builds, `patch_base` (in
/// `library/src/updater.rs`) reads that file directly when applying
/// a patch — the Android `cfg(test)` path that uses an apk doesn't
/// run in our non-`cfg(test)` cdylib. Tests that install a patch
/// must pass the patch fixture's `base` bytes here. Tests that
/// don't install a patch can leave it as the default empty slice.
void init({
required Directory tmp,
required FakePatchServer server,
Uint8List? libappBase,
String releaseVersion = '1.0.0',
}) {
final storage = '${tmp.path}/storage';
final cache = '${tmp.path}/cache';
Directory(storage).createSync();
Directory(cache).createSync();
final libapp = '${tmp.path}/lib/arm64/libapp.so';
final libappFile = File(libapp);
libappFile.parent.createSync(recursive: true);
libappFile.writeAsBytesSync(libappBase ?? Uint8List(0));
final yaml = 'app_id: test_app\nbase_url: ${server.baseUrl}';
final pStorage = storage.toNativeUtf8();
final pCache = cache.toNativeUtf8();
final pRelease = releaseVersion.toNativeUtf8();
final pLibapp = libapp.toNativeUtf8();
final pYaml = yaml.toNativeUtf8();
try {
final ok = _bindings.shorebird_test_init(
pStorage.cast(),
pCache.cast(),
pRelease.cast(),
pLibapp.cast(),
pYaml.cast(),
);
if (!ok) {
throw StateError('shorebird_test_init returned false');
}
} finally {
// shorebird_test_init copies the strings via to_rust(), so
// freeing here is safe.
malloc
..free(pStorage)
..free(pCache)
..free(pRelease)
..free(pLibapp)
..free(pYaml);
}
}
/// Resets all global updater state. Equivalent to a fresh process
/// boot — call between tests.
void reset() => _bindings.shorebird_test_reset();
/// Simulates the engine's successful boot of `next_boot_patch`. In
/// production the engine does this internally after Dart VM startup
/// completes; the Dart layer never observes the underlying protocol.
void simulateSuccessfulLaunch() =>
_bindings.shorebird_test_simulate_successful_launch();
/// Builds a [ShorebirdUpdater] suitable for tests.
///
/// FFI calls run in a sub-isolate via `Isolate.run` (the same dispatch
/// production uses) so they don't block the main isolate's event loop —
/// the [FakePatchServer] runs there and would otherwise deadlock with
/// the synchronous `ureq` HTTP request inside the FFI call.
///
/// Each sub-isolate re-opens the cdylib (cheap: `dlopen` is ref-counted
/// against the already-loaded image) and sets `Updater.bindings`,
/// because Dart isolates do not share static fields. The override the
/// main isolate did in [TestEngine.setup] doesn't propagate.
ShorebirdUpdater createUpdater() {
final path = _cdylibPath;
return ShorebirdUpdaterImpl(
run: <R>(FutureOr<R> Function() computation, {String? debugName}) {
return Isolate.run<R>(() async {
Updater.bindings = UpdaterBindings(DynamicLibrary.open(path));
return computation();
});
},
);
}
}