> ## 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

> Mobile engine options and the device fixture for iOS and Android.

`@e2e-dev/mobile` is the engine for iOS simulators, Android emulators, and
connected phones, driven through
[agent-device](https://github.com/callstack/agent-device). It contributes the
`device` fixture. `agent`, `app`, `screen`, and `expect` work as they do
everywhere; this page covers what is specific to a device target.

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

export default {
  targets: [
    { name: 'iphone', engine: mobile({ platform: 'ios' }), app: { bundleId: 'com.apple.Preferences' } },
  ],
} satisfies E2EConfig;
```

```ts title="tests/settings.e2e.ts"

test('General opens in dark mode', async ({ app, device, screen }) => {
  await device.setAppearance('dark');
  await app.open();
  await screen.getByText('General').tap();
  await expect(screen.getByText('About')).toBeVisible();
});
```

- Import `test` from `@e2e-dev/mobile` to have `device` typed. Declare
  `requires: ['device']` in a suite that also runs on the web.
- Choose **Mobile (iOS/Android)** in `e2e init` to add the package with a
  Settings example. iOS needs Xcode and a simulator; Android needs the Android
  SDK and an emulator.
- The package includes a compatible version of `agent-device`; no separate
  installation is needed.

## Reference

### Engine

`mobile(options)` creates an engine for one platform. Create one for each
platform you test, and use `platforms: ['ios']` or `platforms: ['android']`
on tests with platform-specific labels or operations.

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

const iphone = mobile({ platform: 'ios' });
const pixel = mobile({ platform: 'android' });

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

`mobile()` takes `MobileOptions`: which devices the engine drives and how.
The app under test is the target's `app`, not an engine option; see
[The app under test](#the-app-under-test).

| Option | Type | Default |
| --- | --- | --- |
| [`platform`](#platform) | `'ios' \| 'android'` | required |
| [`device`](#device-option) | `string \| readonly string[] \| DeviceProvider` | every booted device |
| [`session`](#session) | `string` | `e2e-<target name>` |
| [`snapshot`](#snapshot) | `'full' \| 'interactive'` | `'full'` |
| [`settle`](#settle) | `number \| false` | `150` |
| [`transition`](#transition) | `number` | `500` |
| [`videoTouches`](#videotouches) | `boolean` | `true` |

Every optional value also accepts `undefined`, so values read straight from
`process.env` need no conditional spread. Each entry below shows its member's
declaration.

| Throws | When |
| --- | --- |
| `INVALID_CONFIG` | An empty `device` list. |
| `INVALID_CONFIG` | An option `mobile()` does not take, such as `mobile({ app })` and the other old app options. The message names the nearest known option for a likely typo, else every option. |
| `INVALID_CONFIG` | `settle` or `transition` is not a non-negative integer of milliseconds (`settle` may also be `false`). |

#### `platform`

```ts
/** Platform the target's device runs. */
readonly platform: MobilePlatform;
```

```ts
mobile({ platform: 'android' });
```

- `'ios'` or `'android'`. Required.

#### `device` option

```ts
/**
 * Device to use: a simulator or emulator by name, simulator UDID, or
 * emulator serial (`emulator-5554`), or a connected phone by name. A list
 * is a pool: the engine declares one worker per entry and worker slot `n`
 * drives the `n`th, so `workers` at or above the pool size runs the target's
 * files across every device at once. Omitted, the pool is every booted device
 * of the platform at `prepare` that no other agent-device session holds, as
 * many as the run has slots; with none booted, agent-device boots one, and
 * with every booted one held, `prepare` fails. A `DeviceProvider` leases hosted devices
 * instead: one per worker slot at `prepare`, each driven through the daemon
 * and client configuration the lease names. A replacement worker resumes a
 * healthy agent-device session on the same lease. Forced worker shutdown
 * can close the session. `device.closeApp()` ends the agent-device session,
 * allowing another session to take the device. If agent-device has not
 * confirmed a recording stop, the engine resets the recording before the
 * next attempt. This reset also ends the session. The provider lease stays
 * held until run end. At run end, the engine closes its sessions and the
 * provider releases its leases.
 */
readonly device?: string | readonly string[] | DeviceProvider | undefined;
```

```ts
mobile({ platform: 'ios' }); // default: every booted iOS device no other agent-device session holds
mobile({ platform: 'ios', device: 'iPhone 17' });
mobile({ platform: 'android', device: 'emulator-5554' });
mobile({ platform: 'ios', device: ['iPhone 17', 'iPhone 17 Pro'] });
```

- Two workers never drive the same device, as long as each `device` entry
  names a different one.
- Without `device`, a booted simulator another tool holds through its own
  agent-device session is skipped. A booted device nothing holds, such as
  one you are using by hand, is still in the pool: name the device to keep
  the run off it.
- [Hosted devices](https://e2e.tester.army/docs/mobile#hosted-devices) has the `DeviceProvider` contract
  and an example provider.

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

const iphones = mobile({
  platform: 'ios',
  device: ['iPhone 17', 'iPhone 17 Pro'],
});

export default {
  targets: [{ name: 'ios', engine: iphones, app: { bundleId: 'com.example.app' } }],
  workers: 2,
} satisfies E2EConfig;
```

#### `session`

```ts
/**
 * agent-device session name, before the worker slot: slot `n` drives its
 * device under `<session>-<n>`, `e2e-<target name>-<n>` by default. One run
 * per session at a time: concurrent runs on the same session interleave
 * taps.
 */
readonly session?: string | undefined;
```

```ts
mobile({ platform: 'ios', session: 'checkout' }); // default: e2e-<target name>
```

#### `snapshot`

```ts
/**
 * What an observation captures. `full` (default) includes static text, so
 * judgments can read values; `interactive` keeps only actionable nodes and
 * is cheaper on screens with long lists.
 */
readonly snapshot?: 'full' | 'interactive' | undefined;
```

```ts
mobile({ platform: 'android', snapshot: 'interactive' }); // default: 'full'
```

#### `settle`

```ts
/**
 * UI stability window after a tap, fill, screen scroll, or back, in
 * milliseconds. Default 150. Raise it for slow transitions. `0` keeps only
 * the first re-observation; `false` skips the wait.
 */
readonly settle?: number | false | undefined;
```

```ts
mobile({ platform: 'ios', settle: 300 }); // default: 150
```

#### `transition`

```ts
/**
 * Wait before acting on a control that just appeared or moved, in
 * milliseconds. Default 500. Accessibility bounds cannot tell a sliding
 * control from a landed one (iOS reports the final frame at once, Android
 * the frame in flight), so the control is found again in a fresh snapshot
 * once the wait is over and acted on where it landed. Unmoved controls are
 * acted on immediately only when keyboard visibility is unchanged. Opening
 * or closing the keyboard also uses the remaining budget. Raise it for
 * slower transitions.
 */
readonly transition?: number | undefined;
```

```ts
mobile({ platform: 'ios', transition: 800 }); // default: 500
```

If the control changes during the wait, the action finds it again within its timeout before acting.

`back`, `home`, `alert`, `dismissKeyboard`, `setOrientation`, and `fold` on
[`device`](#device) change the screen, so they count as actions for this
budget the way a tap does. A control that arrives with the screen they reveal
waits out the budget before the next action lands on it.

`fill` and `clear` re-read the field before typing. Missing fields, changed
security state, or incomplete captures return `NODE_STALE` before mutation,
then retry after a fresh observation. Non-input targets return
`UNSUPPORTED_CAPABILITY`. An iOS `other`, the type of an element the platform
did not classify, is typed into when it holds a value or only text, since a
custom-drawn field (Compose Multiplatform, Flutter) can read that way.

#### `videoTouches`

```ts
/**
 * Whether video recordings draw agent-device's touch indicator where a
 * test tapped. Default `true`. `false` records the plain screen and skips
 * drawing the indicator when the recording stops, which on a hosted daemon
 * such as Expo Simulators' can outlast `cleanupTimeout`.
 */
readonly videoTouches?: boolean | undefined;
```

```ts
mobile({ platform: 'ios', videoTouches: false }); // default: true
```

#### The app under test

The app is the target's `app`: `bundleId`, `appPath`, `launchArguments`,
`permissions`, `identity`, and `environment` are documented under
[the app under test](https://e2e.tester.army/docs/reference/config#the-app-under-test).

```ts title="e2e.config.ts"
export default {
  targets: [{ name: 'ios', engine: mobile({ platform: 'ios' }), app: { appPath: './build/MyApp.app' } }],
} satisfies E2EConfig;
```

- A device target needs `app.bundleId` or `app.appPath`.
- `app.open()` launches the target's app fresh. An attempt launches nothing
  on its own, so a test starts where the previous one left the app.
- `app.open` takes no path on a device: a link goes through
  [`device.openLink`](#openlink).
- `app.appPath` is the build the suite runs against. The engine installs
  nothing on its own: [`device.installApp()`](#installapp) installs it where
  the suite says so. A device provider installs it on the device it leases.
- Without `bundleId`, the installed bundle id or package becomes the app
  `app.open()` launches.
- Declare an `identity` when the build path changes per run (a version in
  it), so recordings survive the rename.

| Throws | When |
| --- | --- |
| `INVALID_CONFIG` | At config load: no `app.bundleId` and no `app.appPath`; an `app.url` (not supported yet); a link in `bundleId` (a test opens one with `device.openLink`); an unknown permission name. |

#### Launch arguments and permissions

`app.launchArguments` and `app.permissions` ride every fresh launch of the
pinned app: `app.open()`, `app.restart()`, and `app.clearState()`, which
resets the permissions with the data.

```ts title="e2e.config.ts"
export default {
  targets: [
    {
      name: 'pixel',
      engine: mobile({ platform: 'android' }),
      app: {
        bundleId: 'com.example.app',
        launchArguments: ['--ez', 'e2e', 'true'],
        permissions: { camera: 'grant', notifications: 'deny' },
      },
    },
  ],
} satisfies E2EConfig;
```

- iOS hands the arguments to the app process
  (`ProcessInfo.processInfo.arguments`). Android appends them to `am start`,
  so `['--ez', 'e2e', 'true']` is a boolean intent extra.
- Each permission is `grant`, `deny`, or `reset`. `deny` is a recorded
  refusal on iOS, where the app is told no without a prompt, and a revocation
  on Android, where the OS asks again when the app requests the permission.
- The permissions are set on the app by name before it starts, with no open
  first, since iOS terminates a running app whose permission changed and
  Android one whose permission was revoked.
- The warm-up passes no launch argument and touches no permission.
- [`device.openApp(app, { relaunch: true, launchArguments, permissions })`](#openapp)
  does the same for one launch of any app.

| Throws | When |
| --- | --- |
| `INVALID_CONFIG` | In `app.permissions`: a permission name outside `DevicePermission` (naming the nearest one when it is a likely typo), or a state other than `grant`, `deny`, or `reset`. Fails before any device command. |
| `INVALID_ARGUMENT` | The same, in `device.openApp`. |

#### Boot and prepare

Devices boot in `prepare`, once per run and before the run's clock starts,
so a cold boot on CI is never charged against `launchTimeout`.

- [`device`](#device-option) says which devices `prepare` uses and how many
  workers the engine declares.
- On iOS, `prepare` starts the automation runner on every device with
  agent-device's `prepare ios-runner`, so its first launch is paid before the
  first test. Then it brings the target's `app.bundleId` up once per device
  when it is installed. A build the suite installs itself comes up with the
  first `app.open()`, which relaunches it fresh whenever a test asks.
- A device that cannot boot, or a runner that is busy, wedged, or past its
  watchdog, ends the run in `prepare`.
- A runner that does not start for another reason is logged and left to the
  first attempt, and that device's app is not opened there.

<Note>
agent-device commands take no abort signal, so a timed-out command keeps
running on the device. The next attempt waits for it to settle.
</Note>

#### Capabilities

| Contract member | On a device |
| --- | --- |
| `observe` | The accessibility tree with rects. iOS: `Button` is `button`, `TextField` and `SearchField` are `textbox`, `SecureTextField` is a secure `textbox`, `Cell` is `listitem`, `StaticText` is `text`, `Switch` is `switch` with `checked`, `TabBar` and `SegmentedControl` are `tablist` and the buttons inside them `tab`, `ProgressIndicator` is `progressbar`, `ActivityIndicator` is `status`, `Stepper` is `spinbutton`, `Toolbar`, `Menu`, and `RadioGroup` are `toolbar`, `menu`, and `radiogroup`. Android: `TextView` is `text`, `EditText` is `textbox` and one UIAutomator flags as `password` is a secure `textbox` like `SecureTextField`, `Switch` and `CheckBox` keep their roles and `RadioButton` is `radio`, each with the `checked` state the device reports, layouts are `group`, `RecyclerView` and `AbsListView` (React Native's `FlatList`) are `list`, `TabLayout` and `BottomNavigationView` are `tablist` with their items as `tab`, `ProgressBar` is `progressbar`, `NumberPicker` is `spinbutton`, `Toolbar` and `RadioGroup` keep their names. Any other type is kept verbatim in lower case (`picker-wheel`), so it answers no vocabulary role, unless the platform reports it editable, which makes it a `textbox`. React Native on iOS has no trait for `checkbox`, `radio`, and a few other roles and spells the role and state into the accessibility value instead (`checkbox, unchecked`, `radio button, checked`); such a node takes that role, `checked` and `expanded` from the state words, and reports only what follows them as its value. An iOS field keeps a value equal to its label (an unlabeled field is named by its text); Android's echo of a text view's label as its value is dropped. The accessibility identifier (iOS) or resource id (Android) is the node's `testId`. An Android node is named by the content description its app set (React Native's `accessibilityLabel`), else by its text, and its `text` is what it shows, so a labeled text view or a filled field answers `getByLabel` by its label and `getByText` by its text; a view flagged as a heading is a `heading`, and the role description React Native writes for a role Android has no class for (`Tab`, `Tab List`, `Radio Group`) gives the node that role. A field's hint is its `placeholder` attribute, showing or not. Compose Multiplatform reports no placeholder on iOS: an empty field appends the text it draws to its label (`Name, Ada Lovelace`) over one `StaticText` child, so that text is the field's `placeholder` and the child is left out. An empty field has no value: Android flags a showing hint, and XCTest reports an empty iOS field's placeholder as its value, so an iOS field whose value equals its placeholder (trimmed) reads empty, text typed to match the placeholder exactly included. One Android fact is agent-device's, not the platform's: `rect` and every point are the screen's physical pixels; the display density it reports is not applied yet. Android's status bar and navigation bar, the full-width `com.android.systemui` windows along the screen's edges and the narrow status icons (clock, signal, battery) agent-device reports on their own inside that edge band, are left out; the notification shade, system dialogs, and a heads-up notification or other popup inset from the sides stay. Pixels on request, with every secure node's bounds painted black. |
| `perform` | `tap`, `doubleTap`, `longPress`, `fill`, `clear`, `hover`, `check`, `uncheck`, `press` (`Enter`, `Space`, or one character), `swipe`, `dragTo`, and `focus` on editable fields. A tap on a `switch` or `checkbox` lands on its innermost control, since UIKit reports a settings row as one labelled switch with the real toggle as a child. `check` and `uncheck` need a `checked` state, which Android switches lack and a React Native checkbox or radio on iOS has. `doubleTap` is two presses, 284 to 285 ms apart on iOS (the cadence of agent-device's XCTest tap): agent-device's own double-tap gesture reaches a React Native `Pressable` as one press. A detector with a window of 300 ms or less can read the pair as two single taps. `longPress` holds for one second unless the test passes `duration`, past a React Native `Pressable`'s `delayLongPress` of 500 ms and the longer thresholds apps set; agent-device's own default hold is shorter and registers as a tap on those. `selectOption`, `setInputFiles`, `scrollIntoView`, and `secondaryTap` are not declared, and neither is `tapModifiers`, so `tap` or `doubleTap` with `modifiers` is `UNSUPPORTED_CAPABILITY`. An action on a node that a newer snapshot replaced finds the node again instead of acting on a different element. |
| `performAt` | `tap`, `doubleTap`, `longPress`, and `swipeTo` at a bare screen point, so `screen.tapAt`, `tap({ position })`, `screen.swipe({ from, to })`, and the agent's `tap_at` reach a control the tree does not list. No `hover`, `secondaryTap`, or `dragTo` at a point: touch has none without a node, so `hover_at` needs a listed control. |
| `keyboard` | `type` and `press` into the focused field, the agent's `type` and `press` without a target and the fallback of `type_at` and `press_at`, and `dismiss`, the agent's `dismiss_keyboard`. |
| `locate` | Every `screen` query. Text and label queries answer with the innermost match, since a device echoes both up the tree (a React Native input is a host view and the field inside it, both labelled). On Android a test id query, a string or a RegExp, also matches a resource id without its `<package>:id/` prefix, for any package but the framework's `android`, when nothing on the screen matches the full id. `screen.locator('id=…')` is agent-device's selector and takes the full id. |
| `validateApp` | Requires `app.bundleId` or `app.appPath` and refuses `app.url` for now, with a note on reaching this machine from the platform's device. A target with either is a `native-app` target, so `requires: ['native-app']` selects device targets. |
| `session` | `back`, `restart`, and `reset`. No `open`: `app.open()` takes no path and the runner serves it with `restart`, the fresh launch of the pinned app; the agent's `navigate` verb is unavailable, and `device.openLink(url)` opens a deep link or web link. |
| `artifacts` | Redacted screenshots under `screenshots/NNN-<label>.png`; a screenshot that cannot be redacted fails instead of being written. Video, when the attempt's [video mode](https://e2e.tester.army/docs/reference/config#video) records, as `video/video.mp4`, or a device provider's own recording. |
| `location` | `<bundle id> / <screen title>`, the replay cache's anchor. The title is the navigation bar's on iOS and the toolbar's on Android. |
| `state` | Not declared. `test.setup` and `session:` are unavailable. |

### Device

The `device` fixture is deterministic device management. A test calls it
directly to arrange or assert device state.

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

| Member | Returns |
| --- | --- |
| [`unstable_raw`](#unstable_raw) | `DeviceSession` |
| [`locator`](#locator) | `Locator` |
| [`setNetwork`](#setnetwork) | `Promise<void>` |
| [`setAirplaneMode`](#setairplanemode) | `Promise<void>` |
| [`setPermission`](#setpermission) | `Promise<void>` |
| [`setLocation`](#setlocation) | `Promise<void>` |
| [`clearLocation`](#clearlocation) | `Promise<void>` |
| [`setAppearance`](#setappearance) | `Promise<void>` |
| [`setOrientation`](#setorientation) | `Promise<void>` |
| [`fold`](#fold) | `Promise<void>` |
| [`setBiometrics`](#setbiometrics) | `Promise<void>` |
| [`enrollBiometrics`](#enrollbiometrics) | `Promise<void>` |
| [`installApp`](#installapp) | `Promise<InstalledApp>` |
| [`openApp`](#openapp) | `Promise<void>` |
| [`openLink`](#openlink) | `Promise<void>` |
| [`closeApp`](#closeapp) | `Promise<void>` |
| [`clearKeychain`](#clearkeychain) | `Promise<void>` |
| [`foregroundApp`](#foregroundapp) | `Promise<ForegroundApp>` |
| [`home`](#home) | `Promise<void>` |
| [`back`](#back) | `Promise<void>` |
| [`alert`](#alert) | `Promise<void>` |
| [`dismissKeyboard`](#dismisskeyboard) | `Promise<void>` |
| [`clipboard`](#clipboard) | `Promise<string>` |
| [`setClipboard`](#setclipboard) | `Promise<void>` |

Every async method is a recorded step named `device.<method>`, bounded by
`config.actionTimeout`. `back`, `home`, `alert`, `dismissKeyboard`,
`setOrientation`, and `fold` count as actions for the
[`transition`](#transition) budget. Each entry below shows its member's
declaration; the types they use are under [Types](#device-fixture).

| Throws | When |
| --- | --- |
| `APP_NOT_OPEN` | The session is lost. |
| `ACTION_FAILED` | `actionTimeout` expires. |
| `UNSUPPORTED_CAPABILITY` | The platform cannot run the command. |
| `ENGINE_FAILURE` | Anything else, with agent-device's message. A timeout the device itself reports (a boot, the runner connecting) keeps its text and hint there. A busy or wedged iOS automation runner names itself and the recovery (see [Troubleshooting](https://e2e.tester.army/docs/mobile#troubleshooting)). |

#### `unstable_raw`

```ts
/**
 * The agent-device session this test drives, for what the methods here do
 * not cover. Calls through `client` bypass e2e: they are not steps, so the
 * report and trace leave them out, the agent's cache does not replay them,
 * their arguments reach agent-device unredacted, and nothing waits for the
 * screen to settle after one. Unstable: it may change or go away in any
 * release.
 */
readonly unstable_raw: DeviceSession;
```

```ts
const { client, session, deviceId } = device.unstable_raw;
const sessions = await client.sessions.list();
await client.apps.list(device.unstable_raw.selection);
```

`client` is agent-device's own client at the version `@e2e-dev/mobile` pins.
After the worker disposes the session, reading `unstable_raw` or `client`
throws `INVALID_STATE`.

#### `locator`

```ts
/**
 * A locator from an agent-device selector (`'id=SW_VERSION_SPECIFIER'`,
 * `'role=text label="26.2"'`), for nodes the closed `screen` query
 * vocabulary cannot name. Same polling and strictness as any locator.
 */
locator(selector: string): Locator;
```

```ts
await expect(device.locator('role=navigation-bar id=About')).toBeVisible();
await expect(device.locator('role=text label="iOS Version"')).toHaveText('iOS Version');
await device.locator('id=SW_VERSION_SPECIFIER role=button').tap();
```

Selector terms and their rules are agent-device's, the ones `agent-device
find ... list` applies:

- Terms: `id`, `role`, `text`, `label`, `value`, `appname`, `windowtitle`,
  and the flags `visible`, `hidden`, `editable`, `selected`, `focused`,
  `enabled`, `hittable`.
- `role` takes agent-device's kind, the role `agent-device snapshot` prints:
  `button`, `text`, `text-field`, `navigation-bar`, `scroll-area`, `switch`,
  `image`. It is not the `getByRole` role (`textbox`).
- Text terms compare case-insensitively after collapsing whitespace.
- `visible` means the node is hittable or has a non-empty frame; `hidden` is
  its opposite.
- `text` reads the first non-empty label, value, or identifier.

<Note>
An action on a selector that matches several nodes fails, where an
agent-device action skips an ambiguous `||` alternative.
</Note>

#### `setNetwork`

```ts
/**
 * Switches an Android device's Wi-Fi. Cellular data stays on, so an
 * emulator hands the app over to its cellular link; `setAirplaneMode(true)`
 * cuts both. On iOS `offline` is `UNSUPPORTED_CAPABILITY`, since a
 * simulator shares the Mac's network and simctl can only repaint its status
 * bar; `online` sends nothing there, so a cleanup hook shared with Android
 * passes.
 */
setNetwork(state: 'online' | 'offline'): Promise<void>;
```

```ts
await device.setNetwork('offline');
await device.setNetwork('online');
```

| Throws | When |
| --- | --- |
| `UNSUPPORTED_CAPABILITY` | `offline` on iOS. |

#### `setAirplaneMode`

```ts
/**
 * Switches an Android device's airplane mode, Wi-Fi and cellular together;
 * Android 11 or newer. On iOS, as for `setNetwork`, `true` is
 * `UNSUPPORTED_CAPABILITY` and `false` sends nothing.
 */
setAirplaneMode(enabled: boolean): Promise<void>;
```

```ts
await device.setAirplaneMode(true);
```

| Throws | When |
| --- | --- |
| `UNSUPPORTED_CAPABILITY` | `true` on iOS. |

#### `setPermission`

```ts
/**
 * Grants, denies, or resets one permission for the pinned app, which need
 * not be running. A change terminates a running app on iOS, a revoke one
 * on Android, so it goes before the `app.open()` a test starts with.
 */
setPermission(permission: DevicePermission, state: PermissionState): Promise<void>;
```

```ts
await device.setPermission('location', 'deny');
await app.open();
```

- `permission` is a [`DevicePermission`](#device-fixture); `state` is
  `grant`, `deny`, or `reset`.

#### `setLocation`

```ts
/** Sets the simulated location, switching location services on first on Android. */
setLocation(coordinates: { latitude: number; longitude: number }): Promise<void>;
```

```ts
await device.setLocation({ latitude: 52.23, longitude: 21.01 });
```

#### `clearLocation`

```ts
/** Turns simulated location off; on Android that is location services off, until the next `setLocation`. */
clearLocation(): Promise<void>;
```

```ts
await device.clearLocation();
```

#### `setAppearance`

```ts
/** Sets the system appearance. */
setAppearance(mode: 'light' | 'dark'): Promise<void>;
```

```ts
await device.setAppearance('dark');
```

#### `setOrientation`

```ts
/** Rotates the device. */
setOrientation(orientation: DeviceOrientation): Promise<void>;
```

```ts
await device.setOrientation('landscape-left');
```

- `'portrait'`, `'portrait-upside-down'`, `'landscape-left'`, or
  `'landscape-right'`.

#### `fold`

```ts
/**
 * Puts a foldable simulator or emulator in a hinge pose. On the iPhone Duo
 * it resolves once agent-device reads the angle back from CoreDevice at
 * that pose, typically 10 to 16 seconds later; `closed` lights the outer
 * display, `half-open` (a 130° book) and `open` light the inner one. On a
 * foldable Android emulator it sets the emulator console posture and
 * resolves once the guest commits the matching device state, within a few
 * seconds; `half-open` is the profile's own angle there (90° on the Pixel
 * folds), and a fold to the cover display dismisses the lock screen it
 * raises. Either way the app window changes size, so the next observation
 * reads a new screen. Any device without a hinge is `UNSUPPORTED_CAPABILITY`.
 */
fold(pose: FoldPose): Promise<void>;
```

```ts
await device.fold('half-open');
```

| Throws | When |
| --- | --- |
| `UNSUPPORTED_CAPABILITY` | The device has no hinge. |

#### `setBiometrics`

```ts
/** Simulates one biometric attempt on the open app. */
setBiometrics(sensor: BiometricSensor, result: 'match' | 'nonmatch'): Promise<void>;
```

```ts
await device.setBiometrics('faceid', 'nonmatch');
```

- `sensor` is `'faceid'`, `'touchid'`, or `'fingerprint'`.

#### `enrollBiometrics`

```ts
/** Enrolls or unenrolls the simulator's Face ID or Touch ID. */
enrollBiometrics(sensor: 'faceid' | 'touchid', enrolled: boolean): Promise<void>;
```

```ts
await device.enrollBiometrics('touchid', true);
```

#### `installApp`

```ts
/**
 * Installs a build (an iOS `.app` bundle or an Android `.apk`, resolved
 * against the project root) on the device; without a path, the target's
 * `app.appPath`. The engine installs nothing on its own, so a suite that runs
 * against a build calls this once per device, in a fixture or a test.
 * `reinstall: true` removes the app first so it starts with no data; a
 * plain install replaces the binary and keeps its data. Resolves to the
 * identity to `openApp` it by, which becomes the app `app.open()` launches
 * when the target's build is installed and no `app.bundleId` is pinned.
 */
installApp(appPath?: string, options?: InstallAppOptions): Promise<InstalledApp>;
```

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

- Use it for the run's build, or for another build on an upgrade path.
- The resolved id is the bundle id or package agent-device read from the
  build, else the `app` passed in.

| Throws | When |
| --- | --- |
| `ENGINE_FAILURE` | agent-device reported no id and no `app` was passed, instead of a file path posing as an id. See [Troubleshooting](https://e2e.tester.army/docs/mobile#troubleshooting). |

#### `openApp`

```ts
/**
 * Brings an app to the foreground by bundle id, package, or display name;
 * `relaunch` restarts it fresh. `launchArguments` and `permissions` apply
 * to this launch alone; the target's configured ones apply to a `relaunch`
 * of the pinned app, and to no foreground-only open. A link is
 * `INVALID_ARGUMENT` (`file:`, `data:`, `javascript:`, `view-source:`,
 * `blob:`, and `filesystem:` are `POLICY_DENIED`): `openLink` opens one.
 */
openApp(app: string, options?: OpenAppOptions): Promise<void>;
```

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

- A link is refused before the device sees it, since agent-device opens any
  `scheme:rest` string as a URL.

| Throws | When |
| --- | --- |
| `POLICY_DENIED` | A `file:`, `data:`, `javascript:`, `view-source:`, `blob:`, or `filesystem:` link. |
| `INVALID_ARGUMENT` | Any other link, with a message pointing at `openLink`; or an invalid permission name or state (see [Launch arguments and permissions](#launch-arguments-and-permissions)). |

#### `openLink`

```ts
/**
 * Opens a deep link (`myapp://orders/42`) or a web link (`https://...`)
 * into `app` (default: the pinned app), which the session observes
 * afterwards. iOS launches the app for a web link and then opens the URL;
 * a web link the app does not claim goes to Safari, and the next
 * observation brings the app back. Android starts the link on that
 * package. Without an app, Android lets the OS route the link; iOS needs
 * one (`INVALID_ARGUMENT` without). `file:`, `data:`, `javascript:`,
 * `view-source:`, `blob:`, and `filesystem:` links are `POLICY_DENIED`.
 */
openLink(url: string, options?: { app?: string }): Promise<void>;
```

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

- Use it for magic-link sign-in and deep-link routes.
- Without an app, Android routes the link to a verified app link owner or
  the browser, and the session follows the package that took it.
- A device link may use any app's custom scheme, so the rule refuses the
  schemes below rather than admitting http(s) alone as the web does.
- The report shows the link cut before its query and fragment, where a magic
  link carries its token.
- The replay cache anchors on the location the link lands on; see
  [Replay cache on a device](#replay-cache-on-a-device).

| Throws | When |
| --- | --- |
| `POLICY_DENIED` | A `file:`, `data:`, `javascript:`, `view-source:`, `blob:`, or `filesystem:` link. |
| `INVALID_ARGUMENT` | No app on iOS: an open bound to none leaves no app session to observe. |
| `INVALID_ARGUMENT` | A string that does not parse as an absolute URL. The message names no part of the input, which may carry a magic-link token in its query, path, or userinfo. |

#### `closeApp`

```ts
/**
 * Terminates the session's app and ends the session, so the next
 * `openApp` launches it fresh. Until then the screen is unobservable and
 * `foregroundApp` is `APP_NOT_OPEN`.
 */
closeApp(): Promise<void>;
```

```ts
await device.closeApp();
await device.openApp('com.example.app'); // launches fresh, on its first screen
```

- A screen query before the next `openApp` is `APP_NOT_OPEN` too.

#### `clearKeychain`

```ts
/**
 * Resets the simulator's keychain, every app's, since simctl has no
 * per-app reset: what `app.clearState()` leaves behind (a session token, a
 * Firebase sign-in). iOS simulator only; Android is
 * `UNSUPPORTED_CAPABILITY`. Launch the app afterwards to see it signed out.
 */
clearKeychain(): Promise<void>;
```

```ts
await device.clearKeychain();
await app.open(); // see it signed out
```

| Throws | When |
| --- | --- |
| `UNSUPPORTED_CAPABILITY` | On Android, before any device command. |

#### `foregroundApp`

```ts
/**
 * The app the session is on. iOS answers from the session, not the
 * device, so it names the app the session opened even after `home()`;
 * Android reads the device's foreground activity. `APP_NOT_OPEN` once
 * `closeApp` ended the session.
 */
foregroundApp(): Promise<ForegroundApp>;
```

```ts
const { name, bundleId } = await device.foregroundApp();
```

- On iOS, agent-device's `appstate` has no device-level answer.

| Throws | When |
| --- | --- |
| `APP_NOT_OPEN` | `closeApp` ended the session. |

#### `home`

```ts
/** Sends the device to its home screen. */
home(): Promise<void>;
```

```ts
await device.home();
```

#### `back`

```ts
/** Navigates back once (in-app back). */
back(): Promise<void>;
```

```ts
await device.back();
```

#### `alert`

```ts
/** Accepts or dismisses the visible system alert. */
alert(action: 'accept' | 'dismiss'): Promise<void>;
```

```ts
await device.alert('accept');
```

#### `dismissKeyboard`

```ts
/**
 * Dismisses the soft keyboard: the keyboard's own dismiss key where it has
 * one (Android, iPad), else the way a user does it on an iPhone, a short
 * drag that a scroll view turns into a dismissal, horizontal first and
 * vertical second, stopping as soon as the keyboard is gone. Each drag
 * starts at the point nearest the centre of the screen that no control
 * covers, inside a scroll view when the screen has one, so it never presses
 * a button or toggles a checkbox. The simulator's "Speed up your typing" tip
 * goes through its Continue button first. A screen with no clear point
 * above the keyboard, or a keyboard that outlives both drags, fails with
 * `UNSUPPORTED_CAPABILITY`; the message names the alternatives (the app's
 * own Done or close control, or pressing Enter on the field).
 */
dismissKeyboard(): Promise<void>;
```

```ts
await device.dismissKeyboard();
```

| Throws | When |
| --- | --- |
| `UNSUPPORTED_CAPABILITY` | Every point above the keyboard (in a scroll view, when the screen has one) is on a control, or the keyboard outlives both drags. |

#### `clipboard`

```ts
/** Reads the clipboard. */
clipboard(): Promise<string>;
```

```ts
const copied = await device.clipboard();
```

#### `setClipboard`

```ts
/** Writes the clipboard. */
setClipboard(text: string): Promise<void>;
```

```ts
await device.setClipboard('SAVE20');
```

### Agent tools

`mobileTools(...engines)` from `@e2e-dev/mobile/tools` adds device tools to
an agent's `tools`.

```ts
tools: mobileTools(iphone, pixel),
```

- It takes at least one engine. Pass all device engines in one call; the
  tools use the current attempt's engine.

| Tool | Replay | Purpose |
| --- | --- | --- |
| `open_app` | gap | Bring an app to the foreground by id, optionally relaunched. Without an id it returns to the target's app. A link is refused, as it is for `device.openApp`. |
| `swipe` | gap | Free-form swipe between two points in logical pixels. |
| `alert` | gap | Accept or dismiss a system alert; prefer a listed button, whose tap replays. |

The grammar verbs need no tool pack. The engine's declared actions give the
agent:

- `tap`, `double_tap`, `long_press`, `hover`, `type`, `press`, `check`,
  `scroll`, `drag`, `scroll_to` by `text` (paging the list until a row
  reading it is drawn), `back`, and `dismiss_keyboard`.
- The pixel tools `screenshot`, `tap_at`, `hover_at` (on a listed control
  only), `drag_at` (between two different listed controls only), `type_at`, and
  `press_at`. Point actions can tap controls absent from the accessibility
  tree.
- `type_secret`, when the step declares a secret.

There is no `right_click`, `select`, `scroll_to` by node id, `upload`, or
`navigate` on a device, and `select_at` is not offered.

<Note>
Pixels whose masking the engine cannot prove are withheld as `MASKING_UNPROVEN`. An accessibility
capture failure still stops the observation. Device screenshot masks depend
on secure-field bounds from that capture, so without them the engine cannot
prove a fallback screenshot safe.
</Note>

## Video recording cleanup

A provider recording that fails to stop stays owned by its worker. The
engine retries its stop at attempt end, before another attempt on that
worker, and during worker disposal. A pending stop is shared. Each retry
uses the original attempt's video directory.

The next attempt starts only after the provider confirms the stop. A
failed or cancelled stop wait prevents that attempt from starting.
Confirmed stops are not repeated if the returned artifact is invalid.
A failed provider stop alone does not close a healthy agent-device session
during worker replacement.

A recording recovered by a retry is not added to the report: its file can
stay in the original attempt's video directory, and a returned URL is
dropped. A required video whose first stop failed stays a cleanup failure.

These recording handles do not survive worker exit. At run end, the engine
closes its agent-device sessions and the provider releases its leases.
Providers must clean up any remaining external recordings when releasing a
lease. Closing an agent-device session cannot guarantee that an arbitrary
cloud recording stops. A failed provider stop callback does not confirm that
the recording stopped.

## Replay cache on a device

The replay cache anchors on the [`location`](#capabilities) a device reports,
`<bundle id> / <screen title>`. A step recorded on
`com.apple.Preferences / General` replays only when Settings is on General
again.

```ts
await app.open();
await screen.getByText('General').tap();
await agent.act('Open About and read the iOS version');
```

- A mutating project-tool call such as `open_app` ends a replay.
- Declare `app.bundleId` and call `app.open()` first, so steps start on the
  same screen.
- Switch apps through the `device` fixture between steps rather than inside
  one.
- A step recorded after [`device.openLink`](#openlink) replays when the link
  lands on the same screen again.

## Types

### Engine options

Each `MobileOptions` member is declared, with its JSDoc, in its
[Engine](#engine) entry.

```ts
/** A permission's state: held, refused, or not asked for yet, so the OS asks again. */
export type PermissionState = 'grant' | 'deny' | 'reset';

/** Permissions an app holds when it launches, by name. */
export type LaunchPermissions = Readonly<Partial<Record<DevicePermission, PermissionState>>>;
```

### Device fixture

Each `Device` member is declared, with its JSDoc, in its [Device](#device)
entry.

```ts
/** How `device.openApp` launches an app. */
export interface OpenAppOptions {
  /** Terminate the app first, so it starts fresh; without it a running app is brought forward as it is. */
  readonly relaunch?: boolean;
  /**
   * Arguments this launch hands the app: its process arguments on iOS,
   * `am start` arguments on Android. Replaces the target's `app.launchArguments`
   * for a relaunch of the pinned app.
   */
  readonly launchArguments?: readonly string[];
  /**
   * Permissions the app holds before this launch, each granted, denied, or
   * reset. Replaces the target's `app.permissions` for a relaunch of the
   * pinned app.
   */
  readonly permissions?: LaunchPermissions;
}

/** Permissions agent-device can grant, deny, or reset on an app. */
export type DevicePermission =
  | 'camera'
  | 'microphone'
  | 'photos'
  | 'contacts'
  | 'notifications'
  | 'calendar'
  | 'location'
  | 'reminders'
  | 'motion'
  | 'siri'
  | 'media-library';

/** Orientations `setOrientation` accepts. */
export type DeviceOrientation = 'portrait' | 'portrait-upside-down' | 'landscape-left' | 'landscape-right';

/** Hinge poses `device.fold` accepts: `closed` lights the outer display, `half-open` and `open` the inner one. */
export type FoldPose = 'closed' | 'half-open' | 'open';

/** How `device.installApp` installs a build. */
export interface InstallAppOptions {
  /**
   * Bundle id or package of the build. `reinstall` needs one: agent-device
   * removes that app before installing. Defaults to the pinned app.
   */
  readonly app?: string;
  /** Remove the installed app first so the build starts with no data. */
  readonly reinstall?: boolean;
}

/** What an install put on the device, as agent-device identified it. */
export interface InstalledApp {
  /** The bundle id or package to open the app by. */
  readonly app: string;
  readonly bundleId?: string;
}

/** The app in the foreground, as `foregroundApp` reports it. */
export interface ForegroundApp {
  readonly name: string;
  readonly bundleId?: string;
}

/** Sensors `setBiometrics` simulates. */
export type BiometricSensor = 'faceid' | 'touchid' | 'fingerprint';

/** The agent-device session behind `device`; see `Device.unstable_raw`. */
export interface DeviceSession {
  readonly platform: MobilePlatform;
  /** The agent-device session name: `e2e-<target>-<worker slot>`, or `<session>-<worker slot>` with the `session` option. */
  readonly session: string;
  /** The device's agent-device id: a simulator UDID or an Android serial. */
  readonly deviceId: string;
  readonly deviceName: string;
  /** The device selection the engine passes commands; spread it into a call that can run before `app.open()`. */
  readonly selection: DeviceSelection;
  /** agent-device's client, bound to this session. */
  readonly client: AgentDeviceClient;
}

/** The fields agent-device commands select a device by: the platform, then one of a name, a simulator UDID, or an Android serial. */
export interface DeviceSelection {
  readonly platform: MobilePlatform;
  readonly device?: string;
  readonly udid?: string;
  readonly serial?: string;
}
```

### Exported types

`MobileOptions`, `MobilePlatform`, `DevicePermission`, `LaunchPermissions`,
`PermissionState`, `DeviceClientConfig`, `DeviceConnection`, `DeviceDaemon`,
`DeviceProvider`, `DeviceLease`, `DeviceReleaseContext`, `DeviceRequest`,
`Device`, `DeviceSession`, `DeviceSelection`, `DeviceOrientation`, `BiometricSensor`, `ForegroundApp`,
`FoldPose`, `InstallAppOptions`, `InstalledApp`, and `OpenAppOptions` are exported from
`@e2e-dev/mobile`. The runtime exports are `mobile`, and `test`, `describe`,
`beforeEach`, `afterEach`, `beforeAll`, and `afterAll`, typed with the `device`
fixture; `mobileTools` comes from `@e2e-dev/mobile/tools`.

## Related

The [Mobile](https://e2e.tester.army/docs/mobile) guide covers setup, hosted devices, and troubleshooting.
The portable lifecycle methods are on [`app`](https://e2e.tester.army/docs/reference/app).
