From 280efeac81b08b71e61a1d1027b999c2672b9736 Mon Sep 17 00:00:00 2001 From: pvg13 Date: Thu, 7 May 2026 23:55:55 +0200 Subject: [PATCH 1/4] test(fcm): Maestro flow proving FCM cold-wake delivers a sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end test for the headline contract of `dioxus_fcm_sync`: when the Android app is killed (swiped from recents), a write made by another peer reaches the killed phone via FCM wake-up. The engine wakes briefly in the background, syncs, and shuts back down — all without the user opening the app. Three new files in the example directory: - `test.maestro.phase-a.yaml` — launchApp clear, add a sentinel ("from-phone-A"), `killApp`. We use Maestro's `killApp` (process kill, FCM-deliverable) rather than `am force-stop` (puts package in stopped-state, FCM-blocked) — the former is the realistic shape of "user swiped from recents", which is what the push-sync feature is designed to handle. - `test.maestro.phase-b.yaml` — launchApp without clearState, assert the writer's row is visible within 5s of first paint. A row appearing only later means FCM wake didn't happen and foreground-resume sync caught up after the fact — that's a separate failure mode the README documents. - `test.sh` — orchestrator that boots a local relay (configured with FCM creds), starts a writer-peer (the existing `test-peer` binary from `tests-e2e/`) connected to the same relay+topic+ passphrase, builds & installs the APK with `WAVESYNC_RELAY_OVERRIDE` pointing at the local relay, runs phase A, drives the writer to add a uniquely-titled row while the phone is killed, sleeps 90s for FCM delivery + background sync, runs phase B. Two small support changes to keep the example pristine for non-test users: - `src/main.rs` reads `WAVESYNC_RELAY_OVERRIDE` via `option_env!()` with the production relay as fallback. Test infra sets it; users building normally see no change. - `build.rs` adds `cargo:rerun-if-env-changed=WAVESYNC_RELAY_OVERRIDE` so flipping it triggers a rebuild. Plus `TESTING.md` with: prerequisites (Play-Store AVD, FCM_CREDENTIALS pointing at a service-account JSON from the same Firebase project as the bundled google-services.json), running instructions, pass/fail interpretation, known limitations, and a note on why `killApp` is the right tool and `force-stop` isn't. --- examples/dioxus_fcm_sync/build.rs | 5 + examples/dioxus_fcm_sync/src/main.rs | 14 +- .../dioxus_fcm_sync/test.maestro.phase-a.yaml | 43 +++ .../dioxus_fcm_sync/test.maestro.phase-b.yaml | 44 +++ examples/dioxus_fcm_sync/test.sh | 290 ++++++++++++++++++ 5 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 examples/dioxus_fcm_sync/test.maestro.phase-a.yaml create mode 100644 examples/dioxus_fcm_sync/test.maestro.phase-b.yaml create mode 100755 examples/dioxus_fcm_sync/test.sh diff --git a/examples/dioxus_fcm_sync/build.rs b/examples/dioxus_fcm_sync/build.rs index 7c65002..27bcf2c 100644 --- a/examples/dioxus_fcm_sync/build.rs +++ b/examples/dioxus_fcm_sync/build.rs @@ -1,6 +1,11 @@ fn main() { println!("cargo::rustc-check-cfg=cfg(has_google_services)"); println!("cargo:rerun-if-changed=google-services.json"); + // Pick up `WAVESYNC_RELAY_OVERRIDE` in `option_env!()` at compile + // time. The Maestro test harness (`test.sh`) sets this so the + // installed APK dials a local relay with controlled FCM creds + // instead of the bundled production relay address. + println!("cargo:rerun-if-env-changed=WAVESYNC_RELAY_OVERRIDE"); let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); if target_os != "android" { diff --git a/examples/dioxus_fcm_sync/src/main.rs b/examples/dioxus_fcm_sync/src/main.rs index b0bb725..2eda560 100644 --- a/examples/dioxus_fcm_sync/src/main.rs +++ b/examples/dioxus_fcm_sync/src/main.rs @@ -83,9 +83,17 @@ const STYLE: &str = r#" /// Relay server address. Set this to your relay's multiaddr for WAN sync. /// Example: "/ip4/your-server-ip/tcp/4001/p2p/12D3KooW..." /// Leave as None for LAN-only mDNS sync (desktop testing). -const RELAY_SERVER: Option<&str> = Some( - "/dns4/relay.wavesyncdb.com/udp/4001/quic-v1/p2p/12D3KooWH2ZzVdXehxyNa1QDeWrBLAWKynMVPn8BK2LaDCTiPs4D", -); +/// +/// `WAVESYNC_RELAY_OVERRIDE` lets bench/test scripts (`test.sh`) point the +/// app at a locally-running relay with controlled FCM credentials, +/// without modifying this file. `cargo:rerun-if-env-changed` is set in +/// `build.rs` so changing the env triggers a rebuild. +const RELAY_SERVER: Option<&str> = match option_env!("WAVESYNC_RELAY_OVERRIDE") { + Some(s) => Some(s), + None => Some( + "/dns4/relay.wavesyncdb.com/udp/4001/quic-v1/p2p/12D3KooWH2ZzVdXehxyNa1QDeWrBLAWKynMVPn8BK2LaDCTiPs4D", + ), +}; static DB: OnceLock = OnceLock::new(); diff --git a/examples/dioxus_fcm_sync/test.maestro.phase-a.yaml b/examples/dioxus_fcm_sync/test.maestro.phase-a.yaml new file mode 100644 index 0000000..0532a8c --- /dev/null +++ b/examples/dioxus_fcm_sync/test.maestro.phase-a.yaml @@ -0,0 +1,43 @@ +# Phase A — setup + kill. See test.sh for the full sequence. +# +# Sets up a known starting state on the phone: +# - Fresh install (clearState wipes the SQLite DB). +# - App boots, registers FCM token with the relay, registers +# entities, syncs schema. The "Tasks" header is the visible +# marker that the engine is up. +# - Add a sentinel task ("from-phone-A") so the parallel writer +# peer in test.sh can verify the app→relay path is alive +# before we kill the app. +# - killApp is the realistic shape of "user swiped from recents": +# the process exits but Android keeps it FCM-deliverable +# (unlike `am force-stop` which puts it in stopped-state). +# +# Selectors: +# - "What needs to be done?" is the placeholder text on the +# AddTaskForm input (see src/main.rs). +# - "Add" is the submit button on the same form. + +appId: com.wavesync.mobile_demo +--- +- launchApp: + clearState: true + +# Wait for the engine to come up and the Tasks panel to render. +- extendedWaitUntil: + visible: "Tasks" + timeout: 60000 + +# Sentinel task: proves the app itself can write while running. +# The writer peer in test.sh asserts it sees this row before +# we kill the app, which validates the relay path is alive. +- tapOn: + text: "What needs to be done?" +- inputText: "from-phone-A" +- tapOn: "Add" +- extendedWaitUntil: + visible: "from-phone-A" + timeout: 15000 + +# killApp = "swipe from recents". App is fully killed but +# remains FCM-deliverable; this is the cold-wake test scope. +- killApp diff --git a/examples/dioxus_fcm_sync/test.maestro.phase-b.yaml b/examples/dioxus_fcm_sync/test.maestro.phase-b.yaml new file mode 100644 index 0000000..f872dc4 --- /dev/null +++ b/examples/dioxus_fcm_sync/test.maestro.phase-b.yaml @@ -0,0 +1,44 @@ +# Phase B — assert FCM cold-wake delivered the sync. See test.sh +# for the full sequence. +# +# Between phase A and phase B, test.sh: +# 1. Drives a CLI peer (test-peer binary, connected to the same +# relay+topic+passphrase) to add a task with a known title +# while the phone is killed. +# 2. Sleeps up to 90 s so the OS can deliver FCM, wake the +# FirebaseMessagingService, run the engine in background, +# reconnect to the relay, sync, and shut back down. +# +# This phase relaunches the app **without** clearState — we want to +# inspect what the FCM-woken engine wrote to the local SQLite while +# the UI was dead. If the row is there at first paint, FCM cold-wake +# is working. If it isn't there but appears within a few seconds +# after relaunch, FCM didn't wake the engine but the foreground +# resume did — note that as a separate failure mode. +# +# The writer's task title is provided via WAVESYNC_FCM_REMOTE_TASK +# (defaults to a fixed string for ad-hoc runs). It must match the +# string test.sh writes via the writer peer. + +appId: com.wavesync.mobile_demo +--- +- launchApp: + clearState: false + +# Wait for the engine to come back up after relaunch. +- extendedWaitUntil: + visible: "Tasks" + timeout: 30000 + +# The critical assertion: the writer's row must already be in the +# local DB at first paint. We give the visibility check a short +# (5 s) window to absorb UI repaints, but anything longer than that +# means the engine is fetching the row only NOW (foreground resume) +# rather than having received it during FCM background-wake. +- extendedWaitUntil: + visible: ${WAVESYNC_FCM_REMOTE_TASK ?? 'from-cli-while-killed'} + timeout: 5000 + +# Sentinel from phase A is still here — proves the DB persisted +# across kill+relaunch (no clearState). +- assertVisible: "from-phone-A" diff --git a/examples/dioxus_fcm_sync/test.sh b/examples/dioxus_fcm_sync/test.sh new file mode 100755 index 0000000..33df98f --- /dev/null +++ b/examples/dioxus_fcm_sync/test.sh @@ -0,0 +1,290 @@ +#!/usr/bin/env bash +# End-to-end FCM cold-wake test for the dioxus_fcm_sync example. +# +# Proves that when the Android app is killed (swiped from recents), +# a write made by another peer reaches the killed phone via FCM +# wake-up — the actual contract of the push-sync feature on mobile. +# +# # Sequence +# +# 1. Start a local wavesync_relay configured with FCM credentials. +# 2. Start a writer peer (the tests-e2e test-peer binary) connected +# to that relay with the same topic + passphrase the example +# app uses. +# 3. Build & install the example APK on the connected emulator +# (or device). The build sets WAVESYNC_RELAY_OVERRIDE so the +# installed app dials our local relay rather than the bundled +# production address. +# 4. Maestro phase-a: launch app, add sentinel task, killApp. +# 5. Verify the writer peer received the sentinel — the relay path +# is alive. +# 6. Writer peer adds a task with a unique title. Relay sees the +# writer's notify, FCM-pushes the killed phone. +# 7. Sleep 90 s so the OS delivers FCM and wakes the engine in +# background. (Real Android delivery latency on a fresh build +# is typically <30 s.) +# 8. Maestro phase-b: relaunch app (no clearState), assert the +# writer's row is in the local DB at first paint. +# +# Pass = phase-b finds the row at first paint. +# Fail = the row isn't there OR appears only after foreground sync +# kicks in (→ FCM cold-wake isn't working). +# +# # Prerequisites +# +# - Connected Android device or emulator running a Google Play +# Services system image: +# system-images;android-34;google_apis_playstore;x86_64 +# `default` images don't ship Google Play Services and FCM +# never gets delivered. `adb shell pm list packages | grep gms` +# should return at least `com.google.android.gms` if your +# device is suitable. +# +# - Maestro CLI installed (https://maestro.mobile.dev). Tested +# against v1.39+. +# +# - dx (Dioxus CLI) on PATH. +# +# - FCM credentials JSON for a Firebase project that matches the +# google-services.json bundled in this example. Set its path +# via FCM_CREDENTIALS env var, e.g.: +# export FCM_CREDENTIALS=/path/to/firebase-adminsdk.json +# +# # Usage +# +# ./test.sh # full FCM cold-wake test +# ./test.sh --stop # stop everything started by a prior run +# +# Override knobs: +# FCM_CREDENTIALS=/abs/path # required, see above +# ANDROID_SERIAL=emulator-5554 # adb -s target if multiple devices +# SKIP_INSTALL=1 # reuse already-installed APK + +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +LOGDIR="$HERE/.test-logs" +PIDDIR="$HERE/.test-pids" +mkdir -p "$LOGDIR" "$PIDDIR" + +PACKAGE="com.wavesync.mobile_demo" +TOPIC="mobile-tasks-demo" +PASSPHRASE="demo-shared-secret" +WRITER_HTTP_PORT="${WRITER_HTTP_PORT:-8489}" + +# Identity-keypair pinned so the relay's PeerId is stable across runs — +# the WAVESYNC_RELAY_OVERRIDE we set into the APK build embeds a fixed +# multiaddr that includes /p2p/. Re-running with a +# different keypair would invalidate the previously-installed APK. +RELAY_KEY='CAESQGlCc264ZKF3D4l/5VXTLjnGdDKxg0cyX2UosIkZmNAbxV5oeISRfEDIrc/+hdQuqepe9CCCc3M5G3DJBs6N6lE=' +RELAY_PEER_ID='12D3KooWH2ZzVdXehxyNa1QDeWrBLAWKynMVPn8BK2LaDCTiPs4D' +RELAY_QUIC_PORT="${RELAY_QUIC_PORT:-4001}" +RELAY_TCP_PORT="${RELAY_TCP_PORT:-4002}" + +stop_all() { + for pidfile in "$PIDDIR"/*.pid; do + [[ -f "$pidfile" ]] || continue + local name pid + name="$(basename "$pidfile" .pid)" + pid="$(cat "$pidfile")" + if kill -0 "$pid" 2>/dev/null; then + kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true + echo "stopped $name (pid=$pid)" + fi + rm -f "$pidfile" + done +} +trap stop_all EXIT INT TERM + +if [[ "${1:-}" == "--stop" ]]; then + stop_all + trap - EXIT + exit 0 +fi + +# Idempotent — kill leftovers from a previous run before starting. +stop_all + +# ── Prereq checks ────────────────────────────────────────────────── + +require() { + command -v "$1" >/dev/null 2>&1 || { + echo "ERROR: $1 not found on PATH. $2" >&2 + exit 2 + } +} +require adb "Install Android SDK platform-tools." +require dx "Install Dioxus CLI: cargo install dioxus-cli." +require maestro "Install Maestro: https://maestro.mobile.dev" +require cargo "Install Rust toolchain." + +if [[ -z "${FCM_CREDENTIALS:-}" ]]; then + echo "ERROR: FCM_CREDENTIALS env var not set." >&2 + echo " Point it at the Firebase Admin SDK JSON for the project that" >&2 + echo " matches google-services.json in this directory." >&2 + exit 2 +fi +if [[ ! -f "$FCM_CREDENTIALS" ]]; then + echo "ERROR: FCM_CREDENTIALS=$FCM_CREDENTIALS — file does not exist." >&2 + exit 2 +fi + +# Detect a connected emulator/device. If ANDROID_SERIAL is set, +# trust it; otherwise pick the first online device. +if [[ -z "${ANDROID_SERIAL:-}" ]]; then + ANDROID_SERIAL="$(adb devices | awk 'NR>1 && $2=="device" {print $1; exit}')" +fi +if [[ -z "$ANDROID_SERIAL" ]]; then + echo "ERROR: no online adb devices. Boot an emulator (Play Store image!) or plug a phone." >&2 + exit 2 +fi +export ANDROID_SERIAL +echo "Using device: $ANDROID_SERIAL" + +# Verify it's a Play-Store image — the test will hang on FCM +# delivery without Google Play Services. +if ! adb -s "$ANDROID_SERIAL" shell pm list packages 2>/dev/null | grep -q '^package:com.google.android.gms$'; then + echo "WARNING: $ANDROID_SERIAL does not have com.google.android.gms installed." >&2 + echo " FCM delivery will not work. Recreate the AVD with a" >&2 + echo " google_apis_playstore system image." >&2 +fi + +# ── 1. Start the relay ───────────────────────────────────────────── + +LAN_IP="$(ip -4 -o addr show scope global 2>/dev/null \ + | awk '{print $4}' | cut -d/ -f1 \ + | grep -E '^192\.168\.' | head -1 || true)" +[[ -z "$LAN_IP" ]] && LAN_IP="$(ip -4 -o addr show scope global 2>/dev/null \ + | awk '{print $4}' | cut -d/ -f1 | head -1 || echo 127.0.0.1)" +echo "LAN IP: $LAN_IP" + +RELAY_ADDR="/ip4/$LAN_IP/udp/$RELAY_QUIC_PORT/quic-v1/p2p/$RELAY_PEER_ID" + +echo "==> Starting relay at $RELAY_ADDR" +( + cd "$ROOT" + setsid env IDENTITY_KEYPAIR="$RELAY_KEY" \ + FCM_CREDENTIALS="$FCM_CREDENTIALS" \ + EXTERNAL_ADDRESS="$RELAY_ADDR" \ + RUST_LOG=info \ + cargo run --release -p wavesync_relay -- \ + --listen-addr "/ip4/0.0.0.0/udp/$RELAY_QUIC_PORT/quic-v1" \ + --listen-addr "/ip4/0.0.0.0/tcp/$RELAY_TCP_PORT" \ + --external-address "$RELAY_ADDR" \ + > "$LOGDIR/relay.log" 2>&1 & + echo $! > "$PIDDIR/relay.pid" +) +sleep 3 +if ! kill -0 "$(cat "$PIDDIR/relay.pid")" 2>/dev/null; then + echo "ERROR: relay died at startup. Tail of log:" >&2 + tail -30 "$LOGDIR/relay.log" >&2 + exit 1 +fi +echo "relay pid=$(cat "$PIDDIR/relay.pid") log=$LOGDIR/relay.log" + +# ── 2. Start the writer peer (test-peer binary) ──────────────────── + +echo "==> Starting writer peer on http://127.0.0.1:$WRITER_HTTP_PORT" +WRITER_DB="$(mktemp -d)/writer.db" +( + cd "$ROOT" + setsid env BIND_ADDR="0.0.0.0:$WRITER_HTTP_PORT" \ + DB_URL="sqlite:$WRITER_DB?mode=rwc" \ + TOPIC="$TOPIC" \ + PASSPHRASE="$PASSPHRASE" \ + RELAY_ADDR="$RELAY_ADDR" \ + RUST_LOG=info,libp2p_swarm=warn \ + cargo run --release -p wavesyncdb-e2e --bin test-peer \ + > "$LOGDIR/writer.log" 2>&1 & + echo $! > "$PIDDIR/writer.pid" +) +# Wait for HTTP up +for i in {1..20}; do + if curl -fs "http://127.0.0.1:$WRITER_HTTP_PORT/health" >/dev/null 2>&1; then + echo "writer up after ${i}s" + break + fi + sleep 1 +done +if ! curl -fs "http://127.0.0.1:$WRITER_HTTP_PORT/health" >/dev/null; then + echo "ERROR: writer-peer never came up. Tail of log:" >&2 + tail -30 "$LOGDIR/writer.log" >&2 + exit 1 +fi + +# ── 3. Build & install the example APK ───────────────────────────── + +if [[ -z "${SKIP_INSTALL:-}" ]]; then + echo "==> Building & installing APK with WAVESYNC_RELAY_OVERRIDE=$RELAY_ADDR" + ( + cd "$HERE" + WAVESYNC_RELAY_OVERRIDE="$RELAY_ADDR" \ + dx build --platform android --release 2>&1 \ + | tee "$LOGDIR/dx-build.log" + ) + # dx outputs the apk path; the standard Dioxus location: + APK="$(find "$HERE/target" -name '*.apk' -path '*release*' 2>/dev/null \ + | sort | tail -1)" + if [[ -z "$APK" ]]; then + echo "ERROR: no APK produced; see $LOGDIR/dx-build.log" >&2 + exit 1 + fi + echo "APK: $APK" + adb -s "$ANDROID_SERIAL" install -r -g "$APK" >/dev/null +fi + +# ── 4. Maestro phase A — launch, add sentinel, killApp ───────────── + +REMOTE_TITLE="from-cli-$(date +%s)-$$" +echo "==> Maestro phase A (launch + sentinel + killApp)" +maestro --device "$ANDROID_SERIAL" test "$HERE/test.maestro.phase-a.yaml" \ + | tee "$LOGDIR/maestro-a.log" + +# ── 5. Verify the writer received the sentinel (app→relay path alive) ─ + +echo "==> Waiting for writer to see the sentinel from-phone-A..." +SENTINEL_OK=0 +for i in {1..30}; do + if curl -fs "http://127.0.0.1:$WRITER_HTTP_PORT/tasks/from-phone-A" >/dev/null 2>&1; then + SENTINEL_OK=1 + echo "sentinel reached writer after ${i}s" + break + fi + sleep 1 +done +if [[ $SENTINEL_OK -eq 0 ]]; then + echo "ERROR: writer never saw the sentinel — app→relay path is broken," >&2 + echo " cold-wake test invalid. Tail of writer log:" >&2 + tail -30 "$LOGDIR/writer.log" >&2 + exit 1 +fi + +# ── 6. Writer adds the under-test task while phone is killed ─────── + +echo "==> Writer adds task '$REMOTE_TITLE' while phone is killed" +curl -fsS -X POST "http://127.0.0.1:$WRITER_HTTP_PORT/tasks" \ + -H 'content-type: application/json' \ + -d "$(printf '{"id":"%s","title":"%s","completed":false}' "$REMOTE_TITLE" "$REMOTE_TITLE")" \ + > /dev/null + +# ── 7. Wait for FCM to wake the killed app and sync ──────────────── + +WAKE_WAIT="${WAKE_WAIT:-90}" +echo "==> Sleeping ${WAKE_WAIT}s for FCM to deliver and wake the engine in background..." +sleep "$WAKE_WAIT" + +# ── 8. Maestro phase B — relaunch and assert the row is present ──── + +echo "==> Maestro phase B (relaunch + assert)" +WAVESYNC_FCM_REMOTE_TASK="$REMOTE_TITLE" \ + maestro --device "$ANDROID_SERIAL" test \ + --env "WAVESYNC_FCM_REMOTE_TASK=$REMOTE_TITLE" \ + "$HERE/test.maestro.phase-b.yaml" \ + | tee "$LOGDIR/maestro-b.log" + +echo +echo "============================================================" +echo " PASS — FCM cold-wake delivered '$REMOTE_TITLE' to the" +echo " killed app while the UI was dead." +echo "============================================================" From 1d48d381463a517ec73b1a2b662c6f83f35e280b Mon Sep 17 00:00:00 2001 From: pvg13 Date: Thu, 7 May 2026 23:56:17 +0200 Subject: [PATCH 2/4] docs(fcm): add TESTING.md (force-add past **/*.md gitignore) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project's .gitignore says 'Keep doc files tracked — only ignore generated markdown' but the actual rule is **/*.md which catches everything. Force-add this README per the comment's intent so users discovering this directory have prerequisites and pass/fail interpretation in front of them. --- examples/dioxus_fcm_sync/TESTING.md | 156 ++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 examples/dioxus_fcm_sync/TESTING.md diff --git a/examples/dioxus_fcm_sync/TESTING.md b/examples/dioxus_fcm_sync/TESTING.md new file mode 100644 index 0000000..f3a9ef7 --- /dev/null +++ b/examples/dioxus_fcm_sync/TESTING.md @@ -0,0 +1,156 @@ +# FCM cold-wake test — `examples/dioxus_fcm_sync` + +End-to-end test that proves the headline contract of the push-sync +feature: **when the Android app is killed (swiped from recents), +a write made by another peer reaches the killed phone via FCM +wake-up**, the engine wakes briefly in the background, syncs, and +shuts back down. + +If this test passes locally, you have evidence that: + +1. The relay correctly maps `NotifyTopic` → FCM token list → + `messaging.send()`. +2. The Android app's `FirebaseMessagingService` survives the kill + and is invoked when FCM arrives. +3. The Rust engine's background-sync entry point reconnects to + the relay, runs version-vector catch-up, and writes the row + to local SQLite — all while the UI is dead. + +## Files + +| File | Purpose | +|---|---| +| `test.sh` | Orchestrator: relay + writer peer + APK build/install + maestro phases | +| `test.maestro.phase-a.yaml` | Setup: launchApp clear, add sentinel, killApp | +| `test.maestro.phase-b.yaml` | Assertion: relaunch (no clear), assert remote row visible at first paint | + +## Prerequisites + +### Hardware / OS + +- A Linux or macOS workstation with `adb` on PATH. +- A connected Android device **OR** an emulator running a Google + Play Services system image. **`default` images don't work** — + Google Play Services isn't installed on them, so FCM never + delivers and `test.sh` will stall at the wait step. + + Recommended AVD setup: + + ```bash + sdkmanager 'system-images;android-34;google_apis_playstore;x86_64' + avdmanager create avd \ + -n fcm-test \ + -k 'system-images;android-34;google_apis_playstore;x86_64' \ + -d pixel_7 + emulator -avd fcm-test -no-snapshot-load & + ``` + + Quick check that GMS is on the device: + + ```bash + adb shell pm list packages | grep com.google.android.gms + # → package:com.google.android.gms + ``` + +### Tools + +- `dx` (Dioxus CLI): `cargo install dioxus-cli` +- Maestro: (tested against v1.39+) +- Rust toolchain (already required by this repo) + +### Firebase project + +Two halves must come from the **same Firebase project**: + +- `examples/dioxus_fcm_sync/google-services.json` — the client + config bundled into the APK. Already present in the repo for + the `wavesync` Firebase project; replace it with your own if + testing under a different project. +- An Admin SDK service-account JSON for the relay, exported via + `FCM_CREDENTIALS`. Generate via Firebase Console → + Project Settings → Service Accounts → "Generate new private + key". Save outside the repo and never commit it. + +If the two halves don't match, the relay's FCM `send()` succeeds +but the message goes to a different project's tokens (or fails +silently), and the test stalls. + +## Running + +```bash +export FCM_CREDENTIALS=/abs/path/to/firebase-adminsdk.json +./test.sh +``` + +Optional knobs: + +| Var | Default | What | +|---|---|---| +| `ANDROID_SERIAL` | first online adb device | which device to drive | +| `WAKE_WAIT` | `90` (seconds) | how long to wait for FCM delivery + background sync | +| `WRITER_HTTP_PORT` | `8489` | local port for the writer-peer HTTP API | +| `RELAY_QUIC_PORT` | `4001` | UDP port the local relay listens on | +| `SKIP_INSTALL` | (unset) | reuse already-installed APK; speeds iteration | + +## Pass / fail interpretation + +**Pass** — Phase B's `extendedWaitUntil` for the writer's row +succeeds within 5 s of relaunch. The row was written to SQLite +*before* the UI started, which means FCM woke the engine in the +background and let it complete the sync. + +**Fail at Phase B's assertion** — three sub-cases: + +1. **No row at all after 5 s** — FCM didn't wake the app. Check: + - Is the AVD/device on a Play Store image? + `adb shell pm list packages | grep gms` + - Are `FCM_CREDENTIALS` and `google-services.json` from the + same Firebase project? + - Did the relay's startup log show `FCM credentials loaded` + (look in `.test-logs/relay.log`)? + - Was the FCM token registered with the relay during phase A? + Look for `RegisterToken` in the relay log. +2. **Row appears within ~10 s of relaunch but not at first paint** + — FCM wake didn't happen, but foreground-resume sync caught + up. Less catastrophic than (1) but still a failure of the + cold-wake contract. +3. **Sentinel `from-phone-A` is missing in Phase B** — local DB + was wiped, probably by an OS-level "uninstall on app crash" + or by a stale `clearState` somewhere. Re-run. + +**Fail at the sentinel-check step (after Phase A)** — the +app→relay path itself is broken, before FCM is even relevant. +Most common cause: the relay's bridge address isn't reachable +from the emulator (check `LAN_IP` detection in `test.sh` and +that the emulator can ping it). + +## Known limitations + +- Single-test-at-a-time: the topic is hardcoded, so two parallel + runs would cross-talk via the relay. +- The "writer" peer is the test-peer binary from `tests-e2e/`, + which has `iproute2`/`iptables` baggage it doesn't need here + but builds the same way. No functional impact. +- Doze mode delays FCM delivery by minutes when the device has + been idle a long time. Real CI machines aren't a concern; if + you're testing on a personal phone left overnight, run a + short workload first to keep it out of Doze. +- iOS is not yet covered. APNs has a similar wake mechanism but + Maestro's `killApp` interacts differently with iOS task death, + and APNs sandbox tokens have shorter TTLs. Filed as a future + follow-up. + +## Why we use `killApp` and not `am force-stop` + +`killApp` (Maestro) maps to `adb shell am stop-app` (Android 14+) +or a process kill — the app exits but stays FCM-deliverable, the +shape of "user swiped from recents". + +`am force-stop` puts the package in **stopped state**, an +Android security flag that disables FCM delivery until the user +manually relaunches. It's the right tool for testing the +"reinstall / first launch" path but the wrong tool for testing +"app was killed by the OS or user kill action" — exactly the +case our push-sync feature is designed to handle. Don't switch +to `force-stop` even if `killApp` flakes; the test would silently +become useless. From 4e973e2b769a9b8f63f0f576296ef65745ac68e8 Mon Sep 17 00:00:00 2001 From: pvg13 Date: Fri, 8 May 2026 00:25:09 +0200 Subject: [PATCH 3/4] test(fcm): iterate Maestro flow + harness until end-to-end PASS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified end-to-end on a Google Play Services emulator (system-images;android-36.1;google_apis_playstore;x86_64), with WaveSyncDB's actual Firebase project. Concrete result: sentinel reached writer after 1s Writer adds task 'from-cli-1778192513-2978327' while phone is killed Sleeping 90s for FCM to deliver and wake the engine in background... Maestro phase B (relaunch + assert) Assert that "from-cli-1778192513-2978327" is visible... COMPLETED Assert that "from-phone-1778192513-2978327" is visible... COMPLETED PASS — FCM cold-wake delivered 'from-cli-1778192513-2978327' to the killed app while the UI was dead. Five issues surfaced and fixed during the run: 1. Relay CLI uses --listen-addr (single, default TCP) + --ws-listen-addr; multiple --listen-addr is rejected. Switched to one TCP listen-addr + advertise additional QUIC + ws via --external-address (mirrors what qr_pairing/test.sh does). 2. Relay's pinned PeerId can't be hardcoded — it's derived from the identity keypair. Now read from the relay's startup log (`Relay server PeerId: …`) and substituted into both the writer's RELAY_ADDR and the APK's WAVESYNC_RELAY_OVERRIDE. 3. Writer-peer's `cargo run --release` cold-compile took 5+ minutes; my 20-second health-check loop fired before it was up. Added an up-front `cargo build --release --quiet` for both relay and writer so the runtime startup timeouts are about binary boot, not compile. 4. Android emulators NAT to the host as 10.0.2.2; the host's LAN IP isn't reachable from inside the emulator. APK build now uses 10.0.2.2 when ANDROID_SERIAL starts with `emulator-`, LAN-IP otherwise (physical devices on the same WiFi). 5. Maestro selectors targeted "Tasks" — the actual h1 in src/main.rs is "WaveSyncDB Mobile Demo". Fixed. 6. Per-run UUID-suffixed sentinels (`from-phone-$RUN_TAG`, `from-cli-$RUN_TAG`) replace the previous hardcoded strings. Wired through Maestro `--env` so phase A and phase B agree on the same row to look for. Eliminates false-positives from residual SQLite state surviving `clearState`. 7. APK output path under dx is at `${CARGO_TARGET_DIR}/dx/example-dioxus-fcm-sync/release/android/app/` not `$HERE/target/`. Find updated to look in the right place. Plus an `.gitignore` rule for the harness's runtime log/pid dirs. --- .gitignore | 5 +- .../dioxus_fcm_sync/test.maestro.phase-a.yaml | 17 ++- .../dioxus_fcm_sync/test.maestro.phase-b.yaml | 4 +- examples/dioxus_fcm_sync/test.sh | 138 +++++++++++++----- 4 files changed, 116 insertions(+), 48 deletions(-) diff --git a/.gitignore b/.gitignore index 1c71386..3fca048 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,7 @@ wavesync_relay/secrets/ **/.gradle/ **/build/ **/.build/ -**/.kotlin/ \ No newline at end of file +**/.kotlin/ +# Maestro / FCM test harness runtime artifacts +examples/dioxus_fcm_sync/.test-logs/ +examples/dioxus_fcm_sync/.test-pids/ diff --git a/examples/dioxus_fcm_sync/test.maestro.phase-a.yaml b/examples/dioxus_fcm_sync/test.maestro.phase-a.yaml index 0532a8c..c7688b4 100644 --- a/examples/dioxus_fcm_sync/test.maestro.phase-a.yaml +++ b/examples/dioxus_fcm_sync/test.maestro.phase-a.yaml @@ -22,20 +22,23 @@ appId: com.wavesync.mobile_demo - launchApp: clearState: true -# Wait for the engine to come up and the Tasks panel to render. +# Wait for the app's WebView to render. h1 in src/main.rs is +# "WaveSyncDB Mobile Demo". - extendedWaitUntil: - visible: "Tasks" + visible: "WaveSyncDB Mobile Demo" timeout: 60000 -# Sentinel task: proves the app itself can write while running. -# The writer peer in test.sh asserts it sees this row before -# we kill the app, which validates the relay path is alive. +# Sentinel task: proves the app itself can write while running and +# the relay path delivers it to the writer-peer. The literal title +# is provided via WAVESYNC_FCM_PHONE_SENTINEL — test.sh sets a +# UUID-suffixed value per run so prior-run residue can't make the +# assertion trivially pass. - tapOn: text: "What needs to be done?" -- inputText: "from-phone-A" +- inputText: ${WAVESYNC_FCM_PHONE_SENTINEL ?? 'from-phone-A'} - tapOn: "Add" - extendedWaitUntil: - visible: "from-phone-A" + visible: ${WAVESYNC_FCM_PHONE_SENTINEL ?? 'from-phone-A'} timeout: 15000 # killApp = "swipe from recents". App is fully killed but diff --git a/examples/dioxus_fcm_sync/test.maestro.phase-b.yaml b/examples/dioxus_fcm_sync/test.maestro.phase-b.yaml index f872dc4..6a716c1 100644 --- a/examples/dioxus_fcm_sync/test.maestro.phase-b.yaml +++ b/examples/dioxus_fcm_sync/test.maestro.phase-b.yaml @@ -27,7 +27,7 @@ appId: com.wavesync.mobile_demo # Wait for the engine to come back up after relaunch. - extendedWaitUntil: - visible: "Tasks" + visible: "WaveSyncDB Mobile Demo" timeout: 30000 # The critical assertion: the writer's row must already be in the @@ -41,4 +41,4 @@ appId: com.wavesync.mobile_demo # Sentinel from phase A is still here — proves the DB persisted # across kill+relaunch (no clearState). -- assertVisible: "from-phone-A" +- assertVisible: ${WAVESYNC_FCM_PHONE_SENTINEL ?? 'from-phone-A'} diff --git a/examples/dioxus_fcm_sync/test.sh b/examples/dioxus_fcm_sync/test.sh index 33df98f..6027edc 100755 --- a/examples/dioxus_fcm_sync/test.sh +++ b/examples/dioxus_fcm_sync/test.sh @@ -73,12 +73,12 @@ TOPIC="mobile-tasks-demo" PASSPHRASE="demo-shared-secret" WRITER_HTTP_PORT="${WRITER_HTTP_PORT:-8489}" -# Identity-keypair pinned so the relay's PeerId is stable across runs — -# the WAVESYNC_RELAY_OVERRIDE we set into the APK build embeds a fixed -# multiaddr that includes /p2p/. Re-running with a -# different keypair would invalidate the previously-installed APK. +# Identity-keypair pinned so the relay's PeerId is stable across runs. +# The actual PeerId derived from this keypair is read from the relay's +# startup log ("Relay server PeerId: …") and substituted into +# WAVESYNC_RELAY_OVERRIDE for the APK build — that way we don't have +# to hand-maintain a peer-id constant that has to match the keypair. RELAY_KEY='CAESQGlCc264ZKF3D4l/5VXTLjnGdDKxg0cyX2UosIkZmNAbxV5oeISRfEDIrc/+hdQuqepe9CCCc3M5G3DJBs6N6lE=' -RELAY_PEER_ID='12D3KooWH2ZzVdXehxyNa1QDeWrBLAWKynMVPn8BK2LaDCTiPs4D' RELAY_QUIC_PORT="${RELAY_QUIC_PORT:-4001}" RELAY_TCP_PORT="${RELAY_TCP_PORT:-4002}" @@ -159,29 +159,70 @@ LAN_IP="$(ip -4 -o addr show scope global 2>/dev/null \ | awk '{print $4}' | cut -d/ -f1 | head -1 || echo 127.0.0.1)" echo "LAN IP: $LAN_IP" -RELAY_ADDR="/ip4/$LAN_IP/udp/$RELAY_QUIC_PORT/quic-v1/p2p/$RELAY_PEER_ID" +# EXTERNAL_ADDRESS bootstrap: the relay needs to know its own peer-id +# to print a multiaddr we can dial. We pass it the identity keypair +# via env, but we don't yet know what peer-id that derives. Start the +# relay with a placeholder external address pointing at the LAN IP — +# the relay logs "Relay server PeerId: …" in the first 1-2s of startup, +# we read it back, and then we have the real RELAY_ADDR to embed in +# the APK build. +# Pre-build the relay and writer-peer release binaries up front so +# the runtime startup timeouts below aren't dominated by `cargo run` +# compile time on a cold target dir (5+ minutes from scratch). +echo "==> Pre-building relay + writer (cold compile can take several minutes)" +(cd "$ROOT" && cargo build --release --quiet -p wavesync_relay) +(cd "$ROOT" && cargo build --release --quiet -p wavesyncdb-e2e --bin test-peer) -echo "==> Starting relay at $RELAY_ADDR" +echo "==> Starting relay (peer-id will be read from log)" ( cd "$ROOT" - setsid env IDENTITY_KEYPAIR="$RELAY_KEY" \ - FCM_CREDENTIALS="$FCM_CREDENTIALS" \ - EXTERNAL_ADDRESS="$RELAY_ADDR" \ - RUST_LOG=info \ - cargo run --release -p wavesync_relay -- \ - --listen-addr "/ip4/0.0.0.0/udp/$RELAY_QUIC_PORT/quic-v1" \ - --listen-addr "/ip4/0.0.0.0/tcp/$RELAY_TCP_PORT" \ - --external-address "$RELAY_ADDR" \ - > "$LOGDIR/relay.log" 2>&1 & + setsid env RUST_LOG=info cargo run --release --quiet -p wavesync_relay -- \ + --identity-keypair="$RELAY_KEY" \ + --listen-addr "/ip4/0.0.0.0/tcp/$RELAY_TCP_PORT" \ + --external-address "/ip4/$LAN_IP/tcp/$RELAY_TCP_PORT" \ + --external-address "/ip4/$LAN_IP/udp/$RELAY_QUIC_PORT/quic-v1" \ + --max-reservations-per-peer 256 \ + --fcm-credentials "$FCM_CREDENTIALS" \ + > "$LOGDIR/relay.log" 2>&1 & echo $! > "$PIDDIR/relay.pid" ) -sleep 3 -if ! kill -0 "$(cat "$PIDDIR/relay.pid")" 2>/dev/null; then - echo "ERROR: relay died at startup. Tail of log:" >&2 - tail -30 "$LOGDIR/relay.log" >&2 + +# Wait for the PeerId to appear in the log (or the relay to die). +RELAY_PEER_ID="" +for i in {1..120}; do + if ! kill -0 "$(cat "$PIDDIR/relay.pid")" 2>/dev/null; then + echo "ERROR: relay died at startup. Tail of log:" >&2 + tail -50 "$LOGDIR/relay.log" >&2 + exit 1 + fi + RELAY_PEER_ID="$(grep -oP 'Relay server PeerId: \K\S+' "$LOGDIR/relay.log" | head -1 || true)" + if [[ -n "$RELAY_PEER_ID" ]]; then + break + fi + sleep 1 +done +if [[ -z "$RELAY_PEER_ID" ]]; then + echo "ERROR: relay did not print 'Relay server PeerId: …' within 120s." >&2 + tail -50 "$LOGDIR/relay.log" >&2 exit 1 fi -echo "relay pid=$(cat "$PIDDIR/relay.pid") log=$LOGDIR/relay.log" +RELAY_ADDR_HOST="/ip4/$LAN_IP/udp/$RELAY_QUIC_PORT/quic-v1/p2p/$RELAY_PEER_ID" +# Android emulators (`emulator-NNNN`) use a NAT'd network where the +# host machine appears as `10.0.2.2` — the LAN IP of the host is NOT +# routable from inside the emulator. For physical devices on the same +# WiFi as the host, the LAN IP is reachable directly. +if [[ "$ANDROID_SERIAL" == emulator-* ]]; then + APK_RELAY_HOST="10.0.2.2" + echo "Detected emulator — APK will dial relay at 10.0.2.2 (Android NAT to host)" +else + APK_RELAY_HOST="$LAN_IP" + echo "Detected physical device — APK will dial relay at LAN IP $LAN_IP" +fi +APK_RELAY_ADDR="/ip4/$APK_RELAY_HOST/udp/$RELAY_QUIC_PORT/quic-v1/p2p/$RELAY_PEER_ID" +RELAY_ADDR="$RELAY_ADDR_HOST" # writer peer on the host uses LAN-IP form +echo "relay pid=$(cat "$PIDDIR/relay.pid") peer-id=$RELAY_PEER_ID" +echo "relay addr (host/writer) = $RELAY_ADDR" +echo "relay addr (apk/emulator) = $APK_RELAY_ADDR" # ── 2. Start the writer peer (test-peer binary) ──────────────────── @@ -199,8 +240,10 @@ WRITER_DB="$(mktemp -d)/writer.db" > "$LOGDIR/writer.log" 2>&1 & echo $! > "$PIDDIR/writer.pid" ) -# Wait for HTTP up -for i in {1..20}; do +# Wait for HTTP up. Writer was pre-built above so this is just +# binary startup — but we still allow 60s of slack for slow CI +# machines / cold caches. +for i in {1..60}; do if curl -fs "http://127.0.0.1:$WRITER_HTTP_PORT/health" >/dev/null 2>&1; then echo "writer up after ${i}s" break @@ -216,18 +259,24 @@ fi # ── 3. Build & install the example APK ───────────────────────────── if [[ -z "${SKIP_INSTALL:-}" ]]; then - echo "==> Building & installing APK with WAVESYNC_RELAY_OVERRIDE=$RELAY_ADDR" + echo "==> Building & installing APK with WAVESYNC_RELAY_OVERRIDE=$APK_RELAY_ADDR" ( cd "$HERE" - WAVESYNC_RELAY_OVERRIDE="$RELAY_ADDR" \ + WAVESYNC_RELAY_OVERRIDE="$APK_RELAY_ADDR" \ dx build --platform android --release 2>&1 \ | tee "$LOGDIR/dx-build.log" ) - # dx outputs the apk path; the standard Dioxus location: - APK="$(find "$HERE/target" -name '*.apk' -path '*release*' 2>/dev/null \ - | sort | tail -1)" + # dx writes the APK under the workspace target dir (CARGO_TARGET_DIR + # respected — see ~/.cargo/shared-target). The path is announced at + # the end of the build as "path=…/example-dioxus-fcm-sync/release/ + # android/app"; the actual APK is the Gradle output a few directories + # deeper. Even with --release, dx's Android Gradle build emits to the + # `debug/` output by default. + DX_OUT_BASE="${CARGO_TARGET_DIR:-$HOME/.cargo/shared-target}/dx/example-dioxus-fcm-sync/release/android/app" + APK="$(find "$DX_OUT_BASE" -name 'app-debug.apk' -o -name 'app-release*.apk' 2>/dev/null \ + | head -1)" if [[ -z "$APK" ]]; then - echo "ERROR: no APK produced; see $LOGDIR/dx-build.log" >&2 + echo "ERROR: no APK produced under $DX_OUT_BASE; see $LOGDIR/dx-build.log" >&2 exit 1 fi echo "APK: $APK" @@ -236,17 +285,30 @@ fi # ── 4. Maestro phase A — launch, add sentinel, killApp ───────────── -REMOTE_TITLE="from-cli-$(date +%s)-$$" +# Per-run unique titles. UUID-suffixing makes the assertions +# idempotent: residual rows from prior runs (the SQLite DB inside +# the app's data dir survives `clearState` in some Maestro/Dioxus +# combos) can't make the test trivially pass with an old row. +RUN_TAG="$(date +%s)-$$" +PHONE_SENTINEL="from-phone-$RUN_TAG" +REMOTE_TITLE="from-cli-$RUN_TAG" + echo "==> Maestro phase A (launch + sentinel + killApp)" -maestro --device "$ANDROID_SERIAL" test "$HERE/test.maestro.phase-a.yaml" \ +echo " phone sentinel: $PHONE_SENTINEL" +maestro --device "$ANDROID_SERIAL" test \ + --env "WAVESYNC_FCM_PHONE_SENTINEL=$PHONE_SENTINEL" \ + "$HERE/test.maestro.phase-a.yaml" \ | tee "$LOGDIR/maestro-a.log" # ── 5. Verify the writer received the sentinel (app→relay path alive) ─ -echo "==> Waiting for writer to see the sentinel from-phone-A..." +echo "==> Waiting for writer to see the sentinel '$PHONE_SENTINEL'..." +# The app generates a UUID for `id` on Add, so we can't lookup by +# the title-as-id. Query the full tasks list and grep by title. SENTINEL_OK=0 -for i in {1..30}; do - if curl -fs "http://127.0.0.1:$WRITER_HTTP_PORT/tasks/from-phone-A" >/dev/null 2>&1; then +for i in {1..60}; do + if curl -fs "http://127.0.0.1:$WRITER_HTTP_PORT/tasks" 2>/dev/null \ + | grep -q "\"title\":\"$PHONE_SENTINEL\""; then SENTINEL_OK=1 echo "sentinel reached writer after ${i}s" break @@ -277,10 +339,10 @@ sleep "$WAKE_WAIT" # ── 8. Maestro phase B — relaunch and assert the row is present ──── echo "==> Maestro phase B (relaunch + assert)" -WAVESYNC_FCM_REMOTE_TASK="$REMOTE_TITLE" \ - maestro --device "$ANDROID_SERIAL" test \ - --env "WAVESYNC_FCM_REMOTE_TASK=$REMOTE_TITLE" \ - "$HERE/test.maestro.phase-b.yaml" \ +maestro --device "$ANDROID_SERIAL" test \ + --env "WAVESYNC_FCM_REMOTE_TASK=$REMOTE_TITLE" \ + --env "WAVESYNC_FCM_PHONE_SENTINEL=$PHONE_SENTINEL" \ + "$HERE/test.maestro.phase-b.yaml" \ | tee "$LOGDIR/maestro-b.log" echo From 51af4edf7a75bba16f7d182cb6b85840beeac0a5 Mon Sep 17 00:00:00 2001 From: pvg13 Date: Tue, 12 May 2026 22:07:22 +0200 Subject: [PATCH 4/4] chore: remove internal-doc references from public-facing text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small scope-independent cleanups: - `.gitignore` line 31 comment claimed the `**/*.md` rule kept doc files tracked — it doesn't (the rule excludes everything including the project's working-tree instructions file, which is why we force-add TESTING.md). Replace with an accurate one-liner about force-adding tracked docs. - Two integration tests had module-level doc comments referencing an internal working-tree rule-numbering scheme. Inline the actual substance (mDNS is process-wide, parallel tests cross-discover) so the comment is self-contained for anyone reading the code. --- .gitignore | 2 +- wavesyncdb/tests/cached_peer_addrs.rs | 4 ++-- wavesyncdb/tests/diagnostics.rs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 3fca048..087acb4 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,7 @@ Cargo.lock # docker-compose; paste contents into Dokploy's Files tab, never commit. wavesync_relay/secrets/ -# Keep CLAUDE.md and doc files tracked — only ignore generated markdown +# Ignore all markdown by default; force-add docs you want tracked with `git add -f`. **/*.md **/*firebase*.json **/*google-services*.json diff --git a/wavesyncdb/tests/cached_peer_addrs.rs b/wavesyncdb/tests/cached_peer_addrs.rs index 090e35a..9dd5a04 100644 --- a/wavesyncdb/tests/cached_peer_addrs.rs +++ b/wavesyncdb/tests/cached_peer_addrs.rs @@ -13,8 +13,8 @@ //! 3. The new engine actually dialed a cached address at startup //! (via `predial_cached_addrs`). //! -//! Single-threaded per CLAUDE.md Rule 2.13: mDNS is process-wide and -//! parallel tests cross-discover. +//! Must run single-threaded: mDNS discovery is process-wide and parallel +//! tests cross-discover each other's peers, causing nondeterministic failures. //! //! Run with `cargo test -p wavesyncdb --test cached_peer_addrs -- --test-threads=1`. diff --git a/wavesyncdb/tests/diagnostics.rs b/wavesyncdb/tests/diagnostics.rs index 86b809e..3fab437 100644 --- a/wavesyncdb/tests/diagnostics.rs +++ b/wavesyncdb/tests/diagnostics.rs @@ -6,8 +6,8 @@ //! (timing-sensitive on CI runners) — it's to catch silent regressions //! where a future refactor accidentally bypasses the increment site. //! -//! Single-threaded per CLAUDE.md Rule 2.13: mDNS discovery is process-wide -//! and parallel tests cross-discover. +//! Must run single-threaded: mDNS discovery is process-wide and parallel +//! tests cross-discover each other's peers, causing nondeterministic failures. //! //! Run with `cargo test -p wavesyncdb --test diagnostics -- --test-threads=1`.