e2e 0.15.1 → 0.15.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 (87) hide show
  1. package/README.md +51 -24
  2. package/dist/.build.json +1 -1
  3. package/dist/agent/act.js +3 -3
  4. package/dist/agent/act.js.map +1 -1
  5. package/dist/agent/error.d.ts +16 -3
  6. package/dist/agent/error.d.ts.map +1 -1
  7. package/dist/agent/error.js +25 -6
  8. package/dist/agent/error.js.map +1 -1
  9. package/dist/agent/model/tool-choice.d.ts +24 -1
  10. package/dist/agent/model/tool-choice.d.ts.map +1 -1
  11. package/dist/agent/model/tool-choice.js +27 -1
  12. package/dist/agent/model/tool-choice.js.map +1 -1
  13. package/dist/agent/step-cache.d.ts +14 -3
  14. package/dist/agent/step-cache.d.ts.map +1 -1
  15. package/dist/agent/step-cache.js +13 -3
  16. package/dist/agent/step-cache.js.map +1 -1
  17. package/dist/agent/tool-loop.d.ts.map +1 -1
  18. package/dist/agent/tool-loop.js +62 -9
  19. package/dist/agent/tool-loop.js.map +1 -1
  20. package/dist/cache/context.d.ts +19 -6
  21. package/dist/cache/context.d.ts.map +1 -1
  22. package/dist/cache/context.js +15 -21
  23. package/dist/cache/context.js.map +1 -1
  24. package/dist/cli/init/scaffold.d.ts +1 -0
  25. package/dist/cli/init/scaffold.d.ts.map +1 -1
  26. package/dist/cli/init/scaffold.js +2 -1
  27. package/dist/cli/init/scaffold.js.map +1 -1
  28. package/dist/cli/init/sibling-versions.json +1 -1
  29. package/dist/expect/values.js +1 -1
  30. package/dist/expect/values.js.map +1 -1
  31. package/dist/run/execute.d.ts.map +1 -1
  32. package/dist/run/execute.js +21 -8
  33. package/dist/run/execute.js.map +1 -1
  34. package/dist/types.d.ts +1 -1
  35. package/dist/types.d.ts.map +1 -1
  36. package/docs/agent-steps.mdx +126 -0
  37. package/docs/agents.mdx +217 -0
  38. package/docs/api-testing.mdx +65 -0
  39. package/docs/authentication.mdx +345 -0
  40. package/docs/browser.mdx +440 -0
  41. package/docs/bug-bash.mdx +90 -0
  42. package/docs/cache.mdx +306 -0
  43. package/docs/ci.mdx +210 -0
  44. package/docs/coding-agents.mdx +183 -0
  45. package/docs/debugging.mdx +291 -0
  46. package/docs/executors.mdx +243 -0
  47. package/docs/explore.mdx +174 -0
  48. package/docs/github.mdx +184 -0
  49. package/docs/index.mdx +82 -0
  50. package/docs/integrations/eas.mdx +216 -0
  51. package/docs/integrations/index.mdx +24 -0
  52. package/docs/integrations/kernel.mdx +163 -0
  53. package/docs/migrate/cypress.mdx +124 -0
  54. package/docs/migrate/detox.mdx +140 -0
  55. package/docs/migrate/maestro.mdx +229 -0
  56. package/docs/migrate/playwright.mdx +427 -0
  57. package/docs/migrate/selenium.mdx +110 -0
  58. package/docs/mobile-ci.mdx +567 -0
  59. package/docs/mobile.mdx +555 -0
  60. package/docs/models.mdx +241 -0
  61. package/docs/quickstart.mdx +260 -0
  62. package/docs/reference/agent.mdx +524 -0
  63. package/docs/reference/app.mdx +72 -0
  64. package/docs/reference/cli.mdx +817 -0
  65. package/docs/reference/config.mdx +986 -0
  66. package/docs/reference/engine.mdx +1362 -0
  67. package/docs/reference/environment.mdx +113 -0
  68. package/docs/reference/errors.mdx +143 -0
  69. package/docs/reference/expect.mdx +513 -0
  70. package/docs/reference/mcp.mdx +202 -0
  71. package/docs/reference/mobile.mdx +522 -0
  72. package/docs/reference/reporters.mdx +216 -0
  73. package/docs/reference/screen.mdx +688 -0
  74. package/docs/reference/test.mdx +449 -0
  75. package/docs/reference/web.mdx +611 -0
  76. package/docs/security.mdx +292 -0
  77. package/docs/starting-your-app.mdx +231 -0
  78. package/docs/subscriptions.mdx +157 -0
  79. package/docs/telemetry.mdx +185 -0
  80. package/docs/tools.mdx +150 -0
  81. package/docs/viewports.mdx +146 -0
  82. package/docs/writing-an-engine.mdx +316 -0
  83. package/docs/writing-tests.mdx +256 -0
  84. package/package.json +2 -1
  85. package/skills/e2e/SKILL.md +4 -1
  86. package/skills/e2e/references/agent.md +3 -1
  87. package/skills/e2e/references/setup.md +13 -4
