@e2e-dev/mobile 0.8.0-canary-20260928184528
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/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +159 -0
- package/dist/.build.json +1 -0
- package/dist/actions.d.ts +41 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +70 -0
- package/dist/actions.js.map +1 -0
- package/dist/bindings.d.ts +87 -0
- package/dist/bindings.d.ts.map +1 -0
- package/dist/bindings.js +112 -0
- package/dist/bindings.js.map +1 -0
- package/dist/device.d.ts +117 -0
- package/dist/device.d.ts.map +1 -0
- package/dist/device.js +116 -0
- package/dist/device.js.map +1 -0
- package/dist/engine.d.ts +17 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +98 -0
- package/dist/engine.js.map +1 -0
- package/dist/errors.d.ts +61 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +223 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/links.d.ts +41 -0
- package/dist/links.d.ts.map +1 -0
- package/dist/links.js +79 -0
- package/dist/links.js.map +1 -0
- package/dist/locate.d.ts +11 -0
- package/dist/locate.d.ts.map +1 -0
- package/dist/locate.js +119 -0
- package/dist/locate.js.map +1 -0
- package/dist/nodes.d.ts +107 -0
- package/dist/nodes.d.ts.map +1 -0
- package/dist/nodes.js +530 -0
- package/dist/nodes.js.map +1 -0
- package/dist/options.d.ts +134 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +7 -0
- package/dist/options.js.map +1 -0
- package/dist/png.d.ts +4 -0
- package/dist/png.d.ts.map +1 -0
- package/dist/png.js +25 -0
- package/dist/png.js.map +1 -0
- package/dist/pool.d.ts +99 -0
- package/dist/pool.d.ts.map +1 -0
- package/dist/pool.js +235 -0
- package/dist/pool.js.map +1 -0
- package/dist/provider.d.ts +119 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +112 -0
- package/dist/provider.js.map +1 -0
- package/dist/selector.d.ts +17 -0
- package/dist/selector.d.ts.map +1 -0
- package/dist/selector.js +84 -0
- package/dist/selector.js.map +1 -0
- package/dist/support.d.ts +63 -0
- package/dist/support.d.ts.map +1 -0
- package/dist/support.js +87 -0
- package/dist/support.js.map +1 -0
- package/dist/surface.d.ts +405 -0
- package/dist/surface.d.ts.map +1 -0
- package/dist/surface.js +1159 -0
- package/dist/surface.js.map +1 -0
- package/dist/tools.d.ts +24 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +90 -0
- package/dist/tools.js.map +1 -0
- package/package.json +83 -0
package/dist/surface.js
ADDED
|
@@ -0,0 +1,1159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent-device surface: one simulator or emulator session, driven through
|
|
3
|
+
* agent-device's typed client, exposed to the runner as the contract's
|
|
4
|
+
* observe/locate/perform members. It owns the id space (one fresh generation
|
|
5
|
+
* per observation), the attempt state (artifact directory, screenshot
|
|
6
|
+
* counter), and every translation between the contract's vocabulary and
|
|
7
|
+
* agent-device's commands. Its device and session come from the target's
|
|
8
|
+
* `DevicePool`, by worker slot. The runner owns everything else.
|
|
9
|
+
*
|
|
10
|
+
* Every observation is reported under one root of a stable id (`ROOT_ID`)
|
|
11
|
+
* wrapping the device's top-level elements; a `swipe` performed on that root
|
|
12
|
+
* scrolls the whole screen.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { EngineError, KEY_NAMES, parseKey, raceAbort, withinCleanupBudget, ConfigurationError, TestError, } from 'e2e/engine';
|
|
18
|
+
import { isNoSessionApp, isSnapshotPresentationFailure, runCommand, staleOr } from './errors.js';
|
|
19
|
+
import { pointerInteraction, DEFAULT_LONG_PRESS_MS } from './actions.js';
|
|
20
|
+
import { resolveExpression } from './locate.js';
|
|
21
|
+
import { isWithin, projectSnapshot, ROOT_ID, screenRoot, screenTitle, } from './nodes.js';
|
|
22
|
+
import { maskPng } from './png.js';
|
|
23
|
+
import { deviceLabel, pinnedApp } from './bindings.js';
|
|
24
|
+
import { assertAppId, assertConfiguredApp } from './links.js';
|
|
25
|
+
import { DevicePool, deviceSelection } from './pool.js';
|
|
26
|
+
import { invalidState, notActionable, logicalScreenSize, readPngSize, sanitizeFilename, screenLocation, swipeWithin, unsupported, } from './support.js';
|
|
27
|
+
/** Why nothing is pinned with `appPath` alone: the install is the suite's. */
|
|
28
|
+
const UNINSTALLED_BUILD = 'the build `appPath` names installed first with `device.installApp()`';
|
|
29
|
+
/** Platform element types that are the soft keyboard or one of its keys, as agent-device names them. */
|
|
30
|
+
const KEYBOARD_TYPES = new Set(['keyboard', 'key']);
|
|
31
|
+
/**
|
|
32
|
+
* How far the dismissing drag travels, as a share of the viewport: short
|
|
33
|
+
* enough that a list under the finger barely moves, long enough for a scroll
|
|
34
|
+
* view to read it as a drag and let go of the keyboard. The same ratio
|
|
35
|
+
* Maestro's `hideKeyboard` uses.
|
|
36
|
+
*/
|
|
37
|
+
const KEYBOARD_DRAG_RATIO = 0.03;
|
|
38
|
+
/**
|
|
39
|
+
* Whether a capture shows the soft keyboard: the band its producer measured
|
|
40
|
+
* when it measured one, else a keyboard or key element in the tree, the probe
|
|
41
|
+
* Maestro's `hideKeyboard` uses. A capture that says nothing either way
|
|
42
|
+
* counts as no keyboard: a drag on a guess costs more than a keyboard a later
|
|
43
|
+
* tap refuses against by name.
|
|
44
|
+
*/
|
|
45
|
+
function keyboardShowing(raw) {
|
|
46
|
+
if (raw.keyboard?.kind === 'visible')
|
|
47
|
+
return true;
|
|
48
|
+
if (raw.keyboard?.kind === 'absent')
|
|
49
|
+
return false;
|
|
50
|
+
return (raw.nodes ?? []).some((node) => KEYBOARD_TYPES.has((node.type ?? '').toLowerCase()));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Whether a snapshot leaves out nodes that are on screen: agent-device says
|
|
54
|
+
* it cut the tree, or the tree is still sparse after the retries (one
|
|
55
|
+
* application node while the app publishes its tree). The harness never
|
|
56
|
+
* calls such a screen unchanged, and the trace cache records the flag.
|
|
57
|
+
*/
|
|
58
|
+
function isTruncated(raw) {
|
|
59
|
+
return raw.truncated === true || raw.snapshotQuality?.state === 'sparse';
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* How long a control that appeared or moved with the last action is given to
|
|
63
|
+
* finish arriving before a test acts on it. The tree cannot tell a sliding
|
|
64
|
+
* control from a landed one: iOS reports the final frame the moment a
|
|
65
|
+
* transition starts, Android the frame in flight, so a modal or pushed screen
|
|
66
|
+
* keeps sliding for about half a second after the action that opened it and
|
|
67
|
+
* a tap aimed from either frame lands on whatever is behind the control.
|
|
68
|
+
* Once the budget has passed the control is found again in a fresh
|
|
69
|
+
* snapshot. Controls that were already on screen at the same place before
|
|
70
|
+
* the action are acted on at once.
|
|
71
|
+
*/
|
|
72
|
+
const DEFAULT_TRANSITION_MS = 500;
|
|
73
|
+
/** The centre of a rect in logical pixels. */
|
|
74
|
+
function centreOf(rect) {
|
|
75
|
+
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
|
76
|
+
}
|
|
77
|
+
/** Whether two rects are the same to the pixel; a missing rect never matches, so a target must be measurable to be stable. */
|
|
78
|
+
function sameRect(a, b) {
|
|
79
|
+
if (a === undefined || b === undefined)
|
|
80
|
+
return false;
|
|
81
|
+
return Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.width - b.width) < 1 && Math.abs(a.height - b.height) < 1;
|
|
82
|
+
}
|
|
83
|
+
const MAX_LOCATED_REFS = 2048;
|
|
84
|
+
/**
|
|
85
|
+
* The Apple runner defers a full snapshot after slow accessibility work and
|
|
86
|
+
* returns a sparse one-node tree. Retrying device-side costs under a second;
|
|
87
|
+
* returning the sparse tree costs the model a confused turn.
|
|
88
|
+
*/
|
|
89
|
+
const SPARSE_RETRY_BACKOFF_MS = [0, 1_200, 3_000];
|
|
90
|
+
/** Number of parent hops from `entry` up to `ancestor`. */
|
|
91
|
+
function depthBelow(entry, ancestor) {
|
|
92
|
+
let hops = 0;
|
|
93
|
+
for (let current = entry.parent; current !== undefined; current = current.parent) {
|
|
94
|
+
hops += 1;
|
|
95
|
+
if (current === ancestor)
|
|
96
|
+
return hops;
|
|
97
|
+
}
|
|
98
|
+
return hops;
|
|
99
|
+
}
|
|
100
|
+
function sleep(ms, signal) {
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
const timer = setTimeout(done, ms);
|
|
103
|
+
function done() {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
signal.removeEventListener('abort', done);
|
|
106
|
+
resolve();
|
|
107
|
+
}
|
|
108
|
+
signal.addEventListener('abort', done, { once: true });
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const DEFAULT_SETTLE_QUIET_MS = 150;
|
|
112
|
+
/** Resolves the `transition` option: the default budget or a custom one. */
|
|
113
|
+
function transitionMs(transition) {
|
|
114
|
+
const budget = transition ?? DEFAULT_TRANSITION_MS;
|
|
115
|
+
if (!Number.isInteger(budget) || budget < 0) {
|
|
116
|
+
throw new ConfigurationError('INVALID_CONFIG', 'mobile: `transition` must be a non-negative integer of milliseconds');
|
|
117
|
+
}
|
|
118
|
+
return budget;
|
|
119
|
+
}
|
|
120
|
+
/** Resolves the `settle` option: the default window, a custom one, or no wait at all. */
|
|
121
|
+
function settleOptions(settle) {
|
|
122
|
+
if (settle === false)
|
|
123
|
+
return {};
|
|
124
|
+
const quietMs = settle ?? DEFAULT_SETTLE_QUIET_MS;
|
|
125
|
+
if (!Number.isInteger(quietMs) || quietMs < 0) {
|
|
126
|
+
throw new ConfigurationError('INVALID_CONFIG', 'mobile: `settle` must be a non-negative integer of milliseconds, or false');
|
|
127
|
+
}
|
|
128
|
+
return { settle: true, settleQuietMs: quietMs };
|
|
129
|
+
}
|
|
130
|
+
export class AgentDeviceSurface {
|
|
131
|
+
options;
|
|
132
|
+
createClient;
|
|
133
|
+
client;
|
|
134
|
+
attempt;
|
|
135
|
+
/** The device this worker drives, the pool's binding for its slot; undefined leaves the choice to agent-device. */
|
|
136
|
+
device;
|
|
137
|
+
generation = new Map();
|
|
138
|
+
located = new Map();
|
|
139
|
+
idCounter = 0;
|
|
140
|
+
appIdentity;
|
|
141
|
+
/**
|
|
142
|
+
* The app this worker's agent-device session is on, and so the app a
|
|
143
|
+
* permission command there acts on: what the warm-up in `prepare` opened
|
|
144
|
+
* under the session this worker resumed, while that session is still open,
|
|
145
|
+
* then whatever this surface last opened. Undefined while nothing has been opened in the session, and once
|
|
146
|
+
* `closeApp` ended it.
|
|
147
|
+
*/
|
|
148
|
+
sessionApp;
|
|
149
|
+
/** The app the build `appPath` installed, once `init` has, itself or through a lease. */
|
|
150
|
+
installedApp;
|
|
151
|
+
/** Where relative build paths resolve; the run's project root once init has told us. */
|
|
152
|
+
projectRoot = process.cwd();
|
|
153
|
+
/** The session and device this worker drives, for the error messages whose recovery is per device; set in init. */
|
|
154
|
+
where;
|
|
155
|
+
/**
|
|
156
|
+
* Commands still running on the device. agent-device takes no abort
|
|
157
|
+
* signal, so a cancelled or timed-out call is only abandoned by its
|
|
158
|
+
* caller; it keeps executing. The next attempt waits for these to settle
|
|
159
|
+
* before it opens anything, so a ghost tap can never land in a retry.
|
|
160
|
+
*/
|
|
161
|
+
inflight = new Set();
|
|
162
|
+
/** The target's devices, one per worker slot; `init` takes this worker's from it. */
|
|
163
|
+
pool;
|
|
164
|
+
/** The settle wait every action carries: quiet window from the `settle` option, or nothing when it is `false`. */
|
|
165
|
+
settleOptions;
|
|
166
|
+
/** When this surface last acted on the device (an input or an app launch); the screen may be in transition for a while after. */
|
|
167
|
+
lastActionAt = 0;
|
|
168
|
+
/** The screen as last projected before that action, to tell controls that were already there from ones that came with it. */
|
|
169
|
+
indexBeforeAction;
|
|
170
|
+
/** The most recent projection of the screen, from any observe or locate. */
|
|
171
|
+
latestIndex;
|
|
172
|
+
/** Budget a control that came with the last action gets to finish arriving; see DEFAULT_TRANSITION_MS. */
|
|
173
|
+
transitionMs;
|
|
174
|
+
/**
|
|
175
|
+
* The screen's logical size as last learned from a snapshot with geometry
|
|
176
|
+
* or from the device itself, so a snapshot without geometry (an empty
|
|
177
|
+
* screen before any app is open, a sparse tree) still reports the viewport
|
|
178
|
+
* every observation must carry.
|
|
179
|
+
*/
|
|
180
|
+
knownViewport;
|
|
181
|
+
constructor(options, createClient) {
|
|
182
|
+
this.options = options;
|
|
183
|
+
this.createClient = createClient;
|
|
184
|
+
assertConfiguredApp(options.app);
|
|
185
|
+
this.pool = new DevicePool(options, createClient);
|
|
186
|
+
this.settleOptions = settleOptions(options.settle);
|
|
187
|
+
this.transitionMs = transitionMs(options.transition);
|
|
188
|
+
}
|
|
189
|
+
/** Whether the manifest declares app restart and state clearing. */
|
|
190
|
+
get managesApp() {
|
|
191
|
+
return this.options.app !== undefined || this.options.appPath !== undefined;
|
|
192
|
+
}
|
|
193
|
+
/** The app `app.open()` launches: the `app` option, else the build `appPath` installed. */
|
|
194
|
+
get pinnedApp() {
|
|
195
|
+
return pinnedApp(this.options, this.installedApp);
|
|
196
|
+
}
|
|
197
|
+
/** Whether an attempt is running on this surface right now. */
|
|
198
|
+
get attemptRunning() {
|
|
199
|
+
return this.attempt !== undefined;
|
|
200
|
+
}
|
|
201
|
+
/** The live client; INVALID_STATE before init or after dispose. */
|
|
202
|
+
requireClient() {
|
|
203
|
+
if (this.client === undefined)
|
|
204
|
+
throw invalidState('the agent-device engine is not initialized');
|
|
205
|
+
return this.client;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Runs one agent-device command under an operation budget and translates
|
|
209
|
+
* its failure. Contributed-fixture methods route through here too, so the
|
|
210
|
+
* device fixture never carries its own error mapping.
|
|
211
|
+
*/
|
|
212
|
+
async command(label, run, signal) {
|
|
213
|
+
const client = this.requireClient();
|
|
214
|
+
return runCommand(label, () => this.track(run(client)), signal ?? new AbortController().signal, this.where);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Runs a fixture command that changes the screen (a back, the home screen,
|
|
218
|
+
* a dismissed alert or keyboard, a rotation) and records it as an action,
|
|
219
|
+
* so a control that arrives with the change waits out the transition
|
|
220
|
+
* budget before a test acts on it, as it does after a tap. Without this a
|
|
221
|
+
* `device.back()` followed by a tap lands where the returning screen has
|
|
222
|
+
* not yet arrived.
|
|
223
|
+
*/
|
|
224
|
+
async screenCommand(label, run, signal) {
|
|
225
|
+
const before = this.latestIndex;
|
|
226
|
+
const result = await this.command(label, run, signal);
|
|
227
|
+
this.markAction(before);
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
/** Registers one device command as in flight until it settles. */
|
|
231
|
+
track(pending) {
|
|
232
|
+
this.inflight.add(pending);
|
|
233
|
+
pending.then(() => this.inflight.delete(pending), () => this.inflight.delete(pending));
|
|
234
|
+
return pending;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Waits for every abandoned command to settle, within the caller's
|
|
238
|
+
* budget. A command that never settles fails the attempt launch instead of
|
|
239
|
+
* racing it: the launch timeout is the honest bound on a stuck device.
|
|
240
|
+
*/
|
|
241
|
+
async settleInflight(signal) {
|
|
242
|
+
while (this.inflight.size > 0) {
|
|
243
|
+
await raceAbort(Promise.allSettled(this.inflight), signal, 'settling in-flight device commands');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** The device selection this worker's commands take. */
|
|
247
|
+
selection() {
|
|
248
|
+
return deviceSelection(this.options.platform, this.device);
|
|
249
|
+
}
|
|
250
|
+
async init(info) {
|
|
251
|
+
this.projectRoot = info.projectRoot;
|
|
252
|
+
const binding = this.pool.binding(info.targetName, info.workerSlot, info.env);
|
|
253
|
+
this.device = binding;
|
|
254
|
+
const session = this.pool.session(info.targetName, info.workerSlot);
|
|
255
|
+
const label = deviceLabel(binding);
|
|
256
|
+
this.where = `session ${session}${label === undefined ? '' : ` on ${label}`}`;
|
|
257
|
+
this.client ??= this.createClient(session, binding);
|
|
258
|
+
await this.command('boot', (client) => client.devices.boot(this.selection()), info.signal);
|
|
259
|
+
this.sessionApp = binding?.sessionApp === undefined ? undefined : await this.resumedSessionApp(session, binding.sessionApp, info.signal);
|
|
260
|
+
// Nothing is installed here: a device provider that installed the build
|
|
261
|
+
// from `appPath` says so on the binding, and otherwise the suite installs
|
|
262
|
+
// it where it wants to, with `device.installApp()`.
|
|
263
|
+
this.installedApp = binding?.installedApp;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* The app the warm-up left the slot's session on, while agent-device still
|
|
267
|
+
* holds that session. Only the slot's first worker finds it: a worker
|
|
268
|
+
* retired after a failing test closed it in `dispose`, and a permission
|
|
269
|
+
* command on a closed session reaches no device. A session not listed, or
|
|
270
|
+
* a list that fails, leaves the app unknown, and the first permission
|
|
271
|
+
* change opens it again.
|
|
272
|
+
*/
|
|
273
|
+
async resumedSessionApp(session, app, signal) {
|
|
274
|
+
const sessions = await this.command('sessions', (client) => client.sessions.list(), signal).catch((cause) => {
|
|
275
|
+
if (signal.aborted)
|
|
276
|
+
throw cause;
|
|
277
|
+
return undefined;
|
|
278
|
+
});
|
|
279
|
+
if (!Array.isArray(sessions))
|
|
280
|
+
return undefined;
|
|
281
|
+
const open = sessions.some((entry) => (entry.address ?? entry.name) === session);
|
|
282
|
+
return open ? app : undefined;
|
|
283
|
+
}
|
|
284
|
+
async startAttempt(context) {
|
|
285
|
+
if (this.attempt !== undefined) {
|
|
286
|
+
throw invalidState('an attempt is already running on this agent-device engine');
|
|
287
|
+
}
|
|
288
|
+
await this.settleInflight(context.signal);
|
|
289
|
+
// Nothing is launched here: the app is where the last test, or the
|
|
290
|
+
// worker's `prepare`, left it, and a test that wants it fresh calls
|
|
291
|
+
// `app.open()`, which is `restart` below. The bindings are forgotten, the
|
|
292
|
+
// screen is not, so a control still in place is acted on at once.
|
|
293
|
+
this.attempt = { artifactsDir: context.artifactsDir, screenshots: 0, video: undefined };
|
|
294
|
+
this.generation = new Map();
|
|
295
|
+
this.located.clear();
|
|
296
|
+
}
|
|
297
|
+
async endAttempt(context) {
|
|
298
|
+
const dangling = this.attempt?.video;
|
|
299
|
+
this.attempt = undefined;
|
|
300
|
+
this.generation = new Map();
|
|
301
|
+
this.located.clear();
|
|
302
|
+
// The harness stops the video before it ends the attempt; a recording still
|
|
303
|
+
// marked here belongs to an attempt cut short, or to a stop that failed,
|
|
304
|
+
// and the device must not keep recording into the next one. A start that
|
|
305
|
+
// outlived its budget may still be landing: it settles first, so the stop
|
|
306
|
+
// cannot overtake it.
|
|
307
|
+
if (dangling !== undefined) {
|
|
308
|
+
await this.settleInflight(context.signal).catch(() => undefined);
|
|
309
|
+
await this.command('stop video recording', (client) => client.recording.record({ action: 'stop' }), context.signal).catch(() => undefined);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// --- video ---
|
|
313
|
+
/**
|
|
314
|
+
* Asks the device to record its screen into the attempt directory. Taps stay
|
|
315
|
+
* visible in the recording (agent-device's touch indicator), which is the
|
|
316
|
+
* closest a phone comes to a cursor.
|
|
317
|
+
*/
|
|
318
|
+
async startVideo(operation) {
|
|
319
|
+
const attempt = this.attempt;
|
|
320
|
+
if (attempt === undefined)
|
|
321
|
+
throw invalidState('startVideo outside an attempt');
|
|
322
|
+
if (attempt.video !== undefined)
|
|
323
|
+
throw invalidState('a video is already recording');
|
|
324
|
+
const relative = path.join('video', 'video.mp4');
|
|
325
|
+
const absolute = path.join(attempt.artifactsDir, relative);
|
|
326
|
+
mkdirSync(path.dirname(absolute), { recursive: true });
|
|
327
|
+
// Marked before the device is asked: a start that outlives its budget
|
|
328
|
+
// still records, and `endAttempt` must be able to stop it.
|
|
329
|
+
const recording = { relative, absolute, startedAt: new Date().toISOString() };
|
|
330
|
+
attempt.video = recording;
|
|
331
|
+
await this.command('start video recording', (client) => client.recording.record({ action: 'start', path: absolute, quality: 'medium' }), operation.signal);
|
|
332
|
+
// The device confirmed: it is recording from about now.
|
|
333
|
+
recording.startedAt = new Date().toISOString();
|
|
334
|
+
}
|
|
335
|
+
/** Stops the recording and returns its one segment, or none when the device wrote nothing. */
|
|
336
|
+
async stopVideo(operation) {
|
|
337
|
+
const attempt = this.attempt;
|
|
338
|
+
if (attempt === undefined)
|
|
339
|
+
throw invalidState('stopVideo outside an attempt');
|
|
340
|
+
const video = attempt.video;
|
|
341
|
+
if (video === undefined)
|
|
342
|
+
return [];
|
|
343
|
+
const result = await this.command('stop video recording', (client) => client.recording.record({ action: 'stop' }), operation.signal);
|
|
344
|
+
// Cleared only now: a stop that failed leaves the recording for `endAttempt`.
|
|
345
|
+
attempt.video = undefined;
|
|
346
|
+
// The device may finalize the file under a path of its own choosing; the
|
|
347
|
+
// artifact must live where the attempt directory expects it.
|
|
348
|
+
const written = typeof result.outPath === 'string' ? result.outPath : video.absolute;
|
|
349
|
+
if (written !== video.absolute && existsSync(written))
|
|
350
|
+
renameSync(written, video.absolute);
|
|
351
|
+
if (!existsSync(video.absolute))
|
|
352
|
+
return [];
|
|
353
|
+
return [{ path: video.relative, startedAt: video.startedAt }];
|
|
354
|
+
}
|
|
355
|
+
async dispose(context) {
|
|
356
|
+
const client = this.client;
|
|
357
|
+
this.client = undefined;
|
|
358
|
+
this.attempt = undefined;
|
|
359
|
+
this.generation = new Map();
|
|
360
|
+
this.located.clear();
|
|
361
|
+
this.appIdentity = undefined;
|
|
362
|
+
this.sessionApp = undefined;
|
|
363
|
+
this.installedApp = undefined;
|
|
364
|
+
this.knownViewport = undefined;
|
|
365
|
+
if (client === undefined)
|
|
366
|
+
return;
|
|
367
|
+
await withinCleanupBudget(client.sessions.close().catch(() => undefined), context);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Opens an app in the session, remembering its identity for the path
|
|
371
|
+
* anchor. A link is refused before the device sees it: agent-device would
|
|
372
|
+
* open it as a URL, and `openLink` is the path that carries the
|
|
373
|
+
* navigation rule.
|
|
374
|
+
*/
|
|
375
|
+
async openApp(app, options, signal) {
|
|
376
|
+
assertAppId(app);
|
|
377
|
+
const relaunch = options.relaunch === true;
|
|
378
|
+
// The engine's own launch options belong to a fresh launch of the pinned
|
|
379
|
+
// app. A foreground-only open of a running app takes no arguments, and a
|
|
380
|
+
// permission change there would terminate the app it means to keep.
|
|
381
|
+
const configured = relaunch && app === this.pinnedApp;
|
|
382
|
+
const launchArguments = options.launchArguments ?? (configured ? this.options.launchArguments : undefined);
|
|
383
|
+
const permissions = options.permissions ?? (configured ? this.options.permissions : undefined);
|
|
384
|
+
if (permissions !== undefined)
|
|
385
|
+
await this.presetPermissions(app, permissions, signal);
|
|
386
|
+
await this.open(app, relaunch, launchArguments, signal);
|
|
387
|
+
}
|
|
388
|
+
/** One `open` of an app, remembered as the session's app and as the screen's replacement. */
|
|
389
|
+
async open(app, relaunch, launchArguments, signal) {
|
|
390
|
+
const result = await this.command(`open ${app}`, (client) => client.apps.open({
|
|
391
|
+
app,
|
|
392
|
+
...this.selection(),
|
|
393
|
+
...(relaunch ? { relaunch: true } : {}),
|
|
394
|
+
...(launchArguments === undefined || launchArguments.length === 0 ? {} : { launchArgs: [...launchArguments] }),
|
|
395
|
+
}), signal);
|
|
396
|
+
this.sessionApp = app;
|
|
397
|
+
this.launched(result.appBundleId ?? result.appName ?? app);
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Puts an app's permissions in place before it launches. agent-device sets
|
|
401
|
+
* a permission on the app its session is on, so a session on another app,
|
|
402
|
+
* or on none yet, is first brought onto this one with a foreground open;
|
|
403
|
+
* the warm-up in `prepare` did that for the pinned app under the session
|
|
404
|
+
* this worker resumed, so its first launch needs none. Before the launch,
|
|
405
|
+
* never after: iOS terminates a running app whose
|
|
406
|
+
* permission changed, and Android one whose permission was revoked, so a
|
|
407
|
+
* change after the launch would leave the test on no screen.
|
|
408
|
+
*/
|
|
409
|
+
async presetPermissions(app, permissions, signal) {
|
|
410
|
+
const entries = Object.entries(permissions).filter((entry) => entry[1] !== undefined);
|
|
411
|
+
if (entries.length === 0)
|
|
412
|
+
return;
|
|
413
|
+
if (this.sessionApp !== app)
|
|
414
|
+
await this.open(app, false, undefined, signal);
|
|
415
|
+
for (const [permission, state] of entries)
|
|
416
|
+
await this.permission(permission, state, signal);
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* One permission change for `device.setPermission`: on the app the session
|
|
420
|
+
* is on, which is what agent-device acts on. With none known here (nothing
|
|
421
|
+
* opened yet, `closeApp`, a worker resumed on a bare session) the pinned
|
|
422
|
+
* app is brought to the foreground first. agent-device's own refusal, met
|
|
423
|
+
* when its session lost the app since (a failed attempt left it on none),
|
|
424
|
+
* gets the same foreground open and the command once more.
|
|
425
|
+
*/
|
|
426
|
+
async setPermission(permission, state, signal) {
|
|
427
|
+
const app = this.pinnedApp;
|
|
428
|
+
if (this.sessionApp === undefined && app !== undefined)
|
|
429
|
+
await this.open(app, false, undefined, signal);
|
|
430
|
+
try {
|
|
431
|
+
await this.permission(permission, state, signal);
|
|
432
|
+
}
|
|
433
|
+
catch (cause) {
|
|
434
|
+
if (signal.aborted || app === undefined || !isNoSessionApp(cause))
|
|
435
|
+
throw cause;
|
|
436
|
+
await this.open(app, false, undefined, signal);
|
|
437
|
+
await this.permission(permission, state, signal);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
async permission(permission, state, signal) {
|
|
441
|
+
await this.command(`permission ${permission} ${state}`, (client) => client.settings.update({ setting: 'permission', permission, state }), signal);
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Opens a link on the device, into `app` (the pinned app when none is
|
|
445
|
+
* named). Bound to an app, agent-device hands the link to it and the
|
|
446
|
+
* session observes that app afterwards: iOS launches the app first for a
|
|
447
|
+
* web link and then opens the URL, Android starts a VIEW intent on the
|
|
448
|
+
* package. Without an app the OS routes the link to the scheme's app, a
|
|
449
|
+
* verified app link owner, or the browser. iOS needs the app: an unbound
|
|
450
|
+
* `open <url>` there leaves no app session, so the next snapshot would
|
|
451
|
+
* fail, and agent-device refuses a deep link without one on a physical
|
|
452
|
+
* device anyway. An unbound open reports the URL as its app name, which is
|
|
453
|
+
* no identity, and the package that took the link as its bundle id when
|
|
454
|
+
* the OS told it; the next snapshot names the foreground app either way.
|
|
455
|
+
*/
|
|
456
|
+
async openLink(url, app, signal) {
|
|
457
|
+
const target = app ?? this.pinnedApp;
|
|
458
|
+
if (target === undefined && this.options.platform === 'ios') {
|
|
459
|
+
throw new TestError('INVALID_ARGUMENT', 'openLink needs an app on iOS: pass `app`, or pin one with the engine option `app` or `appPath`; the session observes the app a link is opened into');
|
|
460
|
+
}
|
|
461
|
+
const result = await this.command('device.openLink', (client) => client.apps.open({
|
|
462
|
+
...this.selection(),
|
|
463
|
+
...(target === undefined ? { app: url.href } : { app: target, url: url.href }),
|
|
464
|
+
}), signal);
|
|
465
|
+
this.sessionApp = target;
|
|
466
|
+
this.launched(result.appBundleId ?? (target === undefined ? undefined : (result.appName ?? target)));
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* What a launch leaves behind. The identity is the app agent-device named;
|
|
470
|
+
* a link the OS routed keeps the last known one until the next snapshot
|
|
471
|
+
* reports the foreground app.
|
|
472
|
+
*/
|
|
473
|
+
launched(identity) {
|
|
474
|
+
if (identity !== undefined)
|
|
475
|
+
this.appIdentity = identity;
|
|
476
|
+
this.screenReplaced();
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Forgets the screen after a launch or a close. There is no screen before
|
|
480
|
+
* it worth matching against: every control of whatever shows next is
|
|
481
|
+
* arriving, whatever the previous screen looked like, and none of its
|
|
482
|
+
* bindings names a node that still exists.
|
|
483
|
+
*/
|
|
484
|
+
screenReplaced() {
|
|
485
|
+
this.markAction(undefined);
|
|
486
|
+
this.generation = new Map();
|
|
487
|
+
this.located.clear();
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Terminates the session's app, then ends the session. agent-device's bare
|
|
491
|
+
* `close` ends the session and leaves the app running, so the next
|
|
492
|
+
* `openApp` would resume it mid-flow; a close that names the app
|
|
493
|
+
* terminates it first. The app is the one the session last observed, else
|
|
494
|
+
* the pinned one; with neither known there is nothing to terminate and
|
|
495
|
+
* only the session ends.
|
|
496
|
+
*/
|
|
497
|
+
async closeApp(signal) {
|
|
498
|
+
const app = this.appIdentity ?? this.pinnedApp;
|
|
499
|
+
await this.command('device.closeApp', (client) => client.apps.close(app === undefined ? {} : { app }), signal);
|
|
500
|
+
this.sessionApp = undefined;
|
|
501
|
+
this.screenReplaced();
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Installs a build on the session's device. `reinstall` removes the app
|
|
505
|
+
* named by `options.app` (else the pinned app) first, so the build starts
|
|
506
|
+
* with no data; a plain install replaces the binary and keeps its data.
|
|
507
|
+
*/
|
|
508
|
+
async installApp(appPath, options, signal) {
|
|
509
|
+
const build = appPath ?? this.options.appPath;
|
|
510
|
+
if (build === undefined)
|
|
511
|
+
throw invalidState('installApp needs a build: pass a path, or name one with the engine option `appPath`');
|
|
512
|
+
const resolved = path.resolve(this.projectRoot, build);
|
|
513
|
+
const selection = this.selection();
|
|
514
|
+
const engineBuild = appPath === undefined || (this.options.appPath !== undefined && resolved === path.resolve(this.projectRoot, this.options.appPath));
|
|
515
|
+
const app = options.app ?? (engineBuild ? this.options.app : undefined) ?? (options.reinstall === true ? this.pinnedApp : undefined);
|
|
516
|
+
if (options.reinstall === true && app === undefined) {
|
|
517
|
+
throw invalidState('reinstall needs an app: pass `app`, or pin one with the engine option `app` or `appPath`');
|
|
518
|
+
}
|
|
519
|
+
const result = (await this.command(`install ${resolved}`, (client) => options.reinstall === true && app !== undefined
|
|
520
|
+
? client.apps.reinstall({ ...selection, app, appPath: resolved })
|
|
521
|
+
: client.apps.install({ ...selection, ...(app === undefined ? {} : { app }), appPath: resolved }), signal));
|
|
522
|
+
const identity = result.bundleId ?? result.package ?? result.appId;
|
|
523
|
+
const installed = { app: identity ?? result.app, ...(identity === undefined ? {} : { bundleId: identity }) };
|
|
524
|
+
// The engine's own build, installed: without `app`, this is what `app.open()` launches from here on.
|
|
525
|
+
if (engineBuild)
|
|
526
|
+
this.installedApp = installed.app;
|
|
527
|
+
return installed;
|
|
528
|
+
}
|
|
529
|
+
async snapshot(signal, interactiveOnly) {
|
|
530
|
+
let last = {};
|
|
531
|
+
for (const backoffMs of SPARSE_RETRY_BACKOFF_MS) {
|
|
532
|
+
if (backoffMs > 0)
|
|
533
|
+
await sleep(backoffMs, signal);
|
|
534
|
+
if (signal.aborted)
|
|
535
|
+
throw new EngineError('CANCELLED', 'snapshot cancelled', { retryable: false });
|
|
536
|
+
last = await this.capture(signal, interactiveOnly);
|
|
537
|
+
if (last.appBundleId !== undefined || last.appName !== undefined) {
|
|
538
|
+
this.appIdentity = last.appBundleId ?? last.appName;
|
|
539
|
+
}
|
|
540
|
+
if (last.snapshotQuality?.state !== 'sparse')
|
|
541
|
+
return last;
|
|
542
|
+
}
|
|
543
|
+
return last;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* One capture, taken again once when the iOS runner acquired the tree but
|
|
547
|
+
* failed its own presentation check on it (a viewport it could not read, a
|
|
548
|
+
* malformed graph). The check is per capture and a capture is a read, so
|
|
549
|
+
* the retry is safe; a second failure is the runner's and propagates.
|
|
550
|
+
*/
|
|
551
|
+
async capture(signal, interactiveOnly) {
|
|
552
|
+
const take = () => this.command('snapshot', (client) => client.capture.snapshot({ interactiveOnly }), signal);
|
|
553
|
+
try {
|
|
554
|
+
return await take();
|
|
555
|
+
}
|
|
556
|
+
catch (cause) {
|
|
557
|
+
if (signal.aborted || !isSnapshotPresentationFailure(cause))
|
|
558
|
+
throw cause;
|
|
559
|
+
return take();
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Snapshot for an observation. Without a pinned app, an observation before
|
|
564
|
+
* anything is open is an empty screen rather than a fault: the model's next
|
|
565
|
+
* move is the open tool, and failing the step would take that move away.
|
|
566
|
+
*/
|
|
567
|
+
async snapshotOrEmpty(operation, interactiveOnly) {
|
|
568
|
+
try {
|
|
569
|
+
return await this.snapshot(operation.signal, interactiveOnly);
|
|
570
|
+
}
|
|
571
|
+
catch (cause) {
|
|
572
|
+
if (!this.managesApp && cause instanceof EngineError && cause.code === 'INVALID_STATE')
|
|
573
|
+
return { nodes: [] };
|
|
574
|
+
throw cause;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
project(raw) {
|
|
578
|
+
const projected = projectSnapshot(raw.nodes ?? [], {
|
|
579
|
+
mintId: () => {
|
|
580
|
+
this.idCounter += 1;
|
|
581
|
+
return `n${this.idCounter}`;
|
|
582
|
+
},
|
|
583
|
+
});
|
|
584
|
+
this.latestIndex = projected.index;
|
|
585
|
+
if (projected.viewport !== undefined)
|
|
586
|
+
this.knownViewport = projected.viewport;
|
|
587
|
+
return projected;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* The viewport of a snapshot: its own geometry, else the last one this
|
|
591
|
+
* session learned, else the device's logical screen size read off one
|
|
592
|
+
* screenshot. A device whose size cannot be learned fails the observation:
|
|
593
|
+
* the harness measures every rect and point against the viewport, so an
|
|
594
|
+
* invented one would misplace every tap.
|
|
595
|
+
*/
|
|
596
|
+
async viewportFor(projected, signal) {
|
|
597
|
+
const known = projected.viewport ?? this.knownViewport;
|
|
598
|
+
if (known !== undefined)
|
|
599
|
+
return known;
|
|
600
|
+
const probed = await this.probeViewport(signal);
|
|
601
|
+
if (probed === undefined) {
|
|
602
|
+
throw new EngineError('ENGINE_FAILURE', 'the device reported no screen geometry: the snapshot has no bounds and the screenshot no logical size, so the viewport is unknown', { retryable: false });
|
|
603
|
+
}
|
|
604
|
+
this.knownViewport = probed;
|
|
605
|
+
return probed;
|
|
606
|
+
}
|
|
607
|
+
/** The device's logical screen size as its screenshot reports it; undefined when it reports none. */
|
|
608
|
+
async probeViewport(signal) {
|
|
609
|
+
const result = await this.captureScreenshot(signal, async (shot) => shot);
|
|
610
|
+
return logicalScreenSize(result);
|
|
611
|
+
}
|
|
612
|
+
async observe(operation, options) {
|
|
613
|
+
const raw = await this.snapshotOrEmpty(operation, this.options.snapshot === 'interactive');
|
|
614
|
+
const projected = this.project(raw);
|
|
615
|
+
this.generation = new Map(projected.index.map((entry) => [entry.id, this.bind(entry, projected.index)]));
|
|
616
|
+
const viewport = await this.viewportFor(projected, operation.signal);
|
|
617
|
+
const location = screenLocation(raw.appBundleId ?? raw.appName ?? this.appIdentity, screenTitle(projected));
|
|
618
|
+
const capture = options?.pixels === true ? await this.capturePixels(operation, projected, viewport) : undefined;
|
|
619
|
+
return {
|
|
620
|
+
root: screenRoot(projected.roots, viewport),
|
|
621
|
+
viewport,
|
|
622
|
+
...(isTruncated(raw) ? { truncated: true } : {}),
|
|
623
|
+
...(location === undefined ? {} : { location }),
|
|
624
|
+
...(capture === undefined ? {} : { pixels: capture.pixels, maskedRegionCount: capture.masked }),
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
/** Retains only action bindings for matches, independently of the observation generation. */
|
|
628
|
+
async locate(expression, operation) {
|
|
629
|
+
const raw = await this.snapshotOrEmpty(operation, false);
|
|
630
|
+
const projected = this.project(raw);
|
|
631
|
+
const matches = resolveExpression(expression, projected.index);
|
|
632
|
+
for (const entry of matches)
|
|
633
|
+
this.located.set(entry.id, this.bind(entry, projected.index));
|
|
634
|
+
for (const oldest of this.located.keys()) {
|
|
635
|
+
if (this.located.size <= MAX_LOCATED_REFS)
|
|
636
|
+
break;
|
|
637
|
+
this.located.delete(oldest);
|
|
638
|
+
}
|
|
639
|
+
return matches.map((entry) => entry.node);
|
|
640
|
+
}
|
|
641
|
+
resolveRef(ref) {
|
|
642
|
+
const entry = this.located.get(ref.id) ?? this.generation.get(ref.id);
|
|
643
|
+
if (entry === undefined) {
|
|
644
|
+
throw new EngineError('NODE_STALE', `node ${ref.id} is not part of the newest observation`, { retryable: true });
|
|
645
|
+
}
|
|
646
|
+
return entry;
|
|
647
|
+
}
|
|
648
|
+
actionTarget(entry, control = false) {
|
|
649
|
+
const ref = control ? entry.controlRef : entry.ref;
|
|
650
|
+
if (ref === '')
|
|
651
|
+
throw notActionable(`node ${entry.id} has no agent-device ref to act on`);
|
|
652
|
+
return { ref: `@${ref}` };
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* The node a toggle press must land on. UIKit reports a settings row as a
|
|
656
|
+
* labelled `Switch` spanning the whole row with the real control as an
|
|
657
|
+
* unlabelled `Switch` child at its trailing edge; a press at the row's
|
|
658
|
+
* centre hits the label and changes nothing. The innermost same-role
|
|
659
|
+
* descendant with a ref is the control; a node without one is its own.
|
|
660
|
+
*/
|
|
661
|
+
controlOf(entry, snapshot) {
|
|
662
|
+
const role = entry.node.role;
|
|
663
|
+
if (role !== 'switch' && role !== 'checkbox')
|
|
664
|
+
return entry;
|
|
665
|
+
let control = entry;
|
|
666
|
+
let depth = 0;
|
|
667
|
+
for (const candidate of snapshot) {
|
|
668
|
+
if (candidate.ref === '' || candidate.node.role !== role || !isWithin(candidate, entry))
|
|
669
|
+
continue;
|
|
670
|
+
const candidateDepth = depthBelow(candidate, entry);
|
|
671
|
+
if (candidateDepth > depth) {
|
|
672
|
+
control = candidate;
|
|
673
|
+
depth = candidateDepth;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return control;
|
|
677
|
+
}
|
|
678
|
+
/** Copies the fields actions use and pre-resolves a toggle's inner control. */
|
|
679
|
+
bind(entry, snapshot) {
|
|
680
|
+
const { children: _children, ...node } = entry.node;
|
|
681
|
+
const control = this.controlOf(entry, snapshot);
|
|
682
|
+
return { id: entry.id, ref: entry.ref, node, controlRef: control.ref };
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* The node a binding stands for, in a fresh snapshot: same role, same test
|
|
686
|
+
* id, same name and text, and of those the one closest to where it was.
|
|
687
|
+
*/
|
|
688
|
+
refind(entry, index) {
|
|
689
|
+
const candidates = index.filter((candidate) => candidate.node.role === entry.node.role &&
|
|
690
|
+
candidate.node.testId === entry.node.testId &&
|
|
691
|
+
candidate.node.name === entry.node.name &&
|
|
692
|
+
candidate.node.text === entry.node.text);
|
|
693
|
+
if (candidates.length <= 1 || entry.node.rect === undefined)
|
|
694
|
+
return candidates[0];
|
|
695
|
+
const was = centreOf(entry.node.rect);
|
|
696
|
+
let best = candidates[0];
|
|
697
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
698
|
+
for (const candidate of candidates) {
|
|
699
|
+
if (candidate.node.rect === undefined)
|
|
700
|
+
continue;
|
|
701
|
+
const at = centreOf(candidate.node.rect);
|
|
702
|
+
const distance = Math.hypot(at.x - was.x, at.y - was.y);
|
|
703
|
+
if (distance < bestDistance) {
|
|
704
|
+
best = candidate;
|
|
705
|
+
bestDistance = distance;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return best;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Lets a control that came with the last action finish arriving before a
|
|
712
|
+
* test acts on it, and answers the binding to act on. A control already
|
|
713
|
+
* present at the same place in the snapshot the last action was resolved
|
|
714
|
+
* from is not in transition and is acted on at once; anything else waits
|
|
715
|
+
* out the remainder of the transition budget since that action and is then
|
|
716
|
+
* found again in a fresh snapshot. The budget is the only signal that the
|
|
717
|
+
* transition is over, since the tree cannot tell a sliding control from a
|
|
718
|
+
* landed one; the fresh snapshot is where the control landed. Android
|
|
719
|
+
* reports a window's frames while it slides in, so a ref resolved
|
|
720
|
+
* mid-flight would send the action where the control was: below the
|
|
721
|
+
* screen, for a modal rising from the bottom.
|
|
722
|
+
*/
|
|
723
|
+
async settled(entry, operation) {
|
|
724
|
+
const since = Date.now() - this.lastActionAt;
|
|
725
|
+
const remaining = this.transitionMs - since;
|
|
726
|
+
if (remaining <= 0)
|
|
727
|
+
return entry;
|
|
728
|
+
const before = this.indexBeforeAction;
|
|
729
|
+
if (before !== undefined) {
|
|
730
|
+
const prior = this.refind(entry, before);
|
|
731
|
+
if (prior !== undefined && sameRect(prior.node.rect, entry.node.rect))
|
|
732
|
+
return entry;
|
|
733
|
+
}
|
|
734
|
+
await sleep(remaining, operation.signal);
|
|
735
|
+
// The sleep resolves on abort; the action behind it must not go out once
|
|
736
|
+
// the caller has already been told the operation was cancelled.
|
|
737
|
+
if (operation.signal.aborted) {
|
|
738
|
+
throw new EngineError('CANCELLED', 'transition wait cancelled', { retryable: false });
|
|
739
|
+
}
|
|
740
|
+
return this.relocated(entry, operation);
|
|
741
|
+
}
|
|
742
|
+
/** The same control in a fresh snapshot; the binding as it was when the snapshot no longer lists it. */
|
|
743
|
+
async relocated(entry, operation) {
|
|
744
|
+
const projected = this.project(await this.snapshotOrEmpty(operation, false));
|
|
745
|
+
const found = this.refind(entry, projected.index);
|
|
746
|
+
return found === undefined ? entry : this.bind(found, projected.index);
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Records an action the device received: the screen as it was projected
|
|
750
|
+
* before it, and when it returned. The transition budget counts from the
|
|
751
|
+
* return, since the input lands late in the command's own round trip.
|
|
752
|
+
*/
|
|
753
|
+
markAction(before) {
|
|
754
|
+
this.indexBeforeAction = before;
|
|
755
|
+
this.lastActionAt = Date.now();
|
|
756
|
+
}
|
|
757
|
+
async perform(ref, action, operation) {
|
|
758
|
+
// A test's step verifies its outcome with `expect`, so it never waits for
|
|
759
|
+
// the screen to settle afterwards; it only lets a control that came with
|
|
760
|
+
// the last action finish arriving. The agent reads the screen right after
|
|
761
|
+
// acting, so its actions settle first.
|
|
762
|
+
const deterministic = operation.origin === 'test';
|
|
763
|
+
const settle = deterministic ? {} : this.settleOptions;
|
|
764
|
+
// The observation root is the screen: a swipe on it scrolls the whole
|
|
765
|
+
// viewport, which is the one action a screen takes. It sends no `amount`
|
|
766
|
+
// and keeps agent-device's default for every momentum and every origin:
|
|
767
|
+
// agent-device centres the gesture on the viewport, so the finger starts
|
|
768
|
+
// at `0.5 + amount / 2` of the height, and a larger amount lands it in
|
|
769
|
+
// bottom chrome (a sticky footer, a tab bar) that swallows the drag. A
|
|
770
|
+
// live iOS run lost every `slow` scroll of the mobile benchmark's sticky
|
|
771
|
+
// chrome flow that way. Only the settle wait varies here, by origin.
|
|
772
|
+
if (ref.id === ROOT_ID) {
|
|
773
|
+
if (action.kind !== 'swipe')
|
|
774
|
+
throw notActionable(`the screen root takes swipe only, not ${action.kind}; act on a node`);
|
|
775
|
+
const before = this.latestIndex;
|
|
776
|
+
await this.command('swipe', (client) => client.interactions.scroll({ direction: action.direction, ...settle }), operation.signal);
|
|
777
|
+
this.markAction(before);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
const entry = this.resolveRef(ref);
|
|
781
|
+
const label = `perform ${action.kind}`;
|
|
782
|
+
const client = this.requireClient();
|
|
783
|
+
// A toggle already in the wanted state sends nothing, so it neither waits
|
|
784
|
+
// for a transition nor counts as an action the next control must wait on.
|
|
785
|
+
if ((action.kind === 'check' || action.kind === 'uncheck') && entry.node.states?.checked === (action.kind === 'check')) {
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const before = this.latestIndex;
|
|
789
|
+
const run = async () => {
|
|
790
|
+
const target = deterministic ? await this.settled(entry, operation) : entry;
|
|
791
|
+
switch (action.kind) {
|
|
792
|
+
case 'tap':
|
|
793
|
+
return client.interactions.press({ ...this.actionTarget(target, true), ...settle });
|
|
794
|
+
case 'focus':
|
|
795
|
+
// A touch surface focuses by tapping, and a tap on anything but an
|
|
796
|
+
// editable field activates it; focus is offered for fields only.
|
|
797
|
+
if (target.node.role !== 'textbox') {
|
|
798
|
+
throw unsupported(`agent-device can only focus editable fields; node ${target.id} is ${target.node.role ?? 'unknown'}`);
|
|
799
|
+
}
|
|
800
|
+
return client.interactions.press({ ...this.actionTarget(target), ...settle });
|
|
801
|
+
case 'doubleTap':
|
|
802
|
+
return client.interactions.press({ ...this.actionTarget(target), count: 2, ...settle });
|
|
803
|
+
case 'longPress':
|
|
804
|
+
return client.interactions.longPress({ ...this.actionTarget(target), ...settle, durationMs: action.durationMs ?? DEFAULT_LONG_PRESS_MS });
|
|
805
|
+
case 'hover':
|
|
806
|
+
return client.interactions.hover(this.actionTarget(target));
|
|
807
|
+
case 'fill':
|
|
808
|
+
// oxlint-disable-next-line unicorn/no-array-fill-with-reference-type -- agent-device fill, not Array#fill
|
|
809
|
+
return client.interactions.fill({ ...this.actionTarget(target), text: action.value, ...settle });
|
|
810
|
+
case 'clear':
|
|
811
|
+
// oxlint-disable-next-line unicorn/no-array-fill-with-reference-type -- agent-device fill, not Array#fill
|
|
812
|
+
return client.interactions.fill({ ...this.actionTarget(target), text: '', ...settle });
|
|
813
|
+
case 'check':
|
|
814
|
+
case 'uncheck': {
|
|
815
|
+
const wanted = action.kind === 'check';
|
|
816
|
+
const checked = target.node.states?.checked;
|
|
817
|
+
// A toggle whose state the tree does not expose (Android switches)
|
|
818
|
+
// cannot be set, only flipped; flipping blind could undo a correct state.
|
|
819
|
+
if (checked === undefined) {
|
|
820
|
+
throw unsupported(`agent-device cannot read whether node ${target.id} is checked; tap it instead`);
|
|
821
|
+
}
|
|
822
|
+
if (checked === wanted)
|
|
823
|
+
return undefined;
|
|
824
|
+
return client.interactions.press({ ...this.actionTarget(target, true), ...settle });
|
|
825
|
+
}
|
|
826
|
+
case 'press':
|
|
827
|
+
return this.pressKey(client, target, action.key, settle);
|
|
828
|
+
case 'swipe': {
|
|
829
|
+
const rect = target.node.rect;
|
|
830
|
+
if (rect === undefined)
|
|
831
|
+
throw notActionable(`node ${target.id} has no bounds to swipe within`);
|
|
832
|
+
return client.interactions.swipe(swipeWithin(rect, action.direction, action.momentum));
|
|
833
|
+
}
|
|
834
|
+
case 'dragTo': {
|
|
835
|
+
const destination = this.resolveRef(action.target);
|
|
836
|
+
return client.interactions.drag({
|
|
837
|
+
source: this.actionTarget(target).ref,
|
|
838
|
+
destination: this.actionTarget(destination).ref,
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
case 'scrollIntoView':
|
|
842
|
+
case 'selectOption':
|
|
843
|
+
case 'setInputFiles':
|
|
844
|
+
// Not in DEVICE_ACTIONS (actions.ts), so the harness never sends them; kept exhaustive.
|
|
845
|
+
throw unsupported(`agent-device cannot perform "${action.kind}" on a device surface`);
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
try {
|
|
849
|
+
await raceAbort(() => this.track(run()), operation.signal, label);
|
|
850
|
+
}
|
|
851
|
+
catch (cause) {
|
|
852
|
+
throw staleOr(cause, label, this.where);
|
|
853
|
+
}
|
|
854
|
+
this.markAction(before);
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Keys on a touch surface, in the contract's key grammar: `Enter` submits
|
|
858
|
+
* through the soft keyboard, `Space` and any single character are typed
|
|
859
|
+
* into the focused field. agent-device exposes no key event bus, so
|
|
860
|
+
* modifiers and the other named keys (`Escape`, `Tab`, `Backspace`, the
|
|
861
|
+
* arrows) have nothing to land on and are refused.
|
|
862
|
+
*/
|
|
863
|
+
async pressKey(client, entry, key, settle) {
|
|
864
|
+
const parsed = parseKey(key);
|
|
865
|
+
if (parsed === undefined) {
|
|
866
|
+
throw unsupported(`"${key}" is not a key: press takes one key in the form [Modifier+]...Key, a named key (${KEY_NAMES.join(', ')}) or one character`);
|
|
867
|
+
}
|
|
868
|
+
if (parsed.modifiers.length > 0) {
|
|
869
|
+
throw unsupported(`agent-device cannot hold ${parsed.modifiers.join('+')} on a device surface; press the key alone`);
|
|
870
|
+
}
|
|
871
|
+
if (parsed.key.kind === 'named' && parsed.key.name === 'Enter') {
|
|
872
|
+
return client.command.keyboard({ action: 'enter' });
|
|
873
|
+
}
|
|
874
|
+
if (parsed.key.kind === 'char' || parsed.key.name === 'Space') {
|
|
875
|
+
const text = parsed.key.kind === 'char' ? parsed.key.char : ' ';
|
|
876
|
+
if (entry.node.role === 'textbox' && entry.node.states?.focused !== true) {
|
|
877
|
+
await client.interactions.press({ ...this.actionTarget(entry), ...settle });
|
|
878
|
+
}
|
|
879
|
+
return client.interactions.type({ text });
|
|
880
|
+
}
|
|
881
|
+
throw unsupported(`agent-device cannot press ${parsed.key.name} on a device surface; only Enter, Space, and single characters reach the soft keyboard`);
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* One pointer action at a screen point in logical pixels, the space every
|
|
885
|
+
* node's bounds are in, with no element resolved behind it. `settle` waits
|
|
886
|
+
* for the UI to go quiet, as the node taps do.
|
|
887
|
+
*/
|
|
888
|
+
async performAt(point, action, operation) {
|
|
889
|
+
const settle = operation.origin === 'test' ? {} : this.settleOptions;
|
|
890
|
+
const before = this.latestIndex;
|
|
891
|
+
await this.command(`${action.kind} at point`, (client) => pointerInteraction(client, point, action, settle), operation.signal);
|
|
892
|
+
this.markAction(before);
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Types into whatever holds focus through the device's text-input path;
|
|
896
|
+
* agent-device's `type` lands on the focused field. `replace` is not a
|
|
897
|
+
* device primitive without a target, so it is refused rather than faked:
|
|
898
|
+
* the model clears a listed field by filling it by id.
|
|
899
|
+
*/
|
|
900
|
+
async typeText(text, options, operation) {
|
|
901
|
+
if (options.replace) {
|
|
902
|
+
throw unsupported('agent-device cannot clear the focused field without a node; type into a listed field by id to replace its value');
|
|
903
|
+
}
|
|
904
|
+
const before = this.latestIndex;
|
|
905
|
+
await this.command('keyboard.type', (client) => client.interactions.type({ text }), operation.signal);
|
|
906
|
+
this.markAction(before);
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* One key to the focused field, with the same reach as a node press: Enter
|
|
910
|
+
* submits through the soft keyboard, Space and single characters are typed.
|
|
911
|
+
* Modifiers and the other named keys have no event bus to land on.
|
|
912
|
+
*/
|
|
913
|
+
async pressFocusedKey(key, operation) {
|
|
914
|
+
const parsed = parseKey(key);
|
|
915
|
+
if (parsed === undefined) {
|
|
916
|
+
throw unsupported(`"${key}" is not a key: press takes one key in the form [Modifier+]...Key, a named key (${KEY_NAMES.join(', ')}) or one character`);
|
|
917
|
+
}
|
|
918
|
+
if (parsed.modifiers.length > 0) {
|
|
919
|
+
throw unsupported(`agent-device cannot hold ${parsed.modifiers.join('+')} on a device surface; press the key alone`);
|
|
920
|
+
}
|
|
921
|
+
const before = this.latestIndex;
|
|
922
|
+
if (parsed.key.kind === 'named' && parsed.key.name === 'Enter') {
|
|
923
|
+
await this.command('keyboard.press', (client) => client.command.keyboard({ action: 'enter' }), operation.signal);
|
|
924
|
+
}
|
|
925
|
+
else if (parsed.key.kind === 'char' || parsed.key.name === 'Space') {
|
|
926
|
+
const text = parsed.key.kind === 'char' ? parsed.key.char : ' ';
|
|
927
|
+
await this.command('keyboard.press', (client) => client.interactions.type({ text }), operation.signal);
|
|
928
|
+
}
|
|
929
|
+
else {
|
|
930
|
+
throw unsupported(`agent-device cannot press ${parsed.key.name} on a device surface; only Enter, Space, and single characters reach the soft keyboard`);
|
|
931
|
+
}
|
|
932
|
+
this.markAction(before);
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Hides the soft keyboard, so a control it covered can be reached.
|
|
936
|
+
* agent-device presses the keyboard's own dismiss key and refuses when the
|
|
937
|
+
* keyboard has none (an iPhone), because a touch outside the keyboard is a
|
|
938
|
+
* guess about the screen it never makes on a caller's behalf. The engine
|
|
939
|
+
* makes that guess, the way a user does and Maestro's `hideKeyboard` does,
|
|
940
|
+
* in {@link dismissKeyboardByGesture}.
|
|
941
|
+
*/
|
|
942
|
+
async dismissKeyboard(signal) {
|
|
943
|
+
const before = this.latestIndex;
|
|
944
|
+
try {
|
|
945
|
+
await this.command('keyboard.dismiss', (client) => client.command.keyboard({ action: 'dismiss' }), signal);
|
|
946
|
+
}
|
|
947
|
+
catch (cause) {
|
|
948
|
+
if (!(cause instanceof EngineError && cause.code === 'UNSUPPORTED_CAPABILITY'))
|
|
949
|
+
throw cause;
|
|
950
|
+
await this.dismissKeyboardByGesture(signal);
|
|
951
|
+
}
|
|
952
|
+
this.markAction(before);
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* A keyboard with no dismiss key goes the way a user sends it away: a short
|
|
956
|
+
* drag at the centre of the screen, which a scroll view turns into a
|
|
957
|
+
* dismissal, each followed by a fresh look at whether the keyboard is still
|
|
958
|
+
* there. Horizontal first: on a vertical scroll view it is no scroll, so
|
|
959
|
+
* React Native's scroll view takes it as the touch outside the keyboard
|
|
960
|
+
* that dismisses by default, where a vertical drag scrolls the content and
|
|
961
|
+
* dismisses only under `keyboardDismissMode="on-drag"`. Vertical second,
|
|
962
|
+
* for the scroll views that dismiss on a drag alone. Maestro's
|
|
963
|
+
* `hideKeyboard` drags in the other order, and on a React Native screen
|
|
964
|
+
* that costs a drag plus the settle after it. The simulator's "Speed up
|
|
965
|
+
* your typing" tip, which sits over a keyboard the first time it appears,
|
|
966
|
+
* goes through its Continue button first. A drag is a guess about what sits
|
|
967
|
+
* at the centre, so the sequence stops the moment the keyboard is gone, and
|
|
968
|
+
* a keyboard that outlives both drags fails naming what does work. Each
|
|
969
|
+
* look is the observation's own capture, sparse-tree retries included: a
|
|
970
|
+
* one-node tree has no keys and would pass for a dismissed keyboard.
|
|
971
|
+
*/
|
|
972
|
+
async dismissKeyboardByGesture(signal) {
|
|
973
|
+
let raw = await this.dismissKeyboardTip(await this.snapshot(signal, false), signal);
|
|
974
|
+
if (!keyboardShowing(raw))
|
|
975
|
+
return;
|
|
976
|
+
const projected = projectSnapshot(raw.nodes ?? [], { mintId: () => 'keyboard' });
|
|
977
|
+
const viewport = await this.viewportFor(projected, signal);
|
|
978
|
+
const centre = { x: viewport.width / 2, y: viewport.height / 2 };
|
|
979
|
+
const drags = [
|
|
980
|
+
{ x: centre.x - viewport.width * KEYBOARD_DRAG_RATIO, y: centre.y },
|
|
981
|
+
{ x: centre.x, y: centre.y - viewport.height * KEYBOARD_DRAG_RATIO },
|
|
982
|
+
];
|
|
983
|
+
for (const to of drags) {
|
|
984
|
+
await this.command('keyboard.dismiss', (client) => client.interactions.swipe({ from: centre, to }), signal);
|
|
985
|
+
raw = await this.snapshot(signal, false);
|
|
986
|
+
if (!keyboardShowing(raw))
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
throw new EngineError('UNSUPPORTED_CAPABILITY', 'keyboard dismissal failed: the keyboard has no dismiss key and stayed up through a horizontal and a vertical drag at the centre of the screen. Tap the app\'s own Done or close control, or press Enter on the field when submitting is what you want.', { retryable: false });
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* The simulator shows a "Speed up your typing" tip over the keyboard the
|
|
993
|
+
* first time it appears; its Continue button is the only way past it, and
|
|
994
|
+
* Maestro's `hideKeyboard` presses it too. Answers the capture after the
|
|
995
|
+
* press, or the one given when no tip is showing.
|
|
996
|
+
*/
|
|
997
|
+
async dismissKeyboardTip(raw, signal) {
|
|
998
|
+
const nodes = raw.nodes ?? [];
|
|
999
|
+
const tip = nodes.find((node) => (node.label ?? node.value ?? '').startsWith('Speed up your typing'));
|
|
1000
|
+
if (tip?.rect === undefined)
|
|
1001
|
+
return raw;
|
|
1002
|
+
const tipTop = tip.rect.y;
|
|
1003
|
+
const button = nodes.find((node) => node.label === 'Continue' && node.rect !== undefined && node.rect.y > tipTop);
|
|
1004
|
+
if (button?.rect === undefined)
|
|
1005
|
+
return raw;
|
|
1006
|
+
await this.command('keyboard.dismiss', (client) => client.interactions.press(centreOf(button.rect)), signal);
|
|
1007
|
+
return await this.snapshot(signal, false);
|
|
1008
|
+
}
|
|
1009
|
+
async back(operation) {
|
|
1010
|
+
const settle = operation.origin === 'test' ? {} : this.settleOptions;
|
|
1011
|
+
const before = this.latestIndex;
|
|
1012
|
+
await this.command('back', (client) => client.command.back({ ...settle }), operation.signal);
|
|
1013
|
+
this.markAction(before);
|
|
1014
|
+
}
|
|
1015
|
+
async restart(operation) {
|
|
1016
|
+
const app = this.pinnedApp;
|
|
1017
|
+
if (app === undefined)
|
|
1018
|
+
throw unsupported(`app.restart needs the engine option \`app\`, or ${UNINSTALLED_BUILD}`);
|
|
1019
|
+
await this.openApp(app, { relaunch: true }, operation.signal);
|
|
1020
|
+
}
|
|
1021
|
+
/** Clears the pinned app's persisted state and relaunches it: the device equivalent of a fresh context. */
|
|
1022
|
+
async reset(operation) {
|
|
1023
|
+
const app = this.pinnedApp;
|
|
1024
|
+
if (app === undefined)
|
|
1025
|
+
throw unsupported(`app.clearState needs the engine option \`app\`, or ${UNINSTALLED_BUILD}`);
|
|
1026
|
+
await this.command('clear app state', (client) => client.settings.update({ setting: 'clear-app-state', state: 'clear', app }), operation.signal);
|
|
1027
|
+
await this.openApp(app, { relaunch: true }, operation.signal);
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Resets the simulator's keychain through agent-device. simctl resets the
|
|
1031
|
+
* whole simulator's, not one app's, and only a simulator has one to reset:
|
|
1032
|
+
* Android is refused before any device command.
|
|
1033
|
+
*/
|
|
1034
|
+
async clearKeychain(signal) {
|
|
1035
|
+
if (this.options.platform !== 'ios') {
|
|
1036
|
+
throw unsupported('device.clearKeychain resets an iOS simulator keychain; Android has none to reset');
|
|
1037
|
+
}
|
|
1038
|
+
await this.command('device.clearKeychain', (client) => client.settings.update({ setting: 'reset-keychain', state: 'clear' }), signal);
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* A redacted screenshot artifact. The device paints secure fields as dots,
|
|
1042
|
+
* but the last typed character shows in clear, so every secure node's
|
|
1043
|
+
* bounds are painted over before the file is kept. A secure node without
|
|
1044
|
+
* bounds cannot be masked, and an image that cannot be redacted is not
|
|
1045
|
+
* written at all.
|
|
1046
|
+
*/
|
|
1047
|
+
async screenshot(label, operation) {
|
|
1048
|
+
const attempt = this.attempt;
|
|
1049
|
+
if (attempt === undefined)
|
|
1050
|
+
throw invalidState('screenshot outside an attempt');
|
|
1051
|
+
const masked = await this.maskedScreenshot(operation.signal);
|
|
1052
|
+
attempt.screenshots += 1;
|
|
1053
|
+
const name = `${String(attempt.screenshots).padStart(3, '0')}-${sanitizeFilename(label ?? 'screenshot')}.png`;
|
|
1054
|
+
const relative = path.join('screenshots', name);
|
|
1055
|
+
mkdirSync(path.join(attempt.artifactsDir, 'screenshots'), { recursive: true });
|
|
1056
|
+
writeFileSync(path.join(attempt.artifactsDir, relative), masked.data);
|
|
1057
|
+
return relative;
|
|
1058
|
+
}
|
|
1059
|
+
/** Raw device pixels; cleanup follows the capture even when its caller abandons it. */
|
|
1060
|
+
rawScreenshot(signal) {
|
|
1061
|
+
return this.captureScreenshot(signal, async (shot, file) => new Uint8Array(readFileSync(shot.path ?? file)));
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* One screenshot into a temp directory that is removed once `read` has
|
|
1065
|
+
* taken what it needs from the response or the file, even when the caller
|
|
1066
|
+
* abandons the capture.
|
|
1067
|
+
*/
|
|
1068
|
+
captureScreenshot(signal, read) {
|
|
1069
|
+
return this.command('screenshot', async (client) => {
|
|
1070
|
+
const directory = mkdtempSync(path.join(tmpdir(), 'e2e-agent-device-'));
|
|
1071
|
+
const file = path.join(directory, 'screenshot.png');
|
|
1072
|
+
try {
|
|
1073
|
+
return await read((await client.capture.screenshot({ path: file })), file);
|
|
1074
|
+
}
|
|
1075
|
+
finally {
|
|
1076
|
+
rmSync(directory, { recursive: true, force: true });
|
|
1077
|
+
}
|
|
1078
|
+
}, signal);
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Screenshot with every secure node on the current screen painted over.
|
|
1082
|
+
* Observes first so the regions describe the screen the pixels show;
|
|
1083
|
+
* throws when a secure field cannot be covered, because an image that may
|
|
1084
|
+
* hold a credential must not leave the engine.
|
|
1085
|
+
*/
|
|
1086
|
+
async maskedScreenshot(signal) {
|
|
1087
|
+
const operation = {
|
|
1088
|
+
signal: signal ?? new AbortController().signal,
|
|
1089
|
+
timeoutMs: 30_000,
|
|
1090
|
+
runId: '',
|
|
1091
|
+
attemptId: '',
|
|
1092
|
+
origin: 'test',
|
|
1093
|
+
};
|
|
1094
|
+
const projected = this.project(await this.snapshotOrEmpty(operation, false));
|
|
1095
|
+
const data = await this.rawScreenshot(signal);
|
|
1096
|
+
const masked = redactSecure(data, projected, projected.viewport ?? this.knownViewport);
|
|
1097
|
+
if (masked === undefined) {
|
|
1098
|
+
throw new EngineError('ENGINE_FAILURE', 'a secure field on screen could not be masked; screenshot withheld', {
|
|
1099
|
+
retryable: false,
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
return masked;
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Viewport pixels for an observation. Best-effort: a screenshot that cannot
|
|
1106
|
+
* be produced or redacted costs the observation its image, not the step.
|
|
1107
|
+
*/
|
|
1108
|
+
async capturePixels(operation, projected, viewport) {
|
|
1109
|
+
let raw;
|
|
1110
|
+
try {
|
|
1111
|
+
raw = await this.rawScreenshot(operation.signal);
|
|
1112
|
+
}
|
|
1113
|
+
catch {
|
|
1114
|
+
return undefined;
|
|
1115
|
+
}
|
|
1116
|
+
const redacted = redactSecure(raw, projected, viewport);
|
|
1117
|
+
if (redacted === undefined)
|
|
1118
|
+
return undefined;
|
|
1119
|
+
const size = readPngSize(redacted.data);
|
|
1120
|
+
if (size === undefined)
|
|
1121
|
+
return undefined;
|
|
1122
|
+
const scale = viewport.width > 0 ? size.width / viewport.width : 1;
|
|
1123
|
+
return {
|
|
1124
|
+
pixels: { data: redacted.data, mediaType: 'image/png', width: size.width, height: size.height, scale },
|
|
1125
|
+
masked: redacted.masked,
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Paints every secure node's bounds black on a screenshot of the same
|
|
1131
|
+
* screen. Returns the masked bytes and how many regions were covered, or
|
|
1132
|
+
* undefined when a secure node has no bounds or the image format cannot be
|
|
1133
|
+
* edited: the caller then withholds the image rather than ship one it could
|
|
1134
|
+
* not prove redacted. Bounds are in logical points; the image may be at
|
|
1135
|
+
* device scale, so they are scaled by the image-to-viewport ratio.
|
|
1136
|
+
*/
|
|
1137
|
+
function redactSecure(data, projected, viewport) {
|
|
1138
|
+
const secure = projected.index.filter((entry) => entry.node.states?.secure === true);
|
|
1139
|
+
if (secure.length === 0)
|
|
1140
|
+
return { data, masked: 0 };
|
|
1141
|
+
const size = readPngSize(data);
|
|
1142
|
+
if (size === undefined)
|
|
1143
|
+
return undefined;
|
|
1144
|
+
const scale = viewport !== undefined && viewport.width > 0 ? size.width / viewport.width : 1;
|
|
1145
|
+
const rects = [];
|
|
1146
|
+
for (const entry of secure) {
|
|
1147
|
+
const rect = entry.node.rect;
|
|
1148
|
+
if (rect === undefined)
|
|
1149
|
+
return undefined;
|
|
1150
|
+
rects.push({ x: rect.x * scale, y: rect.y * scale, width: rect.width * scale, height: rect.height * scale });
|
|
1151
|
+
}
|
|
1152
|
+
try {
|
|
1153
|
+
return { data: maskPng(data, rects), masked: rects.length };
|
|
1154
|
+
}
|
|
1155
|
+
catch {
|
|
1156
|
+
return undefined;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
//# sourceMappingURL=surface.js.map
|