e2e replaces Maestro’s YAML flows with TypeScript tests. Each command becomes an awaited call, usually on a locator, or one agent goal covers several. Both runners can use the same simulator or emulator while you move one flow at a time.

Agent prompt

Migrate this project's Maestro flows to e2e, one flow at a time. Docs: https://e2e.tester.army/docs/migrate/maestro.md and https://e2e.tester.army/docs/mobile.md

  1. Read the migration guide above. It maps every Maestro config option and command to e2e and marks what e2e does not have yet. The mobile guide covers devices and app builds.
  2. e2e tests are TypeScript. If the project has no package.json, as in a native iOS or Android project, create the test project in an e2e/ folder beside the app.
  3. If the test 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) there. 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.
  4. Read the e2e skill (npx e2e guide prints it, npx e2e guide <topic> prints a topic) and follow it for every step below.
  5. 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.
  6. Port the flows' appId and .maestro/config.yaml into the e2e config: one target per platform with mobile({ platform }), app: { bundleId } on each target, and tags as { tags } on tests. Move passwords and tokens from env into credentials or secrets.
  7. Install dependencies, then run npx agent-device doctor and fix what it reports.
  8. List the flows and ask me which one to move first. Suggest one whose optional steps and runFlow: { when } branches often need updating.
  9. Rewrite that flow as a .e2e.ts test under tests/ (e2e runs tests/**/*.e2e.ts by default), with one awaited call per command. Start it with app.open(), since nothing launches on its own. Follow the guide's "Rewrite selectors" section: e2e matches the whole string and fails on several matches. Where the guide says "Not yet" or "No equivalent", use the workaround it gives, or leave that flow in Maestro and tell me why.
  10. Keep the launch and the final assertions deterministic, with app.open() and locator assertions. Where a run of scripted taps breaks often, propose an agent.act goal for it instead.
  11. 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 Maestro flow until I confirm it can go.
  12. Summarize which flows moved, which stayed in Maestro, 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.

The reference pages hold the details: screen, expect, test, and the mobile engine.

Maestroe2e
appId at the top of every flowapp: { bundleId: 'com.example.app' } on the target, the app app.open() launches
a different appId per flow, or a second one mid-flowdevice.openApp(id) inside the test, or separate app-specific configs and runs; every test runs on every selected target. With mobileTools, the agent’s open_app tool switches apps inside a goal, see Agent tools
--platform iosmobile({ platform: 'ios' }), one engine per platform
--devicemobile({ device: 'iPhone 17' }); a list is a pool, one worker per device
.maestro/config.yaml flowstests, default tests/**/*.e2e.ts; Maestro runs one folder level, e2e recurses
flows: ['!helper.yaml']a tests entry with a leading !: tests: ['tests/**/*.e2e.ts', '!tests/helpers/**']
tags and --include-tags{ tags: [...] } on a test or group, --tag on the CLI
--exclude-tags--exclude-tag
env and ${VAR}module constants, and params in a goal: agent.act('sign in as {email}', { params: { email } })
MAESTRO_* variablesprocess.env in the config; a password or token goes under credentials or secrets, which the model never sees, see Signing in
--format junit, --output--reporter list,junit, written to .e2e/junit.xml
--format htmlNot yet. markdown writes .e2e/summary.md, and each failed test gets a trace.md page under .e2e/results/
properties (JUnit <property>)Not yet
--shards, --shard-splitdevice: [...] plus workers on one machine; --shard <index/total> across machines, see Sharding
maestro recordnpx e2e run --video, one MP4 of the device per attempt, or video in the config
maestro test -c, Maestro StudioNot yet. npx e2e mcp gives a coding agent a live session on the device, see MCP server
maestro hierarchyNot yet. device.locator takes agent-device selectors for nodes screen cannot name
Maestro Cloudyour CI, see Continuous integration

Rewrite one flow

Start with a flow whose optional steps and runFlow: { when } branches often need updating.

Maestro
appId: com.example.app
---
- launchApp:
    clearState: true
- tapOn: "Get started"
- tapOn:
    id: "email"
- inputText: "ada@example.test"
- tapOn: "Continue"
- assertVisible: "Check your inbox"
- scrollUntilVisible:
    element: "Resend"
- assertVisible:
    id: "resend"
e2e
import { test } from '@e2e-dev/mobile';
import { expect } from 'e2e';

test('asks for the email', async ({ app, agent, screen }) => {
  await app.clearState();
  await agent.act('get started with the email {email}', { params: { email: 'ada@example.test' } });
  await expect(screen.getByText('Check your inbox')).toBeVisible();
  await screen.scrollUntilVisible(screen.getByTestId('resend'));
  await expect(screen.getByTestId('resend')).toBeVisible();
});

Map each command

Calls on device run only on a device, so a test that uses it declares requires: ['device'] and other targets skip it. Calls on screen, app, agent, and expect(locator) run on every target. “Not yet” marks a command e2e doesn’t have today, and “No equivalent” one it leaves out on purpose.

App lifecycle

Maestroe2e
launchAppapp.open(), the fresh launch of the target’s app.bundleId; nothing launches on its own
launchApp: { clearState: true }, clearStateapp.clearState()
launchApp: { stopApp: false }device.openApp(id) without relaunch
launchApp: { arguments }app.launchArguments on the target, or device.openApp(app, { relaunch: true, launchArguments }) for one launch; a list of strings, not a map
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 allow, deny, unset; no all, nothing is granted unless named
stopApp, killAppdevice.closeApp()
stopApp, then launchAppapp.restart()
clearKeychaindevice.clearKeychain(), the whole simulator’s keychain; iOS only
openLinkdevice.openLink(url, { app }); on iOS the link opens into app, default the pinned app
addMediaNot yet
backapp.back(), or device.back()

