Files
shorebird-updater/docs/integration_tests.md
T
Eric Seidel 8072ed9ad7 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.
2026-05-05 16:32:57 -07:00

350 lines
16 KiB
Markdown

# 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.