> ## Documentation Index
> Fetch the complete documentation index at: https://e2e.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# From Appium

> Move Appium and WebdriverIO mobile tests to TypeScript with locators, device calls, and agent goals.

export const migrationPrompt = `Migrate this project's Appium tests to e2e, one test at a time. Docs: \`https://e2e.com/docs/migrate/appium.md\` and \`https://e2e.com/docs/mobile.md\`

1. Read the migration guide above. It maps Appium capabilities, selectors, and commands to e2e and marks what has no equivalent. The mobile guide covers devices and app builds.
2. e2e tests run in Node, in TypeScript by default or in JavaScript. If the Appium suite is Java, Python, or another language, or the project has no \`package.json\`, 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 capabilities into the e2e config with the guide's table: one target per platform with \`mobile({ platform, device })\`, and \`app: { bundleId, appPath, launchArguments, permissions }\` on each target. Move passwords and tokens into \`credentials\` or \`secrets\`.
7. Install dependencies, then run \`npx e2e doctor\` and fix what it reports.
8. List the Appium tests and ask me which one to move first. Suggest one with XPath selectors or explicit waits that often break.
9. Rewrite that test as a \`.e2e.ts\` file under \`tests/\` (e2e runs \`tests/**/*.e2e.ts\` by default). Start it with \`app.open()\`, since nothing launches on its own. Follow the guide's "Rewrite selectors" section: XPath has no port, and e2e fails on several matches. Where the guide says "Not yet" or "No equivalent", use the workaround it gives, or leave that test in Appium 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 Appium test until I confirm it can go.
12. Summarize which tests moved, which stayed in Appium, and why.`;

