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:
@@ -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
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 */
|
||||||
@@ -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}');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user