e2e 0.21.1 → 0.21.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +2 -0
  2. package/dist/.build.json +1 -1
  3. package/dist/agent/act-validation.js +1 -0
  4. package/dist/agent/act.js +7 -3
  5. package/dist/agent/action-dispatcher.js +30 -8
  6. package/dist/agent/actions.d.ts +2 -0
  7. package/dist/agent/actions.js +5 -2
  8. package/dist/agent/error.js +1 -0
  9. package/dist/agent/executor.d.ts +14 -6
  10. package/dist/agent/index.js +5 -1
  11. package/dist/agent/invocation.d.ts +3 -0
  12. package/dist/agent/model/sdk.d.ts +5 -0
  13. package/dist/agent/model/sdk.js +18 -1
  14. package/dist/agent/observation.js +1 -0
  15. package/dist/agent/primitives.js +14 -3
  16. package/dist/agent/replay.js +9 -3
  17. package/dist/agent/step-cache.d.ts +3 -2
  18. package/dist/agent/step-cache.js +7 -4
  19. package/dist/agent/tool-loop.js +4 -1
  20. package/dist/cache/context.js +1 -1
  21. package/dist/cache/recorder.d.ts +2 -1
  22. package/dist/cache/recorder.js +11 -2
  23. package/dist/cache/trace-fields.d.ts +10 -0
  24. package/dist/cache/trace-fields.js +17 -0
  25. package/dist/cache/trace.d.ts +9 -1
  26. package/dist/cache/trace.js +26 -7
  27. package/dist/cli/feedback.js +2 -1
  28. package/dist/cli/init/sibling-versions.json +1 -1
  29. package/dist/collect/select.js +12 -2
  30. package/dist/config/agent.d.ts +1 -1
  31. package/dist/config/diagnose.js +9 -3
  32. package/dist/config/esm-hooks.js +3 -2
  33. package/dist/config/resolve.js +3 -2
  34. package/dist/config/typescript.js +52 -2
  35. package/dist/engine/contract.d.ts +3 -2
  36. package/dist/engine/manifest.js +2 -2
  37. package/dist/internal/errors.js +5 -2
  38. package/dist/internal/source.js +2 -0
  39. package/dist/internal/unserved-model.d.ts +5 -0
  40. package/dist/internal/unserved-model.js +22 -0
  41. package/dist/locator/screen.js +1 -2
  42. package/dist/oauth/copilot.js +23 -5
  43. package/dist/oauth/device-code.js +35 -19
  44. package/dist/oauth/errors.d.ts +3 -0
  45. package/dist/oauth/errors.js +26 -11
  46. package/dist/oauth/providers/github-copilot.d.ts +4 -0
  47. package/dist/oauth/providers/github-copilot.js +64 -16
  48. package/dist/oauth/providers/openai.d.ts +2 -2
  49. package/dist/oauth/providers/openai.js +47 -19
  50. package/dist/oauth/providers/opencode-console.js +36 -15
  51. package/dist/oauth/providers/xai.js +18 -9
  52. package/dist/oauth/token-endpoint.d.ts +15 -6
  53. package/dist/oauth/token-endpoint.js +18 -6
  54. package/dist/report/code-frame.js +9 -3
  55. package/dist/report/failure-text.d.ts +10 -2
  56. package/dist/report/failure-text.js +24 -8
  57. package/dist/report/list-model.d.ts +13 -0
  58. package/dist/report/list-steps.d.ts +6 -1
  59. package/dist/report/list-steps.js +29 -0
  60. package/dist/report/list.d.ts +1 -0
  61. package/dist/report/list.js +16 -7
  62. package/dist/report/live-window.js +2 -1
  63. package/dist/report/markdown.js +1 -1
  64. package/dist/run/fixtures.js +2 -0
  65. package/dist/run/host-trace.d.ts +3 -2
  66. package/dist/run/host-trace.js +2 -1
  67. package/dist/run/last-run.d.ts +9 -0
  68. package/dist/run/last-run.js +28 -0
  69. package/dist/run/open-session.d.ts +3 -1
  70. package/dist/run/open-session.js +1 -1
  71. package/dist/run/runner.js +6 -2
  72. package/dist/run/sessions.js +3 -3
  73. package/dist/run/standalone-attempt.d.ts +2 -1
  74. package/dist/run/standalone-attempt.js +3 -1
  75. package/dist/run/steps.d.ts +3 -1
  76. package/dist/run/units.d.ts +8 -0
  77. package/dist/run/units.js +14 -0
  78. package/dist/run/worker-models.d.ts +2 -0
  79. package/dist/run/worker-models.js +7 -0
  80. package/dist/types.d.ts +2 -2
  81. package/docs/authentication.mdx +26 -23
  82. package/docs/browser.mdx +7 -4
  83. package/docs/bug-bash.mdx +2 -2
  84. package/docs/cache.mdx +6 -1
  85. package/docs/ci/bitrise.mdx +1 -1
  86. package/docs/ci/codemagic.mdx +2 -2
  87. package/docs/ci/eas-workflows.mdx +1 -1
  88. package/docs/coding-agents.mdx +7 -35
  89. package/docs/debugging.mdx +24 -36
  90. package/docs/executors.mdx +1 -1
  91. package/docs/github.mdx +2 -1
  92. package/docs/integrations/eas.mdx +1 -1
  93. package/docs/integrations/index.mdx +6 -0
  94. package/docs/integrations/lightpanda.mdx +192 -0
  95. package/docs/integrations/testmu.mdx +152 -0
  96. package/docs/mcp.mdx +123 -0
  97. package/docs/migrate/detox.mdx +1 -1
  98. package/docs/migrate/maestro.mdx +101 -105
  99. package/docs/migrate/playwright.mdx +65 -70
  100. package/docs/mobile.mdx +18 -3
  101. package/docs/models.mdx +4 -2
  102. package/docs/quickstart.mdx +7 -0
  103. package/docs/reference/agent.mdx +369 -277
  104. package/docs/reference/app.mdx +141 -32
  105. package/docs/reference/cli.mdx +674 -558
  106. package/docs/reference/config.mdx +1406 -752
  107. package/docs/reference/engine.mdx +1062 -256
  108. package/docs/reference/environment.mdx +156 -84
  109. package/docs/reference/errors.mdx +106 -72
  110. package/docs/reference/expect.mdx +752 -372
  111. package/docs/reference/mcp.mdx +366 -141
  112. package/docs/reference/mobile.mdx +844 -403
  113. package/docs/reference/reporters.mdx +312 -106
  114. package/docs/reference/runner.mdx +373 -174
  115. package/docs/reference/screen.mdx +931 -398
  116. package/docs/reference/security.mdx +106 -40
  117. package/docs/reference/test.mdx +626 -273
  118. package/docs/reference/web.mdx +956 -533
  119. package/docs/security.mdx +6 -5
  120. package/docs/snippets/model-provider.jsx +763 -0
  121. package/docs/snippets/setup-prompt-card.jsx +22 -0
  122. package/docs/snippets/show-more.jsx +22 -0
  123. package/docs/snippets/video-card.jsx +34 -0
  124. package/docs/snippets/video.jsx +25 -0
  125. package/docs/subscriptions.mdx +10 -3
  126. package/docs/telemetry.mdx +39 -39
  127. package/package.json +3 -2
  128. package/schema/report-v1.schema.json +6 -0
  129. package/skills/e2e/references/agent.md +8 -3
  130. package/skills/e2e/references/debugging.md +1 -1
  131. package/skills/e2e/references/mcp.md +1 -1
  132. package/skills/e2e/references/setup.md +12 -3
  133. package/skills/e2e/references/writing-tests.md +1 -1
@@ -6,22 +6,50 @@ icon: "mobile-screen"
6
6
  "twitter:image": "https://e2e.tester.army/docs/images/og/reference/mobile.png?v=3da450961d"
7
7
  ---
8
8
 
