From b3f6445d7d8a60386328ad345e978bbe87773b4f Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Sat, 18 Jul 2026 20:32:37 -0500 Subject: [PATCH 1/5] test(e2e): make all contentious e2e resources configurable Slot Metro/Jet/emulator ports per platform, self-detect at runtime, and fully isolate Firebase emulator suites (including Firestore websocket, Eventarc, and Cloud Tasks). Also covers macOS e2e completion detection (stdio detach / kill hardening) that shares the Jet/host harness files. --- .../scripts/firebase.emulator.template.json | 26 ++ .gitignore | 2 + .../testing/change-authoring-workflow.md | 2 +- okf-bundle/testing/running-e2e.md | 208 ++++++++++---- package.json | 12 +- packages/ai/lib/requests/request.ts | 18 +- packages/app/__tests__/e2eHelpers.test.js | 170 +++++++++++ packages/app/e2e/config.e2e.js | 3 +- packages/app/e2e/helpers.js | 181 ++++++++++++ packages/auth/e2e/emailLink.e2e.js | 9 +- packages/auth/e2e/helpers.js | 28 +- packages/database/e2e/helpers.js | 8 +- packages/firestore/e2e/helpers.js | 11 +- packages/firestore/e2e/issues.e2e.js | 6 +- packages/functions/e2e/functions.e2e.js | 52 ++-- scripts/e2e/create-android-avds.sh | 53 ++++ scripts/e2e/create-ios-simulators.sh | 34 +++ scripts/e2e/start-emulator-slotted.sh | 87 ++++++ tests/.babelrc | 32 ++- tests/.detoxrc.js | 268 +++++++++++------- tests/.jetrc.js | 174 ++++++++++-- tests/app.js | 32 ++- tests/e2e/firebase.test.js | 167 +++++++++-- tests/e2e/jest.config.js | 2 +- tests/ios/testing/AppDelegate.mm | 11 +- .../io.invertase.testing-macOS/AppDelegate.mm | 11 +- tests/package.json | 2 +- 27 files changed, 1345 insertions(+), 264 deletions(-) create mode 100644 .github/workflows/scripts/firebase.emulator.template.json create mode 100644 packages/app/__tests__/e2eHelpers.test.js create mode 100755 scripts/e2e/create-android-avds.sh create mode 100755 scripts/e2e/create-ios-simulators.sh create mode 100755 scripts/e2e/start-emulator-slotted.sh diff --git a/.github/workflows/scripts/firebase.emulator.template.json b/.github/workflows/scripts/firebase.emulator.template.json new file mode 100644 index 0000000000..613f219da5 --- /dev/null +++ b/.github/workflows/scripts/firebase.emulator.template.json @@ -0,0 +1,26 @@ +{ + "name": "react-native-firebase-testing", + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "functions": { + "predeploy": ["cd functions && yarn", "cd functions && yarn --cwd \"$RESOURCE_DIR\" build"], + "source": "functions", + "ignore": [".yarn", "yarn.lock", "*.log", "node_modules"] + }, + "database": { + "rules": "database.rules" + }, + "storage": { + "rules": "storage.rules" + }, + "emulators": { + "auth": { "port": 9099 }, + "database": { "port": 9000 }, + "firestore": { "port": 8080 }, + "functions": { "port": 5001 }, + "storage": { "port": 9199 }, + "ui": { "enabled": false } + } +} diff --git a/.gitignore b/.gitignore index 365d97da79..df93ac0eaa 100644 --- a/.gitignore +++ b/.gitignore @@ -548,6 +548,8 @@ google-services.json GoogleService-Info.plist tests/harness.overrides.js +tests/e2e-slots.env.json +.github/workflows/scripts/.e2e-emulator-*.json # RNFB TurboModule codegen — misplaced iOS provider stubs under Android output paths # (NewArch-AD-5: okf-bundle/new-architecture/architecture-decisions.md — commit generated diff --git a/okf-bundle/testing/change-authoring-workflow.md b/okf-bundle/testing/change-authoring-workflow.md index 622cd1e9d0..8ce397b4f6 100644 --- a/okf-bundle/testing/change-authoring-workflow.md +++ b/okf-bundle/testing/change-authoring-workflow.md @@ -176,7 +176,7 @@ Keep **`implementation`** and **`independent-review`** in separate passes. E2e e On a shared dev host during change authoring: -- One `:test-cover` at a time — never overlap **unit-focused**-tier and **area-focused**-tier runs. +- One unslotted `:test-cover` at a time — never overlap **unit-focused**-tier and **area-focused**-tier runs. Slotted cross-platform concurrency (distinct worktrees/ports/devices): [running e2e § parallel topology](running-e2e.md#parallel-e2e-topology). - Every run starts from [running e2e § pre-flight](running-e2e.md#pre-flight-is-the-host-clear-to-start) (host-clear probes, services, harness tier). - Use only [canonical e2e commands](running-e2e.md#rules). Stalled runs → [stalled run detection](running-e2e.md#stalled-run-detection). diff --git a/okf-bundle/testing/running-e2e.md b/okf-bundle/testing/running-e2e.md index 1bad3cc837..fb7eb84793 100644 --- a/okf-bundle/testing/running-e2e.md +++ b/okf-bundle/testing/running-e2e.md @@ -59,13 +59,13 @@ Clean `:build` + `:test-cover` each time — not reuse variants. 5. **Report locations** — [Coverage design](coverage-design.md). -6. **One e2e at a time** — never overlap `:test-cover` runs on one host. All platforms share Metro `:8081` and the test-runner WebSocket port (default **8090**); parallel runs race on coverage/device/emulator state. Every run starts after [clean pre-flight](#pre-flight-is-the-host-clear-to-start). Log triage for port/orchestration markers: [test-runner host orchestration](#test-runner-host-orchestration-log-triage-only). +6. **One e2e at a time (default)** — never overlap `:test-cover` runs on one host unless each run uses a distinct port/device slot via [configurable e2e environment](#configurable-e2e-environment). Serial runs share Metro `:8081` and the test-runner WebSocket port (default **8090**); parallel runs race on coverage/device/emulator state without slotted env. Every run starts after [clean pre-flight](#pre-flight-is-the-host-clear-to-start). Log triage for port/orchestration markers: [test-runner host orchestration](#test-runner-host-orchestration-log-triage-only). 7. **No source edits during e2e** — wait/cancel cleanly before editing `packages/**`, `tests/**`, or bundle-affecting OKF docs. Saves can hot reload/rebundle and invalidate tests/coverage. ## Serialized e2e loops (shared dev host) -Use [validation tiers](#e2e-validation-tiers-unit-focused-area-focused-full): **unit-focused**, **area-focused**, **full**. Match tier to [work type](change-authoring-workflow.md#work-types). Runs are serial from clean [pre-flight](#pre-flight-is-the-host-clear-to-start). Log long output; upstream gets exit code + short summary. +Use [validation tiers](#e2e-validation-tiers-unit-focused-area-focused-full): **unit-focused**, **area-focused**, **full**. Match tier to [work type](change-authoring-workflow.md#work-types). **Serial default:** one unslotted `:test-cover` from clean [pre-flight](#pre-flight-is-the-host-clear-to-start). Slotted cross-platform concurrency: [parallel e2e topology](#parallel-e2e-topology). Log long output; upstream gets exit code + short summary. **Policy:** [OKF documentation and commit policy](../documentation-policy.md). **Terms:** [iteration vocabulary](iteration-vocabulary.md). @@ -117,7 +117,7 @@ GitHub Actions **Testing E2E iOS** adds CI-only steps local `:test-cover` does n ### Running one iteration 1. [Pre-flight](#pre-flight-is-the-host-clear-to-start); if [host-clear probes](#host-clear-probes) fail, [pre-flight recovery](#pre-flight-recovery) first. -2. One foreground Shell command; set `block_until_ms` large enough (~15m macOS, ~45–60m iOS/Android). Do **not** background/poll. +2. One foreground Shell command; set `block_until_ms` large enough (~15m macOS, ~45–60m iOS/Android). Do **not** background/poll. If the Shell tool returns no exit status under default sandbox permissions, see [agent command policy § Shell sandbox / permissions](agent-command-policy.md#shell-sandbox-permissions) before retrying or concluding failure. 3. From repo root, tee canonical command: ```bash @@ -128,18 +128,20 @@ yarn tests:macos:test-cover 2>&1 | tee /tmp/rnfb-e2e-macos.log Use `/tmp/rnfb-e2e-.log` (overwrite each iteration). Do not substitute other entrypoints — see [agent rule](#agent-rule-read-first). -4. Completion = shell exit code. `0` finished; non-zero failed/aborted. Read log for counts. -5. Parse log tail; do not infer from processes: +4. Completion — **prefer shell exit code** when the Shell tool returns one: `0` finished; non-zero failed/aborted. If the Shell tool is **aborted, interrupted, or returns no exit status**, do **not** conclude failure or incomplete from that alone — check the tee log footer for [done markers](#stalled-run-detection) before deciding; see [interrupted run](#interrupted-run-abort-killed-terminal-eaddrinuse-on-8090). +5. Parse log tail with **anchored** patterns; do not infer from processes. Do **not** use bare `rg 'passing|failing'` — it matches mid-suite test titles too (e.g. `accepts passing in…`), not just the Jest summary line: ```bash -rg 'passing|failing' /tmp/rnfb-e2e-.log | tail -1 -rg '^\s+\d+\)' /tmp/rnfb-e2e-.log # failure blocks, if any -rg 'Tests Complete|jet-coverage.*merged' /tmp/rnfb-e2e-.log | tail -3 +rg '^\s*\d+ (passing|failing)' /tmp/rnfb-e2e-.log | tail -2 +rg '^\s+\d+\)' /tmp/rnfb-e2e-.log # failure blocks, if any +rg 'jet-coverage.*merged .* before NYC' /tmp/rnfb-e2e-.log | tail -1 +rg 'Tests Complete' /tmp/rnfb-e2e-.log | tail -1 # optional, see below ``` -Markers: `✨ Tests Complete ✨`, Jest `N passing` / `N failing`, `[jet-coverage] merged … before NYC shutdown`, `[rnfb-e2e] orchestrate-state=`, `[jet-control] launch-ready received`. +**Done footers** (either is sufficient; both together is strongest): Jest summary `N passing` / `N failing`, **and** — on the coverage path — `[jet-coverage] merged … before NYC shutdown`. `✨ Tests Complete ✨` is **optional** — it is not always emitted on local macOS runs; never require it alone as the done signal. Other markers: `[rnfb-e2e] orchestrate-state=`, `[jet-control] launch-ready received`. -6. Return only platform, exit code, pass/fail line, failing tests, log path, optional coverage-gap line. No full log upstream. +6. Return only platform, exit code (or `unknown (tool aborted); log footer green|red` when the Shell tool gave no exit status — [interrupted run](#interrupted-run-abort-killed-terminal-eaddrinuse-on-8090)), pass/fail line, failing tests, log path, optional coverage-gap line. No full log upstream. +7. **Waiting on output (optional):** if using output-match notifications while a foreground `:test-cover` runs, never pattern bare `passing`. Use an anchored pattern such as `^\s*\d+ (passing|failing)` or `jet-coverage.*merged .* before NYC` instead. Prefer relying on the Shell tool's own exit code when it returns normally over any output-match notification. ### Pre-flight: is the host clear to start? @@ -165,11 +167,11 @@ Owner for install/prepare serialization: [agent command policy § prepare must f No in-flight test run on the target platform: -| Platform | Clear when | -| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Android** | [Android app reset](#android-app-reset-blocking) + [host-clear probes](#host-clear-probes) pass | -| **iOS** | [Host-clear probes](#host-clear-probes) pass — **zero booted simulators** and no stray listener on `:8090`. Detox boots `iPhone 17` from `tests/.detoxrc.js`; do not pre-boot or leave simulators running. | -| **macOS** | [Host-clear probes](#host-clear-probes) pass (no `io.invertase.testing` process) | +| Platform | Clear when | +|----------|------------| +| **Android** | [Android app reset](#android-app-reset-blocking) + `bash scripts/e2e/check-e2e-resources.sh --platform=android` pass | +| **iOS** | `bash scripts/e2e/check-e2e-resources.sh --platform=ios` passes — **zero booted simulators** and no stray listener on `:8090`. Detox boots `iPhone 17` from `tests/.detoxrc.js`; do not pre-boot or leave simulators running. Plain [host-clear probes](#host-clear-probes) without `--platform=ios` intentionally do **not** fail on an unrelated booted simulator — see [global device scoping](#global-device-scoping). | +| **macOS** | [Host-clear probes](#host-clear-probes) pass (no `io.invertase.testing` process) | Also wait for any visible unfinished `yarn tests:*:test-cover`. @@ -178,70 +180,87 @@ Also wait for any visible unfinished `yarn tests:*:test-cover`. **Android app reset (blocking)** — run **before every** Android `:test-cover`, not only after a failed run: ```bash -adb -s emulator-5554 shell am force-stop com.invertase.testing -adb -s emulator-5554 shell am force-stop com.invertase.testing.test +ANDROID_SERIAL="${ANDROID_SERIAL:-emulator-5554}" +adb -s "$ANDROID_SERIAL" shell am force-stop com.invertase.testing +adb -s "$ANDROID_SERIAL" shell am force-stop com.invertase.testing.test +# Or via generic release (also clears Jet/Metro per env): +# bash scripts/e2e/release-e2e-resources.sh --only android-apps,jet ``` The main app (`com.invertase.testing`) can sit on **“waiting for jet to start tests…”** while the [host-clear probe](#host-clear-probes) still passes (it only checks `com.invertase.testing.test`). A stale main app then connects as a **second Jet client** after Detox launches a fresh run → `Received a message from the client, but server wasn't running` and no Mocha summary. Force-stop **both** packages, then re-run the probe. -On **darwin** hosts, also clear the macOS test app before Android `:test-cover` — macOS and Android share Jet **`:8090`**. Alternating macOS and Android runs without killing `io.invertase.testing` leaves a stale macOS Jet client that connects when Android starts → duplicate clients and `server wasn't running`. +On **darwin** hosts, also clear the macOS test app before Android `:test-cover` when they share a Jet port (serial default `:8090`, or the same `JET_REMOTE_PORT` / platform Jet in slotted runs). Alternating macOS and Android without killing `io.invertase.testing` leaves a stale macOS Jet client → duplicate clients and `server wasn't running`. ```bash ! pgrep -x io.invertase.testing >/dev/null 2>&1 +# or: bash scripts/e2e/release-e2e-resources.sh --only macos-app,jet ``` (`firebase.test.js` runs this automatically via `ensureAndroidJetHostClear` before spawning Android Jet; use the probe manually when prepping from the runbook.) -**Host-clear probes** — run after [Android app reset](#android-app-reset-blocking) (Android) or directly (iOS/macOS); **exit 0 = clear** (chain with `&&`): +**Host-clear probes** — prefer the generic scripts (env-aware, mellifera-agnostic). They resolve [configurable e2e environment](#configurable-e2e-environment) vars first, then fall back to serial defaults (`8090`, `8081`, `emulator-5554`, …). **Exit 0 = clear.** + +`check-e2e-resources.sh` **default mode reports Jet WebSocket + apps + simulators only** — it does **not** fail solely on Metro `:8081` or the Firebase emulator ports being open, because those are expected to be up already ([step 2, services ready](#2-services-ready), the opposite check). Pass `--services` (alias `--strict`) to additionally treat Metro/emulator ports as BUSY. `--platform=android|ios|macos` scopes device probes to one platform (otherwise an ambiguous serial "global" fallback is used — it does not treat android+ios+macos as all simultaneously active; see [global device scoping](#global-device-scoping)). `tests/mellifera.env.json` is only consulted with `--mellifera` or `RNFB_MELLIFERA=1` — see [mellifera JSON scoping](#mellifera-json-scoping). ```bash -# iOS — booted-device count must be 0 -test "$(xcrun simctl list devices booted | grep -c '(Booted)' || true)" -eq 0 -test -z "$(lsof -nP -iTCP:8090 -sTCP:LISTEN -t 2>/dev/null || true)" +# Host-clear (default): Jet + apps + sims only — Metro/emulator ports are informational. +bash scripts/e2e/check-e2e-resources.sh + +# Services mode: also flag Metro/emulator ports as BUSY (single all-in-one probe). +bash scripts/e2e/check-e2e-resources.sh --services + +# Scope device probes to one platform instead of the ambiguous serial "global" fallback. +bash scripts/e2e/check-e2e-resources.sh --platform=ios + +# Soft then forceful clear (ports + apps, full wipe by default incl. Metro/emulators). +# Add --devices to also stop AVD / shutdown sims. Add --only to limit scope. +bash scripts/e2e/release-e2e-resources.sh +# bash scripts/e2e/release-e2e-resources.sh --only jet,android-apps # jet also releases jet-control +# bash scripts/e2e/release-e2e-resources.sh --devices +``` -# Android -! adb -s emulator-5554 shell pidof com.invertase.testing.test >/dev/null 2>&1 +Manual one-liners (defaults only — use when debugging without the scripts): -# macOS +```bash +JET_PORT="${JET_REMOTE_PORT:-${RNFB_ANDROID_JET_PORT:-${RNFB_IOS_JET_PORT:-${RNFB_MACOS_JET_PORT:-8090}}}}" +test -z "$(lsof -nP -iTCP:${JET_PORT} -sTCP:LISTEN -t 2>/dev/null || true)" +ANDROID_SERIAL="${ANDROID_SERIAL:-emulator-5554}" +! adb -s "$ANDROID_SERIAL" shell pidof com.invertase.testing.test >/dev/null 2>&1 ! pgrep -x io.invertase.testing >/dev/null 2>&1 ``` -**Pre-flight recovery** — when probes fail **after** [Android app reset](#android-app-reset-blocking), abort, kill, or `EADDRINUSE` on `:8090`. Before any new `:test-cover`, kill the stray **8090** listener, re-run force-stop (Android) or the iOS/macOS steps below, then [host-clear probes](#host-clear-probes). +**Pre-flight recovery** — when probes fail **after** [Android app reset](#android-app-reset-blocking), abort, kill, or `EADDRINUSE` on the Jet port. Canonical recovery: ```bash -# Android — force-stop both apps, then clear the Jet WS listener -adb -s emulator-5554 shell am force-stop com.invertase.testing -adb -s emulator-5554 shell am force-stop com.invertase.testing.test -lsof -nP -iTCP:8090 -sTCP:LISTEN -t | xargs kill 2>/dev/null || true - -# iOS — Detox re-boots iPhone 17 after shutdown booted -lsof -nP -iTCP:8090 -sTCP:LISTEN -t | xargs kill 2>/dev/null || true -pkill -f 'detox test --configuration ios' 2>/dev/null || true -pkill -f 'jet.js --target=ios' 2>/dev/null || true -xcrun simctl shutdown booted +bash scripts/e2e/release-e2e-resources.sh +# If AVD/sim must go down too: +bash scripts/e2e/release-e2e-resources.sh --devices +bash scripts/e2e/check-e2e-resources.sh # must exit 0 ``` -After Android recovery, verify `pidof com.invertase.testing.test` is empty and `:8090` is closed before rerunning `:test-cover`. - Do **not** use `boot-simulator.sh` or `simctl shutdown all` as routine prep ([what not to do](#what-not-to-do)). #### 2. Services ready -Metro and emulators must be **running and responsive** — do not assume from a prior session or background start. +Metro and emulators must be **running and responsive** — do not assume from a prior session or background start. This is the **opposite** of [host-clear](#host-clear-probes) (clear = nothing listening; ready = packager/emulators up). ```bash -curl -sf http://127.0.0.1:8081/status >/dev/null # Metro (127.0.0.1 matches test app bundle URL) -curl -sf http://127.0.0.1:8080 >/dev/null # Firestore emulator +METRO_PORT="${RCT_METRO_PORT:-${RNFB_METRO_PORT:-8081}}" +FIRESTORE_PORT="${RNFB_ANDROID_EMULATOR_FIRESTORE_PORT:-${RNFB_IOS_EMULATOR_FIRESTORE_PORT:-${RNFB_MACOS_EMULATOR_FIRESTORE_PORT:-8080}}}" +curl -sf "http://127.0.0.1:${METRO_PORT}/status" >/dev/null +curl -sf "http://127.0.0.1:${FIRESTORE_PORT}" >/dev/null test -n "$(lsof -nP -iTCP:5001 -sTCP:LISTEN -t 2>/dev/null || true)" # Functions emulator — listener only ``` If Metro or Firestore checks fail: start `yarn tests:packager:jet` and `yarn tests:emulator:start` (background); re-check until both pass. After **`yarn lerna:prepare` has finished** (step [0](#prepare-completion-gate-blocking)) or test-runner patch edits, restart the packager with `yarn tests:packager:jet-reset-cache` ([Rules §1](#rules)) — never restart Metro while prepare is still running. -A listener on `:8081`, `:8080`, or `:5001` alone is **not** sufficient for Metro/Firestore — their HTTP checks must succeed. **Functions (`:5001`):** verify the listener is up; `curl -sf http://127.0.0.1:5001/` exits non-zero because the root path returns **404** — that is expected and **not** a service failure (do not treat it like the Metro/Firestore gates). +Slotted mellifera runs use `scripts/e2e/mellifera-preflight.sh` to wait on the reserved Metro/emulator ports from `tests/mellifera.env.json` (services-up gate, not host-clear). + +A listener on the Metro/Firestore ports (or `:5001`) alone is **not** sufficient for Metro/Firestore — their HTTP checks must succeed. **Functions (`:5001`):** verify the listener is up; `curl -sf http://127.0.0.1:5001/` exits non-zero because the root path returns **404** — that is expected and **not** a service failure (do not treat it like the Metro/Firestore gates). #### 3. Harness matches validation tier @@ -261,12 +280,14 @@ See [Harness narrowing gate (blocking)](#harness-narrowing-gate-blocking) — a Completion = shell exit code + log markers — not open-ended log tailing. -| Platform | Early markers (≈2–3 min) | Done | -| --------------- | ----------------------------------------- | ---------------------------------------- | -| **macOS** | `Jet client connected` | `✨ Tests Complete ✨`, Jest `N passing` | -| **iOS/Android** | Detox launch done, `Jet client connected` | Same | +| Platform | Early markers (≈2–3 min) | Done | +|----------|--------------------------|------| +| **macOS** | `Jet client connected` | Jest `N passing` / `N failing`, preferably **and** `[jet-coverage] merged … before NYC shutdown` | +| **iOS/Android** | Detox launch done, `Jet client connected` | Same | + +`✨ Tests Complete ✨` is **optional** if present — it is not always emitted on local macOS runs; treat it as a bonus signal, never a required one. -**If stalled** — no new markers for **5 minutes**, or past tier budget (~15m macOS, ~45–60m iOS/Android) without `Tests Complete`: treat as [interrupted run](#interrupted-run-abort-killed-terminal-eaddrinuse-on-8090). Run [pre-flight recovery](#pre-flight-recovery), confirm [host-clear probes](#host-clear-probes) and [services ready](#2-services-ready), retry. Do not keep watching flat tee output. +**If stalled** — no new markers for **5 minutes**, or past tier budget (~15m macOS, ~45–60m iOS/Android) without a Jest summary (`N passing`/`N failing`) or `[jet-coverage] merged … before NYC shutdown`: treat as [interrupted run](#interrupted-run-abort-killed-terminal-eaddrinuse-on-8090). Do not gate the stall decision on emoji `Tests Complete` alone. Run [pre-flight recovery](#pre-flight-recovery), confirm [host-clear probes](#host-clear-probes) and [services ready](#2-services-ready), retry. Do not keep watching flat tee output. - macOS bundle/Metro hangs → [ci-workflows/other.md § bundle load hang](../ci-workflows/other.md#ci-failure-bundle-load-hang--could-not-connect-to-development-server) - iOS Metro at launch → [ci-workflows/ios.md § Metro unresponsive](../ci-workflows/ios.md) @@ -342,14 +363,14 @@ For `implementation` work type — validation tier **unit-focused** ([change aut ### Serialized e2e dispatch -Never overlap runs that use `:test-cover`. See [host rule](change-authoring-workflow.md#host-rule). +**Serial default** for gate closure and unslotted hosts — see [host rule](change-authoring-workflow.md#host-rule). Slotted exception: [parallel e2e topology](#parallel-e2e-topology) + [configurable e2e environment](#configurable-e2e-environment). | Rule | Requirement | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **One e2e run at a time** | Wait for prior shell exit code + short log summary | +| **One unslotted e2e at a time** | Wait for prior shell exit code + short log summary (unless each run has distinct slotted ports/devices per [parallel e2e topology](#parallel-e2e-topology)) | | **No overlapping tiers** | Never run unit-focused-tier and area-focused-tier `:test-cover` concurrently on one host | | **Clean pre-flight every run** | [Pre-flight](#pre-flight-is-the-host-clear-to-start) — [host-clear probes](#host-clear-probes), services, harness tier | -| **Phase J loop** | `implementation` (Jest + **unit-focused**) → `independent-review` (**area-focused**, frozen tree) → `commit` — [work queue protocol](../packages/firestore/pipeline-coverage-work-queue.md#phase-j-iteration-protocol-strict) | +| **Authoring loop** | `implementation` (Jest + **unit-focused**) → `independent-review` (**area-focused**, frozen tree) → `commit` — [change authoring](change-authoring-workflow.md) | Tier scope table: [E2e validation tiers](#e2e-validation-tiers-unit-focused-area-focused-full). @@ -357,7 +378,23 @@ Each run owns its blocking `:test-cover` and returns summaries only. ### Interrupted run (abort, killed terminal, EADDRINUSE on :8090) -Run [pre-flight recovery](#pre-flight-recovery), confirm [host-clear probes](#host-clear-probes) pass, then rerun from repo root: `yarn tests::build && yarn tests::test-cover` (foreground; tee if logging). Keep one `:test-cover` active at a time on a host. +**Check the log before recovering.** After a STOP/abort or a Shell tool interruption that leaves the exit status unknown, read the `/tmp/rnfb-e2e-.log` footer **first** — before `pkill`, [pre-flight recovery](#pre-flight-recovery), or any other kill/recovery step: + +```bash +rg '^\s*\d+ (passing|failing)' /tmp/rnfb-e2e-.log | tail -2 +rg 'jet-coverage.*merged .* before NYC' /tmp/rnfb-e2e-.log | tail -1 +``` + +- **Done footers present** (Jest `N passing`/`N failing`, preferably with `[jet-coverage] merged … before NYC shutdown`) → treat the run as **complete**. Record pass/fail from the log; report exit as `unknown (tool aborted); log footer green|red` — do not conclude failure or incomplete just because the Shell tool gave no exit status. +- **Log shows incomplete or stalled** (no done footers, matches [stalled run detection](#stalled-run-detection)) → only then run [pre-flight recovery](#pre-flight-recovery), confirm [host-clear probes](#host-clear-probes) pass, then rerun from repo root: `yarn tests::build && yarn tests::test-cover` (foreground; tee if logging). Keep one `:test-cover` active at a time on a host. + + + +#### macOS: done footer green but `tee`/shell never returns + +**Failure mode (fixed):** the Jest summary and `[jet-coverage] merged … before NYC shutdown` footer print, but the foreground `| tee /tmp/rnfb-e2e-macos*.log` shell never exits — Agent Shell looks idle/hung forever. Root cause was `tests/.jetrc.js` spawning `io.invertase.testing` with `stdio: ['ignore', 'inherit', 'inherit']`: the app inherited the write end of the tee pipe, so the pipe never saw EOF even after the suite finished. Fixed by detaching macOS app stdio (`stdio: ['ignore', 'ignore', 'ignore']`) and hardening `killMacOsTestApp()` (soft kill → wait → `killall -9` → verify) in `.jetrc.js`. + +If this recurs: `pgrep -x io.invertase.testing` — if still alive after the done footer, kill it (`killall -9 io.invertase.testing`) so the tee pipe can close; the anchored log footer ([done footers](#stalled-run-detection)) is still the authoritative completion signal, not the shell exit code alone. The recommended tee command (`yarn tests:macos:test-cover 2>&1 | tee /tmp/rnfb-e2e-macos.log` — [running one iteration](#running-one-iteration)) is unchanged by this fix. ### What not to do @@ -366,7 +403,7 @@ Run [pre-flight recovery](#pre-flight-recovery), confirm [host-clear probes](#ho - Do not background `:test-cover` and poll `pgrep`, `detox`, or process names for completion. - Do not use `:test-cover-reuse`, `:test-cover-and-process`, or `:test-reuse` when measuring coverage or closing review gates. - Do not use `:8090` listening as “e2e still running” without the platform active signal above. -- Do not start iOS/Android/macOS `:test-cover` concurrently on one host. +- Do not start unslotted iOS/Android/macOS `:test-cover` concurrently on one host (same Metro/Jet/emulator defaults). Slotted cross-platform concurrency only per [parallel e2e topology](#parallel-e2e-topology). - Do not edit source while a tee'd run is still in progress. - Do not passively tail tee output when progress markers stop — follow [stalled run detection](#stalled-run-detection). - Do not run **full** harness (`require.context`, all modules) for **unit-focused**/**area-focused** tier — match [harness to tier](#3-harness-matches-validation-tier). @@ -491,7 +528,7 @@ All tiers use [canonical commands](#rules), [host rule](change-authoring-workflo **Universal rules:** -- E2e is **always serial** — one `:test-cover` at a time on the host. +- **Serial default** for gate closure — one unslotted `:test-cover` at a time. Slotted cross-platform concurrency only per [parallel e2e topology](#parallel-e2e-topology) (still never two of the **same** platform from one worktree). - Every run starts from **verified [pre-flight](#pre-flight-is-the-host-clear-to-start)**; if probes fail, [pre-flight recovery](#pre-flight-recovery) before another run. - Use **only** canonical commands from this doc. - Never overlap unit-focused-tier and area-focused-tier `:test-cover` on one host. @@ -500,11 +537,72 @@ See also: [unit-focused-tier loop](#unit-focused-tier-iteration-loop), [dispatch ## Environment -- **Devices** — Detox boots simulator/emulator (`iPhone 17` on iOS, `TestingAVD` on Android); [host-clear probes](#host-clear-probes) require zero booted iOS simulators before `:test-cover`. macOS auto-starts app. +- **Devices** — Detox boots simulator/emulator (`iPhone 17` on iOS, `TestingAVD` on Android); iOS `:test-cover` should preflight with `check-e2e-resources.sh --platform=ios` (or a mellifera reservation) for the stricter zero-booted-simulators check — see [global device scoping](#global-device-scoping). macOS auto-starts app. - **adb empty** — `adb kill-server && adb start-server && adb devices` -- **Stale processes** — one Metro (`:8081`), one emulator set (`:8080`, `:9099`, `:9000`, `:4400`, `:5001`, …). Stray listener on `:8090` after a run → [pre-flight recovery](#pre-flight-recovery), then restart background services with [Rules §1–2](#rules) (`yarn tests:packager:jet`, `yarn tests:emulator:start`). +- **Stale processes (serial default)** — one Metro (`:8081`), one emulator set (`:8080`, `:9099`, `:9000`, `:4400`, `:5001`, …). Stray listener on `:8090` after a run → [pre-flight recovery](#pre-flight-recovery), then restart background services with [Rules §1–2](#rules) (`yarn tests:packager:jet`, `yarn tests:emulator:start`). Slotted runs: [configurable e2e environment](#configurable-e2e-environment). - **Android Gradle home** — when Android `:build` or `:test-cover` fails with missing/wrong Gradle cache on a host that does not default to `~/.gradle`, export `GRADLE_USER_HOME=$HOME/.gradle` before the run. + + +### Configurable e2e environment + +Serial e2e uses committed defaults (Metro `:8081`, Jet `:8090`, emulators `:8080` / `:9099` / …). Slotted parallel runs export **per-platform prefixed** ports (e.g. `RNFB_ANDROID_JET_PORT`, `RNFB_IOS_METRO_PORT`) **before** Metro/native build and `:test-cover`. Unset vars keep legacy serial behaviour. + + + +**Parallel e2e topology (worktrees):** same-platform parallel needs **one git worktree per concurrent instance** of that platform. A single worktree may run **at most** `1× android ∥ 1× ios ∥ 1× macos` (distinct Metro/Jet/emulator blocks + distinct AVD/sim). Example: `3× android + 3× ios + 1× macos` ⇒ three worktrees (each: android+ios; one also runs macos). Do **not** launch two androids (or two ioses) from one worktree — native build products, Detox configs, and coverage paths are not multi-instance-safe inside one tree. + +**Parallel / multi-platform carry-in (proven model):** when android + ios (+ macos) share a worktree, every Metro/Jest/Detox process for a slot must receive **the full set** of `RNFB_{ANDROID,IOS,MACOS}_*` port variables for that slot — not only the active platform’s block. Runtime selection uses platform self-detection (`Platform.*` in [`packages/app/e2e/helpers.js`](../../packages/app/e2e/helpers.js); Detox `device.getPlatform()` / configuration name on the host in [`tests/e2e/firebase.test.js`](../../tests/e2e/firebase.test.js)). Do **not** use `RNFB_E2E_PLATFORM` to choose ports: `tests/.babelrc` inlines static `process.env.NAME` via `transform-inline-environment-variables`, and concurrent transforms in one worktree must see every labeled port present so each `process.env.RNFB_ANDROID_*` / `RNFB_IOS_*` / `RNFB_MACOS_*` literal bakes correctly. Computed keys (`process.env[\`RNFB_${x}_…\`]`) are **not** inlined — in-app code must use static member expressions (or helpers that do). Process-local listen/bind vars (`RCT_METRO_PORT`, `JET_REMOTE_PORT`, Detox config) still identify which socket/config **this** process owns. Host Jet config ([`tests/.jetrc.js`](../../tests/.jetrc.js)) should prefer those process-local binds (and per-target `before()` hooks with an explicit platform key) — not `RNFB_E2E_PLATFORM`. + +In-app / e2e specs must call `getE2eEmulatorPort('firestore'|…)` (and siblings) — never hardcode `:8080` / `:5001` / other serial emulator ports. + +**Slotted Firebase emulator suites (full isolation):** each platform×slot suite needs its **own** Firebase Tools process with **non-overlapping** ports for every listener the suite actually binds. [`scripts/e2e/start-emulator-slotted.sh`](../../scripts/e2e/start-emulator-slotted.sh) assigns auth/database/firestore/functions/storage/hub/logging **and** Firestore `websocketPort`, Eventarc, and Cloud Tasks (derived as `firestore+8/+9/+12` inside the platform block). Defaults `9150` / `9299` / `9499` collide across suites: Firebase Tools still starts Eventarc+Tasks as Functions dependencies even when `--only` omits them; `EADDRINUSE` on those aux ports aborts the suite and leaves Functions dead while Firestore may still listen — e2e then hangs on callables. Parallel readiness must require the **Functions** port up (not only hub). Serialize `scripts/functions` `yarn`/`yarn build` across concurrent suite starts (shared source dir). + +**macOS concurrency today:** host-global. Orchestration (`pgrep -x` / `killall`, check/release, mellifera `platform:macos:global`) keys on process name **`io.invertase.testing`** (`PRODUCT_NAME`). That is the hard singleton — not Firebase cloud registration. macOS e2e uses the JS/Other path (no `GoogleService-Info` in the macOS target; JS config follows the android test app id in `tests/globals.js`). Per-worktree / multi-slot macOS would require slotting **`PRODUCT_NAME`** (and spawn/kill/Metro `app=` plumbing); see [macOS process identity](#macos-process-identity-concurrency). + +| Variable | Purpose | +|----------|---------| +| `RCT_METRO_PORT`, `RNFB_METRO_PORT` | Metro bundler **listen** port for this process (global fallback; not the in-app selector when prefixed vars are set) | +| `RNFB_{ANDROID,IOS,MACOS}_METRO_PORT` | Per-platform Metro port (in-app / host selection via self-detection) | +| `JET_REMOTE_PORT`, `JET_METRO_PORT` | Process-local Jet / Metro hints (global fallback) | +| `RNFB_{ANDROID,IOS,MACOS}_JET_PORT` | Per-platform Jet WebSocket port | +| `RNFB_{ANDROID,IOS,MACOS}_JET_CONTROL_PORT` | Per-platform Jet HTTP control (preferred); `RNFB_JET_CONTROL_PORT` remains a process-local fallback | +| `RNFB_JET_CONTROL_PORT` | Process-local Jet HTTP control plane fallback (default `JET_REMOTE_PORT + 1`) | +| `RNFB_{ANDROID,IOS,MACOS}_EMULATOR_{FIRESTORE,AUTH,DATABASE,FUNCTIONS,STORAGE,HUB,LOGGING}_PORT` | Per-platform Firebase emulator suite (in-app + host). Slotted launcher also derives Firestore `websocketPort` / Eventarc / Tasks from the firestore port — not separate env vars today | +| `RNFB_DETOX_ANDROID_CONFIG`, `RNFB_DETOX_IOS_CONFIG` | Detox configuration name (e.g. `android.emu.debug.slot1`, `ios.sim.debug.slot1`) | +| `RNFB_E2E_SLOT` | Slot index for orchestration / AVD / sim naming | +| `RNFB_E2E_PLATFORM` | Optional orchestration label only — **not** used for port selection (prefer unset in slotted multi-platform launches) | +| `RNFB_ANDROID_AVD`, `RNFB_IOS_SIMULATOR`, `RNFB_ANDROID_EMULATOR_BOOT_ARGS` | Device selection overrides | +| `ORG_GRADLE_PROJECT_reactNativeDevServerPort` | Android Gradle Metro port baked into the APK's `react_native_dev_server_port` resource at build time. **Set automatically** by `yarn tests:android:build` (`RNFB_ANDROID_METRO_PORT` → `RCT_METRO_PORT` → `RNFB_METRO_PORT` → `JET_METRO_PORT` → `8081`) — only export it yourself when building Android outside that script (e.g. `detox build` invoked directly). Detox's `reversePorts` (`tests/.detoxrc.js`) already forwards the same slotted Metro port; this var makes the APK actually *ask* for that port. | +| `SIMCTL_CHILD_RCT_METRO_PORT` | iOS simulator child Metro port | +| `RNFB_E2E_DEBUG` | Verbose `[rnfb-e2e]` port resolution logging in app helpers | +| `RNFB_MELLIFERA` | Set to `1` to opt in to reading `tests/mellifera.env.json` in `check-e2e-resources.sh` / `release-e2e-resources.sh` (`--mellifera` CLI flag also works). **Unset by default** — a stale/leftover JSON from a previous mellifera session must not silently switch a plain serial check into multi-platform mode; the scripts warn on stderr when the file exists but this isn't set. | + +See [host-clear probes](#host-clear-probes) for the canonical `check-e2e-resources.sh` / `release-e2e-resources.sh` commands (same scripts; slotted env applies when exported or via mellifera JSON). + + + + +**Global device scoping** — with no `--platform`, no `RNFB_E2E_PLATFORM`, and no per-platform port env set, both scripts fall back to an ambiguous serial `global` mode. `global` probes android app state (specific package on the default serial) and the macOS app process (specific process name) unconditionally — those are precise, false-positive-safe checks — but it does **not** escalate "any booted iOS simulator" to BUSY in that ambiguous mode, since an unrelated simulator left open for other work would otherwise fail every host-clear check. Pass `--platform=ios` (or set `RNFB_E2E_PLATFORM=ios`) when iOS is actually the platform about to run, to get the stricter "zero booted simulators" behaviour documented under [host clear](#1-host-clear). + +With a mellifera reservation (`RNFB_MELLIFERA=1` or `--mellifera`), `mellifera-apply-reservation.js` writes `tests/mellifera.env.json` + platform env files; check/release then read that file (or exported `RNFB_*` vars) so slotted ports clear correctly. `mellifera-teardown.sh` / `mellifera-host-clean.sh` / `mellifera-release-resources.sh` call these generics, then handle mellifera lease APIs. + +Helper scripts (not canonical `:test-cover` entrypoints): `scripts/e2e/start-emulator-slotted.sh`, `yarn tests:e2e:setup-android-avds`, `yarn tests:e2e:setup-ios-sims`. + + + +#### macOS process identity (concurrency) + +| Surface | Current value | Concurrent macOS? | +|---------|---------------|-------------------| +| **Process / `PRODUCT_NAME`** | `io.invertase.testing` | **Hard singleton** — `pgrep -x` / `killall`, check/release, mellifera `platform:macos:global` | +| App path | `…/io.invertase.testing.app/Contents/MacOS/io.invertase.testing` | Follows `PRODUCT_NAME` | +| `CFBundleIdentifier` | `org.reactjs.native.io-invertase-testing` (`org.reactjs.native.$(PRODUCT_NAME:rfc1034identifier)` in the macOS pbxproj) | OS sandbox/prefs; Metro `app=` should match if changed | +| Metro `app=` query | `org.reactjs.native.io-invertase-testing` | Must track bundle ID if that changes | +| Firebase / GoogleService | **None on macOS target** | iOS/Android use `com.invertase.testing` in `GoogleService-Info.plist` / `google-services.json`; macOS does not ship those and does not `[FIRApp configure]` in `AppDelegate` | + +**Unlock for per-worktree / multi-slot macOS:** override `PRODUCT_NAME` at `xcodebuild` time (Info.plist already uses `$(PRODUCT_NAME)` / `$(PRODUCT_BUNDLE_IDENTIFIER)`), then make spawn/kill/preflight/Metro `app=` env-driven in `.jetrc.js` and `scripts/e2e/lib/e2e-resource-env.sh`. Slotting bundle ID alone does **not** help — kill scripts key on process name. No new Firebase macOS/iOS app registration is required for the current JS/Other e2e path. Mild residual: native RNFB preferences use a shared suite name `io.invertase.firebase` (not app-bundle-scoped). + ### Android emulator gray screen / Quick Boot (blocking) Detox's default emulator launch **restores the AVD Quick Boot snapshot** unless told otherwise. On `TestingAVD` that can leave the device **`offline` on a gray screen** — `adb devices` shows `emulator-XXXX offline` and Detox hangs on `wait-for-device`. diff --git a/package.json b/package.json index 9875093955..8cf5007730 100644 --- a/package.json +++ b/package.json @@ -42,19 +42,19 @@ "tests:emulator:start:windows": "yarn tests:emulator:prepare && cd ./.github/workflows/scripts && ./start-firebase-emulator.bat --no-daemon", "tests:emulator:start-ci": "yarn tests:emulator:prepare && cd ./.github/workflows/scripts && ./start-firebase-emulator.sh", "tests:android:build": "run-script-os", - "tests:android:build:default": "cd tests && yarn detox build --configuration android.emu.debug", + "tests:android:build:default": "bash -c 'export ORG_GRADLE_PROJECT_reactNativeDevServerPort=\"${RNFB_ANDROID_METRO_PORT:-${RCT_METRO_PORT:-${RNFB_METRO_PORT:-${JET_METRO_PORT:-8081}}}}\" && cd tests && yarn detox build --configuration \"${RNFB_DETOX_ANDROID_CONFIG:-android.emu.debug}\"'", "tests:android:build:windows": "cd tests && yarn detox build --configuration android.emu.debug.windows", "tests:android:build:release": "cd tests && yarn detox build --configuration android.emu.release", "tests:android:manual": "cd tests && yarn react-native run-android", - "tests:android:test": "cd tests && yarn detox test --configuration android.emu.debug", + "tests:android:test": "bash -c 'cd tests && yarn detox test --configuration \"${RNFB_DETOX_ANDROID_CONFIG:-android.emu.debug}\"'", "tests:android:test:debug": "cd tests && yarn detox test --configuration android.emu.debug --inspect", "tests:android:test-reuse": "cd tests && yarn detox test --configuration android.emu.debug --reuse", - "tests:android:test-cover": "cd tests && yarn detox test --configuration android.emu.debug --loglevel verbose", + "tests:android:test-cover": "bash -c 'cd tests && yarn detox test --configuration \"${RNFB_DETOX_ANDROID_CONFIG:-android.emu.debug}\" --loglevel verbose'", "tests:android:test-cover-reuse": "cd tests && yarn detox test --configuration android.emu.debug --reuse", "tests:android:pull-native-coverage": "node tests/scripts/pull-native-coverage.js --android-pull", "tests:android:post-e2e-coverage": "node tests/scripts/pull-native-coverage.js --android-post-e2e", "tests:android:test:jacoco-report": "cd tests/android && ./gradlew jacocoAndroidTestReport", - "tests:ios:build": "cd tests && yarn detox build --configuration ios.sim.debug", + "tests:ios:build": "bash -c 'cd tests && yarn detox build --configuration \"${RNFB_DETOX_IOS_CONFIG:-ios.sim.debug}\"'", "tests:ios:build:release": "cd tests && yarn detox build --configuration ios.sim.release", "tests:ios:detox-framework-cache:rebuild": "cd tests && yarn detox rebuild-framework-cache", "tests:ios:manual": "cd tests && SIMCTL_CHILD_GULGeneratedClassDisposeDisabled=1 CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ yarn react-native run-ios", @@ -62,7 +62,7 @@ "tests:ios:test:release": "cd tests && SIMCTL_CHILD_GULGeneratedClassDisposeDisabled=1 yarn detox test --configuration ios.sim.release --loglevel warn", "tests:ios:test:debug": "cd tests && SIMCTL_CHILD_GULGeneratedClassDisposeDisabled=1 yarn detox test --configuration ios.sim.debug --loglevel warn --inspect", "tests:ios:test-reuse": "cd tests && SIMCTL_CHILD_GULGeneratedClassDisposeDisabled=1 yarn detox test --configuration ios.sim.debug --reuse --loglevel warn", - "tests:ios:test-cover": "cd tests && SIMCTL_CHILD_GULGeneratedClassDisposeDisabled=1 yarn detox test --configuration ios.sim.debug --loglevel verbose", + "tests:ios:test-cover": "bash -c 'cd tests && SIMCTL_CHILD_GULGeneratedClassDisposeDisabled=1 yarn detox test --configuration \"${RNFB_DETOX_IOS_CONFIG:-ios.sim.debug}\" --loglevel verbose'", "tests:ios:test-cover-reuse": "cd tests && SIMCTL_CHILD_GULGeneratedClassDisposeDisabled=1 yarn detox test --configuration ios.sim.debug --reuse --loglevel warn", "tests:ios:test:process-coverage": "node tests/scripts/process-ios-native-coverage.js", "tests:ios:test-cover-and-process": "yarn tests:ios:test-cover && yarn tests:ios:test:process-coverage", @@ -72,6 +72,8 @@ "tests:macos:manual": "cd tests && yarn react-native run-macos", "tests:macos:test": "cd tests && npx jet --target=macos --coverage", "tests:macos:test-cover": "cd tests && npx jet --target=macos --coverage", + "tests:e2e:setup-android-avds": "bash ./scripts/e2e/create-android-avds.sh", + "tests:e2e:setup-ios-sims": "bash ./scripts/e2e/create-ios-simulators.sh", "format:js": "prettier --write \"packages/**/*.{js,ts,tsx}\"", "format:markdown": "prettier --write \"docs/**/*.md[x]\"" }, diff --git a/packages/ai/lib/requests/request.ts b/packages/ai/lib/requests/request.ts index 0d9d9bb596..275e221a34 100644 --- a/packages/ai/lib/requests/request.ts +++ b/packages/ai/lib/requests/request.ts @@ -59,11 +59,25 @@ export class RequestUrl { 'Running VertexAI in test environment, pointing to Firebase Functions emulator URL', ); const isAndroid = Platform.OS === 'android'; + const isMacos = (Platform.OS as string) === 'macos'; + let fnHost = isAndroid ? '10.0.2.2' : '127.0.0.1'; + // Static process.env.RNFB_* members — babel transform-inline-environment-variables + // does not rewrite computed keys (see okf-bundle/testing/running-e2e.md). + let envPort: string | undefined; + if (isAndroid) { + envPort = process.env.RNFB_ANDROID_EMULATOR_FUNCTIONS_PORT; + } else if (isMacos) { + envPort = process.env.RNFB_MACOS_EMULATOR_FUNCTIONS_PORT; + } else { + envPort = process.env.RNFB_IOS_EMULATOR_FUNCTIONS_PORT; + } + const parsed = envPort ? parseInt(envPort, 10) : NaN; + const fnPort = Number.isFinite(parsed) ? parsed : 5001; if (this.stream) { - emulatorUrl = `http://${isAndroid ? '10.0.2.2' : '127.0.0.1'}:5001/react-native-firebase-testing/us-central1/testFetchStream`; + emulatorUrl = `http://${fnHost}:${fnPort}/react-native-firebase-testing/us-central1/testFetchStream`; } else { - emulatorUrl = `http://${isAndroid ? '10.0.2.2' : '127.0.0.1'}:5001/react-native-firebase-testing/us-central1/testFetch`; + emulatorUrl = `http://${fnHost}:${fnPort}/react-native-firebase-testing/us-central1/testFetch`; } return emulatorUrl; } diff --git a/packages/app/__tests__/e2eHelpers.test.js b/packages/app/__tests__/e2eHelpers.test.js new file mode 100644 index 0000000000..3920c9661a --- /dev/null +++ b/packages/app/__tests__/e2eHelpers.test.js @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; + +// This suite runs under plain Jest/Node — not Metro — so it cannot prove the +// babel `transform-inline-environment-variables` static-inlining behaviour +// (tests/.babelrc + e2e proof on-device cover that). What it does prove is +// that the platform-routing / precedence logic in helpers.js itself is +// correct, independent of how `process.env` is populated at runtime. +const helpers = require('../e2e/helpers'); + +const ENV_KEYS = [ + 'RNFB_ANDROID_METRO_PORT', + 'RNFB_IOS_METRO_PORT', + 'RNFB_MACOS_METRO_PORT', + 'RNFB_ANDROID_JET_PORT', + 'RNFB_IOS_JET_PORT', + 'RNFB_MACOS_JET_PORT', + 'RNFB_ANDROID_EMULATOR_FIRESTORE_PORT', + 'RNFB_IOS_EMULATOR_FIRESTORE_PORT', + 'RNFB_MACOS_EMULATOR_FIRESTORE_PORT', + 'JET_REMOTE_PORT', + 'JET_METRO_PORT', + 'RCT_METRO_PORT', + 'RNFB_METRO_PORT', +]; + +const originalEnv = {}; +const originalPlatform = global.Platform; + +function setPlatform(pk) { + global.Platform = { + android: pk === 'android', + ios: pk === 'ios', + other: pk === 'macos', + }; +} + +beforeEach(() => { + ENV_KEYS.forEach(key => { + originalEnv[key] = process.env[key]; + delete process.env[key]; + }); +}); + +afterEach(() => { + ENV_KEYS.forEach(key => { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + }); + global.Platform = originalPlatform; +}); + +describe('app e2e helpers — slotted env resolution', () => { + describe('getE2ePlatformKey', () => { + it('maps Platform flags to android/ios/macos', () => { + setPlatform('android'); + expect(helpers.getE2ePlatformKey()).toBe('android'); + setPlatform('ios'); + expect(helpers.getE2ePlatformKey()).toBe('ios'); + setPlatform('macos'); + expect(helpers.getE2ePlatformKey()).toBe('macos'); + }); + }); + + describe('getE2eEmulatorPort', () => { + it('falls back to the built-in default when nothing is set', () => { + setPlatform('ios'); + expect(helpers.getE2eEmulatorPort('firestore')).toBe(8080); + expect(helpers.getE2eEmulatorPort('auth')).toBe(9099); + }); + + it('uses the platform-prefixed port for the active platform only', () => { + process.env.RNFB_ANDROID_EMULATOR_FIRESTORE_PORT = '18080'; + process.env.RNFB_IOS_EMULATOR_FIRESTORE_PORT = '28080'; + process.env.RNFB_MACOS_EMULATOR_FIRESTORE_PORT = '38080'; + + setPlatform('android'); + expect(helpers.getE2eEmulatorPort('firestore')).toBe(18080); + setPlatform('ios'); + expect(helpers.getE2eEmulatorPort('firestore')).toBe(28080); + setPlatform('macos'); + expect(helpers.getE2eEmulatorPort('firestore')).toBe(38080); + }); + }); + + describe('getJetRemotePort (finding #4: platform-prefixed first)', () => { + it('defaults to 8090 when nothing is set', () => { + setPlatform('ios'); + expect(helpers.getJetRemotePort()).toBe(8090); + }); + + it('prefers the platform-prefixed port over global JET_REMOTE_PORT', () => { + setPlatform('macos'); + process.env.JET_REMOTE_PORT = '9000'; + process.env.RNFB_MACOS_JET_PORT = '9500'; + expect(helpers.getJetRemotePort()).toBe(9500); + }); + + it('falls back to JET_REMOTE_PORT when no platform-prefixed port is set', () => { + setPlatform('android'); + process.env.JET_REMOTE_PORT = '9001'; + expect(helpers.getJetRemotePort()).toBe(9001); + }); + }); + + describe('getMetroPort (finding #8: JET_METRO_PORT is a fallback)', () => { + it('defaults to 8081 when nothing is set', () => { + setPlatform('ios'); + expect(helpers.getMetroPort()).toBe(8081); + }); + + it('prefers the platform-prefixed port over every other source', () => { + setPlatform('ios'); + process.env.RCT_METRO_PORT = '7001'; + process.env.RNFB_METRO_PORT = '7002'; + process.env.JET_METRO_PORT = '7003'; + process.env.RNFB_IOS_METRO_PORT = '7100'; + expect(helpers.getMetroPort()).toBe(7100); + }); + + it('prefers RCT_METRO_PORT over RNFB_METRO_PORT and JET_METRO_PORT', () => { + setPlatform('android'); + process.env.RCT_METRO_PORT = '7001'; + process.env.RNFB_METRO_PORT = '7002'; + process.env.JET_METRO_PORT = '7003'; + expect(helpers.getMetroPort()).toBe(7001); + }); + + it('prefers RNFB_METRO_PORT over JET_METRO_PORT', () => { + setPlatform('android'); + process.env.RNFB_METRO_PORT = '7002'; + process.env.JET_METRO_PORT = '7003'; + expect(helpers.getMetroPort()).toBe(7002); + }); + + it('falls back to JET_METRO_PORT when nothing else is set', () => { + setPlatform('android'); + process.env.JET_METRO_PORT = '7003'; + expect(helpers.getMetroPort()).toBe(7003); + }); + }); + + describe('getJetRemoteUrl', () => { + it('builds a ws:// url from host + resolved jet port', () => { + setPlatform('android'); + expect(helpers.getJetRemoteUrl()).toBe('ws://10.0.2.2:8090'); + setPlatform('ios'); + expect(helpers.getJetRemoteUrl()).toBe('ws://127.0.0.1:8090'); + }); + }); +}); diff --git a/packages/app/e2e/config.e2e.js b/packages/app/e2e/config.e2e.js index bcaef2667c..9f0b72e14a 100644 --- a/packages/app/e2e/config.e2e.js +++ b/packages/app/e2e/config.e2e.js @@ -47,8 +47,9 @@ describe('config', function () { // NOTE: "preferencesClearAll" clears Firestore settings. Set DB as emulator again. after(async function () { const { connectFirestoreEmulator, getFirestore } = firestoreModular; + const { getE2eEmulatorPort } = require('../../app/e2e/helpers'); if (Platform.other) return; - connectFirestoreEmulator(getFirestore(), 'localhost', 8080); + connectFirestoreEmulator(getFirestore(), 'localhost', getE2eEmulatorPort('firestore')); }); it('should set bool values', async function () { diff --git a/packages/app/e2e/helpers.js b/packages/app/e2e/helpers.js index c1362fd012..c290775db5 100644 --- a/packages/app/e2e/helpers.js +++ b/packages/app/e2e/helpers.js @@ -8,3 +8,184 @@ exports.getE2eEmulatorHost = function getE2eEmulatorHost() { } return '127.0.0.1'; }; + +function platformKey() { + if (Platform.other) { + return 'macos'; + } + if (Platform.android) { + return 'android'; + } + return 'ios'; +} + +const DEFAULT_EMULATOR = { + firestore: 8080, + auth: 9099, + database: 9000, + functions: 5001, + storage: 9199, +}; + +const DEFAULT_JET_SERIAL = 8090; +const DEFAULT_METRO_PORT = 8081; + +function e2eDebug(msg, extra) { + if (process.env.RNFB_E2E_DEBUG !== '1') { + return; + } + const pk = platformKey(); + // eslint-disable-next-line no-console + console.log(`[rnfb-e2e] platform=${pk} ${msg}`, extra || ''); +} + +function parsePort(value) { + if (!value) { + return null; + } + const n = parseInt(value, 10); + return Number.isFinite(n) ? n : null; +} + +// NOTE: tests/.babelrc's `transform-inline-environment-variables` plugin only rewrites +// static `process.env.NAME` member expressions at Metro-transform time — it cannot see +// through computed/dynamic lookups such as `process.env[key]` or template-built names. +// The packaged app has no real `process.env` at runtime, so every slotted var below must +// be spelled out literally to match the include list in tests/.babelrc. +function staticEmulatorPort(pk, service) { + switch (pk) { + case 'android': + switch (service) { + case 'firestore': + return process.env.RNFB_ANDROID_EMULATOR_FIRESTORE_PORT; + case 'auth': + return process.env.RNFB_ANDROID_EMULATOR_AUTH_PORT; + case 'database': + return process.env.RNFB_ANDROID_EMULATOR_DATABASE_PORT; + case 'functions': + return process.env.RNFB_ANDROID_EMULATOR_FUNCTIONS_PORT; + case 'storage': + return process.env.RNFB_ANDROID_EMULATOR_STORAGE_PORT; + default: + return undefined; + } + case 'macos': + switch (service) { + case 'firestore': + return process.env.RNFB_MACOS_EMULATOR_FIRESTORE_PORT; + case 'auth': + return process.env.RNFB_MACOS_EMULATOR_AUTH_PORT; + case 'database': + return process.env.RNFB_MACOS_EMULATOR_DATABASE_PORT; + case 'functions': + return process.env.RNFB_MACOS_EMULATOR_FUNCTIONS_PORT; + case 'storage': + return process.env.RNFB_MACOS_EMULATOR_STORAGE_PORT; + default: + return undefined; + } + case 'ios': + default: + switch (service) { + case 'firestore': + return process.env.RNFB_IOS_EMULATOR_FIRESTORE_PORT; + case 'auth': + return process.env.RNFB_IOS_EMULATOR_AUTH_PORT; + case 'database': + return process.env.RNFB_IOS_EMULATOR_DATABASE_PORT; + case 'functions': + return process.env.RNFB_IOS_EMULATOR_FUNCTIONS_PORT; + case 'storage': + return process.env.RNFB_IOS_EMULATOR_STORAGE_PORT; + default: + return undefined; + } + } +} + +function staticMetroPort(pk) { + switch (pk) { + case 'android': + return process.env.RNFB_ANDROID_METRO_PORT; + case 'macos': + return process.env.RNFB_MACOS_METRO_PORT; + case 'ios': + default: + return process.env.RNFB_IOS_METRO_PORT; + } +} + +function staticJetPort(pk) { + switch (pk) { + case 'android': + return process.env.RNFB_ANDROID_JET_PORT; + case 'macos': + return process.env.RNFB_MACOS_JET_PORT; + case 'ios': + default: + return process.env.RNFB_IOS_JET_PORT; + } +} + +exports.getE2ePlatformKey = platformKey; + +exports.getE2eEmulatorPort = function getE2eEmulatorPort(service) { + const pk = platformKey(); + const prefixed = parsePort(staticEmulatorPort(pk, service)); + if (prefixed) { + e2eDebug(`emulator.${service} from env`, prefixed); + return prefixed; + } + const fallback = DEFAULT_EMULATOR[service] ?? 8080; + e2eDebug(`emulator.${service} default`, fallback); + return fallback; +}; + +// Precedence: platform-prefixed (static env) -> JET_REMOTE_PORT (global override) -> default. +exports.getJetRemotePort = function getJetRemotePort() { + const pk = platformKey(); + const prefixed = parsePort(staticJetPort(pk)); + if (prefixed) { + e2eDebug('jet.port from platform env', prefixed); + return prefixed; + } + const global_ = parsePort(process.env.JET_REMOTE_PORT); + if (global_) { + e2eDebug('jet.port from JET_REMOTE_PORT', global_); + return global_; + } + e2eDebug('jet.port default', DEFAULT_JET_SERIAL); + return DEFAULT_JET_SERIAL; +}; + +exports.getJetRemoteUrl = function getJetRemoteUrl() { + const host = exports.getE2eEmulatorHost(); + return `ws://${host}:${exports.getJetRemotePort()}`; +}; + +// Precedence: platform-prefixed -> RCT_METRO_PORT -> RNFB_METRO_PORT -> JET_METRO_PORT -> default. +exports.getMetroPort = function getMetroPort() { + const pk = platformKey(); + const prefixed = parsePort(staticMetroPort(pk)); + if (prefixed) { + e2eDebug('metro.port from platform env', prefixed); + return prefixed; + } + const rct = parsePort(process.env.RCT_METRO_PORT); + if (rct) { + e2eDebug('metro.port from RCT_METRO_PORT', rct); + return rct; + } + const rnfb = parsePort(process.env.RNFB_METRO_PORT); + if (rnfb) { + e2eDebug('metro.port from RNFB_METRO_PORT', rnfb); + return rnfb; + } + const jet = parsePort(process.env.JET_METRO_PORT); + if (jet) { + e2eDebug('metro.port from JET_METRO_PORT', jet); + return jet; + } + e2eDebug('metro.port default', DEFAULT_METRO_PORT); + return DEFAULT_METRO_PORT; +}; diff --git a/packages/auth/e2e/emailLink.e2e.js b/packages/auth/e2e/emailLink.e2e.js index b1cc54d725..3e09b4cb20 100644 --- a/packages/auth/e2e/emailLink.e2e.js +++ b/packages/auth/e2e/emailLink.e2e.js @@ -1,4 +1,9 @@ const { getLastOob, signInUser } = require('./helpers'); +const { getE2eEmulatorHost, getMetroPort } = require('../../app/e2e/helpers'); + +function emailLinkContinueUrl(random) { + return `http://${getE2eEmulatorHost()}:${getMetroPort()}/authLinkFoo?bar=${random}`; +} describe('auth() -> emailLink Provider', function () { beforeEach(async function () { @@ -43,7 +48,7 @@ describe('auth() -> emailLink Provider', function () { const auth = getAuth(); const random = Utils.randString(12, '#aa'); const email = `${random}@${random}.com`; - const continueUrl = `http://${Platform.android ? '10.0.2.2' : '127.0.0.1'}:8081/authLinkFoo?bar=${random}`; + const continueUrl = emailLinkContinueUrl(random); const actionCodeSettings = { url: continueUrl, handleCodeInApp: true, @@ -97,7 +102,7 @@ describe('auth() -> emailLink Provider', function () { const auth = getAuth(); const random = Utils.randString(12, '#aa'); const email = `${random}@${random}.com`; - const continueUrl = `http://${Platform.android ? '10.0.2.2' : '127.0.0.1'}:8081/authLinkFoo?bar=${random}`; + const continueUrl = emailLinkContinueUrl(random); const actionCodeSettings = { url: continueUrl, handleCodeInApp: true, diff --git a/packages/auth/e2e/helpers.js b/packages/auth/e2e/helpers.js index fa40a5add6..11ca72c3ef 100644 --- a/packages/auth/e2e/helpers.js +++ b/packages/auth/e2e/helpers.js @@ -1,5 +1,9 @@ /* eslint-disable no-console */ -const { getE2eTestProject, getE2eEmulatorHost } = require('../../app/e2e/helpers'); +const { + getE2eTestProject, + getE2eEmulatorHost, + getE2eEmulatorPort, +} = require('../../app/e2e/helpers'); // Call HTTP REST API URL and return JSON response parsed into object const callRestApi = async function callRestAPI(url, returnRedirectUrl = false) { @@ -27,7 +31,8 @@ exports.clearAllUsers = async function clearAllUsers() { const response = await fetch( 'http://' + getE2eEmulatorHost() + - ':9099' + + ':' + + getE2eEmulatorPort('auth') + '/emulator/v1/projects/' + getE2eTestProject() + '/accounts', @@ -51,7 +56,8 @@ exports.disableUser = async function disableUser(userId) { const response = await fetch( 'http://' + getE2eEmulatorHost() + - ':9099' + + ':' + + getE2eEmulatorPort('auth') + '/identitytoolkit.googleapis.com/v1/projects/' + getE2eTestProject() + '/accounts:update', @@ -77,7 +83,9 @@ async function getLastSmsCode(specificPhone) { const getSmsCodesUrl = 'http://' + getE2eEmulatorHost() + - ':9099/emulator/v1/projects/' + + ':' + + getE2eEmulatorPort('auth') + + '/emulator/v1/projects/' + getE2eTestProject() + '/verificationCodes'; @@ -117,7 +125,9 @@ async function getLastOob(specificEmail) { const getOobCodesUrl = 'http://' + getE2eEmulatorHost() + - ':9099/emulator/v1/projects/' + + ':' + + getE2eEmulatorPort('auth') + + '/emulator/v1/projects/' + getE2eTestProject() + '/oobCodes'; @@ -154,7 +164,9 @@ exports.resetPassword = async function resetPassword(oobCode, newPassword) { const resetPasswordUrl = 'http://' + getE2eEmulatorHost() + - ':9099/emulator/action?mode=resetPassword&lang=en&oobCode=' + + ':' + + getE2eEmulatorPort('auth') + + '/emulator/action?mode=resetPassword&lang=en&oobCode=' + oobCode + '&apiKey=fake-api-key&newPassword=' + newPassword; @@ -165,7 +177,9 @@ async function verifyEmail(oobCode) { const verifyEmailUrl = 'http://' + getE2eEmulatorHost() + - ':9099/emulator/action?mode=verifyEmail&lang=en&oobCode=' + + ':' + + getE2eEmulatorPort('auth') + + '/emulator/action?mode=verifyEmail&lang=en&oobCode=' + oobCode + '&apiKey=fake-api-key'; return await callRestApi(verifyEmailUrl); diff --git a/packages/database/e2e/helpers.js b/packages/database/e2e/helpers.js index ce16f48c50..232ad21b70 100644 --- a/packages/database/e2e/helpers.js +++ b/packages/database/e2e/helpers.js @@ -13,7 +13,11 @@ const testingUtils = require('@firebase/rules-unit-testing'); // eslint-disable-next-line no-console console.warn = originalConsoleWarn; -const { getE2eTestProject, getE2eEmulatorHost } = require('../../app/e2e/helpers'); +const { + getE2eTestProject, + getE2eEmulatorHost, + getE2eEmulatorPort, +} = require('../../app/e2e/helpers'); // TODO make more unique? const ID = Date.now(); @@ -78,7 +82,7 @@ exports.seed = function seed(path) { databaseName: DB_NAME, rules: JSON.stringify(DB_RULES), host: getE2eEmulatorHost(), - port: 9000, + port: getE2eEmulatorPort('database'), }, }), ]); diff --git a/packages/firestore/e2e/helpers.js b/packages/firestore/e2e/helpers.js index c44aa4e156..02cc740d55 100644 --- a/packages/firestore/e2e/helpers.js +++ b/packages/firestore/e2e/helpers.js @@ -1,5 +1,9 @@ /* eslint-disable no-console */ -const { getE2eTestProject, getE2eEmulatorHost } = require('../../app/e2e/helpers'); +const { + getE2eTestProject, + getE2eEmulatorHost, + getE2eEmulatorPort, +} = require('../../app/e2e/helpers'); /* * Copyright (c) 2016-present Invertase Limited & Contributors @@ -20,8 +24,9 @@ const { getE2eTestProject, getE2eEmulatorHost } = require('../../app/e2e/helpers exports.wipe = async function wipe(debug = false, databaseId = '(default)', retries = 3) { const host = getE2eEmulatorHost(); + const fsPort = getE2eEmulatorPort('firestore'); const url = - `http://${host}:8080/emulator/v1/projects/` + + `http://${host}:${fsPort}/emulator/v1/projects/` + getE2eTestProject() + `/databases/${databaseId}/documents`; @@ -105,7 +110,7 @@ exports.setDocumentOutOfBand = async function setDocumentOutOfBand( databaseId = '(default)', ) { const url = - `http://${getE2eEmulatorHost()}:8080/v1/projects/` + + `http://${getE2eEmulatorHost()}:${getE2eEmulatorPort('firestore')}/v1/projects/` + getE2eTestProject() + `/databases/${databaseId}/documents/${path}`; diff --git a/packages/firestore/e2e/issues.e2e.js b/packages/firestore/e2e/issues.e2e.js index 9b89e4e6d6..732b712be7 100644 --- a/packages/firestore/e2e/issues.e2e.js +++ b/packages/firestore/e2e/issues.e2e.js @@ -16,7 +16,7 @@ */ const COLLECTION = 'firestore'; -const { getE2eEmulatorHost } = require('../../app/e2e/helpers'); +const { getE2eEmulatorHost, getE2eEmulatorPort } = require('../../app/e2e/helpers'); // const jsFirebase = require('firebase/compat/app'); // require('firebase/compat/firestore'); @@ -137,7 +137,7 @@ describe('firestore()', function () { const app = await initializeApp(FirebaseHelpers.app.config(), appName); const db = getFirestore(app); const emulatorSettings = { - host: `${getE2eEmulatorHost()}:8080`, + host: `${getE2eEmulatorHost()}:${getE2eEmulatorPort('firestore')}`, ssl: false, }; @@ -170,7 +170,7 @@ describe('firestore()', function () { jsFirestoreModular.connectFirestoreEmulator( jsFirestoreModular.getFirestore(), getE2eEmulatorHost(), - 8080, + getE2eEmulatorPort('firestore'), ); } catch (_e) {} diff --git a/packages/functions/e2e/functions.e2e.js b/packages/functions/e2e/functions.e2e.js index 630fd014c6..5121f6e2dd 100644 --- a/packages/functions/e2e/functions.e2e.js +++ b/packages/functions/e2e/functions.e2e.js @@ -88,6 +88,16 @@ const SAMPLE_DATA = { ], }; +const { getE2eEmulatorHost, getE2eEmulatorPort } = require('../../app/e2e/helpers'); + +function functionsEmulatorPort() { + return getE2eEmulatorPort('functions'); +} + +function functionsCallableUrl(fnPath) { + return `http://${getE2eEmulatorHost()}:${functionsEmulatorPort()}/react-native-firebase-testing/us-central1/${fnPath}`; +} + const E2E_CALLABLE_TIMEOUT_MS = 120000; function e2eCallableTimeoutOptions(extra = {}) { @@ -184,7 +194,7 @@ describe('functions() modular', function () { const region = 'us-central1'; const fnName = 'helloWorldV2'; const functions = getFunctions(getApp(), region); - connectFunctionsEmulator(functions, 'localhost', 5001); + connectFunctionsEmulator(functions, getE2eEmulatorHost(), functionsEmulatorPort()); const response = await httpsCallable(functions, fnName, e2eCallableTimeoutOptions())(); response.data.should.equal('Hello from Firebase!'); }); @@ -195,7 +205,7 @@ describe('functions() modular', function () { const region = 'us-central1'; const fnName = 'helloWorldV2'; const functions = getFunctions(getApp(), region); - connectFunctionsEmulator(functions, 'localhost', 5001); + connectFunctionsEmulator(functions, getE2eEmulatorHost(), functionsEmulatorPort()); const response = await httpsCallable(functions, fnName, e2eCallableTimeoutOptions())(); response.data.should.equal('Hello from Firebase!'); }); @@ -207,7 +217,7 @@ describe('functions() modular', function () { const { getApp } = modular; const { getFunctions, httpsCallable, connectFunctionsEmulator } = functionsModular; const functions = getFunctions(getApp(), 'us-central1'); - connectFunctionsEmulator(functions, 'localhost', 5001); + connectFunctionsEmulator(functions, getE2eEmulatorHost(), functionsEmulatorPort()); const response = await httpsCallable(functions, 'helloWorldV2', { timeout: 10000 })(); response.data.should.equal('Hello from Firebase!'); }); @@ -218,14 +228,10 @@ describe('functions() modular', function () { const { getApp } = modular; const { getFunctions, httpsCallableFromUrl } = functionsModular; - let hostname = 'localhost'; - if (Platform.android) { - hostname = '10.0.2.2'; - } const functions = getFunctions(getApp()); const functionRunner = httpsCallableFromUrl( functions, - `http://${hostname}:5001/react-native-firebase-testing/us-central1/helloWorldV2`, + functionsCallableUrl('helloWorldV2'), e2eCallableTimeoutOptions(), ); const response = await functionRunner(); @@ -884,13 +890,9 @@ describe('functions() modular', function () { it('HttpsError when calling stream from URL', async function () { const { getApp } = modular; const { getFunctions, httpsCallableFromUrl } = functionsModular; - let hostname = 'localhost'; - if (Platform.android) { - hostname = '10.0.2.2'; - } const functionRunner = httpsCallableFromUrl( getFunctions(getApp()), - `http://${hostname}:5001/react-native-firebase-testing/us-central1/testStreamWithHttpsErrorFromUrl`, + functionsCallableUrl('testStreamWithHttpsErrorFromUrl'), e2eCallableTimeoutOptions(), ); @@ -967,13 +969,9 @@ describe('functions() modular', function () { it('should stream data chunks from URL', async function () { const { getApp } = modular; const { getFunctions, httpsCallableFromUrl } = functionsModular; - let hostname = 'localhost'; - if (Platform.android) { - hostname = '10.0.2.2'; - } const functionRunner = httpsCallableFromUrl( getFunctions(getApp()), - `http://${hostname}:5001/react-native-firebase-testing/us-central1/testStreamingCallable`, + functionsCallableUrl('testStreamingCallable'), e2eCallableTimeoutOptions(), ); const { stream, data } = await functionRunner.stream({ count: 3, delay: 400 }); @@ -997,13 +995,9 @@ describe('functions() modular', function () { it('should work with HttpsCallableOptions.timeout on URL stream', async function () { const { getApp } = modular; const { getFunctions, httpsCallableFromUrl } = functionsModular; - let hostname = 'localhost'; - if (Platform.android) { - hostname = '10.0.2.2'; - } const functionRunner = httpsCallableFromUrl( getFunctions(getApp()), - `http://${hostname}:5001/react-native-firebase-testing/us-central1/testStreamingCallable`, + functionsCallableUrl('testStreamingCallable'), e2eCallableTimeoutOptions(), ); const { stream, data } = await functionRunner.stream({ count: 2, delay: 300 }); @@ -1027,13 +1021,9 @@ describe('functions() modular', function () { it('should accept stream options as second parameter for URL', async function () { const { getApp } = modular; const { getFunctions, httpsCallableFromUrl } = functionsModular; - let hostname = 'localhost'; - if (Platform.android) { - hostname = '10.0.2.2'; - } const functionRunner = httpsCallableFromUrl( getFunctions(getApp()), - `http://${hostname}:5001/react-native-firebase-testing/us-central1/testStreamingCallable`, + functionsCallableUrl('testStreamingCallable'), e2eCallableTimeoutOptions(), ); const { stream, data } = await functionRunner.stream( @@ -1060,13 +1050,9 @@ describe('functions() modular', function () { it('should return both stream and data promise for URL', async function () { const { getApp } = modular; const { getFunctions, httpsCallableFromUrl } = functionsModular; - let hostname = 'localhost'; - if (Platform.android) { - hostname = '10.0.2.2'; - } const functionRunner = httpsCallableFromUrl( getFunctions(getApp()), - `http://${hostname}:5001/react-native-firebase-testing/us-central1/testStreamingCallable`, + functionsCallableUrl('testStreamingCallable'), e2eCallableTimeoutOptions(), ); const result = await functionRunner.stream({ count: 2, delay: 200 }); diff --git a/scripts/e2e/create-android-avds.sh b/scripts/e2e/create-android-avds.sh new file mode 100755 index 0000000000..6feb6c502b --- /dev/null +++ b/scripts/e2e/create-android-avds.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Clone TestingAVD into TestingAVD-1 … TestingAVD-4 for parallel e2e slots. +# Same AVD *definition* cannot run two read-write instances concurrently; clones are required. +set -euo pipefail + +COUNT="${1:-4}" +BASE_AVD="${RNFB_ANDROID_BASE_AVD:-TestingAVD}" +ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}" +AVD_DIR="${ANDROID_AVD_HOME:-$HOME/.android/avd}" +EMU="${ANDROID_HOME}/emulator/emulator" +SDKMANAGER="${ANDROID_HOME}/cmdline-tools/latest/bin/avdmanager" + +if [[ ! -x "$EMU" ]]; then + echo "error: emulator not found at $EMU (set ANDROID_HOME)" >&2 + exit 1 +fi + +if ! "$EMU" -list-avds | grep -qx "$BASE_AVD"; then + echo "error: base AVD '$BASE_AVD' not found. Create it first (Android Studio AVD Manager)." >&2 + exit 1 +fi + +clone_avd() { + local src=$1 dst=$2 + if "$EMU" -list-avds | grep -qx "$dst"; then + echo "[avd] $dst already exists" + return 0 + fi + + local src_ini="${AVD_DIR}/${src}.ini" + local src_avd="${AVD_DIR}/${src}.avd" + local dst_ini="${AVD_DIR}/${dst}.ini" + local dst_avd="${AVD_DIR}/${dst}.avd" + + echo "[avd] cloning $src → $dst" + cp -R "$src_avd" "$dst_avd" + cp "$src_ini" "$dst_ini" + node -e " + const fs = require('fs'); + const [ini, config, src, dst] = process.argv.slice(1); + for (const file of [ini, config]) { + fs.writeFileSync(file, fs.readFileSync(file, 'utf8').split(src).join(dst)); + } + " "$dst_ini" "${dst_avd}/config.ini" "$src" "$dst" +} + +for i in $(seq 1 "$COUNT"); do + clone_avd "$BASE_AVD" "${BASE_AVD}-${i}" +done + +echo "[avd] available AVDs:" +"$EMU" -list-avds | grep -E "^${BASE_AVD}" || true +echo "[avd] done — slot 0 uses ${BASE_AVD}, slots 1+ use ${BASE_AVD}-N" diff --git a/scripts/e2e/create-ios-simulators.sh b/scripts/e2e/create-ios-simulators.sh new file mode 100755 index 0000000000..b7b42eaa76 --- /dev/null +++ b/scripts/e2e/create-ios-simulators.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Create dedicated iOS simulators for e2e slots 1–4 (slot 0 uses default iPhone 17). +set -euo pipefail + +COUNT="${1:-4}" +BASE_NAME="${RNFB_IOS_BASE_SIMULATOR:-iPhone 17}" +RUNTIME=$(xcrun simctl list runtimes available -j | node -e " + const j=JSON.parse(require('fs').readFileSync(0,'utf8')); + const compareVersions = (a, b) => { + const av = a.split('.').map(Number); + const bv = b.split('.').map(Number); + for (let i = 0; i < Math.max(av.length, bv.length); i++) { + const an = av[i] || 0; + const bn = bv[i] || 0; + if (an !== bn) return bn - an; + } + return 0; + }; + const ios=j.runtimes.filter(r=>r.isAvailable&&r.platform==='iOS').sort((a,b)=>compareVersions(a.version, b.version)); + if(!ios.length) process.exit(1); + console.log(ios[0].identifier); +") + +for i in $(seq 1 "$COUNT"); do + name="RNFB E2E iOS slot-${i}" + if xcrun simctl list devices available | grep -q "${name}"; then + echo "[sim] ${name} exists" + continue + fi + echo "[sim] creating ${name}" + xcrun simctl create "$name" "$BASE_NAME" "$RUNTIME" +done + +echo "[sim] done" diff --git a/scripts/e2e/start-emulator-slotted.sh b/scripts/e2e/start-emulator-slotted.sh new file mode 100755 index 0000000000..a8152d02e6 --- /dev/null +++ b/scripts/e2e/start-emulator-slotted.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Start one Firebase emulator suite for a platform using RNFB__EMULATOR_* env vars. +# +# Also pins Firestore websocket / Eventarc / Cloud Tasks ports. Firebase Tools still +# starts eventarc+tasks as Functions dependencies and defaults Firestore's UI websocket +# to 9150 — those collide when multiple suites share a host (EADDRINUSE → suite dies; +# only the winner keeps a working Functions emulator). +set -euo pipefail + +PLATFORM="${1:?platform required: android|ios|macos}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +SCRIPTS="${REPO_ROOT}/.github/workflows/scripts" +SLOT="${RNFB_E2E_HOST_SLOT:-${RNFB_E2E_SLOT:-0}}" + +prefix="$(echo "${PLATFORM}" | tr '[:lower:]' '[:upper:]')" + +eval "FS_PORT=\$RNFB_${prefix}_EMULATOR_FIRESTORE_PORT" +eval "AUTH_PORT=\$RNFB_${prefix}_EMULATOR_AUTH_PORT" +eval "DB_PORT=\$RNFB_${prefix}_EMULATOR_DATABASE_PORT" +eval "FN_PORT=\$RNFB_${prefix}_EMULATOR_FUNCTIONS_PORT" +eval "ST_PORT=\$RNFB_${prefix}_EMULATOR_STORAGE_PORT" +eval "HUB_PORT=\$RNFB_${prefix}_EMULATOR_HUB_PORT" +eval "LOG_PORT=\$RNFB_${prefix}_EMULATOR_LOGGING_PORT" + +for v in FS_PORT AUTH_PORT DB_PORT FN_PORT ST_PORT HUB_PORT LOG_PORT; do + if [[ -z "${!v:-}" ]]; then + echo "error: ${v} not set (export RNFB_${prefix}_EMULATOR_* ports first)" >&2 + exit 1 + fi +done + +# Offsets within the platform block (FS_PORT is BLK+0): +# +8 websocket, +9 eventarc, +12 tasks (skip +10/+11 Jet). +WS_PORT=$((FS_PORT + 8)) +EVENTARC_PORT=$((FS_PORT + 9)) +TASKS_PORT=$((FS_PORT + 12)) + +CONFIG="${SCRIPTS}/.e2e-emulator-${PLATFORM}-${SLOT}.json" +python3 - </dev/null; do + if (( SECONDS >= deadline )); then + echo "error: timed out waiting for functions build lock ${LOCK_DIR}" >&2 + exit 1 + fi + sleep 1 +done +cleanup_lock() { rmdir "${LOCK_DIR}" 2>/dev/null || true; } +trap cleanup_lock EXIT +pushd "${SCRIPTS}/functions" >/dev/null +yarn >/dev/null 2>&1 || yarn +yarn build +popd >/dev/null +cleanup_lock +trap - EXIT + +# shellcheck source=firebase-cli.sh +source "${SCRIPTS}/firebase-cli.sh" + +cd "${SCRIPTS}" +"${FIREBASE_CMD[@]}" emulators:start \ + --config "${CONFIG}" \ + --only auth,database,firestore,functions,storage \ + --project react-native-firebase-testing diff --git a/tests/.babelrc b/tests/.babelrc index 8f47e44072..c427acf011 100644 --- a/tests/.babelrc +++ b/tests/.babelrc @@ -4,7 +4,37 @@ [ "transform-inline-environment-variables", { - "include": ["CI"] + "include": [ + "CI", + "RNFB_E2E_PLATFORM", + "RNFB_E2E_DEBUG", + "RCT_METRO_PORT", + "RNFB_METRO_PORT", + "JET_METRO_PORT", + "JET_REMOTE_PORT", + "RNFB_JET_CONTROL_PORT", + "RNFB_ANDROID_METRO_PORT", + "RNFB_IOS_METRO_PORT", + "RNFB_MACOS_METRO_PORT", + "RNFB_ANDROID_JET_PORT", + "RNFB_IOS_JET_PORT", + "RNFB_MACOS_JET_PORT", + "RNFB_ANDROID_EMULATOR_FIRESTORE_PORT", + "RNFB_ANDROID_EMULATOR_AUTH_PORT", + "RNFB_ANDROID_EMULATOR_DATABASE_PORT", + "RNFB_ANDROID_EMULATOR_FUNCTIONS_PORT", + "RNFB_ANDROID_EMULATOR_STORAGE_PORT", + "RNFB_IOS_EMULATOR_FIRESTORE_PORT", + "RNFB_IOS_EMULATOR_AUTH_PORT", + "RNFB_IOS_EMULATOR_DATABASE_PORT", + "RNFB_IOS_EMULATOR_FUNCTIONS_PORT", + "RNFB_IOS_EMULATOR_STORAGE_PORT", + "RNFB_MACOS_EMULATOR_FIRESTORE_PORT", + "RNFB_MACOS_EMULATOR_AUTH_PORT", + "RNFB_MACOS_EMULATOR_DATABASE_PORT", + "RNFB_MACOS_EMULATOR_FUNCTIONS_PORT", + "RNFB_MACOS_EMULATOR_STORAGE_PORT" + ] } ], [ diff --git a/tests/.detoxrc.js b/tests/.detoxrc.js index 235263035c..a0cf187ef9 100644 --- a/tests/.detoxrc.js +++ b/tests/.detoxrc.js @@ -1,101 +1,179 @@ /** @type {Detox.DetoxConfig} */ -module.exports = { - testRunner: { - args: { - $0: 'jest', - config: 'e2e/jest.config.js', - }, - jest: { - setupTimeout: 120000, - }, + +function intEnv(name, fallback) { + const v = process.env[name]; + if (v === undefined || v === '') { + return fallback; + } + const n = parseInt(v, 10); + return Number.isFinite(n) ? n : fallback; +} + +function androidReversePortsFromEnv() { + return [ + intEnv('RNFB_ANDROID_EMULATOR_FIRESTORE_PORT', 8080), + intEnv('RNFB_ANDROID_METRO_PORT', intEnv('RCT_METRO_PORT', 8081)), + intEnv('RNFB_ANDROID_JET_PORT', intEnv('JET_REMOTE_PORT', 8090)), + intEnv('RNFB_ANDROID_EMULATOR_DATABASE_PORT', 9000), + intEnv('RNFB_ANDROID_EMULATOR_AUTH_PORT', 9099), + intEnv('RNFB_ANDROID_EMULATOR_STORAGE_PORT', 9199), + intEnv('RNFB_ANDROID_EMULATOR_FUNCTIONS_PORT', 5001), + ]; +} + +const ANDROID_REVERSE_DEFAULT = androidReversePortsFromEnv(); + +// iOS xcodebuild only — prefer the ios-prefixed metro port (always exported alongside +// android/macos ports). Do not key off RNFB_E2E_PLATFORM. +function readNativeMetroPort() { + if (process.env.RNFB_IOS_METRO_PORT) { + return process.env.RNFB_IOS_METRO_PORT; + } + if (process.env.RCT_METRO_PORT) { + return process.env.RCT_METRO_PORT; + } + if (process.env.RNFB_METRO_PORT) { + return process.env.RNFB_METRO_PORT; + } + return '8081'; +} + +function iosXcodebuildPrefix() { + return `RCT_METRO_PORT=${readNativeMetroPort()}`; +} + +const IOS_SIM_NAMES = [ + 'iPhone 17', + 'RNFB E2E iOS slot-1', + 'RNFB E2E iOS slot-2', + 'RNFB E2E iOS slot-3', + 'RNFB E2E iOS slot-4', +]; + +const ANDROID_AVD_NAMES = [ + 'TestingAVD', + 'TestingAVD-1', + 'TestingAVD-2', + 'TestingAVD-3', + 'TestingAVD-4', +]; + +function iosSimulatorDevice(slot) { + const deviceType = process.env.RNFB_IOS_BASE_SIMULATOR || 'iPhone 17'; + if (slot === 0) { + return { + type: 'ios.simulator', + device: { type: deviceType, name: deviceType }, + }; + } + const slotName = IOS_SIM_NAMES[slot] || `RNFB E2E iOS slot-${slot}`; + return { + type: 'ios.simulator', + device: { type: deviceType, name: slotName }, + }; +} + +function androidEmulatorDevice(slot) { + return { + type: 'android.emulator', + device: { avdName: ANDROID_AVD_NAMES[slot] || `TestingAVD-${slot}` }, + bootArgs: process.env.RNFB_ANDROID_EMULATOR_BOOT_ARGS || '-no-snapshot-load -no-snapshot-save', + readonly: true, + }; +} + +function androidApp(reversePorts) { + return { + type: 'android.apk', + binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk', + build: + 'cd android && ./gradlew-with-worker-cap.sh assembleDebug assembleAndroidTest lintDebug -DtestBuildType=debug --warning-mode all --stacktrace && cd ..', + reversePorts, + }; +} + +function androidAppWindows(reversePorts) { + return { + type: 'android.apk', + binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk', + build: + 'cd android && .\\gradlew-with-worker-cap.bat assembleDebug assembleAndroidTest lintDebug -DtestBuildType=debug --warning-mode all --stacktrace && cd ..', + reversePorts, + }; +} + +const devices = { + simulator: iosSimulatorDevice(0), + attached: { + type: 'android.attached', + device: { adbName: '.*' }, }, - apps: { - 'ios.debug': { - type: 'ios.app', - binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/testing.app', - build: - 'set -o pipefail && xcodebuild VALID_ARCHS="`uname -m`" CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ -workspace ios/testing.xcworkspace -scheme testing -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build | xcbeautify', - }, - 'ios.release': { - type: 'ios.app', - binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/testing.app', - build: - 'export RCT_NO_LAUNCH_PACKAGER=true && set -o pipefail | xcodebuild CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ -workspace ios/testing.xcworkspace -scheme testing -configuration Release -sdk iphonesimulator -derivedDataPath ios/build | xcbeautify', - }, - 'android.debug': { - type: 'android.apk', - binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk', - // keep in sync with android.debug.windows below, except gradlew vs gradlew.bat - build: - 'cd android && ./gradlew-with-worker-cap.sh assembleDebug assembleAndroidTest lintDebug -DtestBuildType=debug --warning-mode all --stacktrace && cd ..', - reversePorts: [8080, 8081, 8090, 9000, 9099, 9199], - }, - 'android.debug.windows': { - type: 'android.apk', - binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk', - // android.debug.windows only exists to use .bat script vs shell here: - build: - 'cd android && .\\gradlew-with-worker-cap.bat assembleDebug assembleAndroidTest lintDebug -DtestBuildType=debug --warning-mode all --stacktrace && cd ..', - reversePorts: [8080, 8081, 8090, 9000, 9099, 9199], - }, - 'android.release': { - type: 'android.apk', - binaryPath: 'android/app/build/outputs/apk/release/app-release.apk', - build: 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release', - }, + emulator: androidEmulatorDevice(0), +}; + +for (let slot = 1; slot < 5; slot += 1) { + devices[`simulator-slot${slot}`] = iosSimulatorDevice(slot); + devices[`emulator-slot${slot}`] = androidEmulatorDevice(slot); +} + +const apps = { + 'ios.debug': { + type: 'ios.app', + binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/testing.app', + build: + `set -o pipefail && ${iosXcodebuildPrefix()} xcodebuild VALID_ARCHS="\`uname -m\`" CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ -workspace ios/testing.xcworkspace -scheme testing -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build | xcbeautify`, }, - devices: { - simulator: { - type: 'ios.simulator', - device: { - type: 'iPhone 17', - }, - }, - attached: { - type: 'android.attached', - device: { - adbName: '.*', - }, - }, - emulator: { - type: 'android.emulator', - device: { - avdName: 'TestingAVD', - }, - // Cold boot: do not load/save AVD snapshots (warm quickboot is unreliable locally). - bootArgs: - process.env.RNFB_ANDROID_EMULATOR_BOOT_ARGS || '-no-snapshot-load -no-snapshot-save', - readonly: true, - }, + 'ios.release': { + type: 'ios.app', + binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/testing.app', + build: + `export RCT_NO_LAUNCH_PACKAGER=true && set -o pipefail && ${iosXcodebuildPrefix()} xcodebuild CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ -workspace ios/testing.xcworkspace -scheme testing -configuration Release -sdk iphonesimulator -derivedDataPath ios/build | xcbeautify`, }, - configurations: { - 'ios.sim.debug': { - device: 'simulator', - app: 'ios.debug', - }, - 'ios.sim.release': { - device: 'simulator', - app: 'ios.release', - }, - 'android.att.debug': { - device: 'attached', - app: 'android.debug', - }, - 'android.att.release': { - device: 'attached', - app: 'android.release', - }, - 'android.emu.debug': { - device: 'emulator', - app: 'android.debug', - }, - 'android.emu.debug.windows': { - device: 'emulator', - app: 'android.debug.windows', - }, - 'android.emu.release': { - device: 'emulator', - app: 'android.release', - }, + 'android.debug': androidApp(ANDROID_REVERSE_DEFAULT), + 'android.debug.windows': androidAppWindows(ANDROID_REVERSE_DEFAULT), + 'android.release': { + type: 'android.apk', + binaryPath: 'android/app/build/outputs/apk/release/app-release.apk', + build: 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release', + }, +}; + +for (let slot = 1; slot < 5; slot += 1) { + apps[`android.debug.slot${slot}`] = androidApp(ANDROID_REVERSE_DEFAULT); + apps[`android.debug.slot${slot}.windows`] = androidAppWindows(ANDROID_REVERSE_DEFAULT); +} + +const configurations = { + 'ios.sim.debug': { device: 'simulator', app: 'ios.debug' }, + 'ios.sim.release': { device: 'simulator', app: 'ios.release' }, + 'android.att.debug': { device: 'attached', app: 'android.debug' }, + 'android.att.release': { device: 'attached', app: 'android.release' }, + 'android.emu.debug': { device: 'emulator', app: 'android.debug' }, + 'android.emu.debug.windows': { device: 'emulator', app: 'android.debug.windows' }, + 'android.emu.release': { device: 'emulator', app: 'android.release' }, +}; + +for (let slot = 1; slot < 5; slot += 1) { + configurations[`ios.sim.debug.slot${slot}`] = { + device: `simulator-slot${slot}`, + app: 'ios.debug', + }; + configurations[`android.emu.debug.slot${slot}`] = { + device: `emulator-slot${slot}`, + app: `android.debug.slot${slot}`, + }; + configurations[`android.emu.debug.slot${slot}.windows`] = { + device: `emulator-slot${slot}`, + app: `android.debug.slot${slot}.windows`, + }; +} + +module.exports = { + testRunner: { + args: { $0: 'jest', config: 'e2e/jest.config.js' }, + jest: { setupTimeout: 120000 }, }, + apps, + devices, + configurations, }; diff --git a/tests/.jetrc.js b/tests/.jetrc.js index 84d60d31b1..e0768e4c22 100644 --- a/tests/.jetrc.js +++ b/tests/.jetrc.js @@ -7,6 +7,135 @@ let macOsRetries = 0; const MACOS_BUNDLE_QUERY = 'platform=macos&dev=true&lazy=true&minify=false&inlineSourceMap=true&modulesOnly=false&runModule=true&app=org.reactjs.native.io-invertase-testing'; +const SERIAL_JET_PORT = 8090; +const SERIAL_METRO_PORT = 8081; + +function parseEnvPort(value) { + if (value === undefined || value === null || value === '') { + return null; + } + const n = parseInt(value, 10); + return Number.isFinite(n) ? n : null; +} + +// Prefer process-local binds set by firebase.test.js spawnJet / launchers. +// Fall back to an explicit platform key — never RNFB_E2E_PLATFORM (shared +// babel/transform cache + multi-platform worktrees; see running-e2e.md). +function readJetPort(platformKey) { + const fromLocal = parseEnvPort(process.env.JET_REMOTE_PORT); + if (fromLocal != null) { + return fromLocal; + } + let prefixed = null; + switch (platformKey) { + case 'android': + prefixed = parseEnvPort(process.env.RNFB_ANDROID_JET_PORT); + break; + case 'ios': + prefixed = parseEnvPort(process.env.RNFB_IOS_JET_PORT); + break; + case 'macos': + prefixed = parseEnvPort(process.env.RNFB_MACOS_JET_PORT); + break; + default: + break; + } + return prefixed != null ? prefixed : SERIAL_JET_PORT; +} + +function readMetroPort(platformKey) { + const fromLocal = + parseEnvPort(process.env.RCT_METRO_PORT) ?? parseEnvPort(process.env.RNFB_METRO_PORT); + if (fromLocal != null) { + return fromLocal; + } + let prefixed = null; + switch (platformKey) { + case 'android': + prefixed = parseEnvPort(process.env.RNFB_ANDROID_METRO_PORT); + break; + case 'ios': + prefixed = parseEnvPort(process.env.RNFB_IOS_METRO_PORT); + break; + case 'macos': + prefixed = parseEnvPort(process.env.RNFB_MACOS_METRO_PORT); + break; + default: + break; + } + return prefixed != null ? prefixed : SERIAL_METRO_PORT; +} + +function isMacOsTestAppRunning() { + try { + execSync('pgrep -x io.invertase.testing', { stdio: 'ignore' }); + return true; + } catch (_e) { + return false; + } +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function killMacOsTestApp() { + if (!isMacOsTestAppRunning()) { + return; + } + try { + execSync('killall "io.invertase.testing"', { stdio: 'ignore' }); + } catch (_e) { + // already gone + } + await sleep(500); + if (isMacOsTestAppRunning()) { + try { + execSync('killall -9 "io.invertase.testing"', { stdio: 'ignore' }); + } catch (_e) { + // already gone + } + } + // Short retry loop (~2s total) so callers can rely on the process being + // gone before a piped `tee` is expected to see EOF. + const deadline = Date.now() + 2000; + while (Date.now() < deadline && isMacOsTestAppRunning()) { + await sleep(250); + } + if (isMacOsTestAppRunning()) { + console.warn( + '[rnfb-e2e] io.invertase.testing still running after killall -9 — tee/pipe may not close', + ); + } +} + +let macOsExitHandlersRegistered = false; + +// Best-effort synchronous cleanup on process exit/abort so an interrupted +// run doesn't leave the app holding the tee pipe open indefinitely. +function registerMacOsExitHandlers() { + if (macOsExitHandlersRegistered) { + return; + } + macOsExitHandlersRegistered = true; + const cleanup = () => { + try { + execSync('killall -9 "io.invertase.testing"', { stdio: 'ignore' }); + } catch (_e) { + // already gone + } + }; + process.on('exit', cleanup); + process.on('SIGINT', () => { + cleanup(); + process.exit(130); + }); + process.on('SIGTERM', () => { + cleanup(); + process.exit(143); + }); +} + async function waitForMetroMacosBundle(metroPort = 8081, timeoutMs = 600000) { const host = '127.0.0.1'; const statusUrl = `http://${host}:${metroPort}/status`; @@ -32,6 +161,8 @@ async function waitForMetroMacosBundle(metroPort = 8081, timeoutMs = 600000) { module.exports = { config: { + // Serial fallback only — each target.before() sets the real port. + port: SERIAL_JET_PORT, slow: 3000, reporter: 'spec', timeout: 420000, // 7 minutes - fetchAndActivate takes 5+ sometimes @@ -43,6 +174,7 @@ module.exports = { targets: { android: { async before(config) { + config.port = readJetPort('android'); return config; }, async after(_config) { @@ -51,6 +183,7 @@ module.exports = { }, ios: { async before(config) { + config.port = readJetPort('ios'); return config; }, async after(_config) { @@ -59,19 +192,27 @@ module.exports = { }, macos: { async before(config) { - try { - execSync(`killall "io.invertase.testing"`); - } catch (_e) { - // noop - } - await waitForMetroMacosBundle(config.metroPort ?? 8081); - const macApp = spawn( - 'open', - ['./macos/build/Build/Products/Debug/io.invertase.testing.app'], - { - stdio: ['ignore', 'inherit', 'inherit'], + await killMacOsTestApp(); + registerMacOsExitHandlers(); + const metroPort = readMetroPort('macos'); + const jetPort = readJetPort('macos'); + config.metroPort = metroPort; + config.port = jetPort; + await waitForMetroMacosBundle(metroPort); + const macBinary = + './macos/build/Build/Products/Debug/io.invertase.testing.app/Contents/MacOS/io.invertase.testing'; + const macApp = spawn(macBinary, [], { + // 'ignore' (not 'inherit'): inherited stdio hands the app the + // write end of the agent's stdout/stderr pipe (e.g. `| tee`), so + // the pipe never sees EOF and the shell hangs after the suite + // finishes even though the app has nothing left to print. + stdio: ['ignore', 'ignore', 'ignore'], + env: { + ...process.env, + RCT_METRO_PORT: String(metroPort), + JET_REMOTE_PORT: String(jetPort), }, - ); + }); macApp.on('close', code => { if (code === 0) { return; @@ -95,10 +236,11 @@ module.exports = { return config; }, async after(_config) { - try { - execSync(`killall "io.invertase.testing"`); - } catch (_e) { - // noop + await killMacOsTestApp(); + if (isMacOsTestAppRunning()) { + console.warn('[rnfb-e2e] macOS app teardown FAILED — io.invertase.testing still alive'); + } else { + console.warn('[rnfb-e2e] macOS app teardown complete'); } }, }, diff --git a/tests/app.js b/tests/app.js index ca15bf146b..e63cb8b298 100644 --- a/tests/app.js +++ b/tests/app.js @@ -21,6 +21,12 @@ import { StyleSheet, View, StatusBar, AppRegistry, Text, Button } from 'react-na import { JetProvider, ConnectionText, StatusEmoji, StatusText } from 'jet'; +import { + getE2eEmulatorHost, + getE2eEmulatorPort, + getJetRemoteUrl, +} from '@react-native-firebase/app/e2e/helpers'; + import { TestComponents } from './local-tests'; let harnessOverrides = {}; @@ -91,24 +97,29 @@ function loadTests(_) { } before(async function () { + const emuHost = getE2eEmulatorHost(); if (platformSupportedModules.includes('functions')) { const { connectFunctionsEmulator, getFunctions } = functionsModular; - connectFunctionsEmulator(getFunctions(), 'localhost', 5001); + connectFunctionsEmulator(getFunctions(), emuHost, getE2eEmulatorPort('functions')); } if (platformSupportedModules.includes('database')) { const { connectDatabaseEmulator, getDatabase } = databaseModular; - connectDatabaseEmulator(getDatabase(), 'localhost', 9000); + connectDatabaseEmulator(getDatabase(), emuHost, getE2eEmulatorPort('database')); } if (platformSupportedModules.includes('auth')) { const { connectAuthEmulator, getAuth } = authModular; - connectAuthEmulator(getAuth(), 'http://localhost:9099'); + connectAuthEmulator( + getAuth(), + `http://${emuHost}:${getE2eEmulatorPort('auth')}`, + ); } if (platformSupportedModules.includes('firestore')) { const { getApp } = modular; const { connectFirestoreEmulator, clearIndexedDbPersistence, getFirestore } = firestoreModular; - connectFirestoreEmulator(getFirestore(), 'localhost', 8080); - connectFirestoreEmulator(getFirestore(getApp(), 'second-rnfb'), 'localhost', 8080); + const fsPort = getE2eEmulatorPort('firestore'); + connectFirestoreEmulator(getFirestore(), emuHost, fsPort); + connectFirestoreEmulator(getFirestore(getApp(), 'second-rnfb'), emuHost, fsPort); // Firestore caches documents locally (a great feature!) and that confounds tests // as data from previous runs pollutes following runs until re-install the app. Clear it. if (!Platform.other) { @@ -118,12 +129,13 @@ function loadTests(_) { if (platformSupportedModules.includes('storage')) { const { getApp } = modular; const { getStorage, connectStorageEmulator } = storageModular; - connectStorageEmulator(getStorage(), 'localhost', 9199); - connectStorageEmulator(getStorage(getApp('secondaryFromNative')), 'localhost', 9199); + const stPort = getE2eEmulatorPort('storage'); + connectStorageEmulator(getStorage(), emuHost, stPort); + connectStorageEmulator(getStorage(getApp('secondaryFromNative')), emuHost, stPort); connectStorageEmulator( getStorage(getApp(), 'gs://react-native-firebase-testing'), - 'localhost', - 9199, + emuHost, + stPort, ); } }); @@ -314,7 +326,7 @@ function App() {