e2e tests a React Native app on a simulator or emulator with your normal debug or release build, with no Detox framework in the app. Your testID props work with getByTestId on both platforms.

Agent prompt

Migrate this project's Detox tests to e2e, one spec at a time. Docs: https://e2e.tester.army/docs/migrate/detox.md and https://e2e.tester.army/docs/mobile.md

  1. Read the migration guide above. It maps .detoxrc.js and the Detox API to e2e. The mobile guide covers devices and app builds.
  2. If the project has no e2e config (e2e.config.ts, .mts, .js, or .mjs), run npx e2e init --yes --engine mobile (or the pnpm or bun equivalent). It writes a mobile config with one target for this machine's platform and an example test against the Settings app, and installs nothing yet. Rewrite tests/example.e2e.ts for your app or delete it.
  3. Read the e2e skill (npx e2e guide prints it, npx e2e guide <topic> prints a topic) and follow it for every step below.
  4. If you ran init, ask me which model to use for agent steps: a ChatGPT, GitHub Copilot, OpenCode Console, or SuperGrok subscription, an API key, or a local model. Configure it with its provider package. If it needs a sign-in, give me the npx e2e login command and wait until I confirm.
  5. Port .detoxrc.js into the e2e config: one target per platform with mobile({ platform }), the app's bundleId and appPath on the target, and the device type as mobile({ device }). e2e uses the normal debug or release build, with no Detox framework.
  6. Install dependencies, then run npx agent-device doctor and fix what it reports.
  7. List the Detox specs and ask me which one to move first. Suggest a flow that needs frequent maintenance.
  8. Rewrite that spec as a .e2e.ts file under tests/ (e2e runs tests/**/*.e2e.ts by default), not in the Detox folder, with the guide's tables. Start it with app.open(), since nothing launches on its own. Narrow queries that match several nodes.
  9. Keep the launch and the final assertions deterministic, with app.open() and locator assertions. Where a run of scripted interactions breaks often, propose an agent.act goal for it instead.
  10. Build and install the app, run the new file with npx e2e run <file> on each platform target, and fix it until it passes. Keep the Detox spec until I confirm it can go.
  11. Summarize which tests moved, which stayed in Detox, and why.

For a migration that spans several sessions, the migrate-to-e2e skill records what moved in a progress file and keeps the old suite running until you retire each test.

Port the config

Set up a target with the mobile guide and a model with the quickstart. Both runners can use the same simulator while you move one spec at a time.
Detox (.detoxrc.js)e2e
apps.<name>.binaryPathapp: { appPath } on the target plus one device.installApp() per device in a fixture; the engine installs nothing on its own
apps.<name>.bundleIdapp: { bundleId: 'com.example.app' } on the target, the app app.open() launches
devices.<name>.device.typemobile({ device: 'iPhone 17' })
configurationstargets, one engine per platform
Jest config, maxWorkersworkers, one per device
detox buildYour normal build
device.launchApp({ permissions })app.permissions on the target, set before every fresh launch, or device.openApp(app, { relaunch: true, permissions }) for one; grant, deny, reset for YES, NO, unset
device.launchApp({ launchArgs })app.launchArguments on the target, or device.openApp(app, { relaunch: true, launchArguments }) for one; a list of strings the platform launch command takes, not a map
e2e.config.ts
import type { E2EConfig } from 'e2e';
import { mobile } from '@e2e-dev/mobile';

export default {
  targets: [
    {
      name: 'ios',
      engine: mobile({ platform: 'ios' }),
      app: { bundleId: 'com.example.app', appPath: 'ios/build/MyApp.app' },
    },
    {
      name: 'android',
      engine: mobile({ platform: 'android' }),
      app: { bundleId: 'com.example.app', appPath: 'android/app/build/outputs/apk/debug/app-debug.apk' },
    },
  ],
  workers: 1,
} satisfies E2EConfig;

Rewrite one spec

Start with a flow that needs frequent maintenance. If its screens take time to settle, set the engine’s settle and transition options.

Detox
describe('login', () => {
  beforeAll(async () => { await device.launchApp({ newInstance: true }); });

  it('shows the home screen', async () => {
    await element(by.id('email')).typeText('ada@example.test');
    await element(by.id('password')).typeText('hunter2');
    await element(by.text('Sign in')).tap();
    await waitFor(element(by.id('home'))).toBeVisible().withTimeout(10000);
    await expect(element(by.text('Welcome, Ada'))).toBeVisible();
  });
});
e2e
import { test } from '@e2e-dev/mobile';
import { expect, credentials } from 'e2e';

test('shows the home screen', async ({ agent, app, screen }) => {
  await app.open();
  const ada = credentials.user('ada');
  await agent.act('sign in as {username}', { params: { username: ada.username, password: ada.password } });
  await expect(screen.getByTestId('home')).toBeVisible({ timeout: 10_000 });
  await expect(screen.getByText('Welcome, Ada')).toBeVisible();
});

Nothing launches on its own, so the test starts with app.open(). The password comes from configured credentials.

Map each Detox call

Detoxe2e
by.id('x')screen.getByTestId('x')
by.text('t')screen.getByText('t'); both match the exact text
by.label('l')screen.getByLabel('l')
by.type('RCTImageView')device.locator('role=image'), agent-device’s selector syntax
by.id('x').withAncestor(by.id('row'))screen.getByTestId('row').getByTestId('x')
.atIndex(n).nth(n)
.tap(), .multiTap(2), .longPress().tap(), .doubleTap(), .longPress()
.tap({ x, y }).tap({ position: { x, y } })
.typeText(t), .replaceText(t).fill(t) replaces the content
.clearText().clear()
.tapReturnKey().press('Enter')
.swipe('up', 'fast').swipe({ direction: 'down', momentum: 'fast' }): Detox names the finger’s direction, e2e the content’s
.scroll(200, 'down')screen.swipe({ direction: 'down' })
waitFor(el).toBeVisible().whileElement(list).scroll(...)screen.scrollUntilVisible(el)
waitFor(el).toBeVisible().withTimeout(ms)await expect(el).toBeVisible({ timeout: ms })
expect(el).toBeVisible(), .toExist().toBeVisible() for visibility; .toHaveCount(1) for existence
expect(el).not.toBeVisible().toBeHidden()
expect(el).toHaveText(t).toHaveText(t)
expect(el).toHaveToggleValue(true).toBeChecked()
expect(el).toHaveValue(v).toHaveValue(v)
device.launchApp({ newInstance: true })app.restart()
device.launchApp({ delete: true })app.clearState()
device.reloadReactNative()No equivalent. app.restart() is the nearest
device.sendToHome(), device.pressBack()device.home(), device.back()
device.setLocation(lat, lon)device.setLocation({ latitude, longitude })
device.setOrientation('landscape')device.setOrientation('landscape-left')
device.setBiometricEnrollment(true)device.enrollBiometrics('faceid', true)
device.matchFace(), device.unmatchFace()device.setBiometrics('faceid', 'match' | 'nonmatch')
device.setStatusBar, device.setURLBlacklistNo equivalent
device.disableSynchronization()No equivalent. Actions wait and assertions retry
device.takeScreenshot()app.screenshot('label'), secure fields masked
device.takeScreenshot() compared with an image snapshot libraryawait expect(screen).toHaveScreenshot('name.png'), see Visual comparison
:ios: / :android: in a test name{ platforms: ['ios'] } on the test

Two things differ from Detox:

  • Actions and single-node reads and assertions need one match. Several fail with LOCATOR_AMBIGUOUS. Narrow with .filter(), .first(), or .nth(). See screen.
  • A device has no test.setup or { session }. See what differs from the web.

Next