8072ed9ad7
* 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.
350 lines
16 KiB
Markdown
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.
|