@e2e-dev/mobile 0.9.1 → 0.9.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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # @e2e-dev/mobile
2
2
 
3
3
  The mobile engine for [`e2e`](https://www.npmjs.com/package/e2e), built on
4
- [agent-device](https://github.com/callstack/agent-device): iOS simulators and
5
- Android emulators through the same `e2e/engine` contract the browser engine
4
+ [agent-device](https://github.com/callstack/agent-device): iOS simulators,
5
+ Android emulators, and connected phones through the same `e2e/engine` contract the browser engine
6
6
  implements. A test written against `screen`, `expect`, `app`, and `agent` runs
7
7
  on a device target unchanged; nothing in `e2e` core knows this package exists.
8
8
 
@@ -18,7 +18,8 @@ npm install --save-dev e2e @e2e-dev/mobile
18
18
  ```
19
19
 
20
20
  `agent-device` needs Xcode with an iOS simulator runtime, or the Android SDK
21
- with an emulator. Run `npx agent-device doctor` once before handing the target
21
+ with an emulator. A phone needs Developer Mode and runner signing on iOS, or
22
+ USB debugging authorized on Android (see [Physical devices](https://e2e.tester.army/docs/physical-devices)). Run `npx agent-device doctor` once before handing the target
22
23
  to the runner.
23
24
 
24
25
  ```ts title="e2e.config.ts"
@@ -68,7 +69,7 @@ the other old app options are `INVALID_CONFIG` naming their key under `app`.
68
69
  | Option | Meaning |
69
70
  | --- | --- |
70
71
  | `platform` | `'ios'` or `'android'`. |
71
- | `device` | Simulator or emulator name or UDID. A list is a pool: one worker per entry, worker slot `n` driving the `n`th. Omitted, every booted device of the platform is the pool, as many as the run has slots. |
72
+ | `device` | Simulator or emulator name or UDID, or a connected phone's name. A list is a pool: one worker per entry, worker slot `n` driving the `n`th. Omitted, every booted device of the platform is the pool, as many as the run has slots. |
72
73
  | `session` | agent-device session name, before the worker slot: slot `n` drives its device under `<session>-<n>`, `e2e-<target name>-<n>` by default. One run per session at a time. |
73
74
  | `snapshot` | `'full'` (default, includes static text) or `'interactive'` (actionable nodes only). |
74
75
  | `settle` | For agent actions: milliseconds the UI must hold still after an action before the agent observes again, default `150`; `false` skips the wait. A test's own steps never settle; `expect` verifies their outcome. |
@@ -145,7 +146,8 @@ test, for upgrade and fresh-install paths the target's `app.appPath` cannot
145
146
  express.
146
147
  A plain install replaces the binary and keeps its data; `reinstall: true`
147
148
  removes the app named by `app` (default: the pinned app) first. It resolves to
148
- the bundle id or package to `openApp` the build by. `openApp` takes an app id,
149
+ the bundle id or package to `openApp` the build by, else the `app` passed in;
150
+ with no reported id and no `app`, it fails with `ENGINE_FAILURE`. `openApp` takes an app id,
149
151
  never a link; `openLink` opens one, under the navigation rule.
150
152
 
151
153
  ## Agent tools
package/dist/.build.json CHANGED
@@ -1 +1 @@
1
- {"version":"0.9.1"}
1
+ {"version":"0.9.2"}
package/dist/device.d.ts CHANGED
@@ -82,8 +82,8 @@ export interface Device {
82
82
  * a web link the app does not claim goes to Safari, and the next
83
83
  * observation brings the app back. Android starts the link on that
84
84
  * package. Without an app, Android lets the OS route the link; iOS needs
85
- * one (`INVALID_ARGUMENT` without). `file:`, `data:`, and `javascript:`
86
- * links are `POLICY_DENIED`.
85
+ * one (`INVALID_ARGUMENT` without). `file:`, `data:`, `javascript:`,
86
+ * `view-source:`, `blob:`, and `filesystem:` links are `POLICY_DENIED`.
87
87
  */
88
88
  openLink(url: string, options?: {
89
89
  app?: string;
package/dist/errors.d.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * | no active session / session not found / requires a session | INVALID_STATE |
9
9
  * | UNSUPPORTED_OPERATION, NOT_IMPLEMENTED, UNSUPPORTED_PLATFORM | UNSUPPORTED_CAPABILITY |
10
10
  * | "not supported on this device" under any code | UNSUPPORTED_CAPABILITY |
11
- * | a ref the daemon no longer knows (action paths only) | NODE_STALE (retryable) |
11
+ * | a stale-ref refusal, by `details.reason` (action paths only) | NODE_STALE (retryable) |
12
12
  * | the iOS runner busy, wedged, or past its watchdog | ENGINE_FAILURE, naming the |
13
13
  * | a snapshot the iOS runner acquired but could not present | runner and the recovery |
14
14
  * | anything else, agent-device's own timeouts included | ENGINE_FAILURE with the text |
@@ -53,8 +53,10 @@ export declare function isSnapshotPresentationFailure(error: unknown): boolean;
53
53
  */
54
54
  export declare function runCommand<T>(label: string, work: () => Promise<T>, signal: AbortSignal, where?: string): Promise<T>;
55
55
  /**
56
- * Like translateError, but a ref the daemon no longer binds becomes retryable
57
- * `NODE_STALE`: nothing was dispatched, so the harness may re-observe and
58
- * re-resolve the node instead of failing the action.
56
+ * Like translateError, but a ref agent-device refused as stale becomes
57
+ * retryable `NODE_STALE`: nothing was dispatched, so the harness may
58
+ * re-observe and re-resolve the node instead of failing the action. Keyed on
59
+ * the reason alone: agent-device marks a drag's stale ref `dispatched:
60
+ * 'unknown'` although it refuses it before the gesture.
59
61
  */
60
62
  export declare function staleOr(cause: unknown, operation: string, where?: string): Error;
package/dist/errors.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * | no active session / session not found / requires a session | INVALID_STATE |
9
9
  * | UNSUPPORTED_OPERATION, NOT_IMPLEMENTED, UNSUPPORTED_PLATFORM | UNSUPPORTED_CAPABILITY |
10
10
  * | "not supported on this device" under any code | UNSUPPORTED_CAPABILITY |
11
- * | a ref the daemon no longer knows (action paths only) | NODE_STALE (retryable) |
11
+ * | a stale-ref refusal, by `details.reason` (action paths only) | NODE_STALE (retryable) |
12
12
  * | the iOS runner busy, wedged, or past its watchdog | ENGINE_FAILURE, naming the |
13
13
  * | a snapshot the iOS runner acquired but could not present | runner and the recovery |
14
14
  * | anything else, agent-device's own timeouts included | ENGINE_FAILURE with the text |
@@ -61,7 +61,20 @@ const CLI_ADVICE = /--help|command arguments/i;
61
61
  const NO_SESSION_PATTERN = /no active (?:app )?session|requires an active session|session\b.*\bnot found|open an app first|no app (?:is )?open/i;
62
62
  const UNSUPPORTED_CODES = new Set(['UNSUPPORTED_OPERATION', 'NOT_IMPLEMENTED', 'UNSUPPORTED_PLATFORM']);
63
63
  const UNSUPPORTED_PATTERN = /\b(?:is )?not supported\b|\bunsupported\b/i;
64
- const STALE_PATTERN = /\bref\b.*\b(?:not found|unknown|stale|no longer|expired|invalid|missing)|\b(?:not found|unknown|stale|no longer|expired|invalid|missing)\b.*\bref\b|refs?generation/i;
64
+ /**
65
+ * The `details.reason` values agent-device refuses an `@ref` with: the ref
66
+ * names no node of the session's tree, or belongs to a capture the session
67
+ * has since moved past (a newer snapshot, or an action that expired its refs).
68
+ * agent-device raises each one while resolving or admitting the ref, before
69
+ * the action reaches the device.
70
+ */
71
+ const STALE_REF_REASONS = new Set([
72
+ 'ref_not_found',
73
+ 'ref_frame_expired',
74
+ 'ref_generation_mismatch',
75
+ 'plain_ref_requires_complete_frame',
76
+ 'ref_not_issued',
77
+ ]);
65
78
  /**
66
79
  * What the iOS automation runner reported about itself, by the code
67
80
  * agent-device emits for it: `RUNNER_WEDGED` at the top level, `RUNNER_BUSY`
@@ -207,16 +220,18 @@ export async function runCommand(label, work, signal, where) {
207
220
  }
208
221
  }
209
222
  /**
210
- * Like translateError, but a ref the daemon no longer binds becomes retryable
211
- * `NODE_STALE`: nothing was dispatched, so the harness may re-observe and
212
- * re-resolve the node instead of failing the action.
223
+ * Like translateError, but a ref agent-device refused as stale becomes
224
+ * retryable `NODE_STALE`: nothing was dispatched, so the harness may
225
+ * re-observe and re-resolve the node instead of failing the action. Keyed on
226
+ * the reason alone: agent-device marks a drag's stale ref `dispatched:
227
+ * 'unknown'` although it refuses it before the gesture.
213
228
  */
214
229
  export function staleOr(cause, operation, where) {
215
230
  if (isClassified(cause))
216
231
  return cause;
217
- const normalized = normalizeAgentDeviceError(cause);
218
- if (STALE_PATTERN.test(normalized.message)) {
219
- return new EngineError('NODE_STALE', `${operation}: ${normalized.message}`, { retryable: true, cause });
232
+ const { reason } = details(cause);
233
+ if (typeof reason === 'string' && STALE_REF_REASONS.has(reason)) {
234
+ return new EngineError('NODE_STALE', `${operation}: ${message(cause)}`, { retryable: true, cause });
220
235
  }
221
236
  return translateError(cause, operation, where);
222
237
  }
package/dist/links.d.ts CHANGED
@@ -5,22 +5,27 @@
5
5
  *
6
6
  * The runner's own rule (`resolveNavigationUrl`, reached by a fixture as
7
7
  * `context.app.resolveUrl`) first demands an app URL, which a device engine
8
- * never declares, so the same small check lives here rather than behind a
9
- * gate that would refuse every link.
8
+ * never declares, and admits http(s) alone, where a device link is any app's
9
+ * custom scheme. So the check here is a list of refused schemes: the ones
10
+ * that read local content or inline a document, and the browser wrappers
11
+ * (`view-source:file:...`, `blob:`, `filesystem:`) that carry one inside.
10
12
  */
11
13
  /** Whether agent-device would open this string as a URL rather than resolve it as an app. */
12
14
  export declare function isLink(value: string): boolean;
13
15
  /**
14
16
  * Parses a link a test asked to open. A string that is not an absolute URL
15
- * is `INVALID_ARGUMENT`; a `file:`, `data:`, or `javascript:` link is
16
- * `POLICY_DENIED`, as it is for `app.open` on the web.
17
+ * is `INVALID_ARGUMENT`; a `file:`, `data:`, `javascript:`, `view-source:`,
18
+ * `blob:`, or `filesystem:` link is `POLICY_DENIED`, as it is for `app.open`
19
+ * on the web. The message never echoes the input: a malformed link can carry
20
+ * a magic-link token in its query, path, or userinfo, and nothing about a
21
+ * string that failed to parse says which part is safe to repeat.
17
22
  */
18
23
  export declare function linkTarget(input: string): URL;
19
24
  /**
20
25
  * Refuses a link handed to `openApp`, which opens an app by bundle id,
21
26
  * package, or display name. agent-device opens any `scheme:rest` string as
22
- * a URL, so the check runs before the device sees it: a `file:`, `data:`,
23
- * or `javascript:` link is `POLICY_DENIED` as it is for `openLink`, any
27
+ * a URL, so the check runs before the device sees it: a link with a
28
+ * forbidden scheme is `POLICY_DENIED` as it is for `openLink`, any
24
29
  * other link is `INVALID_ARGUMENT` pointing at `openLink`. The message
25
30
  * never echoes the link, whose query may carry a magic-link token.
26
31
  */
package/dist/links.js CHANGED
@@ -5,11 +5,13 @@
5
5
  *
6
6
  * The runner's own rule (`resolveNavigationUrl`, reached by a fixture as
7
7
  * `context.app.resolveUrl`) first demands an app URL, which a device engine
8
- * never declares, so the same small check lives here rather than behind a
9
- * gate that would refuse every link.
8
+ * never declares, and admits http(s) alone, where a device link is any app's
9
+ * custom scheme. So the check here is a list of refused schemes: the ones
10
+ * that read local content or inline a document, and the browser wrappers
11
+ * (`view-source:file:...`, `blob:`, `filesystem:`) that carry one inside.
10
12
  */
11
13
  import { ConfigurationError, TestError } from 'e2e/engine';
12
- const FORBIDDEN_PROTOCOLS = new Set(['file:', 'data:', 'javascript:']);
14
+ const FORBIDDEN_PROTOCOLS = new Set(['file:', 'data:', 'javascript:', 'view-source:', 'blob:', 'filesystem:']);
13
15
  /**
14
16
  * What agent-device opens as a URL instead of resolving as an app: a
15
17
  * `scheme:rest` string with no whitespace. Every string the device would
@@ -28,8 +30,11 @@ function denyForbiddenScheme(protocol) {
28
30
  }
29
31
  /**
30
32
  * Parses a link a test asked to open. A string that is not an absolute URL
31
- * is `INVALID_ARGUMENT`; a `file:`, `data:`, or `javascript:` link is
32
- * `POLICY_DENIED`, as it is for `app.open` on the web.
33
+ * is `INVALID_ARGUMENT`; a `file:`, `data:`, `javascript:`, `view-source:`,
34
+ * `blob:`, or `filesystem:` link is `POLICY_DENIED`, as it is for `app.open`
35
+ * on the web. The message never echoes the input: a malformed link can carry
36
+ * a magic-link token in its query, path, or userinfo, and nothing about a
37
+ * string that failed to parse says which part is safe to repeat.
33
38
  */
34
39
  export function linkTarget(input) {
35
40
  let url;
@@ -37,7 +42,7 @@ export function linkTarget(input) {
37
42
  url = new URL(input);
38
43
  }
39
44
  catch {
40
- throw new TestError('INVALID_ARGUMENT', `openLink needs an absolute URL such as myapp://orders/42 or https://example.com/verify, got "${input}"`);
45
+ throw new TestError('INVALID_ARGUMENT', 'openLink needs an absolute URL such as myapp://orders/42 or https://example.com/verify');
41
46
  }
42
47
  denyForbiddenScheme(url.protocol);
43
48
  return url;
@@ -45,8 +50,8 @@ export function linkTarget(input) {
45
50
  /**
46
51
  * Refuses a link handed to `openApp`, which opens an app by bundle id,
47
52
  * package, or display name. agent-device opens any `scheme:rest` string as
48
- * a URL, so the check runs before the device sees it: a `file:`, `data:`,
49
- * or `javascript:` link is `POLICY_DENIED` as it is for `openLink`, any
53
+ * a URL, so the check runs before the device sees it: a link with a
54
+ * forbidden scheme is `POLICY_DENIED` as it is for `openLink`, any
50
55
  * other link is `INVALID_ARGUMENT` pointing at `openLink`. The message
51
56
  * never echoes the link, whose query may carry a magic-link token.
52
57
  */
package/dist/nodes.d.ts CHANGED
@@ -6,48 +6,14 @@
6
6
  * vocabulary `screen.getByRole` and trace relocation speak, and mints the
7
7
  * ids the surface owns.
8
8
  */
9
+ import type { SnapshotNode } from 'agent-device';
9
10
  import type { SemanticNode, ViewportSize } from 'e2e/engine';
10
- import type { Rect } from './support.ts';
11
- /** The subset of an agent-device snapshot node this engine reads. */
12
- export interface RawNode {
13
- readonly ref?: string;
14
- readonly index?: number;
15
- readonly parentIndex?: number;
16
- readonly depth?: number;
17
- readonly type?: string;
18
- readonly role?: string;
19
- readonly label?: string;
20
- readonly value?: string;
21
- /** True while a text field shows its placeholder; the runner then reports the placeholder as the value. */
22
- readonly hintShowing?: boolean;
23
- /**
24
- * Android's content description when it differs from `label`: the
25
- * accessible name an app set beside visible text (agent-device 0.21.14+).
26
- */
27
- readonly contentDescription?: string;
28
- /** The field's placeholder text, showing or not (agent-device 0.21.16+). */
29
- readonly placeholder?: string;
30
- /** The checked state of a checkable control; absent for one that cannot be checked or an older helper. */
31
- readonly checked?: boolean;
32
- /** The accessibility heading flag an app set on the node (Android). */
33
- readonly heading?: boolean;
34
- /** The localized role description an app set beside the native class, verbatim (`Tab`, `Tab List`). */
35
- readonly roleDescription?: string;
36
- readonly identifier?: string;
37
- readonly rect?: Rect;
38
- readonly enabled?: boolean;
39
- readonly selected?: boolean;
40
- readonly focused?: boolean;
41
- /** Native accessibility facts; absent means unavailable, not false. */
42
- readonly editable?: boolean;
43
- readonly password?: boolean;
44
- readonly visibleToUser?: boolean;
45
- readonly hittable?: boolean;
46
- readonly appName?: string;
47
- readonly windowTitle?: string;
48
- /** The package (Android) or bundle id (iOS) of the window the node belongs to, when agent-device reports it. */
49
- readonly bundleId?: string;
50
- }
11
+ /**
12
+ * One agent-device snapshot node as this engine reads it: agent-device's own
13
+ * node type, every field optional, since the projection tolerates each one
14
+ * missing (a fixture, a producer that does not read it).
15
+ */
16
+ export type RawNode = Readonly<Partial<SnapshotNode>>;
51
17
  /** One projected node with what the surface needs to act on it and to answer selector terms. */
52
18
  export interface ProjectedNode {
53
19
  readonly id: string;
package/dist/options.d.ts CHANGED
@@ -33,8 +33,9 @@ export interface MobileOptions {
33
33
  /** Platform the target's device runs. */
34
34
  readonly platform: MobilePlatform;
35
35
  /**
36
- * Simulator or emulator to use, by name, simulator UDID, or emulator
37
- * serial (`emulator-5554`). A list is a pool: the
36
+ * Device to use: a simulator or emulator by name, simulator UDID, or
37
+ * emulator serial (`emulator-5554`), or a connected phone by name. A list
38
+ * is a pool: the
38
39
  * engine declares one worker per entry and worker slot `n` drives the
39
40
  * `n`th, so `workers` at or above the pool size runs the target's files
40
41
  * across every device at once. Omitted, the pool is every booted device of
package/dist/support.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  /** Shared helpers for the agent-device engine: error constructors, filenames, PNG headers, gestures, the screen location and size. */
2
+ import type { CaptureScreenshotResult } from 'agent-device';
2
3
  import { EngineError, type Momentum, type ScrollDirection, type ViewportSize } from 'e2e/engine';
3
4
  export interface Point {
4
5
  readonly x: number;
@@ -46,14 +47,7 @@ export declare function swipeWithin(rect: Rect, direction: ScrollDirection, mome
46
47
  */
47
48
  export declare function screenLocation(app: string | undefined, title: string | undefined): string | undefined;
48
49
  /** The screenshot fields a viewport probe reads off agent-device's response. */
49
- export interface RawScreenshotResult {
50
- readonly path?: string;
51
- readonly width?: number;
52
- readonly height?: number;
53
- readonly logicalWidth?: number;
54
- readonly logicalHeight?: number;
55
- readonly pixelDensity?: number;
56
- }
50
+ export type RawScreenshotResult = Partial<Pick<CaptureScreenshotResult, 'path' | 'width' | 'height' | 'logicalWidth' | 'logicalHeight' | 'pixelDensity'>>;
57
51
  /**
58
52
  * The device's logical screen size as its screenshot reports it, in points:
59
53
  * the logical dimensions when given, else the pixel dimensions over the
package/dist/surface.js CHANGED
@@ -634,11 +634,15 @@ export class AgentDeviceSurface {
634
634
  if (options.reinstall === true && app === undefined) {
635
635
  throw invalidState("reinstall needs an app: pass `app`, or pin one with the target's app.bundleId or app.appPath");
636
636
  }
637
- const result = (await this.command(`install ${resolved}`, (client) => options.reinstall === true && app !== undefined
637
+ const result = await this.command(`install ${resolved}`, (client) => options.reinstall === true && app !== undefined
638
638
  ? client.apps.reinstall({ ...selection, app, appPath: resolved })
639
- : client.apps.install({ ...selection, ...(app === undefined ? {} : { app }), appPath: resolved }), signal));
639
+ : client.apps.install({ ...selection, ...(app === undefined ? {} : { app }), appPath: resolved }), signal);
640
640
  const identity = result.bundleId ?? result.package ?? result.appId;
641
- const installed = { app: identity ?? result.app, ...(identity === undefined ? {} : { bundleId: identity }) };
641
+ const opensBy = identity ?? app;
642
+ if (opensBy === undefined) {
643
+ throw new EngineError('ENGINE_FAILURE', `installed ${resolved}, but agent-device reported no bundle id or package for it, so nothing can open it: name the app with the target's \`app.bundleId\` or installApp's \`app\` option`, { retryable: false });
644
+ }
645
+ const installed = { app: opensBy, ...(identity === undefined ? {} : { bundleId: identity }) };
642
646
  // The engine's own build, installed: without `app`, this is what `app.open()` launches from here on.
643
647
  if (engineBuild)
644
648
  this.installedApp = installed.app;
@@ -1133,7 +1137,7 @@ export class AgentDeviceSurface {
1133
1137
  async restart(operation) {
1134
1138
  const app = this.pinnedApp;
1135
1139
  if (app === undefined)
1136
- throw unsupported(`app.restart needs the target's \`app.bundleId\`, or ${UNINSTALLED_BUILD}`);
1140
+ throw unsupported(`launching the app needs the target's \`app.bundleId\`, or ${UNINSTALLED_BUILD}`);
1137
1141
  await this.openApp(app, { relaunch: true }, operation.signal);
1138
1142
  }
1139
1143
  /** Clears the pinned app's persisted state and relaunches it: the device equivalent of a fresh context. */
@@ -1199,7 +1203,7 @@ export class AgentDeviceSurface {
1199
1203
  const directory = mkdtempSync(path.join(tmpdir(), 'e2e-agent-device-'));
1200
1204
  const file = path.join(directory, 'screenshot.png');
1201
1205
  try {
1202
- return await read((await client.capture.screenshot({ path: file })), file);
1206
+ return await read(await client.capture.screenshot({ path: file }), file);
1203
1207
  }
1204
1208
  finally {
1205
1209
  rmSync(directory, { recursive: true, force: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@e2e-dev/mobile",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Mobile engine (iOS and Android) for e2e agentic end-to-end testing, built on agent-device",
5
5
  "keywords": [
6
6
  "e2e",
@@ -51,7 +51,7 @@
51
51
  "NOTICE"
52
52
  ],
53
53
  "dependencies": {
54
- "agent-device": "0.21.18",
54
+ "agent-device": "0.21.20",
55
55
  "pngjs": "7.0.0",
56
56
  "zod": "4.6.1"
57
57
  },
@@ -68,7 +68,7 @@
68
68
  "@types/node": "26.6.2",
69
69
  "@types/pngjs": "6.0.5",
70
70
  "ai": "7.0.107",
71
- "e2e": "0.16.0",
71
+ "e2e": "0.17.0",
72
72
  "typescript": "7.0.2",
73
73
  "vitest": "5.0.1"
74
74
  },