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
|
||||
- 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
|
||||
|
||||
@@ -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(¶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"
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user