From 9d3c5ffaf46de5f0f14bcdebffc47ca4760a841f Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Tue, 22 Sep 2026 07:32:33 -0500 Subject: [PATCH] test(e2e): use neutral iOS slot simulators --- .../scripts/resolve-ios-simulator-name.sh | 2 +- okf-bundle/ci-workflows/detox-patches.md | 4 +- .../monorepo-tooling/prepare-and-cache.md | 2 +- okf-bundle/testing/agent-command-policy.md | 13 +- okf-bundle/testing/e2e-parallel-design.md | 10 +- okf-bundle/testing/index.md | 2 +- okf-bundle/testing/running-e2e.md | 78 ++++- .../app/__tests__/createE2eDevices.test.js | 298 ++++++++++++++++++ .../__tests__/detoxAppleBuildSettings.test.js | 138 +++++++- packages/app/__tests__/e2eResourceEnv.test.js | 217 ++++++++++++- packages/app/__tests__/e2eSlotEnv.test.js | 105 +++++- .../app/__tests__/jestInlineEnvCache.test.js | 2 +- .../app/__tests__/resolveIosSimulator.test.js | 4 +- scripts/e2e/check-e2e-resources.sh | 19 +- scripts/e2e/create-android-avds.sh | 11 +- scripts/e2e/create-ios-simulators.sh | 25 +- scripts/e2e/export-slot-env.sh | 4 +- scripts/e2e/lib/e2e-resource-env.sh | 40 ++- scripts/e2e/lib/e2e-slot-env.sh | 56 +++- scripts/e2e/release-e2e-resources.sh | 10 +- tests/.detoxrc.js | 11 +- tests/patches/detox+20.51.0.patch | 30 ++ 22 files changed, 1000 insertions(+), 81 deletions(-) create mode 100644 tests/patches/detox+20.51.0.patch diff --git a/.github/workflows/scripts/resolve-ios-simulator-name.sh b/.github/workflows/scripts/resolve-ios-simulator-name.sh index 7cc0e11b10..2056c0bd39 100755 --- a/.github/workflows/scripts/resolve-ios-simulator-name.sh +++ b/.github/workflows/scripts/resolve-ios-simulator-name.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Resolve the iOS simulator *device name* for boot-simulator.sh (CI + Jet reboot). -# Prefer RNFB_IOS_SIMULATOR (slot env already exports `RNFB E2E iOS slot-N`; +# Prefer RNFB_IOS_SIMULATOR (slot env already exports `RN E2E iOS slot-N`; # serial default follows scripts/e2e/create-ios-simulators.sh / E2E_DEFAULT_IOS_SIMULATOR / # RNFB_IOS_BASE_SIMULATOR). Detoxrc fallback skips comment lines so a comment # mentioning iPhone cannot become the device name. diff --git a/okf-bundle/ci-workflows/detox-patches.md b/okf-bundle/ci-workflows/detox-patches.md index 9c038dfa9c..1ec33b7289 100644 --- a/okf-bundle/ci-workflows/detox-patches.md +++ b/okf-bundle/ci-workflows/detox-patches.md @@ -8,10 +8,12 @@ timestamp: 2026-08-26T00:00:00Z # Detox yarn patches -E2E runs on **Detox 20.51.0** (`tests/package.json`), applied via Yarn Berry patch: +E2E runs on **Detox 20.51.0** (`tests/package.json`). This file inventories the **Yarn Berry** patch: `.yarn/patches/detox-npm-20.51.0-3e13b6e309.patch` +**Not this file:** iOS neutral-slot exact-base-or-fail allocation is [`tests/patches/detox+20.51.0.patch`](../../tests/patches/detox+20.51.0.patch) via `tests` `prepare` (`patch-package`). Contract: [running e2e § iOS slot simulators](../testing/running-e2e.md#ios-slot-simulators). Do not add that hunk here. + Patches are in-repo. Prefer direct patch-file edits or headless workflow; `yarn patch-commit` may prompt and fail in non-interactive shells. ## Inventory diff --git a/okf-bundle/monorepo-tooling/prepare-and-cache.md b/okf-bundle/monorepo-tooling/prepare-and-cache.md index 078317914a..642a18a0de 100644 --- a/okf-bundle/monorepo-tooling/prepare-and-cache.md +++ b/okf-bundle/monorepo-tooling/prepare-and-cache.md @@ -105,7 +105,7 @@ Add `nx.json`; keep the `yarn lerna:prepare` entrypoint name, with `NX_NO_CLOUD` Most packages' `prepare` is bob transpile (`lib/**` → `dist/**`) and is correctly cached under [MonoTool-AD-11](architecture-decisions.md#monotool-ad-11--scope-prepare-cache-inputs-with-a-jssource-namedinput--accepted). The **tests** and **tests-macos** workspaces are different: their `prepare` script is **`patch-package`**, which mutates that app's `node_modules/**`. -- **`tests/`** (mobile RN **0.86.2**): still runs `patch-package` for non-fmt patches (e.g. `@firebase+rules-unit-testing`). Mobile RN ships fmt **12.1.0** upstream; there is **no** `tests/patches/react-native+*.patch` fmt bump. +- **`tests/`** (mobile RN **0.86.2**): still runs `patch-package` for non-fmt patches (e.g. `@firebase+rules-unit-testing`, [`detox+20.51.0.patch`](../../tests/patches/detox+20.51.0.patch) — [iOS slot simulators](../testing/running-e2e.md#ios-slot-simulators)). Mobile RN ships fmt **12.1.0** upstream; there is **no** `tests/patches/react-native+*.patch` fmt bump. - **`tests-macos/`** (RN **0.78.3** + `react-native-macos@0.78.6`): applies [`tests-macos/patches/react-native+0.78.3.patch`](../../tests-macos/patches/react-native+0.78.3.patch) (fmt **12.1.0**) and the macos patch. Those targets must set **`"cache": false`** on the project-level Nx `prepare` override. Reasons: diff --git a/okf-bundle/testing/agent-command-policy.md b/okf-bundle/testing/agent-command-policy.md index fe3187d799..94b4b57f68 100644 --- a/okf-bundle/testing/agent-command-policy.md +++ b/okf-bundle/testing/agent-command-policy.md @@ -50,6 +50,7 @@ Single source for **which shell commands agents may run** in this repo. E2e `yar | E2e + coverage | [running e2e](running-e2e.md) — **only** `yarn tests:*` | `jet`, `npx jet`, `yarn jet`, `detox test`, bare `detox`, `cd tests && …`, `cd tests-macos && …`, 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 § pre-flight](running-e2e.md#pre-flight-is-the-host-clear-to-start) — host-clear + services ready + **[checkout ownership](running-e2e.md#services-checkout-ownership-blocking)** + harness tier. `yarn tests:e2e:check` / `yarn tests:e2e:release` ([host-clear probes](running-e2e.md#host-clear-probes)) | Port/HTTP checks alone when Metro/emulators belong to another worktree; `pgrep`/spawn probes of Jet/Detox as completion signals; ad-hoc `pgrep` / hardcoded `:8090` only; improvised kill lists; `--all-slots` while another owner is live | +| E2e slot devices (first use of a slot) | `yarn tests:e2e:setup-android-avds [count]` / `yarn tests:e2e:setup-ios-sims [count]` — **create/reuse-only** of the exact iOS neutral base; count is a strict decimal **1..8** ([slot index](running-e2e.md#e2e-slot-index)). Patched Detox reuses that base or fails when registry-taken; check/release target its exact name — [iOS slot simulators](running-e2e.md#ios-slot-simulators). Teardown of a running device is `yarn tests:e2e:release --devices` (shutdown, never delete) | Direct `xcrun simctl create/delete/erase/rename`, `avdmanager` / `sdkmanager` device commands, `simctl delete all`/`unavailable`, `simctl shutdown all`. Removing a legacy simulator is a **human-operator** step — [migration](running-e2e.md#ios-slot-simulator-migration) | | Slotted parallel | [running e2e § slot lifecycle](running-e2e.md#slot-lifecycle) — `eval "$(yarn tests:e2e:export-slot-env N)"` then the **same** `yarn tests:packager:*` / `yarn tests:emulator:start` / `pod:install` / `:build` / `:test-cover` as serial. macOS packager is `yarn tests:macos:packager:*` (`tests-macos/`); mobile is `yarn tests:packager:*` (`tests/`) | ad-hoc port math; second lifecycle helpers as the runbook; rsync between local worktrees; host flock; Metro-after-build; disabling Swift explicit modules; Debug `RCT_NO_LAUNCH_PACKAGER` as a Metro fix; using the mobile packager for macOS Jet (or vice versa) | | TurboModule codegen (all migrated / CI) | `yarn codegen:verify` (wipe + regen + diff); `yarn codegen:all` for local regen via package scripts | ad-hoc CLI without wipe; inventing alternate codegen yarn scripts — see [TurboModule codegen](#turbomodule-codegen) | @@ -86,7 +87,7 @@ Single source for **which shell commands agents may run** in this repo. E2e `yar **Before any** `yarn tests:ios:build`, `yarn tests:android:build`, or other Detox native build path: 1. **Root `yarn` MUST have run and exited 0** in this checkout. Required on a fresh checkout, after deleting `node_modules`, after pulling patch changes, and whenever patches may be stale. Do **not** start native `:build` until that install finished successfully. -2. Root `yarn` applies **`.yarn/patches`** (jet, detox, mocha-remote) and workspace **`patch-package`** patches. **macOS** still applies **`tests-macos/patches/react-native+0.78.3.patch`** (fmt **12.1.0**). **Mobile RN 0.86.2** ships fmt **12.1.0** upstream (no `tests/patches/react-native+*.patch` fmt bump). `tests` / `tests-macos` `prepare` is `patch-package` and must **not** be Nx-cache-skipped ([MonoTool-AD-12](../monorepo-tooling/architecture-decisions.md#monotool-ad-12--never-nx-cache-prepare-when-the-script-is-patch-package--accepted)). +2. Root `yarn` applies **`.yarn/patches`** (jet, detox, mocha-remote) and workspace **`patch-package`**. **`tests/patches/detox+20.51.0.patch`** is required for slotted iOS exact-base-or-fail allocation ([running e2e § iOS slot simulators](running-e2e.md#ios-slot-simulators)) — it is **not** the Yarn Berry Detox patch. **macOS** still applies **`tests-macos/patches/react-native+0.78.3.patch`** (fmt **12.1.0**). **Mobile RN 0.86.2** ships fmt **12.1.0** upstream (no `tests/patches/react-native+*.patch` fmt bump). `tests` / `tests-macos` `prepare` is `patch-package` and must **not** be Nx-cache-skipped ([MonoTool-AD-12](../monorepo-tooling/architecture-decisions.md#monotool-ad-12--never-nx-cache-prepare-when-the-script-is-patch-package--accepted)). Without the tests Detox patch applied, an occupied neutral slot may cause stock Detox to create a suffixed device outside the slot contract. 3. **Verify** the React Native fmt podspec reports version **≥ 12.1.0** (Xcode 26 / Apple Clang 21-safe floor for this pin). **Yarn exit 0 alone is not sufficient** — always run the check below before native `:build` (a prepare cache-skip historically left fmt at **11.0.2** despite a green install): ```bash @@ -99,10 +100,16 @@ rg 'spec\.version|:tag' tests-macos/node_modules/react-native/third-party-podspe Expect `12.1.0` (or higher) on both `spec.version` and `:tag`. +Before **slotted** iOS `:test-cover`, also confirm `tests/patches/detox+20.51.0.patch` landed (expect a match; missing means an occupied neutral base can trigger stock Detox clone creation / suffix escape instead of exact-base-or-fail — [iOS slot simulators](running-e2e.md#ios-slot-simulators)): + +```bash +rg 'RN_E2E_SLOT_BASE_NAME' tests/node_modules/detox/src/devices/allocation/drivers/ios/SimulatorAllocDriver.js +``` + 4. If fmt is still **11.0.2** (or anything **< 12.1.0**): **STOP**. Re-run root `yarn` / fix patch application for **macOS** (including Nx `tests-macos:prepare` cache policy — [MonoTool-AD-12](../monorepo-tooling/architecture-decisions.md#monotool-ad-12--never-nx-cache-prepare-when-the-script-is-patch-package--accepted)). On mobile 0.86+, fmt should already be ≥12.1.0 without a patch; if not, investigate the resolved `react-native` version. Do **not** invent Podfile `post_install` fmt hacks, `FMT_USE_CONSTEVAL` / `base.h` patches, c++17-for-fmt-only, or web-search workarounds. 5. **Before `yarn tests:ios:build` on a clean checkout:** run root `yarn` (which includes `yarn ruby:install`), then run **`yarn tests:ios:pod:install`** and wait for exit 0. Re-run the Pod install after `tests/ios/Podfile` / `Podfile.lock`, package podspec, or native dependency-resolution changes, and whenever `tests/ios/Pods` is absent. Root `yarn` does **not** create the CocoaPods xcconfig or file-list support files; the shared build invokes Detox/Xcode and does **not** provision them. CI installs the root bundle before yarn, then uses this same Pods-before-build order in `.github/workflows/tests_e2e_ios.yml`. -**Symptoms when violated:** Apple Clang 21 consteval errors compiling unpatched fmt **11.0.2**; missing `tests/ios/Pods` xcconfig or file-list support files; agents inventing Podfile/fmt workarounds instead of re-running root `yarn` / fixing prepare cache policy. +**Symptoms when violated:** Apple Clang 21 consteval errors compiling unpatched fmt **11.0.2**; missing `tests/ios/Pods` xcconfig or file-list support files; an occupied slotted iOS base causing stock Detox to create a suffixed simulator instead of failing (tests Detox patch-package skipped); agents inventing Podfile/fmt workarounds instead of re-running root `yarn` / fixing prepare cache policy. ## When install or prepare fails @@ -130,6 +137,7 @@ Expect `12.1.0` (or higher) on both `spec.version` and `:tag`. | `detox test`, bare `detox`, `cd tests && detox …` | E2e agent rule | | bare `bundle install` at repo root | Use **`yarn ruby:install`** or root **`yarn`** (`postinstallDev` includes ruby:install) | | Ad-hoc Metro / emulator start | Use `yarn tests:packager:jet` (iOS/Android) or `yarn tests:macos:packager:jet` (macOS), `yarn tests:emulator:start` ([running e2e](running-e2e.md#rules)) | +| Direct `xcrun simctl delete` / `erase` / `rename` / `create` (and `simctl delete all` / `unavailable`, `simctl shutdown all`) as e2e device management | Destroys devices other worktrees, repos, or operators may be mid-run on. Provision with `yarn tests:e2e:setup-ios-sims` (create/reuse-only), free with `yarn tests:e2e:release --devices` (shutdown only). Legacy-simulator removal is human-operator — [migration](running-e2e.md#ios-slot-simulator-migration) | | Spawn / PATH probes to “test” Jet or genversion | Log triage only; fix product code and re-run canonical command | ## Known traps @@ -240,6 +248,7 @@ Never react-native init / npx @react-native-community/cli init — @react-native Never: yarn workspace prepare, yarn jet, npx jet, cd packages/* && yarn prepare/build for diagnostics. Never invent format/install: yarn google-java-format, bare/npx google-java-format, npm install, yarn install in tests/ alone — use root yarn first; Java format = yarn lint:android ONLY. Never invent Android Gradle: ad-hoc ./gradlew outside yarn tests:android:unit / :build / :post-e2e-coverage / :test:jacoco-report; bare detox/jet/metro. +E2e slot devices: yarn tests:e2e:setup-ios-sims / setup-android-avds (create/reuse-only of the exact iOS neutral base; count decimal 1..8). Patched Detox reuses that base or fails when registry-taken (tests/patches/detox+20.51.0.patch via tests prepare; exact slot-0..7). yarn tests:e2e:release --devices shuts down the exact base. Direct check/release --slot=N uses the same 0..effective max guard as export-slot-env. Never direct xcrun simctl create/delete/erase/rename, simctl delete all|unavailable, simctl shutdown all. Legacy RNFB E2E iOS slot-N removal is human-operator only — okf-bundle/testing/running-e2e.md#ios-slot-simulator-migration. Prepare/install: yarn or yarn lerna:prepare must exit 0 before ANY other command — never parallelize with e2e/Metro/build. Before native :build: root yarn exit 0 + verify tests/node_modules/react-native/third-party-podspecs/fmt.podspec (and tests-macos copy when building macOS) ≥ 12.1.0 — okf-bundle/testing/agent-command-policy.md#install-patch-fmt-gate-blocking. Before iOS build on a clean checkout: root yarn, then yarn tests:ios:pod:install exit 0. If fmt < 12.1.0: STOP and re-run yarn; never invent Podfile/FMT_USE_CONSTEVAL/c++17 fmt hacks. Area harness: okf-bundle/testing/running-e2e.md#local-harness-overrides-harnessoverridesjs — copy harness.overrides.example.js to gitignored harness.overrides.js; set modules + RNFBDebug; delete overrides after run. diff --git a/okf-bundle/testing/e2e-parallel-design.md b/okf-bundle/testing/e2e-parallel-design.md index d4e82ca961..2d7268025a 100644 --- a/okf-bundle/testing/e2e-parallel-design.md +++ b/okf-bundle/testing/e2e-parallel-design.md @@ -28,7 +28,7 @@ After the `tests-macos/` split, **iOS/Android** Metro (`yarn tests:packager:*`, | **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` (yarn tests:* pin `RNFB_ANDROID_CONSOLE_PORT=5554`). Slotted: `TestingAVD-{n}` (incl. `-0`) + console `5556+2n` (`emulator-5556/5558/5560`, …). Detox must not pick FreePortFinder **10000–20000**; check/release treat those leftover serials as BUSY / kill | -| **iOS** | Simulator device name | Serial: `iPhone 17`. Slotted: `RNFB E2E iOS slot-{n}` (incl. `slot-0`) | +| **iOS** | Simulator device name | Serial: `iPhone 17` (stock Detox). Slotted: exact `RN E2E iOS slot-{n}` base; patched Detox reuses it or fails when registry-taken ([running e2e § iOS slot simulators](running-e2e.md#ios-slot-simulators); [slot index](running-e2e.md#e2e-slot-index)) | | **macOS** | Process / `PRODUCT_NAME` (+ derived bundle id) | `io.invertase.testing` | | **Coverage paths** | NYC / coverage — `tests/` (iOS/Android) and `tests-macos/` (macOS); see [coverage design § e2e TS](coverage-design.md#e2e-typescript-coverage-jet--nyc) | Fixed per worktree (same-platform parallel overwrites) | | **CocoaPods CDN cache** | Trunk spec CDN under `~/.cocoapods` | **Not** parameterized. Overlapping `yarn tests:ios:pod:install` can miss trunk specs (`SocketRocket (~> 0.7.1)`) while siblings succeed. Serialize Apple `pod:install` before overlapping `:build`/`:test-cover` ([running e2e § slot lifecycle](running-e2e.md#slot-lifecycle)). `:build` does not run pod install. | @@ -130,7 +130,7 @@ The numbered recipe lives in [running e2e § slot lifecycle](running-e2e.md#slot └─────────────────────────────────────────────────────────────┘ ``` -Same-platform scale-out: **N worktrees × slot 0..N-1** (e.g. `3× android + 3× ios + 3× macos`). +Same-platform scale-out: **N worktrees × slot 0..N-1** (e.g. `3× android + 3× ios + 3× macos`). Slot index, effective max (**min(configured, hard 7)**), setup count **1..8**, and `eval`/check/release fail-before-action: [running e2e § slot index](running-e2e.md#e2e-slot-index). ### Metro per worktree and per slot @@ -168,7 +168,7 @@ Each **android-slot** / **ios-slot** / **macos-slot** is a fixed **port block** | 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`), console **`5556+2n`** (`emulator-5556` …), Detox `android.emu.debug.slot{n}` — not FreePortFinder 10000–20000 | -| iOS | `iPhone 17` | `RNFB E2E iOS slot-{n}` (incl. `slot-0`), Detox `ios.sim.debug.slot{n}` | +| iOS | `iPhone 17` | exact `.detoxrc` base `RN E2E iOS slot-{n}` (incl. `slot-0`); occupied bases fail allocation; 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). @@ -551,7 +551,7 @@ Work-queue rows for implementation are ephemeral — not duplicated here per [do ## 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`). +2. **iOS simulators:** **decided** — dedicated exact devices `RN E2E iOS slot-0`…`slot-7`, so a shared Mac can run other RN e2e stacks (Detox or Appium) against the same slots; serial keeps `iPhone 17` (`create-ios-simulators.sh`, create/reuse-only). Patched Detox reuses the exact base or fails on ownership conflict. Naming and host migration: [running e2e § iOS slot simulators](running-e2e.md#ios-slot-simulators). 3. **Shared build artifacts:** single `tests/ios/build` / `tests-macos/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:** a Mellifera-published slot **may** run inside a Tart VM; Tart is not the RNFB e2e product ([layers](#rnfb-mellifera-tart-layers)). @@ -561,7 +561,7 @@ Work-queue rows for implementation are ephemeral — not duplicated here per [do ## Related docs -* [Running e2e tests](running-e2e.md) — canonical commands, change bar, slot lifecycle +* [Running e2e tests](running-e2e.md) — canonical commands, change bar, [slot lifecycle](running-e2e.md#slot-lifecycle), [slot index](running-e2e.md#e2e-slot-index) * [Coverage design](coverage-design.md) — per-platform artifact policy * [Firebase testing project](firebase-testing-project.md) — emulator vs cloud * Mellifera coordinator — co-developed out-of-tree; see [§ RNFB e2e, Mellifera, and Tart](#rnfb-mellifera-tart-layers) (not shipped in the contention PR) diff --git a/okf-bundle/testing/index.md b/okf-bundle/testing/index.md index f45f951aeb..0d0a18f401 100644 --- a/okf-bundle/testing/index.md +++ b/okf-bundle/testing/index.md @@ -5,7 +5,7 @@ * [Change authoring workflow](change-authoring-workflow.md) — verified product change loop (unit-focused → `documentation?` → area-focused `independent-review` → commit); [§ gates](change-authoring-workflow.md#gates); [§ frozen tree](change-authoring-workflow.md#frozen-tree); [§ quality standards](change-authoring-workflow.md#quality-standards); [§ validation evidence (blocking)](change-authoring-workflow.md#validation-evidence-blocking); [§ commit](change-authoring-workflow.md#commit); [coverage evidence package](coverage-design.md#coverage-evidence-package) * [compare-types justification bar](../../.github/scripts/compare-types/README.md#justification-bar) — firebase-js-sdk type/API drift justification * [Iteration vocabulary](iteration-vocabulary.md) — work type, tier, and queue field identifiers -* [Running e2e tests](running-e2e.md) — canonical e2e commands; start here for `:test-cover`; [§ agent rule (read first)](running-e2e.md#agent-rule-read-first); [§ test-app native modules](running-e2e.md#test-app-native-modules) (`NativeRNFBTesting` / `RNFBTestingMessaging` probes vs package `Coverage` flush); [§ platform coverage gate](running-e2e.md#platform-coverage-gate-blocking); [§ slot lifecycle](running-e2e.md#slot-lifecycle); [§ infrastructure change bar](running-e2e.md#e2e-infrastructure-change-bar); [§ startup fail-fast poll](running-e2e.md#startup-fail-fast-poll) +* [Running e2e tests](running-e2e.md) — canonical e2e commands; start here for `:test-cover`; [§ agent rule (read first)](running-e2e.md#agent-rule-read-first); [§ test-app native modules](running-e2e.md#test-app-native-modules) (`NativeRNFBTesting` / `RNFBTestingMessaging` probes vs package `Coverage` flush); [§ platform coverage gate](running-e2e.md#platform-coverage-gate-blocking); [§ slot lifecycle](running-e2e.md#slot-lifecycle); [§ slot index](running-e2e.md#e2e-slot-index); [§ iOS slot simulators](running-e2e.md#ios-slot-simulators) (exact `.detoxrc` base or fail; [migration](running-e2e.md#ios-slot-simulator-migration)); [§ infrastructure change bar](running-e2e.md#e2e-infrastructure-change-bar); [§ startup fail-fast poll](running-e2e.md#startup-fail-fast-poll) * [E2e parallel design](e2e-parallel-design.md) — why resources collide, parameterization, coordinator rollout (commands stay in running-e2e) * [Validation checklist](validation-checklist.md) — validation command sequence; [§ OKF bundle review](validation-checklist.md#okf-bundle-review) (frozen `independent-review` when those files are in the tree) * [Coverage design](coverage-design.md) — coverage policy, Codecov/native gates (merged Android `jacocoTestReport`; iOS unit + e2e LCOV); [§ coverage expectations (policy)](coverage-design.md#coverage-expectations-policy); [§ evidence package](coverage-design.md#coverage-evidence-package); [§ react-native-coverage](coverage-design.md#react-native-coverage) (package `flush()`; probe modules in [running e2e](running-e2e.md#test-app-native-modules)); [§ coverage migration rollback](coverage-design.md#coverage-migration-rollback); [iOS Ruby SimpleCov](coverage-design.md#ios-ruby-simplecov) (`yarn tests:ios:ruby`, flag `ios-ruby`) diff --git a/okf-bundle/testing/running-e2e.md b/okf-bundle/testing/running-e2e.md index e7dc3fe31e..0d9fe470fe 100644 --- a/okf-bundle/testing/running-e2e.md +++ b/okf-bundle/testing/running-e2e.md @@ -23,7 +23,7 @@ Install, prepare, validation, the workspace Expo documented-path iOS **link** fi ## Prerequisites (once per checkout) ```bash -yarn # repo root — exit 0 required. Applies .yarn/patches (jet, mocha-remote-*, detox) and patch-package (tests-macos fmt patch on 0.78; mobile 0.86 ships fmt 12.1.0); installs tests devDeps incl. babel-plugin-istanbul +yarn # repo root — exit 0 required. Applies .yarn/patches (jet, mocha-remote-*, detox) and tests/tests-macos patch-package (incl. tests/patches/detox+20.51.0.patch) — [install / patch / fmt gate](agent-command-policy.md#install-patch-fmt-gate-blocking) ``` **Before `yarn tests:ios:build` / `yarn tests:android:build`:** [install / patch / fmt gate](agent-command-policy.md#install-patch-fmt-gate-blocking) (root `yarn` exit 0 + fmt **≥ 12.1.0**). @@ -303,7 +303,7 @@ MACOS_APP="${RNFB_MACOS_PRODUCT_NAME:-io.invertase.testing}" `yarn tests:e2e:check` **default** reports Jet + apps + simulators — not Metro/emulator ports (those are [services ready](#2-services-ready)). Pass `--services` to treat Metro/emulator as BUSY. `--platform=android|ios|macos` scopes devices ([global device scoping](#global-device-scoping)). Android host-clear also flags **stray `emulator-*` serials** (console ports outside serial **5554** and slotted **5556+2n**) as BUSY — leftover Detox FreePortFinder **10000–20000** guests (`emulator-16xxx`) must not report CLEAR. `yarn tests:e2e:release` emu-kills those strays even without `--devices`. -**Default (no args, no slot env):** serial ports/devices only. Do **not** wipe leftover `TestingAVD-N` / slot sims / slotted `12xxx` ports. **`--all-slots`:** explicit whole-host recover (dangerous if another owner is live). Slot scope: `eval "$(yarn tests:e2e:export-slot-env …)"` or `--slot=N`. +**Default (no args, no slot env):** serial ports/devices only. Do **not** wipe leftover `TestingAVD-N` / slot sims / slotted `12xxx` ports. **`--all-slots`:** explicit whole-host recover of leftover slots through **effective max** (default/cap **7**; dangerous if another owner is live) — [slot index](#e2e-slot-index). Preferred slot scope is `eval "$(yarn tests:e2e:export-slot-env …)"` then check/release; direct `--slot=N` uses the same **0..effective max** guard and fails before probes or release. iOS device scope targets the exact neutral base name — [iOS slot simulators](#ios-slot-simulators). Kill policy: SIGTERM then SIGKILL of **listener PIDs on in-scope ports**, plus named AVD/sim/`PRODUCT_NAME`. No process-group / starter-shell assumptions. @@ -696,7 +696,7 @@ See also: [unit-focused-tier loop](#unit-focused-tier-iteration-loop), [one :tes ## Environment -- **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 `yarn tests:e2e:check --platform=ios` for the stricter zero-booted-simulators check — see [global device scoping](#global-device-scoping). macOS auto-starts app. +- **Devices** — Serial iOS/Android: Detox boots `iPhone 17` / `TestingAVD`. Slotted Android: `TestingAVD-N`. Slotted iOS: `.detoxrc` names `RN E2E iOS slot-N`; patched Detox boots that exact base or fails when it is registry-taken — [iOS slot simulators](#ios-slot-simulators). iOS `:test-cover` should preflight with `yarn tests:e2e:check --platform=ios` 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 (serial default)** — one Metro (`:8081`) per packager project (`tests/` for iOS/Android, `tests-macos/` for macOS), 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` or `yarn tests:macos:packager:jet`, `yarn tests:emulator:start`). Reset-cache `EADDRINUSE` on `:8081` → [packager reset-cache](#packager-reset-cache-eaddrinuse). Slotted runs: [configurable e2e environment](#configurable-e2e-environment). - **Android Gradle home** — leave `GRADLE_USER_HOME` unset so Gradle uses the host default (`~/.gradle`). Slotted `export-slot-env` does **not** isolate a per-slot Gradle home; independent git worktrees share `~/.gradle` by design (Gradle’s user home is built for concurrent CI). If a host does not default there, optionally set `GRADLE_USER_HOME=$HOME/.gradle` as host setup — not slot isolation. Historical “cross-worktree Gradle race” (`bundleLibRuntimeToDirDebug` resolving another tree’s absolute `node_modules` paths) was **rsync path poisoning** (stale `autolinking.json` / config-cache worktree roots), not a Gradle cache concurrency bug. @@ -716,8 +716,8 @@ Serial e2e uses committed defaults (Metro `:8081`, Jet `:8090`, emulators `:8080 ```bash eval "$(yarn tests:e2e:clear-slot-env)" # serial only: drop leftover export-slot-env -eval "$(yarn tests:e2e:export-slot-env N)" # omit for serial defaults -yarn tests:e2e:check # optional; default = serial-only +eval "$(yarn tests:e2e:export-slot-env N)" # omit for serial defaults; N is the slot index +yarn tests:e2e:check # optional; default = serial-only; slotted = env from the line above yarn tests:packager:jet # or yarn tests:macos:packager:jet yarn tests:emulator:start yarn tests::pod:install # Apple, when needed — serialize across worktrees (shared CocoaPods CDN) @@ -728,6 +728,14 @@ yarn tests:e2e:release [--devices] # optional No second lifecycle (no host flock, no Metro-after-build, no slotted packager/test-cover yarn aliases, no `clean-slot-build-artifacts`). Collision control is **slot identity + check/release**. Mellifera leases slots; Tart may host a slot VM — [layers](e2e-parallel-design.md#rnfb-mellifera-tart-layers). + + +**Slot index (`N`):** decimal integer **0..effective max**, where effective max is **min(configured `E2E_SLOTTED_MAX`, hard 7)**. The same guard applies to `yarn tests:e2e:export-slot-env` (and apply) and to canonical `yarn tests:e2e:check --slot=N` / `release --slot=N`. Invalid or out-of-range fails **before any export, probe, or release action** (`eval "$(…)"` cannot apply a partial env). Lowering `E2E_SLOTTED_MAX` is honored; raising it cannot exceed Detox slot **7**. Preferred lifecycle selector remains `eval "$(yarn tests:e2e:export-slot-env …)"` then check/release. + +**`--all-slots` sweeps** (ports, Android AVDs, exact iOS neutral bases, macOS `.sN`, check/release) use that same effective max. + +**Setup count:** `yarn tests:e2e:setup-android-avds [count]` / `yarn tests:e2e:setup-ios-sims [count]` accept a strict decimal integer **1..8** (default **1**). `count=8` provisions indices **0..7**. Invalid values and **9+** fail **before any inventory or device command**. Setup count is the hard Detox inventory bound, independent of a lowered sweep ceiling. + ### Full suite orchestrator (`yarn test:full`) @@ -745,7 +753,7 @@ yarn test:full --verbose # stream output (default: compact st | Flag | Default | Purpose | | -------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--slot N` | off (serial) | Parallel slotted e2e in this worktree. Each platform subprocess gets its own `eval "$(yarn tests:e2e:export-slot-env N)"` — **never** one android export for all three. | +| `--slot N` | off (serial) | Parallel slotted e2e in this worktree. `N` must be a [slot index](#e2e-slot-index) `export-slot-env` would accept. Each platform subprocess gets its own `eval "$(yarn tests:e2e:export-slot-env N)"` — **never** one android export for all three. | | `--clear-whole-host` | off | Whole-host `yarn tests:e2e:release --all-slots --devices` + check `--all-slots --services` before e2e, and `--all-slots --devices` after. **Dedicated e2e Mac only** — kills every slot. Default (no flag) is scoped release only (serial → `TestingAVD` / `iPhone 17` / `io.invertase.testing`; slotted → slot-N). | | `--verbose` | off | Stream step output. Default prints one status line per phase; full logs under `/tmp/rnfb-full-*` (preserved on failure). | @@ -753,13 +761,13 @@ Slotted mode: no e2e retries (fail-fast). Serial mode: up to 3 `:test-cover` att **Readiness (orchestrator):** follow [§2 Services ready](#2-services-ready) — Metro `curl /status` with `packager-status:running`; emulator ready = `yarn tests:emulator:start` exit 0 (Functions port via `e2e_port_listening` inside that script). No fixed `sleep` for readiness; poll or delegate to canonical `yarn tests:*`. -**First use of a slot:** `yarn tests:e2e:setup-android-avds [count]` / `yarn tests:e2e:setup-ios-sims [count]`. Default **count=1** (CI / typical developer). Pass a higher count on a host that can sustain it (e.g. `8`). Serial unslotted devices stay `TestingAVD` / `iPhone 17`. +**First use of a slot:** `yarn tests:e2e:setup-android-avds [count]` / `yarn tests:e2e:setup-ios-sims [count]` — [setup count](#e2e-slot-index). Both scripts are **create/reuse-only** — an existing device is reported and reused; neither deletes, renames, or erases anything. Serial unslotted devices stay `TestingAVD` / `iPhone 17`. iOS naming and host migration: [iOS slot simulators](#ios-slot-simulators). Keep exported env in the **same shell**. `export-slot-env` loads full `RNFB_{ANDROID,IOS,MACOS}_*` carry-in, `ANDROID_SERIAL=emulator-$((5556+2*N))`, `RNFB_ANDROID_CONSOLE_PORT` (Detox must not FreePortFinder **10000–20000**), `RNFB_MACOS_PRODUCT_NAME=io.invertase.testing.sN`. Does not set `GRADLE_USER_HOME`. **Serial leftover env + Jest transform cache:** `tests/.babelrc` / `tests-macos/.babelrc` `babel-plugin-transform-inline-environment-variables` inlines `RNFB_*` at transform time. Jest’s transform cache does **not** include those env values, so a prior slotted shell can make serial `:test-cover` wait on android slot-0 Metro **`:12007`**. Mitigations: `eval "$(yarn tests:e2e:clear-slot-env)"` before a serial Law run; `e2e_sanitize_serial_env` inside packager / emulator / `:build` / `:test-cover` when `RNFB_E2E_SLOT` is unset (then **pins** `RNFB_ANDROID_CONSOLE_PORT=5554` / `ANDROID_SERIAL=emulator-5554` so Detox does not FreePortFinder **10000–20000**); `cache: false` in `tests/e2e/jest.config.js` so Detox Jest cannot replay a slotted transform. Host Metro wait is not the only leftover: a **slot-baked APK** (`react_native_dev_server_port=12007`) on serial `:test-cover` still launches `TestingAVD`/`emulator-5554` then fails Detox `isReady` / APP_STATUS — `e2e_assert_android_apk_metro_port` fail-fasts that mismatch. -**Check/release with slot env loaded:** all three platform port blocks for that slot (ports+apps; `--devices` for AVD/sims). Mid-wave free of one platform: `--platform=`. `--platform=` never selects a slot. No slot env → serial defaults only (not leftover slots 0..N) unless `--all-slots`. +**Check/release with slot env loaded:** all three platform port blocks for that slot (ports+apps; `--devices` for AVD/sims). Mid-wave free of one platform: `--platform=`. `--platform=` never selects a slot. No slot env → serial defaults only (not leftover slots) unless `--all-slots`. Direct `--slot=N` is the same [slot index](#e2e-slot-index) guard; preferred lifecycle is still export then check/release. **Infra failures:** hub/port taken, suite busy, Detox `TELNET_ERROR` / leftover `emulator-16xxx`, wrong-owner Metro, **`yarn tests:ios:pod:install` / `yarn tests:macos:pod:install` / `:build` failure** → **abort the wave**. On the first infra fail: stop remaining platform×slot cells (do **not** wait for them to finish), then scoped `yarn tests:e2e:release` (wave declared dead → `yarn tests:e2e:release --all-slots --devices` is allowed). Do not mid-wave unscoped `--all-slots` while another owner is still live. `:test-cover` does not restart Metro. @@ -769,7 +777,7 @@ Keep exported env in the **same shell**. `export-slot-env` loads full `RNFB_{AND **Overlapping 3×3 is 9 cells at once.** After serialized Apple `pod:install` (CDN only), launch android + ios + macos `:test-cover` on all three worktrees together — qemu included while Apple Jets are live. Do **not** serialize Android. Do **not** wait for Apple `:test-cover` to finish before qemu. Do **not** treat Detox `currentStatus` / status-query timeout latency as host-capacity proof or as a reason to run fewer than 9 cells. Latency is tolerated: raise Detox ready/status timeouts and launch retries ([`tests/e2e/detoxLatencyPolicy.js`](../../tests/e2e/detoxLatencyPolicy.js), [Detox yarn patch](../ci-workflows/detox-patches.md)) so CI-like slowness waits instead of flakes. Hard infra still aborts ([startup fail-fast poll](#startup-fail-fast-poll)). Metro `/status` can be `packager-status:running` before `:build` and dead at `:test-cover` — start `yarn tests:packager:*` kill-then-start again immediately before `:test-cover` (same `RCT_METRO_PORT` / `$HOME/.metro/rnfb-${port}` / `/status` re-gate). Jet launch-retry/drain leftover listen PIDs (SIGTERM then SIGKILL); macOS bundle wait fail-fast if `/status` is not `packager-status:running` within 120s. Same-platform 3× still needs **three worktrees**. Do **not** use host flock, Metro-after-build, per-slot `GRADLE_USER_HOME`, or `setsid`. -**Slotted device identities (including slot 0):** `TestingAVD-N`, `RNFB E2E iOS slot-N`, Detox `*.slotN`, `io.invertase.testing.sN`. Console `5556+2N` (never `5554` for slotted 0). Detox `LaunchCommand` already prepends `-port`; do not add `-port` to `bootArgs`. Apple `:build` compiles only — `pod:install` is a prior step when pods may be stale. +**Slotted device identities (including slot 0):** `TestingAVD-N`, exact `RN E2E iOS slot-N` ([iOS slot simulators](#ios-slot-simulators)), Detox `*.slotN`, `io.invertase.testing.sN`. Console `5556+2N` (never `5554` for slotted 0). Detox `LaunchCommand` already prepends `-port`; do not add `-port` to `bootArgs`. Apple `:build` compiles only — `pod:install` is a prior step when pods may be stale. **Parallel / multi-platform carry-in:** every Metro/Jest/Detox process for a slot gets the **full** `RNFB_{ANDROID,IOS,MACOS}_*` set (`tests/.babelrc` / `tests-macos/.babelrc` inline static `process.env.RNFB_*`). Do not use `RNFB_E2E_PLATFORM` to choose ports. In-app: `getE2eEmulatorPort(…)` — never hardcode serial emulator ports. @@ -790,10 +798,10 @@ Keep exported env in the **same shell**. `export-slot-env` loads full `RNFB_{AND | `RNFB_DETOX_STATUS_QUERY_TIMEOUT_MS`, `RNFB_DETOX_LOGIN_TIMEOUT_MS`, `RNFB_DETOX_DEBUG_SYNCHRONIZATION_MS`, `RNFB_LAUNCH_APP_TIMEOUT_MS`, `RNFB_LAUNCH_APP_MAX_ATTEMPTS` | Load-latency knobs. **Defaults live in** [`tests/e2e/detoxLatencyPolicy.js`](../../tests/e2e/detoxLatencyPolicy.js). Detox Login / CurrentStatus floors also require the yarn patch ([detox-patches](../ci-workflows/detox-patches.md)). Do not copy timeout numbers elsewhere | | `RNFB_ANDROID_ADB_INSTALL_TIMEOUT_MS` | Android Detox `pm install` spawn timeout (policy default **300000ms**). **Defaults live in** [`tests/e2e/detoxLatencyPolicy.js`](../../tests/e2e/detoxLatencyPolicy.js). Detox `ADB.js` patch hardcodes the same value at yarn apply time — env override does not reach Detox without patch regen ([detox-patches](../ci-workflows/detox-patches.md)) | | `RNFB_JET_AWAIT_EXIT_STALL_MS` | Stall guard after fatal Jet disconnect before killing Detox Jest (policy default **1200000ms**). **Defaults live in** [`tests/e2e/detoxLatencyPolicy.js`](../../tests/e2e/detoxLatencyPolicy.js) | -| `RNFB_E2E_SLOT` | Slot index for orchestration / AVD / sim naming | +| `RNFB_E2E_SLOT` | Slot index for orchestration / AVD / sim naming. Set by `export-slot-env` — [slot index](#e2e-slot-index) | | `GRADLE_USER_HOME` | Optional host setup only. Leave unset for the Gradle default (`~/.gradle`); slotted `export-slot-env` does not set or isolate it. If the host does not default to `$HOME/.gradle`, you may set `GRADLE_USER_HOME=$HOME/.gradle` — not a per-slot override | | `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_ANDROID_EMULATOR_BOOT_ARGS` is snapshot flags only — never include `-port` | +| `RNFB_ANDROID_AVD`, `RNFB_IOS_SIMULATOR`, `RNFB_ANDROID_EMULATOR_BOOT_ARGS` | Device selection overrides. Slotted helpers set `TestingAVD-{n}` / exact `RN E2E iOS slot-{n}` (including `n=0`; patched Detox reuses that iOS base or fails when registry-taken, see [iOS slot simulators](#ios-slot-simulators)); serial defaults stay `TestingAVD` / `iPhone 17`. `RNFB_ANDROID_EMULATOR_BOOT_ARGS` is snapshot flags only — never include `-port` | | `RNFB_ANDROID_CONSOLE_PORT`, `ANDROID_SERIAL` | Android qemu console port and adb serial. **Serial** `e2e_sanitize_serial_env` pins `5554` / `emulator-5554` (`TestingAVD`). **Slotted** helpers pin `5556+2×slot` / `emulator-${port}` (adb-safe **[5554, 5584]**, skipping 5554 so unslotted `emulator-5554` can coexist). Detox patch uses this instead of FreePortFinder 10000–20000 | | `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` | @@ -808,7 +816,53 @@ See [host-clear probes](#host-clear-probes) (`yarn tests:e2e:check` / `yarn test **Global device scoping** — with no `--platform`, no `RNFB_E2E_PLATFORM`, and no per-platform port env set, check/release use **serial defaults only**. They probe android app state on the default serial and the macOS default process name, but do **not** escalate “any booted iOS simulator” to BUSY (an unrelated sim would fail every host-clear). Pass `--platform=ios` when iOS is about to run ([host clear](#1-host-clear)). `--platform=` does **not** load a slot — see [slot lifecycle](#slot-lifecycle). `--all-slots` is the only unscoped leftover-slot wipe. -Setup: `yarn tests:e2e:setup-android-avds [count]` / `yarn tests:e2e:setup-ios-sims [count]` (default count **1**). +Setup: `yarn tests:e2e:setup-android-avds [count]` / `yarn tests:e2e:setup-ios-sims [count]` (create/reuse-only; [setup count](#e2e-slot-index), [iOS slot simulators](#ios-slot-simulators)). + + + +#### iOS slot simulators (exact neutral base or fail) + +Slotted iOS devices use a **repo-neutral base name**: **`RN E2E iOS slot-N`**, with `N` a [slot index](#e2e-slot-index). [`tests/.detoxrc.js`](../../tests/.detoxrc.js) `ios.sim.debug.slotN` sets `device.name` to that **base**. The name is deliberately not RNFB-specific so one Mac can host other React Native e2e stacks — Detox or Appium — on the same slot devices; on such a host RNFB's reserved index is **slot 3** (the `yarn test:full --slot 3` example above). Serial unslotted iOS stays **`iPhone 17`** and Android stays `TestingAVD` / `TestingAVD-N`. + +| Surface | Name / owner | +| ------- | ------------ | +| `.detoxrc` `device.name` for `ios.sim.debug.slotN` | `RN E2E iOS slot-N` — the **base** | +| `yarn tests:e2e:setup-ios-sims` | create/reuse of the **base** only | +| Device that runs slotted iOS | exact **`RN E2E iOS slot-N`** base | +| `RNFB_IOS_SIMULATOR` from `export-slot-env` | the exact base used by Detox, check, and release | +| Serial `ios.sim.debug` | stock Detox allocates **`iPhone 17`** (unchanged) | + +**Stock Detox is not sufficient for this contract.** Detox 20.51.0 reuses a free matching base, but when every match is registry-taken it calls `AppleSimUtils.create()` and creates a suffixed simulator. [`tests/patches/detox+20.51.0.patch`](../../tests/patches/detox+20.51.0.patch) (`patch-package` via `tests` `prepare`) preserves free exact-base reuse for `RN E2E iOS slot-0`…`slot-7`, but fails before creation when the base is taken. The error directs the operator to confirm Mellifera/manual ownership, release the owner, and inspect the Detox device registry only when no owner is active. Serial `iPhone 17` and unrelated simulator configurations keep stock allocation behavior. Apply the patch with root `yarn` — [install / patch / fmt gate](agent-command-policy.md#install-patch-fmt-gate-blocking). This is **not** the Yarn Berry Detox patch under `.yarn/patches/` ([detox-patches.md](../ci-workflows/detox-patches.md)). + +**Setup is create/reuse-only.** `yarn tests:e2e:setup-ios-sims [count]` creates a missing base and reports an existing one as `exists`. It never deletes, renames, erases, shuts down, or re-creates a device. + +**An occupied base is a hard ownership conflict.** Detox must not escape a Mellifera/manual slot lease by creating another simulator. Release the owning run, or resolve a stale registry entry only after confirming no owner is active. + +**Check/release are exact-name scoped.** After [export](#e2e-slot-index) (or a range-validated `--slot=N`), `yarn tests:e2e:check` reports BUSY when the selected slot's exact base is booted; `yarn tests:e2e:release --devices` issues `simctl shutdown` for exactly that base (shutdown only — release never deletes a simulator). Matching is whole-name, so `slot-1` never picks up `slot-10` or any suffixed name. `--all-slots` widens that to every exact base through **effective max** plus serial `iPhone 17` ([host-clear probes](#host-clear-probes)). + + + +##### Migrating a host off legacy `RNFB E2E iOS slot-N` (human operator only) + +Hosts provisioned before the neutral base still hold `RNFB E2E iOS slot-N` devices (and their `-Detox` clones). Nothing in the runbook selects them, so they are inert; the only reason to remove them is reclaiming disk on a host that is about to run a slotted iOS wave. + +This is a **human-operator** procedure. Agents must not run it — `xcrun simctl delete` is not in the [agent command registry](agent-command-policy.md#canonical-registry), and unregistered direct `simctl` device commands are [forbidden](agent-command-policy.md#forbidden-always). An agent that needs the migration asks the operator to run it. + +For the **one** slot `N` being migrated (shown for `N=3`): + +```bash +xcrun simctl delete 'RNFB E2E iOS slot-3' # exact legacy base +xcrun simctl delete 'RNFB E2E iOS slot-3-Detox' # exact legacy Detox clone +yarn tests:e2e:setup-ios-sims 4 # recreate neutral bases 0..3 (create/reuse-only) +``` + +| Safety rule | Why | +| ----------- | --- | +| Delete **only** the two exact legacy names for the selected `N` | A broader match can destroy a device another worktree, repo, or operator is mid-run on | +| Never prefix/glob delete (`RNFB E2E iOS*`), `simctl delete all`, `simctl delete unavailable`, or `simctl shutdown all` | Not scoped to this migration; also [what not to do](#what-not-to-do) | +| Never delete a neutral `RN E2E iOS slot-N` base | It is the live slot device, shared with any other e2e stack on this host | +| Never delete, rename, or erase serial **`iPhone 17`** | Serial unslotted e2e and CI depend on it | +| Recreate only via `yarn tests:e2e:setup-ios-sims` | A hand-rolled `simctl create` can pick a different device type or runtime than the setup script resolves | diff --git a/packages/app/__tests__/createE2eDevices.test.js b/packages/app/__tests__/createE2eDevices.test.js index 7cbddac37d..0d5b08605a 100644 --- a/packages/app/__tests__/createE2eDevices.test.js +++ b/packages/app/__tests__/createE2eDevices.test.js @@ -1,4 +1,6 @@ +const { execFileSync } = require('child_process'); const fs = require('fs'); +const os = require('os'); const path = require('path'); const repoRoot = path.resolve(__dirname, '../../..'); @@ -19,4 +21,300 @@ describe('e2e device setup default count', function () { expect(src).toMatch(/COUNT="\$\{1:-1\}"/); expect(src).toMatch(/for \(\(i = 0; i < COUNT; i\+\+\)\)/); }); + + it('rejects unsupported setup counts before invoking device runners', function () { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rnfb-device-count-')); + const actionLog = path.join(dir, 'actions.log'); + fs.writeFileSync( + path.join(dir, 'xcrun'), + `#!/bin/bash +printf 'xcrun %s\\n' "$*" >> "$RNFB_ACTION_LOG" +exit 90 +`, + ); + fs.chmodSync(path.join(dir, 'xcrun'), 0o755); + const androidHome = path.join(dir, 'sdk'); + fs.mkdirSync(path.join(androidHome, 'emulator'), { recursive: true }); + fs.writeFileSync( + path.join(androidHome, 'emulator', 'emulator'), + `#!/bin/bash +printf 'emulator %s\\n' "$*" >> "$RNFB_ACTION_LOG" +exit 90 +`, + ); + fs.chmodSync(path.join(androidHome, 'emulator', 'emulator'), 0o755); + const env = { + ...process.env, + PATH: `${dir}:${process.env.PATH}`, + ANDROID_HOME: androidHome, + RNFB_ACTION_LOG: actionLog, + }; + const scripts = ['create-ios-simulators.sh', 'create-android-avds.sh']; + + try { + for (const script of scripts) { + for (const count of ['9', '99', '0', '-1', '1.5', 'foo', '08']) { + let result; + try { + result = { + status: 0, + stdout: execFileSync( + '/bin/bash', + [path.join(repoRoot, 'scripts/e2e', script), count], + { + encoding: 'utf8', + cwd: repoRoot, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ), + stderr: '', + }; + } catch (error) { + result = { + status: error.status, + stdout: error.stdout || '', + stderr: error.stderr || '', + }; + } + expect(result.status).toBe(2); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain(`count must be an integer 1..8 (got ${count})`); + } + } + expect(fs.existsSync(actionLog)).toBe(false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('reuses exact neutral bases and only creates missing neutral bases', function () { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rnfb-ios-sim-setup-')); + const xcrun = path.join(dir, 'xcrun'); + const log = path.join(dir, 'xcrun.log'); + fs.writeFileSync( + xcrun, + `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$RNFB_XCRUN_LOG" +case "$*" in + "simctl list runtimes available -j") + printf '%s\\n' '{"runtimes":[{"isAvailable":true,"platform":"iOS","version":"26.0","identifier":"runtime-ios-26"}]}' + ;; + "simctl list devices available -j") + printf '%s\\n' '{"devices":{"runtime-ios-26":[ + {"name":"RN E2E iOS slot-0","isAvailable":true}, + {"name":"RNFB E2E iOS slot-0","isAvailable":true} + ]}}' + ;; + "simctl create RN E2E iOS slot-1 iPhone 17 runtime-ios-26") + printf '%s\\n' created-slot-1 + ;; + *) + exit 91 + ;; +esac +`, + ); + fs.chmodSync(xcrun, 0o755); + + try { + const output = execFileSync( + 'bash', + [path.join(repoRoot, 'scripts/e2e/create-ios-simulators.sh'), '2'], + { + encoding: 'utf8', + cwd: repoRoot, + env: { + ...process.env, + PATH: `${dir}:${process.env.PATH}`, + RNFB_XCRUN_LOG: log, + }, + }, + ); + const calls = fs.readFileSync(log, 'utf8'); + expect(output).toMatch(/RN E2E iOS slot-0 exists/); + expect(calls).toMatch(/^simctl create RN E2E iOS slot-1 iPhone 17 runtime-ios-26$/m); + expect(calls).not.toMatch(/simctl create RN E2E iOS slot-0/); + expect(calls).not.toMatch(/-Detox/); + expect(calls).not.toMatch(/\b(delete|erase|rename|shutdown)\b/); + expect(calls).not.toMatch(/simctl create RNFB E2E iOS/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('release shuts down only the selected exact neutral base', function () { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rnfb-ios-sim-release-')); + const log = path.join(dir, 'xcrun.log'); + fs.writeFileSync( + path.join(dir, 'xcrun'), + `#!/bin/bash +printf '%s\\n' "$*" >> "$RNFB_XCRUN_LOG" +`, + ); + fs.writeFileSync( + path.join(dir, 'bash'), + `#!/bin/bash +if [[ "$1" == *"/check-e2e-resources.sh" ]]; then + exit 0 +fi +exec /bin/bash "$@" +`, + ); + fs.writeFileSync(path.join(dir, 'sleep'), '#!/bin/bash\nexit 0\n'); + fs.writeFileSync(path.join(dir, 'lsof'), '#!/bin/bash\nexit 1\n'); + for (const command of ['xcrun', 'bash', 'sleep', 'lsof']) { + fs.chmodSync(path.join(dir, command), 0o755); + } + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if ( + key.startsWith('RNFB_') || + key.startsWith('E2E_') || + key.startsWith('JET_') || + key === 'RCT_METRO_PORT' + ) { + delete env[key]; + } + } + env.PATH = `${dir}:${env.PATH}`; + env.RNFB_XCRUN_LOG = log; + + try { + execFileSync( + '/bin/bash', + [ + path.join(repoRoot, 'scripts/e2e/release-e2e-resources.sh'), + '--platform=ios', + '--slot=1', + '--only=ios-sims', + ], + { encoding: 'utf8', cwd: repoRoot, env }, + ); + expect(fs.readFileSync(log, 'utf8').trim().split('\n')).toEqual([ + 'simctl shutdown RN E2E iOS slot-1', + ]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('caps all-slot release device actions at slot 7 when configured max is 99', function () { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rnfb-all-slot-release-')); + const log = path.join(dir, 'actions.log'); + fs.writeFileSync( + path.join(dir, 'adb'), + `#!/bin/bash +printf 'adb %s\\n' "$*" >> "$RNFB_ACTION_LOG" +if [[ "$1" == "devices" ]]; then + printf '%s\\n' 'List of devices attached' +fi +exit 0 +`, + ); + fs.writeFileSync( + path.join(dir, 'pkill'), + `#!/bin/bash +printf 'pkill %s\\n' "$*" >> "$RNFB_ACTION_LOG" +exit 0 +`, + ); + fs.writeFileSync( + path.join(dir, 'bash'), + `#!/bin/bash +if [[ "$1" == *"/check-e2e-resources.sh" ]]; then + exit 0 +fi +exec /bin/bash "$@" +`, + ); + fs.writeFileSync(path.join(dir, 'sleep'), '#!/bin/bash\nexit 0\n'); + fs.writeFileSync(path.join(dir, 'lsof'), '#!/bin/bash\nexit 1\n'); + for (const command of ['adb', 'pkill', 'bash', 'sleep', 'lsof']) { + fs.chmodSync(path.join(dir, command), 0o755); + } + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if ( + key.startsWith('RNFB_') || + key.startsWith('E2E_') || + key.startsWith('JET_') || + key === 'RCT_METRO_PORT' + ) { + delete env[key]; + } + } + env.E2E_SLOTTED_MAX = '99'; + env.PATH = `${dir}:${env.PATH}`; + env.RNFB_ACTION_LOG = log; + + try { + execFileSync( + '/bin/bash', + [ + path.join(repoRoot, 'scripts/e2e/release-e2e-resources.sh'), + '--platform=android', + '--all-slots', + '--devices', + '--only=android-emulator', + ], + { encoding: 'utf8', cwd: repoRoot, env }, + ); + const calls = fs.readFileSync(log, 'utf8'); + expect(calls).toMatch(/@TestingAVD-7/); + expect(calls).not.toMatch(/@TestingAVD-8/); + expect(calls).toMatch(/adb -s emulator-5570 emu kill/); + expect(calls).not.toMatch(/adb -s emulator-5572 emu kill/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('check reports a selected slot busy when its exact neutral base is booted', function () { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rnfb-ios-sim-check-')); + fs.writeFileSync( + path.join(dir, 'xcrun'), + `#!/bin/bash +if [[ "$*" == "simctl list devices booted -j" ]]; then + printf '%s\\n' '{"devices":{"runtime":[{"name":"RN E2E iOS slot-1","state":"Booted"}]}}' + exit 0 +fi +exit 92 +`, + ); + fs.writeFileSync(path.join(dir, 'lsof'), '#!/bin/bash\nexit 1\n'); + fs.chmodSync(path.join(dir, 'xcrun'), 0o755); + fs.chmodSync(path.join(dir, 'lsof'), 0o755); + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if ( + key.startsWith('RNFB_') || + key.startsWith('E2E_') || + key.startsWith('JET_') || + key === 'RCT_METRO_PORT' + ) { + delete env[key]; + } + } + env.PATH = `${dir}:${env.PATH}`; + + try { + let status = 0; + let output = ''; + try { + output = execFileSync( + '/bin/bash', + [path.join(repoRoot, 'scripts/e2e/check-e2e-resources.sh'), '--platform=ios', '--slot=1'], + { encoding: 'utf8', cwd: repoRoot, env, stdio: ['pipe', 'pipe', 'pipe'] }, + ); + } catch (error) { + status = error.status; + output = `${error.stdout || ''}${error.stderr || ''}`; + } + expect(status).toBe(1); + expect(output).toMatch(/BUSY\s+ios simulator booted \(RN E2E iOS slot-1/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/app/__tests__/detoxAppleBuildSettings.test.js b/packages/app/__tests__/detoxAppleBuildSettings.test.js index b3ab854049..395c107910 100644 --- a/packages/app/__tests__/detoxAppleBuildSettings.test.js +++ b/packages/app/__tests__/detoxAppleBuildSettings.test.js @@ -18,7 +18,7 @@ import { readFileSync } from 'fs'; import { join } from 'path'; -import { describe, expect, it } from '@jest/globals'; +import { describe, expect, it, jest } from '@jest/globals'; const REPO_ROOT = join(__dirname, '..', '..', '..'); const EXPLICIT_MODULES_OFF = 'SWIFT_ENABLE_EXPLICIT_MODULES=NO'; @@ -53,3 +53,139 @@ describe('Apple Detox/test build settings — RCT_NO_LAUNCH_PACKAGER', () => { expect(pkg.scripts['build:macos']).not.toContain(NO_LAUNCH_PACKAGER); }); }); + +describe('slotted Detox device contracts', () => { + const detox = require(join(REPO_ROOT, 'tests', '.detoxrc.js')); + + it('configures exact neutral iOS bases for slots 0 through 7', () => { + expect(detox.devices['simulator-slot0'].device.name).toBe('RN E2E iOS slot-0'); + expect(detox.devices['simulator-slot7'].device.name).toBe('RN E2E iOS slot-7'); + expect(detox.devices['simulator-slot8']).toBeUndefined(); + expect(detox.devices['simulator-slot0'].device.name).not.toContain('-Detox'); + expect(detox.configurations['ios.sim.debug.slot7']).toEqual({ + device: 'simulator-slot7', + app: 'ios.debug', + }); + expect(detox.configurations['ios.sim.debug.slot8']).toBeUndefined(); + }); + + it('keeps serial iOS and Android formulas unchanged while extending Android through slot 7', () => { + expect(detox.devices.simulator.device.name).toBe('iPhone 17'); + expect(detox.devices.emulator.device.avdName).toBe('TestingAVD'); + expect(detox.devices['emulator-slot0'].device.avdName).toBe('TestingAVD-0'); + expect(detox.devices['emulator-slot7'].device.avdName).toBe('TestingAVD-7'); + expect(detox.devices['emulator-slot8']).toBeUndefined(); + expect(detox.apps['android.debug.slot7']).toBeDefined(); + expect(detox.apps['android.debug.slot8']).toBeUndefined(); + expect(detox.configurations['android.emu.debug.slot7']).toEqual({ + device: 'emulator-slot7', + app: 'android.debug.slot7', + }); + expect(detox.configurations['android.emu.debug.slot8']).toBeUndefined(); + }); +}); + +describe('neutral iOS slot Detox allocation', () => { + const SimulatorAllocDriver = require( + join( + REPO_ROOT, + 'tests', + 'node_modules', + 'detox', + 'src', + 'devices', + 'allocation', + 'drivers', + 'ios', + 'SimulatorAllocDriver.js', + ), + ); + const SimulatorQuery = require( + join( + REPO_ROOT, + 'tests', + 'node_modules', + 'detox', + 'src', + 'devices', + 'allocation', + 'drivers', + 'ios', + 'SimulatorQuery.js', + ), + ); + + function makeAllocator(takenDevices = []) { + const prototypeDevice = { + udid: 'base-udid', + name: 'RN E2E iOS slot-3', + deviceType: { identifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-17' }, + os: { identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0' }, + }; + const applesimutils = { + create: jest.fn(async () => 'created-clone-udid'), + list: jest.fn(async query => { + if (query.byName === 'iPhone 17') { + return [{ ...prototypeDevice, name: 'iPhone 17', udid: 'serial-udid' }]; + } + return [prototypeDevice]; + }), + takeScreenshot: jest.fn(async () => undefined), + }; + const driver = new SimulatorAllocDriver({ + detoxConfig: { behavior: { cleanup: { shutdownDevice: false } } }, + deviceRegistry: { getTakenDevicesSync: () => takenDevices }, + applesimutils, + }); + return { applesimutils, driver, prototypeDevice }; + } + + it('reuses the exact neutral base when it is free', async () => { + const { applesimutils, driver } = makeAllocator(); + const udid = await driver._findOrCreateDevice( + new SimulatorQuery({ name: 'RN E2E iOS slot-3', type: 'iPhone 17' }), + ); + expect(udid).toBe('base-udid'); + expect(applesimutils.create).not.toHaveBeenCalled(); + }); + + it('fails with ownership guidance instead of cloning an occupied neutral base', async () => { + const { applesimutils, driver } = makeAllocator(['base-udid']); + await expect( + driver._findOrCreateDevice( + new SimulatorQuery({ name: 'RN E2E iOS slot-3', type: 'iPhone 17' }), + ), + ).rejects.toThrow( + /The neutral iOS slot RN E2E iOS slot-3 is already allocated[\s\S]*Confirm Mellifera\/manual ownership[\s\S]*device\.registry\.json/, + ); + expect(applesimutils.create).not.toHaveBeenCalled(); + }); + + it('preserves Detox base allocation for serial iPhone 17', async () => { + const { applesimutils, driver } = makeAllocator(); + const udid = await driver._findOrCreateDevice( + new SimulatorQuery({ name: 'iPhone 17', type: 'iPhone 17' }), + ); + expect(udid).toBe('serial-udid'); + expect(applesimutils.create).not.toHaveBeenCalled(); + }); + + it('guards exact neutral slots through 7 without changing unrelated stock cloning', async () => { + const { applesimutils, driver, prototypeDevice } = makeAllocator(); + jest + .spyOn(driver, '_groupDevicesByStatus') + .mockResolvedValue({ free: [], taken: [prototypeDevice] }); + + await expect( + driver._findOrCreateDevice(new SimulatorQuery({ name: 'RN E2E iOS slot-7' })), + ).rejects.toThrow('already allocated'); + await expect( + driver._findOrCreateDevice(new SimulatorQuery({ name: 'RN E2E iOS slot-8' })), + ).resolves.toBe('created-clone-udid'); + await expect( + driver._findOrCreateDevice(new SimulatorQuery({ name: 'custom simulator' })), + ).resolves.toBe('created-clone-udid'); + expect(applesimutils.create).toHaveBeenCalledTimes(2); + expect(applesimutils.create).toHaveBeenCalledWith(prototypeDevice); + }); +}); diff --git a/packages/app/__tests__/e2eResourceEnv.test.js b/packages/app/__tests__/e2eResourceEnv.test.js index b33a70717f..380f1b6f4f 100644 --- a/packages/app/__tests__/e2eResourceEnv.test.js +++ b/packages/app/__tests__/e2eResourceEnv.test.js @@ -111,7 +111,7 @@ e2e_slot_env_apply foo 0 }); describe('E2E_SLOTTED_MAX default', function () { - it('defaults to 4 aligned with Detox slots 0-4', function () { + it('defaults to 7 aligned with Detox slots 0-7', function () { const slotLib = path.join(repoRoot, 'scripts/e2e/lib/e2e-slot-env.sh'); const env = { ...process.env }; delete env.E2E_SLOTTED_MAX; @@ -127,7 +127,7 @@ echo "$E2E_SLOTTED_MAX" ], { encoding: 'utf8', cwd: repoRoot, env }, ); - expect(out.trim()).toBe('4'); + expect(out.trim()).toBe('7'); }); }); @@ -162,6 +162,68 @@ e2e_print_collected_ports expect(out).toMatch(/^4400 emulator-hub:global$/m); }); + it('sweeps slot 7 and excludes slot 8 at the shared default ceiling', function () { + const env = cleanEnv(); + env.E2E_ALL_SLOTS = '1'; + env.E2E_SLOTTED_MAX = '7'; + const out = execFileSync( + 'bash', + [ + '-c', + `set -euo pipefail +source ${JSON.stringify(lib)} +e2e_collect_targets +e2e_print_collected_ports +`, + ], + { encoding: 'utf8', cwd: repoRoot, env }, + ); + expect(out).toMatch(/^19005 emulator-hub:android-slot7$/m); + expect(out).toMatch(/^19107 metro:ios-slot7$/m); + expect(out).not.toMatch(/android-slot8|ios-slot8|macos-slot8/); + }); + + it('caps a configured max of 99 at slot 7 for every all-slot port sweep', function () { + const env = cleanEnv(); + env.E2E_ALL_SLOTS = '1'; + env.E2E_SLOTTED_MAX = '99'; + const out = execFileSync( + 'bash', + [ + '-c', + `set -euo pipefail +source ${JSON.stringify(lib)} +e2e_collect_targets +e2e_print_collected_ports +`, + ], + { encoding: 'utf8', cwd: repoRoot, env }, + ); + expect(out).toMatch(/^19005 emulator-hub:android-slot7$/m); + expect(out).toMatch(/^19107 metro:ios-slot7$/m); + expect(out).not.toMatch(/android-slot8|ios-slot8|macos-slot8/); + }); + + it('honors a lower max for every all-slot port sweep', function () { + const env = cleanEnv(); + env.E2E_ALL_SLOTS = '1'; + env.E2E_SLOTTED_MAX = '2'; + const out = execFileSync( + 'bash', + [ + '-c', + `set -euo pipefail +source ${JSON.stringify(lib)} +e2e_collect_targets +e2e_print_collected_ports +`, + ], + { encoding: 'utf8', cwd: repoRoot, env }, + ); + expect(out).toMatch(/android-slot2|ios-slot2|macos-slot2/); + expect(out).not.toMatch(/android-slot3|ios-slot3|macos-slot3/); + }); + it('does not include leftover slotted hubs when slot env is loaded', function () { const env = cleanEnv(); env.RNFB_E2E_SLOT = '1'; @@ -206,12 +268,13 @@ describe('unscoped device names', function () { expect(avds).not.toMatch(/^TestingAVD-0$/m); const sims = bashSnippet('e2e_ios_simulator_names_for_release'); expect(sims).toMatch(/^iPhone 17$/m); - expect(sims).not.toMatch(/^RNFB E2E iOS slot-0$/m); + expect(sims).not.toMatch(/^RN E2E iOS slot-0$/m); }); - it('lists TestingAVD plus TestingAVD-0..MAX and iOS slot sims with --all-slots', function () { + it('lists serial plus every exact neutral base through MAX with --all-slots', function () { const env = cleanEnv(); env.E2E_ALL_SLOTS = '1'; + env.E2E_SLOTTED_MAX = '7'; const avds = execFileSync( 'bash', [ @@ -225,7 +288,8 @@ e2e_android_avd_names_for_release ); expect(avds).toMatch(/^TestingAVD$/m); expect(avds).toMatch(/^TestingAVD-0$/m); - expect(avds).toMatch(/^TestingAVD-2$/m); + expect(avds).toMatch(/^TestingAVD-7$/m); + expect(avds).not.toMatch(/^TestingAVD-8$/m); const sims = execFileSync( 'bash', [ @@ -238,8 +302,79 @@ e2e_ios_simulator_names_for_release { encoding: 'utf8', cwd: repoRoot, env }, ); expect(sims).toMatch(/^iPhone 17$/m); - expect(sims).toMatch(/^RNFB E2E iOS slot-0$/m); - expect(sims).toMatch(/^RNFB E2E iOS slot-2$/m); + expect(sims).toMatch(/^RN E2E iOS slot-0$/m); + expect(sims).toMatch(/^RN E2E iOS slot-7$/m); + expect(sims).not.toMatch(/^RN E2E iOS slot-8$/m); + expect(sims).not.toMatch(/-Detox$/m); + expect(sims).not.toMatch(/^RNFB E2E iOS slot-/m); + }); + + it('caps configured 99 and honors lower max across Android, iOS, and macOS names', function () { + const renderNames = max => { + const env = cleanEnv(); + env.E2E_ALL_SLOTS = '1'; + env.E2E_SLOTTED_MAX = max; + return execFileSync( + 'bash', + [ + '-c', + `set -euo pipefail +source ${JSON.stringify(lib)} +e2e_android_avd_names_for_release +e2e_ios_simulator_names_for_release +e2e_macos_process_names_for_probe +`, + ], + { encoding: 'utf8', cwd: repoRoot, env }, + ); + }; + + const capped = renderNames('99'); + expect(capped).toMatch(/^TestingAVD-7$/m); + expect(capped).toMatch(/^RN E2E iOS slot-7$/m); + expect(capped).toMatch(/^io\.invertase\.testing\.s7$/m); + expect(capped).not.toMatch(/TestingAVD-8|slot-8|\.s8$/m); + + const lowered = renderNames('2'); + expect(lowered).toMatch(/^TestingAVD-2$/m); + expect(lowered).toMatch(/^RN E2E iOS slot-2$/m); + expect(lowered).toMatch(/^io\.invertase\.testing\.s2$/m); + expect(lowered).not.toMatch(/TestingAVD-3|slot-3|\.s3$/m); + }); + + it('targets only the selected exact neutral base', function () { + const env = cleanEnv(); + env.E2E_SLOT_OVERRIDE = '1'; + const sims = execFileSync( + 'bash', + [ + '-c', + `set -euo pipefail +source ${JSON.stringify(lib)} +e2e_ios_simulator_names_for_release +`, + ], + { encoding: 'utf8', cwd: repoRoot, env }, + ); + expect(sims.trim().split('\n')).toEqual(['RN E2E iOS slot-1']); + }); +}); + +describe('exact iOS simulator boot checks', function () { + it('distinguishes a neutral base from suffixed and substring-collision names', function () { + const out = bashSnippet(` +xcrun() { + cat <<'JSON' +{"devices":{"runtime":[ + {"name":"RN E2E iOS slot-1-other","state":"Booted"}, + {"name":"RN E2E iOS slot-10","state":"Booted"} +]}} +JSON +} +e2e_ios_sim_booted 'RN E2E iOS slot-1' && echo BASE_BUSY || echo BASE_CLEAR +`); + expect(out).toMatch(/^BASE_CLEAR$/m); + expect(out).not.toMatch(/^BASE_BUSY$/m); }); }); @@ -315,6 +450,74 @@ e2e_print_collected_ports expect(out).not.toMatch(/metro:ios-slot2/); expect(out).not.toMatch(/^4400 emulator-hub:global$/m); }); + + it('canonical check and release reject invalid selectors before output or actions', function () { + const fs = require('fs'); + const os = require('os'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rnfb-slot-selector-')); + const actionLog = path.join(dir, 'actions.log'); + for (const command of ['xcrun', 'lsof', 'adb', 'pgrep', 'pkill', 'killall', 'sleep']) { + fs.writeFileSync( + path.join(dir, command), + `#!/bin/bash +printf '%s %s\\n' ${JSON.stringify(command)} "$*" >> "$RNFB_ACTION_LOG" +exit 0 +`, + ); + fs.chmodSync(path.join(dir, command), 0o755); + } + const env = cleanEnv(); + env.E2E_SLOTTED_MAX = '99'; + env.PATH = `${dir}:${env.PATH || '/usr/bin:/bin'}`; + env.RNFB_ACTION_LOG = actionLog; + const scripts = ['check-e2e-resources.sh', 'release-e2e-resources.sh']; + + try { + for (const script of scripts) { + for (const { max, slot, expectedMax } of [ + { max: '99', slot: '8', expectedMax: '7' }, + { max: '99', slot: '99', expectedMax: '7' }, + { max: '99', slot: '-1', expectedMax: '7' }, + { max: '99', slot: '1.5', expectedMax: '7' }, + { max: '99', slot: 'foo', expectedMax: '7' }, + { max: '99', slot: '07', expectedMax: '7' }, + { max: '99', slot: '', expectedMax: '7' }, + { max: '2', slot: '3', expectedMax: '2' }, + ]) { + env.E2E_SLOTTED_MAX = max; + let result; + try { + result = { + status: 0, + stdout: execFileSync( + '/bin/bash', + [path.join(repoRoot, 'scripts/e2e', script), `--slot=${slot}`], + { + encoding: 'utf8', + cwd: repoRoot, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ), + stderr: '', + }; + } catch (error) { + result = { + status: error.status, + stdout: error.stdout || '', + stderr: error.stderr || '', + }; + } + expect(result.status).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain(`slot must be an integer 0..${expectedMax}`); + } + } + expect(fs.existsSync(actionLog)).toBe(false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); }); describe('adb stray emulator serials (UNI-adb-leftover)', function () { diff --git a/packages/app/__tests__/e2eSlotEnv.test.js b/packages/app/__tests__/e2eSlotEnv.test.js index df62aaa486..1aecc356d3 100644 --- a/packages/app/__tests__/e2eSlotEnv.test.js +++ b/packages/app/__tests__/e2eSlotEnv.test.js @@ -28,17 +28,114 @@ describe('export-slot-env android console pin', function () { }); describe('export-slot-env iOS simulator pin', function () { - it('exports RNFB_IOS_SIMULATOR for slotted iOS including slot 0', function () { + it('exports the exact neutral base for slotted iOS including slots 0 and 7', function () { const out0 = execFileSync('bash', [exportSlotEnv, 'ios', '0'], { encoding: 'utf8', cwd: repoRoot, }); - expect(out0).toMatch(/export RNFB_IOS_SIMULATOR=.*slot-0/); + expect(out0).toMatch(/^export RNFB_IOS_SIMULATOR=RN\\ E2E\\ iOS\\ slot-0$/m); + expect(out0).not.toMatch(/slot-0-Detox/); expect(out0).not.toMatch(/iPhone 17/); - const out1 = execFileSync('bash', [exportSlotEnv, 'ios', '1'], { + const out7 = execFileSync('bash', [exportSlotEnv, 'ios', '7'], { encoding: 'utf8', cwd: repoRoot, }); - expect(out1).toMatch(/export RNFB_IOS_SIMULATOR=.*slot-1/); + expect(out7).toMatch(/^export RNFB_IOS_SIMULATOR=RN\\ E2E\\ iOS\\ slot-7$/m); + }); +}); + +function exportSlotFail(platform, slot, extraEnv = {}) { + const env = { ...process.env, ...extraEnv }; + delete env.E2E_SLOTTED_MAX; + delete env.E2E_MACOS_SLOTTED_MAX; + Object.assign(env, extraEnv); + try { + const stdout = execFileSync('bash', [exportSlotEnv, platform, String(slot)], { + encoding: 'utf8', + cwd: repoRoot, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { status: 0, stdout, stderr: '' }; + } catch (error) { + return { + status: error.status, + stdout: error.stdout || '', + stderr: error.stderr || '', + }; + } +} + +describe('export-slot-env slot bounds', function () { + it('accepts slot 7 with exact Detox configs, ports, and device names', function () { + const env = { ...process.env }; + delete env.E2E_SLOTTED_MAX; + delete env.E2E_MACOS_SLOTTED_MAX; + const out = execFileSync('bash', [exportSlotEnv, 'android', '7'], { + encoding: 'utf8', + cwd: repoRoot, + env, + }); + expect(out).toMatch(/^export RNFB_E2E_SLOT=7$/m); + expect(out).toMatch(/^export RNFB_ANDROID_AVD=TestingAVD-7$/m); + expect(out).toMatch(/^export RNFB_DETOX_ANDROID_CONFIG=android.emu.debug.slot7$/m); + expect(out).toMatch(/^export RNFB_DETOX_IOS_CONFIG=ios.sim.debug.slot7$/m); + expect(out).toMatch(/^export RNFB_IOS_SIMULATOR=RN\\ E2E\\ iOS\\ slot-7$/m); + expect(out).toMatch(/^export RNFB_ANDROID_CONSOLE_PORT=5570$/m); + expect(out).toMatch(/^export ANDROID_SERIAL=emulator-5570$/m); + expect(out).toMatch(/^export RNFB_ANDROID_METRO_PORT=19007$/m); + expect(out).toMatch(/^export RNFB_IOS_METRO_PORT=19107$/m); + expect(out).toMatch(/^export RNFB_MACOS_METRO_PORT=19207$/m); + expect(out).not.toMatch(/slot8/); + expect(out).not.toMatch(/-Detox/); + }); + + it('rejects slot 8, negatives, and non-integers with empty stdout', function () { + for (const slot of ['8', '-1', '1.5', 'foo', '07']) { + const result = exportSlotFail('ios', slot); + expect(result.status).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stdout).not.toMatch(/^export /m); + expect(result.stderr).toMatch(/slot must be an integer 0\.\.7 \(got /); + } + }); + + it('honors a lowered E2E_SLOTTED_MAX but still rejects a raised ceiling past 7', function () { + const loweredOk = execFileSync('bash', [exportSlotEnv, 'android', '2'], { + encoding: 'utf8', + cwd: repoRoot, + env: { ...process.env, E2E_SLOTTED_MAX: '2' }, + }); + expect(loweredOk).toMatch(/^export RNFB_E2E_SLOT=2$/m); + + const loweredReject = exportSlotFail('android', '3', { E2E_SLOTTED_MAX: '2' }); + expect(loweredReject.status).toBe(1); + expect(loweredReject.stdout).toBe(''); + expect(loweredReject.stderr).toMatch(/slot must be an integer 0\.\.2 \(got 3\)/); + + const raisedReject = exportSlotFail('android', '8', { E2E_SLOTTED_MAX: '99' }); + expect(raisedReject.status).toBe(1); + expect(raisedReject.stdout).toBe(''); + expect(raisedReject.stderr).toMatch(/slot must be an integer 0\.\.7 \(got 8\)/); + }); +}); + +describe('setup slot count bounds', function () { + it('accepts counts 1 and 8 at the hard provisioning boundary', function () { + const slotLib = path.join(repoRoot, 'scripts/e2e/lib/e2e-slot-env.sh'); + const out = execFileSync( + 'bash', + [ + '-c', + `set -euo pipefail +source ${JSON.stringify(slotLib)} +e2e_validate_slot_count 1 +e2e_validate_slot_count 8 +echo VALID +`, + ], + { encoding: 'utf8', cwd: repoRoot }, + ); + expect(out.trim()).toBe('VALID'); }); }); diff --git a/packages/app/__tests__/jestInlineEnvCache.test.js b/packages/app/__tests__/jestInlineEnvCache.test.js index c8aa620f32..c8eb709130 100644 --- a/packages/app/__tests__/jestInlineEnvCache.test.js +++ b/packages/app/__tests__/jestInlineEnvCache.test.js @@ -83,7 +83,7 @@ describe('e2e_sanitize_serial_env leftover slot Metro', function () { JET_REMOTE_PORT: '12010', RNFB_ANDROID_EMULATOR_FIRESTORE_PORT: '12000', RNFB_ANDROID_AVD: 'TestingAVD-0', - RNFB_IOS_SIMULATOR: 'RNFB E2E iOS slot-0', + RNFB_IOS_SIMULATOR: 'RN E2E iOS slot-0', RNFB_MACOS_PRODUCT_NAME: 'io.invertase.testing.s0', }, ); diff --git a/packages/app/__tests__/resolveIosSimulator.test.js b/packages/app/__tests__/resolveIosSimulator.test.js index bfda711083..c0ec8dedde 100644 --- a/packages/app/__tests__/resolveIosSimulator.test.js +++ b/packages/app/__tests__/resolveIosSimulator.test.js @@ -28,8 +28,8 @@ describe('resolve_ios_simulator_name', function () { ].join('\n'); it('prefers RNFB_IOS_SIMULATOR when set', function () { - expect(resolveName({ RNFB_IOS_SIMULATOR: 'RNFB E2E iOS slot-0' }, fixture)).toBe( - 'RNFB E2E iOS slot-0', + expect(resolveName({ RNFB_IOS_SIMULATOR: 'RN E2E iOS slot-0' }, fixture)).toBe( + 'RN E2E iOS slot-0', ); }); diff --git a/scripts/e2e/check-e2e-resources.sh b/scripts/e2e/check-e2e-resources.sh index ab08f7b5d0..6cf9ba3662 100755 --- a/scripts/e2e/check-e2e-resources.sh +++ b/scripts/e2e/check-e2e-resources.sh @@ -32,6 +32,7 @@ JSON=0 SERVICES=0 E2E_ALL_SLOTS="${E2E_ALL_SLOTS:-0}" E2E_SLOT_OVERRIDE="${E2E_SLOT_OVERRIDE:-}" +E2E_SLOT_SELECTOR_PRESENT=0 for arg in "$@"; do case "$arg" in --json) @@ -45,6 +46,7 @@ for arg in "$@"; do ;; --slot=*) E2E_SLOT_OVERRIDE="${arg#--slot=}" + E2E_SLOT_SELECTOR_PRESENT=1 ;; --platform=*) E2E_PLATFORM_OVERRIDE="${arg#--platform=}" @@ -62,6 +64,9 @@ done export E2E_ALL_SLOTS E2E_SLOT_OVERRIDE [[ -n "${E2E_PLATFORM_OVERRIDE:-}" ]] && export E2E_PLATFORM_OVERRIDE +if [[ -n "${E2E_SLOT_OVERRIDE:-}" || "$E2E_SLOT_SELECTOR_PRESENT" -eq 1 ]]; then + e2e_validate_slot "$E2E_SLOT_OVERRIDE" +fi [[ -n "${E2E_PLATFORM_OVERRIDE:-}" ]] && e2e_validate_platform_name "$E2E_PLATFORM_OVERRIDE" [[ -n "${RNFB_E2E_PLATFORM:-}" ]] && e2e_validate_platform_name "$RNFB_E2E_PLATFORM" @@ -176,14 +181,22 @@ fi if platform_active ios; then if command -v xcrun >/dev/null 2>&1; then - if e2e_ios_sim_booted "$E2E_IOS_SIMULATOR"; then + busy_ios_sim="" + while IFS= read -r ios_sim_name; do + [[ -z "$ios_sim_name" ]] && continue + if e2e_ios_sim_booted "$ios_sim_name"; then + busy_ios_sim="$ios_sim_name" + break + fi + done < <(e2e_ios_simulator_names_for_release) + if [[ -n "$busy_ios_sim" ]]; then if platform_explicit ios; then - report BUSY "ios simulator booted (${E2E_IOS_SIMULATOR} or any booted for default)" + report BUSY "ios simulator booted (${busy_ios_sim} 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" + report INFO "ios simulator booted (${busy_ios_sim} or another) — global mode does not fail on this; pass --platform=ios to enforce" fi else report CLEAR "ios simulator (${E2E_IOS_SIMULATOR})" diff --git a/scripts/e2e/create-android-avds.sh b/scripts/e2e/create-android-avds.sh index 352ba36727..2278c01e86 100755 --- a/scripts/e2e/create-android-avds.sh +++ b/scripts/e2e/create-android-avds.sh @@ -4,7 +4,13 @@ # Serial unslotted runs keep using TestingAVD; slotted slot 0 uses TestingAVD-0. 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" + COUNT="${1:-1}" +e2e_validate_slot_count "$COUNT" + 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}" @@ -45,11 +51,6 @@ clone_avd() { " "$dst_ini" "${dst_avd}/config.ini" "$src" "$dst" } -if [[ "$COUNT" -lt 1 ]]; then - echo "error: count must be >= 1 (got ${COUNT})" >&2 - exit 2 -fi - for ((i = 0; i < COUNT; i++)); do clone_avd "$BASE_AVD" "${BASE_AVD}-${i}" done diff --git a/scripts/e2e/create-ios-simulators.sh b/scripts/e2e/create-ios-simulators.sh index 78738b5f08..c764639cb3 100755 --- a/scripts/e2e/create-ios-simulators.sh +++ b/scripts/e2e/create-ios-simulators.sh @@ -1,10 +1,17 @@ #!/usr/bin/env bash # Create dedicated iOS simulators for e2e slots 0..(count-1). # Default count=1 (CI / typical developer). Pass 8 on a host that can sustain it. -# Serial unslotted runs keep using iPhone 17; slotted slot 0 uses RNFB E2E iOS slot-0. +# Serial unslotted runs keep using iPhone 17; slotted slot 0 uses RN E2E iOS slot-0. +# Setup is create-only: it never erases, renames, or deletes existing simulators. 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" + COUNT="${1:-1}" +e2e_validate_slot_count "$COUNT" + 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')); @@ -23,14 +30,16 @@ RUNTIME=$(xcrun simctl list runtimes available -j | node -e " console.log(ios[0].identifier); ") -if [[ "$COUNT" -lt 1 ]]; then - echo "error: count must be >= 1 (got ${COUNT})" >&2 - exit 2 -fi +DEVICES_JSON=$(xcrun simctl list devices available -j) for ((i = 0; i < COUNT; i++)); do - name="RNFB E2E iOS slot-${i}" - if xcrun simctl list devices available | grep -q "${name}"; then + name="RN E2E iOS slot-${i}" + if printf '%s' "$DEVICES_JSON" | node -e " + const target = process.argv[1]; + const input = require('fs').readFileSync(0, 'utf8'); + const devices = Object.values(JSON.parse(input).devices || {}).flat(); + process.exit(devices.some(device => device.name === target && device.isAvailable !== false) ? 0 : 1); + " "$name"; then echo "[sim] ${name} exists" continue fi @@ -38,4 +47,4 @@ for ((i = 0; i < COUNT; i++)); do xcrun simctl create "$name" "$BASE_NAME" "$RUNTIME" done -echo "[sim] done — slotted slots 0..$((COUNT - 1)) use RNFB E2E iOS slot-N; serial unslotted keeps ${BASE_NAME}" +echo "[sim] done — slotted slots 0..$((COUNT - 1)) use RN 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 index cb90e9610e..8933ebe959 100755 --- a/scripts/e2e/export-slot-env.sh +++ b/scripts/e2e/export-slot-env.sh @@ -13,7 +13,7 @@ # yarn tests:e2e:release [--devices] # # 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). +# (TestingAVD-N / RN E2E iOS slot-N / RNFB_MACOS_PRODUCT_NAME=io.invertase.testing.s). # Pins RNFB_ANDROID_CONSOLE_PORT=$((5556+2*slot)) and ANDROID_SERIAL=emulator-${port} # (adb-safe; not FreePortFinder 10000–20000). Emits unset for other parent leftovers # (AVD_NAME, …) that apply clears. @@ -26,6 +26,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/lib/e2e-slot-env.sh" PLATFORM="${1:?platform required: android|ios|macos}" -SLOT="${2:?slot required: non-negative integer}" +SLOT="${2:?slot required: integer 0..E2E_SLOTTED_MAX}" e2e_slot_env_print "$PLATFORM" "$SLOT" diff --git a/scripts/e2e/lib/e2e-resource-env.sh b/scripts/e2e/lib/e2e-resource-env.sh index a6fbb04327..8ba87cacb4 100644 --- a/scripts/e2e/lib/e2e-resource-env.sh +++ b/scripts/e2e/lib/e2e-resource-env.sh @@ -42,7 +42,7 @@ 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. # Same slot ceiling as leftover Android/iOS devices and unscoped port collect. -E2E_MACOS_SLOTTED_MAX="${E2E_MACOS_SLOTTED_MAX:-$E2E_SLOTTED_MAX}" +E2E_MACOS_SLOTTED_MAX="$(e2e_effective_slot_max)" e2e_repo_root() { local here @@ -268,8 +268,9 @@ e2e_android_avd_names_for_release() { fi echo "$E2E_DEFAULT_ANDROID_AVD" if [[ "${E2E_ALL_SLOTS:-0}" == "1" ]]; then - local i - for ((i = 0; i <= E2E_SLOTTED_MAX; i++)); do + local i max + max="$(e2e_effective_slot_max)" + for ((i = 0; i <= max; i++)); do echo "TestingAVD-${i}" done fi @@ -283,14 +284,15 @@ e2e_ios_simulator_names_for_release() { local slot slot="$(e2e_effective_slot)" if [[ -n "$slot" ]]; then - echo "RNFB E2E iOS slot-${slot}" + echo "RN E2E iOS slot-${slot}" return 0 fi echo "$E2E_DEFAULT_IOS_SIMULATOR" if [[ "${E2E_ALL_SLOTS:-0}" == "1" ]]; then - local i - for ((i = 0; i <= E2E_SLOTTED_MAX; i++)); do - echo "RNFB E2E iOS slot-${i}" + local i max + max="$(e2e_effective_slot_max)" + for ((i = 0; i <= max; i++)); do + echo "RN E2E iOS slot-${i}" done fi } @@ -415,11 +417,13 @@ e2e_collect_targets() { # Leftover slots only with --all-slots. Do not nest this while-read inside the # platform loop (bash 3.2 inner `read` steals the outer stdin). if [[ "${E2E_ALL_SLOTS:-0}" == "1" ]]; then + local max + max="$(e2e_effective_slot_max)" while read -r lab p; do [[ -z "$lab" ]] && continue add_port "$p" "$lab" done < <( - for ((slot = 0; slot <= E2E_SLOTTED_MAX; slot++)); do + for ((slot = 0; slot <= max; slot++)); do for plat in android ios macos; do e2e_slot_block_port_lines "$plat" "$slot" done @@ -495,9 +499,10 @@ e2e_adb_emulator_serials() { # Anything else (Detox FreePortFinder 10000–20000 → emulator-16xxx) is stray. e2e_android_console_port_allocated() { local port=$1 - local i p + local i p max [[ "$port" == "${E2E_SERIAL_ANDROID_CONSOLE_PORT:-5554}" ]] && return 0 - for ((i = 0; i <= E2E_SLOTTED_MAX; i++)); do + max="$(e2e_effective_slot_max)" + for ((i = 0; i <= max; i++)); do p=$(e2e_slot_android_console_port "$i") [[ "$port" == "$p" ]] && return 0 done @@ -532,8 +537,9 @@ e2e_macos_process_names_for_probe() { fi echo "$E2E_DEFAULT_MACOS_APP_PROCESS" if [[ "${E2E_ALL_SLOTS:-0}" == "1" ]]; then - local i - for ((i = 0; i <= E2E_SLOTTED_MAX; i++)); do + local i max + max="$(e2e_effective_slot_max)" + for ((i = 0; i <= max; i++)); do echo "${E2E_DEFAULT_MACOS_APP_PROCESS}.s${i}" done fi @@ -563,8 +569,6 @@ e2e_macos_app_path() { 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 @@ -572,5 +576,11 @@ e2e_ios_sim_booted() { [[ "${count:-0}" -gt 0 ]] return $? fi - return 1 + # Slotted checks must be exact: slot-1 must not collide with slot-10. + xcrun simctl list devices booted -j 2>/dev/null | node -e " + const target = process.argv[1]; + const input = require('fs').readFileSync(0, 'utf8'); + const devices = Object.values(JSON.parse(input).devices || {}).flat(); + process.exit(devices.some(device => device.name === target && device.state === 'Booted') ? 0 : 1); + " "$name" } diff --git a/scripts/e2e/lib/e2e-slot-env.sh b/scripts/e2e/lib/e2e-slot-env.sh index a9d331ae25..a330ab446b 100755 --- a/scripts/e2e/lib/e2e-slot-env.sh +++ b/scripts/e2e/lib/e2e-slot-env.sh @@ -7,8 +7,11 @@ # # Leftover slots 0..E2E_SLOTTED_MAX are collected only with --all-slots (E2E_ALL_SLOTS=1). # Default check/release is serial ports/devices only. -# Aligned with Detox slot configs 0–4 (tests/.detoxrc.js). -E2E_SLOTTED_MAX="${E2E_SLOTTED_MAX:-${E2E_MACOS_SLOTTED_MAX:-4}}" +# Aligned with Detox slot configs 0–7 (tests/.detoxrc.js). +# Env may lower the leftover-sweep ceiling (tests). Raising above the Detox hard +# max is unsafe — env selection never accepts a slot past E2E_SLOTTED_HARD_MAX. +E2E_SLOTTED_HARD_MAX=7 +E2E_SLOTTED_MAX="${E2E_SLOTTED_MAX:-${E2E_MACOS_SLOTTED_MAX:-$E2E_SLOTTED_HARD_MAX}}" # # 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 — @@ -16,6 +19,50 @@ E2E_SLOTTED_MAX="${E2E_SLOTTED_MAX:-${E2E_MACOS_SLOTTED_MAX:-4}}" # # shellcheck shell=bash +# Effective slot ceiling for selection and every all-slot resource sweep: +# documented E2E_SLOTTED_MAX, never above Detox 0–7. +e2e_effective_slot_max() { + local max="${E2E_SLOTTED_MAX:-$E2E_SLOTTED_HARD_MAX}" + if ! [[ "$max" =~ ^[0-9]+$ ]]; then + echo "$E2E_SLOTTED_HARD_MAX" + return 0 + fi + if ((max > E2E_SLOTTED_HARD_MAX)); then + echo "$E2E_SLOTTED_HARD_MAX" + else + echo "$max" + fi +} + +# Fail-fast before any slot env is computed or printed. Decimal integer only +# (no sign, fraction, or leading zeros except 0). +e2e_validate_slot() { + local slot=$1 + local max + max=$(e2e_effective_slot_max) + if ! [[ "$slot" =~ ^[0-9]+$ ]] || [[ "$slot" =~ ^0[0-9] ]]; then + echo "error: slot must be an integer 0..${max} (got ${slot})" >&2 + return 1 + fi + if ((slot > max)); then + echo "error: slot must be an integer 0..${max} (got ${slot})" >&2 + return 1 + fi + return 0 +} + +# Provisioning count maps directly to slots 0..count-1 and is intentionally +# bounded by the hard Detox inventory, independent of a lowered sweep override. +e2e_validate_slot_count() { + local count=$1 + local max_count=$((E2E_SLOTTED_HARD_MAX + 1)) + if ! [[ "$count" =~ ^[1-9][0-9]*$ ]] || ((count > max_count)); then + echo "error: count must be an integer 1..${max_count} (got ${count})" >&2 + return 2 + fi + return 0 +} + e2e_slot_platform_offset() { case "$1" in android) echo 0 ;; @@ -121,6 +168,7 @@ e2e_slot_env_apply() { local slot=$2 local base off blk metro jet jc + e2e_validate_slot "$slot" || return 1 base=$(e2e_slot_base "$slot") off=$(e2e_slot_platform_offset "$platform") || return 1 blk=$((base + off)) @@ -142,7 +190,7 @@ e2e_slot_env_apply() { 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 / + # Slotted slot 0 uses TestingAVD-0 / RN 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. @@ -153,7 +201,7 @@ e2e_slot_env_apply() { export RNFB_ANDROID_CONSOLE_PORT="$(e2e_slot_android_console_port "$slot")" export ANDROID_SERIAL="emulator-${RNFB_ANDROID_CONSOLE_PORT}" export RNFB_DETOX_IOS_CONFIG="ios.sim.debug.slot${slot}" - export RNFB_IOS_SIMULATOR="RNFB E2E iOS slot-${slot}" + export RNFB_IOS_SIMULATOR="RN 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//./-}}" diff --git a/scripts/e2e/release-e2e-resources.sh b/scripts/e2e/release-e2e-resources.sh index 5b85ee073f..7d8de5a424 100755 --- a/scripts/e2e/release-e2e-resources.sh +++ b/scripts/e2e/release-e2e-resources.sh @@ -35,6 +35,7 @@ DEVICES=0 MAX_FORCE_ROUNDS=2 E2E_ALL_SLOTS="${E2E_ALL_SLOTS:-0}" E2E_SLOT_OVERRIDE="${E2E_SLOT_OVERRIDE:-}" +E2E_SLOT_SELECTOR_PRESENT=0 while [[ $# -gt 0 ]]; do case "$1" in @@ -60,6 +61,7 @@ while [[ $# -gt 0 ]]; do ;; --slot=*) E2E_SLOT_OVERRIDE="${1#--slot=}" + E2E_SLOT_SELECTOR_PRESENT=1 shift ;; -h|--help) @@ -75,6 +77,9 @@ done export E2E_ALL_SLOTS E2E_SLOT_OVERRIDE [[ -n "${E2E_PLATFORM_OVERRIDE:-}" ]] && export E2E_PLATFORM_OVERRIDE +if [[ -n "${E2E_SLOT_OVERRIDE:-}" || "$E2E_SLOT_SELECTOR_PRESENT" -eq 1 ]]; then + e2e_validate_slot "$E2E_SLOT_OVERRIDE" +fi [[ -n "${E2E_PLATFORM_OVERRIDE:-}" ]] && e2e_validate_platform_name "$E2E_PLATFORM_OVERRIDE" [[ -n "${RNFB_E2E_PLATFORM:-}" ]] && e2e_validate_platform_name "$RNFB_E2E_PLATFORM" @@ -289,8 +294,9 @@ clear_android_emulator() { if [[ "${E2E_ALL_SLOTS:-0}" == "1" ]] && [[ -z "${RNFB_ANDROID_AVD:-}${RNFB_ANDROID_AVD_NAME:-}" && -z "${RNFB_E2E_SLOT:-${RNFB_E2E_HOST_SLOT:-}${E2E_SLOT_OVERRIDE:-}}" ]] && command -v adb >/dev/null 2>&1; then - local i serial - for ((i = 0; i <= E2E_SLOTTED_MAX; i++)); do + local i serial max + max="$(e2e_effective_slot_max)" + for ((i = 0; i <= max; i++)); do serial="emulator-$(e2e_slot_android_console_port "$i")" adb -s "$serial" emu kill 2>/dev/null || true done diff --git a/tests/.detoxrc.js b/tests/.detoxrc.js index e0c8a4c683..e1b3efe663 100644 --- a/tests/.detoxrc.js +++ b/tests/.detoxrc.js @@ -44,9 +44,12 @@ function iosXcodebuildPrefix() { // Slotted devices (including slot 0) — distinct from serial iPhone 17 / TestingAVD // so a slotted wave can run beside an unslotted serial run. +const E2E_SLOTTED_MAX = 7; + function iosSimulatorDevice(slot) { const deviceType = process.env.RNFB_IOS_BASE_SIMULATOR || 'iPhone 17'; - const slotName = `RNFB E2E iOS slot-${slot}`; + // Patched Detox must reuse this exact neutral base or fail if it is allocated. + const slotName = `RN E2E iOS slot-${slot}`; return { type: 'ios.simulator', device: { type: deviceType, name: slotName }, @@ -114,7 +117,7 @@ const devices = { }, }; -for (let slot = 0; slot < 5; slot += 1) { +for (let slot = 0; slot <= E2E_SLOTTED_MAX; slot += 1) { devices[`simulator-slot${slot}`] = iosSimulatorDevice(slot); devices[`emulator-slot${slot}`] = androidEmulatorDevice(slot); } @@ -139,7 +142,7 @@ const apps = { }, }; -for (let slot = 0; slot < 5; slot += 1) { +for (let slot = 0; slot <= E2E_SLOTTED_MAX; slot += 1) { apps[`android.debug.slot${slot}`] = androidApp(ANDROID_REVERSE_DEFAULT); apps[`android.debug.slot${slot}.windows`] = androidAppWindows(ANDROID_REVERSE_DEFAULT); } @@ -154,7 +157,7 @@ const configurations = { 'android.emu.release': { device: 'emulator', app: 'android.release' }, }; -for (let slot = 0; slot < 5; slot += 1) { +for (let slot = 0; slot <= E2E_SLOTTED_MAX; slot += 1) { configurations[`ios.sim.debug.slot${slot}`] = { device: `simulator-slot${slot}`, app: 'ios.debug', diff --git a/tests/patches/detox+20.51.0.patch b/tests/patches/detox+20.51.0.patch new file mode 100644 index 0000000000..94fad914c7 --- /dev/null +++ b/tests/patches/detox+20.51.0.patch @@ -0,0 +1,30 @@ +diff --git a/node_modules/detox/src/devices/allocation/drivers/ios/SimulatorAllocDriver.js b/node_modules/detox/src/devices/allocation/drivers/ios/SimulatorAllocDriver.js +index 291d739..be7f2b1 100644 +--- a/node_modules/detox/src/devices/allocation/drivers/ios/SimulatorAllocDriver.js ++++ b/node_modules/detox/src/devices/allocation/drivers/ios/SimulatorAllocDriver.js +@@ -13,6 +13,8 @@ const SimulatorAppCache = require('../../../common/drivers/ios/tools/SimulatorAp + + const SimulatorQuery = require('./SimulatorQuery'); + ++const RN_E2E_SLOT_BASE_NAME = /^RN E2E iOS slot-[0-7]$/; ++ + /** + * @implements {AllocationDriverBase} + */ +@@ -127,6 +129,16 @@ class SimulatorAllocDriver { + const { free, taken } = await this._groupDevicesByStatus(deviceQuery); + + if (_.isEmpty(free)) { ++ if (RN_E2E_SLOT_BASE_NAME.test(deviceQuery.byName)) { ++ throw new DetoxRuntimeError({ ++ message: `The neutral iOS slot ${deviceQuery.byName} is already allocated`, ++ hint: `Detox will not create a suffixed clone for a neutral slot. ` + ++ `Confirm Mellifera/manual ownership and release the owning run before retrying. ` + ++ `If no owner is active, inspect ~/Library/Detox/device.registry.json for a stale allocation; ` + ++ `do not reset the registry while another run is active.` ++ }); ++ } ++ + const prototypeDevice = taken[0]; + udid = this._applesimutils.create(prototypeDevice); + await this._runScreenshotWorkaround(udid);