feat: stage 1 integration test harness (#353)

* docs: integration tests design proposal

Adds docs/integration_tests.md proposing a desktop-only integration suite
that drives the Dart `ShorebirdUpdater` API against the real Rust core via
FFI, with a fake HTTP server and per-test tempdir state. Describes a new
`library_test_hooks` cdylib that reaches into `updater` via a `test-hooks`
Cargo feature, so production C API stays clean. Status: design exploration,
not a committed plan.

* feat: stage 1 integration test harness for shorebird_code_push

Lands the test_hooks crate, the Dart-side loader, and one trivial
end-to-end test, per docs/integration_tests.md stage 1.

Pieces:

- New `library_test_hooks` workspace crate (cdylib). Depends on `updater`
  with a new `test-hooks` Cargo feature that widens the visibility of
  internal items (currently `testing_reset_config`) for sibling-crate
  access only — production builds do not enable it. The crate also
  re-exports `updater::c_api::dart::*` and `updater::c_api::engine::*`,
  which keeps the rlib's `#[no_mangle]` symbols out of DCE so the
  resulting cdylib carries both the production C API and the new
  `shorebird_test_*` hooks.
- Workspace `default-members` excludes `library_test_hooks` so plain
  `cargo build` / `cargo test` invocations don't unify the `test-hooks`
  feature into production builds.
- `shorebird_code_push/test/integration/all_test.dart` (single file,
  with a header comment explaining why) loads the cdylib via
  `DynamicLibrary.open`, reassigns the existing `@visibleForTesting`
  `Updater.bindings` setter, and exercises both surfaces. The build
  helper shells out to `cargo build -p library_test_hooks`; if it
  fails, `markTestSkipped` keeps the suite green on machines without
  a working Rust toolchain.
- ffigen config (`ffigen_test_hooks.yaml`) generates the test-only
  Dart bindings under `test/integration/generated/`, not `lib/`.
- CI: `library_test_hooks` is added to the rust_crate matrix, and the
  shorebird_code_push job triggers on `library/**` and
  `library_test_hooks/**` so cdylib changes can't break the Dart-side
  integration suite without CI noticing.

Verified locally: 232 Rust unit tests + 42 Dart tests (41 existing + 1
integration) green; clippy/fmt/cspell clean; production cdylib does
not contain `shorebird_test_reset` (`nm` confirms feature isolation).

Stages 2 (FakePatchServer + golden path) and 3 (adversarial scenarios)
land separately.

* fix(integration test): early-return on skip and bump per-test timeout

Two issues caught by Shorebird CI on PR #353:

1. `markTestSkipped` does not abort test execution — it only flags the
   test as skipped on its way out. The body kept running and crashed on
   the `late testHooks` field when the cdylib build had failed in
   setUpAll. Move the skip check into the test body itself with an
   early return; drop the (no-op) skip in `setUp`.

2. Default per-test timeout (30s) also covers `setUpAll`. A cold
   `cargo build -p library_test_hooks` compiles `updater` and ~100
   transitive deps, which can run minutes on CI. Bump to 10 minutes
   via `@Timeout` on the library.

Verified locally: passes when cargo is on PATH (1 passed), reports a
clean skip and exit 0 when cargo is removed from PATH (1 skipped).

* refactor(integration test): drop ! by promoting testHooks to late final

`testHooks` was nullable so accessing it after the skipReason check
required `!`, and `markTestSkipped(skipReason!)` had the same smell.
Make `testHooks` `late final` (non-nullable, throws if read before
setUpAll assigns) and pull `skipReason` into a non-null local before
use. Same control flow, no bang operators.
This commit is contained in:
Eric Seidel
2026-05-05 16:32:57 -07:00
committed by GitHub
parent b97c7919bd
commit 8072ed9ad7
17 changed files with 5956 additions and 4 deletions
+11
View File
@@ -43,6 +43,13 @@ jobs:
- ./.github/workflows/main.yaml - ./.github/workflows/main.yaml
- ./.github/actions/flutter_package/action.yaml - ./.github/actions/flutter_package/action.yaml
- shorebird_code_push/** - shorebird_code_push/**
# Integration tests under shorebird_code_push/test/integration
# build and load the library_test_hooks cdylib, which
# re-exports the production updater C API. A change to
# either crate can break the suite, so trigger the
# flutter package job on those paths too.
- library/**
- library_test_hooks/**
- uses: dorny/paths-filter@v4 - uses: dorny/paths-filter@v4
name: Build Detection name: Build Detection
@@ -55,6 +62,10 @@ jobs:
patch: patch:
- ./.github/actions/rust_crate/action.yaml - ./.github/actions/rust_crate/action.yaml
- patch/** - patch/**
library_test_hooks:
- ./.github/actions/rust_crate/action.yaml
- library/**
- library_test_hooks/**
build_rust_crates: build_rust_crates:
needs: changes needs: changes
Generated
+9
View File
@@ -925,6 +925,15 @@ version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "library_test_hooks"
version = "0.1.0"
dependencies = [
"cbindgen",
"libc",
"updater",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.12.1" version = "0.12.1"
+7 -1
View File
@@ -1,5 +1,11 @@
[workspace] [workspace]
members = ["library", "patch"] members = ["library", "patch", "library_test_hooks"]
# `library_test_hooks` is excluded from default-members so plain
# `cargo build` / `cargo test` invocations don't enable the
# `test-hooks` feature on `updater` (Cargo unifies features within a
# single build). The integration test suite explicitly builds it with
# `cargo build -p library_test_hooks`.
default-members = ["library", "patch"]
resolver = "2" resolver = "2"
[profile.release] [profile.release]
+5
View File
@@ -39,6 +39,7 @@ words:
- dllexport - dllexport
- dlopen - dlopen
- EDQUOT - EDQUOT
- EACCES
- embedders - embedders
- EOCD - EOCD
- endtemplate - endtemplate
@@ -62,8 +63,10 @@ words:
- pubspec - pubspec
- repr - repr
- reqwest - reqwest
- rlib
- rsplit - rsplit
- rollouts - rollouts
- RTLD
- rustflags - rustflags
- rustls - rustls
- rustup - rustup
@@ -77,6 +80,8 @@ words:
- ureq - ureq
- unbootable - unbootable
- unbooted - unbooted
- unreviewable
- unwritable
- usize - usize
- Swatinem - Swatinem
- taiki - taiki
+349
View File
@@ -0,0 +1,349 @@
# Integration Tests for the Updater
Status: design exploration. This is a proposal, not a committed plan. The goal
is to agree on the shape before writing harness code.
## Why we are talking about this
Most recent updater bugs have lived at the seam between Rust core, FFI, and
the `shorebird_code_push` Dart wrapper. A non-exhaustive recent sample:
- `checkForUpdate` returns `upToDate` after a patch-to-release rollback
(shorebirdtech/shorebird#3728, originally #3206). The Rust unit test
passed because it stubbed `currentPatchNumber`. The real FFI path cleared
`last_booted_patch` and made the Dart side compare `null != null`.
- `update()` re-downloads bytes that are deterministically bad on this
device (updater #351; design follow-on shorebirdtech/shorebird#3737). The
Rust-side network hooks made it easy to test the resume logic in
isolation, but not the cross-cycle "we just installed this and it
hash-failed" loop.
- `update()` throwing `UpdateException` for `noUpdate` (shorebird #3681,
updater #334) and for benign `UpdateInProgress` (shorebird #3682, updater
#335). Both reproduced cleanly only when the Dart layer was driving real
Rust state.
- `patches_state.json` write failures on iOS (shorebird #3683). Not all of
this is reproducible on desktop, but the on-disk round-trip part is.
What these have in common: each one was discoverable only when Dart, FFI,
and on-disk Rust state were all participating. Today we have:
- **Rust unit tests** with `NetworkHooks::default` swapped for fakes — good
coverage of branching logic, no FFI, no `DynamicLibrary`.
- **Dart unit tests** with `_MockUpdater` swapped in for the FFI wrapper —
good coverage of `ShorebirdUpdaterImpl`, never loads `libupdater`.
There is no test that drives the Dart `ShorebirdUpdater` API and ends up
reading and writing real bytes via the real Rust network and cache code.
## Proposal
Add a desktop-only integration suite that runs:
```
ShorebirdUpdater (Dart)
→ DynamicLibrary.open("libupdater.dylib") ← real cdylib build
→ c_api::dart + c_api::engine ← real FFI
→ updater::* + cache::* + network::* ← real Rust
→ 127.0.0.1:<port> ← FakePatchServer (Dart shelf)
```
Each test gets a fresh tempdir for `app_storage_dir` and `code_cache_dir`,
boots a fresh fake server, calls `shorebird_init`, and then exercises the
Dart `ShorebirdUpdater` API. The `cdylib` build target already exists
(`library/Cargo.toml` lists `cdylib`); today nothing actually loads it.
This is intentionally narrower than a true end-to-end test. We are *not*
spinning up a Flutter app, *not* exercising the engine integration in
`flutter::shorebird::Updater`, and *not* running on a phone. Those remain
the job of `shorebird preview`, engine smoke tests, and production
telemetry. What we get here is the layer that has been quietly producing
the most bugs.
## Components to build
### 1. A test-hooks library target
Add a new workspace crate, e.g. `library_test_hooks/`, with
`crate-type = ["cdylib"]`. It depends on the `updater` crate as a path
dependency with a `test-hooks` Cargo feature enabled. The crate's job is
to add a small set of test-only `#[no_mangle] pub extern "C"` symbols
that wrap *internal Rust functions* in `updater` — not new C entry
points exposed by the updater crate itself.
Guiding principle: production packages stay clean. The production C
API in `c_api::dart` and `c_api::engine` does not gain any test-only
symbols. Where the test_hooks cdylib needs to reach behind the C API
(reset the global config, seed an installed patch, force a clock
value), it does so via *Rust-internal* `pub` items in `updater` that
are gated behind the `test-hooks` Cargo feature. Those items already
exist for unit tests (`testing_reset_config`, `test_utils::*`); the
feature flag is what makes them visible to a sibling crate without
also exposing them to production builds.
Concretely, `library/Cargo.toml` grows:
```toml
[features]
test-hooks = []
```
and items like `library/src/config.rs:200` move from
`#[cfg(test)] pub fn testing_reset_config(...)` to
`#[cfg(any(test, feature = "test-hooks"))] pub fn testing_reset_config(...)`.
The test_hooks cdylib then wraps them:
```rust
#[no_mangle]
pub extern "C" fn shorebird_test_reset() {
updater::testing_reset_config();
}
```
Plus seeding helpers as we need them
(`shorebird_test_install_fake_patch(usize)`,
`shorebird_test_corrupt_patches_state()`, etc.).
The production C symbols (`shorebird_init`, `shorebird_update_with_result`,
…) come along automatically: the `updater` crate is consumed as an rlib
here, and `#[no_mangle]` exports from an rlib propagate into the
dependent's cdylib. One artifact, `libupdater_test_hooks.{dylib,so,dll}`,
exposes both surfaces.
Why this way:
- No test-only C symbols in the cdylib/staticlib that ships inside the
engine. The `test-hooks` feature is opt-in and only the test_hooks
crate enables it.
- All scenarios run in a single Dart process. Reset between tests with
one C call instead of forking a subprocess each time.
- A natural place to add diagnostic / fault-injection hooks later
without touching production code.
The test_hooks crate also owns the engine-side symbols we need for tests
(`shorebird_init`, `shorebird_report_launch_*`, etc.). They are part of
the engine API today; since this crate's cdylib is never linked into a
Flutter engine build, exposing them here doesn't widen what production
ships.
### 2. Loading the library: use the existing test seam
`Updater.bindings` at `shorebird_code_push/lib/src/updater.dart:20` is
already a `@visibleForTesting` static field, and the existing unit
tests reassign it (`shorebird_code_push/test/src/updater_test.dart:25`).
We use the same seam:
```dart
setUpAll(() async {
final path = await buildTestHooksCdylib(); // cargo build -p library_test_hooks
testHooksLib = DynamicLibrary.open(path); // single handle
Updater.bindings = UpdaterBindings(testHooksLib); // production path uses our lib
testHooks = TestHooksBindings(testHooksLib); // engine + reset symbols
});
```
This is zero changes to `shorebird_code_push`, works the same on Linux,
macOS, and Windows, and produces no `RTLD_GLOBAL` / `LoadLibrary`
platform-specific code in the harness. Static-field initializers in
Dart are lazy, so as long as the override happens in `setUpAll` before
any production code path touches `Updater.bindings`,
`DynamicLibrary.process()` is never called and there is no question of
"will it find the symbols."
We deliberately keep using the same `Updater` / `UpdaterBindings` types
that production uses. The only Dart-side test-specific code is the
auxiliary `TestHooksBindings` (a separate ffigen-generated file in the
integration-test tree) that exposes the `shorebird_test_*` and
`shorebird_init` / `shorebird_report_launch_*` symbols.
Locating the artifact: a `tool/build_test_hooks.dart` invoked from
`setUpAll` shells out to `cargo build -p library_test_hooks` and returns
`target/debug/libupdater_test_hooks.{dylib,so,dll}`. Cached across tests
in the same run.
### 3. Engine + test-hooks bindings live under `test/integration/`
`library_test_hooks` emits its own header (cbindgen, same pattern as
`updater`), and ffigen generates a `TestHooksBindings` Dart file
inside `shorebird_code_push/test/integration/generated/`**not**
inside `shorebird_code_push/lib/`. The published package keeps shipping
only the Dart-stable surface; the test-only bindings are part of the
test tree, where their dev-time-only nature is obvious and they don't
contribute to the package's public API.
### 4. `FakePatchServer`
A small `package:shelf` server that the test drives. Sketch:
```dart
final server = await FakePatchServer.start();
server.enqueuePatch(number: 1, libapp: <bytes>, releaseLibapp: <bytes>);
server.respondToCheckWith(PatchCheckResponse(...));
server.failNextDownload(after: 1024); // cut off mid-stream
server.serveCorruptedHash();
```
Endpoints we need (today):
- `POST /api/v1/patches/check` — returns `PatchCheckResponse` JSON.
- `GET <patch_url>` — serves patch bytes, with `Range` header support so
resume tests are real (`network.rs` already sends `Range: bytes=N-`).
- `POST /api/v1/patches/events` — accepts and records events for assertion.
`base_url` is read from `shorebird.yaml`, so each test writes a yaml
pointing at the test server's `127.0.0.1:<ephemeral-port>`.
### 5. Patch fixtures
Patches are zstd-compressed bipatch files keyed to a specific base
`libapp.so` (or its iOS equivalent). The fake server has to serve real
bytes: a hand-rolled `[0,1,2,3]` will fail `bipatch::Reader::new` and tell
us nothing useful.
Two options:
**Option A:** Pre-bake a few patch fixtures and check them in. Simple,
fast tests, but binary diffs are unreviewable and we are stuck with
whatever scenarios we baked in.
**Option B:** Build patches on the fly using the `patch` workspace crate.
`setUpAll` shells out to `cargo run -p patch` (or links the crate as a
build dependency) to produce a real patch from controlled inputs. Slower
setup; tests stay readable; we can synthesize hash-mismatch scenarios by
swapping bytes after generation.
Lean B. Cache the compiled `patch` binary across tests.
### 6. Storage scaffolding
Each test boots in a fresh tempdir:
```dart
final tmp = await Directory.systemTemp.createTemp('updater-it-');
addTearDown(() => tmp.delete(recursive: true));
final storageDir = Directory('${tmp.path}/storage')..createSync();
final cacheDir = Directory('${tmp.path}/cache')..createSync();
```
`shorebird_init` is given those paths; we never touch the developer's real
state. Tests can also pre-seed `patches_state.json` to simulate "device
arrives in state X" scenarios without running through the install path.
## First-cut scenarios
Each maps to a real bug or a known fragile path:
| Scenario | Asserts | Maps to |
|---|---|---|
| Server returns no patch | `checkForUpdate` returns `upToDate`, no disk writes | baseline |
| Check + update + report_launch_success | `nextPatchNumber` advances; `current_boot_patch` set after launch cycle | baseline |
| Patch-to-release rollback during running session | `current_boot_patch_number` does not silently go to 0 | shorebird #3728, #3206 |
| Hash mismatch on freshly downloaded patch | Patch is marked bad; subsequent `update()` does not re-fetch | updater #351, shorebird #3737 |
| Download cut off mid-stream | Resume on next `update()` succeeds; bytes match | updater resume tests today, but at the FFI level |
| Concurrent `update()` calls | Second call returns `UpdateInProgress`; does not throw | updater #335, shorebird #3682 |
| `update()` when nothing to do | Returns `noUpdate`; does not throw | updater #334, shorebird #3681 |
| Storage dir becomes unwritable mid-cycle | Defers state write; later cycle recovers | updater #336, #344 |
Each test is small; the value is having them all run together against the
same harness so future regressions show up here first.
## Non-goals
- Phone or emulator coverage. Stays with `shorebird preview` and engine
smoke tests.
- Engine integration (`flutter::shorebird::Updater`). Tested in
`shorebirdtech/flutter`.
- Replacing existing Rust unit tests. They are faster and exhaustively
cover branch logic; this suite is wider, not deeper.
- Network-level fuzzing or fault injection beyond what the fake server
exposes.
## Open questions
- **Process model.** All integration tests live in a single file
(e.g. `test/integration/all_test.dart`). `package:test` runs
*files* in parallel isolates by default but *tests within a file*
serially in the same isolate, and `dlopen` loads the test_hooks
cdylib exactly once per process — so every isolate would share the
Rust `OnceCell<UpdateConfig>`. Single file = single isolate = serial
= no contention. The unit tests in the same package keep their
parallelism since they only ever touch `_MockUpdaterBindings` and
never load the real library.
Add a comment at the top of the integration test file explaining
why everything lives in one file, so a future contributor doesn't
"tidy up" by splitting it without also adding a `dart_test.yaml`
concurrency override. Between tests, `shorebird_test_reset()`
clears the global config; subprocess-per-test stays in the back
pocket only if state leaks turn out to be hard to plug.
If the suite grows past what's comfortable in one file, the next
step is splitting across files behind a `dart_test.yaml`
concurrency override scoped to `test/integration/`. Not stage 1.
- **Where does the suite live?** Inside `shorebird_code_push/test/integration/`,
as regular `package:test` tests. Two reasons this beats a sibling
workspace package:
1. dev_dependencies are private to the package — adding `shelf`,
`path`, etc. has no effect on consumers, so there is no real
"production package contamination" cost.
2. `Updater.bindings` is `@visibleForTesting`, and that annotation is
package-scoped: the analyzer's `invalid_use_of_visible_for_testing_member`
would fire if a sibling package reached in. We could blanket-`ignore`
it, but that defeats the point of the annotation. Same-package tests
use the seam cleanly.
Toolchain prerequisites: handle at runtime, not via tags. `setUpAll`
shells out to `cargo build -p library_test_hooks`; if cargo is
missing or the build fails, store a skip reason and have `setUp`
call `markTestSkipped(reason)`. `dart test` runs the suite by
default everywhere — present-and-working machines exercise the
integration tests, environments without a Rust toolchain see them
reported as skipped rather than failed. CI installs Rust on all
three OSes and expects no skips.
- **CI matrix.** Linux, macOS, Windows. All three are required —
Windows is where we have already shipped FFI bugs (updater #344's
parent issue mentioned EACCES paths) and the loading approach above
is platform-neutral, so there is no reason to skip it.
- **Test-only contamination of `shorebird_code_push`.** Goal is zero.
The plan above achieves zero by reusing the existing
`@visibleForTesting` `Updater.bindings` setter that the package's
own unit tests already use. If something in the integration suite
forces a real change to `shorebird_code_push`, that's a flag to
rethink rather than just patch through.
- **Patch fixture build cost.** Will adding `cargo run -p patch` to test
setup make the suite annoyingly slow on cold checkouts? Worth measuring
with one real fixture before committing.
- **Existing tracker?** Searched all Eric-authored issues across
`shorebird` and `_shorebird` from the last week (and broader). No
dedicated tracker. Closest neighbors: shorebird #3341 (on-device
integration tests via shorebird ci, customer-facing — different
thing) and #3737 (per-patch state machine refactor, which would be
much safer to land with this suite in place). This doc is the
tracking artifact.
## Phasing
Three deliverables, each independently shippable:
1. **Test-hooks crate + harness.** New `library_test_hooks` cdylib,
`test-hooks` feature on `updater` to expose `testing_reset_config`
to a sibling crate, ffigen of the test-hooks header, tempdir
scaffolding, the `Updater.bindings = ...` override in `setUpAll`.
One trivial test: init, read `current_boot_patch_number`, reset,
init again. CI green on Linux, macOS, Windows.
2. **Fake server + golden path.** `FakePatchServer`, patch fixture
pipeline, and a single check-then-update-then-launch scenario.
3. **Adversarial scenarios.** Walk down the table above. Each test
should reference the bug it would have caught.
Stage 1 is the riskiest piece (rlib `#[no_mangle]` propagation across
the three OSes and the reset-between-tests story). Stage 3 is the part
that pays back the investment.
## Out of scope for this doc
- Test data hygiene if patch fixtures end up checked in.
- Coverage reporting (CI uses `cargo llvm-cov` for Rust today; Dart-side
coverage from this suite is a bonus, not the goal).
- Performance benchmarking. This is a correctness suite.
+7
View File
@@ -11,6 +11,13 @@ edition = "2021"
# "staticlib" is used by the engine build for linking into libflutter.so # "staticlib" is used by the engine build for linking into libflutter.so
crate-type = ["lib", "cdylib", "staticlib"] crate-type = ["lib", "cdylib", "staticlib"]
[features]
# Exposes a small set of internal Rust items (e.g. `testing_reset_config`)
# to sibling crates that need to drive the updater from a test harness.
# Production builds (the cdylib/staticlib that ships in the engine) do not
# enable this feature; only `library_test_hooks` does.
test-hooks = []
[dependencies] [dependencies]
# Used for error handling for now. # Used for error handling for now.
anyhow = "1.0.69" anyhow = "1.0.69"
+4 -1
View File
@@ -56,7 +56,10 @@ pub fn set_running_patch_number(patch_number: Option<usize>) {
} }
/// Unit tests should call this to reset the config between tests. /// Unit tests should call this to reset the config between tests.
#[cfg(test)] /// Also exposed (via the `test-hooks` Cargo feature) to the
/// `library_test_hooks` cdylib so Dart-side integration tests can reset
/// state between scenarios without spawning a subprocess.
#[cfg(any(test, feature = "test-hooks"))]
pub fn testing_reset_config() { pub fn testing_reset_config() {
with_config_mut(|config| { with_config_mut(|config| {
*config = None; *config = None;
+3 -2
View File
@@ -20,8 +20,9 @@ use crate::network::{
use crate::updater_lock::{with_updater_thread_lock, UpdaterLockState}; use crate::updater_lock::{with_updater_thread_lock, UpdaterLockState};
use crate::yaml::YamlConfig; use crate::yaml::YamlConfig;
#[cfg(test)] // Expose testing_reset_config for in-crate unit tests (under #[cfg(test)])
// Expose testing_reset_config for integration tests. // and for the `library_test_hooks` sibling crate (under feature = "test-hooks").
#[cfg(any(test, feature = "test-hooks"))]
pub use crate::config::testing_reset_config; pub use crate::config::testing_reset_config;
#[cfg(test)] #[cfg(test)]
pub use crate::network::{DownloadToPathFn, Patch, PatchCheckRequestFn}; pub use crate::network::{DownloadToPathFn, Patch, PatchCheckRequestFn};
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "library_test_hooks"
version = "0.1.0"
edition = "2021"
publish = false
# Test-only cdylib that wraps the production updater with extra C symbols
# (`shorebird_test_*`) used by Dart integration tests. Never linked into a
# production engine build. The library name is `updater_test_hooks` so the
# artifact ends up as `libupdater_test_hooks.{dylib,so}` / `updater_test_hooks.dll`.
[lib]
name = "updater_test_hooks"
crate-type = ["cdylib"]
[dependencies]
libc = "0.2.98"
# `test-hooks` widens the visibility of internal items the C symbols below
# call into (e.g. `testing_reset_config`). Production builds of `updater`
# do not enable this feature.
updater = { path = "../library", features = ["test-hooks"] }
[build-dependencies]
cbindgen = "0.29.2"
+31
View File
@@ -0,0 +1,31 @@
extern crate cbindgen;
use std::env;
use std::path::PathBuf;
fn main() {
let crate_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
let config_path = crate_dir.join("cbindgen.toml");
let config = match cbindgen::Config::from_file(&config_path) {
Ok(config) => config,
Err(e) => {
println!("cargo:warning=Error loading {}: {e}", config_path.display());
return;
}
};
let src_path = crate_dir.join("src/lib.rs");
let result = cbindgen::Builder::new()
.with_src(&src_path)
.with_config(config)
.generate();
match result {
Ok(contents) => {
contents.write_to_file("include/library_test_hooks.h");
}
Err(e) => {
println!("cargo:warning=Error generating include/library_test_hooks.h: {e}");
}
}
}
+29
View File
@@ -0,0 +1,29 @@
# cbindgen configuration for the test-hooks C surface.
#
# Output: include/library_test_hooks.h — consumed by the Dart integration
# test suite under `shorebird_code_push/test/integration/`. No stability
# guarantee; this header changes as new test hooks are added.
#
# build.rs runs cbindgen with `with_src("src/lib.rs")`. Only the
# `pub extern "C"` items defined directly in this crate appear here —
# the production C surface that flows through from the `updater` rlib
# is documented in `library/include/updater_engine.h` and
# `library/include/updater_dart.h`.
#
# See https://github.com/eqrion/cbindgen/blob/master/docs.md#cbindgentoml
language = "C"
include_guard = "library_test_hooks_h"
autogen_warning = "/* Warning, this file is autogenerated by cbindgen. Don't modify this manually. */"
cpp_compat = true
line_length = 80
after_includes = """
#ifdef _WIN32
#define SHOREBIRD_EXPORT __declspec(dllexport)
#else
#define SHOREBIRD_EXPORT __attribute__((visibility("default")))
#endif
"""
[fn]
prefix = "SHOREBIRD_EXPORT"
@@ -0,0 +1,33 @@
#ifndef library_test_hooks_h
#define library_test_hooks_h
/* Warning, this file is autogenerated by cbindgen. Don't modify this manually. */
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#ifdef _WIN32
#define SHOREBIRD_EXPORT __declspec(dllexport)
#else
#define SHOREBIRD_EXPORT __attribute__((visibility("default")))
#endif
#ifdef __cplusplus
extern "C" {
#endif // __cplusplus
/**
* Resets the updater's global config so the next `shorebird_init`
* starts from scratch. Equivalent to a process restart for state
* purposes — Dart tests call this between scenarios so each test
* runs against a fresh updater.
*/
SHOREBIRD_EXPORT void shorebird_test_reset(void);
#ifdef __cplusplus
} // extern "C"
#endif // __cplusplus
#endif /* library_test_hooks_h */
+41
View File
@@ -0,0 +1,41 @@
//! Test-only C symbols layered on top of `updater`.
//!
//! This crate exists so Dart integration tests under
//! `shorebird_code_push/test/integration/` can drive the updater
//! end-to-end without bloating the production C API or adding
//! `#[cfg(test)]` symbols to the cdylib that ships in the engine.
//!
//! The crate produces a single `cdylib` artifact
//! (`libupdater_test_hooks.{dylib,so}` / `updater_test_hooks.dll`) that
//! exposes:
//!
//! 1. The production C surface from `updater::c_api::dart` and
//! `updater::c_api::engine`, re-exported so Dart tests can drive a
//! real `shorebird_init` / `shorebird_update_with_result` cycle
//! against tempdir-scoped state.
//! 2. Extra `shorebird_test_*` symbols defined here that wrap
//! Rust-internal items in `updater` (gated behind the `test-hooks`
//! Cargo feature) — currently just `shorebird_test_reset`, with more
//! to come as later stages of the integration suite need them.
//!
//! Production updater builds (the cdylib/staticlib that ships in the
//! engine) do not enable `test-hooks` and never link this crate.
// 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
// otherwise eligible for DCE because they aren't roots from this
// crate's perspective).
#[allow(unused_imports)]
pub use updater::c_api::dart::*;
#[allow(unused_imports)]
pub use updater::c_api::engine::*;
/// Resets the updater's global config so the next `shorebird_init`
/// starts from scratch. Equivalent to a process restart for state
/// purposes — Dart tests call this between scenarios so each test
/// runs against a fresh updater.
#[no_mangle]
pub extern "C" fn shorebird_test_reset() {
updater::testing_reset_config();
}
@@ -0,0 +1,17 @@
# ffigen config for the integration-test-only `library_test_hooks` C
# surface. Output lives under `test/integration/generated/`, NOT under
# `lib/`, because these bindings are not part of `shorebird_code_push`'s
# public API — they're consumed only by the integration tests.
#
# Regenerate with:
# dart run ffigen --config ffigen_test_hooks.yaml
#
# The header is produced by `cargo build -p library_test_hooks`. If
# the header is stale, run that first.
output: "test/integration/generated/test_hooks_bindings.g.dart"
name: "TestHooksBindings"
headers:
entry-points:
- "../library_test_hooks/include/library_test_hooks.h"
preamble: |
// ignore_for_file: unused_element, unused_field, type=lint
@@ -0,0 +1,85 @@
// All updater integration tests live in this single file. This is
// deliberate: `package:test` parallelizes tests across files via
// isolates, but `dlopen` loads the test_hooks cdylib exactly once per
// process and the updater's `OnceCell<UpdateConfig>` is shared across
// every isolate. Splitting these tests across multiple files would
// require either a `dart_test.yaml` concurrency override scoped to
// `test/integration/`, or a subprocess-per-test runner.
//
// Same file = same isolate = serial = no contention. If the suite
// outgrows one file, see `docs/integration_tests.md` for the
// concurrency-override path.
//
// The unit tests under `test/src/` are unaffected: they only use
// `_MockUpdaterBindings` and never load the real cdylib.
// `setUpAll` shells out to `cargo build -p library_test_hooks`. On a
// cold checkout that compiles `updater` and its dependencies and can
// take a couple of minutes — well past `package:test`'s default 30s
// per-test timeout, which also covers `setUpAll`.
@Timeout(Duration(minutes: 10))
library;
import 'dart:ffi';
import 'package:shorebird_code_push/src/generated/updater_bindings.g.dart';
import 'package:shorebird_code_push/src/updater.dart';
import 'package:test/test.dart';
import 'generated/test_hooks_bindings.g.dart';
import 'helpers/build.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`.
String? skipReason;
late final TestHooksBindings testHooks;
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);
} 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).
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);
// 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);
// Reset is still callable after exercising production symbols.
expect(testHooks.shorebird_test_reset, returnsNormally);
});
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
import 'dart:io';
/// Builds `library_test_hooks` and returns the absolute path to the
/// resulting cdylib artifact.
///
/// Throws on any failure (cargo missing, build error, artifact not found).
/// The test entry point catches and translates these into a
/// `markTestSkipped`, so this layer can stay simple.
Future<String> buildTestHooksCdylib() async {
final workspaceRoot = _resolveWorkspaceRoot();
final result = await Process.run(
'cargo',
const ['build', '-p', 'library_test_hooks'],
workingDirectory: workspaceRoot,
);
if (result.exitCode != 0) {
throw Exception(
'cargo build -p library_test_hooks failed (exit ${result.exitCode}):\n'
'stdout:\n${result.stdout}\n'
'stderr:\n${result.stderr}',
);
}
final artifact = File(
'$workspaceRoot/target/debug/${_artifactName('updater_test_hooks')}',
);
if (!artifact.existsSync()) {
throw Exception(
'cargo build succeeded but artifact not found at ${artifact.path}',
);
}
return artifact.path;
}
/// Resolves the Cargo workspace root from the test process's cwd.
///
/// `dart test` runs with cwd = the package root (`shorebird_code_push/`),
/// so the workspace root is one level up.
String _resolveWorkspaceRoot() => Directory.current.parent.path;
String _artifactName(String libName) {
if (Platform.isMacOS) return 'lib$libName.dylib';
if (Platform.isLinux) return 'lib$libName.so';
if (Platform.isWindows) return '$libName.dll';
throw UnsupportedError('Unsupported platform: ${Platform.operatingSystem}');
}