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

# Testing iOS and Android

> Set up a simulator or emulator target, write a device test, and know what differs from the web.

`@e2edev/agent-device` drives iOS simulators and Android emulators. Devices
share the `agent`, `screen`, and `expect` APIs with browser targets, but app
setup follows the platform: a device attempt starts in its configured app
instead of opening a URL. This page covers setup, the `device` fixture, and
the operations that differ from a browser.

## Set up

You need Xcode with an iOS simulator runtime, or the Android SDK with an
emulator. Run the doctor once before handing the target to the runner:

```bash theme={"theme":"catppuccin-mocha"}
npx agent-device doctor
```

Then follow the [Quickstart](/quickstart) and choose **Mobile (iOS/Android)**
in the wizard. `init` defaults to iOS on macOS and Android elsewhere, pins the
Settings app so the first run works on any machine, and sets one worker.
There is no `APP_URL`; a device target declares no URL.

```ts title="e2e.config.ts" theme={"theme":"catppuccin-mocha"}
import type { E2EConfig } from '@e2edev/e2e';
import { agentDevice } from '@e2edev/agent-device';

export default {
  // Replace Settings with your app's bundle id.
  targets: [{ name: 'ios', engine: agentDevice({ platform: 'ios', app: 'Settings' }) }],
  workers: 1,
} satisfies E2EConfig;
```

The devices boot before the run's clock starts. On a CI runner, where a cold
boot takes a minute or more, that time is not charged to `launchTimeout`.

## Point at your app

Replace `Settings` with your app's bundle id or package name. `app` is opened
fresh at the start of every attempt and unlocks `app.restart()` and
`app.clearState()`. To install a build first, add `appPath`:

```ts theme={"theme":"catppuccin-mocha"}
agentDevice({
  platform: 'ios',
  app: 'com.example.app',
  appPath: process.env.E2E_APP_PATH,   // an .app bundle or .apk, installed once per worker
})
```

Every optional value accepts `undefined`, so an environment variable passes
straight through.

Without `app`, the engine observes whatever is in the foreground and the
model opens apps with the `open_app` tool. Pin `app` whenever you can: it
gives every attempt the same starting screen, which the
[trace cache](/cache) needs to replay.

## Several devices

One engine per device family; a config declares as many as it has devices:

```ts title="e2e.config.ts" theme={"theme":"catppuccin-mocha"}
import type { E2EConfig } from '@e2edev/e2e';
import { agentDevice } from '@e2edev/agent-device';

const iphone = agentDevice({ platform: 'ios', app: 'com.example.app' });
const pixel = agentDevice({ platform: 'android', app: 'com.example.app' });

export default {
  targets: [
    { name: 'iphone', engine: iphone },
    { name: 'pixel', engine: pixel },
  ],
  workers: 1,
} satisfies E2EConfig;
```

A deterministic check that names a platform label is scoped with
`platforms: ['ios']` or `platforms: ['android']`; everything else runs on
both.

To split a target's files across several simulators, pass a pool and let
`workers` be at least its size. Each entry is one worker, and worker `n`
drives the `n`th device. With no `device` at all, every booted device of the
platform is eligible for the pool, up to the run's worker slots. Four booted
simulators can run four files at a time when `workers` is at least four.
With none booted, agent-device boots one and the target uses one worker.

```ts theme={"theme":"catppuccin-mocha"}
agentDevice({ platform: 'ios', app: 'com.example.app', device: ['iPhone 17', 'iPhone 17 Pro'] })
```

The engine declares one worker per device, so a device target never has two
workers driving one simulator, whatever `workers` allows.

## Writing a device test

`screen` queries work as on the web. iOS `Button` is `button`, `TextField` is
`textbox`, `Cell` is `listitem`, `Switch` is `switch` with `checked`. Android
`TextView` is `text`, `EditText` is `textbox`, `RecyclerView` is `list`.
Element identifiers surface under `getByTestId`.