Taps and input

Maestroe2e
tapOn: "Text"screen.getByText('Text').tap(); exact, see Rewrite selectors
tapOn: { id: x }screen.getByTestId('x').tap()
tapOn: { text, index: 2 }.nth(2)
tapOn: { below: "Email" }, above, leftOf, rightOf, containsDescendantsNo equivalent, see Rewrite selectors
tapOn: { point: "x,y" }screen.tapAt({ x, y }); inside a node, .tap({ position: { x, y } })
retryTapIfNoChange, waitToSettleTimeoutMsmobile({ settle, transition }), engine-wide; no per-call knob
doubleTapOn, longPressOn.doubleTap(), .longPress({ duration })
inputTextscreen.getByTestId('field').fill('text')
inputRandomEmail, inputRandomText, fakerunique() around a value in act params so the cache still replays; any npm faker for the rest
eraseText.clear()
hideKeyboarddevice.dismissKeyboard()
pressKey: Enter.press('Enter') on the field
pressKey: Home, backdevice.home(), device.back()
pressKey: lock, power, volume up, TV remote keysNot yet
copyTextFrom, pasteTextawait locator.textContent(), then .fill(text)
setClipboarddevice.setClipboard(text); device.clipboard() reads it
label: "..." on a commandNot yet. Every screen, device, and agent call is a report step named by the call

Gestures

Maestroe2e
swipe: { direction: UP }screen.swipe({ direction: 'down' }), or locator.swipe(...) inside one node; Maestro names the finger’s direction, e2e the content’s
swipe: { start, end }screen.swipe({ from: { x, y }, to: { x, y } })
scrollscreen.swipe({ direction: 'down' })
scrollUntilVisible: { element }screen.scrollUntilVisible(locator, { direction })
waitForAnimationToEndan assertion on the settled state; mobile({ settle, transition }) for the engine

Assertions and waits

Maestroe2e
assertVisible, assertNotVisibleawait expect(locator).toBeVisible(), .toBeHidden()
assertVisible: { text, enabled: true }.toBeVisible() and .toBeEnabled()
assertVisible: { checked, selected, focused }.toBeChecked(), .toBeSelected(), .toBeFocused()
assertTrue: ${...}expect(value).toBe(true), or expect.poll for a value that is still changing
extendedWaitUntil: { visible, timeout }.toBeVisible({ timeout })
assertScreenshot: { path, thresholdPercentage }await expect(screen).toHaveScreenshot(name, { maxDiffPixelRatio }), see Visual comparison
assertDarkMode, assertLightModeNot yet. Assert on a themed control
retry: { maxRetries, commands }expect.poll for a value, { retries } on the test for the flow
takeScreenshotapp.screenshot('label'), secure fields masked

Device

Maestroe2e
setLocationdevice.setLocation({ latitude, longitude })
travel: { points }device.setLocation in a loop
setAirplaneMode, toggleAirplaneModedevice.setAirplaneMode(true) or false, the state you want; true is Android only
setDarkMode, toggleDarkModedevice.setAppearance('dark') or 'light'
setOrientationdevice.setOrientation('landscape-left')
setPermissionsdevice.setPermission(name, 'grant' | 'deny' | 'reset'), one permission per call, on the target’s app, which need not be running; call it before app.open()
platform-specific selectorsdevice.locator('role=text label="..."'), agent-device’s selector terms

AI commands and flow control

Maestroe2e
assertWithAI, assertNoDefectsWithAIagent.assert('...'), agent.assert('...', { vision: true })
extractTextWithAIagent.extract('...', { schema })
runFlow: file.yamla function, or a fixture from test.extend
runFlow: { when: { visible } }if (await locator.isVisible()) { ... }, see Rewrite selectors
optional: true on a commandthe same isVisible() check
repeat: { times: n }, repeat: { while }a for or while loop
runScript, evalScript, ${output.x}TypeScript in the test
http.get, http.post, json()Node fetch, see API
onFlowStart, onFlowCompletebeforeEach, afterEach
startRecording, stopRecordingtest('x', { video: 'on' }, ...) records that test’s whole attempts; no start and stop inside a test

Rewrite selectors

Four differences from Maestro’s selectors cause most edits.

  • Maestro reads text and id as case-insensitive regular expressions, so tapOn: ".*Save.*" matches any text with Save in it, and "Mute Account" matches Mute account. getByText and getByTestId match the whole string, case included. Write getByText(/^mute account$/i) or getByTestId(/^row-/) for a pattern, or { exact: false } for a case-insensitive substring of text.
  • Maestro taps the first of several matches. e2e fails with LOCATOR_AMBIGUOUS, so write .first() when the first match is what you mean.
  • isVisible(), the check that replaces optional and runFlow: { when }, is false on zero matches and LOCATOR_AMBIGUOUS on several. Add { visible: true } to the query or .first() when a hidden twin may exist.
  • below, above, leftOf, rightOf, and containsDescendants have no port. e2e has filter({ has, hasText }) and chaining for structure, and nothing spatial. Use getByLabel, a test id, or a goal: agent.act('enter {email} in the email field', { params }) finds the field.

What differs on purpose

  • Tests get no agent-device client. Every call goes through screen, app, device, or the agent, so e2e records and redacts it.
  • A device has no test.setup or { session }, see what differs from the web.

Next