@@ -0,0 +1,555 @@
1
+ ---
2
+ title: Testing iOS and Android
3
+ description: Set up a simulator or emulator target, write a device test, and know what differs from the web.
4
+ ---
5
+
6
+ `@e2e-dev/mobile` runs tests on iOS simulators and Android emulators. It is
7
+ built on [agent-device](https://github.com/callstack/agent-device).
8
+ Tests use the same `agent`, `screen`, and `expect` APIs as browser tests.
9
+ A test launches the configured app with `app.open()`; between tests the app
10
+ stays where the last one left it.
11
+
12
+ ## Set up
13
+
14
+ Install Xcode with an iOS simulator runtime, or the Android SDK with an
15
+ emulator. Check your setup with:
16
+
17
+ <CodeGroup>
18
+ ```bash npm
19
+ npx agent-device doctor
20
+ ```
21
+
22
+ ```bash pnpm
23
+ pnpm exec agent-device doctor
24
+ ```
25
+
26
+ ```bash bun
27
+ bunx agent-device doctor
28
+ ```
29
+ </CodeGroup>
30
+
31
+ Follow the [Quickstart](/quickstart) and choose **Mobile (iOS/Android)**.
32
+ The wizard selects iOS on macOS and Android elsewhere. It starts with the
33
+ Settings app and one worker:
34
+
35
+ ```ts title="e2e.config.ts"
36
+ import type { E2EConfig } from 'e2e';
37
+ import { mobile } from '@e2e-dev/mobile';
38
+
39
+ export default {
40
+ // Replace Settings with your app's bundle id.
41
+ targets: [{ name: 'ios', engine: mobile({ platform: 'ios' }), app: { bundleId: 'Settings' } }],
42
+ workers: 1,
43
+ } satisfies E2EConfig;
44
+ ```
45
+
46
+ The runner boots the device before test timing starts. Boot time does not
47
+ count toward `launchTimeout`.
48
+
49
+ ## Point at your app
50
+
51
+ The app under test is the target's `app`; `mobile()` only drives it.
52
+ Replace `Settings` with your app's bundle id or package name in
53
+ `app.bundleId`: it is the app `app.open()`, `app.restart()`, and
54
+ `app.clearState()` launch. Add `appPath` to install a build first:
55
+
56
+ ```ts
57
+ {
58
+ name: 'ios',
59
+ engine: mobile({ platform: 'ios' }),
60
+ app: {
61
+ bundleId: 'com.example.app',
62
+ appPath: './build/MyApp.app', // the iOS .app bundle
63
+ },
64
+ }
65
+ ```
66
+
67
+ The engine installs nothing on its own: `app.appPath` names the build, and
68
+ the suite installs it where it says so, once per device, with
69
+ `device.installApp()` (no path installs `app.appPath`), for example in a
70
+ fixture every test takes. Use an `.app` bundle for iOS or an `.apk` for
71
+ Android. Without `bundleId`, the app the build installs is the one
72
+ `app.open()` launches. A device target needs one of the two.
73
+
74
+ To run the suite in CI on GitHub Actions, EAS Workflows, Bitrise, or
75
+ Codemagic, see [Mobile CI](/mobile-ci).
76
+
77
+ ### Launch arguments and permissions
78
+
79
+ `app.launchArguments` reach the app on every fresh launch the engine performs
80
+ (`app.open()`, `app.restart()`, `app.clearState()`): the process arguments on
81
+ iOS, `am start` arguments on Android. `app.permissions` are set before each of
82
+ those launches, so the app starts holding them and no system prompt appears;
83
+ `app.clearState()` puts them back after it reset them with the data. `deny`
84
+ differs by platform: iOS records the refusal and the app is told no without
85
+ a prompt, Android revokes the permission and the OS asks again when the app
86
+ requests it. The warm-up in `prepare` starts the iOS runner with
87
+ agent-device's `prepare ios-runner`, whose startup budget covers a cold
88
+ simulator, then opens the app once with neither, to put the worker's session
89
+ on the app; the process a test sees comes from `app.open()`. A build the
90
+ suite installs itself gets the runner start only, so the first test's
91
+ `app.open()` does not pay for it. Arguments that select a build mode change
92
+ what the app is: give each mode its own `app.identity` so their recordings
93
+ stay apart.
94
+
95
+ ```ts
96
+ app: {
97
+ bundleId: 'com.example.app',
98
+ launchArguments: ['-e2e', 'YES'],
99
+ permissions: { camera: 'grant', notifications: 'deny', location: 'reset' },
100
+ },
101
+ ```
102
+
103
+ One test launches with its own through `device.openApp`:
104
+
105
+ ```ts
106
+ await device.openApp('com.example.app', { relaunch: true, permissions: { camera: 'deny' } });
107
+ ```
108
+
109
+ ## Several devices
110
+
111
+ Add a target for each platform:
112
+
113
+ ```ts title="e2e.config.ts"
114
+ import type { E2EConfig } from 'e2e';
115
+ import { mobile } from '@e2e-dev/mobile';
116
+
117
+ const app = { bundleId: 'com.example.app' };
118
+
119
+ export default {
120
+ targets: [
121
+ { name: 'iphone', engine: mobile({ platform: 'ios' }), app },
122
+ { name: 'pixel', engine: mobile({ platform: 'android' }), app },
123
+ ],
124
+ workers: 1,
125
+ } satisfies E2EConfig;
126
+ ```
127
+
128
+ Tests run on both targets. Use `platforms: ['ios']` or
129
+ `platforms: ['android']` for tests with platform-specific labels or behavior.
130
+
131
+ To run files in parallel, provide a device pool and set `workers` to at
132
+ least the pool's size. Each device gets one worker:
133
+
134
+ ```ts
135
+ mobile({ platform: 'ios', device: ['iPhone 17', 'iPhone 17 Pro'] })
136
+ ```
137
+
138
+ Without `device`, the engine uses booted devices up to the worker limit.
139
+ If none are booted, it boots one. Two workers never drive the same device.
140
+
141
+ ## Hosted devices
142
+
143
+ A `device` can also be a `DeviceProvider`: an object that leases one hosted
144
+ device per worker slot when the run starts and releases it when the run
145
+ ends. The engine drives each lease through the agent-device daemon the lease
146
+ names instead of the local one, so any service that runs an agent-device
147
+ daemon next to a simulator, an emulator, or a physical device works. Nothing
148
+ in the framework knows a vendor: for [EAS Simulators](/integrations/eas),
149
+ `easSimulators()` from `@e2e-dev/eas` is the provider, and for
150
+ any other service a provider is a few dozen lines in your project.
151
+
152
+ ```ts
153
+ export interface DeviceProvider {
154
+ /** Label in progress lines and error messages. */
155
+ readonly name: string;
156
+ /** Called once per worker slot, for every slot at once, in the runner process. */
157
+ acquire(request: DeviceRequest): Promise<DeviceLease>;
158
+ /** Called once per lease when the run ends, on every exit path. */
159
+ release(lease: DeviceLease, context: DeviceReleaseContext): Promise<void>;
160
+ /** Optional: the service's own recording of the device, in the worker, for an attempt that records video. */
161
+ record?(lease: DeviceLease, context: ProviderRecordContext): Promise<ProviderRecording>;
162
+ }
163
+
164
+ export interface DeviceLease {
165
+ /** The provider's handle (a session id), handed back to `release`. */
166
+ readonly id: string;
167
+ /** The agent-device daemon the worker connects to; a lease names this, `client`, or both. */
168
+ readonly daemon?: { readonly baseUrl: string; readonly authToken?: string };
169
+ /** agent-device client configuration the worker's client is created with: a lease scope, a `stateDir`. */
170
+ readonly client?: DeviceClientConfig;
171
+ /** Device to select inside that daemon, when it hosts more than one. */
172
+ readonly device?: string;
173
+ /** Device to select inside that daemon by the id agent-device lists it under: a simulator UDID or an Android serial. */
174
+ readonly deviceId?: string;
175
+ /** Bundle id or package the provider installed itself; skips the target's `app.appPath`. */
176
+ readonly installedApp?: string;
177
+ }
178
+ ```
179
+
180
+ `acquire` receives the platform, run id, target, slot, the target's
181
+ `app.bundleId` as `app` and its resolved `app.appPath` as `appPath`, the
182
+ agent-device version the engine's client speaks (`agentDeviceVersion`, for a
183
+ service that starts a daemon of a chosen version), the directory the config's
184
+ relative paths resolve against (`projectRoot`), the run's environment, an
185
+ interrupt signal, and a `log` that reaches the reporter. Read tokens from
186
+ `request.env`, not `process.env`, and project files under
187
+ `request.projectRoot`, not `process.cwd()`. A target with nothing to run leases nothing. Every lease
188
+ granted is held by the engine, one it then refuses included: a slot that
189
+ fails to lease ends the run before any test, and the leases that succeeded
190
+ are still released. Release happens after the last worker exits, on every
191
+ exit path including failure, interrupt, and a `prepare` that threw, within
192
+ `cleanupTimeout`. `release` receives the object `acquire` returned, so a
193
+ provider can keep its own fields on it; only `id`, `daemon`, `client`,
194
+ `device`, `deviceId`, and `installedApp` travel to the workers. Report `installedApp` only for a
195
+ request that carried `appPath`.
196
+ Leased devices are warmed like local ones: booted, the iOS runner started,
197
+ and opened once before the run's clock starts.
198
+
199
+ `client` is agent-device client configuration, JSON data the worker's client
200
+ is created with on top of the session name and the `daemon` address (the
201
+ engine owns `session`, `daemonBaseUrl`, `daemonAuthToken`, and
202
+ `daemonTransport`, and refuses a lease that sets them). It is how a device reached through agent-device's own
203
+ device-cloud runtimes is told apart: the daemon resolves a hosted device from
204
+ the lease scope on each command (`tenant`, `runId`, `leaseId`, `leaseBackend`,
205
+ `leaseProvider`, as `leases.allocate` returned it), and a command without one
206
+ falls back to the local inventory. A `stateDir` in it points the worker at a
207
+ daemon the provider started, so such a provider needs no `daemon` address at
208
+ all.
209
+
210
+ A service that records its devices can record the attempt's video too.
211
+ Implement `record` and an attempt that records video calls it from the
212
+ worker, with the lease that worker drives, instead of starting agent-device's
213
+ screen recording. It returns a `ProviderRecording` (import the type from
214
+ `e2e/engine`), stopped when the attempt's video ends: `stop` either writes a
215
+ file into the directory it is given and returns `{ file }`, kept as
216
+ `video/<file>` beside the attempt's other artifacts, or returns
217
+ `{ url, mediaType }` for a recording the service keeps, which the report
218
+ links to. The lease `record` gets is the lease as it traveled to the worker,
219
+ so a field only `release` needs is not on it. A `record` that fails fails
220
+ the attempt's launch, named after the provider and the lease; a `stop` that
221
+ fails is tried once more when the attempt ends. A provider that cannot
222
+ record leaves `record` out, and the engine records with agent-device.
223
+
224
+ A provider against a typical session API has three requests: create a
225
+ session, poll it until the daemon is reachable, stop it.
226
+
227
+ ```ts title="device-provider.ts"
228
+ import type { DeviceProvider } from '@e2e-dev/mobile';
229
+
230
+ /**
231
+ * Leases devices from a service that starts an agent-device daemon per
232
+ * session. Replace the three requests with your service's API.
233
+ */
234
+ export function hostedDevices(options: { serviceUrl: string; image: string }): DeviceProvider {
235
+ return {
236
+ name: 'hosted-devices',
237
+ async acquire(request) {
238
+ const token = request.env['DEVICE_SERVICE_TOKEN'];
239
+ if (token === undefined || token === '') throw new Error('DEVICE_SERVICE_TOKEN is not set');
240
+ const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
241
+ // 1. Ask for a device; name the session after the run so it can be found later.
242
+ const created = await fetch(`${options.serviceUrl}/sessions`, {
243
+ method: 'POST',
244
+ headers,
245
+ body: JSON.stringify({
246
+ platform: request.platform,
247
+ image: options.image,
248
+ name: `e2e ${request.runId} ${request.targetName} ${request.slot + 1}/${request.slots}`,
249
+ }),
250
+ signal: request.signal,
251
+ });
252
+ if (!created.ok) throw new Error(`device service: HTTP ${created.status}`);
253
+ const { id } = (await created.json()) as { id: string };
254
+ // 2. Wait until the session exposes its agent-device daemon.
255
+ for (;;) {
256
+ const response = await fetch(`${options.serviceUrl}/sessions/${id}`, { headers, signal: request.signal });
257
+ const session = (await response.json()) as { status: string; daemonUrl?: string; daemonToken?: string; viewerUrl?: string };
258
+ if (session.status === 'ready' && session.daemonUrl !== undefined) {
259
+ if (session.viewerUrl !== undefined) request.log(`watch at ${session.viewerUrl}`);
260
+ return { id, daemon: { baseUrl: session.daemonUrl, authToken: session.daemonToken } };
261
+ }
262
+ if (session.status !== 'starting') throw new Error(`device service: session ${id} is ${session.status}`);
263
+ await new Promise((resolve) => setTimeout(resolve, 5_000));
264
+ }
265
+ },
266
+ async release(lease, context) {
267
+ // 3. Stop the session; the run waits for this within cleanupTimeout.
268
+ const token = context.env['DEVICE_SERVICE_TOKEN'] ?? '';
269
+ await fetch(`${options.serviceUrl}/sessions/${lease.id}/stop`, {
270
+ method: 'POST',
271
+ headers: { Authorization: `Bearer ${token}` },
272
+ signal: context.signal,
273
+ });
274
+ },
275
+ };
276
+ }
277
+ ```
278
+
279
+ ```ts title="e2e.config.ts"
280
+ import type { E2EConfig } from 'e2e';
281
+ import { mobile } from '@e2e-dev/mobile';
282
+ import { hostedDevices } from './device-provider.ts';
283
+
284
+ export default {
285
+ targets: [
286
+ {
287
+ name: 'ios',
288
+ engine: mobile({
289
+ platform: 'ios',
290
+ device: hostedDevices({ serviceUrl: 'https://devices.example.com', image: 'ios-18-iphone-17' }),
291
+ }),
292
+ app: { bundleId: 'com.example.app' },
293
+ },
294
+ ],
295
+ workers: 2,
296
+ } satisfies E2EConfig;
297
+ ```
298
+
299
+ Two workers lease two sessions in parallel. Hosted sessions are billed while
300
+ they run, so keep `workers` at what the suite can fill. The replay cache keys
301
+ on `app.bundleId`, so traces recorded locally replay on hosted devices.
302
+
303
+ A device cloud agent-device speaks to itself needs no session API of your
304
+ own: the provider allocates a lease through a daemon it starts for the run and
305
+ hands the worker the lease scope as `client`.
306
+
307
+ ```ts title="device-cloud-provider.ts"
308
+ import { createAgentDeviceClient } from 'agent-device';
309
+ import type { DeviceProvider } from '@e2e-dev/mobile';
310
+
311
+ /**
312
+ * Leases devices from a device cloud agent-device itself speaks to, through
313
+ * a daemon started for the run. The daemon's runtime for the cloud creates
314
+ * the instance when the lease is allocated and deletes it when the lease is
315
+ * released; the worker's client carries the lease, so every command lands on
316
+ * that instance. `provider` is agent-device's name for the cloud; its runtime
317
+ * reads the credentials from the environment the daemon starts with.
318
+ */
319
+ export function cloudDevices(options: { provider: string; stateDir: string }): DeviceProvider {
320
+ const scopes = new Map<string, { tenant: string; runId: string; leaseId: string; leaseBackend: 'ios-instance' | 'android-instance'; leaseProvider: string }>();
321
+ return {
322
+ name: options.provider,
323
+ async acquire(request) {
324
+ // One daemon per run, under a directory of its own.
325
+ const stateDir = `${options.stateDir}/${request.runId}`;
326
+ const client = createAgentDeviceClient({ stateDir, session: `lease-${request.slot}` });
327
+ const leaseBackend = request.platform === 'ios' ? ('ios-instance' as const) : ('android-instance' as const);
328
+ const lease = await client.leases.allocate({
329
+ tenant: options.provider,
330
+ runId: request.runId,
331
+ leaseBackend,
332
+ leaseProvider: options.provider,
333
+ platform: request.platform,
334
+ target: 'mobile',
335
+ });
336
+ request.log(`leased ${lease.leaseId}`);
337
+ const scope = { tenant: lease.tenantId, runId: lease.runId, leaseId: lease.leaseId, leaseBackend, leaseProvider: options.provider };
338
+ scopes.set(lease.leaseId, scope);
339
+ // The worker's client is created with these fields; the daemon resolves the device from them.
340
+ return { id: lease.leaseId, client: { stateDir, ...scope } };
341
+ },
342
+ async release(lease) {
343
+ const scope = scopes.get(lease.id);
344
+ if (scope === undefined) return;
345
+ const client = createAgentDeviceClient({ stateDir: `${options.stateDir}/${scope.runId}`, session: 'release' });
346
+ await client.leases.release(scope);
347
+ },
348
+ };
349
+ }
350
+ ```
351
+
352
+ ## Writing a device test
353
+
354
+ `screen` maps native controls to common roles such as `button`, `textbox`,
355
+ `listitem`, and `switch`. Use `getByTestId` for native element identifiers.
356
+
357
+ ```ts
358
+ import { test } from '@e2e-dev/mobile';
359
+ import { expect } from 'e2e';
360
+
361
+ test('shows the version offline in dark mode', async ({ agent, app, device, screen }) => {
362
+ await device.setAppearance('dark');
363
+ await device.setNetwork('offline');
364
+ await app.open();
365
+ await agent.act('go to General, then About');
366
+ await expect(screen.getByRole('button', /^iOS Version/)).toBeVisible();
367
+ await expect(device.locator('role=NavigationBar id=About')).toBeVisible();
368
+ });
369
+ ```
370
+
371
+ `selectOption`, `setInputFiles`, `scrollIntoView`, `secondaryTap`, and
372
+ `modifiers` on `tap` or `doubleTap` are unsupported on devices. Use taps and swipes to interact with native controls.
373
+
374
+ ### The `device` fixture
375
+
376
+ Use `device` to manage network state, permissions, appearance, app installs,
377
+ and other device settings without a model call. Each call appears as a
378
+ `device.<method>` step in the report.
379
+
380
+ `device.locator('...')` accepts an agent-device selector for controls that
381
+ `screen` queries cannot identify:
382
+
383
+ ```ts
384
+ await expect(device.locator('role=NavigationBar id=About')).toBeVisible();
385
+ await device.locator('id=SW_VERSION_SPECIFIER role=Button').tap();
386
+ ```
387
+
388
+ Use `device.installApp` within a test to check upgrades or fresh installs; with no path it installs the target's `app.appPath`:
389
+
390
+ ```ts
391
+ const { app } = await device.installApp('./build/MyApp.app', { reinstall: true });
392
+ await device.openApp(app);
393
+ ```
394
+
395
+ `device.openApp` takes a bundle id, package, or display name, never a link.
396
+ `device.openLink` opens a deep link or a web link into the pinned app, or
397
+ into the `app` named in its options, for magic-link sign-in and deep-link
398
+ routes:
399
+
400
+ ```ts
401
+ await device.openLink('myapp://orders/42');
402
+ await expect(screen.getByText('Order #42')).toBeVisible();
403
+ ```
404
+
405
+ `device.clearKeychain()` resets the simulator's keychain, which
406
+ `app.clearState()` leaves alone, so a stored session token is gone on the
407
+ next launch. iOS only; Android has no keychain to reset.
408
+
409
+ `device.fold(pose)` puts a foldable iOS simulator, the iPhone Duo, in a hinge
410
+ pose: `closed` lights the outer display, `half-open` (a 130° book) and `open`
411
+ the inner one. It returns once the simulator reports the hinge at that angle,
412
+ typically 10 to 16 seconds, so keep `actionTimeout` at 20 seconds or more, and
413
+ the next observation reads the app on the other display. The engine never
414
+ resets the pose: a retry and the next test start in whatever pose a test left,
415
+ so fold back in `afterEach`, which runs when the test fails too:
416
+
417
+ ```ts
418
+ import { afterEach, test } from '@e2e-dev/mobile';
419
+
420
+ afterEach(async ({ device }) => {
421
+ await device.fold('closed');
422
+ });
423
+
424
+ test('the list and the detail split across the fold', async ({ agent, app, device }) => {
425
+ await app.open();
426
+ await device.fold('half-open');
427
+ await agent.assert('the list and the detail sit on either side of the fold', { vision: true });
428
+ });
429
+ ```
430
+
431
+ Every method is in the [mobile engine reference](/reference/mobile#device).
432
+
433
+ ### Agent tools
434
+
435
+ The agent already has basic actions such as `tap`, `type`, and `scroll`.
436
+ Add `mobileTools` for app switching, device gestures, and system alerts:
437
+
438
+ ```ts title="e2e.config.ts"
439
+ import type { E2EConfig } from 'e2e';
440
+ import { mobile } from '@e2e-dev/mobile';
441
+ import { mobileTools } from '@e2e-dev/mobile/tools';
442
+ import { gateway } from 'ai';
443
+
444
+ const iphone = mobile({ platform: 'ios' });
445
+ const pixel = mobile({ platform: 'android' });
446
+ const app = { bundleId: 'com.example.app' };
447
+
448
+ export default {
449
+ targets: [
450
+ { name: 'iphone', engine: iphone, app },
451
+ { name: 'pixel', engine: pixel, app },
452
+ ],
453
+ workers: 1,
454
+ agents: {
455
+ default: {
456
+ model: gateway('openai/gpt-6-luna-fast'),
457
+ tools: mobileTools(iphone, pixel),
458
+ },
459
+ },
460
+ } satisfies E2EConfig;
461
+ ```
462
+
463
+ Pass each configured device engine to `mobileTools`. Calls go to the
464
+ engine running the current attempt. `open_app`, `swipe`, and `alert` all
465
+ change device state, so the agent takes over for them during cache replay.
466
+ The [mobile engine reference](/reference/mobile#agent-tools) describes each
467
+ tool.
468
+
469
+ ## What differs from the web
470
+
471
+ | On the web | On a device |
472
+ | --- | --- |
473
+ | `app.open('/path')` and the agent's `navigate` verb | No app URL. `app.open()` launches the target's `app.bundleId` fresh, `device.openLink(url)` opens a deep link or web link into it, and there is no `navigate` |
474
+ | `test.setup` saves a session, `session:` restores it | Not available. A simulator has no portable session snapshot; sign in per test or seed the app |
475
+ | Secret input | `type_secret` and `screen.getByLabel(...).fill(secret)` fill declared secrets into secure fields |
476
+ | Playwright trace per attempt | No Playwright trace; screenshots mask secure fields |
477
+ | `--video` writes a WebM screencast | `--video` writes an MP4 of the device screen with taps shown (`videoTouches: false` hides them), or keeps a device provider's own recording |
478
+ | The cache anchors on the page path | The cache anchors on the location `<bundle id> / <screen title>`, the title read off the navigation bar on iOS and the toolbar on Android |
479
+
480
+ ### Known device limitations
481
+
482
+ - `placeholder` is the only attribute a device node has.
483
+ - A React Native tab (`accessibilityRole="tab"`) on iOS has role `other`.
484
+ Query it by test id or label and assert `toBeSelected`. Checkboxes,
485
+ radios, and `toBeChecked` work on both platforms.
486
+ - On iOS a plain React Native `View` has no children in the tree, so
487
+ `filter({ has })` on it and queries scoped to it match nothing. Scope to the screen's `ScrollView`
488
+ (`device.locator('role=ScrollView')`), or give the children test ids.
489
+ - An element hidden with `display: none` is absent from the tree, so
490
+ `toBeAttached` fails. Assert
491
+ `toHaveCount(0)` or `toBeHidden`.
492
+ - `doubleTap()` on iOS is two taps about 285 ms apart. Give a double-tap
493
+ detector a window of 350 ms or more.
494
+ - On iOS, `device.foregroundApp()` still names the session's app after
495
+ `device.home()`. Check the screen instead.
496
+ - On an iPhone Duo, `--video` records the display that was lit when the
497
+ recording started. After `device.fold` moves the app to the other display,
498
+ the rest of the video is black.
499
+
500
+ ## Troubleshooting
501
+
502
+ A device failure shows agent-device's own message. Under the agent, an
503
+ engine fault with no more specific code reports as `APP_UNREACHABLE`, so
504
+ read the message before the code: the app is often not the part that
505
+ failed.
506
+
507
+ **The iOS automation runner is busy, wedged, or could not present a
508
+ snapshot.** A heavy or animating screen can overrun the runner, and a run
509
+ stopped with Ctrl-C can leave it busy for the next run. The message reads:
510
+
511
+ ```text
512
+ APP_UNREACHABLE: agent.act failed: snapshot failed: the iOS automation runner is still
513
+ finishing a command that overran its watchdog (session e2e-ios-0 on iPhone 17 Pro):
514
+ The iOS runner is still finishing a previous command that exceeded its execution
515
+ watchdog (usually an accessibility capture on a heavy or animating screen). The app
516
+ is fine. Wait a few seconds and rerun. If the next run meets it again, stop the
517
+ daemon (`npx agent-device daemon stop`) and reboot the simulator
518
+ (`xcrun simctl shutdown <udid>`, then `boot`).
519
+ ```
520
+
521
+ Do what it says.
522
+
523
+ **A timeout on the device.** A simulator boot, runner connection, or app
524
+ launch that times out on the device is `ENGINE_FAILURE` with agent-device's
525
+ message and hint, never one of e2e's own timeout codes (`LAUNCH_TIMEOUT`,
526
+ `ACTION_FAILED`, `STEP_TIMEOUT`).
527
+
528
+ <CodeGroup>
529
+ ```bash npm
530
+ npx agent-device doctor # the toolchain, the daemon, the booted devices
531
+ npx agent-device daemon stop # after a wedge: drops every session, the next run starts the daemon
532
+ xcrun simctl shutdown <udid> && xcrun simctl boot <udid>
533
+ ```
534
+
535
+ ```bash pnpm
536
+ pnpm exec agent-device doctor # the toolchain, the daemon, the booted devices
537
+ pnpm exec agent-device daemon stop # after a wedge: drops every session, the next run starts the daemon
538
+ xcrun simctl shutdown <udid> && xcrun simctl boot <udid>
539
+ ```
540
+
541
+ ```bash bun
542
+ bunx agent-device doctor # the toolchain, the daemon, the booted devices
543
+ bunx agent-device daemon stop # after a wedge: drops every session, the next run starts the daemon
544
+ xcrun simctl shutdown <udid> && xcrun simctl boot <udid>
545
+ ```
546
+ </CodeGroup>
547
+
548
+ <CardGroup cols={2}>
549
+ <Card title="Mobile engine reference" icon="mobile-screen" href="/reference/mobile">
550
+ Every option, the `device` fixture, and the tool pack.
551
+ </Card>
552
+ <Card title="Writing tests" icon="pen-to-square" href="/writing-tests">
553
+ Goals, checks, and the deterministic APIs, on any platform.
554
+ </Card>
555
+ </CardGroup>