```ts theme={"theme":"catppuccin-mocha"}
import { test } from '@e2edev/agent-device';
import { expect } from '@e2edev/e2e';

test('shows the version offline in dark mode', async ({ agent, device, screen }) => {
  await device.setAppearance('dark');
  await device.setNetwork('offline');
  await agent.act('go to General, then About');
  await expect(screen.getByRole('button', { name: /^iOS Version/ })).toBeVisible();
  await expect(device.locator('role=NavigationBar id=About')).toBeVisible();
});
```

Text queries answer with the innermost match, so a host view that echoes its
child's label counts once. A tap on a `switch` lands on its innermost control.
`selectOption`, `setInputFiles`, and `scrollIntoView` are
`UNSUPPORTED_CAPABILITY` on a device; swipe instead.

### The `device` fixture

`device` is deterministic device management, recorded as `device.<method>`
steps and never involving a model: network and airplane mode, permissions,
location, appearance, orientation, biometrics, install and open apps, home
and back, system alerts, the keyboard, the clipboard.

`device.locator('...')` mints a locator from an agent-device selector for
nodes the closed `screen` vocabulary cannot name:

```ts theme={"theme":"catppuccin-mocha"}
await expect(device.locator('role=NavigationBar id=About')).toBeVisible();
await device.locator('id=SW_VERSION_SPECIFIER role=Button').tap();
```

`device.installApp` puts a build on the device from inside a test, for
upgrade and fresh-install paths:

```ts theme={"theme":"catppuccin-mocha"}
const { app } = await device.installApp('./build/MyApp.app', { reinstall: true });
await device.openApp(app);
```

Every method is in the [agent-device reference](/reference/agent-device#device).

### Agent tools

The grammar verbs (`tap`, `type`, `scroll`) come from the engine and need no
tool. For app switching, free-form swipes, typing into editors that hide the
focused field, and system alerts, hand the agent the device tool pack:

```ts title="e2e.config.ts" theme={"theme":"catppuccin-mocha"}
import type { E2EConfig } from '@e2edev/e2e';
import { createAgent } from '@e2edev/e2e/agent';
import { agentDevice } from '@e2edev/agent-device';
import { agentDeviceTools } from '@e2edev/agent-device/tools';
import { gateway } from 'ai';

const iphone = agentDevice({ platform: 'ios', app: 'com.example.app' });
const pixel = agentDevice({ platform: 'android', app: 'com.example.app' });

export default {
  targets: [
    { name: 'iphone', engine: iphone },
    { name: 'pixel', engine: pixel },
  ],
  workers: 1,
  agents: {
    default: createAgent({
      model: gateway('openai/gpt-5.6-luna'),
      tools: agentDeviceTools(iphone, pixel),
    }),
  },
} satisfies E2EConfig;
```

Pass every device engine the config declares. The pack is scoped to their
platforms and dispatches each call to the engine whose attempt is running.
A mutating tool call is a replay gap, so prefer the grammar inside a step.
The read-only `screenshot` tool creates no gap.

## What differs from the web

| On the web                                           | On a device                                                                                                                                               |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `app.open('/path')` and the agent's `navigate` verb  | No URL. The attempt starts in the pinned `app`; there is no `navigate`                                                                                    |
| `test.setup` saves a session, `session:` restores it | Not available. A simulator has no portable session snapshot; sign in per test or seed the app                                                             |
| A `Secret` fills through `type_secret`               | Not offered. A secret fill needs an origin the runner can check, and `app://` has none. Use `screen.getByLabel(...).fill(secret)` in a deterministic step |
| Playwright trace per attempt                         | Screenshots only, with every secure field painted black. No trace                                                                                         |
| `--video` writes a WebM screencast                   | `--video` writes an MP4 of the device screen with taps shown                                                                                              |
| The cache anchors on the page path                   | The cache anchors on `app://device/<bundle id>/<screen title>`, read off the navigation bar                                                               |

<CardGroup cols={2}>
  <Card title="agent-device reference" icon="mobile-screen" href="/reference/agent-device">
    Every option, the `device` fixture, and the tool pack.
  </Card>

  <Card title="Writing tests" icon="pen-to-square" href="/writing-tests">
    Goals, checks, and the deterministic APIs, on any platform.
  </Card>
</CardGroup>