9
- `@e2e-dev/mobile` drives iOS simulators, Android emulators, and connected
10
- phones through
11
- [agent-device](https://github.com/callstack/agent-device). `agent`, `app`,
12
- `screen`, and `expect` work as they do everywhere; this page covers what is
13
- specific to a device target. Import `test` from the package to have `device`
14
- typed, and declare `requires: ['device']` in a suite that also runs on the
15
- web.
9
+ `@e2e-dev/mobile` is the engine for iOS simulators, Android emulators, and
10
+ connected phones, driven through
11
+ [agent-device](https://github.com/callstack/agent-device). It contributes the
12
+ `device` fixture. `agent`, `app`, `screen`, and `expect` work as they do
13
+ everywhere; this page covers what is specific to a device target.
16
14
 
17
- Choose **Mobile (iOS/Android)** in `e2e init` to add the package with a
18
- Settings example. iOS needs Xcode and a simulator; Android needs the Android
19
- SDK and an emulator.
15
+ ```ts title="e2e.config.ts"
16
+ import type { E2EConfig } from 'e2e';
17
+ import { mobile } from '@e2e-dev/mobile';
18
+
19
+ export default {
20
+ targets: [
21
+ { name: 'iphone', engine: mobile({ platform: 'ios' }), app: { bundleId: 'com.apple.Preferences' } },
22
+ ],
23
+ } satisfies E2EConfig;
24
+ ```
25
+
26
+ ```ts title="tests/settings.e2e.ts"
27
+ import { expect } from 'e2e';
28
+ import { test } from '@e2e-dev/mobile';
29
+
30
+ test('General opens in dark mode', async ({ app, device, screen }) => {
31
+ await device.setAppearance('dark');
32
+ await app.open();
33
+ await screen.getByText('General').tap();
34
+ await expect(screen.getByText('About')).toBeVisible();
35
+ });
36
+ ```
37
+
38
+ - Import `test` from `@e2e-dev/mobile` to have `device` typed. Declare
39
+ `requires: ['device']` in a suite that also runs on the web.
40
+ - Choose **Mobile (iOS/Android)** in `e2e init` to add the package with a
41
+ Settings example. iOS needs Xcode and a simulator; Android needs the Android
42
+ SDK and an emulator.
43
+ - The package includes a compatible version of `agent-device`; no separate
44
+ installation is needed.
45
+
46
+ ## Reference
20
47
 
21
- The package includes a compatible version of `agent-device`; no separate
22
- installation is needed.
48
+ ### Engine
23
49
 
24
- ## Engine
50
+ `mobile(options)` creates an engine for one platform. Create one for each
51
+ platform you test, and use `platforms: ['ios']` or `platforms: ['android']`
52
+ on tests with platform-specific labels or operations.
25
53
 
26
54
  ```ts title="e2e.config.ts"
27
55
  import type { E2EConfig } from 'e2e';
@@ -47,140 +75,73 @@ export default {
47
75
  } satisfies E2EConfig;
48
76
  ```
49
77
 
50
- Create an engine for each platform you test. Use `platforms: ['ios']` or
51
- `platforms: ['android']` on tests with platform-specific labels or operations.
52
- `app.open()` launches the target's app fresh; an attempt launches nothing
53
- on its own, so a test starts where the previous one left the app. `app.open`
54
- takes no path on a device: a link goes through `device.openLink`.
78
+ `mobile()` takes `MobileOptions`: which devices the engine drives and how.
79
+ The app under test is the target's `app`, not an engine option; see
80
+ [The app under test](#the-app-under-test).
55
81
 
56
- The app under test is the target's `app`, not an engine option: `bundleId`,
57
- `appPath`, `launchArguments`, `permissions`, `identity`, and `environment`
58
- are documented under [the app under test](/reference/config#the-app-under-test).
59
- A device target needs `app.bundleId` or `app.appPath`. An `app.url` (not
60
- supported yet), a link in `bundleId` (a test opens one with
61
- `device.openLink`), and an unknown permission name are `INVALID_CONFIG` at
62
- config load. `mobile({ app })` and the other old app options fail naming
63
- their key under `app`.
64
-
65
- `app.appPath` is the build the suite runs against. The engine installs
66
- nothing on its own: `device.installApp()` with no path installs it, once per
67
- device, where the suite says so, and a device provider installs it on the
68
- device it leases. Without `bundleId`, the installed bundle id or package
69
- becomes the app `app.open()` launches. Declare an `identity` when the build
70
- path changes per run (a version in it) so recordings survive the rename.
82
+ | Option | Type | Default |
83
+ | --- | --- | --- |
84
+ | [`platform`](#platform) | `'ios' \| 'android'` | required |
85
+ | [`device`](#device-option) | `string \| readonly string[] \| DeviceProvider` | every booted device |
86
+ | [`session`](#session) | `string` | `e2e-<target name>` |
87
+ | [`snapshot`](#snapshot) | `'full' \| 'interactive'` | `'full'` |
88
+ | [`settle`](#settle) | `number \| false` | `150` |
89
+ | [`transition`](#transition) | `number` | `500` |
90
+ | [`videoTouches`](#videotouches) | `boolean` | `true` |
91
+
92
+ Every optional value also accepts `undefined`, so values read straight from
93
+ `process.env` need no conditional spread. Each entry below shows its member's
94
+ declaration.
95
+
96
+ | Throws | When |
97
+ | --- | --- |
98
+ | `INVALID_CONFIG` | An empty `device` list. |
99
+ | `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. |
100
+ | `INVALID_CONFIG` | `settle` or `transition` is not a non-negative integer of milliseconds (`settle` may also be `false`). |
71
101
 
72
- `mobile()` takes only how the device is driven:
102
+ #### `platform`
73
103
 
74
104
  ```ts
75
- /**
76
- * Options of the device engine: which devices it drives and how. The app
77
- * under test is the target's `app` (`bundleId`, `appPath`,
78
- * `launchArguments`, `permissions`). Every optional value also accepts
79
- * `undefined`, so values read straight from `process.env` need no
80
- * conditional spread.
81
- */
82
- export interface MobileOptions {
83
- /** Platform the target's device runs. */
84
- readonly platform: MobilePlatform;
85
- /**
86
- * Device to use: a simulator or emulator by name, simulator UDID, or
87
- * emulator serial (`emulator-5554`), or a connected phone by name. A list
88
- * is a pool: the
89
- * engine declares one worker per entry and worker slot `n` drives the
90
- * `n`th, so `workers` at or above the pool size runs the target's files
91
- * across every device at once. Omitted, the pool is every booted device of
92
- * the platform at `prepare`, as many as the run has slots; with none booted,
93
- * agent-device boots one. A `DeviceProvider` leases hosted devices instead:
94
- * one per worker slot at `prepare`, each driven through the daemon and
95
- * client configuration the lease names, all released when the run ends.
96
- */
97
- readonly device?: string | readonly string[] | DeviceProvider | undefined;
98
- /**
99
- * agent-device session name, before the worker slot: slot `n` drives its
100
- * device under `<session>-<n>`, `e2e-<target name>-<n>` by default. One run
101
- * per session at a time: concurrent runs on the same session interleave
102
- * taps.
103
- */
104
- readonly session?: string | undefined;
105
- /**
106
- * What an observation captures. `full` (default) includes static text, so
107
- * judgments can read values; `interactive` keeps only actionable nodes and
108
- * is cheaper on screens with long lists.
109
- */
110
- readonly snapshot?: 'full' | 'interactive' | undefined;
111
- /**
112
- * UI stability window after a tap, fill, screen scroll, or back, in
113
- * milliseconds. Default 150. Raise it for slow transitions. `0` keeps only
114
- * the first re-observation; `false` skips the wait.
115
- */
116
- readonly settle?: number | false | undefined;
117
- /**
118
- * Wait before acting on a control that just appeared or moved, in
119
- * milliseconds. Default 500. Accessibility bounds cannot tell a sliding
120
- * control from a landed one (iOS reports the final frame at once, Android
121
- * the frame in flight), so the control is found again in a fresh snapshot
122
- * once the wait is over and acted on where it landed. Unmoved controls are
123
- * acted on immediately. Raise this for slower transitions.
124
- */
125
- readonly transition?: number | undefined;
126
- /**
127
- * Whether video recordings draw agent-device's touch indicator where a
128
- * test tapped. Default `true`. `false` records the plain screen and skips
129
- * drawing the indicator when the recording stops, which on a hosted daemon
130
- * such as EAS Simulators' can outlast `cleanupTimeout`.
131
- */
132
- readonly videoTouches?: boolean | undefined;
133
- }
134
-
135
- /** A permission's state: held, refused, or not asked for yet, so the OS asks again. */
136
- export type PermissionState = 'grant' | 'deny' | 'reset';
105
+ /** Platform the target's device runs. */
106
+ readonly platform: MobilePlatform;
107
+ ```
137
108
 
138
- /** Permissions an app holds when it launches, by name. */
139
- export type LaunchPermissions = Readonly<Partial<Record<DevicePermission, PermissionState>>>;
109
+ ```ts
110
+ mobile({ platform: 'android' });
140
111
  ```
141
112
 
142
- An empty `device` list is `INVALID_CONFIG`, and so is an option `mobile()`
143
- does not take, naming the nearest known option when it is a likely typo and
144
- every option otherwise. A `device` provider object
145
- leases hosted devices; see [Hosted devices](/mobile#hosted-devices) for the
146
- `DeviceProvider` contract and an example provider.
113
+ - `'ios'` or `'android'`. Required.
147
114
 
148
- Devices boot in `prepare`, once per run and before the run's clock starts, so
149
- a cold boot on CI is never charged against `launchTimeout`. agent-device
150
- commands take no abort signal, so a timed-out command keeps running on the
151
- device; the next attempt waits for it to settle. On iOS, `prepare` starts the
152
- automation runner on every device with agent-device's `prepare ios-runner`,
153
- so its first launch is paid before the first test, then brings the target's
154
- `app.bundleId` up once per device when it is installed; a build the suite
155
- installs itself comes up with the first `app.open()`, which relaunches it
156
- fresh whenever a test asks. A runner that does not start in `prepare` is logged and left
157
- to the first attempt, and that device's app is not opened there.
115
+ #### `device` option
158
116
 
159
- `app.launchArguments` and `app.permissions` ride every fresh launch of the
160
- pinned app: `app.open()`, `app.restart()`, and `app.clearState()`, which
161
- resets the permissions with the data. iOS hands the arguments to the app
162
- process (`ProcessInfo.processInfo.arguments`); Android appends them to
163
- `am start`, so `['--ez', 'e2e', 'true']` is a boolean intent extra. `deny` is
164
- a recorded refusal on iOS, where the app is told no without a prompt, and a
165
- revocation on Android, where the OS asks again when the app requests the
166
- permission. The permissions are set on the app by name before it starts,
167
- with no open first, since iOS terminates a running app whose permission
168
- changed and Android one whose permission was revoked. The warm-up passes no
169
- launch argument and touches no permission.
170
- `device.openApp(app, { relaunch: true, launchArguments, permissions })` does
171
- the same for one launch of any app. A permission name outside
172
- `DevicePermission` (naming the nearest one when it is a likely typo) or a
173
- state other than `grant`, `deny`, or `reset` fails before any device command:
174
- `INVALID_CONFIG` in `app.permissions`, `INVALID_ARGUMENT` in
175
- `device.openApp`.
176
-
177
- The engine declares one worker per configured device. With no `device`,
178
- `prepare` uses the booted devices it finds, capped by the run's slots, and
179
- boots one when none are. Two workers never drive the same simulator. To
180
- split a target's files across devices, pass a pool and set `workers` to at
181
- least its size.
117
+ ```ts
118
+ /**
119
+ * Device to use: a simulator or emulator by name, simulator UDID, or
120
+ * emulator serial (`emulator-5554`), or a connected phone by name. A list
121
+ * is a pool: the engine declares one worker per entry and worker slot `n`
122
+ * drives the `n`th, so `workers` at or above the pool size runs the
123
+ * target's files across every device at once. Omitted, the pool is every booted device of
124
+ * the platform at `prepare`, as many as the run has slots; with none booted,
125
+ * agent-device boots one. A `DeviceProvider` leases hosted devices instead:
126
+ * one per worker slot at `prepare`, each driven through the daemon and
127
+ * client configuration the lease names, all released when the run ends.
128
+ */
129
+ readonly device?: string | readonly string[] | DeviceProvider | undefined;
130
+ ```
182
131
 
183
132
  ```ts
133
+ mobile({ platform: 'ios' }); // default: every booted iOS device
134
+ mobile({ platform: 'ios', device: 'iPhone 17' });
135
+ mobile({ platform: 'android', device: 'emulator-5554' });
136
+ mobile({ platform: 'ios', device: ['iPhone 17', 'iPhone 17 Pro'] });
137
+ ```
138
+
139
+ - Two workers never drive the same device, as long as each `device` entry
140
+ names a different one.
141
+ - [Hosted devices](/mobile#hosted-devices) has the `DeviceProvider` contract
142
+ and an example provider.
143
+
144
+ ```ts title="e2e.config.ts"
184
145
  import type { E2EConfig } from 'e2e';
185
146
  import { mobile } from '@e2e-dev/mobile';
186
147
 
@@ -195,160 +156,761 @@ export default {
195
156
  } satisfies E2EConfig;
196
157
  ```
197
158
 
198
- ### Capabilities
159
+ #### `session`
160
+
161
+ ```ts
162
+ /**
163
+ * agent-device session name, before the worker slot: slot `n` drives its
164
+ * device under `<session>-<n>`, `e2e-<target name>-<n>` by default. One run
165
+ * per session at a time: concurrent runs on the same session interleave
166
+ * taps.
167
+ */
168
+ readonly session?: string | undefined;
169
+ ```
170
+
171
+ ```ts
172
+ mobile({ platform: 'ios', session: 'checkout' }); // default: e2e-<target name>
173
+ ```
174
+
175
+ #### `snapshot`
176
+
177
+ ```ts
178
+ /**
179
+ * What an observation captures. `full` (default) includes static text, so
180
+ * judgments can read values; `interactive` keeps only actionable nodes and
181
+ * is cheaper on screens with long lists.
182
+ */
183
+ readonly snapshot?: 'full' | 'interactive' | undefined;
184
+ ```
185
+
186
+ ```ts
187
+ mobile({ platform: 'android', snapshot: 'interactive' }); // default: 'full'
188
+ ```
189
+
190
+ #### `settle`
191
+
192
+ ```ts
193
+ /**
194
+ * UI stability window after a tap, fill, screen scroll, or back, in
195
+ * milliseconds. Default 150. Raise it for slow transitions. `0` keeps only
196
+ * the first re-observation; `false` skips the wait.
197
+ */
198
+ readonly settle?: number | false | undefined;
199
+ ```
200
+
201
+ ```ts
202
+ mobile({ platform: 'ios', settle: 300 }); // default: 150
203
+ ```
204
+
205
+ #### `transition`
206
+
207
+ ```ts
208
+ /**
209
+ * Wait before acting on a control that just appeared or moved, in
210
+ * milliseconds. Default 500. Accessibility bounds cannot tell a sliding
211
+ * control from a landed one (iOS reports the final frame at once, Android
212
+ * the frame in flight), so the control is found again in a fresh snapshot
213
+ * once the wait is over and acted on where it landed. Unmoved controls are
214
+ * acted on immediately. Raise this for slower transitions.
215
+ */
216
+ readonly transition?: number | undefined;
217
+ ```
218
+
219
+ ```ts
220
+ mobile({ platform: 'ios', transition: 800 }); // default: 500
221
+ ```
222
+
223
+ `back`, `home`, `alert`, `dismissKeyboard`, `setOrientation`, and `fold` on
224
+ [`device`](#device) change the screen, so they count as actions for this
225
+ budget the way a tap does. A control that arrives with the screen they reveal
226
+ waits out the budget before the next action lands on it.
227
+
228
+ #### `videoTouches`
229
+
230
+ ```ts
231
+ /**
232
+ * Whether video recordings draw agent-device's touch indicator where a
233
+ * test tapped. Default `true`. `false` records the plain screen and skips
234
+ * drawing the indicator when the recording stops, which on a hosted daemon
235
+ * such as EAS Simulators' can outlast `cleanupTimeout`.
236
+ */
237
+ readonly videoTouches?: boolean | undefined;
238
+ ```
239
+
240
+ ```ts
241
+ mobile({ platform: 'ios', videoTouches: false }); // default: true
242
+ ```
243
+
244
+ #### The app under test
245
+
246
+ The app is the target's `app`: `bundleId`, `appPath`, `launchArguments`,
247
+ `permissions`, `identity`, and `environment` are documented under
248
+ [the app under test](/reference/config#the-app-under-test).
249
+
250
+ ```ts title="e2e.config.ts"
251
+ export default {
252
+ targets: [{ name: 'ios', engine: mobile({ platform: 'ios' }), app: { appPath: './build/MyApp.app' } }],
253
+ } satisfies E2EConfig;
254
+ ```
255
+
256
+ - A device target needs `app.bundleId` or `app.appPath`.
257
+ - `app.open()` launches the target's app fresh. An attempt launches nothing
258
+ on its own, so a test starts where the previous one left the app.
259
+ - `app.open` takes no path on a device: a link goes through
260
+ [`device.openLink`](#openlink).
261
+ - `app.appPath` is the build the suite runs against. The engine installs
262
+ nothing on its own: [`device.installApp()`](#installapp) installs it where
263
+ the suite says so. A device provider installs it on the device it leases.
264
+ - Without `bundleId`, the installed bundle id or package becomes the app
265
+ `app.open()` launches.
266
+ - Declare an `identity` when the build path changes per run (a version in
267
+ it), so recordings survive the rename.
268
+
269
+ | Throws | When |
270
+ | --- | --- |
271
+ | `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. |
272
+
273
+ #### Launch arguments and permissions
274
+
275
+ `app.launchArguments` and `app.permissions` ride every fresh launch of the
276
+ pinned app: `app.open()`, `app.restart()`, and `app.clearState()`, which
277
+ resets the permissions with the data.
278
+
279
+ ```ts title="e2e.config.ts"
280
+ export default {
281
+ targets: [
282
+ {
283
+ name: 'pixel',
284
+ engine: mobile({ platform: 'android' }),
285
+ app: {
286
+ bundleId: 'com.example.app',
287
+ launchArguments: ['--ez', 'e2e', 'true'],
288
+ permissions: { camera: 'grant', notifications: 'deny' },
289
+ },
290
+ },
291
+ ],
292
+ } satisfies E2EConfig;
293
+ ```
294
+
295
+ - iOS hands the arguments to the app process
296
+ (`ProcessInfo.processInfo.arguments`). Android appends them to `am start`,
297
+ so `['--ez', 'e2e', 'true']` is a boolean intent extra.
298
+ - Each permission is `grant`, `deny`, or `reset`. `deny` is a recorded
299
+ refusal on iOS, where the app is told no without a prompt, and a revocation
300
+ on Android, where the OS asks again when the app requests the permission.
301
+ - The permissions are set on the app by name before it starts, with no open
302
+ first, since iOS terminates a running app whose permission changed and
303
+ Android one whose permission was revoked.
304
+ - The warm-up passes no launch argument and touches no permission.
305
+ - [`device.openApp(app, { relaunch: true, launchArguments, permissions })`](#openapp)
306
+ does the same for one launch of any app.
307
+
308
+ | Throws | When |
309
+ | --- | --- |
310
+ | `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. |
311
+ | `INVALID_ARGUMENT` | The same, in `device.openApp`. |
312
+
313
+ #### Boot and prepare
314
+
315
+ Devices boot in `prepare`, once per run and before the run's clock starts,
316
+ so a cold boot on CI is never charged against `launchTimeout`.
317
+
318
+ - [`device`](#device-option) says which devices `prepare` uses and how many
319
+ workers the engine declares.
320
+ - On iOS, `prepare` starts the automation runner on every device with
321
+ agent-device's `prepare ios-runner`, so its first launch is paid before the
322
+ first test. Then it brings the target's `app.bundleId` up once per device
323
+ when it is installed. A build the suite installs itself comes up with the
324
+ first `app.open()`, which relaunches it fresh whenever a test asks.
325
+ - A device that cannot boot, or a runner that is busy, wedged, or past its
326
+ watchdog, ends the run in `prepare`.
327
+ - A runner that does not start for another reason is logged and left to the
328
+ first attempt, and that device's app is not opened there.
329
+
330
+ <Note>
331
+ agent-device commands take no abort signal, so a timed-out command keeps
332
+ running on the device. The next attempt waits for it to settle.
333
+ </Note>
334
+
335
+ #### Capabilities
199
336
 
200
337
  | Contract member | On a device |
201
338
  | --- | --- |
202
339
  | `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. 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. |
203
- | `perform` | `tap`, `doubleTap`, `longPress`, `fill`, `clear`, `hover`, `check`, `uncheck`, `press` (`Enter` 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`. |
340
+ | `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`. |
204
341
  | `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. |
205
342
  | `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`. |
206
- | `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). |
343
+ | `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. |
207
344
  | `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. |
208
345
  | `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. |
209
346
  | `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](/reference/config#video) records, as `video/video.mp4`, or a device provider's own recording. |
210
347
  | `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. |
211
348
  | `state` | Not declared. `test.setup` and `session:` are unavailable. |
212
349
 
350
+ ### Device
351
+
352
+ The `device` fixture is deterministic device management. A test calls it
353
+ directly to arrange or assert device state.
354
+
355
+ ```ts
356
+ test('offline banner', { platforms: ['android'] }, async ({ app, device, screen }) => {
357
+ await app.open();
358
+ await device.setNetwork('offline');
359
+ await expect(screen.getByText('You are offline')).toBeVisible();
360
+ await device.setNetwork('online');
361
+ });
362
+ ```
363
+
364
+ | Member | Returns |
365
+ | --- | --- |
366
+ | [`locator`](#locator) | `Locator` |
367
+ | [`setNetwork`](#setnetwork) | `Promise<void>` |
368
+ | [`setAirplaneMode`](#setairplanemode) | `Promise<void>` |
369
+ | [`setPermission`](#setpermission) | `Promise<void>` |
370
+ | [`setLocation`](#setlocation) | `Promise<void>` |
371
+ | [`clearLocation`](#clearlocation) | `Promise<void>` |
372
+ | [`setAppearance`](#setappearance) | `Promise<void>` |
373
+ | [`setOrientation`](#setorientation) | `Promise<void>` |
374
+ | [`fold`](#fold) | `Promise<void>` |
375
+ | [`setBiometrics`](#setbiometrics) | `Promise<void>` |
376
+ | [`enrollBiometrics`](#enrollbiometrics) | `Promise<void>` |
377
+ | [`installApp`](#installapp) | `Promise<InstalledApp>` |
378
+ | [`openApp`](#openapp) | `Promise<void>` |
379
+ | [`openLink`](#openlink) | `Promise<void>` |
380
+ | [`closeApp`](#closeapp) | `Promise<void>` |
381
+ | [`clearKeychain`](#clearkeychain) | `Promise<void>` |
382
+ | [`foregroundApp`](#foregroundapp) | `Promise<ForegroundApp>` |
383
+ | [`home`](#home) | `Promise<void>` |
384
+ | [`back`](#back) | `Promise<void>` |
385
+ | [`alert`](#alert) | `Promise<void>` |
386
+ | [`dismissKeyboard`](#dismisskeyboard) | `Promise<void>` |
387
+ | [`clipboard`](#clipboard) | `Promise<string>` |
388
+ | [`setClipboard`](#setclipboard) | `Promise<void>` |
389
+
390
+ Every async method is a recorded step named `device.<method>`, bounded by
391
+ `config.actionTimeout`. `back`, `home`, `alert`, `dismissKeyboard`,
392
+ `setOrientation`, and `fold` count as actions for the
393
+ [`transition`](#transition) budget. Each entry below shows its member's
394
+ declaration; the types they use are under [Types](#device-fixture).
395
+
396
+ | Throws | When |
397
+ | --- | --- |
398
+ | `APP_NOT_OPEN` | The session is lost. |
399
+ | `ACTION_FAILED` | `actionTimeout` expires. |
400
+ | `UNSUPPORTED_CAPABILITY` | The platform cannot run the command. |
401
+ | `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](/mobile#troubleshooting)). |
402
+
403
+ #### `locator`
404
+
405
+ ```ts
406
+ /**
407
+ * A locator from an agent-device selector (`'id=SW_VERSION_SPECIFIER'`,
408
+ * `'role=text label="26.2"'`), for nodes the closed `screen` query
409
+ * vocabulary cannot name. Same polling and strictness as any locator.
410
+ */
411
+ locator(selector: string): Locator;
412
+ ```
413
+
414
+ ```ts
415
+ await expect(device.locator('role=navigation-bar id=About')).toBeVisible();
416
+ await expect(device.locator('role=text label="iOS Version"')).toHaveText('iOS Version');
417
+ await device.locator('id=SW_VERSION_SPECIFIER role=button').tap();
418
+ ```
419
+
420
+ Selector terms and their rules are agent-device's, the ones `agent-device
421
+ find ... list` applies:
422
+
423
+ - Terms: `id`, `role`, `text`, `label`, `value`, `appname`, `windowtitle`,
424
+ and the flags `visible`, `hidden`, `editable`, `selected`, `focused`,
425
+ `enabled`, `hittable`.
426
+ - `role` takes agent-device's kind, the role `agent-device snapshot` prints:
427
+ `button`, `text`, `text-field`, `navigation-bar`, `scroll-area`, `switch`,
428
+ `image`. It is not the `getByRole` role (`textbox`).
429
+ - Text terms compare case-insensitively after collapsing whitespace.
430
+ - `visible` means the node is hittable or has a non-empty frame; `hidden` is
431
+ its opposite.
432
+ - `text` reads the first non-empty label, value, or identifier.
433
+
434
+ <Note>
435
+ An action on a selector that matches several nodes fails, where an
436
+ agent-device action skips an ambiguous `||` alternative.
437
+ </Note>
438
+
439
+ #### `setNetwork`
440
+
441
+ ```ts
442
+ /**
443
+ * Switches an Android device's Wi-Fi. Cellular data stays on, so an
444
+ * emulator hands the app over to its cellular link; `setAirplaneMode(true)`
445
+ * cuts both. On iOS `offline` is `UNSUPPORTED_CAPABILITY`, since a
446
+ * simulator shares the Mac's network and simctl can only repaint its status
447
+ * bar; `online` sends nothing there, so a cleanup hook shared with Android
448
+ * passes.
449
+ */
450
+ setNetwork(state: 'online' | 'offline'): Promise<void>;
451
+ ```
452
+
453
+ ```ts
454
+ await device.setNetwork('offline');
455
+ await device.setNetwork('online');
456
+ ```
457
+
458
+ | Throws | When |
459
+ | --- | --- |
460
+ | `UNSUPPORTED_CAPABILITY` | `offline` on iOS. |
461
+
462
+ #### `setAirplaneMode`
463
+
464
+ ```ts
465
+ /**
466
+ * Switches an Android device's airplane mode, Wi-Fi and cellular together;
467
+ * Android 11 or newer. On iOS, as for `setNetwork`, `true` is
468
+ * `UNSUPPORTED_CAPABILITY` and `false` sends nothing.
469
+ */
470
+ setAirplaneMode(enabled: boolean): Promise<void>;
471
+ ```
472
+
473
+ ```ts
474
+ await device.setAirplaneMode(true);
475
+ ```
476
+
477
+ | Throws | When |
478
+ | --- | --- |
479
+ | `UNSUPPORTED_CAPABILITY` | `true` on iOS. |
480
+
481
+ #### `setPermission`
482
+
483
+ ```ts
484
+ /**
485
+ * Grants, denies, or resets one permission for the pinned app, which need
486
+ * not be running. A change terminates a running app on iOS, a revoke one
487
+ * on Android, so it goes before the `app.open()` a test starts with.
488
+ */
489
+ setPermission(permission: DevicePermission, state: PermissionState): Promise<void>;
490
+ ```
491
+
492
+ ```ts
493
+ await device.setPermission('location', 'deny');
494
+ await app.open();
495
+ ```
496
+
497
+ - `permission` is a [`DevicePermission`](#device-fixture); `state` is
498
+ `grant`, `deny`, or `reset`.
499
+
500
+ #### `setLocation`
501
+
502
+ ```ts
503
+ /** Sets the simulated location, switching location services on first on Android. */
504
+ setLocation(coordinates: { latitude: number; longitude: number }): Promise<void>;
505
+ ```
506
+
507
+ ```ts
508
+ await device.setLocation({ latitude: 52.23, longitude: 21.01 });
509
+ ```
510
+
511
+ #### `clearLocation`
512
+
513
+ ```ts
514
+ /** Turns simulated location off; on Android that is location services off, until the next `setLocation`. */
515
+ clearLocation(): Promise<void>;
516
+ ```
517
+
518
+ ```ts
519
+ await device.clearLocation();
520
+ ```
521
+
522
+ #### `setAppearance`
523
+
524
+ ```ts
525
+ /** Sets the system appearance. */
526
+ setAppearance(mode: 'light' | 'dark'): Promise<void>;
527
+ ```
528
+
529
+ ```ts
530
+ await device.setAppearance('dark');
531
+ ```
532
+
533
+ #### `setOrientation`
534
+
535
+ ```ts
536
+ /** Rotates the device. */
537
+ setOrientation(orientation: DeviceOrientation): Promise<void>;
538
+ ```
539
+
540
+ ```ts
541
+ await device.setOrientation('landscape-left');
542
+ ```
543
+
544
+ - `'portrait'`, `'portrait-upside-down'`, `'landscape-left'`, or
545
+ `'landscape-right'`.
546
+
547
+ #### `fold`
548
+
549
+ ```ts
550
+ /**
551
+ * Puts a foldable simulator or emulator in a hinge pose. On the iPhone Duo
552
+ * it resolves once agent-device reads the angle back from CoreDevice at
553
+ * that pose, typically 10 to 16 seconds later; `closed` lights the outer
554
+ * display, `half-open` (a 130° book) and `open` light the inner one. On a
555
+ * foldable Android emulator it sets the emulator console posture and
556
+ * resolves once the guest commits the matching device state, within a few
557
+ * seconds; `half-open` is the profile's own angle there (90° on the Pixel
558
+ * folds), and a fold to the cover display dismisses the lock screen it
559
+ * raises. Either way the app window changes size, so the next observation
560
+ * reads a new screen. Any device without a hinge is `UNSUPPORTED_CAPABILITY`.
561
+ */
562
+ fold(pose: FoldPose): Promise<void>;
563
+ ```
564
+
565
+ ```ts
566
+ await device.fold('half-open');
567
+ ```
568
+
569
+ | Throws | When |
570
+ | --- | --- |
571
+ | `UNSUPPORTED_CAPABILITY` | The device has no hinge. |
572
+
573
+ #### `setBiometrics`
574
+
575
+ ```ts
576
+ /** Simulates one biometric attempt on the open app. */
577
+ setBiometrics(sensor: BiometricSensor, result: 'match' | 'nonmatch'): Promise<void>;
578
+ ```
579
+
580
+ ```ts
581
+ await device.setBiometrics('faceid', 'nonmatch');
582
+ ```
583
+
584
+ - `sensor` is `'faceid'`, `'touchid'`, or `'fingerprint'`.
585
+
586
+ #### `enrollBiometrics`
587
+
588
+ ```ts
589
+ /** Enrolls or unenrolls the simulator's Face ID or Touch ID. */
590
+ enrollBiometrics(sensor: 'faceid' | 'touchid', enrolled: boolean): Promise<void>;
591
+ ```
592
+
593
+ ```ts
594
+ await device.enrollBiometrics('touchid', true);
595
+ ```
596
+
597
+ #### `installApp`
598
+
599
+ ```ts
600
+ /**
601
+ * Installs a build (an iOS `.app` bundle or an Android `.apk`, resolved
602
+ * against the project root) on the device; without a path, the target's
603
+ * `app.appPath`. The engine installs nothing on its own, so a suite that runs
604
+ * against a build calls this once per device, in a fixture or a test.
605
+ * `reinstall: true` removes the app first so it starts with no data; a
606
+ * plain install replaces the binary and keeps its data. Resolves to the
607
+ * identity to `openApp` it by, which becomes the app `app.open()` launches
608
+ * when the target's build is installed and no `app.bundleId` is pinned.
609
+ */
610
+ installApp(appPath?: string, options?: InstallAppOptions): Promise<InstalledApp>;
611
+ ```
612
+
613
+ ```ts
614
+ await device.installApp(); // the target's app.appPath
615
+ const { app } = await device.installApp('./build/MyApp.app', { reinstall: true });
616
+ await device.openApp(app);
617
+ ```
618
+
619
+ - Use it for the run's build, or for another build on an upgrade path.
620
+ - The resolved id is the bundle id or package agent-device read from the
621
+ build, else the `app` passed in.
622
+
623
+ | Throws | When |
624
+ | --- | --- |
625
+ | `ENGINE_FAILURE` | agent-device reported no id and no `app` was passed, instead of a file path posing as an id. See [Troubleshooting](/mobile#troubleshooting). |
626
+
627
+ #### `openApp`
628
+
629
+ ```ts
630
+ /**
631
+ * Brings an app to the foreground by bundle id, package, or display name;
632
+ * `relaunch` restarts it fresh. `launchArguments` and `permissions` apply
633
+ * to this launch alone; the target's configured ones apply to a `relaunch`
634
+ * of the pinned app, and to no foreground-only open. A link is
635
+ * `INVALID_ARGUMENT` (`file:`, `data:`, `javascript:`, `view-source:`,
636
+ * `blob:`, and `filesystem:` are `POLICY_DENIED`): `openLink` opens one.
637
+ */
638
+ openApp(app: string, options?: OpenAppOptions): Promise<void>;
639
+ ```
640
+
641
+ ```ts
642
+ await device.openApp('Settings');
643
+ await device.openApp('com.example.app', { relaunch: true, permissions: { camera: 'grant' } });
644
+ ```
645
+
646
+ - A link is refused before the device sees it, since agent-device opens any
647
+ `scheme:rest` string as a URL.
648
+
649
+ | Throws | When |
650
+ | --- | --- |
651
+ | `POLICY_DENIED` | A `file:`, `data:`, `javascript:`, `view-source:`, `blob:`, or `filesystem:` link. |
652
+ | `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)). |
653
+
654
+ #### `openLink`
655
+
656
+ ```ts
657
+ /**
658
+ * Opens a deep link (`myapp://orders/42`) or a web link (`https://...`)
659
+ * into `app` (default: the pinned app), which the session observes
660
+ * afterwards. iOS launches the app for a web link and then opens the URL;
661
+ * a web link the app does not claim goes to Safari, and the next
662
+ * observation brings the app back. Android starts the link on that
663
+ * package. Without an app, Android lets the OS route the link; iOS needs
664
+ * one (`INVALID_ARGUMENT` without). `file:`, `data:`, `javascript:`,
665
+ * `view-source:`, `blob:`, and `filesystem:` links are `POLICY_DENIED`.
666
+ */
667
+ openLink(url: string, options?: { app?: string }): Promise<void>;
668
+ ```
669
+
670
+ ```ts
671
+ await device.openLink('myapp://orders/42');
672
+ await expect(screen.getByText('Order #42')).toBeVisible();
673
+ ```
674
+
675
+ - Use it for magic-link sign-in and deep-link routes.
676
+ - Without an app, Android routes the link to a verified app link owner or
677
+ the browser, and the session follows the package that took it.
678
+ - A device link may use any app's custom scheme, so the rule refuses the
679
+ schemes below rather than admitting http(s) alone as the web does.
680
+ - The report shows the link cut before its query and fragment, where a magic
681
+ link carries its token.
682
+ - The replay cache anchors on the location the link lands on; see
683
+ [Replay cache on a device](#replay-cache-on-a-device).
684
+
685
+ | Throws | When |
686
+ | --- | --- |
687
+ | `POLICY_DENIED` | A `file:`, `data:`, `javascript:`, `view-source:`, `blob:`, or `filesystem:` link. |
688
+ | `INVALID_ARGUMENT` | No app on iOS: an open bound to none leaves no app session to observe. |
689
+ | `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. |
690
+
691
+ #### `closeApp`
692
+
693
+ ```ts
694
+ /**
695
+ * Terminates the session's app and ends the session, so the next
696
+ * `openApp` launches it fresh. Until then the screen is unobservable and
697
+ * `foregroundApp` is `APP_NOT_OPEN`.
698
+ */
699
+ closeApp(): Promise<void>;
700
+ ```
701
+
702
+ ```ts
703
+ await device.closeApp();
704
+ await device.openApp('com.example.app'); // launches fresh, on its first screen
705
+ ```
706
+
707
+ - A screen query before the next `openApp` is `APP_NOT_OPEN` too.
708
+
709
+ #### `clearKeychain`
710
+
711
+ ```ts
712
+ /**
713
+ * Resets the simulator's keychain, every app's, since simctl has no
714
+ * per-app reset: what `app.clearState()` leaves behind (a session token, a
715
+ * Firebase sign-in). iOS simulator only; Android is
716
+ * `UNSUPPORTED_CAPABILITY`. Launch the app afterwards to see it signed out.
717
+ */
718
+ clearKeychain(): Promise<void>;
719
+ ```
720
+
721
+ ```ts
722
+ await device.clearKeychain();
723
+ await app.open(); // see it signed out
724
+ ```
725
+
726
+ | Throws | When |
727
+ | --- | --- |
728
+ | `UNSUPPORTED_CAPABILITY` | On Android, before any device command. |
729
+
730
+ #### `foregroundApp`
731
+
732
+ ```ts
733
+ /**
734
+ * The app the session is on. iOS answers from the session, not the
735
+ * device, so it names the app the session opened even after `home()`;
736
+ * Android reads the device's foreground activity. `APP_NOT_OPEN` once
737
+ * `closeApp` ended the session.
738
+ */
739
+ foregroundApp(): Promise<ForegroundApp>;
740
+ ```
741
+
742
+ ```ts
743
+ const { name, bundleId } = await device.foregroundApp();
744
+ ```
745
+
746
+ - On iOS, agent-device's `appstate` has no device-level answer.
747
+
748
+ | Throws | When |
749
+ | --- | --- |
750
+ | `APP_NOT_OPEN` | `closeApp` ended the session. |
751
+
752
+ #### `home`
753
+
754
+ ```ts
755
+ /** Sends the device to its home screen. */
756
+ home(): Promise<void>;
757
+ ```
758
+
759
+ ```ts
760
+ await device.home();
761
+ ```
762
+
763
+ #### `back`
764
+
765
+ ```ts
766
+ /** Navigates back once (in-app back). */
767
+ back(): Promise<void>;
768
+ ```
769
+
770
+ ```ts
771
+ await device.back();
772
+ ```
773
+
774
+ #### `alert`
775
+
776
+ ```ts
777
+ /** Accepts or dismisses the visible system alert. */
778
+ alert(action: 'accept' | 'dismiss'): Promise<void>;
779
+ ```
780
+
781
+ ```ts
782
+ await device.alert('accept');
783
+ ```
784
+
785
+ #### `dismissKeyboard`
786
+
787
+ ```ts
788
+ /**
789
+ * Dismisses the soft keyboard: the keyboard's own dismiss key where it has
790
+ * one (Android, iPad), else the way a user does it on an iPhone, a short
791
+ * drag at the centre of the screen that a scroll view turns into a
792
+ * dismissal, horizontal first and vertical second, stopping as soon as the
793
+ * keyboard is gone. The simulator's "Speed up your typing" tip goes through
794
+ * its Continue button first. A keyboard that outlives both drags fails with
795
+ * `UNSUPPORTED_CAPABILITY`; the message names the alternatives (the app's
796
+ * own Done or close control, or pressing Enter on the field).
797
+ */
798
+ dismissKeyboard(): Promise<void>;
799
+ ```
800
+
801
+ ```ts
802
+ await device.dismissKeyboard();
803
+ ```
804
+
805
+ | Throws | When |
806
+ | --- | --- |
807
+ | `UNSUPPORTED_CAPABILITY` | The keyboard outlives both drags. |
808
+
809
+ #### `clipboard`
810
+
811
+ ```ts
812
+ /** Reads the clipboard. */
813
+ clipboard(): Promise<string>;
814
+ ```
815
+
816
+ ```ts
817
+ const copied = await device.clipboard();
818
+ ```
819
+
820
+ #### `setClipboard`
821
+
822
+ ```ts
823
+ /** Writes the clipboard. */
824
+ setClipboard(text: string): Promise<void>;
825
+ ```
826
+
827
+ ```ts
828
+ await device.setClipboard('SAVE20');
829
+ ```
830
+
831
+ ### Agent tools
832
+
833
+ `mobileTools(...engines)` from `@e2e-dev/mobile/tools` adds device tools to
834
+ an agent's `tools`.
835
+
836
+ ```ts
837
+ tools: mobileTools(iphone, pixel),
838
+ ```
839
+
840
+ - It takes at least one engine. Pass all device engines in one call; the
841
+ tools use the current attempt's engine.
842
+
843
+ | Tool | Replay | Purpose |
844
+ | --- | --- | --- |
845
+ | `open_app` | gap | Bring an app to the foreground by id, optionally relaunched. A link is refused, as it is for `device.openApp`. |
846
+ | `swipe` | gap | Free-form swipe between two points in logical pixels. |
847
+ | `alert` | gap | Accept or dismiss a system alert; prefer a listed button, whose tap replays. |
848
+
849
+ The grammar verbs need no tool pack. The engine's declared actions give the
850
+ agent:
851
+
852
+ - `tap`, `double_tap`, `long_press`, `hover`, `type`, `press`, `check`,
853
+ `scroll`, `drag`, `scroll_to` by `text` (paging the list until a row
854
+ reading it is drawn), `back`, and `dismiss_keyboard`.
855
+ - The pixel tools `screenshot`, `tap_at`, `hover_at` (on a listed control
856
+ only), `type_at`, and `press_at`. Point actions can tap controls absent
857
+ from the accessibility tree.
858
+ - `type_secret`, when the step declares a secret.
859
+
860
+ There is no `right_click`, `select`, `scroll_to` by node id, `upload`, or
861
+ `navigate` on a device, and `select_at` is not offered.
862
+
863
+ <Note>
864
+ After a secret fill the agent offers neither `screenshot` nor `tap_at`,
865
+ including in later members of the same serial group. Pixels whose masking
866
+ the engine cannot prove are withheld as `MASKING_UNPROVEN`. An accessibility
867
+ capture failure still stops the observation. Device screenshot masks depend
868
+ on secure-field bounds from that capture, so without them the engine cannot
869
+ prove a fallback screenshot safe.
870
+ </Note>
871
+
213
872
  ## Replay cache on a device
214
873
 
215
- A step recorded on `com.apple.Preferences / General` replays only when
216
- Settings is on General again. A mutating project-tool call such as
217
- `open_app` ends a replay. Declare `app.bundleId` and call `app.open()` first, so steps
218
- start on the same screen, and switch apps through the `device` fixture
219
- between steps rather than inside one.
874
+ The replay cache anchors on the [`location`](#capabilities) a device reports,
875
+ `<bundle id> / <screen title>`. A step recorded on
876
+ `com.apple.Preferences / General` replays only when Settings is on General
877
+ again.
878
+
879
+ ```ts
880
+ await app.open();
881
+ await screen.getByText('General').tap();
882
+ await agent.act('Open About and read the iOS version');
883
+ ```
884
+
885
+ - A mutating project-tool call such as `open_app` ends a replay.
886
+ - Declare `app.bundleId` and call `app.open()` first, so steps start on the
887
+ same screen.
888
+ - Switch apps through the `device` fixture between steps rather than inside
889
+ one.
890
+ - A step recorded after [`device.openLink`](#openlink) replays when the link
891
+ lands on the same screen again.
892
+
893
+ ## Types
220
894
 
221
- ## Device
895
+ ### Engine options
896
+
897
+ Each `MobileOptions` member is declared, with its JSDoc, in its
898
+ [Engine](#engine) entry.
222
899
 
223
900
  ```ts
224
- /** Deterministic device management exposed to tests as `device`. */
225
- export interface Device {
226
- /**
227
- * A locator from an agent-device selector (`'id=SW_VERSION_SPECIFIER'`,
228
- * `'role=text label="26.2"'`), for nodes the closed `screen` query
229
- * vocabulary cannot name. Same polling and strictness as any locator.
230
- */
231
- locator(selector: string): Locator;
232
- /**
233
- * Switches an Android device's Wi-Fi. Cellular data stays on, so an
234
- * emulator hands the app over to its cellular link; `setAirplaneMode(true)`
235
- * cuts both. On iOS `offline` is `UNSUPPORTED_CAPABILITY`, since a
236
- * simulator shares the Mac's network and simctl can only repaint its status
237
- * bar; `online` sends nothing there, so a cleanup hook shared with Android
238
- * passes.
239
- */
240
- setNetwork(state: 'online' | 'offline'): Promise<void>;
241
- /**
242
- * Switches an Android device's airplane mode, Wi-Fi and cellular together;
243
- * Android 11 or newer. On iOS, as for `setNetwork`, `true` is
244
- * `UNSUPPORTED_CAPABILITY` and `false` sends nothing.
245
- */
246
- setAirplaneMode(enabled: boolean): Promise<void>;
247
- /**
248
- * Grants, denies, or resets one permission for the pinned app, which need
249
- * not be running. A change terminates a running app on iOS, a revoke one
250
- * on Android, so it goes before the `app.open()` a test starts with.
251
- */
252
- setPermission(permission: DevicePermission, state: PermissionState): Promise<void>;
253
- /** Sets the simulated location, switching location services on first on Android. */
254
- setLocation(coordinates: { latitude: number; longitude: number }): Promise<void>;
255
- /** Turns simulated location off; on Android that is location services off, until the next `setLocation`. */
256
- clearLocation(): Promise<void>;
257
- /** Sets the system appearance. */
258
- setAppearance(mode: 'light' | 'dark'): Promise<void>;
259
- /** Rotates the device. */
260
- setOrientation(orientation: DeviceOrientation): Promise<void>;
261
- /**
262
- * Puts a foldable simulator or emulator in a hinge pose. On the iPhone Duo
263
- * it resolves once agent-device reads the angle back from CoreDevice at
264
- * that pose, typically 10 to 16 seconds later; `closed` lights the outer
265
- * display, `half-open` (a 130° book) and `open` light the inner one. On a
266
- * foldable Android emulator it sets the emulator console posture and
267
- * resolves once the guest commits the matching device state, within a few
268
- * seconds; `half-open` is the profile's own angle there (90° on the Pixel
269
- * folds), and a fold to the cover display dismisses the lock screen it
270
- * raises. Either way the app window changes size, so the next observation
271
- * reads a new screen. Any device without a hinge is `UNSUPPORTED_CAPABILITY`.
272
- */
273
- fold(pose: FoldPose): Promise<void>;
274
- /** Simulates one biometric attempt on the open app. */
275
- setBiometrics(sensor: BiometricSensor, result: 'match' | 'nonmatch'): Promise<void>;
276
- /** Enrolls or unenrolls the simulator's Face ID or Touch ID. */
277
- enrollBiometrics(sensor: 'faceid' | 'touchid', enrolled: boolean): Promise<void>;
278
- /**
279
- * Installs a build (an iOS `.app` bundle or an Android `.apk`, resolved
280
- * against the project root) on the device; without a path, the target's
281
- * `app.appPath`. The engine installs nothing on its own, so a suite that runs
282
- * against a build calls this once per device, in a fixture or a test.
283
- * `reinstall: true` removes the app first so it starts with no data; a
284
- * plain install replaces the binary and keeps its data. Resolves to the
285
- * identity to `openApp` it by, which becomes the app `app.open()` launches
286
- * when the target's build is installed and no `app.bundleId` is pinned.
287
- */
288
- installApp(appPath?: string, options?: InstallAppOptions): Promise<InstalledApp>;
289
- /**
290
- * Brings an app to the foreground by bundle id, package, or display name;
291
- * `relaunch` restarts it fresh. `launchArguments` and `permissions` apply
292
- * to this launch alone; the target's configured ones apply to a `relaunch`
293
- * of the pinned app, and to no foreground-only open. A link is
294
- * `INVALID_ARGUMENT` (`file:`, `data:`, `javascript:`, `view-source:`,
295
- * `blob:`, and `filesystem:` are `POLICY_DENIED`): `openLink` opens one.
296
- */
297
- openApp(app: string, options?: OpenAppOptions): Promise<void>;
298
- /**
299
- * Opens a deep link (`myapp://orders/42`) or a web link (`https://...`)
300
- * into `app` (default: the pinned app), which the session observes
301
- * afterwards. iOS launches the app for a web link and then opens the URL;
302
- * a web link the app does not claim goes to Safari, and the next
303
- * observation brings the app back. Android starts the link on that
304
- * package. Without an app, Android lets the OS route the link; iOS needs
305
- * one (`INVALID_ARGUMENT` without). `file:`, `data:`, `javascript:`,
306
- * `view-source:`, `blob:`, and `filesystem:` links are `POLICY_DENIED`.
307
- */
308
- openLink(url: string, options?: { app?: string }): Promise<void>;
309
- /**
310
- * Terminates the session's app and ends the session, so the next
311
- * `openApp` launches it fresh. Until then the screen is unobservable and
312
- * `foregroundApp` is `APP_NOT_OPEN`.
313
- */
314
- closeApp(): Promise<void>;
315
- /**
316
- * Resets the simulator's keychain, every app's, since simctl has no
317
- * per-app reset: what `app.clearState()` leaves behind (a session token, a
318
- * Firebase sign-in). iOS simulator only; Android is
319
- * `UNSUPPORTED_CAPABILITY`. Launch the app afterwards to see it signed out.
320
- */
321
- clearKeychain(): Promise<void>;
322
- /**
323
- * The app the session is on. iOS answers from the session, not the
324
- * device, so it names the app the session opened even after `home()`;
325
- * Android reads the device's foreground activity. `APP_NOT_OPEN` once
326
- * `closeApp` ended the session.
327
- */
328
- foregroundApp(): Promise<ForegroundApp>;
329
- /** Sends the device to its home screen. */
330
- home(): Promise<void>;
331
- /** Navigates back once (in-app back). */
332
- back(): Promise<void>;
333
- /** Accepts or dismisses the visible system alert. */
334
- alert(action: 'accept' | 'dismiss'): Promise<void>;
335
- /**
336
- * Dismisses the soft keyboard: the keyboard's own dismiss key where it has
337
- * one (Android, iPad), else the way a user does it on an iPhone, a short
338
- * drag at the centre of the screen that a scroll view turns into a
339
- * dismissal, horizontal first and vertical second, stopping as soon as the
340
- * keyboard is gone. The simulator's "Speed up your typing" tip goes through
341
- * its Continue button first. A keyboard that outlives both drags fails with
342
- * `UNSUPPORTED_CAPABILITY`; the message names the alternatives (the app's
343
- * own Done or close control, or pressing Enter on the field).
344
- */
345
- dismissKeyboard(): Promise<void>;
346
- /** Reads the clipboard. */
347
- clipboard(): Promise<string>;
348
- /** Writes the clipboard. */
349
- setClipboard(text: string): Promise<void>;
350
- }
901
+ /** A permission's state: held, refused, or not asked for yet, so the OS asks again. */
902
+ export type PermissionState = 'grant' | 'deny' | 'reset';
903
+
904
+ /** Permissions an app holds when it launches, by name. */
905
+ export type LaunchPermissions = Readonly<Partial<Record<DevicePermission, PermissionState>>>;
906
+ ```
907
+
908
+ ### Device fixture
909
+
910
+ Each `Device` member is declared, with its JSDoc, in its [Device](#device)
911
+ entry.
351
912
 
913
+ ```ts
352
914
  /** How `device.openApp` launches an app. */
353
915
  export interface OpenAppOptions {
354
916
  /** Terminate the app first, so it starts fresh; without it a running app is brought forward as it is. */
@@ -415,139 +977,18 @@ export interface ForegroundApp {
415
977
  export type BiometricSensor = 'faceid' | 'touchid' | 'fingerprint';
416
978
  ```
417
979
 
418
- Every async method is a recorded step named `device.<method>`, bounded by
419
- `config.actionTimeout`. A lost session is `APP_NOT_OPEN`, that budget
420
- expiring is `ACTION_FAILED`, a command the platform cannot run is
421
- `UNSUPPORTED_CAPABILITY`, anything else `ENGINE_FAILURE` with agent-device's
422
- message: a timeout the device itself reports (a boot, the runner connecting)
423
- keeps its text and hint there, and a busy or wedged iOS automation runner
424
- names itself and the recovery (see
425
- [Troubleshooting](/mobile#troubleshooting)).
426
-
427
- `back`, `home`, `alert`, `dismissKeyboard`, `setOrientation`, and `fold`
428
- change the screen, so they count as actions for the `transition` budget the way a tap
429
- does: a control that arrives with the screen they reveal waits out the budget
430
- before the next action lands on it.
431
-
432
- `closeApp` terminates the app the session is on and ends the session, so
433
- `openApp` after it launches the app fresh, on its first screen; a screen
434
- query or `foregroundApp` in between is `APP_NOT_OPEN`. `foregroundApp` on
435
- iOS reads the session, not the device: agent-device's `appstate` has no
436
- device-level answer there, so after `home()` it still names the app the
437
- session opened. Android reads the device's foreground activity.
438
-
439
- `installApp` installs a build from inside a test or a fixture: the run's build once per device (no path installs `app.appPath`), or another build for an upgrade path. A plain
440
- install keeps the app's data; `reinstall: true` removes the app named by
441
- `app` (default: the pinned app) first. It resolves to the id to `openApp`
442
- the build by: the bundle id or package agent-device read from the build, else
443
- the `app` passed in. With no reported id and no `app`, the install is
444
- `ENGINE_FAILURE` instead of a file path posing as an id; see
445
- [Troubleshooting](/mobile#troubleshooting).
446
-
447
- ```ts
448
- const { app } = await device.installApp('./build/MyApp.app', { reinstall: true });
449
- await device.openApp(app);
450
- ```
451
-
452
- `clearKeychain` resets the simulator's keychain, which `app.clearState()`
453
- leaves alone: simctl resets the whole simulator's, so every app on it loses
454
- its keychain-backed credentials. iOS only; on Android it is
455
- `UNSUPPORTED_CAPABILITY` before any device command. Launch the app afterwards
456
- to see it signed out.
457
-
458
- `openApp` brings an app to the foreground by bundle id, package, or display
459
- name (`Settings`); `relaunch: true` restarts it fresh, and `launchArguments`
460
- and `permissions` apply to that launch alone (the target's own apply to a
461
- `relaunch` of the pinned app). It takes no link:
462
- agent-device opens any `scheme:rest` string as a URL, so a link is refused
463
- before the device sees it. A `file:`, `data:`, `javascript:`,
464
- `view-source:`, `blob:`, or `filesystem:` link is `POLICY_DENIED`; any other
465
- link is `INVALID_ARGUMENT` pointing at `openLink`.
466
-
467
- `openLink` opens a deep link or a web link into `app` (default: the pinned
468
- app), for magic-link sign-in and deep-link routes, and the session observes
469
- that app afterwards. iOS launches the app for a web link and then opens the
470
- URL; a web link the app does not claim goes to Safari, and the next
471
- observation brings the app back. Android starts the link on that package.
472
- Without an app, Android lets the OS route the link, to a verified app link
473
- owner or the browser, and the session follows the package that took it. iOS
474
- needs an app: an open bound to none leaves no app session to observe, so it
475
- is `INVALID_ARGUMENT`. `file:`, `data:`, `javascript:`, `view-source:`,
476
- `blob:`, and `filesystem:` links are `POLICY_DENIED`. A device link may use
477
- any app's custom scheme, so the rule refuses these schemes rather than
478
- admitting http(s) alone as the web does. A string that does not parse as an
479
- absolute URL is `INVALID_ARGUMENT` too, and its message names no part of the
480
- input, which may carry a magic-link token in its query, path, or userinfo.
481
- The report shows the link cut before its
482
- query and fragment, where a magic link carries its token. The replay cache
483
- anchors on the location the link lands on: a step recorded after `openLink`
484
- replays when the link lands on the same screen again.
485
-
486
- ```ts
487
- await device.openLink('myapp://orders/42');
488
- await expect(screen.getByText('Order #42')).toBeVisible();
489
- ```
490
-
491
- Use `device.locator` for platform-specific selectors that `screen` queries
492
- cannot express.
493
-
494
- ```ts
495
- await expect(device.locator('role=navigation-bar id=About')).toBeVisible();
496
- await expect(device.locator('role=text label="iOS Version"')).toHaveText('iOS Version');
497
- await device.locator('id=SW_VERSION_SPECIFIER role=button').tap();
498
- ```
499
-
500
- Selector terms and their rules are agent-device's (the ones `agent-device
501
- find ... list` applies): `id`, `role`, `text`, `label`, `value`, `appname`,
502
- `windowtitle`, and the flags `visible`, `hidden`, `editable`, `selected`,
503
- `focused`, `enabled`, `hittable`. Strictness stays the locator's: an action
504
- on a selector that matches several nodes fails, where an agent-device action
505
- skips an ambiguous `||` alternative.
506
-
507
- - `role` takes agent-device's kind, the role `agent-device snapshot` prints:
508
- `button`, `text`, `text-field`, `navigation-bar`, `scroll-area`, `switch`,
509
- `image`. It is not the `getByRole` role (`textbox`).
510
- - Text terms compare case-insensitively after collapsing whitespace.
511
- - `visible` means the node is hittable or has a non-empty frame;
512
- `hidden` is its opposite.
513
- - `text` reads the first non-empty label, value, or identifier.
514
-
515
- ## Agent tools
516
-
517
- `mobileTools(...engines)` adds device tools to an agent's `tools`. It takes at
518
- least one engine; pass all device engines in one call, and the tools use the
519
- current attempt's engine.
520
-
521
- | Tool | Replay | Purpose |
522
- | --- | --- | --- |
523
- | `open_app` | gap | Bring an app to the foreground by id, optionally relaunched. A link is refused, as it is for `device.openApp`. |
524
- | `swipe` | gap | Free-form swipe between two points in logical pixels. |
525
- | `alert` | gap | Accept or dismiss a system alert; prefer a listed button, whose tap replays. |
526
-
527
- The grammar verbs need no tool pack: the engine's declared actions give the
528
- agent `tap`, `double_tap`, `long_press`, `hover`, `type`, `press`, `check`,
529
- `scroll`, `drag`, `scroll_to` by `text` (paging the list until a row reading it
530
- is drawn), `back`, and `dismiss_keyboard`; there is no `right_click`,
531
- `select`, `scroll_to` by node id, `upload`, or `navigate` on a device. The
532
- pixel tools are `screenshot`, `tap_at`, `hover_at` (on a listed control only),
533
- `type_at`, and `press_at`; `select_at` is not offered. Point actions can tap
534
- controls absent from the accessibility tree. `type_secret` is offered when
535
- the step declares a secret.
536
-
537
- After a secret fill the agent offers neither `screenshot` nor `tap_at`,
538
- including in later members of the same serial group. Pixels whose masking the
539
- engine cannot prove are withheld as `MASKING_UNPROVEN`.
540
-
541
- An accessibility capture failure still stops the observation. Device
542
- screenshot masks depend on secure-field bounds from that capture, so the
543
- engine cannot prove a fallback screenshot safe when those bounds are missing.
544
-
545
- ## Exported types
980
+ ### Exported types
546
981
 
547
982
  `MobileOptions`, `MobilePlatform`, `DevicePermission`, `LaunchPermissions`,
548
983
  `PermissionState`, `DeviceClientConfig`, `DeviceConnection`, `DeviceDaemon`,
549
984
  `DeviceProvider`, `DeviceLease`, `DeviceReleaseContext`, `DeviceRequest`,
550
985
  `Device`, `DeviceOrientation`, `BiometricSensor`, `ForegroundApp`,
551
- `InstallAppOptions`, `InstalledApp`, and `OpenAppOptions` are exported from
552
- `@e2e-dev/mobile`. The runtime exports are `mobile` and `test`, typed with the
553
- `device` fixture; `mobileTools` comes from `@e2e-dev/mobile/tools`.
986
+ `FoldPose`, `InstallAppOptions`, `InstalledApp`, and `OpenAppOptions` are exported from
987
+ `@e2e-dev/mobile`. The runtime exports are `mobile`, and `test`, `describe`,
988
+ `beforeEach`, `afterEach`, `beforeAll`, and `afterAll`, typed with the `device`
989
+ fixture; `mobileTools` comes from `@e2e-dev/mobile/tools`.
990
+
991
+ ## Related
992
+
993
+ The [Mobile](/mobile) guide covers setup, hosted devices, and troubleshooting.
994
+ The portable lifecycle methods are on [`app`](/reference/app).