diff --git a/.gitignore b/.gitignore index 1c71386..087acb4 100644 --- a/.gitignore +++ b/.gitignore @@ -28,11 +28,14 @@ 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 **/.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/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. 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..c7688b4 --- /dev/null +++ b/examples/dioxus_fcm_sync/test.maestro.phase-a.yaml @@ -0,0 +1,46 @@ +# 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 app's WebView to render. h1 in src/main.rs is +# "WaveSyncDB Mobile Demo". +- extendedWaitUntil: + visible: "WaveSyncDB Mobile Demo" + timeout: 60000 + +# 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: ${WAVESYNC_FCM_PHONE_SENTINEL ?? 'from-phone-A'} +- tapOn: "Add" +- extendedWaitUntil: + visible: ${WAVESYNC_FCM_PHONE_SENTINEL ?? '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..6a716c1 --- /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: "WaveSyncDB Mobile Demo" + 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: ${WAVESYNC_FCM_PHONE_SENTINEL ?? '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..6027edc --- /dev/null +++ b/examples/dioxus_fcm_sync/test.sh @@ -0,0 +1,352 @@ +#!/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 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_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" + +# 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 (peer-id will be read from log)" +( + cd "$ROOT" + 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" +) + +# 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 +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) ──────────────────── + +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. 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 + 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=$APK_RELAY_ADDR" + ( + cd "$HERE" + WAVESYNC_RELAY_OVERRIDE="$APK_RELAY_ADDR" \ + dx build --platform android --release 2>&1 \ + | tee "$LOGDIR/dx-build.log" + ) + # 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 under $DX_OUT_BASE; 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 ───────────── + +# 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)" +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 '$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..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 + 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)" +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 +echo "============================================================" +echo " PASS — FCM cold-wake delivered '$REMOTE_TITLE' to the" +echo " killed app while the UI was dead." +echo "============================================================" 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`.