e2e replaces the Appium server, its drivers, and the client library with
one Node package. `@e2e-dev/mobile` drives iOS simulators, Android
emulators, and connected phones through
[agent-device](https://github.com/callstack/agent-device), which it
includes. Your app build and its accessibility ids stay as they are.

This guide is for suites that test a mobile app through Appium's XCUITest or
UiAutomator2 driver, from WebdriverIO or another client. The examples use
WebdriverIO. A Java or Python client maps the same way.

<SetupPrompt>

<Prompt description={migrationPrompt} children={migrationPrompt} actions={["copy", "cursor"]} />

</SetupPrompt>

For a migration that spans several sessions, the [`migrate-to-e2e` skill](https://e2e.com/docs/coding-agents#migrate-an-existing-suite) records what moved in a progress file and keeps the old suite running until you retire each test.

## What replaces each Appium part

| Appium | e2e |
| --- | --- |
| The Appium server, started before the run | No server. The engine starts agent-device's daemon |
| Drivers (`appium driver install xcuitest`) | `mobile({ platform: 'ios' })` or `'android'`, one engine per platform; nothing else to install |
| Capabilities | A target in `e2e.config.ts`: engine options plus `app` |
| A session (`remote()`, `deleteSession()`) | One device session per worker, which the runner opens and closes |
| Selector strategies (accessibility id, id, XPath) | `screen.getByTestId`, `getByLabel`, `getByText`, `getByRole` |
| Element commands (`click`, `setValue`) | Locator actions (`tap`, `fill`), which wait for one actionable match |
| `driver` and `mobile:` commands | The `device` and `app` fixtures |
| expect-webdriverio, implicit and explicit waits | `expect(locator)` matchers, which retry until their timeout |
| The WebdriverIO testrunner, Mocha, TestNG, pytest | `test`, `describe`, hooks, and `npx e2e run` |
| Nothing in Appium | Agent steps: `agent.act`, `agent.assert`, and `agent.extract` take a goal in plain words |

## Port the capabilities

<Note>Set up a target with the [mobile guide](https://e2e.com/docs/mobile) and a model with the [quickstart](https://e2e.com/docs/quickstart). Only agent steps need a model.</Note>

On this page, "Not yet" marks a capability or command e2e doesn't have
today, and "No equivalent" one it leaves out on purpose.

| Capability | e2e |
| --- | --- |
| `platformName` | `mobile({ platform: 'ios' })` or `'android'` |
| `appium:automationName` | Nothing to set |
| `appium:deviceName`, `appium:udid` | `mobile({ device: 'iPhone 17' })`: a name, a simulator UDID, or an `emulator-5554` serial; a list is a pool, one worker per device |
| `appium:platformVersion` | Not yet. Name a simulator or emulator that runs that version |
| `appium:app` | `app: { appPath }` on the target, installed by the suite, see the note after the config |
| `appium:bundleId`, `appium:appPackage` | `app: { bundleId }`, the app `app.open()` launches |
| `appium:appActivity`, `appium:appWaitActivity` | Not yet. e2e opens the app by its package |
| `appium:processArguments`, `appium:optionalIntentArguments` | `app.launchArguments`, a list of strings: process arguments on iOS, `am start` arguments on Android. `processArguments.env` is not yet supported |
| `appium:autoGrantPermissions`, `appium:permissions` | `app.permissions: { camera: 'grant' }`, set before every fresh launch; nothing is granted unless named |
| `appium:autoAcceptAlerts`, `appium:autoDismissAlerts` | Not yet. Set `app.permissions` so the prompt never shows, or call `device.alert('accept')` |
| `appium:noReset: true` | The default. Nothing clears the app's data unless a test asks |
| `appium:noReset: false` | `app.clearState()` at the start of the test |
| `appium:fullReset` | `device.installApp(path, { reinstall: true })` |
| `appium:newCommandTimeout`, WebDriverAgent timeouts | Not needed. Actions use `actionTimeout`, assertions `assertionTimeout`, and tests `timeout` |
| `appium:language`, `appium:locale` | Not yet |
| `bstack:options`, `lt:options` | A device provider: [`browserstack()`](https://e2e.com/docs/integrations/browserstack) or [`testmu()`](https://e2e.com/docs/integrations/testmu) |
| Several capabilities, `maxInstances` | One target per platform, `workers`, and a `device` pool |

```ts title="e2e.config.ts"

export default {
  targets: [
    {
      name: 'ios',
      engine: mobile({ platform: 'ios', device: 'iPhone 17' }),
      app: {
        bundleId: 'com.example.app',
        appPath: 'ios/build/MyApp.app',
        launchArguments: ['-e2e', 'YES'],
        permissions: { notifications: 'grant' },
      },
    },
    {
      name: 'android',
      engine: mobile({ platform: 'android' }),
      app: {
        bundleId: 'com.example.app',
        appPath: 'android/app/build/outputs/apk/release/app-release.apk',
        launchArguments: ['--ez', 'e2e', 'true'],
        permissions: { notifications: 'grant' },
      },
    },
  ],
  workers: 1,
} satisfies E2EConfig;
```

`appPath` names the build, but the suite installs it. Install it in CI
before the run, or call `device.installApp()` in a fixture. See
[Point at your app](https://e2e.com/docs/mobile#point-at-your-app).

## Rewrite one test

Start with a test whose XPath selectors or explicit waits often break.

```js title="Appium (WebdriverIO)"
describe('login', () => {
  it('signs in', async () => {
    await $('~email').setValue('ada@example.test');
    await $('~password').setValue('hunter2');
    await $('//XCUIElementTypeButton[@name="Sign in"]').click();
    await $('~welcome').waitForDisplayed({ timeout: 10000 });
    await expect($('~welcome')).toHaveText('Welcome, Ada');
  });
});
```

```ts title="e2e"

test('signs in', async ({ app, screen }) => {
  await app.open();
  await screen.getByTestId('email').fill('ada@example.test');
  await screen.getByTestId('password').fill('hunter2');
  await screen.getByRole('button', 'Sign in').tap();
  await expect(screen.getByText('Welcome, Ada')).toBeVisible({ timeout: 10_000 });
});
```

- Nothing launches on its own, so the test starts with `app.open()`.
- Actions wait for their element and assertions retry, so the explicit
  wait goes away.

Where a run of scripted taps breaks often, one goal can replace it. The
agent reads the screen and finds the fields. The [replay cache](https://e2e.com/docs/cache)
records its actions once a later check passes, and the next run replays them
without a model call. A password belongs in
[configured credentials](https://e2e.com/docs/authentication), which the model never sees:

```ts

const ada = credentials.user('ada');
await agent.act('sign in as {username}', { params: { username: ada.username, password: ada.password } });
```

## Rewrite selectors

| Appium | e2e |
| --- | --- |
| `$('~login')` on iOS, an accessibility identifier or React Native `testID` | `screen.getByTestId('login')` |
| `$('~Log in')` on Android, a content description or React Native `accessibilityLabel` | `screen.getByLabel('Log in')` |
| `$('id=com.example:id/email')`, `resourceId` | `screen.getByTestId('email')`; the `com.example:id/` prefix is optional |
| `-ios predicate string:label == "Sign in"`, `android=new UiSelector().text("Sign in")` | `screen.getByText('Sign in')` or `getByLabel('Sign in')` |
| `-ios class chain`, class names (`XCUIElementTypeButton`, `android.widget.Button`) | `screen.getByRole('button', 'Sign in')` |
| XPath | No equivalent. Chain queries (`screen.getByTestId('row').getByText('Delete')`), or use `filter({ has, hasText })` |
| `$$(selector)[2]` | `.nth(2)`; `.all()` returns every match |
| `-image` selectors | No equivalent. Use a goal, or `screen.tapAt({ x, y })` |
| A selector the queries can't express | `device.locator('role=text label="Total"')`, agent-device's selector syntax |

Three differences from Appium cause most edits:

- `getByText`, `getByLabel`, and `getByTestId` match the whole string, case
  included. Pass a `RegExp`, or `{ exact: false }` for a case-insensitive
  substring of text or label.
- Appium's `$()` returns the first match. An e2e action or single-element
  read needs exactly one, and fails with `LOCATOR_AMBIGUOUS` on several.
  Narrow it with `.filter()`, `.first()`, or `.nth()`.
- Appium reads the tree in each platform's own terms, as the iOS class in
  the XPath above shows. e2e maps native controls to shared roles such as
  `button`, `textbox`, and `switch`, so one test runs on both platforms. The
  [capabilities table](https://e2e.com/docs/reference/mobile#capabilities) lists the mapping.

## Map each command

Calls on `device` run only on a device. A suite that also runs on the web
declares `requires: ['device']` on those tests.

<AccordionGroup>
<Accordion title="Element commands">

| Appium (WebdriverIO) | e2e |
| --- | --- |
| `.click()` | `.tap()` |
| `.doubleClick()` | `.doubleTap()` |
| `mobile: longClickGesture`, `mobile: touchAndHold` | `.longPress({ duration })` |
| `.setValue(t)` | `.fill(t)`, which replaces the content |
| `.addValue(t)` | `.pressSequentially(t)`, which taps the field and types key by key at the cursor |
| `.clearValue()` | `.clear()` |
| `driver.keys('Enter')`, `pressKeyCode(66)` | `.press('Enter')` on the field; a device takes only `Enter`, `Space`, or one character |
| `.getText()` | `.textContent()` |
| `.getValue()`, `.getAttribute('value')` | `.inputValue()` |
| `.getAttribute(name)` | `.getAttribute('placeholder')`; `placeholder` is the only attribute on a device |
| `.isDisplayed()`, `.isEnabled()` | `.isVisible()`, `.isEnabled()` |
| `.isSelected()` | `await expect(locator).toBeSelected()`; there is no selected read |
| `.getAttribute('checked')` | `.isChecked()` |
| `.isExisting()` | `(await locator.count()) > 0` |
| `.getLocation()`, `.getSize()` | `.boundingBox()` |
| `.waitForDisplayed({ timeout })` | `await expect(locator).toBeVisible({ timeout })`, or `locator.waitFor()` |
| `.waitForDisplayed({ reverse: true })` | `await expect(locator).toBeHidden()` |

</Accordion>
<Accordion title="Gestures">

| Appium (WebdriverIO) | e2e |
| --- | --- |
| `mobile: swipeGesture`, `mobile: swipe`, `driver.swipe({ direction: 'up' })` | `locator.swipe({ direction: 'down' })` or `screen.swipe(...)`; Appium names the finger's direction, e2e the content's |
| `mobile: scrollGesture`, `mobile: scroll` to an element, `.scrollIntoView()` | `screen.scrollUntilVisible(locator)` |
| `mobile: dragGesture`, `mobile: dragFromToForDuration`, `.dragAndDrop(target)` | `locator.dragTo(target)` |
| A `performActions` swipe between two points | `screen.swipe({ from: { x, y }, to: { x, y } })` |
| A tap at coordinates | `screen.tapAt({ x, y })`; inside an element, `.tap({ position: { x, y } })` |
| `mobile: pinchOpenGesture`, `mobile: pinch`, multi-finger `performActions` | Not yet |

</Accordion>
<Accordion title="Assertions">

| expect-webdriverio | e2e |
| --- | --- |
| `toBeDisplayed()`, `not.toBeDisplayed()` | `toBeVisible()`, `toBeHidden()` |
| `toExist()` | `toBeAttached()` |
| `toHaveText(t)`, `toHaveText(t, { containing: true })` | `toHaveText(t)`, `toContainText(t)` |
| `toHaveValue(v)` | `toHaveValue(v)` |
| `toBeEnabled()`, `toBeDisabled()` | `toBeEnabled()`, `toBeDisabled()` |
| `toBeSelected()`, `toBeChecked()` | `toBeSelected()`, `toBeChecked()` |
| `toBeElementsArrayOfSize(n)` | `toHaveCount(n)` |
| `@wdio/visual-service` screen comparison | `await expect(screen).toHaveScreenshot('name.png')`, see [Visual comparison](https://e2e.com/docs/visual-testing) |

</Accordion>
<Accordion title="App lifecycle">

| Appium (WebdriverIO) | e2e |
| --- | --- |
| The launch at session start | `app.open()`, a fresh launch of the target's app |
| `driver.activateApp(id)` | `device.openApp(id)`; `{ relaunch: true }` restarts it |
| `driver.terminateApp(id)` | `device.closeApp()`, for the session's app only. It ends the session, so open an app next |
| `terminateApp`, then `activateApp` | `app.restart()` |
| `mobile: clearApp` | `app.clearState()` |
| `driver.installApp(path)` | `device.installApp(path)` |
| `driver.removeApp(id)` | Not yet. `device.installApp(path, { reinstall: true })` removes the app first |
| `driver.isAppInstalled(id)`, `queryAppState(id)` | Not yet. On Android, `device.foregroundApp()` names the app on screen |
| `driver.background(seconds)` | `device.home()`, then `device.openApp(id)` |
| `driver.url('myapp://x')`, `mobile: deepLink` | `device.openLink('myapp://x')` |
| `driver.back()` | `app.back()` |
| `driver.hideKeyboard()` | `device.dismissKeyboard()` |
| `driver.getPageSource()` | `screen.tree()`, or `screen.debug()` to print it |
| `driver.takeScreenshot()` | `app.screenshot('label')`, secure fields masked |
| `startRecordingScreen()`, `stopRecordingScreen()` | `app.startRecording()`, `app.stopRecording()`, or `npx e2e run --video` |

</Accordion>
<Accordion title="Device">

| Appium (WebdriverIO) | e2e |
| --- | --- |
| `mobile: setPermission`, `mobile: changePermissions` | `device.setPermission('camera', 'grant')`, one per call, before `app.open()` |
| `driver.acceptAlert()`, `driver.dismissAlert()` | `device.alert('accept')`, `device.alert('dismiss')` |
| `driver.setGeoLocation({ latitude, longitude })` | `device.setLocation({ latitude, longitude })` |
| `driver.setOrientation('LANDSCAPE')` | `device.setOrientation('landscape-left')` |
| `mobile: setAppearance` | `device.setAppearance('dark')` |
| `driver.setClipboard(t)`, `driver.getClipboard()` | `device.setClipboard(t)`, `device.clipboard()`, plain text where Appium uses base64. Neither works on an Android build with no clipboard shell command |
| `driver.toggleWiFi(false)`, `mobile: setConnectivity` with `wifi: false` | `device.setNetwork('offline')`, which turns off Wi-Fi only; Android only |
| `driver.toggleAirplaneMode()`, `mobile: setConnectivity` with `airplaneMode: true` | `device.setAirplaneMode(true)`, Wi-Fi and cellular; Android 11 or later |
| `mobile: enrollBiometric` | `device.enrollBiometrics('faceid', true)` |
| `mobile: sendBiometricMatch`, `driver.fingerPrint(1)` | `device.setBiometrics('faceid', 'match')`, `device.setBiometrics('fingerprint', 'match')` |
| `pressKeyCode` for Home | `device.home()` |
| `mobile: clearKeychains` | `device.clearKeychain()`; iOS simulator only |
| `driver.isIOS`, `driver.isAndroid` | `{ platforms: ['ios'] }` on the test, or the `platform` fixture |

</Accordion>
<Accordion title="Runner and tooling">

| Appium (WebdriverIO) | e2e |
| --- | --- |
| `specs` in `wdio.conf.ts` | `tests`, default `tests/**/*.e2e.ts` |
| `specFileRetries`, Mocha `retries` | `retries` in the config, or `{ retries }` on a test |
| Mocha `--grep`, tags in titles | `--grep`, or `{ tags }` on a test and `--tag` |
| `before`, `beforeEach` hooks | `beforeAll`, `beforeEach` |
| JUnit, Allure, spec reporters | `--reporter list,junit` writes `.e2e/junit.xml`; `markdown` writes a summary page |
| Appium Inspector | `screen.debug()`, or `npx e2e mcp` for a coding agent's live session, see [MCP server](https://e2e.com/docs/mcp-server) |

</Accordion>
</AccordionGroup>

## Permissions and deep links

Appium grants permissions through capabilities or `mobile:` commands, and
opens links through `driver.url()`:

```js title="Appium (WebdriverIO)"
await driver.execute('mobile: setPermission', {
  bundleId: 'com.example.app',
  access: { location: 'yes' },
});
await driver.activateApp('com.example.app');
await driver.url('myapp://orders/42');
await expect($('~order-title')).toHaveText('Order #42');
```

```ts title="e2e"

test('opens an order from a link', async ({ app, device, screen }) => {
  await device.setPermission('location', 'grant');
  await app.open();
  await device.openLink('myapp://orders/42');
  await expect(screen.getByText('Order #42')).toBeVisible();
});
```

Set a permission before `app.open()`: a change terminates a running app on
iOS. Permissions every test needs go in `app.permissions`, see
[Launch arguments and permissions](https://e2e.com/docs/mobile#launch-arguments-and-permissions).

## Run in CI

An Appium job starts the server with `appium &`, then the client with
`npx wdio run wdio.conf.ts`. An e2e job has no server. It installs the build
and runs the suite:

<CodeGroup>
```bash npm
xcrun simctl install booted ios/build/MyApp.app
npx e2e run --target ios --reporter list,junit
```

```bash pnpm
xcrun simctl install booted ios/build/MyApp.app
pnpm exec e2e run --target ios --reporter list,junit
```

```bash bun
xcrun simctl install booted ios/build/MyApp.app
bunx e2e run --target ios --reporter list,junit
```
</CodeGroup>

Each platform needs its own job: iOS on macOS, Android on Linux with KVM.
[Mobile jobs](https://e2e.com/docs/ci#mobile-jobs) has the full setup.

## Migration checklist

1. Install e2e beside the app, or in an `e2e/` folder for a Java or Python
   suite. Run `npx e2e doctor`.
2. Port the capabilities to one target per platform.
3. Rewrite one flaky test. Replace XPath with test ids, labels, and roles,
   and add an id where the app has none.
4. Run it with `npx e2e run <file>` on each target until it passes.
5. Add the e2e job to CI beside the Appium job.
6. Delete the Appium test, and repeat. After the last one, remove the Appium
   server and drivers from CI.

## What has no direct equivalent

The tables above mark single commands. These gaps cut across a whole suite:

- **Webview contexts.** Not yet. There is no `getContexts` or
  `switchContext`, and no script runs in a webview.
- **Device shell and files.** Not yet. No `mobile: shell`, `pushFile`,
  `pullFile`, `startActivity`, `lock`, or performance data. For what
  agent-device supports, `device.unstable_raw` exposes its client. Its calls
  aren't steps, so they skip the report and secret redaction.
- **Sauce Labs and other Appium grids.** Not yet. BrowserStack, TestMu AI,
  Limrun, and [Expo Simulators](https://e2e.com/docs/integrations/eas) have providers.
- **Session reuse.** No equivalent. A device has no `test.setup` or
  `{ session }`, see [what differs from the web](https://e2e.com/docs/mobile#what-differs-from-the-web).

## Next

<CardGroup cols={2}>
  <Card title="Mobile" icon="mobile-screen" href="/mobile">
    Configure your app, devices, and hosted devices.
  </Card>
  <Card title="Mobile engine reference" icon="book" href="/reference/mobile">
    Every option, the `device` fixture, and the agent tools.
  </Card>
</CardGroup>
