@e2e-dev/mobile 0.11.1-nightly-20261012095456 → 0.12.0

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
@@ -95,7 +95,7 @@ spread.
95
95
  `modifiers` on `tap` or `doubleTap`, focus on a control, and other keys fail with
96
96
  `UNSUPPORTED_CAPABILITY`.
97
97
  - **Location**: every `screen` query, plus agent-device selectors through
98
- `device.locator('role=NavigationBar id=General')`.
98
+ `device.locator('role=navigation-bar id=General')`.
99
99
  - **Viewport swipe**, `app.back()`, `app.restart()`, `app.clearState()`, and
100
100
  redacted screenshots under the attempt artifact directory: the bounds of
101
101
  every secure field are painted black before the file is kept, and a
@@ -126,12 +126,11 @@ Deterministic device management, recorded as `device.<method>` steps. Import
126
126
  import { test } from '@e2e-dev/mobile';
127
127
  import { expect } from 'e2e';
128
128
 
129
- test('shows the version offline in dark mode', async ({ agent, device, screen }) => {
129
+ test('shows the version in dark mode', async ({ agent, device, screen }) => {
130
130
  await device.setAppearance('dark');
131
- await device.setNetwork('offline');
132
131
  await agent.act('go to General, then About');
133
132
  await expect(screen.getByRole('button', /^iOS Version/)).toBeVisible();
134
- await expect(device.locator('role=NavigationBar id=About')).toBeVisible();
133
+ await expect(device.locator('role=navigation-bar id=About')).toBeVisible();
135
134
  });
136
135
  ```
137
136
 
@@ -141,6 +140,14 @@ Methods: `setNetwork`, `setAirplaneMode`, `setPermission`, `setLocation`,
141
140
  `foregroundApp`, `home`, `back`, `alert`, `dismissKeyboard`, `clipboard`,
142
141
  `setClipboard`, and the `locator` accessor.
143
142
 
143
+ `setNetwork('offline')` and `setAirplaneMode(true)` are Android only. An iOS
144
+ simulator shares the Mac's network, and simctl can only change its status
145
+ bar, so on iOS both fail with `UNSUPPORTED_CAPABILITY`; going back online
146
+ does nothing there, so a shared cleanup hook passes. On Android,
147
+ `setNetwork('offline')` turns off Wi-Fi alone, so the app can stay online
148
+ over cellular; `setAirplaneMode(true)` (Android 11 or later) takes it
149
+ offline.
150
+
144
151
  `installApp(appPath, { app, reinstall })` puts a build on the device from a
145
152
  test, for upgrade and fresh-install paths the target's `app.appPath` cannot
146
153
  express.
package/dist/.build.json CHANGED
@@ -1 +1 @@
1
- {"version":"0.11.1-nightly-20261012095456"}
1
+ {"version":"0.12.0"}
@@ -44,14 +44,6 @@ export interface SlotBinding {
44
44
  * lease: with it the worker hands the lease to the provider's `record`.
45
45
  */
46
46
  readonly leaseId?: string | undefined;
47
- /**
48
- * App the warm-up in `prepare` opened in the slot's session, which the
49
- * worker resumes: a permission command there already acts on it. Absent
50
- * when nothing was opened, or the open failed. Every worker of the slot
51
- * reads it, but only a worker that finds the session still open keeps it:
52
- * one retired after a failing test closed it.
53
- */
54
- readonly sessionApp?: string | undefined;
55
47
  }
56
48
  /**
57
49
  * Where a pool's devices come from. `bind` runs once per run and target in
package/dist/bindings.js CHANGED
@@ -45,7 +45,6 @@ export function encodeBindings(bindings) {
45
45
  client: binding.client,
46
46
  installedApp: binding.installedApp,
47
47
  leaseId: binding.leaseId,
48
- sessionApp: binding.sessionApp,
49
48
  })));
50
49
  const bytes = Buffer.byteLength(encoded);
51
50
  if (bytes > MAX_BINDINGS_ENV_BYTES) {
@@ -74,12 +73,11 @@ function isOptionalString(value) {
74
73
  export function isSlotBinding(value) {
75
74
  if (typeof value !== 'object' || value === null)
76
75
  return false;
77
- const { device, deviceId, daemon, client, installedApp, leaseId, sessionApp } = value;
76
+ const { device, deviceId, daemon, client, installedApp, leaseId } = value;
78
77
  return (isOptionalString(device) &&
79
78
  isOptionalString(deviceId) &&
80
79
  isOptionalString(installedApp) &&
81
80
  isOptionalString(leaseId) &&
82
- isOptionalString(sessionApp) &&
83
81
  (daemon === undefined || isDaemon(daemon)) &&
84
82
  (client === undefined || isClientConfig(client)));
85
83
  }
package/dist/device.d.ts CHANGED
@@ -18,19 +18,29 @@ export interface ForegroundApp {
18
18
  export interface Device {
19
19
  /**
20
20
  * A locator from an agent-device selector (`'id=SW_VERSION_SPECIFIER'`,
21
- * `'role=StaticText label="26.2"'`), for nodes the closed `screen` query
21
+ * `'role=text label="26.2"'`), for nodes the closed `screen` query
22
22
  * vocabulary cannot name. Same polling and strictness as any locator.
23
23
  */
24
24
  locator(selector: string): Locator;
25
- /** Toggles the device's Wi-Fi. */
25
+ /**
26
+ * Switches an Android device's Wi-Fi. Cellular data stays on, so an
27
+ * emulator hands the app over to its cellular link; `setAirplaneMode(true)`
28
+ * cuts both. On iOS `offline` is `UNSUPPORTED_CAPABILITY`, since a
29
+ * simulator shares the Mac's network and simctl can only repaint its status
30
+ * bar; `online` sends nothing there, so a cleanup hook shared with Android
31
+ * passes.
32
+ */
26
33
  setNetwork(state: 'online' | 'offline'): Promise<void>;
27
- /** Toggles airplane mode. */
34
+ /**
35
+ * Switches an Android device's airplane mode, Wi-Fi and cellular together;
36
+ * Android 11 or newer. On iOS, as for `setNetwork`, `true` is
37
+ * `UNSUPPORTED_CAPABILITY` and `false` sends nothing.
38
+ */
28
39
  setAirplaneMode(enabled: boolean): Promise<void>;
29
40
  /**
30
- * Grants, denies, or resets one permission for the pinned app, brought to
31
- * the foreground first when the session is on no app. A change terminates
32
- * a running app on iOS, a revoke one on Android, so it goes before the
33
- * `app.open()` a test starts with.
41
+ * Grants, denies, or resets one permission for the pinned app, which need
42
+ * not be running. A change terminates a running app on iOS, a revoke one
43
+ * on Android, so it goes before the `app.open()` a test starts with.
34
44
  */
35
45
  setPermission(permission: DevicePermission, state: PermissionState): Promise<void>;
36
46
  /** Sets the simulated location, switching location services on first on Android. */
@@ -45,12 +55,16 @@ export interface Device {
45
55
  /** Rotates the device. */
46
56
  setOrientation(orientation: DeviceOrientation): Promise<void>;
47
57
  /**
48
- * Puts a foldable iOS simulator (iPhone Duo) in a hinge pose and resolves
49
- * once agent-device reads the angle back from CoreDevice at that pose,
50
- * typically 10 to 16 seconds later. `closed` lights the outer display;
51
- * `half-open` (a 130° book) and `open` light the inner one, whose app
52
- * window has a different size, so the next observation reads a new screen.
53
- * Any device without a hinge is `UNSUPPORTED_CAPABILITY`.
58
+ * Puts a foldable simulator or emulator in a hinge pose. On the iPhone Duo
59
+ * it resolves once agent-device reads the angle back from CoreDevice at
60
+ * that pose, typically 10 to 16 seconds later; `closed` lights the outer
61
+ * display, `half-open` (a 130° book) and `open` light the inner one. On a
62
+ * foldable Android emulator it sets the emulator console posture and
63
+ * resolves once the guest commits the matching device state, within a few
64
+ * seconds; `half-open` is the profile's own angle there (90° on the Pixel
65
+ * folds), and a fold to the cover display dismisses the lock screen it
66
+ * raises. Either way the app window changes size, so the next observation
67
+ * reads a new screen. Any device without a hinge is `UNSUPPORTED_CAPABILITY`.
54
68
  */
55
69
  fold(pose: FoldPose): Promise<void>;
56
70
  /** Simulates one biometric attempt on the open app. */
package/dist/device.js CHANGED
@@ -5,14 +5,34 @@
5
5
  * `device.<method>` step bounded by the action timeout.
6
6
  */
7
7
  import { linkLabel, linkTarget } from './links.js';
8
+ import { unsupported } from './support.js';
9
+ /**
10
+ * Why iOS refuses to take the device offline, before any device command:
11
+ * agent-device's iOS `wifi` and `airplane` settings only override a
12
+ * simulator's status bar, and a physical iPhone takes no settings at all. A
13
+ * success would let a test read the repainted indicator as the app going
14
+ * offline. Going back online sends nothing there, like a toggle already in
15
+ * the wanted state, so a cleanup hook shared with Android still passes.
16
+ */
17
+ const IOS_OFFLINE = "on iOS agent-device can only repaint a simulator's status bar, since the simulator shares the Mac's network, and cannot switch an iPhone's radios, so the app would stay online";
8
18
  /** Builds the device fixture for one attempt. */
9
19
  export function createDeviceFixture(surface, context) {
10
20
  const device = {
11
21
  locator: (selector) => context.locator({ kind: 'selector', selector }),
12
22
  async setNetwork(state) {
23
+ if (surface.options.platform === 'ios') {
24
+ if (state === 'offline')
25
+ throw unsupported(`device.setNetwork('offline') turns off an Android device's Wi-Fi; ${IOS_OFFLINE}`);
26
+ return;
27
+ }
13
28
  await surface.command('device.setNetwork', (client) => client.settings.update({ setting: 'wifi', state: state === 'offline' ? 'off' : 'on' }), context.signal);
14
29
  },
