---
name: rnx-test
description: Run automated tests against your React Native app under Peach with Maestro YAML or Detox/Jest
---

# Peach tests

Peach has two test runners. Maestro owns every YAML test, whether it was
hand-authored, generated from a goal, or recorded from live CLI actions.
Detox runs existing imperative Jest suites through the Peach driver.

> needs a connected, pinned sim. if `rnx describe` errors, load
> `/rnx-setup` first.

Set `RNX_NO_OPEN=1` before starting an app dev server for a Detox,
Maestro, or iOS-only pass. Apps can opt into a named Electron window through
the official Vite or Metro plugin, and test-only work must not open that window.

## route in (decision tree)

- have an existing `.maestro/` directory? → [Maestro](#branch--maestro)
- have existing Detox specs? → [detox drop-in](#branch--detox-drop-in)
- writing tests fresh, want plain-language generation, or want live authoring? → [Maestro](#branch--maestro)
- need one short, timing-sensitive sequence from the shell? → [inline chains](#one-off-inline-chains)

Do not invent a third runner for how a Maestro flow was authored.

## anti-patterns (apply to both runners)

- **`sleep` is almost always a bug.** the underlying CLI auto-settles
  before reads and polls layout hash after writes. if a flow fails
  without a `sleep`, the fix is `waitFor`, `waitForAnimationToEnd`, or
  `wait idle` — not a blind timer. the one legitimate case: a
  user-controlled debounce ("press and hold for 2s").
- **prefer testID (`tapOn: { id: ... }`, `by.id(testID)`) over text.** text breaks
  under i18n, copy churn, and duplicate strings. add `testID` during
  implementation and author flows against it.
- **pin a sim before running a suite.** parallel CI jobs colliding
  on the default bridge port is the #1 flake source. pass `--port` in
  CI and `rnx use <id>` locally.
- **every flow ends with an assert, not a screenshot.** a screenshot
  passes as long as the flow didn't throw. `assertVisible` (or
  `expect(...).toBeVisible()`) actually verifies the happy path.
- **don't run a 40-step "smoke everything" flow.** when it fails, you
  can't tell what regressed. split into `login.yaml`, `compose.yaml`,
  `settings.yaml`, chain with `runFlow`.
- **inside an `rnx do` chain, use `sleep` only for gesture timing.** after the
  chain, use `rnx wait` for app state so automation advances on a real condition.

## you're done when

- the suite passes locally and in CI
- every flake is either fixed or marked `xfail` with a written reason
  and a removal date
- the green run took noticeably less wall-clock than the previous
  baseline (a suite that grows slower without growing wider is a smell)

## one-off inline chains

repeat `--then` before each additional `do` action to run one ordered engine
batch. use a chain for a short repro, form fill, or timing-sensitive gesture
that does not belong in a saved test:

```sh
rnx do tap email --then type user@example.com --then tap submit
rnx wait selector dashboard
```

the batch stops on the first failure. add `--json` for per-action results.
`tap <id>` and `tap-id <id>` are exact; use `tap-text <text>` for visible text.
punctuation in action data remains data. the exact `--then` token is reserved
as the action boundary. use Maestro when the sequence should be reviewed,
reused, or run in CI; an inline chain is not a third test runner and does not
read flow files.

## branch — Maestro

Write declarative Maestro YAML. The same command runs existing suites,
generates tests, records live drafts, profiles, and uploads replayable results.

```sh
rnx maestro test .maestro/login.yaml             # run one flow
rnx maestro test .maestro/login.yaml --record    # record video
rnx maestro test .maestro/login.yaml --profile   # include perf stats
rnx maestro test .maestro/                       # run the suite
```

Author from a live interaction:

```sh
rnx maestro start                     # begin a draft
rnx do tap-id loginButton             # drive the app via the CLI
rnx maestro keep                      # save the step
rnx do type "user@example.com"
rnx maestro keep
rnx do tap-id submit
rnx maestro keep
rnx maestro end --output .maestro/login.yaml --validate
```

the recorder emits minimal YAML — no coordinate dumps, no `sleep` noise —
because the underlying CLI already settled between every action.

exemplar flow:

```yaml
# .maestro/login.yaml
- launchApp: {}

- waitFor:
    text: 'Sign in'
    timeout: 10000

- tapOn:
    id: emailInput
- inputText: '${USERNAME}'

- tapOn:
    id: passwordInput
- inputText: '${PASSWORD}'

- tapOn:
    id: signInButton
- waitForAnimationToEnd: true

- assertVisible: 'Welcome back'
```

run with env interpolation:

```sh
rnx maestro --env USERNAME=alice --env PASSWORD=hunter2 \
  test .maestro/login.yaml \
  --record --profile
```

automatic settling inside flows: every tap, type, scroll, swipe, and
drag is backed by the same primitives that auto-wait for transitions on
the way in and layout stability on the way out. you rarely need
explicit waits; when you do, `waitForAnimationToEnd: true` maps to the
CLI's `wait idle` with a generous budget. use it before any
`assertVisible` that depends on a transition longer than ~400 ms.

Generate and immediately run a Maestro flow from a plain-language goal:

```sh
rnx maestro generate "log in with the demo account and verify the dashboard"
```

Generation writes the same deterministic Maestro artifact, uploads its replay,
and registers it as a Maestro run. The dashboard does not distinguish how it
was authored.

### existing Maestro suites

if you already have a `.maestro/` directory, the migration is one
command change:

```sh
# before
maestro test .maestro/login.yaml

# after
rnx maestro test .maestro/login.yaml
```

no test rewrites, no detox config, no simulator. the runtime auto-launches
a Peach shell if one isn't running.

```sh
rnx maestro                           # auto-discover .maestro/ or maestro/
rnx maestro test .maestro/login.yaml
rnx maestro test .maestro/            # every flow in a directory
rnx maestro init                      # scaffold a starter flow
rnx maestro --list-compat             # supported / partial / unsupported
rnx maestro --env USERNAME=alice test .maestro/login.yaml
```

### supported verbs

`launchApp` (including JSON `arguments`), `stopApp`, `clearState`, `tapOn`, `tapAtCoords`, `longPressOn`, `inputText`,
`pressKey`, `dispatchKey`, `hideKeyboard`, `eraseText`, `assertVisible`,
`assertNotVisible`, `assertTreeContains`, `waitFor`, `extendedWaitUntil`,
`waitForAnimationToEnd`, `scroll`, `scrollUntilVisible`, `scrollTo`,
`swipe`, `pinch`, `takeScreenshot`, `dumpTree`, `back`, `repeat.times`,
`runFlow`, `when` (`visible` / `notVisible` / `platform` / `true`),
`optional: true`, `onFlowStart` / `onFlowComplete`, `copyTextFrom` +
`${maestroCopiedText}`, `evalScript`, `openLink`, env var `${NAME}`
interpolation.

### partial

- `clearKeychain` — no keychain in Peach; logs a warning and continues.
  if a test depends on keychain state being reset, add an explicit
  in-app "sign out" step instead.
- `when.platform` — Peach emulates iOS; android branches are skipped.
- `openLink`: app routing works; there is no OS-level browser fallback.

`stopApp` keeps the guest runtime stopped. The next `launchApp` starts a fresh
guest with its replacement arguments. Every `openLink` replaces the guest and
delivers the URL through React Native Linking after the replacement starts.

### not yet implemented

these throw `unsupported maestro verb: X`:

- `travel`, `setLocation`, `setAirplaneMode`, `killApp`
- `addMedia`
- `repeat.while` (use `repeat.times`)

### troubleshooting

- **"no maestro flows found"** — pass an explicit path
  (`rnx maestro test path/to/flow.yaml`) or run `rnx maestro
  init` to scaffold one.
- **"missing environment variable for flow placeholder: FOO"** — pass
  `--env FOO=bar` or export it. pick one mechanism per repo and document
  it in the flow directory's README.
- **flow can't find an element** — Peach's matcher uses testID + visible
  text. UIKit-only labels that real maestro scrapes from the host OS won't
  resolve here; switch to testID.
- **flow runs but nothing renders** — confirm the bundler your flow
  targets is reachable and pass `--url <port>` or add `app: <port>`
  frontmatter at the top of the YAML.

## branch — detox drop-in

an existing detox suite runs against Peach with **one line** of jest
config and zero test changes.

`import { by, device, element, expect, waitFor } from 'detox'` is
rewritten by jest to an rnx-backed driver that drives the real shell
over playwright. matchers, assertions, and the `waitFor` API match the
upstream detox surface.

### one-line install

```js
// rnx-detox.config.cjs
module.exports = {
  preset: 'rnxsim/detox',
  rootDir: __dirname,
  testMatch: ['<rootDir>/e2e/**/*.test.ts'],
}
```

or, if you already have a jest config, pull in just the module mapper:

```js
moduleNameMapper: {
  '^detox$': require.resolve('rnxsim/detox'),
}
```

keep the **preset**, not just the mapper — the preset wires up the
global setup/teardown that tears the shell down between suites. mapper-only
works until your first hung shell.

### commands

```sh
rnx detox                             # run e2e/, test/e2e/, or detox/
rnx detox --config my.cjs             # use a specific jest config
rnx detox --watch                     # jest watch mode
rnx detox -t "login"                  # --testNamePattern
rnx detox --headed                    # keep the shell window visible
rnx detox --no-launch                 # don't auto-launch a shell
rnx detox --port 5173                 # explicit bridge port for CI
rnx detox init                        # scaffold config + e2e/example.test.ts
```

### compat surface

| detox API                          | Peach |
| ---------------------------------- | ------- |
| `element(by.id(...))`              | supported |
| `element(by.text(...))`            | supported — but prefer `by.id` |
| `element(by.label(...))`           | supported |
| `element(by.traits([...]))`        | supported for the iOS trait set |
| `.toBeVisible()` / `.not.toBeVisible()` | supported |
| `.toHaveText(...)` / `.toHaveLabel(...)` | supported |
| `.tap()` / `.longPress()`          | supported |
| `.typeText(...)` / `.replaceText(...)` | supported |
| `.scroll(...)` / `.scrollTo(...)`  | supported |
| `.swipe(...)`                      | supported |
| `.setColumnToValue(...)`           | supported for uniquely named picker rows |
| `waitFor(...).toBeVisible().withTimeout(...)` | supported |
| `device.launchApp(...)` / `device.reloadReactNative()` | supported |
| `device.shake()`                    | partial — emits the JS event, no haptic |
| `device.sendUserNotification(...)`  | not yet |

### troubleshooting

- **"no Peach shell reachable"** — start one yourself
  (`rnx open <port>`) or pass `--port <n>`.
- **"jest cannot find module 'detox'"** — the preset isn't wired. confirm
  `preset: 'rnxsim/detox'` is in the jest config jest
  actually loaded (`--config` overrides package.json).
- **matchers fail on elements that exist** — canvas has no DOM. use
  `by.id` (testID); `rnx describe` shows what the shell sees.
- **passes locally, flakes in CI** — the shell boots asynchronously.
  bump jest `testTimeout` to 10–15 s and add a `waitFor(element(...))
  .toBeVisible().withTimeout(10000)` before the first assertion.
- **port conflicts under parallel CI** — pass `--port` explicitly per
  job so workers don't collide on the default bridge port.

## recovery — common failure modes (both runners)

- **"no flows found"** — confirm the working directory or pass an explicit
  `.maestro/` path.
- **"missing env variable"** — set with `--env` (or jest env config) and
  document the variable list in the flow directory's README so new
  contributors aren't guessing.
- **CI flakes but local passes** — bump test timeouts to 10–15 s before
  the first assertion, pin the bridge port, and run with `--video` so
  failing Maestro runs land a recording next to the output.
- **a flake reproduces but resists fixing** — don't paper over with
  `sleep`. open `/rnx-debug` against the same sim, capture
  the timeline (`rnx timeline start` → reproduce → `rnx
  what-happened`), and look for the missing animation-completion or
  the unresolved fetch that's racing the assertion.
