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

# Mobile

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

`@e2e-dev/mobile` runs tests on iOS simulators, Android emulators, and
phones connected to your machine. It is built on
[agent-device](https://github.com/callstack/agent-device). Tests use the
same `agent`, `screen`, and `expect` APIs as browser tests.

## Set up

Install Xcode with an iOS simulator runtime, or the Android SDK with an
emulator. Then follow the [Quickstart](https://e2e.tester.army/docs/quickstart) and choose **Mobile
(iOS/Android)**. The wizard selects iOS on macOS and Android elsewhere, and
targets the Settings app with one worker, so a test runs before you point it
at your app. Device boot happens before test timing starts and doesn't count
toward `launchTimeout`. See the [example projects](https://e2e.tester.army/docs/example-projects) for
finished setups.

Check your setup:

<CodeGroup>
```bash npm
npx e2e doctor
```

```bash pnpm
pnpm exec e2e doctor
```

```bash bun
bunx e2e doctor
```
</CodeGroup>

It checks agent-device, Xcode or the Android SDK, each device the target
names, and the `appPath` build. Without `appPath`, it checks that the
`bundleId` is installed when one device is booted. See
[`e2e doctor`](https://e2e.tester.army/docs/reference/cli#e2e-doctor).

## Point at your app

Set the target's `app.bundleId` to your app's bundle id or package name.
`app.open()`, `app.restart()`, and `app.clearState()` launch that app. Add
`appPath` to name a build, an `.app` bundle for iOS or an `.apk` for
Android:

```ts
{
  name: 'ios',
  engine: mobile({ platform: 'ios' }),
  app: {
    bundleId: 'com.example.app',
    appPath: './build/MyApp.app', // the iOS .app bundle
  },
}
```

A device target needs `bundleId`, `appPath`, or both. The engine installs
nothing on its own. Call `device.installApp()` once per device, for example
in a fixture every test takes. With no path, it installs `app.appPath`.
Without `bundleId`, `app.open()` launches the app the build installed.

To run the suite in CI, see [Mobile jobs](https://e2e.tester.army/docs/ci#mobile-jobs).

### Launch arguments and permissions

`app.launchArguments` and `app.permissions` apply to every fresh launch.

```ts
app: {
  bundleId: 'com.example.app',
  launchArguments: ['-e2e', 'YES'],
  permissions: { camera: 'grant', notifications: 'deny', location: 'reset' },
},
```

- Launch arguments are the process arguments on iOS and `am start`
  arguments on Android.
- Permissions are set before the launch, so no system prompt appears.
  `app.clearState()` resets them with the data, then sets them again.
- `deny` on iOS tells the app no without a prompt. On Android it revokes
  the permission, so the OS asks again.
- The warm-up before the run passes neither, so start each test with
  `app.open()`.
- Arguments that select a build mode change what the app is. Give each mode
  its own `app.identity` so its recordings stay apart.

To launch one test with its own, use `device.openApp`. Each option it passes
replaces the target's whole value: `permissions: { camera: 'deny' }` drops
the target's other permissions. On a relaunch of the target's app, options
it doesn't pass keep the target's values:

```ts
await device.openApp('com.example.app', { relaunch: true, permissions: { camera: 'deny' } });
```

### Expo development builds

A development build (`expo-dev-client`) opens on the dev launcher and loads
its JavaScript from a dev server. On an iOS simulator, these launch
arguments open the server directly and turn off the dev menu, its
onboarding sheet, and its floating button. `app.command` starts the server:

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

export default {
  targets: [
    {
      engine: mobile({ platform: 'ios' }),
      app: {
        bundleId: 'com.example.app',
        launchArguments: [
          '--initialUrl', 'http://localhost:8081',
          '-EXDevMenuShowsAtLaunch', 'NO',
          '-EXDevMenuIsOnboardingFinished', 'YES',
          '-EXDevMenuShowFloatingActionButton', 'NO',
        ],
        command: { executable: 'npx', args: ['expo', 'start', '--port', '8081'] },
        readyUrl: 'http://localhost:8081/status',
      },
    },
  ],
} satisfies E2EConfig;
```

- The warm-up leaves the app on the dev launcher. Start each test with
  `app.open()`.
- The first launch waits for the dev server to bundle the app. Give the
  test's first check a longer timeout.
- On Android, expo-dev-launcher doesn't read `--initialUrl`. Open the server
  with `device.openLink` and the build's
  `exp+<slug>://expo-development-client/?url=<encoded server URL>` link.

## Write a device test

`screen` maps native controls to common roles such as `button`, `textbox`,
`listitem`, and `switch`. Use `getByTestId` for native element identifiers.

```ts

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

Between tests, the app stays where the last test left it. Devices don't
support `selectOption`, `setInputFiles`, `scrollIntoView`, `secondaryTap`,
or `modifiers` on `tap` and `doubleTap`. Use taps and swipes on native
controls.

### Test ids by framework

`getByTestId` reads the accessibility identifier on iOS and the resource id
on Android. Each UI framework sets them its own way:

| Framework | Set the id with | Example |
| --- | --- | --- |
| React Native | `testID` | [with-expo](https://github.com/tester-army/e2e/tree/main/examples/with-expo) |
| SwiftUI | `.accessibilityIdentifier()` | [with-swiftui](https://github.com/tester-army/e2e/tree/main/examples/with-swiftui) |
| Jetpack Compose | `Modifier.testTag()` | [with-compose](https://github.com/tester-army/e2e/tree/main/examples/with-compose) |
| Compose Multiplatform | `Modifier.testTag()` | [with-kotlin-multiplatform](https://github.com/tester-army/e2e/tree/main/examples/with-kotlin-multiplatform) |
| Flutter | `Semantics(identifier:)` | [with-flutter](https://github.com/tester-army/e2e/tree/main/examples/with-flutter) |
| .NET MAUI | `AutomationId` | [with-maui](https://github.com/tester-army/e2e/tree/main/examples/with-maui) |

- On Android, a native `android:id` and a .NET MAUI `AutomationId` read as
  `<package>:id/<id>`. `getByTestId('greet')` finds them without the app's
  package, as on iOS. Framework ids such as `android:id/content` match only
  in full.
- On Android, Compose hides test tags until a root composable sets
  `Modifier.semantics { testTagsAsResourceId = true }`. Without it,
  `getByTestId` finds nothing. iOS reads a Compose Multiplatform tag as is,
  so a shared screen sets the flag in its Android activity.
- In Flutter, wrap a button's `Semantics` in `MergeSemantics` so the id and
  the button role land on one node.

## The device fixture

`device` changes device settings without a model call: network,
permissions, appearance, app installs, and more. Each call is a
`device.<method>` step in the report. The
[mobile engine reference](https://e2e.tester.army/docs/reference/mobile#device) lists every method.

`device.locator` takes an agent-device selector, as in the test above, for
controls that `screen` queries can't name.

`device.installApp` also tests upgrades and fresh installs:

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

`device.openApp` takes a bundle id, package, or display name, never a link.
`device.openLink` opens a deep link or a web link into the pinned app, or
the `app` in its options, for magic-link sign-in and deep-link routes:

```ts
await device.openLink('myapp://orders/42');
await expect(screen.getByText('Order #42')).toBeVisible();
```

`device.setNetwork('offline')` and `device.setAirplaneMode(true)` work on
Android only. An iOS simulator shares the Mac's network, and simctl can only
change its status bar, so on iOS both fail with `UNSUPPORTED_CAPABILITY`.
`setNetwork('online')` and `setAirplaneMode(false)` do nothing on iOS, so a
cleanup hook that both platforms run still passes.

On Android, `setNetwork('offline')` turns off Wi-Fi only, and the app can
stay online over cellular. To take the app offline, use
`setAirplaneMode(true)`. It needs Android 11 or later. The engine doesn't
restore the network after a test, so turn it back on in `afterEach`, which
runs when the test fails too:

```ts

afterEach(async ({ device }) => {
  await device.setAirplaneMode(false);
});

test('shows the offline banner', { platforms: ['android'] }, async ({ app, device, screen }) => {
  await app.open();
  await device.setAirplaneMode(true);
  await expect(screen.getByText('You are offline')).toBeVisible();
});
```

`device.clearKeychain()` resets the simulator's keychain, which
`app.clearState()` leaves alone, so a stored session token is gone on the
next launch. It is iOS only.

For anything `device` doesn't cover, `device.unstable_raw` has an
[agent-device](https://github.com/callstack/agent-device) client
bound to the test's session, plus the session name and the device's id and
name. It is unstable and may change or go away in any release:

```ts
const { client, deviceId } = device.unstable_raw;
const { nodes } = await client.capture.snapshot({});
```

Client calls aren't steps: the report and trace leave them out, the agent
cache doesn't replay them, secrets passed to them aren't redacted, and
nothing waits for the screen to settle after one.

<Accordion title="Foldable simulators and emulators">
`device.fold(pose)` puts the iPhone Duo simulator or a foldable Android
emulator (a Pixel Fold profile, or the generic "7.6in Foldable") in a hinge
pose. `closed` lights the outer display. `half-open` and `open` light the
inner one. On the Duo the call returns when the hinge reaches the pose,
typically after 10 to 16 seconds, so keep `actionTimeout` at 20 seconds or
more; `half-open` is a 130° book there. On an Android emulator it returns
within a few seconds, once Android commits the matching device state;
`half-open` is the profile's own angle (90° on the Pixel folds), and a fold
to the cover display dismisses the "Swipe up to continue" lock screen the
emulator raises. The next observation reads the app on the other display.

The engine never resets the pose, so a retry and the next test start where
the last test left it. Fold back in `afterEach`, which also runs when the
test fails:

```ts

afterEach(async ({ device }) => {
  await device.fold('closed');
});

test('the list and the detail split across the fold', async ({ agent, app, device }) => {
  await app.open();
  await device.fold('half-open');
  await agent.assert('the list and the detail sit on either side of the fold', { vision: true });
});
```
</Accordion>

### Record a transition

[`app.startRecording()` and `app.stopRecording()`](https://e2e.tester.army/docs/reference/app#startrecording)
record one span of the screen as an `.mp4`. A locator's `boundingBox()`
gives an element's frame in points (physical pixels on Android).

```ts
test('the sheet slides up', { video: 'off' }, async ({ app, screen }) => {
  await app.open();
  await app.startRecording({ name: 'sheet' });
  await screen.getByRole('button', 'Open sheet').tap();
  await expect(screen.getByText('Sheet title')).toBeVisible();
  const [video] = await app.stopRecording();
});
```

Start it after `app.open()`. A recording started after `device.closeApp()`
ends the device session when it stops, so open the app again after it.
Taps show unless [`videoTouches`](https://e2e.tester.army/docs/reference/mobile#videotouches) is `false`.

## Several devices

Add a target for each platform:

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

const app = { bundleId: 'com.example.app' };

export default {
  targets: [
    { name: 'iphone', engine: mobile({ platform: 'ios' }), app },
    { name: 'pixel', engine: mobile({ platform: 'android' }), app },
  ],
  workers: 1,
} satisfies E2EConfig;
```

Tests run on both targets. Set `platforms: ['ios']` or
`platforms: ['android']` on a test with platform-specific labels or
behavior.

To run files in parallel, give a device pool and set `workers` to at least
its size. Each device gets one worker:

```ts
mobile({ platform: 'ios', device: ['iPhone 17', 'iPhone 17 Pro'] })
```

Without `device`, the engine uses booted devices, up to the worker limit.
If none are booted, it boots one. Two workers never drive the same device.

## Agent tools

The agent already has basic actions such as `tap`, `type`, and `scroll`.
Add `mobileTools` for app switching, free-form swipes, and system alerts.
Pass every device engine in the config to one call:

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

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

export default {
  targets: [
    { name: 'iphone', engine: iphone, app },
    { name: 'pixel', engine: pixel, app },
  ],
  agents: { default: { model: gateway('openai/gpt-6-luna-fast'), tools: mobileTools(iphone, pixel) } },
} satisfies E2EConfig;
```

Calls go to the engine that runs the current attempt. `open_app`, `swipe`,
and `alert` change device state, so the agent runs them live during a cache
replay. The [mobile engine reference](https://e2e.tester.army/docs/reference/mobile#agent-tools)
describes each tool. To add your own beside them, see
[Project tools](https://e2e.tester.army/docs/tools).

## Physical devices

agent-device drives a phone plugged into your machine as it drives a
simulator, with no other engine or option. List the devices it sees:

<CodeGroup>
```bash npm
npx agent-device devices --platform ios
```

```bash pnpm
pnpm exec agent-device devices --platform ios
```

```bash bun
bunx agent-device devices --platform ios
```
</CodeGroup>

Then name the phone in `device` as it is listed:

```ts
mobile({ platform: 'ios', device: 'QA iPhone' })
```

Use the name, not the id. The engine reads a `device` string as an id only
for a simulator UDID or an `emulator-<port>` serial. The first device with
a matching name wins, so give each phone a unique name. Without `device`, a
connected phone counts as booted and joins the pool beside booted
simulators.

Set the phone up one time:

<Tabs>
  <Tab title="iOS">
  - Pair and trust the phone, unlock it, and turn on Developer Mode.
  - agent-device builds and signs its XCTest runner onto the phone. Set
    `AGENT_DEVICE_IOS_TEAM_ID` to your team id and
    `AGENT_DEVICE_IOS_BUNDLE_ID` to a unique reverse-DNS id, such as
    `com.example.agentdevice.runner`. The signing profile must allow that id
    and `<id>.uitests`. The first run builds the runner and takes longer.
  - After you change these variables, run `npx agent-device daemon stop`.
    A running daemon keeps the environment it started with.
  - `app.appPath` must be a signed device build, not a simulator `.app`.

  `npx agent-device help physical-device` covers iOS signing in detail.
  </Tab>
  <Tab title="Android">
  - Turn on USB debugging and authorize the phone. `adb devices` lists it
    as `device`.
  - `app.appPath` is the same `.apk` as on an emulator.
  </Tab>
</Tabs>

On a phone, `localhost` in the app is the phone itself. Point the app at
your machine's network address, or on Android forward the port with
`adb reverse tcp:<port> tcp:<port>`.

<Accordion title="What doesn't work on a phone">
Some device controls need a simulator or emulator. On a phone, they fail
with agent-device's message.

- On an iPhone, every device setting is simulator-only: `app.permissions`,
  `device.setPermission`, `app.clearState()`, `device.setLocation`,
  `device.clearLocation`, `device.setAppearance`, the biometric methods, and
  `device.clearKeychain`. So are `device.clipboard`, `device.setClipboard`,
  and `device.fold`.
- For a fresh start on an iPhone, reinstall with
  `device.installApp(path, { reinstall: true })`. Set `app.bundleId` or the
  `app` option so agent-device knows which app to remove.
- agent-device drives an older iPhone without CoreDevice when
  `xcrun devicectl list devices` doesn't list it. Taps, typing, and the
  screen work. Installs, `--video`, `device.openLink`, and
  `app.launchArguments` fail. Install the build from Xcode and pin
  `app.bundleId`.
- On an Android phone, `device.setLocation` is emulator-only.
  `device.setBiometrics('fingerprint', ...)` works only where the phone
  exposes `cmd fingerprint`.
- `--video` on an iPhone is built from screenshots at about 15 frames a
  second, and drops frames.
</Accordion>

## Hosted devices

Use [Limrun](https://e2e.tester.army/docs/integrations/limrun) for hosted iOS simulators and Android
emulators with automatic app installation and device cleanup.

Set `device` to a `DeviceProvider` to run on devices a service hosts. Any
service that runs an agent-device daemon next to a simulator, an emulator,
or a phone works. The provider leases one device per worker slot when the
run starts, and releases it when the run ends.

For [Expo Simulators](https://e2e.tester.army/docs/integrations/eas), use `expoSimulators()` from
`@e2e-dev/eas`. For [TestMu AI](https://e2e.tester.army/docs/integrations/testmu), use `testmu()` from
`@e2e-dev/testmu`. For [BrowserStack](https://e2e.tester.army/docs/integrations/browserstack), use
`browserstack()` from `@e2e-dev/browserstack`. For another service, write a provider of a few dozen lines,
such as `hostedDevices` in the example below:

```ts
mobile({
  platform: 'ios',
  device: hostedDevices({ serviceUrl: 'https://devices.example.com', image: 'ios-18-iphone-17' }),
})
```

- Each worker leases its own session, and a service bills a session while
  it runs. Keep `workers` at what the suite can fill.
- The replay cache keys on `app.bundleId`, so recordings made locally
  replay on hosted devices.
- Leased devices warm up like local ones, before test timing starts.

<Accordion title="The DeviceProvider contract">
```ts
export interface DeviceProvider {
  /** Label in progress lines and error messages. */
  readonly name: string;
  acquire(request: DeviceRequest): Promise<DeviceLease>;
  release(lease: DeviceLease, context: DeviceReleaseContext): Promise<void>;
  record?(lease: DeviceLease, context: ProviderRecordContext): Promise<ProviderRecording>;
}

export interface DeviceLease {
  /** The provider's handle (a session id), handed back to `release`. */
  readonly id: string;
  /** The agent-device daemon the worker connects to; a lease names this, `client`, or both. */
  readonly daemon?: { readonly baseUrl: string; readonly authToken?: string };
  /** agent-device client configuration the worker's client is created with: a lease scope, a `stateDir`. */
  readonly client?: DeviceClientConfig;
  /** Device to select inside that daemon, when it hosts more than one. */
  readonly device?: string;
  /** Device to select inside that daemon by the id agent-device lists it under: a simulator UDID or an Android serial. */
  readonly deviceId?: string;
  /** Bundle id or package the provider installed itself; skips the target's `app.appPath`. */
  readonly installedApp?: string;
}
```

**`acquire`** runs for every worker slot at once, in the runner process.
The request carries `platform`, `runId`, `targetName`, `slot`, `slots`, `app` (the
target's `app.bundleId`), the resolved `appPath`, `agentDeviceVersion` (for
a service that starts a daemon of a chosen version), `projectRoot`, `env`,
an interrupt `signal`, and a `log` that reaches the reporter.

- Read tokens from `request.env`, not `process.env`. Read project files
  under `request.projectRoot`, not `process.cwd()`.
- A target with nothing to run leases nothing.
- Set `installedApp` only when the request has an `appPath`. Otherwise the
  run fails.

**`release`** runs after the last worker exits, within `cleanupTimeout`, on
every exit path: a failure, an interrupt, or a `prepare` that threw. It gets
the object `acquire` returned, so a provider can keep its own fields on it.
Only the `DeviceLease` fields reach the workers. When one slot fails to
lease, the run ends before any test, and the engine still releases every
lease it got.

**`client`** is agent-device client configuration, as JSON data, applied on
top of the session name and the `daemon` address. A lease that sets
`session`, `daemonBaseUrl`, `daemonAuthToken`, or `daemonTransport` is
refused. For agent-device's own device-cloud runtimes, `client` carries the
lease scope that `leases.allocate` returned: `tenant`, `runId`, `leaseId`,
`leaseBackend`, and `leaseProvider`. A command without a scope falls back
to the local devices. A `stateDir` in `client` points the worker at a
daemon the provider started, so no `daemon` address is needed.

**`record`** is optional. When an attempt records video, the worker calls
it instead of agent-device's screen recording. The worker may be another
process, so `record` gets only the lease fields, not state `acquire` kept.
It returns a `ProviderRecording` (a type from `e2e/engine`) whose `stop`
either writes a file into the directory it gets and returns `{ file }`,
kept as `video/<file>`, or returns `{ url, mediaType }` for a recording the
service keeps, which the report links. A `record` that fails fails the
attempt's launch. A `stop` that fails is tried one more time when the
attempt ends.
</Accordion>

<Accordion title="Example: a provider for a session API">
A typical session API needs three requests: create a session, poll it until
the daemon is reachable, and stop it.

```ts title="device-provider.ts" expandable

/**
 * Leases devices from a service that starts an agent-device daemon per
 * session. Replace the three requests with your service's API.
 */
export function hostedDevices(options: { serviceUrl: string; image: string }): DeviceProvider {
  return {
    name: 'hosted-devices',
    async acquire(request) {
      const token = request.env['DEVICE_SERVICE_TOKEN'];
      if (token === undefined || token === '') throw new Error('DEVICE_SERVICE_TOKEN is not set');
      const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
      // 1. Ask for a device; name the session after the run so it can be found later.
      const created = await fetch(`${options.serviceUrl}/sessions`, {
        method: 'POST',
        headers,
        body: JSON.stringify({
          platform: request.platform,
          image: options.image,
          name: `e2e ${request.runId} ${request.targetName} ${request.slot + 1}/${request.slots}`,
        }),
        signal: request.signal,
      });
      if (!created.ok) throw new Error(`device service: HTTP ${created.status}`);
      const { id } = (await created.json()) as { id: string };
      // 2. Wait until the session exposes its agent-device daemon.
      for (;;) {
        const response = await fetch(`${options.serviceUrl}/sessions/${id}`, { headers, signal: request.signal });
        const session = (await response.json()) as { status: string; daemonUrl?: string; daemonToken?: string; viewerUrl?: string };
        if (session.status === 'ready' && session.daemonUrl !== undefined) {
          if (session.viewerUrl !== undefined) request.log(`watch at ${session.viewerUrl}`);
          return { id, daemon: { baseUrl: session.daemonUrl, authToken: session.daemonToken } };
        }
        if (session.status !== 'starting') throw new Error(`device service: session ${id} is ${session.status}`);
        await new Promise((resolve) => setTimeout(resolve, 5_000));
      }
    },
    async release(lease, context) {
      // 3. Stop the session; the run waits for this within cleanupTimeout.
      const token = context.env['DEVICE_SERVICE_TOKEN'] ?? '';
      await fetch(`${options.serviceUrl}/sessions/${lease.id}/stop`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${token}` },
        signal: context.signal,
      });
    },
  };
}
```
</Accordion>

<Accordion title="Example: a device cloud that agent-device supports">
A device cloud that agent-device itself speaks to needs no session API of
your own. `agentDeviceLeases()` from `@e2e-dev/mobile/lease` starts an
agent-device daemon per worker slot with the credentials in `env`, allocates
a lease with the `selectors`, keeps it alive while the run holds it, and on
release fails unless agent-device confirms the session ended. `testmu()`
from `@e2e-dev/testmu` works this way for [TestMu AI](https://e2e.tester.army/docs/integrations/testmu),
and `browserstack()` from `@e2e-dev/browserstack` for
[BrowserStack](https://e2e.tester.army/docs/integrations/browserstack).

```ts title="device-cloud-provider.ts" expandable

/**
 * Leases devices from a device cloud agent-device itself speaks to. Each
 * worker slot gets its own agent-device daemon, whose runtime for the cloud
 * starts the session when the lease is allocated and ends it on release.
 * `provider` is agent-device's name for the cloud.
 */
export function cloudDevices(options: { provider: string; device: string; osVersion: string; app: string }): DeviceProvider {
  const leases = agentDeviceLeases({ provider: options.provider, label: options.provider });
  return {
    name: options.provider,
    async acquire(request) {
      const token = request.env['CLOUD_TOKEN'];
      if (token === undefined || token === '') throw new Error('CLOUD_TOKEN is not set');
      return leases.acquire(request, {
        // The daemon's runtime reads the credentials from its environment.
        env: { CLOUD_TOKEN: token },
        selectors: { device: options.device, providerOsVersion: options.osVersion, providerApp: options.app },
        summary: `${options.device}, ${request.platform} ${options.osVersion}`,
      });
    },
    release: (lease, context) => leases.release(lease, context),
  };
}
```
</Accordion>

## What differs from the web

| On the web | On a device |
| --- | --- |
| `app.open('/path')` and the agent's `navigate` verb | No app URL and no `navigate`. `app.open()` launches `app.bundleId` fresh, and `device.openLink(url)` opens a link into it |
| `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 |
| Secret input | `type_secret` and `screen.getByLabel(...).fill(secret)` fill declared secrets into secure fields |
| `--video` writes a WebM screencast | `--video` writes an MP4 of the device screen with taps shown (`videoTouches: false` hides them), or keeps a device provider's own recording |
| The cache anchors on the page path | The cache anchors on the location `<bundle id> / <screen title>`, the title read off the navigation bar on iOS and the toolbar on Android |

### Known device limitations

- `placeholder` is the only attribute a device node has.
- A React Native tab (`accessibilityRole="tab"`) on iOS has role `other`.
  Query it by test id or label and assert `toBeSelected`. Checkboxes,
  radios, and `toBeChecked` work on both platforms.
- On iOS, a plain React Native `View` has no children in the tree, so
  `filter({ has })` on it and queries scoped to it match nothing, while
  `filter({ hasNot })` keeps every such row, including one you meant to
  exclude. Scope to the screen's `ScrollView`
  (`device.locator('role=scroll-area')`), or give the children test ids.
- A Jetpack Compose clickable drawn smaller than 48dp shows as two buttons
  in the tree, and a tap fails with `has no parent-owned touch point`. Make
  the control at least 48dp, Android's minimum touch target.
- An element hidden with `display: none` isn't in the tree, so
  `toBeAttached` fails. Assert `toHaveCount(0)` or `toBeHidden`.
- `doubleTap()` on iOS is two taps about 285 ms apart. Give a double-tap
  detector a window of 350 ms or more.
- On iOS, `device.foregroundApp()` still names the session's app after
  `device.home()`. Check the screen instead.
- On an iPhone Duo, `--video` records the display that was lit when the
  recording started. After `device.fold` moves the app to the other display,
  the rest of the video is black.

## Troubleshooting

A device failure shows agent-device's own message. Under the agent, an
engine fault with no more specific code reports as `APP_UNREACHABLE`, so
read the message before the code: often the app isn't what failed.

**The iOS automation runner is busy, wedged, or can't present a
snapshot.** A heavy or animating screen can overrun the runner, and a run
stopped with Ctrl-C can leave it busy. The message names the runner and
the first move, usually a rerun. If the next run meets it again, stop the
daemon and reboot the simulator with the commands below.

**`device.installApp()` on Android reports no bundle id or package.**
agent-device couldn't tell which package the build is, so `app.open()` has
nothing to launch. When it can't read the APK's manifest, it falls back to
the Android SDK's `aapt`, found under `ANDROID_SDK_ROOT`, `ANDROID_HOME`,
`~/Library/Android/sdk` on macOS, or `~/Android/Sdk`. Set the target's
`app.bundleId`. Or export `ANDROID_HOME`, then stop the agent-device daemon
so the next run starts it with the variable.

**Android reports insufficient foreground app content, or iOS reports the
app is not running.** agent-device couldn't read the screen yet, often an
app still loading after a cold start. On iOS this only counts until the
first readable screen after `app.open()`, `app.restart()`, or
`device.openLink()`, as on a physical iPhone that is still launching the
app; an app that exits later fails at once. A waiting locator action or
`expect` waits through it, as for an element not there yet. If the screen
is still unreadable when the wait runs out, the step fails with this
message. Immediate reads like `count()` and `isVisible()` see no matches
instead, so `not.toBeVisible()` can pass while the screen is unreadable,
including on an app that crashed before it rendered. An observation, such
as the agent's next turn, fails on it at once.

**A timeout on the device.** A simulator boot, runner connection, or app
launch that times out on the device is `ENGINE_FAILURE` with agent-device's
message and hint, never `LAUNCH_TIMEOUT`, `ACTION_FAILED`, or
`STEP_TIMEOUT`.

**An action that outlives its timeout.** A tap, fill, key, swipe, or
`back` gets its `timeout` (default `actionTimeout`). If the device doesn't
answer in time, the step fails with `ACTION_FAILED` and isn't retried: the
input may have landed. A `longPress` that holds at least as long as the
time left is refused before it's sent; raise its `timeout`. Snapshots and
screenshots run within the timeout of the call that takes them, so a device
that stops answering fails the step with `ACTION_FAILED` instead of holding
it until the test timeout. `locator.waitFor`, `expect`, and
`toHaveScreenshot` give their last snapshot extra time, up to their slowest
one so far and within the test timeout, so a screen that shows up during it
still passes. An element that never shows up fails as
`LOCATOR_NOT_FOUND`, or `ASSERTION_FAILED` for `expect`.

<CodeGroup>
```bash npm
npx e2e doctor                   # agent-device, the toolchain, the devices, the app
npx agent-device daemon stop     # after a wedge: drops every session, the next run starts the daemon
xcrun simctl shutdown <udid> && xcrun simctl boot <udid>
```

```bash pnpm
pnpm exec e2e doctor                   # agent-device, the toolchain, the devices, the app
pnpm exec agent-device daemon stop     # after a wedge: drops every session, the next run starts the daemon
xcrun simctl shutdown <udid> && xcrun simctl boot <udid>
```

```bash bun
bunx e2e doctor                   # agent-device, the toolchain, the devices, the app
bunx agent-device daemon stop     # after a wedge: drops every session, the next run starts the daemon
xcrun simctl shutdown <udid> && xcrun simctl boot <udid>
```
</CodeGroup>

## Next

<CardGroup cols={2}>
  <Card title="Mobile engine reference" icon="mobile-screen" href="/reference/mobile">
    Every option, the `device` fixture, and the tool pack.
  </Card>
  <Card title="Locators" icon="crosshairs" href="/core-concepts#locators">
    Exact taps, swipes, and checks with `screen` and `expect`, on any platform.
  </Card>
</CardGroup>