15
30
  async setAirplaneMode(enabled) {
31
+ if (surface.options.platform === 'ios') {
32
+ if (enabled)
33
+ throw unsupported(`device.setAirplaneMode(true) cuts an Android device's radios; ${IOS_OFFLINE}`);
34
+ return;
35
+ }
16
36
  await surface.command('device.setAirplaneMode', (client) => client.settings.update({ setting: 'airplane', state: enabled ? 'on' : 'off' }), context.signal);
17
37
  },
18
38
  async setPermission(permission, state) {
package/dist/errors.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * | ----------------------------------------------------------- | ---------------------------- |
6
6
  * | already an EngineError / runner error | passed through untouched |
7
7
  * | AbortError | CANCELLED |
8
- * | no active session / session not found / requires a session | INVALID_STATE |
8
+ * | SESSION_NOT_FOUND, or a session-or-device-selector refusal | 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
11
  * | a stale-ref refusal, by `details.reason` (action paths only) | NODE_STALE (retryable) |
@@ -35,12 +35,6 @@ export declare function translateError(cause: unknown, operation: string, where?
35
35
  * must not tolerate this.
36
36
  */
37
37
  export declare function isRunnerFailure(error: unknown): boolean;
38
- /**
39
- * True for a translated settings command agent-device refused because its
40
- * session is on no app: the one refusal a foreground open of the pinned app
41
- * answers.
42
- */
43
- export declare function isNoSessionApp(error: unknown): boolean;
44
38
  /**
45
39
  * True for a translated snapshot the iOS runner acquired but could not
46
40
  * present. The check is per capture and the capture is a read, so one retry
package/dist/errors.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * | ----------------------------------------------------------- | ---------------------------- |
6
6
  * | already an EngineError / runner error | passed through untouched |
7
7
  * | AbortError | CANCELLED |
8
- * | no active session / session not found / requires a session | INVALID_STATE |
8
+ * | SESSION_NOT_FOUND, or a session-or-device-selector refusal | 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
11
  * | a stale-ref refusal, by `details.reason` (action paths only) | NODE_STALE (retryable) |
@@ -58,9 +58,13 @@ function withHint(cause, text) {
58
58
  }
59
59
  /** agent-device's generic hint (`Check command arguments and run --help`) is CLI advice, and stays out too. */
60
60
  const CLI_ADVICE = /--help|command arguments/i;
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
61
  const UNSUPPORTED_CODES = new Set(['UNSUPPORTED_OPERATION', 'NOT_IMPLEMENTED', 'UNSUPPORTED_PLATFORM']);
63
62
  const UNSUPPORTED_PATTERN = /\b(?:is )?not supported\b|\bunsupported\b/i;
63
+ /**
64
+ * The `details.reason` agent-device refuses a command with when it has no
65
+ * session and no device selector to fall back on (0.21.21).
66
+ */
67
+ const NO_SESSION_REASON = 'session_or_device_selector_required';
64
68
  /**
65
69
  * The `details.reason` values agent-device refuses an `@ref` with: the ref
66
70
  * names no node of the session's tree, or belongs to a capture the session
@@ -167,7 +171,7 @@ export function translateError(cause, operation, where) {
167
171
  const normalized = normalizeAgentDeviceError(cause);
168
172
  const text = `${operation} failed: ${withHint(cause, normalized.message)}`;
169
173
  const options = { retryable: false, cause };
170
- if (normalized.code === 'SESSION_NOT_FOUND' || NO_SESSION_PATTERN.test(normalized.message)) {
174
+ if (normalized.code === 'SESSION_NOT_FOUND' || details(cause).reason === NO_SESSION_REASON) {
171
175
  return new EngineError('INVALID_STATE', text, options);
172
176
  }
173
177
  if (UNSUPPORTED_CODES.has(normalized.code) || UNSUPPORTED_PATTERN.test(normalized.message)) {
@@ -190,15 +194,6 @@ export function translateError(cause, operation, where) {
190
194
  export function isRunnerFailure(error) {
191
195
  return error instanceof Error && runnerState(error.cause) !== undefined;
192
196
  }
193
- const NO_SESSION_APP_PATTERN = /requires an active app in (?:the )?session/i;
194
- /**
195
- * True for a translated settings command agent-device refused because its
196
- * session is on no app: the one refusal a foreground open of the pinned app
197
- * answers.
198
- */
199
- export function isNoSessionApp(error) {
200
- return error instanceof Error && NO_SESSION_APP_PATTERN.test(normalizeAgentDeviceError(error.cause).message);
201
- }
202
197
  /**
203
198
  * True for a translated snapshot the iOS runner acquired but could not
204
199
  * present. The check is per capture and the capture is a read, so one retry
package/dist/locate.d.ts CHANGED
@@ -6,5 +6,6 @@
6
6
  */
7
7
  import { type LocatorExpression } from 'e2e/engine';
8
8
  import { type ProjectedNode } from './nodes.ts';
9
- /** Resolves one expression to the nodes it currently matches. */
10
- export declare function resolveExpression(expression: LocatorExpression, index: readonly ProjectedNode[]): ProjectedNode[];
9
+ import type { MobilePlatform } from './options.ts';
10
+ /** Resolves one expression to the nodes it currently matches; `platform` is the one agent-device selectors match for. */
11
+ export declare function resolveExpression(expression: LocatorExpression, index: readonly ProjectedNode[], platform: MobilePlatform): ProjectedNode[];
package/dist/locate.js CHANGED
@@ -7,35 +7,35 @@
7
7
  import { EngineError, matchesText } from 'e2e/engine';
8
8
  import { isWithin } from './nodes.js';
9
9
  import { compileSelector } from './selector.js';
10
- /** Resolves one expression to the nodes it currently matches. */
11
- export function resolveExpression(expression, index) {
10
+ /** Resolves one expression to the nodes it currently matches; `platform` is the one agent-device selectors match for. */
11
+ export function resolveExpression(expression, index, platform) {
12
12
  switch (expression.kind) {
13
13
  case 'query': {
14
- const candidates = expression.scope === undefined ? index : descendantsOf(resolveExpression(expression.scope, index), index);
14
+ const candidates = expression.scope === undefined ? index : descendantsOf(resolveExpression(expression.scope, index, platform), index);
15
15
  const matches = candidates.filter((entry) => matchesQuery(entry, expression.query));
16
16
  return ECHOED_QUERY_KINDS.has(expression.query.kind) ? innermostOnly(matches) : matches;
17
17
  }
18
18
  case 'filter': {
19
- const source = resolveExpression(expression.source, index);
19
+ const source = resolveExpression(expression.source, index, platform);
20
20
  return source.filter((entry) => {
21
21
  if (expression.hasText !== undefined && !subtreeHasText(entry, index, expression.hasText))
22
22
  return false;
23
23
  if (expression.has !== undefined) {
24
24
  const within = descendantsOf([entry], index);
25
- if (resolveExpression(expression.has, within).length === 0)
25
+ if (resolveExpression(expression.has, within, platform).length === 0)
26
26
  return false;
27
27
  }
28
28
  return true;
29
29
  });
30
30
  }
31
31
  case 'index': {
32
- const source = resolveExpression(expression.source, index);
32
+ const source = resolveExpression(expression.source, index, platform);
33
33
  const position = expression.index === 'first' ? 0 : expression.index === 'last' ? source.length - 1 : expression.index;
34
34
  const picked = source[position];
35
35
  return picked === undefined ? [] : [picked];
36
36
  }
37
37
  case 'selector':
38
- return compileSelector(expression.selector)(index);
38
+ return compileSelector(expression.selector, platform)(index);
39
39
  case 'frame':
40
40
  throw new EngineError('FRAME_NOT_FOUND', 'a device surface has no nested documents to scope a query into', {
41
41
  retryable: false,
package/dist/nodes.d.ts CHANGED
@@ -32,15 +32,16 @@ export interface ProjectedSnapshot {
32
32
  readonly index: readonly ProjectedNode[];
33
33
  readonly viewport: ViewportSize | undefined;
34
34
  }
35
- /** One element-type spelling for `NavigationBar`, `navigation-bar`, and `android.widget.NavigationBar` alike. */
36
- export declare function normalizeKind(type: string): string;
37
35
  /**
38
36
  * Projects one snapshot. `mintId` is called once per node in document order,
39
- * so the surface's id space stays unique across observations. Android's
40
- * system bars are left out with their children, see `isSystemBar`.
37
+ * so the surface's id space stays unique across observations. `viewport` is
38
+ * the one agent-device published with the snapshot; a producer that measured
39
+ * none leaves it to `viewportOf`. Android's system bars are left out with
40
+ * their children, see `isSystemBar`.
41
41
  */
42
42
  export declare function projectSnapshot(raw: readonly RawNode[], options: {
43
43
  readonly mintId: () => string;
44
+ readonly viewport?: ViewportSize | undefined;
44
45
  }): ProjectedSnapshot;
45
46
  /**
46
47
  * The screen's logical size, read off the application or window node when
package/dist/nodes.js CHANGED
@@ -199,7 +199,7 @@ function isAndroidClass(type) {
199
199
  return type !== undefined && type.includes('.');
200
200
  }
201
201
  /** One element-type spelling for `NavigationBar`, `navigation-bar`, and `android.widget.NavigationBar` alike. */
202
- export function normalizeKind(type) {
202
+ function normalizeKind(type) {
203
203
  const simple = type.slice(type.lastIndexOf('.') + 1);
204
204
  return simple
205
205
  .replaceAll(/([a-z0-9])([A-Z])/g, '$1-$2')
@@ -339,11 +339,13 @@ function isSystemBar(node, screen, displayOnly) {
339
339
  }
340
340
  /**
341
341
  * Projects one snapshot. `mintId` is called once per node in document order,
342
- * so the surface's id space stays unique across observations. Android's
343
- * system bars are left out with their children, see `isSystemBar`.
342
+ * so the surface's id space stays unique across observations. `viewport` is
343
+ * the one agent-device published with the snapshot; a producer that measured
344
+ * none leaves it to `viewportOf`. Android's system bars are left out with
345
+ * their children, see `isSystemBar`.
344
346
  */
345
347
  export function projectSnapshot(raw, options) {
346
- const viewport = viewportOf(raw);
348
+ const viewport = options.viewport ?? viewportOf(raw);
347
349
  const parents = parentPositions(raw);
348
350
  const children = new Map();
349
351
  const roots = [];
package/dist/pool.d.ts CHANGED
@@ -64,9 +64,8 @@ export declare class DevicePool {
64
64
  /**
65
65
  * Binds a device to every worker slot the run will use and warms each,
66
66
  * once per run and outside every launch budget, so `init` finds a booted
67
- * device. The bindings, each naming the app its session is on, are
68
- * reported back as the target's worker cap and left in the environment
69
- * for the workers. A target nothing runs on binds
67
+ * device. The bindings are reported back as the target's worker cap and
68
+ * left in the environment for the workers. A target nothing runs on binds
70
69
  * nothing: a hosted session is billed from the moment it starts.
71
70
  */
72
71
  prepare(info: EnginePrepareInfo): Promise<EnginePrepareResult>;
@@ -83,9 +82,8 @@ export declare class DevicePool {
83
82
  finish(info: EngineFinishInfo): Promise<void>;
84
83
  /**
85
84
  * Boots every bound device, starts the iOS automation runner on it, and
86
- * opens the pinned app on it once, so the slot's session is on the app,
87
- * which the returned binding records for the worker: a permission it
88
- * presets there needs no open first. A plain foreground open, with none of the engine's
85
+ * opens the pinned app on it once, so a test that starts without
86
+ * `app.open()` finds it on screen. A plain foreground open, with none of the engine's
89
87
  * launch options: an app still running from an earlier run keeps its
90
88
  * process either way, and a test's `app.open()` relaunches it with them.
91
89
  * One slot after another, on purpose: workers
@@ -98,8 +96,7 @@ export declare class DevicePool {
98
96
  * its first observation with the app blamed, and the message names the
99
97
  * recovery, which a wait of up to the runner's recycle window would only
100
98
  * hide. A runner that did not start leaves the slot unopened: the open
101
- * would start it again under a shorter budget, and its timeout resets the
102
- * daemon every earlier slot is on. A build `appPath` installs in `init` is
99
+ * would start it again under a shorter budget. A build `appPath` installs in `init` is
103
100
  * not on the device yet, so that slot boots and starts the runner only,
104
101
  * unless a lease says the build is already there.
105
102
  */
package/dist/pool.js CHANGED
@@ -151,15 +151,15 @@ export class DevicePool {
151
151
  /**
152
152
  * Binds a device to every worker slot the run will use and warms each,
153
153
  * once per run and outside every launch budget, so `init` finds a booted
154
- * device. The bindings, each naming the app its session is on, are
155
- * reported back as the target's worker cap and left in the environment
156
- * for the workers. A target nothing runs on binds
154
+ * device. The bindings are reported back as the target's worker cap and
155
+ * left in the environment for the workers. A target nothing runs on binds
157
156
  * nothing: a hosted session is billed from the moment it starts.
158
157
  */
159
158
  async prepare(info) {
160
159
  if (info.slots === 0)
161
160
  return {};
162
- const bindings = await this.warm(await this.source.bind(info), info);
161
+ const bindings = await this.source.bind(info);
162
+ await this.warm(bindings, info);
163
163
  this.bound.set(info.targetName, bindings);
164
164
  return { workers: bindings.length, env: { [bindingsVariable(info.targetName)]: encodeBindings(bindings) } };
165
165
  }
@@ -181,9 +181,8 @@ export class DevicePool {
181
181
  }
182
182
  /**
183
183
  * Boots every bound device, starts the iOS automation runner on it, and
184
- * opens the pinned app on it once, so the slot's session is on the app,
185
- * which the returned binding records for the worker: a permission it
186
- * presets there needs no open first. A plain foreground open, with none of the engine's
184
+ * opens the pinned app on it once, so a test that starts without
185
+ * `app.open()` finds it on screen. A plain foreground open, with none of the engine's
187
186
  * launch options: an app still running from an earlier run keeps its
188
187
  * process either way, and a test's `app.open()` relaunches it with them.
189
188
  * One slot after another, on purpose: workers
@@ -196,13 +195,11 @@ export class DevicePool {
196
195
  * its first observation with the app blamed, and the message names the
197
196
  * recovery, which a wait of up to the runner's recycle window would only
198
197
  * hide. A runner that did not start leaves the slot unopened: the open
199
- * would start it again under a shorter budget, and its timeout resets the
200
- * daemon every earlier slot is on. A build `appPath` installs in `init` is
198
+ * would start it again under a shorter budget. A build `appPath` installs in `init` is
201
199
  * not on the device yet, so that slot boots and starts the runner only,
202
200
  * unless a lease says the build is already there.
203
201
  */
204
202
  async warm(bindings, info) {
205
- const warmed = [];
206
203
  for (const [slot, binding] of bindings.entries()) {
207
204
  const label = deviceLabel(binding) ?? `a booted ${this.options.platform} device`;
208
205
  const where = deviceSelection(this.options.platform, binding);
@@ -214,29 +211,23 @@ export class DevicePool {
214
211
  await runCommand('boot', () => client.devices.boot(where), info.signal, at);
215
212
  const runnerUp = this.options.platform !== 'ios' || (await prepareRunner(client, where, info, at));
216
213
  const app = pinnedApp(info.app, binding.installedApp);
217
- if (app === undefined || !runnerUp) {
218
- warmed.push(binding);
214
+ if (app === undefined || !runnerUp)
219
215
  continue;
220
- }
221
216
  // A build the suite installs itself is not on the device yet, so there
222
217
  // is nothing to open: the first attempt installs it.
223
218
  if (info.app.appPath !== undefined && binding.installedApp === undefined) {
224
219
  info.log(`${label}: ${app} awaits the suite's device.installApp()`);
225
- warmed.push(binding);
226
220
  continue;
227
221
  }
228
222
  try {
229
223
  await runCommand(`open ${app}`, () => client.apps.open({ app, ...where }), info.signal, at);
230
- warmed.push({ ...binding, sessionApp: app });
231
224
  }
232
225
  catch (cause) {
233
226
  if (info.signal.aborted || isRunnerFailure(cause))
234
227
  throw cause;
235
228
  info.log(`${label}: ${app} did not open (${message(cause)}); the first attempt opens it`);
236
- warmed.push(binding);
237
229
  }
238
230
  }
239
- return warmed;
240
231
  }
241
232
  /** Holds a warm-up client for `finish`, before its first command: a boot that fails still opened the session. */
242
233
  retain(targetName, client) {
@@ -249,8 +240,7 @@ export class DevicePool {
249
240
  * Starts the slot's iOS automation runner under `prepare ios-runner`, whose
250
241
  * startup budget covers a cold simulator. Left to the first `open`, the
251
242
  * start runs inside that request's 90 s envelope, which a cold runner on a
252
- * loaded CI Mac outlasts; a timed-out `open` resets the daemon, ending every
253
- * other slot's session with it. A runner that is busy or wedged ends the run
243
+ * loaded CI Mac outlasts. A runner that is busy or wedged ends the run
254
244
  * here, like a warm-up open that meets one; any other failure is logged and
255
245
  * left to the first attempt. True when the runner is up.
256
246
  */
@@ -1,16 +1,17 @@
1
1
  /**
2
2
  * agent-device selector support for the `selector` locator expression:
3
3
  * `screen.locator('id=save role=button')` and the structural hint on every
4
- * observed node. The grammar is agent-device's own (`parseSelectorChain`), so a
5
- * selector that works on the agent-device CLI works here unchanged; matching
6
- * runs against the projected snapshot, one immediate pass, every match returned.
4
+ * observed node. Parsing and term rules are agent-device's own
5
+ * (`parseSelectorChain`, `listSelectorChainMatches`); matching runs against
6
+ * the projected snapshot, one immediate pass, every match returned, and
7
+ * strictness stays with the runner.
7
8
  */
8
- import { type ProjectedNode } from './nodes.ts';
9
+ import type { ProjectedNode } from './nodes.ts';
10
+ import type { MobilePlatform } from './options.ts';
9
11
  export type CompiledSelector = (entries: readonly ProjectedNode[]) => ProjectedNode[];
10
12
  /**
11
- * Compiles one selector string. Alternatives (agent-device's fallback chain)
12
- * are tried in order and the first that matches anything wins, mirroring how
13
- * agent-device itself resolves a chain; a term's text compares exactly after
14
- * whitespace collapsing, like every other text rule in the runner.
13
+ * Compiles one selector string for a platform. Alternatives (agent-device's
14
+ * fallback chain) are tried in order and the first that matches anything
15
+ * wins, with every node it matches returned in snapshot order.
15
16
  */
16
- export declare function compileSelector(raw: string): CompiledSelector;
17
+ export declare function compileSelector(raw: string, platform: MobilePlatform): CompiledSelector;
package/dist/selector.js CHANGED
@@ -1,21 +1,20 @@
1
1
  /**
2
2
  * agent-device selector support for the `selector` locator expression:
3
3
  * `screen.locator('id=save role=button')` and the structural hint on every
4
- * observed node. The grammar is agent-device's own (`parseSelectorChain`), so a
5
- * selector that works on the agent-device CLI works here unchanged; matching
6
- * runs against the projected snapshot, one immediate pass, every match returned.
4
+ * observed node. Parsing and term rules are agent-device's own
5
+ * (`parseSelectorChain`, `listSelectorChainMatches`); matching runs against
6
+ * the projected snapshot, one immediate pass, every match returned, and
7
+ * strictness stays with the runner.
7
8
  */
8
- import { parseSelectorChain } from 'agent-device/selectors';
9
+ import { listSelectorChainMatches, parseSelectorChain } from 'agent-device/selectors';
9
10
  import { EngineError } from 'e2e/engine';
10
- import { normalizeKind } from './nodes.js';
11
11
  import { message } from './errors.js';
12
12
  /**
13
- * Compiles one selector string. Alternatives (agent-device's fallback chain)
14
- * are tried in order and the first that matches anything wins, mirroring how
15
- * agent-device itself resolves a chain; a term's text compares exactly after
16
- * whitespace collapsing, like every other text rule in the runner.
13
+ * Compiles one selector string for a platform. Alternatives (agent-device's
14
+ * fallback chain) are tried in order and the first that matches anything
15
+ * wins, with every node it matches returned in snapshot order.
17
16
  */
18
- export function compileSelector(raw) {
17
+ export function compileSelector(raw, platform) {
19
18
  let chain;
20
19
  try {
21
20
  chain = parseSelectorChain(raw);
@@ -27,57 +26,10 @@ export function compileSelector(raw) {
27
26
  });
28
27
  }
29
28
  return (entries) => {
30
- for (const alternative of chain.selectors) {
31
- const matches = entries.filter((entry) => alternative.terms.every((term) => matchesTerm(entry, term)));
32
- if (matches.length > 0)
33
- return matches;
34
- }
35
- return [];
29
+ const byNode = new Map(entries.map((entry) => [entry.raw, entry]));
30
+ // The projection types agent-device's nodes as partial for fixtures that omit fields; the term rules read only optional ones.
31
+ const nodes = entries.map((entry) => entry.raw);
32
+ const matched = listSelectorChainMatches(nodes, chain, { platform })?.matchedNodes ?? [];
33
+ return matched.flatMap((node) => byNode.get(node) ?? []);
36
34
  };
37
35
  }
38
- function normalize(text) {
39
- return text.replaceAll(/\s+/g, ' ').trim();
40
- }
41
- function textEquals(actual, expected) {
42
- return actual !== undefined && normalize(actual) === normalize(String(expected));
43
- }
44
- function flag(value) {
45
- return value === true || String(value).toLowerCase() === 'true';
46
- }
47
- function matchesTerm(entry, term) {
48
- const states = entry.node.states ?? {};
49
- switch (term.key) {
50
- case 'id':
51
- return textEquals(entry.raw.identifier, term.value);
52
- case 'role': {
53
- const wanted = normalizeKind(String(term.value));
54
- return entry.kind === wanted || entry.node.role === wanted;
55
- }
56
- case 'text':
57
- return textEquals(entry.raw.label, term.value) || textEquals(entry.raw.value, term.value);
58
- case 'label':
59
- return textEquals(entry.raw.label, term.value);
60
- case 'value':
61
- return textEquals(entry.raw.value, term.value);
62
- case 'appname':
63
- return textEquals(entry.raw.appName, term.value);
64
- case 'windowtitle':
65
- return textEquals(entry.raw.windowTitle, term.value);
66
- case 'visible':
67
- return (states.hidden !== true) === flag(term.value);
68
- case 'hidden':
69
- return (states.hidden === true) === flag(term.value);
70
- case 'editable':
71
- return (entry.node.role === 'textbox') === flag(term.value);
72
- case 'selected':
73
- return (states.selected === true) === flag(term.value);
74
- case 'focused':
75
- return (states.focused === true) === flag(term.value);
76
- case 'enabled':
77
- return (states.disabled !== true) === flag(term.value);
78
- case 'hittable':
79
- return (entry.raw.hittable !== false) === flag(term.value);
80
- default:
81
- return false;
82
- }
83
- }
package/dist/surface.d.ts CHANGED
@@ -76,14 +76,6 @@ export declare class AgentDeviceSurface {
76
76
  private readonly located;
77
77
  private idCounter;
78
78
  private appIdentity;
79
- /**
80
- * The app this worker's agent-device session is on, and so the app a
81
- * permission command there acts on: what the warm-up in `prepare` opened
82
- * under the session this worker resumed, while that session is still open,
83
- * then whatever this surface last opened. Undefined while nothing has been opened in the session, and once
84
- * `closeApp` ended it.
85
- */
86
- private sessionApp;
87
79
  /** The app the build `appPath` installed, once `init` has, itself or through a lease. */
88
80
  private installedApp;
89
81
  /** The target's app, as `init` received it. */
@@ -157,15 +149,6 @@ export declare class AgentDeviceSurface {
157
149
  /** The device selection this worker's commands take. */
158
150
  private selection;
159
151
  init(info: EngineInitInfo): Promise<void>;
160
- /**
161
- * The app the warm-up left the slot's session on, while agent-device still
162
- * holds that session. Only the slot's first worker finds it: a worker
163
- * retired after a failing test closed it in `dispose`, and a permission
164
- * command on a closed session reaches no device. A session not listed, or
165
- * a list that fails, leaves the app unknown, and the first permission
166
- * change opens it again.
167
- */
168
- private resumedSessionApp;
169
152
  startAttempt(context: EngineAttemptContext): Promise<void>;
170
153
  endAttempt(context: EngineCleanupContext): Promise<void>;
171
154
  /**
@@ -205,25 +188,15 @@ export declare class AgentDeviceSurface {
205
188
  /** One `open` of an app, remembered as the session's app and as the screen's replacement. */
206
189
  private open;
207
190
  /**
208
- * Puts an app's permissions in place before it launches. agent-device sets
209
- * a permission on the app its session is on, so a session on another app,
210
- * or on none yet, is first brought onto this one with a foreground open;
211
- * the warm-up in `prepare` did that for the pinned app under the session
212
- * this worker resumed, so its first launch needs none. Before the launch,
213
- * never after: iOS terminates a running app whose
214
- * permission changed, and Android one whose permission was revoked, so a
215
- * change after the launch would leave the test on no screen.
191
+ * Puts an app's permissions in place before it launches, never after: iOS
192
+ * terminates a running app whose permission changed, and Android one whose
193
+ * permission was revoked, so a change after the launch would leave the
194
+ * test on no screen.
216
195
  */
217
196
  private presetPermissions;
218
- /**
219
- * One permission change for `device.setPermission`: on the app the session
220
- * is on, which is what agent-device acts on. With none known here (nothing
221
- * opened yet, `closeApp`, a worker resumed on a bare session) the pinned
222
- * app is brought to the foreground first. agent-device's own refusal, met
223
- * when its session lost the app since (a failed attempt left it on none),
224
- * gets the same foreground open and the command once more.
225
- */
197
+ /** One permission change for `device.setPermission`: on the pinned app, else on the app agent-device's session is on. */
226
198
  setPermission(permission: DevicePermission, state: PermissionState, signal: AbortSignal): Promise<void>;
199
+ /** One permission change on `app`, which needs no running app and no open in the session. */
227
200
  private permission;
228
201
  /**
229
202
  * Opens a link on the device, into `app` (the pinned app when none is
@@ -410,9 +383,11 @@ export declare class AgentDeviceSurface {
410
383
  /** Clears the pinned app's persisted state and relaunches it: the device equivalent of a fresh context. */
411
384
  reset(operation: OperationContext): Promise<void>;
412
385
  /**
413
- * Sends a foldable iOS simulator's hinge to a pose; agent-device confirms
414
- * the angle through CoreDevice before it answers. The pose moves the app to
415
- * another panel, so it counts as an action like a rotation.
386
+ * Sends a foldable simulator's or emulator's hinge to a pose; agent-device
387
+ * confirms it before it answers: the hinge angle through CoreDevice on iOS,
388
+ * the device state the guest commits on Android. The pose moves the app to
389
+ * another panel, so it counts as an action like a rotation. A device
390
+ * without a hinge comes back from agent-device as `UNSUPPORTED_OPERATION`.
416
391
  */
417
392
  fold(pose: FoldPose, signal: AbortSignal): Promise<void>;
418
393
  /**
package/dist/surface.js CHANGED
@@ -15,7 +15,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, w
15
15
  import { tmpdir } from 'node:os';
16
16
  import path from 'node:path';
17
17
  import { EngineError, KEY_NAMES, parseKey, raceAbort, rejectUnknownKeys, stopProviderRecording, withinCleanupBudget, ConfigurationError, TestError, } from 'e2e/engine';
18
- import { isNoSessionApp, isSnapshotPresentationFailure, runCommand, staleOr } from './errors.js';
18
+ import { isSnapshotPresentationFailure, runCommand, staleOr } from './errors.js';
19
19
  import { pointerInteraction, DEFAULT_LONG_PRESS_MS } from './actions.js';
20
20
  import { resolveExpression } from './locate.js';
21
21
  import { isWithin, projectSnapshot, ROOT_ID, screenRoot, screenTitle, } from './nodes.js';
@@ -187,14 +187,6 @@ export class AgentDeviceSurface {
187
187
  located = new Map();
188
188
  idCounter = 0;
189
189
  appIdentity;
190
- /**
191
- * The app this worker's agent-device session is on, and so the app a
192
- * permission command there acts on: what the warm-up in `prepare` opened
193
- * under the session this worker resumed, while that session is still open,
194
- * then whatever this surface last opened. Undefined while nothing has been opened in the session, and once
195
- * `closeApp` ended it.
196
- */
197
- sessionApp;
198
190
  /** The app the build `appPath` installed, once `init` has, itself or through a lease. */
199
191
  installedApp;
200
192
  /** The target's app, as `init` received it. */
@@ -320,31 +312,11 @@ export class AgentDeviceSurface {
320
312
  ? undefined
321
313
  : { provider, lease, runId: info.runId, targetName: info.targetName, env: info.env };
322
314
  await this.command('boot', (client) => client.devices.boot(this.selection()), info.signal);
323
- this.sessionApp = binding?.sessionApp === undefined ? undefined : await this.resumedSessionApp(session, binding.sessionApp, info.signal);
324
315
  // Nothing is installed here: a device provider that installed the build
325
316
  // from `appPath` says so on the binding, and otherwise the suite installs
326
317
  // it where it wants to, with `device.installApp()`.
327
318
  this.installedApp = binding?.installedApp;
328
319
  }
329
- /**
330
- * The app the warm-up left the slot's session on, while agent-device still
331
- * holds that session. Only the slot's first worker finds it: a worker
332
- * retired after a failing test closed it in `dispose`, and a permission
333
- * command on a closed session reaches no device. A session not listed, or
334
- * a list that fails, leaves the app unknown, and the first permission
335
- * change opens it again.
336
- */
337
- async resumedSessionApp(session, app, signal) {
338
- const sessions = await this.command('sessions', (client) => client.sessions.list(), signal).catch((cause) => {
339
- if (signal.aborted)
340
- throw cause;
341
- return undefined;
342
- });
343
- if (!Array.isArray(sessions))
344
- return undefined;
345
- const open = sessions.some((entry) => (entry.address ?? entry.name) === session);
346
- return open ? app : undefined;
347
- }
348
320
  async startAttempt(context) {
349
321
  if (this.attempt !== undefined) {
350
322
  throw invalidState('an attempt is already running on this agent-device engine');
@@ -453,7 +425,6 @@ export class AgentDeviceSurface {
453
425
  async stopScreenRecording(signal) {
454
426
  const result = await this.command('stop video recording', (client) => client.recording.record({ action: 'stop' }), signal);
455
427
  if (result.recordOnlySession === true) {
456
- this.sessionApp = undefined;
457
428
  this.screenReplaced();
458
429
  }
459
430
  return result;
@@ -490,7 +461,6 @@ export class AgentDeviceSurface {
490
461
  this.generation = new Map();
491
462
  this.located.clear();
492
463
  this.appIdentity = undefined;
493
- this.sessionApp = undefined;
494
464
  this.installedApp = undefined;
495
465
  this.knownViewport = undefined;
496
466
  if (client === undefined)
@@ -526,52 +496,26 @@ export class AgentDeviceSurface {
526
496
  ...(relaunch ? { relaunch: true } : {}),
527
497
  ...(launchArguments === undefined || launchArguments.length === 0 ? {} : { launchArgs: [...launchArguments] }),
528
498
  }), signal);
529
- this.sessionApp = app;
530
499
  this.launched(result.appBundleId ?? result.appName ?? app);
531
500
  }
532
501
  /**
533
- * Puts an app's permissions in place before it launches. agent-device sets
534
- * a permission on the app its session is on, so a session on another app,
535
- * or on none yet, is first brought onto this one with a foreground open;
536
- * the warm-up in `prepare` did that for the pinned app under the session
537
- * this worker resumed, so its first launch needs none. Before the launch,
538
- * never after: iOS terminates a running app whose
539
- * permission changed, and Android one whose permission was revoked, so a
540
- * change after the launch would leave the test on no screen.
502
+ * Puts an app's permissions in place before it launches, never after: iOS
503
+ * terminates a running app whose permission changed, and Android one whose
504
+ * permission was revoked, so a change after the launch would leave the
505
+ * test on no screen.
541
506
  */
542
507
  async presetPermissions(app, permissions, signal) {
543
508
  const entries = Object.entries(permissions).filter((entry) => entry[1] !== undefined);
544
- if (entries.length === 0)
545
- return;
546
- if (this.sessionApp !== app)
547
- await this.open(app, false, undefined, signal);
548
509
  for (const [permission, state] of entries)
549
- await this.permission(permission, state, signal);
510
+ await this.permission(permission, state, app, signal);
550
511
  }
551
- /**
552
- * One permission change for `device.setPermission`: on the app the session
553
- * is on, which is what agent-device acts on. With none known here (nothing
554
- * opened yet, `closeApp`, a worker resumed on a bare session) the pinned
555
- * app is brought to the foreground first. agent-device's own refusal, met
556
- * when its session lost the app since (a failed attempt left it on none),
557
- * gets the same foreground open and the command once more.
558
- */
512
+ /** One permission change for `device.setPermission`: on the pinned app, else on the app agent-device's session is on. */
559
513
  async setPermission(permission, state, signal) {
560
- const app = this.pinnedApp;
561
- if (this.sessionApp === undefined && app !== undefined)
562
- await this.open(app, false, undefined, signal);
563
- try {
564
- await this.permission(permission, state, signal);
565
- }
566
- catch (cause) {
567
- if (signal.aborted || app === undefined || !isNoSessionApp(cause))
568
- throw cause;
569
- await this.open(app, false, undefined, signal);
570
- await this.permission(permission, state, signal);
571
- }
514
+ await this.permission(permission, state, this.pinnedApp, signal);
572
515
  }
573
- async permission(permission, state, signal) {
574
- await this.command(`permission ${permission} ${state}`, (client) => client.settings.update({ setting: 'permission', permission, state }), signal);
516
+ /** One permission change on `app`, which needs no running app and no open in the session. */
517
+ async permission(permission, state, app, signal) {
518
+ await this.command(`permission ${permission} ${state}`, (client) => client.settings.update({ setting: 'permission', permission, state, ...this.selection(), ...(app === undefined ? {} : { app }) }), signal);
575
519
  }
576
520
  /**
577
521
  * Opens a link on the device, into `app` (the pinned app when none is
@@ -595,7 +539,6 @@ export class AgentDeviceSurface {
595
539
  ...this.selection(),
596
540
  ...(target === undefined ? { app: url.href } : { app: target, url: url.href }),
597
541
  }), signal);
598
- this.sessionApp = target;
599
542
  this.launched(result.appBundleId ?? (target === undefined ? undefined : (result.appName ?? target)));
600
543
  }
601
544
  /**
@@ -630,7 +573,6 @@ export class AgentDeviceSurface {
630
573
  async closeApp(signal) {
631
574
  const app = this.appIdentity ?? this.pinnedApp;
632
575
  await this.command('device.closeApp', (client) => client.apps.close(app === undefined ? {} : { app }), signal);
633
- this.sessionApp = undefined;
634
576
  this.screenReplaced();
635
577
  }
636
578
  /**
@@ -717,6 +659,7 @@ export class AgentDeviceSurface {
717
659
  this.idCounter += 1;
718
660
  return `n${this.idCounter}`;
719
661
  },
662
+ viewport: raw.viewport,
720
663
  });
721
664
  this.latestIndex = projected.index;
722
665
  if (projected.viewport !== undefined)
@@ -766,7 +709,7 @@ export class AgentDeviceSurface {
766
709
  const raw = await this.snapshotOrEmpty(operation, false);
767
710
  const projected = this.project(raw);
768
711
  this.traceScreen(raw, projected);
769
- const matches = resolveExpression(expression, projected.index);
712
+ const matches = resolveExpression(expression, projected.index, this.options.platform);
770
713
  for (const entry of matches)
771
714
  this.located.set(entry.id, this.bind(entry, projected.index));
772
715
  for (const oldest of this.located.keys()) {
@@ -1129,7 +1072,7 @@ export class AgentDeviceSurface {
1129
1072
  let raw = await this.dismissKeyboardTip(await this.snapshot(signal, false), signal);
1130
1073
  if (!keyboardShowing(raw))
1131
1074
  return;
1132
- const projected = projectSnapshot(raw.nodes ?? [], { mintId: () => 'keyboard' });
1075
+ const projected = projectSnapshot(raw.nodes ?? [], { mintId: () => 'keyboard', viewport: raw.viewport });
1133
1076
  const viewport = await this.viewportFor(projected, signal);
1134
1077
  const centre = { x: viewport.width / 2, y: viewport.height / 2 };
1135
1078
  const drags = [
@@ -1183,14 +1126,13 @@ export class AgentDeviceSurface {
1183
1126
  await this.openApp(app, { relaunch: true }, operation.signal);
1184
1127
  }
1185
1128
  /**
1186
- * Sends a foldable iOS simulator's hinge to a pose; agent-device confirms
1187
- * the angle through CoreDevice before it answers. The pose moves the app to
1188
- * another panel, so it counts as an action like a rotation.
1129
+ * Sends a foldable simulator's or emulator's hinge to a pose; agent-device
1130
+ * confirms it before it answers: the hinge angle through CoreDevice on iOS,
1131
+ * the device state the guest commits on Android. The pose moves the app to
1132
+ * another panel, so it counts as an action like a rotation. A device
1133
+ * without a hinge comes back from agent-device as `UNSUPPORTED_OPERATION`.
1189
1134
  */
1190
1135
  async fold(pose, signal) {
1191
- if (this.options.platform !== 'ios') {
1192
- throw unsupported('device.fold drives a foldable iOS simulator (iPhone Duo) through agent-device; it has no Android driver');
1193
- }
1194
1136
  await this.screenCommand('device.fold', (client) => client.command.fold({ pose }), signal);
1195
1137
  }
1196
1138
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@e2e-dev/mobile",
3
- "version": "0.11.1-nightly-20261012095456",
3
+ "version": "0.12.0",
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,13 +51,13 @@
51
51
  "NOTICE"
52
52
  ],
53
53
  "dependencies": {
54
- "agent-device": "0.21.22",
54
+ "agent-device": "0.21.25",
55
55
  "pngjs": "7.0.0",
56
56
  "zod": "4.6.1"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "ai": "^7.0.0",
60
- "e2e": "0.19.1-nightly-20261012095456"
60
+ "e2e": ">=0.15.0 <1"
61
61
  },
62
62
  "peerDependenciesMeta": {
63
63
  "ai": {
@@ -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.19.1-nightly-20261012095456",
71
+ "e2e": "0.20.0",
72
72
  "typescript": "7.0.2",
73
73
  "vitest": "5.0.1"
74
74
  },