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:
@@ -38,6 +38,7 @@ words:
|
|||||||
- Decompressor
|
- Decompressor
|
||||||
- dllexport
|
- dllexport
|
||||||
- dlopen
|
- dlopen
|
||||||
|
- downloadables
|
||||||
- EDQUOT
|
- EDQUOT
|
||||||
- EACCES
|
- EACCES
|
||||||
- embedders
|
- embedders
|
||||||
@@ -55,6 +56,7 @@ words:
|
|||||||
- libflutter
|
- libflutter
|
||||||
- libupdater
|
- libupdater
|
||||||
- logcat
|
- logcat
|
||||||
|
- malloc
|
||||||
- memmap
|
- memmap
|
||||||
- mmap
|
- mmap
|
||||||
- mockall
|
- mockall
|
||||||
|
|||||||
@@ -26,6 +26,28 @@ extern "C" {
|
|||||||
*/
|
*/
|
||||||
SHOREBIRD_EXPORT void shorebird_test_reset(void);
|
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
|
#ifdef __cplusplus
|
||||||
} // extern "C"
|
} // extern "C"
|
||||||
#endif // __cplusplus
|
#endif // __cplusplus
|
||||||
|
|||||||
@@ -21,6 +21,10 @@
|
|||||||
//! Production updater builds (the cdylib/staticlib that ships in the
|
//! Production updater builds (the cdylib/staticlib that ships in the
|
||||||
//! engine) do not enable `test-hooks` and never link this crate.
|
//! 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
|
// Re-export the production C API. This makes the symbols part of this
|
||||||
// crate's public API surface, which prevents the linker from stripping
|
// crate's public API surface, which prevents the linker from stripping
|
||||||
// them when producing the cdylib (an rlib's `#[no_mangle]` items are
|
// 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() {
|
pub extern "C" fn shorebird_test_reset() {
|
||||||
updater::testing_reset_config();
|
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(¶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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ output: "test/integration/generated/test_hooks_bindings.g.dart"
|
|||||||
name: "TestHooksBindings"
|
name: "TestHooksBindings"
|
||||||
headers:
|
headers:
|
||||||
entry-points:
|
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"
|
- "../library_test_hooks/include/library_test_hooks.h"
|
||||||
preamble: |
|
preamble: |
|
||||||
// ignore_for_file: unused_element, unused_field, type=lint
|
// ignore_for_file: unused_element, unused_field, type=lint
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ dependencies:
|
|||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
ffigen: ">=8.0.2 <21.0.0"
|
ffigen: ">=8.0.2 <21.0.0"
|
||||||
mocktail: ^1.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
|
test: ^1.19.2
|
||||||
very_good_analysis: ">=7.0.0 <11.0.0"
|
very_good_analysis: ">=7.0.0 <11.0.0"
|
||||||
|
|
||||||
|
|||||||
@@ -20,66 +20,172 @@
|
|||||||
@Timeout(Duration(minutes: 10))
|
@Timeout(Duration(minutes: 10))
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:ffi';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:shorebird_code_push/src/generated/updater_bindings.g.dart';
|
import 'package:shorebird_code_push/shorebird_code_push.dart';
|
||||||
import 'package:shorebird_code_push/src/updater.dart';
|
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
import 'generated/test_hooks_bindings.g.dart';
|
|
||||||
import 'helpers/build.dart';
|
import 'helpers/build.dart';
|
||||||
|
import 'helpers/fake_patch_server.dart';
|
||||||
|
import 'helpers/fixtures.dart';
|
||||||
|
import 'helpers/test_engine.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
// Set in setUpAll exactly when the cdylib build/load failed. The
|
// Set in setUpAll exactly when the cdylib build/load failed. The
|
||||||
// pair (`skipReason`, `testHooks`) is contractually mutually
|
// pair (`skipReason`, `engine`) is contractually mutually exclusive:
|
||||||
// exclusive: a null `skipReason` means `testHooks` is initialized,
|
// a null `skipReason` means `engine` is initialized, and tests
|
||||||
// and tests early-return on a non-null `skipReason` before touching
|
// early-return on a non-null `skipReason` before touching `engine`.
|
||||||
// `testHooks`.
|
|
||||||
String? skipReason;
|
String? skipReason;
|
||||||
late final TestHooksBindings testHooks;
|
late final TestEngine engine;
|
||||||
|
|
||||||
setUpAll(() async {
|
setUpAll(() async {
|
||||||
try {
|
try {
|
||||||
final path = await buildTestHooksCdylib();
|
final path = await buildTestHooksCdylib();
|
||||||
final lib = DynamicLibrary.open(path);
|
engine = TestEngine.setup(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);
|
|
||||||
} on Object catch (e, st) {
|
} on Object catch (e, st) {
|
||||||
skipReason = 'Could not build/load library_test_hooks cdylib.\n$e\n$st';
|
skipReason = 'Could not build/load library_test_hooks cdylib.\n$e\n$st';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
group('library_test_hooks', () {
|
group('updater integration', () {
|
||||||
test('exposes both production and test-hook symbols', () {
|
late Directory tmp;
|
||||||
// `markTestSkipped` only flags the test as skipped — it does not
|
late FakePatchServer server;
|
||||||
// abort execution. Early-return after marking, otherwise the body
|
|
||||||
// below would touch the uninitialized `testHooks` when the
|
setUp(() async {
|
||||||
// setUpAll build couldn't run (e.g., no Rust toolchain on the
|
if (skipReason != null) return;
|
||||||
// host).
|
// 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;
|
final reason = skipReason;
|
||||||
if (reason != null) {
|
if (reason != null) {
|
||||||
markTestSkipped(reason);
|
markTestSkipped(reason);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test-only symbol layered on top of the production C API.
|
server.respondWithNoUpdate();
|
||||||
// Calling on a process with no prior init clears already-empty
|
engine.init(tmp: tmp, server: server);
|
||||||
// globals — should be a no-op, not a crash.
|
|
||||||
expect(testHooks.shorebird_test_reset, returnsNormally);
|
|
||||||
|
|
||||||
// Production symbols flow through the same library. With no
|
final updater = engine.createUpdater();
|
||||||
// init, `shorebird_current_boot_patch_number` returns the
|
expect(await updater.checkForUpdate(), UpdateStatus.upToDate);
|
||||||
// `log_on_error` default (0).
|
expect(await updater.readCurrentPatch(), isNull);
|
||||||
const updater = Updater();
|
expect(await updater.readNextPatch(), isNull);
|
||||||
expect(updater.currentPatchNumber(), 0);
|
expect(server.patchCheckCount, 1);
|
||||||
expect(updater.nextPatchNumber(), 0);
|
expect(server.downloadCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
// Reset is still callable after exercising production symbols.
|
test('install a patch and boot from it', () async {
|
||||||
expect(testHooks.shorebird_test_reset, returnsNormally);
|
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');
|
_lookup<ffi.NativeFunction<ffi.Void Function()>>('shorebird_test_reset');
|
||||||
late final _shorebird_test_reset =
|
late final _shorebird_test_reset =
|
||||||
_shorebird_test_resetPtr.asFunction<void Function()>();
|
_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>;
|
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();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user