diff --git a/cspell.config.yaml b/cspell.config.yaml index 8a6484e..52a5a62 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -38,6 +38,7 @@ words: - Decompressor - dllexport - dlopen + - downloadables - EDQUOT - EACCES - embedders @@ -55,6 +56,7 @@ words: - libflutter - libupdater - logcat + - malloc - memmap - mmap - mockall diff --git a/library_test_hooks/include/library_test_hooks.h b/library_test_hooks/include/library_test_hooks.h index 28e6f4b..5bba569 100644 --- a/library_test_hooks/include/library_test_hooks.h +++ b/library_test_hooks/include/library_test_hooks.h @@ -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 diff --git a/library_test_hooks/src/lib.rs b/library_test_hooks/src/lib.rs index e688d84..ed3ba93 100644 --- a/library_test_hooks/src/lib.rs +++ b/library_test_hooks/src/lib.rs @@ -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::::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(¶ms, 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(); +} diff --git a/shorebird_code_push/ffigen_test_hooks.yaml b/shorebird_code_push/ffigen_test_hooks.yaml index 129cf17..5e8593b 100644 --- a/shorebird_code_push/ffigen_test_hooks.yaml +++ b/shorebird_code_push/ffigen_test_hooks.yaml @@ -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 diff --git a/shorebird_code_push/pubspec.yaml b/shorebird_code_push/pubspec.yaml index 4765b0f..ef3f2cd 100644 --- a/shorebird_code_push/pubspec.yaml +++ b/shorebird_code_push/pubspec.yaml @@ -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" diff --git a/shorebird_code_push/test/integration/all_test.dart b/shorebird_code_push/test/integration/all_test.dart index a581d40..2e7d460 100644 --- a/shorebird_code_push/test/integration/all_test.dart +++ b/shorebird_code_push/test/integration/all_test.dart @@ -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); }); }); } diff --git a/shorebird_code_push/test/integration/generated/test_hooks_bindings.g.dart b/shorebird_code_push/test/integration/generated/test_hooks_bindings.g.dart index ed2b11b..3dc845c 100644 --- a/shorebird_code_push/test/integration/generated/test_hooks_bindings.g.dart +++ b/shorebird_code_push/test/integration/generated/test_hooks_bindings.g.dart @@ -2362,6 +2362,58 @@ class TestHooksBindings { _lookup>('shorebird_test_reset'); late final _shorebird_test_reset = _shorebird_test_resetPtr.asFunction(); + + /// 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 app_storage_dir, + ffi.Pointer code_cache_dir, + ffi.Pointer release_version, + ffi.Pointer libapp_path, + ffi.Pointer 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.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer)>>('shorebird_test_init'); + late final _shorebird_test_init = _shorebird_test_initPtr.asFunction< + bool Function( + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer)>(); + + /// 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>( + 'shorebird_test_simulate_successful_launch'); + late final _shorebird_test_simulate_successful_launch = + _shorebird_test_simulate_successful_launchPtr + .asFunction(); } typedef __builtin_va_list = ffi.Pointer; diff --git a/shorebird_code_push/test/integration/helpers/fake_patch_server.dart b/shorebird_code_push/test/integration/helpers/fake_patch_server.dart new file mode 100644 index 0000000..fe841d1 --- /dev/null +++ b/shorebird_code_push/test/integration/helpers/fake_patch_server.dart @@ -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 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> recordedEvents = []; + + Map? _checkResponse; + final Map _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 rolledBackNumbers) { + _checkResponse = { + 'patch_available': false, + 'rolled_back_patch_numbers': rolledBackNumbers, + }; + } + + Future stop() => _httpServer.close(force: true); + + Future _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); + 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'); + } +} diff --git a/shorebird_code_push/test/integration/helpers/fixtures.dart b/shorebird_code_push/test/integration/helpers/fixtures.dart new file mode 100644 index 0000000..bcc8b14 --- /dev/null +++ b/shorebird_code_push/test/integration/helpers/fixtures.dart @@ -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 -- "" "" +/// +/// 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, + ]), +); diff --git a/shorebird_code_push/test/integration/helpers/test_engine.dart b/shorebird_code_push/test/integration/helpers/test_engine.dart new file mode 100644 index 0000000..ddbd6e8 --- /dev/null +++ b/shorebird_code_push/test/integration/helpers/test_engine.dart @@ -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: (FutureOr Function() computation, {String? debugName}) { + return Isolate.run(() async { + Updater.bindings = UpdaterBindings(DynamicLibrary.open(path)); + return computation(); + }); + }, + ); + } +}