@e2e-dev/mobile 0.8.0-canary-20260928184528 → 0.8.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 +33 -22
- package/dist/.build.json +1 -1
- package/dist/bindings.d.ts +10 -6
- package/dist/bindings.d.ts.map +1 -1
- package/dist/bindings.js +8 -6
- package/dist/bindings.js.map +1 -1
- package/dist/device.d.ts +5 -5
- package/dist/device.js +1 -1
- package/dist/device.js.map +1 -1
- package/dist/engine.d.ts +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +30 -18
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +14 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -4
- package/dist/index.js.map +1 -1
- package/dist/links.d.ts +0 -7
- package/dist/links.d.ts.map +1 -1
- package/dist/links.js +0 -11
- package/dist/links.js.map +1 -1
- package/dist/nodes.d.ts +1 -1
- package/dist/nodes.d.ts.map +1 -1
- package/dist/options.d.ts +13 -56
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +14 -1
- package/dist/options.js.map +1 -1
- package/dist/pool.d.ts +17 -6
- package/dist/pool.d.ts.map +1 -1
- package/dist/pool.js +46 -12
- package/dist/pool.js.map +1 -1
- package/dist/provider.d.ts +51 -10
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +57 -6
- package/dist/provider.js.map +1 -1
- package/dist/surface.d.ts +42 -9
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +144 -26
- package/dist/surface.js.map +1 -1
- package/dist/tools.d.ts +3 -3
- package/dist/tools.js +3 -3
- package/package.json +5 -5
package/dist/surface.d.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* scrolls the whole screen.
|
|
13
13
|
*/
|
|
14
14
|
import { type EngineAttemptContext, type EngineCleanupContext, type EngineInitInfo, type EngineObserveOptions, type EngineSnapshot, type LocatorAction, type LocatorExpression, type NodeRef, type OperationContext, type PointerAction, type VideoSegment, type SemanticNode, type ViewportPoint } from 'e2e/engine';
|
|
15
|
-
import type
|
|
15
|
+
import { type AgentDeviceClient, type ClientFactory, type DevicePermission, type LaunchPermissions, type MobileOptions, type PermissionState } from './options.ts';
|
|
16
16
|
import { DevicePool } from './pool.ts';
|
|
17
17
|
/** How `installApp` puts a build on the device. */
|
|
18
18
|
export interface InstallAppOptions {
|
|
@@ -36,14 +36,14 @@ export interface OpenAppOptions {
|
|
|
36
36
|
readonly relaunch?: boolean;
|
|
37
37
|
/**
|
|
38
38
|
* Arguments this launch hands the app: its process arguments on iOS,
|
|
39
|
-
* `am start` arguments on Android. Replaces the
|
|
40
|
-
* for a relaunch of the pinned app.
|
|
39
|
+
* `am start` arguments on Android. Replaces the target's
|
|
40
|
+
* `app.launchArguments` for a relaunch of the pinned app.
|
|
41
41
|
*/
|
|
42
42
|
readonly launchArguments?: readonly string[];
|
|
43
43
|
/**
|
|
44
44
|
* Permissions the app holds before this launch, each granted, denied, or
|
|
45
|
-
* reset. Replaces the
|
|
46
|
-
* app.
|
|
45
|
+
* reset. Replaces the target's `app.permissions` for a relaunch of the
|
|
46
|
+
* pinned app.
|
|
47
47
|
*/
|
|
48
48
|
readonly permissions?: LaunchPermissions;
|
|
49
49
|
}
|
|
@@ -52,6 +52,15 @@ type SettleOptions = {
|
|
|
52
52
|
readonly settle: true;
|
|
53
53
|
readonly settleQuietMs: number;
|
|
54
54
|
} | Record<never, never>;
|
|
55
|
+
/**
|
|
56
|
+
* Refuses a permissions map before any device command: anything but a plain
|
|
57
|
+
* object, a name agent-device
|
|
58
|
+
* does not set, naming the nearest, or a state other than grant, deny, or
|
|
59
|
+
* reset. `INVALID_CONFIG` for a target's `app.permissions`, `INVALID_ARGUMENT`
|
|
60
|
+
* for a test's `device.openApp`. An `undefined` state is skipped, as the preset
|
|
61
|
+
* skips it.
|
|
62
|
+
*/
|
|
63
|
+
export declare function assertPermissions(label: string, permissions: unknown, code: 'INVALID_CONFIG' | 'INVALID_ARGUMENT'): void;
|
|
55
64
|
export declare class AgentDeviceSurface {
|
|
56
65
|
readonly options: MobileOptions;
|
|
57
66
|
private readonly createClient;
|
|
@@ -73,10 +82,14 @@ export declare class AgentDeviceSurface {
|
|
|
73
82
|
private sessionApp;
|
|
74
83
|
/** The app the build `appPath` installed, once `init` has, itself or through a lease. */
|
|
75
84
|
private installedApp;
|
|
85
|
+
/** The target's app, as `init` received it. */
|
|
86
|
+
private app;
|
|
76
87
|
/** Where relative build paths resolve; the run's project root once init has told us. */
|
|
77
88
|
private projectRoot;
|
|
78
89
|
/** The session and device this worker drives, for the error messages whose recovery is per device; set in init. */
|
|
79
90
|
private where;
|
|
91
|
+
/** How this worker's device records through its provider: set in init when the pool's provider records and the slot rides one of its leases. */
|
|
92
|
+
private leaseRecorder;
|
|
80
93
|
/**
|
|
81
94
|
* Commands still running on the device. agent-device takes no abort
|
|
82
95
|
* signal, so a cancelled or timed-out call is only abandoned by its
|
|
@@ -104,10 +117,12 @@ export declare class AgentDeviceSurface {
|
|
|
104
117
|
*/
|
|
105
118
|
private knownViewport;
|
|
106
119
|
constructor(options: MobileOptions, createClient: ClientFactory);
|
|
107
|
-
/** Whether the
|
|
108
|
-
get managesApp()
|
|
109
|
-
/** The app `app.open()` launches: the `
|
|
120
|
+
/** Whether the target pins an app for `app.open()`, `app.restart()`, and `app.clearState()` to launch. */
|
|
121
|
+
private get managesApp();
|
|
122
|
+
/** The app `app.open()` launches: the target's `bundleId`, else the app its build `appPath` installed. */
|
|
110
123
|
get pinnedApp(): string | undefined;
|
|
124
|
+
/** The build the target's `app.appPath` names, as declared. */
|
|
125
|
+
get appPath(): string | undefined;
|
|
111
126
|
/** Whether an attempt is running on this surface right now. */
|
|
112
127
|
get attemptRunning(): boolean;
|
|
113
128
|
/** The live client; INVALID_STATE before init or after dispose. */
|
|
@@ -152,11 +167,29 @@ export declare class AgentDeviceSurface {
|
|
|
152
167
|
/**
|
|
153
168
|
* Asks the device to record its screen into the attempt directory. Taps stay
|
|
154
169
|
* visible in the recording (agent-device's touch indicator), which is the
|
|
155
|
-
* closest a phone comes to a cursor
|
|
170
|
+
* closest a phone comes to a cursor, unless `videoTouches` is `false`. A
|
|
171
|
+
* device provider that records its devices records the attempt instead,
|
|
172
|
+
* when the slot rides one of its leases.
|
|
156
173
|
*/
|
|
157
174
|
startVideo(operation: OperationContext): Promise<void>;
|
|
158
175
|
/** Stops the recording and returns its one segment, or none when the device wrote nothing. */
|
|
159
176
|
stopVideo(operation: OperationContext): Promise<readonly VideoSegment[]>;
|
|
177
|
+
/**
|
|
178
|
+
* Stops the device's screen recording. A device-scope recording started
|
|
179
|
+
* with no session made one of its own, and stopping it ends that session,
|
|
180
|
+
* whatever the test opened in it since, so the session is then as
|
|
181
|
+
* `closeApp` leaves it.
|
|
182
|
+
*/
|
|
183
|
+
private stopScreenRecording;
|
|
184
|
+
/**
|
|
185
|
+
* Starts the device provider's recording of the leased device. Marked
|
|
186
|
+
* the moment `record` is called, before it settles, as the screen
|
|
187
|
+
* recording is before its start command: a start that outlives its budget
|
|
188
|
+
* may still come up, and `endAttempt` then stops it.
|
|
189
|
+
*/
|
|
190
|
+
private startLeaseRecording;
|
|
191
|
+
/** Ends the provider's recording as the attempt's one segment, a file in its `video` directory or a link. */
|
|
192
|
+
private stopLeaseRecording;
|
|
160
193
|
dispose(context: EngineCleanupContext): Promise<void>;
|
|
161
194
|
/**
|
|
162
195
|
* Opens an app in the session, remembering its identity for the path
|
package/dist/surface.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"surface.d.ts","sourceRoot":"","sources":["../src/surface.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAKH,OAAO,
|
|
1
|
+
{"version":3,"file":"surface.d.ts","sourceRoot":"","sources":["../src/surface.ts"],"names":[],"mappings":"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"}
|
package/dist/surface.js
CHANGED
|
@@ -14,18 +14,20 @@
|
|
|
14
14
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
15
|
import { tmpdir } from 'node:os';
|
|
16
16
|
import path from 'node:path';
|
|
17
|
-
import { EngineError, KEY_NAMES, parseKey, raceAbort, withinCleanupBudget, ConfigurationError, TestError, } from 'e2e/engine';
|
|
17
|
+
import { EngineError, KEY_NAMES, parseKey, raceAbort, rejectUnknownKeys, stopProviderRecording, withinCleanupBudget, ConfigurationError, TestError, } from 'e2e/engine';
|
|
18
18
|
import { isNoSessionApp, 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';
|
|
22
|
+
import { DEVICE_PERMISSIONS } from './options.js';
|
|
22
23
|
import { maskPng } from './png.js';
|
|
23
24
|
import { deviceLabel, pinnedApp } from './bindings.js';
|
|
24
|
-
import { assertAppId
|
|
25
|
+
import { assertAppId } from './links.js';
|
|
25
26
|
import { DevicePool, deviceSelection } from './pool.js';
|
|
27
|
+
import { recordLease, travelledLease } from './provider.js';
|
|
26
28
|
import { invalidState, notActionable, logicalScreenSize, readPngSize, sanitizeFilename, screenLocation, swipeWithin, unsupported, } from './support.js';
|
|
27
29
|
/** Why nothing is pinned with `appPath` alone: the install is the suite's. */
|
|
28
|
-
const UNINSTALLED_BUILD =
|
|
30
|
+
const UNINSTALLED_BUILD = "the build the target's `app.appPath` names installed first with `device.installApp()`";
|
|
29
31
|
/** Platform element types that are the soft keyboard or one of its keys, as agent-device names them. */
|
|
30
32
|
const KEYBOARD_TYPES = new Set(['keyboard', 'key']);
|
|
31
33
|
/**
|
|
@@ -109,6 +111,42 @@ function sleep(ms, signal) {
|
|
|
109
111
|
});
|
|
110
112
|
}
|
|
111
113
|
const DEFAULT_SETTLE_QUIET_MS = 150;
|
|
114
|
+
/** Every option `mobile()` takes, kept equal to `MobileOptions` by the compiler. */
|
|
115
|
+
const MOBILE_OPTION_KEYS = Object.keys({
|
|
116
|
+
platform: true,
|
|
117
|
+
device: true,
|
|
118
|
+
session: true,
|
|
119
|
+
snapshot: true,
|
|
120
|
+
settle: true,
|
|
121
|
+
transition: true,
|
|
122
|
+
videoTouches: true,
|
|
123
|
+
});
|
|
124
|
+
/** Every state a permission takes, kept equal to `PermissionState` by the compiler. */
|
|
125
|
+
const PERMISSION_STATES = Object.keys({ grant: true, deny: true, reset: true });
|
|
126
|
+
/**
|
|
127
|
+
* Refuses a permissions map before any device command: anything but a plain
|
|
128
|
+
* object, a name agent-device
|
|
129
|
+
* does not set, naming the nearest, or a state other than grant, deny, or
|
|
130
|
+
* reset. `INVALID_CONFIG` for a target's `app.permissions`, `INVALID_ARGUMENT`
|
|
131
|
+
* for a test's `device.openApp`. An `undefined` state is skipped, as the preset
|
|
132
|
+
* skips it.
|
|
133
|
+
*/
|
|
134
|
+
export function assertPermissions(label, permissions, code) {
|
|
135
|
+
const fail = (message) => {
|
|
136
|
+
throw code === 'INVALID_CONFIG' ? new ConfigurationError(code, message) : new TestError(code, message);
|
|
137
|
+
};
|
|
138
|
+
// A Map or class instance carries no own keys to check, and the preset would apply none of it.
|
|
139
|
+
const prototype = typeof permissions === 'object' && permissions !== null ? Object.getPrototypeOf(permissions) : undefined;
|
|
140
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
141
|
+
fail(`${label} must be a plain object of permission names to ${PERMISSION_STATES.join(', ')}`);
|
|
142
|
+
}
|
|
143
|
+
rejectUnknownKeys(label, permissions, DEVICE_PERMISSIONS, code);
|
|
144
|
+
for (const [name, state] of Object.entries(permissions)) {
|
|
145
|
+
if (state !== undefined && !PERMISSION_STATES.includes(state)) {
|
|
146
|
+
fail(`${label}.${name} must be one of ${PERMISSION_STATES.join(', ')}, got ${typeof state === 'string' ? `"${state}"` : String(state)}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
112
150
|
/** Resolves the `transition` option: the default budget or a custom one. */
|
|
113
151
|
function transitionMs(transition) {
|
|
114
152
|
const budget = transition ?? DEFAULT_TRANSITION_MS;
|
|
@@ -148,10 +186,14 @@ export class AgentDeviceSurface {
|
|
|
148
186
|
sessionApp;
|
|
149
187
|
/** The app the build `appPath` installed, once `init` has, itself or through a lease. */
|
|
150
188
|
installedApp;
|
|
189
|
+
/** The target's app, as `init` received it. */
|
|
190
|
+
app = {};
|
|
151
191
|
/** Where relative build paths resolve; the run's project root once init has told us. */
|
|
152
192
|
projectRoot = process.cwd();
|
|
153
193
|
/** The session and device this worker drives, for the error messages whose recovery is per device; set in init. */
|
|
154
194
|
where;
|
|
195
|
+
/** How this worker's device records through its provider: set in init when the pool's provider records and the slot rides one of its leases. */
|
|
196
|
+
leaseRecorder;
|
|
155
197
|
/**
|
|
156
198
|
* Commands still running on the device. agent-device takes no abort
|
|
157
199
|
* signal, so a cancelled or timed-out call is only abandoned by its
|
|
@@ -181,18 +223,22 @@ export class AgentDeviceSurface {
|
|
|
181
223
|
constructor(options, createClient) {
|
|
182
224
|
this.options = options;
|
|
183
225
|
this.createClient = createClient;
|
|
184
|
-
|
|
226
|
+
rejectUnknownKeys('mobile()', options, MOBILE_OPTION_KEYS);
|
|
185
227
|
this.pool = new DevicePool(options, createClient);
|
|
186
228
|
this.settleOptions = settleOptions(options.settle);
|
|
187
229
|
this.transitionMs = transitionMs(options.transition);
|
|
188
230
|
}
|
|
189
|
-
/** Whether the
|
|
231
|
+
/** Whether the target pins an app for `app.open()`, `app.restart()`, and `app.clearState()` to launch. */
|
|
190
232
|
get managesApp() {
|
|
191
|
-
return this.
|
|
233
|
+
return this.app.bundleId !== undefined || this.app.appPath !== undefined;
|
|
192
234
|
}
|
|
193
|
-
/** The app `app.open()` launches: the `
|
|
235
|
+
/** The app `app.open()` launches: the target's `bundleId`, else the app its build `appPath` installed. */
|
|
194
236
|
get pinnedApp() {
|
|
195
|
-
return pinnedApp(this.
|
|
237
|
+
return pinnedApp(this.app, this.installedApp);
|
|
238
|
+
}
|
|
239
|
+
/** The build the target's `app.appPath` names, as declared. */
|
|
240
|
+
get appPath() {
|
|
241
|
+
return this.app.appPath;
|
|
196
242
|
}
|
|
197
243
|
/** Whether an attempt is running on this surface right now. */
|
|
198
244
|
get attemptRunning() {
|
|
@@ -249,12 +295,19 @@ export class AgentDeviceSurface {
|
|
|
249
295
|
}
|
|
250
296
|
async init(info) {
|
|
251
297
|
this.projectRoot = info.projectRoot;
|
|
298
|
+
this.app = info.app;
|
|
252
299
|
const binding = this.pool.binding(info.targetName, info.workerSlot, info.env);
|
|
253
300
|
this.device = binding;
|
|
254
301
|
const session = this.pool.session(info.targetName, info.workerSlot);
|
|
255
302
|
const label = deviceLabel(binding);
|
|
256
303
|
this.where = `session ${session}${label === undefined ? '' : ` on ${label}`}`;
|
|
257
304
|
this.client ??= this.createClient(session, binding);
|
|
305
|
+
const provider = this.pool.recorder;
|
|
306
|
+
const lease = travelledLease(binding);
|
|
307
|
+
this.leaseRecorder =
|
|
308
|
+
provider === undefined || lease === undefined
|
|
309
|
+
? undefined
|
|
310
|
+
: { provider, lease, runId: info.runId, targetName: info.targetName, env: info.env };
|
|
258
311
|
await this.command('boot', (client) => client.devices.boot(this.selection()), info.signal);
|
|
259
312
|
this.sessionApp = binding?.sessionApp === undefined ? undefined : await this.resumedSessionApp(session, binding.sessionApp, info.signal);
|
|
260
313
|
// Nothing is installed here: a device provider that installed the build
|
|
@@ -290,12 +343,13 @@ export class AgentDeviceSurface {
|
|
|
290
343
|
// worker's `prepare`, left it, and a test that wants it fresh calls
|
|
291
344
|
// `app.open()`, which is `restart` below. The bindings are forgotten, the
|
|
292
345
|
// screen is not, so a control still in place is acted on at once.
|
|
293
|
-
this.attempt = { artifactsDir: context.artifactsDir, screenshots: 0, video: undefined };
|
|
346
|
+
this.attempt = { attemptId: context.attemptId, artifactsDir: context.artifactsDir, screenshots: 0, video: undefined };
|
|
294
347
|
this.generation = new Map();
|
|
295
348
|
this.located.clear();
|
|
296
349
|
}
|
|
297
350
|
async endAttempt(context) {
|
|
298
|
-
const
|
|
351
|
+
const attempt = this.attempt;
|
|
352
|
+
const dangling = attempt?.video;
|
|
299
353
|
this.attempt = undefined;
|
|
300
354
|
this.generation = new Map();
|
|
301
355
|
this.located.clear();
|
|
@@ -304,16 +358,21 @@ export class AgentDeviceSurface {
|
|
|
304
358
|
// and the device must not keep recording into the next one. A start that
|
|
305
359
|
// outlived its budget may still be landing: it settles first, so the stop
|
|
306
360
|
// cannot overtake it.
|
|
307
|
-
if (
|
|
361
|
+
if (attempt !== undefined && dangling?.kind === 'lease') {
|
|
362
|
+
await this.stopLeaseRecording(dangling, attempt.artifactsDir, context.signal).catch(() => undefined);
|
|
363
|
+
}
|
|
364
|
+
else if (dangling !== undefined) {
|
|
308
365
|
await this.settleInflight(context.signal).catch(() => undefined);
|
|
309
|
-
await this.
|
|
366
|
+
await this.stopScreenRecording(context.signal).catch(() => undefined);
|
|
310
367
|
}
|
|
311
368
|
}
|
|
312
369
|
// --- video ---
|
|
313
370
|
/**
|
|
314
371
|
* Asks the device to record its screen into the attempt directory. Taps stay
|
|
315
372
|
* visible in the recording (agent-device's touch indicator), which is the
|
|
316
|
-
* closest a phone comes to a cursor
|
|
373
|
+
* closest a phone comes to a cursor, unless `videoTouches` is `false`. A
|
|
374
|
+
* device provider that records its devices records the attempt instead,
|
|
375
|
+
* when the slot rides one of its leases.
|
|
317
376
|
*/
|
|
318
377
|
async startVideo(operation) {
|
|
319
378
|
const attempt = this.attempt;
|
|
@@ -321,14 +380,26 @@ export class AgentDeviceSurface {
|
|
|
321
380
|
throw invalidState('startVideo outside an attempt');
|
|
322
381
|
if (attempt.video !== undefined)
|
|
323
382
|
throw invalidState('a video is already recording');
|
|
383
|
+
if (this.leaseRecorder !== undefined) {
|
|
384
|
+
await this.startLeaseRecording(attempt, this.leaseRecorder, operation.signal);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
324
387
|
const relative = path.join('video', 'video.mp4');
|
|
325
388
|
const absolute = path.join(attempt.artifactsDir, relative);
|
|
326
389
|
mkdirSync(path.dirname(absolute), { recursive: true });
|
|
327
390
|
// Marked before the device is asked: a start that outlives its budget
|
|
328
391
|
// still records, and `endAttempt` must be able to stop it.
|
|
329
|
-
const recording = { relative, absolute, startedAt: new Date().toISOString() };
|
|
392
|
+
const recording = { kind: 'screen', relative, absolute, startedAt: new Date().toISOString() };
|
|
330
393
|
attempt.video = recording;
|
|
331
|
-
await this.command('start video recording',
|
|
394
|
+
await this.command('start video recording',
|
|
395
|
+
// The whole screen: an app scope refuses to start while no app is open, before `app.open()` and after `closeApp()`.
|
|
396
|
+
(client) => client.recording.record({
|
|
397
|
+
action: 'start',
|
|
398
|
+
path: absolute,
|
|
399
|
+
quality: 'medium',
|
|
400
|
+
recordingScope: 'device',
|
|
401
|
+
...(this.options.videoTouches === false ? { hideTouches: true } : {}),
|
|
402
|
+
}), operation.signal);
|
|
332
403
|
// The device confirmed: it is recording from about now.
|
|
333
404
|
recording.startedAt = new Date().toISOString();
|
|
334
405
|
}
|
|
@@ -340,7 +411,13 @@ export class AgentDeviceSurface {
|
|
|
340
411
|
const video = attempt.video;
|
|
341
412
|
if (video === undefined)
|
|
342
413
|
return [];
|
|
343
|
-
|
|
414
|
+
if (video.kind === 'lease') {
|
|
415
|
+
const segment = await this.stopLeaseRecording(video, attempt.artifactsDir, operation.signal);
|
|
416
|
+
// Cleared only now: a stop that failed leaves the recording for `endAttempt`.
|
|
417
|
+
attempt.video = undefined;
|
|
418
|
+
return [segment];
|
|
419
|
+
}
|
|
420
|
+
const result = await this.stopScreenRecording(operation.signal);
|
|
344
421
|
// Cleared only now: a stop that failed leaves the recording for `endAttempt`.
|
|
345
422
|
attempt.video = undefined;
|
|
346
423
|
// The device may finalize the file under a path of its own choosing; the
|
|
@@ -352,8 +429,47 @@ export class AgentDeviceSurface {
|
|
|
352
429
|
return [];
|
|
353
430
|
return [{ path: video.relative, startedAt: video.startedAt }];
|
|
354
431
|
}
|
|
432
|
+
/**
|
|
433
|
+
* Stops the device's screen recording. A device-scope recording started
|
|
434
|
+
* with no session made one of its own, and stopping it ends that session,
|
|
435
|
+
* whatever the test opened in it since, so the session is then as
|
|
436
|
+
* `closeApp` leaves it.
|
|
437
|
+
*/
|
|
438
|
+
async stopScreenRecording(signal) {
|
|
439
|
+
const result = await this.command('stop video recording', (client) => client.recording.record({ action: 'stop' }), signal);
|
|
440
|
+
if (result.recordOnlySession === true) {
|
|
441
|
+
this.sessionApp = undefined;
|
|
442
|
+
this.screenReplaced();
|
|
443
|
+
}
|
|
444
|
+
return result;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Starts the device provider's recording of the leased device. Marked
|
|
448
|
+
* the moment `record` is called, before it settles, as the screen
|
|
449
|
+
* recording is before its start command: a start that outlives its budget
|
|
450
|
+
* may still come up, and `endAttempt` then stops it.
|
|
451
|
+
*/
|
|
452
|
+
async startLeaseRecording(attempt, recorder, signal) {
|
|
453
|
+
const { provider, lease, runId, targetName, env } = recorder;
|
|
454
|
+
const started = recordLease(provider, lease, { runId, targetName, attemptId: attempt.attemptId, env, signal });
|
|
455
|
+
attempt.video = { kind: 'lease', recorder, started };
|
|
456
|
+
await raceAbort(started, signal, 'start video recording');
|
|
457
|
+
}
|
|
458
|
+
/** Ends the provider's recording as the attempt's one segment, a file in its `video` directory or a link. */
|
|
459
|
+
async stopLeaseRecording(video, artifactsDir, signal) {
|
|
460
|
+
const { provider, lease } = video.recorder;
|
|
461
|
+
video.stopping ??= (async () => stopProviderRecording(await video.started, { artifactsDir, provider: `device provider "${provider.name}"`, leaseId: lease.id, signal }))().finally(() => {
|
|
462
|
+
video.stopping = undefined;
|
|
463
|
+
});
|
|
464
|
+
return raceAbort(video.stopping, signal, 'stop video recording');
|
|
465
|
+
}
|
|
355
466
|
async dispose(context) {
|
|
356
467
|
const client = this.client;
|
|
468
|
+
const attempt = this.attempt;
|
|
469
|
+
// A worker torn down mid-attempt must not leave the provider recording the device.
|
|
470
|
+
if (attempt?.video?.kind === 'lease') {
|
|
471
|
+
await withinCleanupBudget(this.stopLeaseRecording(attempt.video, attempt.artifactsDir, context.signal), context).catch(() => undefined);
|
|
472
|
+
}
|
|
357
473
|
this.client = undefined;
|
|
358
474
|
this.attempt = undefined;
|
|
359
475
|
this.generation = new Map();
|
|
@@ -374,13 +490,15 @@ export class AgentDeviceSurface {
|
|
|
374
490
|
*/
|
|
375
491
|
async openApp(app, options, signal) {
|
|
376
492
|
assertAppId(app);
|
|
493
|
+
if (options.permissions !== undefined)
|
|
494
|
+
assertPermissions('device.openApp({ permissions })', options.permissions, 'INVALID_ARGUMENT');
|
|
377
495
|
const relaunch = options.relaunch === true;
|
|
378
496
|
// The engine's own launch options belong to a fresh launch of the pinned
|
|
379
497
|
// app. A foreground-only open of a running app takes no arguments, and a
|
|
380
498
|
// permission change there would terminate the app it means to keep.
|
|
381
499
|
const configured = relaunch && app === this.pinnedApp;
|
|
382
|
-
const launchArguments = options.launchArguments ?? (configured ? this.
|
|
383
|
-
const permissions = options.permissions ?? (configured ? this.
|
|
500
|
+
const launchArguments = options.launchArguments ?? (configured ? this.app.launchArguments : undefined);
|
|
501
|
+
const permissions = options.permissions ?? (configured ? this.app.permissions : undefined);
|
|
384
502
|
if (permissions !== undefined)
|
|
385
503
|
await this.presetPermissions(app, permissions, signal);
|
|
386
504
|
await this.open(app, relaunch, launchArguments, signal);
|
|
@@ -456,7 +574,7 @@ export class AgentDeviceSurface {
|
|
|
456
574
|
async openLink(url, app, signal) {
|
|
457
575
|
const target = app ?? this.pinnedApp;
|
|
458
576
|
if (target === undefined && this.options.platform === 'ios') {
|
|
459
|
-
throw new TestError('INVALID_ARGUMENT',
|
|
577
|
+
throw new TestError('INVALID_ARGUMENT', "openLink needs an app on iOS: pass `app`, or pin one with the target's app.bundleId or app.appPath; the session observes the app a link is opened into");
|
|
460
578
|
}
|
|
461
579
|
const result = await this.command('device.openLink', (client) => client.apps.open({
|
|
462
580
|
...this.selection(),
|
|
@@ -506,15 +624,15 @@ export class AgentDeviceSurface {
|
|
|
506
624
|
* with no data; a plain install replaces the binary and keeps its data.
|
|
507
625
|
*/
|
|
508
626
|
async installApp(appPath, options, signal) {
|
|
509
|
-
const build = appPath ?? this.
|
|
627
|
+
const build = appPath ?? this.app.appPath;
|
|
510
628
|
if (build === undefined)
|
|
511
|
-
throw invalidState(
|
|
629
|
+
throw invalidState("installApp needs a build: pass a path, or name one with the target's app.appPath");
|
|
512
630
|
const resolved = path.resolve(this.projectRoot, build);
|
|
513
631
|
const selection = this.selection();
|
|
514
|
-
const engineBuild = appPath === undefined || (this.
|
|
515
|
-
const app = options.app ?? (engineBuild ? this.
|
|
632
|
+
const engineBuild = appPath === undefined || (this.app.appPath !== undefined && resolved === path.resolve(this.projectRoot, this.app.appPath));
|
|
633
|
+
const app = options.app ?? (engineBuild ? this.app.bundleId : undefined) ?? (options.reinstall === true ? this.pinnedApp : undefined);
|
|
516
634
|
if (options.reinstall === true && app === undefined) {
|
|
517
|
-
throw invalidState(
|
|
635
|
+
throw invalidState("reinstall needs an app: pass `app`, or pin one with the target's app.bundleId or app.appPath");
|
|
518
636
|
}
|
|
519
637
|
const result = (await this.command(`install ${resolved}`, (client) => options.reinstall === true && app !== undefined
|
|
520
638
|
? client.apps.reinstall({ ...selection, app, appPath: resolved })
|
|
@@ -1015,14 +1133,14 @@ export class AgentDeviceSurface {
|
|
|
1015
1133
|
async restart(operation) {
|
|
1016
1134
|
const app = this.pinnedApp;
|
|
1017
1135
|
if (app === undefined)
|
|
1018
|
-
throw unsupported(`app.restart needs the
|
|
1136
|
+
throw unsupported(`app.restart needs the target's \`app.bundleId\`, or ${UNINSTALLED_BUILD}`);
|
|
1019
1137
|
await this.openApp(app, { relaunch: true }, operation.signal);
|
|
1020
1138
|
}
|
|
1021
1139
|
/** Clears the pinned app's persisted state and relaunches it: the device equivalent of a fresh context. */
|
|
1022
1140
|
async reset(operation) {
|
|
1023
1141
|
const app = this.pinnedApp;
|
|
1024
1142
|
if (app === undefined)
|
|
1025
|
-
throw unsupported(`app.clearState needs the
|
|
1143
|
+
throw unsupported(`app.clearState needs the target's \`app.bundleId\`, or ${UNINSTALLED_BUILD}`);
|
|
1026
1144
|
await this.command('clear app state', (client) => client.settings.update({ setting: 'clear-app-state', state: 'clear', app }), operation.signal);
|
|
1027
1145
|
await this.openApp(app, { relaunch: true }, operation.signal);
|
|
1028
1146
|
}
|