@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 +11 -4
- package/dist/.build.json +1 -1
- package/dist/bindings.d.ts +0 -8
- package/dist/bindings.js +1 -3
- package/dist/device.d.ts +27 -13
- package/dist/device.js +20 -0
- package/dist/errors.d.ts +1 -7
- package/dist/errors.js +7 -12
- package/dist/locate.d.ts +3 -2
- package/dist/locate.js +7 -7
- package/dist/nodes.d.ts +5 -4
- package/dist/nodes.js +6 -4
- package/dist/pool.d.ts +5 -8
- package/dist/pool.js +9 -19
- package/dist/selector.d.ts +10 -9
- package/dist/selector.js +14 -62
- package/dist/surface.d.ts +11 -36
- package/dist/surface.js +19 -77
- package/package.json +4 -4
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=
|
|
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
|
|
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=
|
|
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.
|
|
1
|
+
{"version":"0.12.0"}
|
package/dist/bindings.d.ts
CHANGED
|
@@ -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
|
|
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=
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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,
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
49
|
-
* once agent-device reads the angle back from CoreDevice at
|
|
50
|
-
* typically 10 to 16 seconds later
|
|
51
|
-
* `half-open` (a 130° book) and `open` light the inner one
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
-
* |
|
|
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
|
-
* |
|
|
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' ||
|
|
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
|
-
|
|
10
|
-
|
|
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.
|
|
40
|
-
*
|
|
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
|
-
|
|
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.
|
|
343
|
-
*
|
|
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
|
|
68
|
-
*
|
|
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
|
|
87
|
-
*
|
|
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
|
|
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
|
|
155
|
-
*
|
|
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.
|
|
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
|
|
185
|
-
*
|
|
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
|
|
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
|
|
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
|
*/
|
package/dist/selector.d.ts
CHANGED
|
@@ -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.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
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
|
|
12
|
-
* are tried in order and the first that matches anything
|
|
13
|
-
*
|
|
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.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
14
|
-
* are tried in order and the first that matches anything
|
|
15
|
-
*
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
209
|
-
* a
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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
|
|
414
|
-
*
|
|
415
|
-
*
|
|
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 {
|
|
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
|
|
534
|
-
* a
|
|
535
|
-
*
|
|
536
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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
|
|
1187
|
-
*
|
|
1188
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
71
|
+
"e2e": "0.20.0",
|
|
72
72
|
"typescript": "7.0.2",
|
|
73
73
|
"vitest": "5.0.1"
|
|
74
74
|
},
|