diff --git a/.agents/queues/parallel-e2e-lifecycle-review.md b/.agents/queues/parallel-e2e-lifecycle-review.md new file mode 100644 index 0000000000..2d1412754b --- /dev/null +++ b/.agents/queues/parallel-e2e-lifecycle-review.md @@ -0,0 +1,32 @@ +--- +type: WorkQueue +title: Parallel e2e lifecycle adversarial review +ephemeral: true +timestamp: 2026-07-19T18:34:00Z +--- + +# Parallel e2e lifecycle — adversarial review + +**IN PROGRESS:** (none — item complete) +**Next pickup:** (none) +**Current snapshot:** P5-1..P5-4 closed; committing with subject below + +## Gates + +| Item | Status | +|------|--------| +| P5-1/2 ELI14 + lifecycle | closed | +| P5-3 3×3 validation | **pass** (android 1478 / ios 1448 / macos 1275) | +| P5-4 findings docs | closed | +| commit | closed | +| `commit_subject` | `docs(e2e): ELI14 parallel design + slot-scoped lifecycle` | +| `implementation_gate` | closed | +| `review_gate` | closed | +| `coverage_evidence_gate` | n/a (docs/scripts/harness config; no packages lib/native) | +| `commit_gate` | closed | + +## Notes + +- P5-3 evidence: 3× android / 3× ios / 3× macos parallel slotted runs green. +- P5-4 remediations: `--devices`/pods/first-use docs aligned with scripts. +- Untracked `mellifera/` and mellifera helper scripts left out of this commit (separate experiment). diff --git a/.agents/queues/pr-9099-russell-review.md b/.agents/queues/pr-9099-russell-review.md new file mode 100644 index 0000000000..16c6bbd24e --- /dev/null +++ b/.agents/queues/pr-9099-russell-review.md @@ -0,0 +1,47 @@ +--- +type: WorkQueue +title: PR 9099 adversarial findings remediation +ephemeral: true +--- + +# PR 9099 — adversarial findings (R3) + +> **DONE:** R3 — all gates closed; review approved +> **Next pickup:** done + +| Item | Scope | `commit_subject` (target amend) | `implementation_gate` | `review_gate` | `coverage_evidence_gate` | `commit_gate` | `next_work_type` | `validation_tier` | Notes | +|------|-------|----------------------------------|----------------------|---------------|--------------------------|---------------|------------------|-------------------|-------| +| R3-A | #1 #4 #8 helpers static env + jet/metro precedence | `test(e2e): make all contentious e2e resources configurable` | closed | closed | n/a* | closed | done | `area-focused` | *helpers are e2e JS under packages/app/e2e — not lib; coverage n/a unless review says otherwise | +| R3-B | #2 #3 #5 #7 check/release + preflight + stale JSON | `test(e2e): add env-aware host check and release scripts` | closed | closed | n/a | closed | done | `area-focused` | A/B fixed — SIGTERM-immune metro recheck evidence | +| R3-C | #6 ORG_GRADLE Android Metro wiring | `test(e2e): make all contentious e2e resources configurable` (scripts/docs as needed) | closed | closed | n/a | closed | done | `area-focused` | Wire into android test scripts + env docs | + +## Findings status +### #1–#8 — fixed (implementation closed) +1–8 addressed in prior R3 implementation. + +### Findings A/B — fixed (implementation closed) +- **Finding A:** release recheck without `--services` — fixed; SIGTERM-immune metro recheck evidence +- **Finding B:** `--mellifera` flag clobbered by child check — fixed; SIGTERM-immune metro recheck evidence + +## Amend policy +Folded via fixup + autosquash onto `origin/main`. Final history remains exactly 3 commits with subjects: +1. `test(e2e): make all contentious e2e resources configurable` +2. `test(macos): e2e run completion detection fixes` +3. `test(e2e): add env-aware host check and release scripts` + +## Current gates +- next_work_type: done +- validation_tier: area-focused +- implementation_gate: closed +- review_gate: closed (approved) +- commit_gate: closed +- coverage_evidence_gate: n/a + +## Validation evidence (compact) +- lint: pass +- jest: 12/12 pass +- babel smoke: pass +- check default vs `--services`: pass (default ≠ Metro/emulator BUSY) +- macos / ios / android: exit 0 +- A/B: SIGTERM-immune metro recheck evidence recorded +- independent review: approved 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/ci-workflows/other.md b/okf-bundle/ci-workflows/other.md index 61fc471f00..93d4ecbb48 100644 --- a/okf-bundle/ci-workflows/other.md +++ b/okf-bundle/ci-workflows/other.md @@ -41,6 +41,7 @@ URL: http://localhost:8081/index.bundle?platform=macos&...&inlineSourceMap=false ```bash rg 'Could not connect to development server|HTTP load failed|Jet client connected|macOS Metro bundle prefetched' detox-step.log syslog.log log show --predicate 'process == "io.invertase.testing"' --last 10m --style compact | rg 'development server|HTTP load failed' +# Slotted concurrent macOS: substitute RNFB_MACOS_PRODUCT_NAME (e.g. io.invertase.testing.s1) ``` ### CI failure: `tryDeserialize is not a function` (mocha-remote-server patch) diff --git a/okf-bundle/index.md b/okf-bundle/index.md index 325c54189d..19f75f8880 100644 --- a/okf-bundle/index.md +++ b/okf-bundle/index.md @@ -16,7 +16,8 @@ okf_version: '0.1' - [Agent command policy](/testing/agent-command-policy.md) — allowlisted shell commands for agents (install, prepare, validation, e2e) - [Change authoring workflow](/testing/change-authoring-workflow.md) — verified product change loop (unit-focused → area-focused review → commit); [§ validation evidence (blocking)](testing/change-authoring-workflow.md#validation-evidence-blocking); [coverage evidence package](testing/coverage-design.md#coverage-evidence-package) - [Iteration vocabulary](/testing/iteration-vocabulary.md) — work type, tier, and queue field identifiers -- [Running e2e tests](/testing/running-e2e.md) — canonical e2e commands, narrowing, environment, diagnosis +- [Running e2e tests](/testing/running-e2e.md) — canonical e2e commands, narrowing, environment, diagnosis; [§ slot lifecycle](testing/running-e2e.md#slot-lifecycle) +- [E2e parallel design](/testing/e2e-parallel-design.md) — resources, why serialization, parameterization, coordinator rollout - [Validation checklist](/testing/validation-checklist.md) — compile, Jest, lint, `compare:types`, e2e, coverage - [Coverage design](/testing/coverage-design.md) — unit/e2e coverage policy, native gates, Codecov - [Firebase testing project](/testing/firebase-testing-project.md) — cloud vs emulator, live FIS/RC, helper callables, rules/indexes, deploy diff --git a/okf-bundle/testing/agent-command-policy.md b/okf-bundle/testing/agent-command-policy.md index e9539943b7..40f0467bd0 100644 --- a/okf-bundle/testing/agent-command-policy.md +++ b/okf-bundle/testing/agent-command-policy.md @@ -35,7 +35,8 @@ Single source for **which shell commands agents may run** in this repo. E2e is a | Docs lint (when docs in diff) | `yarn lint:markdown`, `yarn lint:spellcheck` | ad-hoc prettier/eslint on single files | | E2e + coverage | [running e2e](running-e2e.md) — **only** `yarn tests:*` | `jet`, `npx jet`, `yarn jet`, `detox test`, `cd tests && …`, direct Metro/emulator starts | | iOS Detox framework cache rebuild | `yarn tests:ios:detox-framework-cache:rebuild` | `cd tests && yarn detox clean-framework-cache`, `cd tests && yarn detox build-framework-cache`, bare `detox …` | -| Host pre-flight (before each `:test-cover`) | [running e2e § host-clear probes](running-e2e.md#host-clear-probes) | `pgrep`, polling `:8090`, spawn probes of Jet/Detox | +| Host pre-flight (before each `:test-cover`) | [running e2e § host-clear probes](running-e2e.md#host-clear-probes) — `bash scripts/e2e/check-e2e-resources.sh` / `bash scripts/e2e/release-e2e-resources.sh` | ad-hoc `pgrep` / hardcoded `:8090` only; improvised kill lists | +| Slotted parallel env / packager / `:test-cover` | [running e2e § slot lifecycle](running-e2e.md#slot-lifecycle) — `export-slot-env.sh`, `check-e2e-resources.sh` / `release-e2e-resources.sh` (with env loaded = slot-scoped), `start-emulator-slotted.sh`, `run-slotted-packager.sh`, `yarn tests::build`, `run-slotted-test-cover.sh` (or yarn `tests:e2e:export-slot-env` / `tests:e2e:slotted-*`) | ad-hoc port math; `.r4-*` scratch launchers; hardcoding `io.invertase.testing` for concurrent macOS; unscoped release while another slot is running | ### Prepare / transpile (detail) @@ -77,13 +78,21 @@ Single source for **which shell commands agents may run** in this repo. E2e is a | `cd packages/ && yarn prepare` / `yarn run build` | Same trap; not the postinstall / lerna code path | | `yarn jet`, `npx jet`, `cd tests && yarn jet …` | [E2e agent rule](running-e2e.md#agent-rule-read-first) | | `detox test`, `cd tests && detox …` | E2e agent rule | -| Ad-hoc Metro / emulator start | Use `yarn tests:packager:jet`, `yarn tests:emulator:start` | +| Ad-hoc Metro / emulator start | Use `yarn tests:packager:jet`, `yarn tests:emulator:start`. Does **not** forbid allowlisted `run-slotted-packager.sh` / `start-emulator-slotted.sh` ([registry](#canonical-registry)) | | Spawn / PATH probes to “test” Jet or genversion | Log triage only; fix product code and re-run canonical command | ## Known traps + + +### Cursor Shell sandbox / permissions + +When a Shell command returns with **no exit status** (e.g. "execution backend unavailable") under default sandbox permissions, retry the **same** canonical command with `required_permissions: ["all"]` — do **not** invent an alternate command because the sandboxed attempt failed to start. + +Local e2e (`yarn tests:*:test-cover`), the packager, emulator start, native builds, and host pre-flight probes that need real devices/simulators typically need unrestricted permissions on this host. A "no exit status" result on those commands is a sandbox artifact, not evidence the run failed or is incomplete — see [running e2e § running one iteration](running-e2e.md#running-one-iteration) for checking the tee log footer before concluding anything from a missing exit code. + ### genversion / prepare paths - **`genversion` exists** at root `node_modules/.bin` after `yarn`. 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/e2e-parallel-design.md b/okf-bundle/testing/e2e-parallel-design.md new file mode 100644 index 0000000000..2eea5cec36 --- /dev/null +++ b/okf-bundle/testing/e2e-parallel-design.md @@ -0,0 +1,556 @@ +--- +type: Reference +title: E2e parallel execution and host coordination +description: ELI14 architecture for parallel RNFB e2e — host resources, why they serialize, slot parameterization, and coordinator rollout. Commands live in running-e2e.md. +tags: [testing, e2e, detox, jet, parallel, coordination, design] +timestamp: 2026-07-19T00:00:00Z +--- + +# E2e parallel execution and host coordination + +Architecture and lifecycle for running Android, iOS, and macOS e2e in parallel (within a worktree and across worktrees). **Commands and the slot recipe** live in [running e2e](running-e2e.md) ([slot lifecycle](running-e2e.md#slot-lifecycle), [parallel topology](running-e2e.md#parallel-e2e-topology)); this doc explains *why* the resources collide and *how* parameterization isolates them. + +**Policy:** [OKF documentation and commit policy](../documentation-policy.md). **Coverage:** [coverage design](coverage-design.md). + +## How parallel e2e works (ELI14) + +RNFB’s local e2e stack is a small set of **named host resources**. If two runs share any of them, they fight. Parameterization gives each run its own copy. + +### 1. Exact resources (serial defaults) + +| Resource | What it is | Serial default | +|----------|------------|----------------| +| **Metro** | JS bundler listen port | `:8081` | +| **Jet WebSocket** | Mocha-remote test runner | `:8090` | +| **Jet control HTTP** | Paired control plane | `:8091` (Jet+1) | +| **Firebase emulator suite** | auth / database / firestore / functions / storage / hub / logging | `:9099` / `:9000` / `:8080` / `:5001` / `:9199` / `:4400` / `:4500` | +| **Emulator aux ports** | Firestore websocket, Eventarc, Cloud Tasks (Firebase Tools still binds these) | `:9150` / `:9299` / `:9499` (collide if two suites share a host) | +| **Android** | AVD + adb serial | Serial: `TestingAVD` / `emulator-5554`. Slotted: `TestingAVD-{n}` (incl. `-0`) | +| **iOS** | Simulator device name | Serial: `iPhone 17`. Slotted: `RNFB E2E iOS slot-{n}` (incl. `slot-0`) | +| **macOS** | Process / `PRODUCT_NAME` (+ derived bundle id) | `io.invertase.testing` | +| **Coverage paths** | NYC / coverage under `tests/` | Fixed per worktree (same-platform parallel overwrites) | + +One worktree also has **one** `macos/build` (and iOS/Android build product tree) — not safe for two same-platform `:test-cover`s in that tree. + +### 2. Why that forces serialized testing (if not parameterized) + +- **One listener per port** — second Metro/Jet/emulator bind → `EADDRINUSE` or a stolen session. +- **One macOS process name** — `killall` / Jet client collision; stale macOS on a shared Jet port breaks Android. +- **Shared emulator DB** — Firestore `wipe()` and auth/storage state are global per emulator process. +- **One derived-data / build tree per worktree** — two same-platform runs race native products and coverage files. +- **OKF serial default** still assumes the table above ([running e2e § Rules #6](running-e2e.md#rules)): correct for gate closure, slow for iteration (~15m macOS + ~45–60m iOS + ~45–60m Android wall-clock if run one after another). + +### 3. How parameterization fixes it + +- **Per-platform env:** `RNFB_{ANDROID,IOS,MACOS}_METRO_PORT`, `_JET_PORT`, `_JET_CONTROL_PORT`, `_EMULATOR_*_PORT`, plus device overrides (`RNFB_ANDROID_AVD`, `RNFB_IOS_SIMULATOR`, **`RNFB_MACOS_PRODUCT_NAME`**). +- **Slot formula:** `BASE = 12000 + slot×1000`; android offset `0`, ios `+100`, macos `+200`. Inside a platform block: firestore…logging = `BLK+0..6`, metro `+7`, jet `+10`, jet-control `+11`; aux ports `FS+8/+9/+12` in `start-emulator-slotted.sh`. +- **Full babel carry-in:** every Metro/Jest process for a slot gets the **full** `RNFB_{ANDROID,IOS,MACOS}_*` set (static `process.env.RNFB_*` inlining). Process-local binds (`RCT_METRO_PORT`, `JET_REMOTE_PORT`) say which socket *this* process owns. +- **Worktree topology:** at most **`1× android ∥ 1× ios ∥ 1× macos`** per worktree (same slot index, three port blocks). Same-platform scale-out = more worktrees (`macos-slot-N` + `io.invertase.testing.sN`, not a host-global `macos-global` lock). +- **macOS build:** `yarn tests:macos:build` uses `RNFB_MACOS_PRODUCT_NAME_SUFFIX` in the pbxproj — never pass global `PRODUCT_NAME=` on the `xcodebuild` CLI. + +Commands for clear → start → build → test → free: [running e2e § slot lifecycle](running-e2e.md#slot-lifecycle). + +## Goals + +| Goal | Description | +|------|-------------| +| **Tri-platform parallel (Phase 1)** | One worktree runs **at most one** Android, **one** iOS, and **one** macOS `:test-cover` **concurrently** — three platform jobs in parallel, not three Android emulators in one worktree. | +| **Cross-worktree safety (Phase 2)** | Multiple RNFB worktrees on the same Mac **checkout / checkin / stale-clean** host resources so two agents never bind the same emulator, Jet port, or macOS `PRODUCT_NAME`. | +| **Capacity-aware queue (Phase 3)** | Host **advertises** how many Android / iOS / macOS slots it can sustain; worktrees **wait and reserve** when saturated. | +| **macOS hygiene + identity** | macOS e2e **terminates the test app** on success and failure; concurrent macOS uses distinct **`RNFB_MACOS_PRODUCT_NAME`** (proven — see below), not a host-global singleton. | + +### Proven status (2026-07) + +| Capability | Status | Canonical | +|------------|--------|-----------| +| Per-platform prefixed ports + full carry-in | **Shipped** | [running e2e § configurable env](running-e2e.md#configurable-e2e-environment) | +| Worktree topology (`1× android ∥ 1× ios ∥ 1× macos` per tree) | **Shipped** | [parallel topology](running-e2e.md#parallel-e2e-topology) | +| Slotted launch helpers | **Shipped** | `export-slot-env.sh`, `start-emulator-slotted.sh`, `run-slotted-packager.sh`, `run-slotted-test-cover.sh` + [slot lifecycle](running-e2e.md#slot-lifecycle) | +| Concurrent macOS via `PRODUCT_NAME` | **Shipped** (e.g. `io.invertase.testing.s0`…`sN`) | [macOS process identity](running-e2e.md#macos-process-identity-concurrency) | +| Host check/release env-aware (slot-scoped when env loaded; unscoped `.sN` wipe) | **Shipped** | `check-e2e-resources.sh` / `release-e2e-resources.sh` | +| Coordinator / lease queue (mellifera) | **WIP** (uncommitted experimental) | [`mellifera/`](../../mellifera/) when present | + +> **Coordinator note:** [`mellifera/`](../../mellifera/) remains the experimental lease service (opt-in `RNFB_MELLIFERA=1`). Serial `yarn tests:*` and slotted scripts above do **not** require it. Older drafts called macOS `macos-global`; that lease shape is **superseded** by `macos-slot-N` + `RNFB_MACOS_PRODUCT_NAME`. + +## Non-goals + +- More than **one concurrent `:test-cover` per platform per worktree** (no 2× Android or 2× macOS in the same worktree — one `macos/build` derived-data tree). +- Replacing GitHub Actions matrix parallelism (CI already uses separate runners). +- Enterprise emulator / Firestore Pipelines cloud isolation (unchanged; see [firebase testing project](firebase-testing-project.md)). +- Tart VM orchestration changes in Phase 1 (Tart remains an optional iOS isolation path; see [scripts/tart/README.md](../../scripts/tart/README.md)). + +## Constraints (locked for this design) + +1. **Per worktree:** max **1 Android + 1 iOS + 1 macOS** e2e run at a time. +2. **Per host:** same-platform parallel needs **one worktree per instance**. macOS isolation key is **`PRODUCT_NAME` / process name** (`RNFB_MACOS_PRODUCT_NAME`), not a single global lock. +3. **Firebase emulators:** parallel platform runs **must not share one emulator process** — Firestore `wipe()` and auth/storage state are global per emulator instance ([`packages/firestore/e2e/helpers.js`](../../packages/firestore/e2e/helpers.js)). Full aux-port isolation (websocket / eventarc / tasks) required — see [running e2e](running-e2e.md#configurable-e2e-environment). +4. **Backward compatibility:** unset slotted env preserves serial defaults (ports `:8081` / `:8090` / …, process `io.invertase.testing`). + +## Resource inventory (detail) + +Serial defaults and conflict modes are summarized in [§ How parallel e2e works](#how-parallel-e2e-works-eli14). Slotted support: per-platform `RNFB_*` ports + `start-emulator-slotted.sh`, Detox slot configs, and **`RNFB_MACOS_PRODUCT_NAME`**. Cross-platform Jet note: stale macOS on a **shared** Jet port still breaks Android ([running e2e § Android app reset](running-e2e.md#android-app-reset-blocking)) — slotted runs give each platform its own Jet; teardown must still kill the **named** macOS process. + +### Slot lifecycle (mirror) + +For arbitrary slot `N` and platform `android|ios|macos`, the committed path is: + +1. First use of a slot (incl. `N=0`): `yarn tests:e2e:setup-android-avds` / `yarn tests:e2e:setup-ios-sims`. Then `eval "$(bash scripts/e2e/export-slot-env.sh N)"` — full carry-in + slot device identities (`TestingAVD-N` / `RNFB E2E iOS slot-N` / `io.invertase.testing.sN`). +2. `bash scripts/e2e/check-e2e-resources.sh` / `release-e2e-resources.sh` — **slot-scoped** when that env is loaded; default release clears **ports+apps** for all three platform blocks (not AVD/sims). Mid-wave early free: `--platform=` (devices may stay up). Unscoped wipe of `.s0..sN` only when slot env is unset. +3. `bash scripts/e2e/start-emulator-slotted.sh ` (+ optional slot) + `bash scripts/e2e/run-slotted-packager.sh N`. +4. `yarn tests::build` (macOS: suffix via `RNFB_MACOS_PRODUCT_NAME`). After worktree reset/sync for iOS, run `yarn tests:ios:pod:install` first if Pods/Manifest.lock may have drifted. +5. `bash scripts/e2e/run-slotted-test-cover.sh N`. +6. End-of-slot / final free: `release-e2e-resources.sh --devices` with the **same** env still loaded — default release alone leaves sims/AVDs up; slotted `check --platform=ios` then reports **BUSY**. + +Full recipe and caveats: [running e2e § slot lifecycle](running-e2e.md#slot-lifecycle). + +## Resource model + +### Two layers + +```text +┌─────────────────────────────────────────────────────────────┐ +│ Host coordinator (Phase 2+) — lease / capacity │ +│ android-slot-0..N ios-slot-0..M macos-slot-0..K │ +│ (each macos-slot carries RNFB_MACOS_PRODUCT_NAME=.sN) │ +└──────────────────────────┬──────────────────────────────────┘ + │ lease → port map + device / PRODUCT_NAME +┌──────────────────────────▼──────────────────────────────────┐ +│ Worktree runner — one slot index per worktree │ +│ Parallel: android-run ∥ ios-run ∥ macos-run │ +│ (at most one of each platform; distinct port blocks) │ +└─────────────────────────────────────────────────────────────┘ +``` + +Same-platform scale-out: **N worktrees × slot 0..N-1** (e.g. `3× android + 3× ios + 3× macos`). + +### Metro per worktree and per slot + + + +Metro is already multi-platform (`index.bundle?platform=android|ios|macos&…`). Two operating modes: + +| Mode | Metro | When | +|------|-------|------| +| **Serial default** | One Metro on `:8081` for the worktree | Unslotted local iteration | +| **Proven slotted** | **Distinct Metro listen port per platform×slot** (`RNFB_{PLATFORM}_METRO_PORT`, process-local `RCT_METRO_PORT`) | Parallel waves / multi-worktree — avoids babel/cache contention and matches `export-slot-env.sh` | + +Cross-worktree: two worktrees **must not** share one Metro (different `packages/**` / harness). + +**What still cannot be shared** (even within one worktree parallel wave) + +| Resource | Why | +|----------|-----| +| **Jet** (mocha-remote) | One WebSocket server drives **one** Mocha session | +| **Firebase emulators** | `wipe()` clears the whole emulator DB; aux ports must not collide | +| **Devices / macOS process** | One AVD, one sim UDID, one `PRODUCT_NAME` per concurrent instance | +| **Jet control HTTP** | Paired 1:1 with each Jet instance | + +**Port formula (proven):** `BASE = 12000 + slot×1000`; android offset `0`, ios `+100`, macos `+200`; within a platform block firestore…logging = `BLK+0..6`, metro `+7`, jet `+10`, jet-control `+11`. Emulator aux: firestore websocket / eventarc / tasks = `FS+8/+9/+12` inside `start-emulator-slotted.sh`. + +**Babel carry-in:** every Metro/Jest process for a slot must receive the **full** `RNFB_{ANDROID,IOS,MACOS}_*` set — port selection uses `Platform.*` / Detox, not `RNFB_E2E_PLATFORM` ([running e2e](running-e2e.md#configurable-e2e-environment)). + +### Platform bundle (per host slot) + +Each **android-slot** / **ios-slot** / **macos-slot** is a fixed **port block** + **device / process identity**. A worktree that leases slot `n` gets the whole multi-platform carry-in for that index. + +| Service | Serial default | Slotted (platform × slot) | +|---------|----------------|---------------------------| +| Metro | `:8081` | `BASE + platform_off + 7` | +| Jet WS / control | `:8090` / `:8091` | `+10` / `+11` in platform block | +| Emulator suite | fixed serial ports | full `RNFB_*_EMULATOR_*` + aux | +| Android | `TestingAVD` / `emulator-5554` | `TestingAVD-{n}` (incl. `-0`), Detox `android.emu.debug.slot{n}` | +| iOS | `iPhone 17` | `RNFB E2E iOS slot-{n}` (incl. `slot-0`), Detox `ios.sim.debug.slot{n}` | +| macOS | `io.invertase.testing` | **`io.invertase.testing.s{n}`** via `RNFB_MACOS_PRODUCT_NAME` | + +**macOS (proven):** no `macos-global` lock. Concurrent macOS = distinct `PRODUCT_NAME` (+ derived bundle id for Metro `app=`). Build uses `RNFB_MACOS_PRODUCT_NAME_SUFFIX` in pbxproj — **never** pass global `PRODUCT_NAME=` on the `xcodebuild` CLI (renames Pods / breaks linking). Details: [running e2e § macOS process identity](running-e2e.md#macos-process-identity-concurrency). + +### Worktree-internal parallelism + +Within a **single** worktree verification wave (slot `n`): + +```text +eval "$(bash scripts/e2e/export-slot-env.sh n)" # or run-slotted-* wrappers + │ + ├─ android → AVD + emulator suite A + Metro/Jet block A + :test-cover + ├─ ios → sim + emulator suite B + Metro/Jet block B + :test-cover } parallel + └─ macos → app io.invertase.testing.sn + emulator suite C + Metro/Jet C + :test-cover +``` + +**Coverage:** each platform run writes under its worktree; same-platform parallel still needs separate worktrees. + +## macOS app lifecycle + identity + +### Teardown (required) + +macOS `:test-cover` must not leave the test app running — a stale process on a shared Jet port breaks later Android runs ([running e2e § Android app reset](running-e2e.md#android-app-reset-blocking)). + +[`tests/.jetrc.js`](../../tests/.jetrc.js) macOS target: + +- `before`: kill named app, prefetch Metro bundle (`app=` = bundle id), spawn binary path from `RNFB_MACOS_PRODUCT_NAME`. +- `after` / exit handlers: `killall` the **same** product name; verify `pgrep` empty. +- App stdio detached (`ignore`) so `| tee` pipes close when Jet exits. + +Unscoped `release-e2e-resources.sh` also clears `io.invertase.testing.s0`…`.sN` leftovers. + +### Concurrent identity (proven — was “future” in older drafts) + +| Surface | Serial default | Concurrent | +|---------|----------------|------------| +| Process / `PRODUCT_NAME` | `io.invertase.testing` | `RNFB_MACOS_PRODUCT_NAME` (`.s{n}`) | +| Bundle id / Metro `app=` | `org.reactjs.native.io-invertase-testing` | derived or `RNFB_MACOS_BUNDLE_IDENTIFIER` | +| Firebase / GoogleService | none on macOS target | no cloud re-registration | + +Mild residual: shared native preferences suite `io.invertase.firebase` across apps (not process-isolating). + +Coordinator leases should grant **`macos-slot-N`** (with product name in the env payload), **not** a single `macos-global` mutex. + +## Lease store: reuse vs build (scope) + +RNFB needs **three layers**. Only layer 1 overlaps with existing OSS; layers 2–3 are RNFB-specific regardless. + +| Layer | Responsibility | Build? | +|-------|----------------|--------| +| **1. Lease store** | Exclusive / counting leases, TTL, token release, stale purge | **Reuse candidate** | +| **2. Coordinator API** | Worker registration, capacity advertisement, bundled reserve (multi-resource + port map + checkout hint), wait queue | **Thin RNFB service** (~300–600 lines) | +| **3. Worker agent** | Start/stop Metro/Jet/emulators, run `:test-cover`, teardown, optional sync | **RNFB scripts** (build on Phase 0 helpers) | + +### What resleased provides + +[resleased](https://github.com/axi92/resleased) is a small Go HTTP daemon (~single-purpose): + +- `POST /api/v1/reserve` — exclusive lease by opaque `resource_id`, returns token + `expires_at` +- `POST /api/v1/extend`, `DELETE /api/v1/release`, `GET /api/v1/status/{id}` +- JSON file persistence; expired leases purged on interval +- **503 when taken** — client must poll/retry (no blocking wait in server) + +**Fits layer 1** for `macos-slot-0`, `android-slot-0`, etc. **Does not provide:** semaphores (N identical slots), FIFO queue metadata, capacity registry, or lease payload beyond owner string. + +**Scope to adopt:** run as sidecar (`localhost:8787`); RNFB coordinator calls it per resource. Low integration cost; **0 stars / early** — vendoring or pinning commit advised. + +### What dflockd provides + +[dflockd](https://github.com/mtingers/dflockd) is a more complete Go lock server (~2k LOC, TS + Python clients): + +- **Locks** (exclusive) and **semaphores** (counting, `Limit=N`) — `android-pool` with `Limit=2` maps directly to “2 Android slots” +- **Blocking acquire** with timeout over HTTP or TCP — built-in wait queue per key +- Lease TTL, auto-renew in Go client, fencing tokens, optional auth/TLS +- **Does not provide:** arbitrary lease metadata (port maps), worker registry, or “reserve android + ios together” atomic bundle + +**Fits layer 1 better than resleased** when the host advertises `android_slots: 2` as a semaphore, and when clients should block-wait instead of poll 503s. + +**Scope to adopt:** `dflockd --http-port 6389` on each worker host (or one central instance on Tailscale); RNFB coordinator uses [dflockd-client-ts](https://github.com/mtingers/dflockd-client-ts) or curl for acquire/release. + +### What a purely local lease store would be + +If layer 1 were inlined (~150–250 lines Node or shell+JSON): + +- Same fields as resleased (`resource_id`, `owner`, `token`, `expires_at`, `pid`) +- Semaphore = counter file or separate “pool” record +- Blocking wait = loop + sleep in client CLI (already needed when a slot pool is saturated) +- **Pros:** zero external dep, full control, identical file format on disk as today’s `~/.rnfb/e2e-host/leases/` +- **Cons:** reimplement TTL purge, stale detection, and queue edge cases dflockd/resleased already solved + +### Recommendation (scope decision) + +| Approach | Layer 1 | Layer 2–3 | Total new code | Ops burden | +|----------|---------|-----------|----------------|------------| +| **A. dflockd + RNFB coordinator** | Reuse | Build | Medium | One small binary per host | +| **B. resleased + RNFB coordinator** | Reuse | Build | Medium | Simpler API; client polls | +| **C. All local (files only)** | Build | Build | Medium–high | No deps; we own bugs | +| **D. dflockd embedded as library** | Reuse in-process | Build | Lower ops | Go coordinator only | + +**Preferred:** **A (dflockd + RNFB coordinator HTTP)** — semaphores match slot pools (`android_slots: N`, `macos_slots: K`), blocking wait matches “wait for a free macos-slot”, TS client exists for agent scripts. Coordinator adds: + +- `POST /v1/workers/register` — `{ host_id, tailscale_ip, android_slots, ios_slots, macos_slots, labels }` +- `POST /v1/sessions/reserve` — `{ owner, resources[], wait, checkout_mode }` → `{ tokens[], worker, env, checkout }` +- `GET /v1/capacity` — aggregated workers + queue depth + +**resleased** remains a good fallback if we want minimal HTTP surface and are fine polling; API shape is nearly identical for layer 1. + +**Not worth adopting wholesale:** SAIR (physical Android farm), Lockgate/K8s (cluster locks), Conch/etcd (process supervision) — wrong abstraction layer. + +## Worktree placement and network filesystems + +Remote e2e raises: **must tests run against the laptop’s live files, or is a synced copy on the worker acceptable?** + +### RNFB-specific constraints + +| Need | Implication | +|------|-------------| +| Gitignored [`tests/harness.overrides.js`](../../tests/harness.overrides.example.js) | Pure `git clone` on worker **misses** local harness narrowing | +| Large trees (`tests/node_modules`, `ios/build`, `.yarn/cache`) | Full-tree SSHFS/NFS is slow; Tart already avoids heavy virtiofs streaming ([tart README](../../scripts/tart/README.md)) | +| Metro | Reads thousands of small files — **needs local disk on the machine running Metro** (worker), not cross-network mount | +| Uncommitted edits | Sync must run **after** reserve, **before** `:test-cover` | + +### Options (over Tailscale or LAN) + +| Strategy | Mechanism | Live laptop files? | RNFB verdict | +|----------|-----------|-------------------|--------------| +| **Collapsed worker** | Worker agent on laptop; `checkout_path=$PWD` | Yes — same inode | **Default**; zero sync | +| **Mutagen one-way-replica** | [`mutagen sync create`](https://mutagen.io/documentation/synchronization/creating-sessions) laptop → worker; [`mutagen sync flush`](https://mutagen.io/documentation/synchronization/forcing-synchronization) at reserve | Snapshot at flush; edits after flush not visible until next flush | **Best remote pattern** — worker has local APFS for Metro/Gradle. Project: [mutagen.io](https://mutagen.io/) · [GitHub](https://github.com/mutagen-io/mutagen) | +| **rsync / tar snapshot** | `rsync -a --delete` with `--include harness.overrides.js` at reserve | Same as Mutagen flush | **Simplest**; no daemon; good for CI-style | +| **SSHFS / NFS / SMB mount** | Worker mounts `laptop:/path/worktree` | Theoretically live | **Discouraged** — Metro, Gradle, Xcode stat storms over VPN ([known pain](https://serverfault.com/questions/470059/how-to-make-sshfs-vpn-git-a-tolerable-working-environment)); IDE-grade lag | +| **SSHFS selective** | Mount only `packages/` + `tests/` excluding `node_modules` | Partial live | Still risky; Metro resolves into `node_modules` | +| **Virtiofs (Tart)** | VM mounts host worktree | Live on **same** Mac host | **Already used** for iOS Tart; not cross-machine | +| **Git ref only** | Worker `git fetch && checkout` | No uncommitted / gitignored | OK for CI; **insufficient for local agent iteration** | +| **Reverse: laptop mounts worker** | SSHFS other direction | N/A | Wrong shape — canonical edits stay on laptop | + +**Tailscale’s role:** MagicDNS + wireguard mesh so coordinator, laptop client, and Mac mini worker address each other as `worker-host.tailnet` without exposing ports publicly. Use **Tailscale SSH** for Mutagen/rsync transport (`mutagen sync create ./worktree worker-host:~/rnfb-checkouts/...`). NFS-over-Tailscale works but shares SSHFS latency problems for build/test IO. + +### Checkout modes (coordinator `reserve` response) + +```json +{ + "checkout_mode": "local", + "checkout_path": "/Users/mike/.../e2e-parallel" +} +``` + +| `checkout_mode` | When | `checkout_path` | +|-----------------|------|-----------------| +| `local` | Worker is laptop (collapsed) | Client worktree abs path | +| `sync_snapshot` | Remote worker | Worker path after rsync/Mutagen flush | +| `git_ref` | CI / clean tree | Worker clone at SHA | +| `live_mount` | **Experimental only** | SSHFS mount point — not recommended for full e2e | + +**Sync recipe (recommended remote default):** + +```bash +# At reserve time on worker (after dflockd grant) +rsync -a --delete \ + --exclude '.git' --exclude 'tests/node_modules' --exclude 'tests/ios/build' \ + --exclude 'tests/android/app/build' \ + --include 'tests/harness.overrides.js' \ + "${CLIENT_WORKTREE}/" "${WORKER_CHECKOUT}/" +cd "${WORKER_CHECKOUT}" && yarn && yarn tests:emulator:prepare # worker-local install +``` + +Mutagen equivalent: session with `--sync-mode=one-way-replica`, explicit `mutagen sync flush` before `verify-parallel`; include gitignored overrides via `.mutagen.yml` ignore negation. + +**Artifacts return path:** coverage logs and `tee` output rsync/scp **worker → laptop** on release (inverse of checkout). + +### “Run on laptop files” honest summary + +True single-copy execution over the network **only works collapsed** (worker on laptop). For a remote Mac worker, **flush-then-run on a local worker copy** is the industry-standard compromise (same as Mutagen remote dev, Docker Dev Environments, Gitpod pre-sync). Chasing live SSHFS for full RNFB e2e is high risk for modest benefit — agent edits locally, one sync at reserve is usually seconds vs tens of minutes of e2e. + +## Phased roadmap + +**Network-first:** HTTP coordinator + worker capacity registration from Phase 1. File-only leases are a **degenerate backend**, not the primary path. + +### Phase 0 — macOS teardown + port helpers (prerequisite) — largely **done** + +- macOS app terminated after `:test-cover` (named process; exit hooks; detached stdio). +- Emulator/Jet URLs centralized in [`packages/app/e2e/helpers.js`](../../packages/app/e2e/helpers.js); no hardcoded serial emulator ports in specs. +- Slotted env helpers: [`scripts/e2e/export-slot-env.sh`](../../scripts/e2e/export-slot-env.sh) / `lib/e2e-slot-env.sh` (supersedes earlier `platform-env.sh` sketch). +- **macOS `PRODUCT_NAME` slotting** shipped — concurrent macOS no longer blocked on a global lock. + +**Done when:** macOS run leaves no matching product-name process; helpers read env ports with serial defaults when unset; concurrent `.sN` apps proven across worktrees. + +### Phase 1 — Network coordinator + local worker + tri-platform parallel + +**Scope:** HTTP coordinator from day one; first worker is **the same machine** as the client (`checkout_mode: local`). + +| Component | Implementation | +|-----------|----------------| +| **Lease store** | [dflockd](https://github.com/mtingers/dflockd) on `127.0.0.1` (or resleased — see [§ Lease store](#lease-store-reuse-vs-build-scope)) | +| **RNFB coordinator** | Thin HTTP service (`scripts/e2e/coordinator/` or small Go binary): register worker, reserve bundle, capacity | +| **Worker agent** | `scripts/e2e/worker-agent.sh` — registers capacity, executes `run-worktree-parallel.sh` on grant | +| **Client CLI** | `yarn tests:e2e:reserve` / `release` / `capacity` → coordinator URL (`RNFB_E2E_COORDINATOR`, default `http://127.0.0.1:8790`) | + +**Worker registration (on agent start):** + +```json +POST /v1/workers/register +{ + "worker_id": "mike-mbp.local", + "android_slots": 1, + "ios_slots": 1, + "macos_slots": 1, + "checkout_modes": ["local"], + "labels": { "platform": "darwin", "arch": "arm64" } +} +``` + +**Bundled reserve (client):** + +```json +POST /v1/sessions/reserve +{ + "owner": "worktree:/path/to/e2e-parallel", + "resources": ["android-pool", "ios-pool", "macos-pool"], + "wait": true, + "ttl": "90m", + "checkout_mode": "local" +} +``` + +Coordinator acquires dflockd semaphores / locks, returns `{ token, env: { RNFB_* ports…, RNFB_MACOS_PRODUCT_NAME }, checkout_path }`, worker agent runs tri-platform parallel. + +Deliverables: + +| Item | Notes | +|------|-------| +| dflockd (or resleased) + coordinator | Docker-compose or brew-style one-liner for dev hosts | +| `run-worktree-parallel.sh` | **Distinct Metro per platform×slot** (+ Jet/emulators); three `:test-cover` parallel | +| `yarn tests:verify-parallel` | Client: reserve → run → release (EXIT trap) | +| Pre-flight | Per-platform scoped probes | + +**Done when:** `yarn tests:verify-parallel` uses coordinator reserve/release; Android + iOS + macOS run concurrently on one worktree via HTTP API; `GET /v1/capacity` shows worker slots. + +### Phase 2 — Multi-worktree + multi-slot hosts + sync + +**Scope:** Same coordinator; worker advertises `android_slots: 2`, `ios_slots: 2`; multiple worktrees on one Mac reserve different semaphore grants. + +| Feature | Detail | +|---------|--------| +| **Slot port maps** | Coordinator assigns slot `n` port blocks + macOS `PRODUCT_NAME` (see [§ Platform bundle](#platform-bundle-per-host-slot)) | +| **macOS-pool wait** | dflockd semaphore on `macos-pool` (`Limit=K`) — worktrees queue when all `.sN` identities are leased | +| **Stale cleanup** | Worker heartbeat; coordinator releases dflockd tokens if worker dies | +| **Remote checkout** | `checkout_mode: sync_snapshot` via rsync or Mutagen over Tailscale (see [§ Worktree placement](#worktree-placement-and-network-filesystems)) | +| **Second worker on same host** | Optional — usually one worker process registers full machine capacity | + +**Done when:** two local worktrees run Android **and** macOS e2e concurrently without collision; `GET /v1/capacity` shows queue depth. + +### Phase 3 — Multi-host workers + autoscale pool + +**Scope:** Workers on multiple Macs / Tart VMs / cloud instances register with central coordinator (any host running coordinator + dflockd, or dflockd sharded per worker). + +```text + Laptop client (worktree canonical) + │ POST /v1/sessions/reserve + ▼ + Coordinator (Tailscale: coordinator.tailnet:8790) + │ assigns worker + sync_snapshot checkout + ▼ + Mac mini worker / Tart VM + │ rsync or Mutagen flush → local checkout + │ verify-parallel → artifacts back + ▼ + release → free semaphores +``` + +| Feature | Detail | +|---------|--------| +| **Worker discovery** | Tailscale MagicDNS names in registration | +| **Autoscale pool** | Tart `run-ephemeral` workers register as `ios-slot` consumers; scale-to-zero when idle (future) | +| **Auth** | Tailscale ACL + coordinator shared secret or mTLS | +| **Collapsed degenerate case** | `coordinator=localhost`, `checkout_mode=local` — must always work offline | + +**Done when:** laptop reserves remote Mac worker; e2e runs on synced copy; artifacts return; local-only mode unchanged. + +### Phase 4 — Optional hardening + +- Coordinator HA (dflockd fence files, coordinator state backup) +- Priority queues (interactive vs background agent) +- Integration with GitHub Actions self-hosted labels +- Evaluate **resleased** swap-in if dflockd ops burden exceeds benefit + +**Explicitly removed from earlier draft:** file-only coordinator as primary path — file leases may remain a dflockd/resleased persistence detail only. + +## Orchestration flow (target end state) + +```mermaid +sequenceDiagram + participant WT as Worktree client + participant CO as RNFB coordinator + participant LS as dflockd + participant WK as Worker agent + participant E2E as test-cover + + WT->>CO: POST /v1/sessions/reserve + CO->>LS: acquire android-pool, ios-pool, macos-pool + LS-->>CO: tokens + CO-->>WT: worker + env (incl. RNFB_MACOS_PRODUCT_NAME) + checkout_path + alt checkout_mode sync_snapshot + WT->>WK: rsync/Mutagen flush worktree + end + par Android + WK->>E2E: AVD + Jet/Metro/emulator block + and iOS + WK->>E2E: sim + Jet/Metro/emulator block + and macOS + WK->>E2E: PRODUCT_NAME.sN + Jet/Metro/emulator block + end + E2E-->>WT: exit codes + artifacts + WT->>CO: POST /v1/sessions/release + CO->>LS: release tokens +``` + +Phase 1 without a coordinator: operators use `export-slot-env.sh` / `run-slotted-*` directly (already shipped). + +Phase 1 with coordinator uses `checkout_mode: local` and omits the sync branch. + +## Open-source landscape (summary) + +See [§ Lease store](#lease-store-reuse-vs-build-scope) for the build-vs-reuse decision. Layer 2–3 remain RNFB-owned. + +| Project | Layer | Verdict | +|---------|-------|---------| +| **dflockd** | 1 | **Preferred** — semaphores, blocking wait, TS client | +| **resleased** | 1 | **Good alternate** — simpler HTTP; poll on 503 | +| **flock / file JSON** | 1 | Fallback if zero deps wins | +| **Mutagen** | Sync | **Preferred** remote checkout transport over Tailscale SSH | +| **rsync** | Sync | **Simplest** snapshot at reserve | +| **SSHFS/NFS** | Sync | **Avoid** for full e2e IO | +| **SAIR** | — | Physical Android pools only | +| **BuildFarm / EngFlow** | — | Remote build cache, not dev-host e2e | + +## Implementation notes (Phase 1 technical) + +### App / test code + +- `getE2eEmulatorPort(name)` and `getJetRemoteUrl()` in app e2e helpers. +- Replace hardcoded `:8080` in firestore wipe, storage, auth deep links, [`emailLink.e2e.js`](../../packages/auth/e2e/emailLink.e2e.js). +- `` in [`tests/app.js`](../../tests/app.js). + +### Host orchestration + +- [`tests/e2e/firebase.test.js`](../../tests/e2e/firebase.test.js): scope port kills to **owned** Jet port, not global `:8090`. +- [`.detoxrc.js`](../../tests/.detoxrc.js): support env substitution or `detoxrc.slot.js` generator. +- Pre-flight in [running e2e](running-e2e.md): replace global probes with platform-scoped variants. + +### Builds + +- **Serial build, parallel test:** `build` phases stay serial per platform before parallel `:test-cover` (Gradle/Xcode lock contention). +- Or: separate `GRADLE_USER_HOME` / `derivedDataPath` per slot (Phase 2+) if build parallelism is needed later. + +## Documentation and policy updates (by phase) + +| Phase | Docs | +|-------|------| +| 1 | [running e2e](running-e2e.md) parallel section; [agent command policy](agent-command-policy.md) allow `tests:verify-parallel` | +| 2 | This doc § coordinator CLI; [change authoring § host rule](change-authoring-workflow.md#host-rule) → per-slot | +| 3 | `capacity.json` schema; operator guide for `host-status` | + +Work-queue rows for implementation track **Phase 0–3** in the `e2e-parallel` branch work queue (ephemeral — not duplicated here per [documentation policy](../documentation-policy.md)). + +## Risks + +| Risk | Mitigation | +|------|------------| +| RAM / CPU exhaustion (N× emulators + Metros) | Capacity advertisement; document minimum host spec | +| Cloud API quota (FIS / RC) under parallel worktrees | Harness narrowing; existing retry in `firebase.test.js` | +| Stale leases after agent crash | TTL + pid check + `cleanup-stale` | +| Operator confusion (wrong PRODUCT_NAME / slot) | `host-status` shows leased `macos-slot-N` + product name; unscoped release clears `.sN` | +| Coverage merge complexity | Per-worktree artifacts; merge script optional for local dev | +| Passing `PRODUCT_NAME=` on xcodebuild CLI | Forbidden — use suffix env only ([running e2e](running-e2e.md#macos-process-identity-concurrency)) | + +## Open questions + +1. **AVD strategy:** **decided** — clone `TestingAVD-0`…`TestingAVD-N` for slotted runs; serial keeps `TestingAVD` (`create-android-avds.sh`). +2. **iOS simulators:** **decided** — dedicated `RNFB E2E iOS slot-0`…`slot-N` devices; serial keeps `iPhone 17` (`create-ios-simulators.sh`). +3. **Shared build artifacts:** single `tests/ios/build` / `tests/macos/build` per worktree (serial build within tree) — confirmed OK; same-platform parallel ⇒ multiple worktrees. +4. **Coordinator language:** Node (matches repo / mellifera) vs Go (matches dflockd) for the thin HTTP layer? +5. **Tart integration:** Phase 3 optional — Tart VM counts as `ios-slot` consuming full VM; Phase 4 worker registration. +6. **Sync transport:** default `rsync` for Phase 2 remote checkout; Mutagen if bi-directional dev loop needed — see [§ Worktree placement](#worktree-placement-and-network-filesystems). +7. **Lease backend:** mellifera-first vs dflockd; keep swap path if adoption stalls. +8. **Shared `io.invertase.firebase` prefs suite:** acceptable residual for concurrent macOS, or suite-name slotting later? + +## Related docs + +* [Running e2e tests](running-e2e.md) — canonical commands, parallel topology, macOS `PRODUCT_NAME` +* [Coverage design](coverage-design.md) — per-platform artifact policy +* [Firebase testing project](firebase-testing-project.md) — emulator vs cloud +* [scripts/tart/README.md](../../scripts/tart/README.md) — VM-isolated iOS reproduction +* [Change authoring workflow § host rule](change-authoring-workflow.md#host-rule) — serial default; slotted exception via running-e2e +* [Agent command policy](agent-command-policy.md) — allowlisted slotted helpers \ No newline at end of file diff --git a/okf-bundle/testing/running-e2e.md b/okf-bundle/testing/running-e2e.md index 1bad3cc837..698b7bade4 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 macOS test app process — default `io.invertase.testing`, or `RNFB_MACOS_PRODUCT_NAME` when set; unscoped check also treats `.sN` siblings as busy) | Also wait for any visible unfinished `yarn tests:*:test-cover`. @@ -178,70 +180,89 @@ 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 the macOS app leaves a stale macOS Jet client → duplicate clients and `server wasn't running`. ```bash -! pgrep -x io.invertase.testing >/dev/null 2>&1 +MACOS_APP="${RNFB_MACOS_PRODUCT_NAME:-io.invertase.testing}" +! pgrep -x "$MACOS_APP" >/dev/null 2>&1 +# or: bash scripts/e2e/release-e2e-resources.sh --only macos-app,jet +# Unscoped release also clears io.invertase.testing.s0..sN leftovers from parallel runs. ``` (`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)). Unscoped check (no slot env) can report **CLEAR** while slotted leftovers (`.sN` apps, slot ports, booted `TestingAVD-N` / `RNFB E2E iOS slot-N`) remain — load `export-slot-env` (or `--mellifera`) before trusting CLEAR for a slot. `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 -# Android -! adb -s emulator-5554 shell pidof com.invertase.testing.test >/dev/null 2>&1 +# Scope device probes to one platform instead of the ambiguous serial "global" fallback. +bash scripts/e2e/check-e2e-resources.sh --platform=ios -# macOS -! pgrep -x io.invertase.testing >/dev/null 2>&1 +# 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 +``` + +Manual one-liners (defaults only — use when debugging without the scripts): + +```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 "${RNFB_MACOS_PRODUCT_NAME:-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 +282,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 +365,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 +380,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 +405,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 +530,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 +539,138 @@ 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); slotted runs use `TestingAVD-N` / `RNFB E2E iOS slot-N` — see [slot lifecycle](#slot-lifecycle). 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 + distinct macOS `PRODUCT_NAME`). Example: `3× android + 3× ios + 3× macos` ⇒ three worktrees (each runs android+ios+macos for its slot). Do **not** launch two androids, two ioses, or two macOS apps from one worktree — native build products, Detox configs, coverage paths, and (for macOS) a single `macos/build` derived-data tree are not multi-instance-safe inside one tree. Architecture / why: [e2e parallel design](e2e-parallel-design.md). + +**Slot lifecycle (arbitrary slot `N`, platform `android|ios|macos`)** — keep the exported env in the **same shell** for every step. + +**First use of a slot (including `N=0`):** create devices once with `yarn tests:e2e:setup-android-avds` / `yarn tests:e2e:setup-ios-sims` so `TestingAVD-N` and `RNFB E2E iOS slot-N` exist before start/build. + +```bash +# 1) Load slot env (full RNFB_{ANDROID,IOS,MACOS}_* carry-in + device identities) +eval "$(bash scripts/e2e/export-slot-env.sh N)" +# yarn tests:e2e:export-slot-env N +# Emits unset for parent leftovers (ANDROID_SERIAL, AVD_NAME, …) that would poison check/release. + +# 2) Clear this slot (see release scope / --devices below) +bash scripts/e2e/check-e2e-resources.sh # expect CLEAR (or release then re-check) +# Default release = ports+apps for all three platform blocks; does NOT stop AVD/sims +bash scripts/e2e/release-e2e-resources.sh +# For a CLEAR iOS check afterward, also shut down devices: +# bash scripts/e2e/release-e2e-resources.sh --devices +# Unscoped (no slot env): release also wipes io.invertase.testing.s0..sN leftovers. + +# 3) Start services for this platform×slot (packager before build) +bash scripts/e2e/start-emulator-slotted.sh # or … N to self-apply +bash scripts/e2e/run-slotted-packager.sh N # background OK; or yarn tests:packager:jet-reset-cache with env loaded + +# 4) Build for the slot (macOS uses RNFB_MACOS_PRODUCT_NAME → PRODUCT_NAME_SUFFIX) +# After worktree reset/sync for iOS, if Pods/Manifest.lock may have drifted: +# yarn tests:ios:pod:install +yarn tests::build + +# 5) Run e2e +bash scripts/e2e/run-slotted-test-cover.sh N +# yarn tests:e2e:slotted-test-cover N + +# 6) Free the same slot (env still loaded) +# End-of-slot / final free so check --platform=ios is CLEAR: include --devices +bash scripts/e2e/release-e2e-resources.sh --devices +``` + +Example for macOS slot 1: + +```bash +eval "$(bash scripts/e2e/export-slot-env.sh macos 1)" +bash scripts/e2e/release-e2e-resources.sh --devices +bash scripts/e2e/start-emulator-slotted.sh macos +bash scripts/e2e/run-slotted-packager.sh macos 1 # background OK +yarn tests:macos:build +bash scripts/e2e/run-slotted-test-cover.sh macos 1 +bash scripts/e2e/release-e2e-resources.sh --devices +``` + +**Release scope after full carry-in:** `export-slot-env` always loads **all three** `RNFB_{ANDROID,IOS,MACOS}_*` port blocks for slot `N`. Default `release-e2e-resources.sh` (no `--platform`) clears **android + ios + macos** Metro/Jet/emulator **ports and apps** for that slot — it does **not** stop AVDs or shut down iOS sims unless **`--devices`** is passed. After slotted iOS, `check-e2e-resources.sh --platform=ios` treats a still-booted slot sim as **BUSY**, so end-of-slot / final free must use `release-e2e-resources.sh --devices` (env still loaded). Mid-wave early free of one finished platform may omit `--devices` (leave the AVD/sim up for a quick re-run) but must use **`--platform=`** so the other platforms’ port blocks stay up: + +```bash +# Wave still running ios+macos; android finished early (ports only; AVD may stay up): +eval "$(bash scripts/e2e/export-slot-env.sh android N)" # same slot env already loaded is fine +bash scripts/e2e/release-e2e-resources.sh --platform=android +# Final free for that platform (or whole slot) when check must be CLEAR for iOS: +# bash scripts/e2e/release-e2e-resources.sh --platform=ios --devices +# bash scripts/e2e/release-e2e-resources.sh --devices +``` + +**`--platform=` never selects a slot.** It only narrows which platform’s devices/ports are probed among whatever env is already loaded. For slotted clear/check, always `export-slot-env` (or `run-slotted-*`) first so `RNFB_E2E_SLOT` + `RNFB_*_JET_PORT` are set; otherwise check/release fall back to serial defaults (`TestingAVD` / `:8090` / …). The scripts warn on stderr when `--platform` is set without slotted carry-in. + +**Slotted device identities (including slot 0):** slotted runs use `TestingAVD-N`, `RNFB E2E iOS slot-N`, Detox `*.slotN`, and `io.invertase.testing.sN` — **including `N=0`**. Serial unslotted defaults remain `TestingAVD` / `iPhone 17` / `io.invertase.testing`. First use of any slot (including 0): `yarn tests:e2e:setup-android-avds` / `yarn tests:e2e:setup-ios-sims` (slots 0–4). + +**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:** default process name `io.invertase.testing` is host-global (`pgrep`/`killall`). For per-worktree / multi-slot macOS, set **`RNFB_MACOS_PRODUCT_NAME`** (and optionally **`RNFB_MACOS_BUNDLE_IDENTIFIER`**) before `:build` and `:test-cover` — `export-slot-env.sh` / `run-slotted-*` do this as `io.invertase.testing.s`. `yarn tests:macos:build` derives `RNFB_MACOS_PRODUCT_NAME_SUFFIX` (e.g. `.s1`) for the pbxproj `PRODUCT_NAME = "io.invertase.testing$(RNFB_MACOS_PRODUCT_NAME_SUFFIX)"` — do **not** pass global `PRODUCT_NAME=` on the `xcodebuild` CLI (that renames Pods and breaks linking). [`.jetrc.js`](../../tests/.jetrc.js) spawn/kill/Metro `app=` follow the same env. Unset → serial defaults. No Firebase Console / GoogleService change is required (JS/Other path). 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.slot0`, `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. Slotted helpers set `TestingAVD-{n}` / `RNFB E2E iOS slot-{n}` (including `n=0`); serial defaults stay `TestingAVD` / `iPhone 17` | +| `RNFB_MACOS_PRODUCT_NAME` | macOS `PRODUCT_NAME` / process name (default `io.invertase.testing`). Required distinct per concurrent macOS slot. Slotted helpers set `io.invertase.testing.s`; override via `RNFB_MACOS_PRODUCT_NAME_OVERRIDE` | +| `RNFB_MACOS_BUNDLE_IDENTIFIER` | macOS `CFBundleIdentifier` (default derived from product name). Metro `app=` follows this. Slotted override: `RNFB_MACOS_BUNDLE_IDENTIFIER_OVERRIDE` | +| `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). Reminder: `--platform=` alone does **not** load a slot — see [slot lifecycle](#slot-lifecycle). + +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): see [slot lifecycle](#slot-lifecycle); also `yarn tests:e2e:setup-android-avds`, `yarn tests:e2e:setup-ios-sims`. + + + +#### macOS process identity (concurrency) + +| Surface | Default | Concurrent macOS | +|---------|---------|------------------| +| **Process / `PRODUCT_NAME`** | `io.invertase.testing` | Set `RNFB_MACOS_PRODUCT_NAME` (e.g. `io.invertase.testing.s1`) — required for isolation | +| App path | `…/${PRODUCT_NAME}.app/Contents/MacOS/${PRODUCT_NAME}` | Follows product name | +| `CFBundleIdentifier` | `org.reactjs.native.io-invertase-testing` | `RNFB_MACOS_BUNDLE_IDENTIFIER` or derived `org.reactjs.native.${PRODUCT_NAME with dots→hyphens}` | +| Metro `app=` query | matches bundle ID | [`.jetrc.js`](../../tests/.jetrc.js) reads the same env | +| Firebase / GoogleService | **None on macOS target** | No cloud re-registration for JS/Other e2e | + +`yarn tests:macos:build` exports `RNFB_MACOS_PRODUCT_NAME_SUFFIX` into the xcodebuild environment (pbxproj expansion only). Check/release use `RNFB_MACOS_PRODUCT_NAME` for `pgrep`/`killall`; unscoped host wipe also clears `.s0`…`.sN`. Same-platform parallel still needs **one worktree per macOS instance** ([parallel topology](#parallel-e2e-topology)). Mild residual: shared `io.invertase.firebase` preferences suite across apps. + ### 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..4cb1a5f17e 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,11 @@ "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", + "tests:e2e:export-slot-env": "bash ./scripts/e2e/export-slot-env.sh", + "tests:e2e:slotted-packager": "bash ./scripts/e2e/run-slotted-packager.sh", + "tests:e2e:slotted-test-cover": "bash ./scripts/e2e/run-slotted-test-cover.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/check-e2e-resources.sh b/scripts/e2e/check-e2e-resources.sh new file mode 100755 index 0000000000..da88b80f85 --- /dev/null +++ b/scripts/e2e/check-e2e-resources.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# Report whether e2e resources for the current env (or mellifera reservation) are busy. +# Uses standardized RNFB_* / JET_* / RCT_* env vars with fallback to serial defaults. +# +# Default ("host-clear") mode reports the contention-prone resources that matter before +# starting a run — Jet WebSocket, e2e apps, and simulators — and does NOT mark Metro or +# Firebase emulator ports as BUSY: those are normal, expected-to-be-running "services" +# (running-e2e.md § Services ready is a separate, opposite check from host-clear). Pass +# --services (alias --strict) to additionally treat Metro/emulator ports as BUSY, e.g. +# when you want a single command that also confirms nothing is bound on those ports. +# +# Exit 0 = all clear; exit 1 = something busy. +# +# Usage: +# bash scripts/e2e/check-e2e-resources.sh +# bash scripts/e2e/check-e2e-resources.sh --services # also flag metro/emulator ports as BUSY +# bash scripts/e2e/check-e2e-resources.sh --platform=android # scope device probes to one platform +# bash scripts/e2e/check-e2e-resources.sh --mellifera # opt in to tests/mellifera.env.json +# bash scripts/e2e/check-e2e-resources.sh --json +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/e2e-resource-env.sh +source "${SCRIPT_DIR}/lib/e2e-resource-env.sh" + +JSON=0 +SERVICES=0 +# Finding B: initialize from an already-exported parent flag (e.g. release-e2e-resources.sh +# invoking this script as a child) instead of forcing 0 — argv --mellifera below still wins +# and callers should pass it through explicitly rather than relying on inherited env alone. +E2E_MELLIFERA_FLAG="${E2E_MELLIFERA_FLAG:-0}" +for arg in "$@"; do + case "$arg" in + --json) + JSON=1 + ;; + --services | --strict) + SERVICES=1 + ;; + --mellifera) + E2E_MELLIFERA_FLAG=1 + ;; + --platform=*) + E2E_PLATFORM_OVERRIDE="${arg#--platform=}" + ;; + -h | --help) + sed -n '2,20p' "$0" + exit 0 + ;; + *) + echo "unknown arg: $arg" >&2 + exit 2 + ;; + esac +done + +e2e_collect_targets + +BUSY=0 +REPORT_LINES=() +ACTIVE_PLATFORMS="$(e2e_active_platforms | tr '\n' ' ')" + +# platform_active: is this platform in scope for probing at all? "global" (the ambiguous +# fallback with no explicit platform intent — see e2e_active_platforms()) means "probe +# every platform's safe/specific checks" — it does NOT mean every platform's device is +# assumed to be actively running e2e (finding #3). +platform_active() { + local p=$1 + [[ " $ACTIVE_PLATFORMS " == *" $p "* || " $ACTIVE_PLATFORMS " == *" global "* ]] +} + +# platform_explicit: true only when platform intent was explicit (--platform, mellifera +# JSON, RNFB_E2E_PLATFORM, or a per-platform port env) — not the ambiguous "global" +# fallback. Used to decide whether an aggressive, false-positive-prone probe (e.g. "any +# booted iOS simulator") should escalate to BUSY or stay informational. +platform_explicit() { + local p=$1 + [[ " $ACTIVE_PLATFORMS " == *" $p "* ]] +} + +report() { + local state=$1 + shift + REPORT_LINES+=("$state $*") + if [[ "$state" == "BUSY" ]]; then + BUSY=1 + fi + if [[ "$JSON" -eq 0 ]]; then + printf '%-5s %s\n' "$state" "$*" + fi +} + +if [[ "$JSON" -eq 0 ]]; then + echo "[check-e2e-resources] mode: $([[ "$SERVICES" -eq 1 ]] && echo "services (metro/emulator ports count as BUSY)" || echo "host-clear (default — metro/emulator ports are informational only)")" + echo "[check-e2e-resources] platforms: ${ACTIVE_PLATFORMS}" + echo "[check-e2e-resources] android serial=${E2E_ANDROID_SERIAL} avd=${E2E_ANDROID_AVD} ios_sim=${E2E_IOS_SIMULATOR}" +fi + +i=0 +for port in "${E2E_PORTS[@]}"; do + label="${E2E_PORT_LABELS[$i]}" + if e2e_port_listening "$port"; then + pids=$(e2e_listener_pids "$port" | tr '\n' ',' | sed 's/,$//') + case "$label" in + jet-control:*) + # Control HTTP port may legitimately be open mid-run (running-e2e.md §Host-clear + # probes check 8090 only) — report for visibility but never count as BUSY. + report INFO "port :${port} (${label}) pids=${pids} — control port, open during a run is normal" + ;; + metro:* | emulator-*) + # Finding #2: Metro/emulator ports are expected to be listening (running-e2e.md § + # Services ready) — default host-clear mode must not fail solely on :8081/:8080. + # Only escalate to BUSY when the caller explicitly asked for --services/--strict. + if [[ "$SERVICES" -eq 1 ]]; then + report BUSY "port :${port} (${label}) pids=${pids}" + else + report INFO "port :${port} (${label}) pids=${pids} — services port, use --services to include as BUSY" + fi + ;; + *) + report BUSY "port :${port} (${label}) pids=${pids}" + ;; + esac + else + report CLEAR "port :${port} (${label})" + fi + i=$((i + 1)) +done + +if platform_active android; then + if [[ -z "${E2E_ANDROID_SERIAL:-}" ]]; then + report CLEAR "android serial unset (slotted AVD ${E2E_ANDROID_AVD} — skip adb app probes)" + elif command -v adb >/dev/null 2>&1; then + if e2e_android_app_running "$E2E_ANDROID_SERIAL" "$E2E_ANDROID_APP_ID"; then + report BUSY "android app ${E2E_ANDROID_APP_ID} on ${E2E_ANDROID_SERIAL}" + else + report CLEAR "android app ${E2E_ANDROID_APP_ID} on ${E2E_ANDROID_SERIAL}" + fi + if e2e_android_app_running "$E2E_ANDROID_SERIAL" "$E2E_ANDROID_TEST_APP_ID"; then + report BUSY "android test app ${E2E_ANDROID_TEST_APP_ID} on ${E2E_ANDROID_SERIAL}" + else + report CLEAR "android test app ${E2E_ANDROID_TEST_APP_ID} on ${E2E_ANDROID_SERIAL}" + fi + else + report CLEAR "adb unavailable — skipped android app probes" + fi +fi + +if platform_active macos; then + busy_macos="$(e2e_macos_busy_process || true)" + if [[ -n "${busy_macos}" ]]; then + report BUSY "macos app process ${busy_macos}" + else + report CLEAR "macos app process ${E2E_MACOS_APP_PROCESS}" + fi +fi + +if platform_active ios; then + if command -v xcrun >/dev/null 2>&1; then + if e2e_ios_sim_booted "$E2E_IOS_SIMULATOR"; then + if platform_explicit ios; then + report BUSY "ios simulator booted (${E2E_IOS_SIMULATOR} or any booted for default)" + else + # Finding #3: "global" is an ambiguous fallback, not confirmed iOS intent — do not + # fail the whole host-clear check just because an unrelated simulator happens to + # be booted. Pass --platform=ios (or set RNFB_E2E_PLATFORM=ios) to enforce. + report INFO "ios simulator booted (${E2E_IOS_SIMULATOR} or another) — global mode does not fail on this; pass --platform=ios to enforce" + fi + else + report CLEAR "ios simulator (${E2E_IOS_SIMULATOR})" + fi + else + report CLEAR "xcrun unavailable — skipped ios sim probes" + fi +fi + +if [[ "$JSON" -eq 1 ]]; then + node -e " + const lines = process.argv.slice(1); + const items = lines.map(l => { + const sp = l.indexOf(' '); + return { state: l.slice(0, sp), detail: l.slice(sp + 1) }; + }); + console.log(JSON.stringify({ clear: items.every(i => i.state !== 'BUSY'), items }, null, 2)); + " "${REPORT_LINES[@]}" +fi + +if [[ "$BUSY" -ne 0 ]]; then + if [[ "$JSON" -eq 0 ]]; then + echo "[check-e2e-resources] BUSY" >&2 + fi + exit 1 +fi +if [[ "$JSON" -eq 0 ]]; then + echo "[check-e2e-resources] CLEAR" +fi +exit 0 diff --git a/scripts/e2e/create-android-avds.sh b/scripts/e2e/create-android-avds.sh new file mode 100755 index 0000000000..7bcc961a7b --- /dev/null +++ b/scripts/e2e/create-android-avds.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Clone TestingAVD into TestingAVD-0 … TestingAVD-4 for parallel e2e slots. +# Same AVD *definition* cannot run two read-write instances concurrently; clones are required. +# Serial unslotted runs keep using TestingAVD; slotted slot 0 uses TestingAVD-0. +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 0 "$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 — slotted slots 0..${COUNT} use ${BASE_AVD}-N; serial unslotted keeps ${BASE_AVD}" diff --git a/scripts/e2e/create-ios-simulators.sh b/scripts/e2e/create-ios-simulators.sh new file mode 100755 index 0000000000..3fac243644 --- /dev/null +++ b/scripts/e2e/create-ios-simulators.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Create dedicated iOS simulators for e2e slots 0–4. +# Serial unslotted runs keep using iPhone 17; slotted slot 0 uses RNFB E2E iOS slot-0. +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 0 "$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 — slotted slots 0..${COUNT} use RNFB E2E iOS slot-N; serial unslotted keeps ${BASE_NAME}" diff --git a/scripts/e2e/export-slot-env.sh b/scripts/e2e/export-slot-env.sh new file mode 100755 index 0000000000..832c8f2f89 --- /dev/null +++ b/scripts/e2e/export-slot-env.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Print slotted e2e env exports for eval into the current shell. +# Full lifecycle: running-e2e.md § slot-lifecycle +# +# Usage: +# eval "$(bash scripts/e2e/export-slot-env.sh )" +# bash scripts/e2e/check-e2e-resources.sh # / release-e2e-resources.sh — slot-scoped +# bash scripts/e2e/start-emulator-slotted.sh # or … +# bash scripts/e2e/run-slotted-packager.sh # background OK +# yarn tests::build +# bash scripts/e2e/run-slotted-test-cover.sh +# bash scripts/e2e/release-e2e-resources.sh +# +# Always sets full RNFB_{ANDROID,IOS,MACOS}_* ports plus slot device identities +# (TestingAVD-N / RNFB E2E iOS slot-N / RNFB_MACOS_PRODUCT_NAME=io.invertase.testing.s). +# Also emits unset for parent leftovers (ANDROID_SERIAL, AVD_NAME, …) that apply clears. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/e2e-slot-env.sh +source "${SCRIPT_DIR}/lib/e2e-slot-env.sh" + +PLATFORM="${1:?platform required: android|ios|macos}" +SLOT="${2:?slot required: non-negative integer}" + +e2e_slot_env_print "$PLATFORM" "$SLOT" diff --git a/scripts/e2e/lib/e2e-resource-env.sh b/scripts/e2e/lib/e2e-resource-env.sh new file mode 100644 index 0000000000..d0a4929a3a --- /dev/null +++ b/scripts/e2e/lib/e2e-resource-env.sh @@ -0,0 +1,385 @@ +#!/usr/bin/env bash +# Shared e2e resource resolution for check/release scripts. +# Precedence (per value): platform-prefixed RNFB_* → process/global env → serial defaults. +# tests/mellifera.env.json is only consulted when mellifera is explicitly enabled — see +# e2e_mellifera_enabled() below (finding #5: a stale/leftover JSON must never silently +# switch a plain serial check into multi-platform mode). +# +# Platform scoping (finding #3): callers may set E2E_PLATFORM_OVERRIDE (populated from a +# --platform=android|ios|macos CLI flag) to pin e2e_active_platforms() to exactly one +# platform. Without an override, an ambiguous "global" fallback is used — see +# e2e_active_platforms() and the caller-side platform_explicit() checks in +# check-e2e-resources.sh for why "global" must not be treated as "all platforms active" +# for aggressive per-platform probes (e.g. "any booted iOS simulator"). +# +# shellcheck shell=bash + +# Serial defaults (legacy single-run behaviour). +E2E_DEFAULT_METRO_PORT=8081 +E2E_DEFAULT_JET_PORT=8090 +E2E_DEFAULT_JET_CONTROL_PORT=8091 +E2E_DEFAULT_FIRESTORE_PORT=8080 +E2E_DEFAULT_AUTH_PORT=9099 +E2E_DEFAULT_DATABASE_PORT=9000 +E2E_DEFAULT_FUNCTIONS_PORT=5001 +E2E_DEFAULT_STORAGE_PORT=9199 +E2E_DEFAULT_HUB_PORT=4400 +E2E_DEFAULT_LOGGING_PORT=4500 +E2E_DEFAULT_ANDROID_SERIAL=emulator-5554 +E2E_DEFAULT_ANDROID_AVD=TestingAVD +E2E_DEFAULT_IOS_SIMULATOR='iPhone 17' +E2E_ANDROID_APP_ID=com.invertase.testing +E2E_ANDROID_TEST_APP_ID=com.invertase.testing.test +# Default macOS process / PRODUCT_NAME. Override with RNFB_MACOS_PRODUCT_NAME for +# concurrent slotted macOS (e.g. io.invertase.testing.s1) — see running-e2e.md. +E2E_DEFAULT_MACOS_APP_PROCESS=io.invertase.testing +E2E_MACOS_APP_PROCESS="${RNFB_MACOS_PRODUCT_NAME:-$E2E_DEFAULT_MACOS_APP_PROCESS}" +# When RNFB_MACOS_PRODUCT_NAME is unset (serial / unscoped host wipe), also probe/kill +# known slotted siblings so parallel leftovers fail host-clear and get released. +E2E_MACOS_SLOTTED_MAX="${E2E_MACOS_SLOTTED_MAX:-7}" + +e2e_repo_root() { + local here + here="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" + echo "$here" +} + +e2e_mellifera_env_json() { + echo "$(e2e_repo_root)/tests/mellifera.env.json" +} + +# Finding #5: only trust tests/mellifera.env.json when the caller opted in — either via +# RNFB_MELLIFERA=1 or an explicit --mellifera flag (callers set E2E_MELLIFERA_FLAG=1). +# A stale JSON left over from a previous mellifera session must not silently switch a +# plain serial check/release into multi-platform mode. +e2e_mellifera_enabled() { + [[ "${RNFB_MELLIFERA:-0}" == "1" || "${E2E_MELLIFERA_FLAG:-0}" == "1" ]] +} + +# Print (once per process, to stderr) a warning when the JSON file exists but mellifera +# was not enabled for this invocation — visibility without changing behaviour. +e2e_warn_stale_mellifera_json() { + local json + json=$(e2e_mellifera_env_json) + if [[ -f "$json" ]] && ! e2e_mellifera_enabled; then + echo "[e2e-resource-env] NOTE: ${json} exists but RNFB_MELLIFERA=1/--mellifera not set — ignoring it (serial defaults / RNFB_E2E_PLATFORM / --platform apply instead)" >&2 + fi +} + +# --platform= never selects a slot; it only narrows which platform's devices/ports +# are probed among whatever env is already loaded. Warn when the flag is set without +# slotted carry-in (no RNFB_E2E_SLOT and no RNFB_*_JET_PORT). +e2e_warn_platform_without_slot() { + if [[ -z "${E2E_PLATFORM_OVERRIDE:-}" ]]; then + return 0 + fi + if [[ -n "${RNFB_E2E_SLOT:-${RNFB_E2E_HOST_SLOT:-}}" ]]; then + return 0 + fi + if [[ -n "${RNFB_ANDROID_JET_PORT:-}${RNFB_IOS_JET_PORT:-}${RNFB_MACOS_JET_PORT:-}" ]]; then + return 0 + fi + echo "[e2e-resource-env] WARN: --platform=${E2E_PLATFORM_OVERRIDE} does not select a slot; load slotted env first via export-slot-env.sh (no RNFB_E2E_SLOT / RNFB_*_JET_PORT — using serial defaults for ports/devices)" >&2 +} + +# First non-empty among args. +e2e_first_set() { + local v + for v in "$@"; do + if [[ -n "${v}" ]]; then + echo "$v" + return 0 + fi + done + echo "" + return 0 +} + +e2e_env_get() { + local key=$1 + # bash indirect expansion + echo "${!key-}" +} + +# Resolve metro for a platform (android|ios|macos) or "global". +e2e_resolve_metro() { + local platform=${1:-global} + local prefix + if [[ "$platform" != "global" ]]; then + prefix=$(echo "$platform" | tr '[:lower:]' '[:upper:]') + e2e_first_set \ + "$(e2e_env_get "RNFB_${prefix}_METRO_PORT")" \ + "${RCT_METRO_PORT:-}" \ + "${RNFB_METRO_PORT:-}" \ + "${JET_METRO_PORT:-}" \ + "$E2E_DEFAULT_METRO_PORT" + else + e2e_first_set \ + "${RCT_METRO_PORT:-}" \ + "${RNFB_METRO_PORT:-}" \ + "${JET_METRO_PORT:-}" \ + "$E2E_DEFAULT_METRO_PORT" + fi +} + +e2e_resolve_jet() { + local platform=${1:-global} + local prefix + if [[ "$platform" != "global" ]]; then + prefix=$(echo "$platform" | tr '[:lower:]' '[:upper:]') + e2e_first_set \ + "$(e2e_env_get "RNFB_${prefix}_JET_PORT")" \ + "${JET_REMOTE_PORT:-}" \ + "$E2E_DEFAULT_JET_PORT" + else + e2e_first_set "${JET_REMOTE_PORT:-}" "$E2E_DEFAULT_JET_PORT" + fi +} + +e2e_resolve_jet_control() { + local platform=${1:-global} + local prefix jet + jet=$(e2e_resolve_jet "$platform") + if [[ "$platform" != "global" ]]; then + prefix=$(echo "$platform" | tr '[:lower:]' '[:upper:]') + e2e_first_set \ + "$(e2e_env_get "RNFB_${prefix}_JET_CONTROL_PORT")" \ + "${RNFB_JET_CONTROL_PORT:-}" \ + "$((jet + 1))" + else + e2e_first_set "${RNFB_JET_CONTROL_PORT:-}" "$((jet + 1))" + fi +} + +e2e_resolve_emulator_port() { + local platform=$1 service=$2 # firestore|auth|database|functions|storage|hub|logging + local prefix svc_up default_var + prefix=$(echo "$platform" | tr '[:lower:]' '[:upper:]') + svc_up=$(echo "$service" | tr '[:lower:]' '[:upper:]') + case "$service" in + firestore) default_var=$E2E_DEFAULT_FIRESTORE_PORT ;; + auth) default_var=$E2E_DEFAULT_AUTH_PORT ;; + database) default_var=$E2E_DEFAULT_DATABASE_PORT ;; + functions) default_var=$E2E_DEFAULT_FUNCTIONS_PORT ;; + storage) default_var=$E2E_DEFAULT_STORAGE_PORT ;; + hub) default_var=$E2E_DEFAULT_HUB_PORT ;; + logging) default_var=$E2E_DEFAULT_LOGGING_PORT ;; + *) default_var="" ;; + esac + e2e_first_set \ + "$(e2e_env_get "RNFB_${prefix}_EMULATOR_${svc_up}_PORT")" \ + "$default_var" +} + +e2e_resolve_android_avd() { + e2e_first_set "${RNFB_ANDROID_AVD:-}" "${RNFB_ANDROID_AVD_NAME:-}" "$E2E_DEFAULT_ANDROID_AVD" +} + +e2e_resolve_android_serial() { + local json avd + json=$(e2e_mellifera_env_json) + local from_json="" + if [[ -f "$json" ]] && e2e_mellifera_enabled; then + from_json=$(node -e "try{const j=require('$json');console.log((j.android&&j.android.device&&j.android.device.androidSerial)||'')}catch(e){}" 2>/dev/null || true) + fi + # Explicit serial (env or mellifera) always wins. For non-default slotted AVDs + # (including TestingAVD-0), do NOT fall back to emulator-5554 — that is the + # serial TestingAVD default and would make slot-scoped release kill the wrong + # qemu. Release then uses AVD-name pkill. + if [[ -n "$from_json" || -n "${ANDROID_SERIAL:-}" ]]; then + e2e_first_set "$from_json" "${ANDROID_SERIAL:-}" + return 0 + fi + avd=$(e2e_resolve_android_avd) + if [[ "$avd" == "$E2E_DEFAULT_ANDROID_AVD" ]]; then + echo "$E2E_DEFAULT_ANDROID_SERIAL" + else + echo "" + fi +} + +e2e_resolve_ios_simulator() { + e2e_first_set "${RNFB_IOS_SIMULATOR:-}" "$E2E_DEFAULT_IOS_SIMULATOR" +} + +# Platforms to consider, in precedence order: +# 1. E2E_PLATFORM_OVERRIDE (--platform=android|ios|macos CLI flag — highest precedence) +# 2. tests/mellifera.env.json (only when e2e_mellifera_enabled — finding #5) +# 3. RNFB_E2E_PLATFORM +# 4. any platform-prefixed metro/jet port env set +# 5. "global" — ambiguous fallback, NOT "all platforms active" (finding #3; see +# platform_explicit() in check-e2e-resources.sh for how callers must treat this +# differently from an explicit platform list for aggressive/false-positive-prone +# probes such as "any booted iOS simulator"). +e2e_active_platforms() { + local platforms=() + if [[ -n "${E2E_PLATFORM_OVERRIDE:-}" ]]; then + printf '%s\n' "$E2E_PLATFORM_OVERRIDE" + return 0 + fi + local json + json=$(e2e_mellifera_env_json) + if [[ -f "$json" ]] && e2e_mellifera_enabled; then + local p + for p in android ios macos; do + if node -e "const j=require('$json'); if(!j.$p||!j.$p.metro) process.exit(1)" 2>/dev/null; then + platforms+=("$p") + fi + done + fi + if [[ ${#platforms[@]} -eq 0 && -n "${RNFB_E2E_PLATFORM:-}" ]]; then + platforms+=("$RNFB_E2E_PLATFORM") + fi + if [[ ${#platforms[@]} -eq 0 ]]; then + local p prefix + for p in android ios macos; do + prefix=$(echo "$p" | tr '[:lower:]' '[:upper:]') + if [[ -n "$(e2e_env_get "RNFB_${prefix}_METRO_PORT")" || -n "$(e2e_env_get "RNFB_${prefix}_JET_PORT")" ]]; then + platforms+=("$p") + fi + done + fi + if [[ ${#platforms[@]} -eq 0 ]]; then + platforms+=(global) + fi + printf '%s\n' "${platforms[@]}" +} + +# Populate arrays: E2E_PORTS (unique), E2E_PORT_LABELS (parallel labels), plus device fields. +# shellcheck disable=SC2034 +e2e_collect_targets() { + e2e_warn_stale_mellifera_json + e2e_warn_platform_without_slot + E2E_PORTS=() + E2E_PORT_LABELS=() + # Plain-string dedup (not an associative array) — bash 3.2 (macOS default /bin/bash) has no `local -A`. + local seen=" " + local platform metro jet jc svc port label fs + + add_port() { + local p=$1 lab=$2 + [[ -z "$p" ]] && return 0 + case "$seen" in + *" ${p} "*) + return 0 + ;; + esac + seen="${seen}${p} " + E2E_PORTS+=("$p") + E2E_PORT_LABELS+=("$lab") + } + + while IFS= read -r platform; do + [[ -z "$platform" ]] && continue + metro=$(e2e_resolve_metro "$platform") + jet=$(e2e_resolve_jet "$platform") + jc=$(e2e_resolve_jet_control "$platform") + add_port "$metro" "metro:${platform}" + add_port "$jet" "jet:${platform}" + add_port "$jc" "jet-control:${platform}" + if [[ "$platform" != "global" ]]; then + for svc in firestore auth database functions storage hub logging; do + port=$(e2e_resolve_emulator_port "$platform" "$svc") + add_port "$port" "emulator-${svc}:${platform}" + done + # Aux listeners Firebase Tools still binds (same offsets as start-emulator-slotted.sh). + fs=$(e2e_resolve_emulator_port "$platform" firestore) + if [[ -n "$fs" ]]; then + add_port "$((fs + 8))" "emulator-firestore-websocket:${platform}" + add_port "$((fs + 9))" "emulator-eventarc:${platform}" + add_port "$((fs + 12))" "emulator-tasks:${platform}" + fi + else + # Serial defaults: one emulator suite (not per-platform prefixed). + add_port "$E2E_DEFAULT_FIRESTORE_PORT" "emulator-firestore:global" + add_port "$E2E_DEFAULT_AUTH_PORT" "emulator-auth:global" + add_port "$E2E_DEFAULT_DATABASE_PORT" "emulator-database:global" + add_port "$E2E_DEFAULT_FUNCTIONS_PORT" "emulator-functions:global" + add_port "$E2E_DEFAULT_STORAGE_PORT" "emulator-storage:global" + add_port "$E2E_DEFAULT_HUB_PORT" "emulator-hub:global" + add_port "$E2E_DEFAULT_LOGGING_PORT" "emulator-logging:global" + # Firebase Tools serial defaults when config omits aux ports. + add_port "9150" "emulator-firestore-websocket:global" + add_port "9299" "emulator-eventarc:global" + add_port "9499" "emulator-tasks:global" + fi + done < <(e2e_active_platforms) + + E2E_ANDROID_SERIAL=$(e2e_resolve_android_serial) + E2E_ANDROID_AVD=$(e2e_resolve_android_avd) + E2E_IOS_SIMULATOR=$(e2e_resolve_ios_simulator) + # Re-resolve after env/mellifera load so RNFB_MACOS_PRODUCT_NAME wins. + E2E_MACOS_APP_PROCESS="${RNFB_MACOS_PRODUCT_NAME:-$E2E_DEFAULT_MACOS_APP_PROCESS}" +} + +e2e_port_listening() { + local port=$1 + lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1 +} + +e2e_listener_pids() { + local port=$1 + lsof -nP -iTCP:"${port}" -sTCP:LISTEN -t 2>/dev/null || true +} + +e2e_android_app_running() { + local serial=$1 pkg=$2 + adb -s "$serial" shell pidof "$pkg" >/dev/null 2>&1 +} + +# Process names in scope for host-clear / release. +# - RNFB_MACOS_PRODUCT_NAME set → only that name (slot-scoped). +# - RNFB_E2E_SLOT / RNFB_E2E_HOST_SLOT set without product name → only .s${slot}. +# - Neither set (serial / unscoped host wipe) → default + .s0..sN siblings. +e2e_macos_process_names_for_probe() { + if [[ -n "${RNFB_MACOS_PRODUCT_NAME:-}" ]]; then + echo "$RNFB_MACOS_PRODUCT_NAME" + return 0 + fi + local slot="${RNFB_E2E_SLOT:-${RNFB_E2E_HOST_SLOT:-}}" + if [[ -n "$slot" ]]; then + echo "${E2E_DEFAULT_MACOS_APP_PROCESS}.s${slot}" + return 0 + fi + echo "$E2E_DEFAULT_MACOS_APP_PROCESS" + local i + for ((i = 0; i <= E2E_MACOS_SLOTTED_MAX; i++)); do + echo "${E2E_DEFAULT_MACOS_APP_PROCESS}.s${i}" + done +} + +# Echo the first matching busy process name, or empty if none. +e2e_macos_busy_process() { + local name + while IFS= read -r name; do + [[ -z "$name" ]] && continue + if pgrep -x "$name" >/dev/null 2>&1; then + echo "$name" + return 0 + fi + done < <(e2e_macos_process_names_for_probe) + return 1 +} + +e2e_macos_app_running() { + e2e_macos_busy_process >/dev/null +} + +e2e_macos_app_path() { + local name="${1:-$E2E_MACOS_APP_PROCESS}" + echo "$(e2e_repo_root)/tests/macos/build/Build/Products/Debug/${name}.app" +} + +e2e_ios_sim_booted() { + local name=$1 + # Match name in booted devices list + xcrun simctl list devices booted 2>/dev/null | grep -F "$name" | grep -q '(Booted)' && return 0 + # Serial/global: any booted counts as busy for iOS clearness when using defaults + if [[ "$name" == "$E2E_DEFAULT_IOS_SIMULATOR" ]]; then + local count + count=$(xcrun simctl list devices booted 2>/dev/null | grep -c '(Booted)' || true) + [[ "${count:-0}" -gt 0 ]] + return $? + fi + return 1 +} diff --git a/scripts/e2e/lib/e2e-slot-env.sh b/scripts/e2e/lib/e2e-slot-env.sh new file mode 100755 index 0000000000..3a15e82cc6 --- /dev/null +++ b/scripts/e2e/lib/e2e-slot-env.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# Slotted e2e port + device identity helpers (shared by export/run scripts). +# +# Port formula (proven): BASE=12000+slot*1000; android OFF=0, ios +100, macos +200. +# Within a platform block: firestore..logging = BLK+0..6, metro=+7, jet=+10, jet-control=+11. +# Firestore websocket / eventarc / tasks are derived in start-emulator-slotted.sh (FS+8/+9/+12). +# +# macOS: concurrent slots require distinct PRODUCT_NAME via RNFB_MACOS_PRODUCT_NAME +# (io.invertase.testing.s${SLOT}). Do not pass PRODUCT_NAME= on the xcodebuild CLI — +# yarn tests:macos:build derives RNFB_MACOS_PRODUCT_NAME_SUFFIX for pbxproj expansion. +# +# shellcheck shell=bash + +e2e_slot_platform_offset() { + case "$1" in + android) echo 0 ;; + ios) echo 100 ;; + macos) echo 200 ;; + *) + echo "error: platform must be android|ios|macos (got $1)" >&2 + return 1 + ;; + esac +} + +e2e_slot_base() { + local slot=$1 + echo $((12000 + slot * 1000)) +} + +# Export one platform's full RNFB__* port block into the current shell. +e2e_slot_export_platform_block() { + local plat=$1 + local off=$2 + local base=$3 + local up blk fs auth db fn st hub log metro jet jc + up=$(echo "$plat" | tr '[:lower:]' '[:upper:]') + blk=$((base + off)) + fs=$((blk + 0)) + auth=$((blk + 1)) + db=$((blk + 2)) + fn=$((blk + 3)) + st=$((blk + 4)) + hub=$((blk + 5)) + log=$((blk + 6)) + metro=$((blk + 7)) + jet=$((blk + 10)) + jc=$((blk + 11)) + export "RNFB_${up}_METRO_PORT=$metro" + export "RNFB_${up}_JET_PORT=$jet" + export "RNFB_${up}_JET_CONTROL_PORT=$jc" + export "RNFB_${up}_EMULATOR_FIRESTORE_PORT=$fs" + export "RNFB_${up}_EMULATOR_AUTH_PORT=$auth" + export "RNFB_${up}_EMULATOR_DATABASE_PORT=$db" + export "RNFB_${up}_EMULATOR_FUNCTIONS_PORT=$fn" + export "RNFB_${up}_EMULATOR_STORAGE_PORT=$st" + export "RNFB_${up}_EMULATOR_HUB_PORT=$hub" + export "RNFB_${up}_EMULATOR_LOGGING_PORT=$log" +} + +# Apply full multi-platform carry-in + process-local binds for the active platform/slot. +# Always exports android+ios+macos port blocks (babel inline + shared worktree) AND +# slot device identities for all three platforms so check/release stay slot-scoped +# even when the active platform is only android or ios (not macos). +e2e_slot_env_apply() { + local platform=$1 + local slot=$2 + local base off blk metro jet jc + + base=$(e2e_slot_base "$slot") + off=$(e2e_slot_platform_offset "$platform") || return 1 + blk=$((base + off)) + metro=$((blk + 7)) + jet=$((blk + 10)) + jc=$((blk + 11)) + + e2e_slot_export_platform_block android 0 "$base" + e2e_slot_export_platform_block ios 100 "$base" + e2e_slot_export_platform_block macos 200 "$base" + + # Orchestration label must stay unset (babel carry-in). Clear process-local device + # binds that belong only to the active platform process; slot identities below. + unset RNFB_E2E_PLATFORM + unset SIMCTL_CHILD_RCT_METRO_PORT AVD_NAME ORG_GRADLE_PROJECT_reactNativeDevServerPort + unset ANDROID_SERIAL + + export RNFB_E2E_SLOT="$slot" RNFB_E2E_HOST_SLOT="$slot" RNFB_E2E_DEBUG="${RNFB_E2E_DEBUG:-1}" + export RCT_METRO_PORT="$metro" RNFB_METRO_PORT="$metro" JET_METRO_PORT="$metro" + export JET_REMOTE_PORT="$jet" RNFB_JET_CONTROL_PORT="$jc" + + # Slot-owned device / process identity for every platform in this worktree wave. + # Slotted slot 0 uses TestingAVD-0 / RNFB E2E iOS slot-0 (not serial TestingAVD / + # iPhone 17) so a slotted wave can run beside an unslotted serial run safely. + # check/release use these so a slot-N env never falls back to serial defaults + # that would hit other slots or wipe-all .s0..sN. + export RNFB_DETOX_ANDROID_CONFIG="android.emu.debug.slot${slot}" + export RNFB_ANDROID_AVD="TestingAVD-${slot}" RNFB_ANDROID_AVD_NAME="TestingAVD-${slot}" + export RNFB_DETOX_IOS_CONFIG="ios.sim.debug.slot${slot}" + export RNFB_IOS_SIMULATOR="RNFB E2E iOS slot-${slot}" + export RNFB_MACOS_PRODUCT_NAME="${RNFB_MACOS_PRODUCT_NAME_OVERRIDE:-io.invertase.testing.s${slot}}" + export RNFB_MACOS_BUNDLE_IDENTIFIER="${RNFB_MACOS_BUNDLE_IDENTIFIER_OVERRIDE:-org.reactjs.native.${RNFB_MACOS_PRODUCT_NAME//./-}}" + + case "$platform" in + android) + export AVD_NAME="$RNFB_ANDROID_AVD" ORG_GRADLE_PROJECT_reactNativeDevServerPort="$metro" + ;; + ios) + export SIMCTL_CHILD_RCT_METRO_PORT="$metro" + ;; + macos) + ;; + esac +} + +# Print `export KEY=value` / `unset KEY` lines suitable for: +# eval "$(bash …/export-slot-env.sh macos 1)" +# Must emit unset for process-local leftovers that e2e_slot_env_apply clears — +# otherwise a dirty parent shell (ANDROID_SERIAL=emulator-5554, stale AVD_NAME, +# etc.) keeps poisoning check/release after eval. +e2e_slot_env_print() { + local platform=$1 + local slot=$2 + # Apply in a subshell-safe way: run apply then dump the relevant exports. + e2e_slot_env_apply "$platform" "$slot" || return 1 + local keys=( + RNFB_E2E_SLOT RNFB_E2E_HOST_SLOT RNFB_E2E_DEBUG + RCT_METRO_PORT RNFB_METRO_PORT JET_METRO_PORT JET_REMOTE_PORT RNFB_JET_CONTROL_PORT + RNFB_ANDROID_METRO_PORT RNFB_ANDROID_JET_PORT RNFB_ANDROID_JET_CONTROL_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_ANDROID_EMULATOR_HUB_PORT + RNFB_ANDROID_EMULATOR_LOGGING_PORT + RNFB_IOS_METRO_PORT RNFB_IOS_JET_PORT RNFB_IOS_JET_CONTROL_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_IOS_EMULATOR_HUB_PORT + RNFB_IOS_EMULATOR_LOGGING_PORT + RNFB_MACOS_METRO_PORT RNFB_MACOS_JET_PORT RNFB_MACOS_JET_CONTROL_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 RNFB_MACOS_EMULATOR_HUB_PORT + RNFB_MACOS_EMULATOR_LOGGING_PORT + RNFB_DETOX_ANDROID_CONFIG RNFB_ANDROID_AVD RNFB_ANDROID_AVD_NAME + RNFB_DETOX_IOS_CONFIG RNFB_IOS_SIMULATOR + RNFB_MACOS_PRODUCT_NAME RNFB_MACOS_BUNDLE_IDENTIFIER + ) + local k + for k in "${keys[@]}"; do + if [[ -n "${!k:-}" ]]; then + printf 'export %s=%q\n' "$k" "${!k}" + fi + done + # Process-local / orchestration leftovers apply() clears — export if re-set for + # this active platform, otherwise unset so parent leftovers cannot stick. + local clear_keys=( + RNFB_E2E_PLATFORM + ANDROID_SERIAL + AVD_NAME + SIMCTL_CHILD_RCT_METRO_PORT + ORG_GRADLE_PROJECT_reactNativeDevServerPort + ) + for k in "${clear_keys[@]}"; do + if [[ -n "${!k:-}" ]]; then + printf 'export %s=%q\n' "$k" "${!k}" + else + printf 'unset %s\n' "$k" + fi + done +} diff --git a/scripts/e2e/mellifera-host-clean.sh b/scripts/e2e/mellifera-host-clean.sh new file mode 100755 index 0000000000..b8e38c9c6e --- /dev/null +++ b/scripts/e2e/mellifera-host-clean.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Aggressive host clean for this worktree's e2e/mellifera leftovers. +# Uses generic release-e2e-resources.sh, then best-effort worktree-scoped process sweeps. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +echo "[host-clean] $(date -u +%Y-%m-%dT%H:%M:%SZ) purging e2e host..." + +bash "${REPO_ROOT}/scripts/e2e/mellifera-teardown.sh" 2>/dev/null || true + +# Worktree-scoped leftovers that may not hold the resolved listen ports anymore. +pkill -f "${REPO_ROOT}.*firebase.*emulators:start" 2>/dev/null || true +pkill -f "${REPO_ROOT}.*functionsEmulatorRuntime" 2>/dev/null || true +pkill -f "${REPO_ROOT}.*detox" 2>/dev/null || true +pkill -f "${REPO_ROOT}.*jest.*e2e/jest.config.js" 2>/dev/null || true +pkill -f "${REPO_ROOT}.*react-native start" 2>/dev/null || true +pkill -f 'mellifera-run-iterations' 2>/dev/null || true + +rm -f /tmp/rnfb-mellifera-iterations.pid 2>/dev/null || true + +echo "[host-clean] adb devices:" +adb devices 2>/dev/null || true +bash "${REPO_ROOT}/scripts/e2e/check-e2e-resources.sh" || true +echo "[host-clean] done" diff --git a/scripts/e2e/mellifera-host-preflight.sh b/scripts/e2e/mellifera-host-preflight.sh new file mode 100755 index 0000000000..ba17658aa3 --- /dev/null +++ b/scripts/e2e/mellifera-host-preflight.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Host-clear gate before mellifera (or any slotted) work. +# Thin wrapper around mellifera-agnostic check-e2e-resources.sh. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +FAIL=0 + +echo "[host-preflight] $(date -u +%Y-%m-%dT%H:%M:%SZ)" + +if [[ ! -d "${REPO_ROOT}/mellifera/dist" ]]; then + echo "[host-preflight] FAIL mellifera not built — run: cd mellifera && yarn build" >&2 + FAIL=1 +else + echo "[host-preflight] OK mellifera dist present" +fi + +if ! bash "${REPO_ROOT}/scripts/e2e/check-e2e-resources.sh"; then + echo "[host-preflight] FAIL e2e resources busy — run: bash scripts/e2e/release-e2e-resources.sh [--devices]" >&2 + FAIL=1 +fi + +if [[ "$FAIL" -ne 0 ]]; then + echo "[host-preflight] FAILED" >&2 + exit 1 +fi + +echo "[host-preflight] all checks passed" +exit 0 diff --git a/scripts/e2e/mellifera-metro.sh b/scripts/e2e/mellifera-metro.sh new file mode 100755 index 0000000000..69784bb69d --- /dev/null +++ b/scripts/e2e/mellifera-metro.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Slotted Metro start helper (per-platform RCT_METRO_PORT). +# Sourced by run-mellifera-verify-parallel.sh — not a clearance entrypoint. +# To clear Metro alone: bash scripts/e2e/release-e2e-resources.sh --only metro +# Canonical packager for serial runs remains: yarn tests:packager:jet +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Kill the process listening on a Metro port (if any). +mellifera_kill_metro_on_port() { + local port=$1 + # Prefer generic release when env already points at this port. + if [[ "${RCT_METRO_PORT:-}" == "$port" || "${RNFB_METRO_PORT:-}" == "$port" ]]; then + bash "${SCRIPT_DIR}/release-e2e-resources.sh" --only metro >/dev/null 2>&1 || true + return 0 + fi + local pid + pid=$(lsof -nP -iTCP:"${port}" -sTCP:LISTEN -t 2>/dev/null | head -1 || true) + if [[ -n "$pid" ]]; then + echo "[metro] stopping pid=${pid} on :${port}" >&2 + kill "$pid" 2>/dev/null || true + sleep 2 + if kill -0 "$pid" 2>/dev/null; then + kill -9 "$pid" 2>/dev/null || true + sleep 1 + fi + fi +} + +# Start Metro on the current platform env (RCT_METRO_PORT must be set). +# Args: repo_root platform log_file use_reset_cache(0|1) +# Prints new Metro PID to stdout. +mellifera_start_metro() { + local repo_root=$1 platform=$2 log_file=$3 use_reset_cache=${4:-0} + local port="${RCT_METRO_PORT:?RCT_METRO_PORT required}" + local -a extra=() + + if [[ "$use_reset_cache" == "1" ]]; then + extra+=(--reset-cache) + fi + + mellifera_kill_metro_on_port "$port" + echo "[metro-${platform}] port=${port} reset_cache=${use_reset_cache} log=${log_file}" >&2 + ( + cd "${repo_root}/tests" + export RNFB_MELLIFERA=1 RCT_METRO_PORT JET_REMOTE_PORT JET_METRO_PORT RNFB_JET_CONTROL_PORT + yarn react-native start --port "${port}" "${extra[@]}" --client-logs + ) >>"$log_file" 2>&1 & + echo $! +} diff --git a/scripts/e2e/mellifera-preflight.sh b/scripts/e2e/mellifera-preflight.sh new file mode 100755 index 0000000000..82142f1c69 --- /dev/null +++ b/scripts/e2e/mellifera-preflight.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Wait for reserved services to be UP before starting e2e (Metro/emulators listening). +# This is the opposite of check-e2e-resources.sh (host CLEAR). +# Prefer env from tests/mellifera.env.json written by mellifera-apply-reservation.js. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +MELLIFERA_URL="${MELLIFERA_URL:-http://127.0.0.1:8790}" +PORTS_FILE="${REPO_ROOT}/tests/mellifera.env.json" +WAIT_SEC="${RNFB_PREFLIGHT_WAIT_SEC:-300}" +FAIL=0 + +log_ok() { echo "[preflight] OK $*"; } +log_fail() { echo "[preflight] FAIL $*" >&2; FAIL=1; } + +wait_http() { + local url=$1 label=$2 max=${3:-180} + local i=0 + while ! curl -sf "$url" >/dev/null 2>&1; do + sleep 2 + i=$((i + 2)) + if [[ $i -ge $max ]]; then + log_fail "${label} timeout ${url} after ${max}s" + return 1 + fi + done + log_ok "${label} ${url}" +} + +wait_listen() { + local port=$1 label=$2 max=${3:-$WAIT_SEC} + local i=0 + while ! lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; do + sleep 2 + i=$((i + 2)) + if [[ $i -ge $max ]]; then + log_fail "${label} timeout :${port} after ${max}s" + return 1 + fi + done + log_ok "${label} listening :${port}" +} + +echo "[preflight] mellifera health (wait up to ${WAIT_SEC}s per service)" +wait_http "${MELLIFERA_URL}/health" "mellifera" 60 || true + +[[ -f "$PORTS_FILE" ]] && log_ok "ports file ${PORTS_FILE}" || log_fail "missing ${PORTS_FILE}" + +PREFLIGHT_PLATFORMS="${RNFB_PREFLIGHT_PLATFORMS:-android,ios,macos}" +IFS=',' read -r -a _preflight_platforms <<< "$PREFLIGHT_PLATFORMS" + +for platform in "${_preflight_platforms[@]}"; do + metro=$(node -e "try{console.log(require('${PORTS_FILE}').${platform}.metro)}catch(e){process.exit(1)}" 2>/dev/null || echo "") + jet=$(node -e "try{console.log(require('${PORTS_FILE}').${platform}.jet)}catch(e){process.exit(1)}" 2>/dev/null || echo "") + fs=$(node -e "try{console.log(require('${PORTS_FILE}').${platform}.emulator.firestore)}catch(e){process.exit(1)}" 2>/dev/null || echo "") + fn=$(node -e "try{console.log(require('${PORTS_FILE}').${platform}.emulator.functions)}catch(e){process.exit(1)}" 2>/dev/null || echo "") + + echo "[preflight] platform=${platform} metro=${metro} jet=${jet} firestore=${fs} functions=${fn}" + + [[ -n "$metro" ]] && wait_listen "$metro" "metro-${platform}" 120 || true + [[ -n "$metro" ]] && wait_http "http://127.0.0.1:${metro}/status" "metro-${platform}-status" 60 || true + if [[ -n "$jet" ]] && lsof -nP -iTCP:"${jet}" -sTCP:LISTEN >/dev/null 2>&1; then + log_fail "stale jet listener on :${jet} (${platform})" + else + [[ -n "$jet" ]] && log_ok "jet port free :${jet} (${platform})" + fi + [[ -n "$fs" ]] && wait_http "http://127.0.0.1:${fs}" "firestore-${platform}" "$WAIT_SEC" || true + [[ -n "$fn" ]] && wait_listen "$fn" "functions-${platform}" "$WAIT_SEC" || true +done + +MACOS_PRODUCT="${RNFB_MACOS_PRODUCT_NAME:-io.invertase.testing}" +MACOS_APP="${REPO_ROOT}/tests/macos/build/Build/Products/Debug/${MACOS_PRODUCT}.app" +if [[ "$PREFLIGHT_PLATFORMS" == *macos* ]]; then + [[ -d "$MACOS_APP" ]] && log_ok "macOS app ${MACOS_PRODUCT}" || log_fail "macOS app missing (${MACOS_APP})" +fi + +# Finding #7: gate iOS/Android artifact existence checks on RNFB_PREFLIGHT_PLATFORMS, +# same as the port waits above — a platform not in scope for this run should not be +# able to fail preflight just because its app/APK was never built. +IOS_APP="${REPO_ROOT}/tests/ios/build/Build/Products/Debug-iphonesimulator/testing.app" +if [[ "$PREFLIGHT_PLATFORMS" == *ios* ]]; then + [[ -d "$IOS_APP" ]] && log_ok "iOS app" || log_fail "iOS app missing" +fi + +APK="${REPO_ROOT}/tests/android/app/build/outputs/apk/debug/app-debug.apk" +if [[ "$PREFLIGHT_PLATFORMS" == *android* ]]; then + [[ -f "$APK" ]] && log_ok "android apk" || log_fail "android apk missing" +fi + +if [[ "$FAIL" -ne 0 ]]; then + echo "[preflight] FAILED — not starting tests" >&2 + exit 1 +fi + +echo "[preflight] all checks passed" +exit 0 diff --git a/scripts/e2e/mellifera-release-resources.sh b/scripts/e2e/mellifera-release-resources.sh new file mode 100755 index 0000000000..10c4a6d6d0 --- /dev/null +++ b/scripts/e2e/mellifera-release-resources.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Release mellifera lease resources AND clear matching local e2e processes. +# Process clear is mellifera-agnostic (release-e2e-resources.sh); lease update is mellifera API. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +MELLIFERA_URL="${MELLIFERA_URL:-http://127.0.0.1:8790}" +SESSION_ID="${MELLIFERA_SESSION_ID:?MELLIFERA_SESSION_ID required}" +SESSION_TOKEN="${MELLIFERA_SESSION_TOKEN:?MELLIFERA_SESSION_TOKEN required}" +ENV_FILE="${1:-${TMPDIR:-/tmp}/rnfb-mellifera-last.env}" +PORTS_FILE="${REPO_ROOT}/tests/mellifera.env.json" + +shift || true +RESOURCES=("$@") +if [[ ${#RESOURCES[@]} -eq 0 ]]; then + echo "usage: mellifera-release-resources.sh [env-file] resourceId ..." >&2 + echo " example: mellifera-release-resources.sh /tmp/x.env jet:macos platform:macos" >&2 + exit 1 +fi + +# Map resource ids → release-e2e --only categories (best-effort). +ONLY_CATS=() +for rid in "${RESOURCES[@]}"; do + case "$rid" in + metro:*) ONLY_CATS+=(metro) ;; + jet:*) ONLY_CATS+=(jet jet-control) ;; + firebase:*) ONLY_CATS+=(emulators) ;; + platform:android) ONLY_CATS+=(android-apps) ;; + platform:ios) ONLY_CATS+=(ios-sims) ;; + platform:macos) ONLY_CATS+=(macos-app) ;; + esac +done + +if [[ ${#ONLY_CATS[@]} -gt 0 ]]; then + # unique + ONLY_JOINED=$(printf '%s\n' "${ONLY_CATS[@]}" | awk 'NF && !u[$0]++' | paste -sd, -) + echo "[release-resources] clearing local processes --only ${ONLY_JOINED}" + # ios-sims is devices-gated in release-e2e-resources.sh — pass --devices when + # platform:ios → ios-sims is in play (platform:android maps to android-apps only). + EXTRA=() + if [[ ",${ONLY_JOINED}," == *",ios-sims,"* ]]; then + EXTRA+=(--devices) + fi + bash "${REPO_ROOT}/scripts/e2e/release-e2e-resources.sh" --only "$ONLY_JOINED" "${EXTRA[@]+"${EXTRA[@]}"}" || true +fi + +JSON_RESOURCES="$(printf '%s\n' "${RESOURCES[@]}" | node -e " + const lines=require('fs').readFileSync(0,'utf8').trim().split(/\\n/).filter(Boolean); + console.log(JSON.stringify(lines)); +")" + +SESSION_JSON="$(curl -sf -X POST "${MELLIFERA_URL}/v1/sessions/${SESSION_ID}/release-resources" \ + -H 'Content-Type: application/json' \ + -d "{\"token\":\"${SESSION_TOKEN}\",\"resources\":${JSON_RESOURCES}}")" + +node "${REPO_ROOT}/scripts/e2e/mellifera-refresh-session-env.js" \ + "$SESSION_JSON" "$ENV_FILE" "$PORTS_FILE" "$SESSION_TOKEN" + +echo "[release-resources] released lease resources: ${RESOURCES[*]}" diff --git a/scripts/e2e/mellifera-teardown.sh b/scripts/e2e/mellifera-teardown.sh new file mode 100755 index 0000000000..315c7e09f0 --- /dev/null +++ b/scripts/e2e/mellifera-teardown.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Clear local e2e processes for the current reservation/env, then release mellifera session if possible. +# Prefer: bash scripts/e2e/release-e2e-resources.sh for process clear alone. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +MELLIFERA_URL="${MELLIFERA_URL:-http://127.0.0.1:8790}" + +echo "[teardown] $(date -u +%Y-%m-%dT%H:%M:%SZ)" + +bash "${REPO_ROOT}/scripts/e2e/release-e2e-resources.sh" --devices || true + +if curl -sf "${MELLIFERA_URL}/health" >/dev/null 2>&1; then + if [[ -f "${REPO_ROOT}/tests/mellifera.env.json" ]]; then + sid=$(node -e "try{console.log(require('${REPO_ROOT}/tests/mellifera.env.json').sessionId||'')}catch(e){}" 2>/dev/null || true) + tok=$(node -e "try{const e=require('fs').readFileSync('${TMPDIR:-/tmp}/rnfb-mellifera-last.env','utf8');const m=e.match(/MELLIFERA_SESSION_TOKEN=\"([^\"]+)\"/);console.log(m?m[1]:'')}catch(e){}" 2>/dev/null || true) + if [[ -n "${sid:-}" && -n "${tok:-}" ]]; then + curl -sf -X POST "${MELLIFERA_URL}/v1/sessions/${sid}/release" \ + -H 'Content-Type: application/json' \ + -d "{\"token\":\"${tok}\"}" >/dev/null 2>&1 || true + echo "[teardown] released mellifera session ${sid}" + fi + fi +fi + +rm -f /tmp/rnfb-mellifera-*.env "${REPO_ROOT}/tests/mellifera.env.json" 2>/dev/null || true +rm -f "${REPO_ROOT}/tests/mellifera-platform/"*.env 2>/dev/null || true + +# No post-delete check here: release-e2e-resources.sh above already verifies CLEAR +# internally (with forceful retry rounds) against the reservation that was still on +# disk at that point. Checking again now would fall back to serial defaults (the +# mellifera.env.json reservation is already gone) and can report false BUSY/CLEAR +# for a completely different platform's ports. +echo "[teardown] done" diff --git a/scripts/e2e/release-e2e-resources.sh b/scripts/e2e/release-e2e-resources.sh new file mode 100755 index 0000000000..26f124ff89 --- /dev/null +++ b/scripts/e2e/release-e2e-resources.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +# Clear e2e resources using standardized env vars (fallback to serial defaults). +# Soft kill first, then forceful if check-e2e-resources.sh still reports BUSY. +# Default release behaviour is a full wipe (ports + apps, including Metro/emulators) — +# use --only to limit scope, e.g. --only jet,android-apps to leave Metro/emulators up. +# +# Usage: +# bash scripts/e2e/release-e2e-resources.sh +# bash scripts/e2e/release-e2e-resources.sh --only metro,jet,android-apps +# bash scripts/e2e/release-e2e-resources.sh --devices # also stop AVD / shutdown sims +# bash scripts/e2e/release-e2e-resources.sh --platform=android # mid-wave: one platform only +# bash scripts/e2e/release-e2e-resources.sh --mellifera +# +# After export-slot-env (full carry-in), default release clears all three platform port +# blocks for that slot (ports+apps only — not AVD/sims). End-of-slot / final free so +# check --platform=ios is CLEAR: pass --devices. Mid-wave early free of one platform +# must pass --platform= (devices may stay up). --platform= never selects a slot; +# load export-slot-env first. +# +# Categories for --only (comma-separated): +# metro | jet | jet-control | emulators | android-apps | macos-app | ios-sims | android-emulator +# Default: all categories except android-emulator and ios-sims (use --devices to include those). +# `jet` also releases jet-control (paired lifecycle); `--only jet-control` alone works too. +# Unknown categories in --only are a hard error (exit 2) before any resource is touched. +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/e2e-resource-env.sh +source "${SCRIPT_DIR}/lib/e2e-resource-env.sh" + +ONLY="" +DEVICES=0 +MAX_FORCE_ROUNDS=2 +E2E_MELLIFERA_FLAG=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --only) + ONLY=${2:-} + shift 2 + ;; + --only=*) + ONLY=${1#--only=} + shift + ;; + --devices) + DEVICES=1 + shift + ;; + --platform=*) + E2E_PLATFORM_OVERRIDE="${1#--platform=}" + shift + ;; + --mellifera) + E2E_MELLIFERA_FLAG=1 + shift + ;; + -h|--help) + sed -n '2,24p' "$0" + exit 0 + ;; + *) + echo "unknown arg: $1" >&2 + exit 2 + ;; + esac +done + +export E2E_MELLIFERA_FLAG +[[ -n "${E2E_PLATFORM_OVERRIDE:-}" ]] && export E2E_PLATFORM_OVERRIDE + +E2E_KNOWN_CATEGORIES=(metro jet jet-control emulators android-apps macos-app ios-sims android-emulator) + +if [[ -n "$ONLY" ]]; then + IFS=',' read -ra requested_cats <<<"$ONLY" + for rc in "${requested_cats[@]}"; do + [[ -z "$rc" ]] && continue + known=0 + for kc in "${E2E_KNOWN_CATEGORIES[@]}"; do + if [[ "$rc" == "$kc" ]]; then + known=1 + break + fi + done + if [[ "$known" -eq 0 ]]; then + echo "[release-e2e-resources] unknown --only category: '${rc}'" >&2 + echo "[release-e2e-resources] known categories: ${E2E_KNOWN_CATEGORIES[*]}" >&2 + exit 2 + fi + done +fi + +want() { + local cat=$1 + if [[ -z "$ONLY" ]]; then + case "$cat" in + android-emulator|ios-sims) + [[ "$DEVICES" -eq 1 ]] + return $? + ;; + *) + return 0 + ;; + esac + fi + [[ ",${ONLY}," == *",${cat},"* ]] +} + +# Finding A: the recheck below must escalate Metro/emulator ports to BUSY (--services) +# whenever release's own clear scope includes them — otherwise a soft-kill failure on +# Metro/emulators never surfaces and the recheck can report CLEAR while a listener is +# still alive (check-e2e-resources.sh defaults to host-clear mode, which treats those +# ports as informational only). Finding B: forward --mellifera / --platform= explicitly +# too, so the child's argv is authoritative rather than relying on inherited env — build +# once, reuse at both recheck sites below. +CHECK_ARGS=() +if want metro || want emulators; then + CHECK_ARGS+=(--services) +fi +if e2e_mellifera_enabled; then + CHECK_ARGS+=(--mellifera) +fi +if [[ -n "${E2E_PLATFORM_OVERRIDE:-}" ]]; then + CHECK_ARGS+=("--platform=${E2E_PLATFORM_OVERRIDE}") +fi + +run_check() { + # Bash 3.2 (macOS default /bin/bash) throws "unbound variable" under `set -u` when + # expanding "${arr[@]}" on a zero-element array — guard with the +alt form instead of + # expanding CHECK_ARGS directly. + bash "${SCRIPT_DIR}/check-e2e-resources.sh" "${CHECK_ARGS[@]+"${CHECK_ARGS[@]}"}" "$@" +} + +e2e_collect_targets + +echo "[release-e2e-resources] platforms: $(e2e_active_platforms | tr '\n' ' ')" +echo "[release-e2e-resources] soft clear..." + +kill_port_soft() { + local port=$1 + local pids + pids=$(e2e_listener_pids "$port") + if [[ -n "$pids" ]]; then + echo "[release] SIGTERM :${port} pids=$(echo "$pids" | tr '\n' ' ')" + # shellcheck disable=SC2086 + kill $pids 2>/dev/null || true + fi +} + +kill_port_hard() { + local port=$1 + local pids + pids=$(e2e_listener_pids "$port") + if [[ -n "$pids" ]]; then + echo "[release] SIGKILL :${port} pids=$(echo "$pids" | tr '\n' ' ')" + # shellcheck disable=SC2086 + kill -9 $pids 2>/dev/null || true + fi +} + +clear_ports_matching() { + local mode=$1 # soft|hard + local i=0 label port + for port in "${E2E_PORTS[@]}"; do + label="${E2E_PORT_LABELS[$i]}" + i=$((i + 1)) + case "$label" in + metro:*) + want metro || continue + ;; + jet:*) + want jet || continue + ;; + jet-control:*) + # jet-control shares lifecycle with jet — release it whenever either the + # jet or jet-control category is requested (see running-e2e.md § Jet ports). + want jet-control || want jet || continue + ;; + emulator-*) + want emulators || continue + ;; + *) + continue + ;; + esac + if [[ "$mode" == soft ]]; then + kill_port_soft "$port" + else + kill_port_hard "$port" + fi + done +} + +ACTIVE_PLATFORMS="$(e2e_active_platforms | tr '\n' ' ')" +platform_active() { + local p=$1 + [[ " $ACTIVE_PLATFORMS " == *" $p "* || " $ACTIVE_PLATFORMS " == *" global "* ]] +} + +clear_android_apps() { + want android-apps || return 0 + platform_active android || return 0 + command -v adb >/dev/null 2>&1 || return 0 + # Empty serial = slotted AVD without known adb id — skip force-stop (pkill by AVD + # in clear_android_emulator covers the qemu; do not guess emulator-5554). + [[ -n "${E2E_ANDROID_SERIAL:-}" ]] || return 0 + echo "[release] adb force-stop on ${E2E_ANDROID_SERIAL}" + adb -s "$E2E_ANDROID_SERIAL" shell am force-stop "$E2E_ANDROID_APP_ID" 2>/dev/null || true + adb -s "$E2E_ANDROID_SERIAL" shell am force-stop "$E2E_ANDROID_TEST_APP_ID" 2>/dev/null || true +} + +clear_macos_app() { + want macos-app || return 0 + platform_active macos || return 0 + local name + while IFS= read -r name; do + [[ -z "$name" ]] && continue + if pgrep -x "$name" >/dev/null 2>&1; then + echo "[release] killall ${name}" + killall "$name" 2>/dev/null || true + fi + done < <(e2e_macos_process_names_for_probe) +} + +clear_macos_app_hard() { + want macos-app || return 0 + platform_active macos || return 0 + local name + while IFS= read -r name; do + [[ -z "$name" ]] && continue + if pgrep -x "$name" >/dev/null 2>&1; then + echo "[release] killall -9 ${name}" + killall -9 "$name" 2>/dev/null || true + fi + done < <(e2e_macos_process_names_for_probe) +} + +clear_ios_sims() { + want ios-sims || return 0 + platform_active ios || return 0 + command -v xcrun >/dev/null 2>&1 || return 0 + echo "[release] simctl shutdown ${E2E_IOS_SIMULATOR}" + xcrun simctl shutdown "$E2E_IOS_SIMULATOR" 2>/dev/null || true + # If still using default name and any booted remain, shut down booted (serial mode). + if [[ "$E2E_IOS_SIMULATOR" == "$E2E_DEFAULT_IOS_SIMULATOR" ]]; then + xcrun simctl shutdown booted 2>/dev/null || true + fi +} + +clear_android_emulator() { + want android-emulator || return 0 + platform_active android || return 0 + echo "[release] android emulator kill serial=${E2E_ANDROID_SERIAL:-unset} avd=${E2E_ANDROID_AVD}" + if [[ -n "${E2E_ANDROID_SERIAL:-}" ]] && command -v adb >/dev/null 2>&1; then + adb -s "$E2E_ANDROID_SERIAL" emu kill 2>/dev/null || true + fi + # Prefer AVD-name match so slotted release never kills another slot's qemu via + # a guessed serial. Requires a non-empty AVD name (always set after collect). + if [[ -n "${E2E_ANDROID_AVD:-}" ]]; then + pkill -f "qemu-system.*${E2E_ANDROID_AVD}" 2>/dev/null || true + fi +} + +clear_ports_matching soft +clear_android_apps +clear_macos_app +clear_ios_sims +clear_android_emulator +sleep 2 + +round=0 +while [[ $round -lt $MAX_FORCE_ROUNDS ]]; do + if run_check >/tmp/rnfb-check-e2e-resources.out 2>&1; then + echo "[release-e2e-resources] CLEAR after soft/force round ${round}" + cat /tmp/rnfb-check-e2e-resources.out + exit 0 + fi + echo "[release-e2e-resources] still BUSY — forceful round $((round + 1))" + cat /tmp/rnfb-check-e2e-resources.out >&2 || true + clear_ports_matching hard + clear_macos_app_hard + clear_android_apps + clear_ios_sims + clear_android_emulator + sleep 2 + round=$((round + 1)) +done + +if run_check; then + echo "[release-e2e-resources] CLEAR" + exit 0 +fi + +echo "[release-e2e-resources] FAILED — resources still busy after forceful clear" >&2 +exit 1 diff --git a/scripts/e2e/run-slotted-packager.sh b/scripts/e2e/run-slotted-packager.sh new file mode 100755 index 0000000000..70ab4f03af --- /dev/null +++ b/scripts/e2e/run-slotted-packager.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Start Metro for a slotted platform with full multi-platform port carry-in. +# Usage: bash scripts/e2e/run-slotted-packager.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +# shellcheck source=lib/e2e-slot-env.sh +source "${SCRIPT_DIR}/lib/e2e-slot-env.sh" + +PLATFORM="${1:?platform required: android|ios|macos}" +SLOT="${2:?slot required: non-negative integer}" + +e2e_slot_env_apply "$PLATFORM" "$SLOT" + +CACHE_DIR="/tmp/rnfb-metrocache-${PLATFORM}-slot${SLOT}" +mkdir -p "$CACHE_DIR" +export TMPDIR="$CACHE_DIR" + +echo "[slotted-packager] active=${PLATFORM} slot${SLOT} metro=${RCT_METRO_PORT} macosProduct=${RNFB_MACOS_PRODUCT_NAME:-} cache=${TMPDIR}" +cd "$REPO_ROOT" +exec yarn tests:packager:jet-reset-cache diff --git a/scripts/e2e/run-slotted-test-cover.sh b/scripts/e2e/run-slotted-test-cover.sh new file mode 100755 index 0000000000..62ade1ddfb --- /dev/null +++ b/scripts/e2e/run-slotted-test-cover.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Run yarn tests::test-cover with slotted ports (+ macOS PRODUCT_NAME). +# Usage: bash scripts/e2e/run-slotted-test-cover.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +# shellcheck source=lib/e2e-slot-env.sh +source "${SCRIPT_DIR}/lib/e2e-slot-env.sh" + +PLATFORM="${1:?platform required: android|ios|macos}" +SLOT="${2:?slot required: non-negative integer}" + +e2e_slot_env_apply "$PLATFORM" "$SLOT" + +echo "[slotted-test-cover] active=${PLATFORM} slot${SLOT} metro=${RCT_METRO_PORT} jet=${JET_REMOTE_PORT} macosProduct=${RNFB_MACOS_PRODUCT_NAME:-}" +cd "$REPO_ROOT" +exec yarn "tests:${PLATFORM}:test-cover" diff --git a/scripts/e2e/start-emulator-slotted.sh b/scripts/e2e/start-emulator-slotted.sh new file mode 100755 index 0000000000..efd39813c5 --- /dev/null +++ b/scripts/e2e/start-emulator-slotted.sh @@ -0,0 +1,100 @@ +#!/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). +# +# Usage: +# bash scripts/e2e/start-emulator-slotted.sh [slot] +# With [slot], applies full carry-in via e2e_slot_env_apply (same as run-slotted-*). +# Without [slot], requires RNFB__EMULATOR_* already exported (e.g. after +# eval "$(export-slot-env.sh …)"). +set -euo pipefail + +PLATFORM="${1:?platform required: android|ios|macos}" +SLOT_ARG="${2:-}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +SCRIPTS="${REPO_ROOT}/.github/workflows/scripts" + +if [[ -n "$SLOT_ARG" ]]; then + # shellcheck source=lib/e2e-slot-env.sh + source "${SCRIPT_DIR}/lib/e2e-slot-env.sh" + e2e_slot_env_apply "$PLATFORM" "$SLOT_ARG" +fi + +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, or pass slot: start-emulator-slotted.sh ${PLATFORM} )" >&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/scripts/run-full-tests.sh b/scripts/run-full-tests.sh index 8db41ea188..dc6a46a845 100755 --- a/scripts/run-full-tests.sh +++ b/scripts/run-full-tests.sh @@ -16,8 +16,17 @@ function terminate_testing_processes() { ps -ef | grep node | grep firebase.js | grep emulators:start | awk '{print $2}' | xargs kill 2>/dev/null || true ps -ef | grep node | grep react-native | grep cli.js | awk '{print $2}' | xargs kill 2>/dev/null || true - # The macOS app stays running even after the run, clean it up as well - killall "io.invertase.testing" 2>/dev/null || true + # The macOS app stays running even after the run, clean it up as well. + # Prefer RNFB_MACOS_PRODUCT_NAME; also clear known slotted siblings (parallel e2e). + local mac_name + mac_name="${RNFB_MACOS_PRODUCT_NAME:-io.invertase.testing}" + killall "$mac_name" 2>/dev/null || true + if [[ -z "${RNFB_MACOS_PRODUCT_NAME:-}" ]]; then + local s + for s in 0 1 2 3 4 5 6 7; do + killall "io.invertase.testing.s${s}" 2>/dev/null || true + done + fi sleep 5 } 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..b4251b5ced 100644 --- a/tests/.detoxrc.js +++ b/tests/.detoxrc.js @@ -1,101 +1,171 @@ /** @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()}`; +} + +// Slotted devices (including slot 0) — distinct from serial iPhone 17 / TestingAVD +// so a slotted wave can run beside an unslotted serial run. +function iosSimulatorDevice(slot) { + const deviceType = process.env.RNFB_IOS_BASE_SIMULATOR || 'iPhone 17'; + const slotName = `RNFB E2E iOS slot-${slot}`; + return { + type: 'ios.simulator', + device: { type: deviceType, name: slotName }, + }; +} + +function androidEmulatorDevice(slot) { + return { + type: 'android.emulator', + device: { avdName: `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 SERIAL_IOS_DEVICE_TYPE = process.env.RNFB_IOS_BASE_SIMULATOR || 'iPhone 17'; +const SERIAL_ANDROID_AVD = 'TestingAVD'; + +const devices = { + // Serial unslotted defaults (yarn tests:ios / android without slot env). + simulator: { + type: 'ios.simulator', + device: { type: SERIAL_IOS_DEVICE_TYPE, name: SERIAL_IOS_DEVICE_TYPE }, + }, + attached: { + type: 'android.attached', + device: { adbName: '.*' }, + }, + emulator: { + type: 'android.emulator', + device: { avdName: SERIAL_ANDROID_AVD }, + bootArgs: process.env.RNFB_ANDROID_EMULATOR_BOOT_ARGS || '-no-snapshot-load -no-snapshot-save', + readonly: true, + }, +}; + +for (let slot = 0; 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`, }, - 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', - }, + '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`, }, - 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, - }, + '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', }, - 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 = 0; 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 = 0; 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..24931fd81d 100644 --- a/tests/.jetrc.js +++ b/tests/.jetrc.js @@ -4,13 +4,170 @@ const execFile = promisify(require('child_process').execFile); 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; +const DEFAULT_MACOS_PRODUCT_NAME = 'io.invertase.testing'; + +function macOsProductName() { + return process.env.RNFB_MACOS_PRODUCT_NAME || DEFAULT_MACOS_PRODUCT_NAME; +} + +function macOsBundleIdentifier() { + if (process.env.RNFB_MACOS_BUNDLE_IDENTIFIER) { + return process.env.RNFB_MACOS_BUNDLE_IDENTIFIER; + } + // Match Xcode $(PRODUCT_NAME:rfc1034identifier) — dots → hyphens. + return `org.reactjs.native.${macOsProductName().replace(/\./g, '-')}`; +} + +function macOsBundleQuery() { + // Bundle IDs are ascii; keep hyphens literal in the Metro query string. + return ( + 'platform=macos&dev=true&lazy=true&minify=false&inlineSourceMap=true&modulesOnly=false&runModule=true&app=' + + macOsBundleIdentifier() + ); +} + +function macOsBinaryPath() { + const name = macOsProductName(); + return `./macos/build/Build/Products/Debug/${name}.app/Contents/MacOS/${name}`; +} + +function shellSingleQuote(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'`; +} + +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 ${shellSingleQuote(macOsProductName())}`, { stdio: 'ignore' }); + return true; + } catch (_e) { + return false; + } +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function killMacOsTestApp() { + const name = macOsProductName(); + if (!isMacOsTestAppRunning()) { + return; + } + try { + execSync(`killall ${shellSingleQuote(name)}`, { stdio: 'ignore' }); + } catch (_e) { + // already gone + } + await sleep(500); + if (isMacOsTestAppRunning()) { + try { + execSync(`killall -9 ${shellSingleQuote(name)}`, { 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] ${name} 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 ${shellSingleQuote(macOsProductName())}`, { 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`; - const bundleUrl = `http://${host}:${metroPort}/index.bundle?${MACOS_BUNDLE_QUERY}`; + const bundleUrl = `http://${host}:${metroPort}/index.bundle?${macOsBundleQuery()}`; const started = Date.now(); while (Date.now() - started < timeoutMs) { @@ -32,6 +189,9 @@ async function waitForMetroMacosBundle(metroPort = 8081, timeoutMs = 600000) { module.exports = { config: { + // Prefer process-local JET_REMOTE_PORT when already exported (slotted launchers); + // each target.before() still re-applies the platform-prefixed port and logs it. + port: parseEnvPort(process.env.JET_REMOTE_PORT) ?? SERIAL_JET_PORT, slow: 3000, reporter: 'spec', timeout: 420000, // 7 minutes - fetchAndActivate takes 5+ sometimes @@ -43,6 +203,8 @@ module.exports = { targets: { android: { async before(config) { + config.port = readJetPort('android'); + console.warn(`[rnfb-e2e] android Jet port=${config.port}`); return config; }, async after(_config) { @@ -51,6 +213,8 @@ module.exports = { }, ios: { async before(config) { + config.port = readJetPort('ios'); + console.warn(`[rnfb-e2e] ios Jet port=${config.port}`); return config; }, async after(_config) { @@ -59,19 +223,28 @@ 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; + console.warn(`[rnfb-e2e] macos Jet port=${jetPort} metro=${metroPort}`); + await waitForMetroMacosBundle(metroPort); + const macBinary = macOsBinaryPath(); + console.warn(`[rnfb-e2e] spawning macOS app ${macBinary} (${macOsBundleIdentifier()})`); + 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 +268,13 @@ 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 — ${macOsProductName()} 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() {