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.
- package/README.md +51 -24
- package/dist/.build.json +1 -1
- package/dist/agent/act.js +3 -3
- package/dist/agent/act.js.map +1 -1
- package/dist/agent/error.d.ts +16 -3
- package/dist/agent/error.d.ts.map +1 -1
- package/dist/agent/error.js +25 -6
- package/dist/agent/error.js.map +1 -1
- package/dist/agent/model/tool-choice.d.ts +24 -1
- package/dist/agent/model/tool-choice.d.ts.map +1 -1
- package/dist/agent/model/tool-choice.js +27 -1
- package/dist/agent/model/tool-choice.js.map +1 -1
- package/dist/agent/step-cache.d.ts +14 -3
- package/dist/agent/step-cache.d.ts.map +1 -1
- package/dist/agent/step-cache.js +13 -3
- package/dist/agent/step-cache.js.map +1 -1
- package/dist/agent/tool-loop.d.ts.map +1 -1
- package/dist/agent/tool-loop.js +62 -9
- package/dist/agent/tool-loop.js.map +1 -1
- package/dist/cache/context.d.ts +19 -6
- package/dist/cache/context.d.ts.map +1 -1
- package/dist/cache/context.js +15 -21
- package/dist/cache/context.js.map +1 -1
- package/dist/cli/init/scaffold.d.ts +1 -0
- package/dist/cli/init/scaffold.d.ts.map +1 -1
- package/dist/cli/init/scaffold.js +2 -1
- package/dist/cli/init/scaffold.js.map +1 -1
- package/dist/cli/init/sibling-versions.json +1 -1
- package/dist/expect/values.js +1 -1
- package/dist/expect/values.js.map +1 -1
- package/dist/run/execute.d.ts.map +1 -1
- package/dist/run/execute.js +21 -8
- package/dist/run/execute.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/docs/agent-steps.mdx +126 -0
- package/docs/agents.mdx +217 -0
- package/docs/api-testing.mdx +65 -0
- package/docs/authentication.mdx +345 -0
- package/docs/browser.mdx +440 -0
- package/docs/bug-bash.mdx +90 -0
- package/docs/cache.mdx +306 -0
- package/docs/ci.mdx +210 -0
- package/docs/coding-agents.mdx +183 -0
- package/docs/debugging.mdx +291 -0
- package/docs/executors.mdx +243 -0
- package/docs/explore.mdx +174 -0
- package/docs/github.mdx +184 -0
- package/docs/index.mdx +82 -0
- package/docs/integrations/eas.mdx +216 -0
- package/docs/integrations/index.mdx +24 -0
- package/docs/integrations/kernel.mdx +163 -0
- package/docs/migrate/cypress.mdx +124 -0
- package/docs/migrate/detox.mdx +140 -0
- package/docs/migrate/maestro.mdx +229 -0
- package/docs/migrate/playwright.mdx +427 -0
- package/docs/migrate/selenium.mdx +110 -0
- package/docs/mobile-ci.mdx +567 -0
- package/docs/mobile.mdx +555 -0
- package/docs/models.mdx +241 -0
- package/docs/quickstart.mdx +260 -0
- package/docs/reference/agent.mdx +524 -0
- package/docs/reference/app.mdx +72 -0
- package/docs/reference/cli.mdx +817 -0
- package/docs/reference/config.mdx +986 -0
- package/docs/reference/engine.mdx +1362 -0
- package/docs/reference/environment.mdx +113 -0
- package/docs/reference/errors.mdx +143 -0
- package/docs/reference/expect.mdx +513 -0
- package/docs/reference/mcp.mdx +202 -0
- package/docs/reference/mobile.mdx +522 -0
- package/docs/reference/reporters.mdx +216 -0
- package/docs/reference/screen.mdx +688 -0
- package/docs/reference/test.mdx +449 -0
- package/docs/reference/web.mdx +611 -0
- package/docs/security.mdx +292 -0
- package/docs/starting-your-app.mdx +231 -0
- package/docs/subscriptions.mdx +157 -0
- package/docs/telemetry.mdx +185 -0
- package/docs/tools.mdx +150 -0
- package/docs/viewports.mdx +146 -0
- package/docs/writing-an-engine.mdx +316 -0
- package/docs/writing-tests.mdx +256 -0
- package/package.json +2 -1
- package/skills/e2e/SKILL.md +4 -1
- package/skills/e2e/references/agent.md +3 -1
- package/skills/e2e/references/setup.md +13 -4
package/docs/mobile.mdx
ADDED
|
@@ -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>
|