@leera.io/qa-runner 1.0.6 → 1.2.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 +2 -1
- package/dist/src/capabilities.js +13 -7
- package/dist/src/cli/commands/setup.js +41 -2
- package/dist/src/debug.js +14 -4
- package/dist/src/devices/ffmpeg.js +135 -0
- package/dist/src/devices/ios/record.js +8 -7
- package/dist/src/devices/macos.js +8 -9
- package/dist/src/devices/screen-record.js +31 -17
- package/dist/src/devices/tauri.js +11 -0
- package/dist/src/devices/windows.js +6 -7
- package/dist/src/drivers/android-appium.js +14 -6
- package/dist/src/drivers/common.js +40 -2
- package/dist/src/drivers/desktop-appium.js +21 -20
- package/dist/src/drivers/electron-playwright.js +216 -47
- package/dist/src/drivers/ios-appium.js +5 -3
- package/dist/src/drivers/mac2-appium.js +2 -2
- package/dist/src/drivers/tauri-webdriver.js +177 -70
- package/dist/src/drivers/web-playwright.js +21 -3
- package/dist/src/drivers/webdriver/actions.js +8 -4
- package/dist/src/drivers/webdriver/dom-element.js +47 -0
- package/dist/src/drivers/webdriver/dom-locate.js +54 -7
- package/dist/src/drivers/webdriver/dom-snapshot.js +8 -5
- package/dist/src/drivers/webdriver/dom-tree.js +7 -15
- package/dist/src/drivers/webdriver/dom.js +40 -27
- package/dist/src/drivers/windows-appium.js +2 -2
- package/dist/src/executor.js +14 -3
- package/dist/src/home.js +1 -0
- package/dist/src/runner.js +3 -3
- package/dist/src/session/loop.js +2 -2
- package/dist/src/template.js +72 -2
- package/package.json +2 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
-
import { renderTemplate } from '../template.js';
|
|
2
|
+
import { renderTemplate, TemplateError } from '../template.js';
|
|
3
3
|
export const POLL_MS = 200;
|
|
4
4
|
/** Polls `check` until it holds or `timeout` passes, then fails with "expected …". */
|
|
5
5
|
export async function expectWithin(timeout, check, expectation) {
|
|
@@ -21,8 +21,46 @@ export function requireTarget(action) {
|
|
|
21
21
|
export function render(text, ctx) {
|
|
22
22
|
return renderTemplate(text ?? '', ctx);
|
|
23
23
|
}
|
|
24
|
+
/** Schemes a rendered launch arg may not carry: they run code or read the runner's files. */
|
|
25
|
+
const LOCAL_SCHEMES = /^(javascript|vbscript|file|data|blob)$/i;
|
|
26
|
+
/**
|
|
27
|
+
* The scheme a URL parser would read from `text`. Parsers drop leading control characters and
|
|
28
|
+
* spaces, and tabs and line breaks anywhere, so `java<tab>script:` still counts as `javascript:`.
|
|
29
|
+
*/
|
|
30
|
+
function urlScheme(text) {
|
|
31
|
+
let start = 0;
|
|
32
|
+
while (start < text.length && text.charCodeAt(start) <= 0x20)
|
|
33
|
+
start += 1;
|
|
34
|
+
return /^([a-z][a-z0-9+.-]*):/i.exec(text.slice(start).replace(/[\t\n\r]/g, ''))?.[1];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Renders a `launch` action's args. An arg filled from a placeholder is checked once rendered,
|
|
38
|
+
* since a data value can hold anything: it must not become a URL (the whole arg, or the value of
|
|
39
|
+
* `--name=value`) with a scheme that runs code or reads local files, or a switch `reserved` names.
|
|
40
|
+
*/
|
|
41
|
+
export function renderLaunchArgs(args, renderText, reserved) {
|
|
42
|
+
return (args ?? []).map((arg, i) => {
|
|
43
|
+
const rendered = renderText(arg);
|
|
44
|
+
if (rendered === arg)
|
|
45
|
+
return rendered;
|
|
46
|
+
const value = /^-[^=\s]*=/.test(rendered) ? rendered.slice(rendered.indexOf('=') + 1) : rendered;
|
|
47
|
+
const scheme = urlScheme(value);
|
|
48
|
+
if (scheme && LOCAL_SCHEMES.test(scheme)) {
|
|
49
|
+
throw new TemplateError(`launch args[${i}] renders a ${scheme.toLowerCase()}: URL, which the runner does not pass to the app`);
|
|
50
|
+
}
|
|
51
|
+
if (reserved?.test(rendered))
|
|
52
|
+
throw new TemplateError(`launch args[${i}] renders '${rendered.split('=')[0]}', which is reserved for the runner`);
|
|
53
|
+
return rendered;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Compares visible text with what the script expects. Both sides are normalised to Unicode NFC,
|
|
58
|
+
* so an "é" typed as one character matches one the app renders as "e" plus a combining accent.
|
|
59
|
+
*/
|
|
24
60
|
export function matches(actual, expected, mode) {
|
|
25
|
-
|
|
61
|
+
const have = actual.normalize('NFC');
|
|
62
|
+
const want = expected.normalize('NFC');
|
|
63
|
+
return mode === 'equals' ? have === want : have.includes(want);
|
|
26
64
|
}
|
|
27
65
|
export function verb(mode) {
|
|
28
66
|
return mode === 'equals' ? 'equal' : 'contain';
|
|
@@ -2,11 +2,12 @@ import { basename } from 'node:path';
|
|
|
2
2
|
import { disposeUnpacked } from '../builds/cleanup.js';
|
|
3
3
|
import { AppNotAvailable, resolveAppFile } from '../builds/install.js';
|
|
4
4
|
import { buildKind, DesktopBuildError, localDesktopApp, unpackDesktopBuild } from '../builds/unpack.js';
|
|
5
|
-
import {
|
|
5
|
+
import { ensureFfmpeg } from '../devices/ffmpeg.js';
|
|
6
|
+
import { ScreenRecorder } from '../devices/screen-record.js';
|
|
6
7
|
import { homePaths } from '../home.js';
|
|
7
8
|
import { renderTemplate } from '../template.js';
|
|
8
9
|
import { APPIUM_LOG_MAX_BYTES, appiumLogLines, LOCAL_PATHS_REFUSED } from './android-appium.js';
|
|
9
|
-
import { errorMessage } from './common.js';
|
|
10
|
+
import { errorMessage, renderLaunchArgs } from './common.js';
|
|
10
11
|
import { Blocked, LaunchRefused, NotFound, Unsupported } from './driver.js';
|
|
11
12
|
import { runNativeAction } from './webdriver/native-actions.js';
|
|
12
13
|
import { nativeFindAll, resolveNativeRef } from './webdriver/native-locate.js';
|
|
@@ -148,16 +149,17 @@ export class DesktopAppiumDriver {
|
|
|
148
149
|
return [];
|
|
149
150
|
validateNativeAction(this.platform, first);
|
|
150
151
|
this.firstLaunchDone = true;
|
|
151
|
-
return (first.args
|
|
152
|
+
return renderLaunchArgs(first.args, this.render);
|
|
152
153
|
}
|
|
153
|
-
/** Starts a whole-screen recording when the job wants video
|
|
154
|
+
/** Starts a whole-screen recording when the job wants video (ffmpeg is downloaded when missing). */
|
|
154
155
|
async startRecording() {
|
|
155
156
|
this.startedAt = Date.now();
|
|
156
157
|
if (this.ctx.video === 'off')
|
|
157
158
|
return;
|
|
158
|
-
const ffmpeg =
|
|
159
|
+
const ffmpeg = await ensureFfmpeg({ ...(this.ctx.paths ? { paths: this.ctx.paths } : {}), log: this.ctx.log });
|
|
159
160
|
if (!ffmpeg)
|
|
160
161
|
return;
|
|
162
|
+
this.startedAt = Date.now();
|
|
161
163
|
const recorder = new ScreenRecorder(ffmpeg, this.platform);
|
|
162
164
|
try {
|
|
163
165
|
await recorder.start();
|
|
@@ -243,22 +245,21 @@ export class DesktopAppiumDriver {
|
|
|
243
245
|
this.appiumLogOffsets.set(index, this.appium.logOffset());
|
|
244
246
|
}
|
|
245
247
|
debug(index, result) {
|
|
246
|
-
if (result !== 'failed' && result !== 'blocked')
|
|
247
|
-
return undefined;
|
|
248
|
-
const start = this.appiumLogOffsets.get(index);
|
|
249
|
-
if (start === undefined)
|
|
250
|
-
return undefined;
|
|
251
|
-
const later = [...this.appiumLogOffsets.entries()].filter(([i]) => i > index).map(([, offset]) => offset);
|
|
252
|
-
const end = later.length > 0 ? Math.min(...later) : undefined;
|
|
253
|
-
const appium = appiumLogLines(this.appium.logSince(start, APPIUM_LOG_MAX_BYTES, end), this.ctx.redact);
|
|
254
|
-
if (appium.lines.length === 0)
|
|
255
|
-
return undefined;
|
|
256
248
|
const offset = this.offsets.get(index);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
249
|
+
// While recording, the start time alone is kept: it is the step's place in the video.
|
|
250
|
+
const debug = offset !== undefined && this.recorder ? { video_offset_secs: offset } : {};
|
|
251
|
+
const start = this.appiumLogOffsets.get(index);
|
|
252
|
+
if ((result === 'failed' || result === 'blocked') && start !== undefined) {
|
|
253
|
+
const later = [...this.appiumLogOffsets.entries()].filter(([i]) => i > index).map(([, at]) => at);
|
|
254
|
+
const end = later.length > 0 ? Math.min(...later) : undefined;
|
|
255
|
+
const appium = appiumLogLines(this.appium.logSince(start, APPIUM_LOG_MAX_BYTES, end), this.ctx.redact);
|
|
256
|
+
if (appium.lines.length > 0) {
|
|
257
|
+
debug.appium_log = appium.lines;
|
|
258
|
+
if (appium.dropped > 0)
|
|
259
|
+
debug.dropped = { appium_log: appium.dropped };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return Object.keys(debug).length > 0 ? debug : undefined;
|
|
262
263
|
}
|
|
263
264
|
async stopRecording() {
|
|
264
265
|
if (!this.recorder || this.videoStopped)
|
|
@@ -13,7 +13,7 @@ import { ensureDisplay, hostDevice } from '../devices/desktop.js';
|
|
|
13
13
|
import { homePaths } from '../home.js';
|
|
14
14
|
import { playwrightVersion } from '../version.js';
|
|
15
15
|
import { LOCAL_PATHS_REFUSED } from './android-appium.js';
|
|
16
|
-
import { clip, errorMessage, expectWithin, firstLine, matches, render } from './common.js';
|
|
16
|
+
import { clip, errorMessage, expectWithin, firstLine, matches, render, renderLaunchArgs } from './common.js';
|
|
17
17
|
import { Blocked, LaunchRefused, Unsupported, } from './driver.js';
|
|
18
18
|
import { webSnapshot } from './snapshot/aria.js';
|
|
19
19
|
import { findOnPage, NO_FFMPEG_VIDEO, playwrightFfmpeg, runWebAction, WEB_ACTIONS } from './web-playwright.js';
|
|
@@ -26,9 +26,13 @@ export const MAX_WINDOW_INDEX = 100;
|
|
|
26
26
|
export const INSTALLED_REFUSED = 'electron apps need an uploaded build or a path on the runner';
|
|
27
27
|
const LAUNCH_TIMEOUT_MS = 60_000;
|
|
28
28
|
const PAINT_TIMEOUT_MS = 15_000;
|
|
29
|
+
/** How long the paint wait pauses before checking a window whose page navigated again. */
|
|
30
|
+
const PAINT_RETRY_MS = 100;
|
|
31
|
+
/** How long after the paint a launch waits for its video's first frame (held-back frames come within about 0.5 s). */
|
|
32
|
+
const FIRST_FRAME_WAIT_MS = 1_500;
|
|
29
33
|
const VIDEO_SIZE = { width: 1024, height: 576 };
|
|
30
34
|
/** Switches the runner controls itself (debugging ports); a script cannot pass them. */
|
|
31
|
-
const RESERVED_ARG = /^--(inspect|inspect-brk|inspect-port|remote-debugging-port|remote-debugging-pipe|remote-allow-origins)(=|$)/i;
|
|
35
|
+
export const RESERVED_ARG = /^--(inspect|inspect-brk|inspect-port|remote-debugging-port|remote-debugging-pipe|remote-allow-origins)(=|$)/i;
|
|
32
36
|
/** Runner secrets never reach the app's environment. */
|
|
33
37
|
const SECRET_ENV = new Set(['RUNNER_TOKEN', 'LEERA_RUNNER_TOKEN', 'ELECTRON_RUN_AS_NODE', 'NODE_OPTIONS']);
|
|
34
38
|
/** Checks an Electron-only action before it runs; throws what the step reports. */
|
|
@@ -106,6 +110,65 @@ function freePort() {
|
|
|
106
110
|
});
|
|
107
111
|
});
|
|
108
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Starts a recording's clock. A video starts with its first frame, not with its window: often a
|
|
115
|
+
* blank frame while the app's script loads, a frame Chromium held back until there was content, or,
|
|
116
|
+
* for a window that shows nothing new, the first change. That frame's time stamp comes from
|
|
117
|
+
* Playwright's screencast of the page, the stream its recorder writes; the listener stops after it.
|
|
118
|
+
*/
|
|
119
|
+
function recordingOf(page, video, log) {
|
|
120
|
+
const recording = { video, page, openedAt: Date.now(), startedAt: null, started: Promise.resolve(), endedAt: null };
|
|
121
|
+
recording.started = new Promise((resolve) => {
|
|
122
|
+
let settled = false;
|
|
123
|
+
let listener = Promise.resolve(null);
|
|
124
|
+
const settle = (at) => {
|
|
125
|
+
if (settled)
|
|
126
|
+
return;
|
|
127
|
+
settled = true;
|
|
128
|
+
if (at !== null)
|
|
129
|
+
recording.startedAt = at;
|
|
130
|
+
resolve();
|
|
131
|
+
void listener.then((started) => started?.dispose()).catch(() => undefined);
|
|
132
|
+
};
|
|
133
|
+
page.once('close', () => settle(null));
|
|
134
|
+
listener = Promise.resolve()
|
|
135
|
+
.then(() => page.screencast.start({ onFrame: ({ timestamp }) => settle(timestamp) }))
|
|
136
|
+
.catch((error) => {
|
|
137
|
+
// Without the time stamp, the window's opening is the earliest the video can start.
|
|
138
|
+
log.warn("the app window's video start could not be read; step offsets count from the window's opening", { error: firstLine(error) });
|
|
139
|
+
settle(recording.openedAt);
|
|
140
|
+
return null;
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
return recording;
|
|
144
|
+
}
|
|
145
|
+
/** The paint check, run in the window: settles once it is visible and has run two animation frames. */
|
|
146
|
+
function paintCheck() {
|
|
147
|
+
return new Promise((resolve) => {
|
|
148
|
+
const tick = () => {
|
|
149
|
+
if (document.visibilityState === 'visible')
|
|
150
|
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve(true)));
|
|
151
|
+
else
|
|
152
|
+
setTimeout(tick, 250);
|
|
153
|
+
};
|
|
154
|
+
tick();
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Where a step starts in a recording, in seconds, or undefined when the recording does not show
|
|
159
|
+
* it: the step ended before the recording's first frame, or began after the recording ended. A
|
|
160
|
+
* step already running when the recording started (the one that relaunched the app) starts at 0.
|
|
161
|
+
*/
|
|
162
|
+
export function stepVideoOffset(recording, begun, ended) {
|
|
163
|
+
// No frame yet: a window that never painted records only a closing frame, which shows no step.
|
|
164
|
+
if (recording.startedAt === null)
|
|
165
|
+
return undefined;
|
|
166
|
+
if (recording.endedAt !== null && begun >= recording.endedAt)
|
|
167
|
+
return undefined;
|
|
168
|
+
if (ended !== null && ended <= recording.startedAt)
|
|
169
|
+
return undefined;
|
|
170
|
+
return Math.max(0, Math.round((begun - recording.startedAt) / 100) / 10);
|
|
171
|
+
}
|
|
109
172
|
export class ElectronPlaywrightDriver {
|
|
110
173
|
ctx;
|
|
111
174
|
options;
|
|
@@ -121,7 +184,10 @@ export class ElectronPlaywrightDriver {
|
|
|
121
184
|
page = null;
|
|
122
185
|
collector = null;
|
|
123
186
|
videoDir = null;
|
|
124
|
-
|
|
187
|
+
/** The recordings of the app instances that started, oldest first; the last one is returned (see stopRecording). */
|
|
188
|
+
recordings = [];
|
|
189
|
+
/** When each step began (ms), in the order they ran. */
|
|
190
|
+
stepStarts = [];
|
|
125
191
|
videoPath;
|
|
126
192
|
videoStopped = false;
|
|
127
193
|
/** The script's first action is `launch`: `launch()` already started the app with its args. */
|
|
@@ -195,7 +261,7 @@ export class ElectronPlaywrightDriver {
|
|
|
195
261
|
async launch() {
|
|
196
262
|
this.executable = await this.resolveExecutable();
|
|
197
263
|
try {
|
|
198
|
-
this.display = await ensureDisplay();
|
|
264
|
+
this.display = await (this.options.display ?? ensureDisplay)();
|
|
199
265
|
}
|
|
200
266
|
catch (error) {
|
|
201
267
|
throw new Blocked(errorMessage(error));
|
|
@@ -206,17 +272,26 @@ export class ElectronPlaywrightDriver {
|
|
|
206
272
|
else
|
|
207
273
|
this.videoDir = await mkdtemp(join(tmpdir(), 'qa-video-'));
|
|
208
274
|
}
|
|
209
|
-
this.collector = new DebugCollector(Date.now(), this.ctx.redact);
|
|
210
275
|
// A script that starts with `launch {args}` gets its arguments on the first start.
|
|
211
276
|
const first = [...this.ctx.job.automation.steps].sort((a, b) => a.index - b.index)[0]?.actions[0];
|
|
212
277
|
let args = [];
|
|
213
278
|
if (first?.type === 'launch') {
|
|
214
279
|
validateElectronAction(first);
|
|
215
|
-
args = (first.args
|
|
280
|
+
args = renderLaunchArgs(first.args, (arg) => render(arg, this.ctx.template), RESERVED_ARG);
|
|
216
281
|
this.firstLaunchDone = true;
|
|
217
282
|
}
|
|
218
|
-
await this.start(args);
|
|
283
|
+
const started = await this.start(args);
|
|
284
|
+
// Times count from where the first window's video starts (the web driver counts from its page).
|
|
285
|
+
// No step has begun yet, so nothing was filed before this. While a window is recorded, a step
|
|
286
|
+
// that captured nothing is still reported, so debug can give it its place in the video.
|
|
287
|
+
this.collector = new DebugCollector(started, this.ctx.redact, () => this.recordings.length > 0);
|
|
288
|
+
for (const page of this.windows)
|
|
289
|
+
this.collector.attach(page);
|
|
219
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* Starts the app and waits for its first window to paint, and for its video's first frame when
|
|
293
|
+
* it is recorded. Returns where that video starts (ms), or now when there is none.
|
|
294
|
+
*/
|
|
220
295
|
async start(args) {
|
|
221
296
|
const executable = this.executable;
|
|
222
297
|
if (!executable)
|
|
@@ -226,7 +301,7 @@ export class ElectronPlaywrightDriver {
|
|
|
226
301
|
try {
|
|
227
302
|
if (this.options.launchVia === 'cdp')
|
|
228
303
|
throw new Error('the DevTools-port launch was requested');
|
|
229
|
-
const electron = await _electron.launch({
|
|
304
|
+
const electron = await (this.options.electron ?? _electron).launch({
|
|
230
305
|
executablePath: executable,
|
|
231
306
|
args,
|
|
232
307
|
env,
|
|
@@ -235,7 +310,7 @@ export class ElectronPlaywrightDriver {
|
|
|
235
310
|
acceptDownloads: false,
|
|
236
311
|
...(this.videoDir ? { recordVideo: { dir: this.videoDir, size: VIDEO_SIZE } } : {}),
|
|
237
312
|
});
|
|
238
|
-
this.adopt({ via: 'playwright', electron, browser: null, context: electron.context(), process: electron.process(),
|
|
313
|
+
this.adopt({ via: 'playwright', electron, browser: null, context: electron.context(), process: electron.process(), recording: null, closed: false });
|
|
239
314
|
electron.on('window', (page) => this.addWindow(page));
|
|
240
315
|
for (const page of electron.windows())
|
|
241
316
|
this.addWindow(page);
|
|
@@ -259,6 +334,19 @@ export class ElectronPlaywrightDriver {
|
|
|
259
334
|
this.page = page;
|
|
260
335
|
await page.waitForLoadState('domcontentloaded', { timeout: LAUNCH_TIMEOUT_MS }).catch(() => undefined);
|
|
261
336
|
await this.waitForPaint(page, 'launch');
|
|
337
|
+
const recording = this.instance?.recording;
|
|
338
|
+
if (!recording)
|
|
339
|
+
return Date.now();
|
|
340
|
+
this.recordings.push(recording);
|
|
341
|
+
// The first frame comes with the paint, or soon after it when Chromium held it back. A window
|
|
342
|
+
// that shows nothing new may send none until it changes: that frame still sets the video's
|
|
343
|
+
// start, only not the case clock, which cannot wait for it.
|
|
344
|
+
const timer = new AbortController();
|
|
345
|
+
const shown = await Promise.race([recording.started.then(() => true), sleep(FIRST_FRAME_WAIT_MS, false, { signal: timer.signal }).catch(() => false)]);
|
|
346
|
+
timer.abort();
|
|
347
|
+
if (!shown)
|
|
348
|
+
this.ctx.log.debug('no video frame yet after the app window painted; logged times may not match the video');
|
|
349
|
+
return recording.startedAt ?? Date.now();
|
|
262
350
|
}
|
|
263
351
|
/**
|
|
264
352
|
* Waits until the window is visible and has painted (two animation frames). A loaded DOM is not
|
|
@@ -266,28 +354,81 @@ export class ElectronPlaywrightDriver {
|
|
|
266
354
|
* VM under Xvfb), and Chromium's screenshot waits for a frame, so the first screenshot timed out
|
|
267
355
|
* while actions on the DOM passed. Animation frames only run while frames are produced. Bounded;
|
|
268
356
|
* a window that never paints is reported by the step that needs it, not here.
|
|
357
|
+
*
|
|
358
|
+
* A page that navigates meanwhile (index.html redirecting to a login page, or a loading page the
|
|
359
|
+
* app replaces with loadURL) fails the check: Playwright rejects it when the old page's context
|
|
360
|
+
* goes, or, for a navigation the app starts from its main process, as soon as that navigation
|
|
361
|
+
* starts. Until the same deadline, the wait then pauses, waits for domcontentloaded and checks
|
|
362
|
+
* again, as the launch does for the first page. Right after the rejection Playwright can still
|
|
363
|
+
* report the old page's load state, and a check made before the new page commits can fail at
|
|
364
|
+
* once: the pause keeps the loop from spinning until it does.
|
|
269
365
|
*/
|
|
270
366
|
async waitForPaint(page, when) {
|
|
271
367
|
const started = Date.now();
|
|
272
|
-
const
|
|
273
|
-
.evaluate(() => new Promise((resolve) => {
|
|
274
|
-
const tick = () => {
|
|
275
|
-
if (document.visibilityState === 'visible')
|
|
276
|
-
requestAnimationFrame(() => requestAnimationFrame(() => resolve(true)));
|
|
277
|
-
else
|
|
278
|
-
setTimeout(tick, 250);
|
|
279
|
-
};
|
|
280
|
-
tick();
|
|
281
|
-
}))
|
|
282
|
-
.catch(() => false);
|
|
368
|
+
const limitMs = this.options.paintTimeoutMs ?? PAINT_TIMEOUT_MS;
|
|
283
369
|
const timer = new AbortController();
|
|
284
|
-
const
|
|
285
|
-
|
|
370
|
+
const after = (ms, value) => sleep(ms, value, { signal: timer.signal }).catch(() => value);
|
|
371
|
+
// A check can outlive its window: one waiting for a new page's context never settles.
|
|
372
|
+
let gone = () => undefined;
|
|
373
|
+
const ended = new Promise((resolve) => {
|
|
374
|
+
gone = resolve;
|
|
375
|
+
});
|
|
376
|
+
const onClose = () => gone('closed');
|
|
377
|
+
const onCrash = () => gone('crashed');
|
|
378
|
+
page.once('close', onClose);
|
|
379
|
+
page.once('crash', onCrash);
|
|
380
|
+
const stop = Promise.race([after(limitMs, 'timeout'), ended]);
|
|
381
|
+
let outcome;
|
|
382
|
+
let checks = 0;
|
|
383
|
+
let navigated = false;
|
|
384
|
+
try {
|
|
385
|
+
for (;;) {
|
|
386
|
+
checks += 1;
|
|
387
|
+
const check = page.evaluate(paintCheck).then(() => 'painted', (error) => (error instanceof Error ? error : new Error(String(error))));
|
|
388
|
+
outcome = await Promise.race([check, stop]);
|
|
389
|
+
if (!(outcome instanceof Error))
|
|
390
|
+
break;
|
|
391
|
+
if (page.isClosed()) {
|
|
392
|
+
outcome = 'closed';
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
// Anything but a lost context (a crash, a script error in the window) fails every check alike.
|
|
396
|
+
if (!/Execution context was destroyed/.test(outcome.message))
|
|
397
|
+
break;
|
|
398
|
+
navigated = true;
|
|
399
|
+
const paused = await Promise.race([after(PAINT_RETRY_MS, 'next'), stop]);
|
|
400
|
+
if (paused !== 'next') {
|
|
401
|
+
outcome = paused;
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
const timeout = Math.max(1, started + limitMs - Date.now());
|
|
405
|
+
const loaded = await Promise.race([page.waitForLoadState('domcontentloaded', { timeout }).then(() => 'next', () => 'next'), stop]);
|
|
406
|
+
if (loaded !== 'next') {
|
|
407
|
+
outcome = loaded;
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
timer.abort();
|
|
414
|
+
page.off('close', onClose);
|
|
415
|
+
page.off('crash', onCrash);
|
|
416
|
+
}
|
|
417
|
+
if (navigated)
|
|
418
|
+
this.ctx.log.debug(`the app window's page navigated before it painted after ${when}`, { checks });
|
|
286
419
|
const ms = Date.now() - started;
|
|
287
|
-
if (
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
420
|
+
if (outcome === 'painted') {
|
|
421
|
+
if (ms > 2_000)
|
|
422
|
+
this.ctx.log.warn(`the app window took ${ms} ms to paint after ${when}`);
|
|
423
|
+
}
|
|
424
|
+
else if (outcome === 'timeout')
|
|
425
|
+
this.ctx.log.warn(`the app window did not paint within ${Math.round(limitMs / 1000)} s after ${when}`);
|
|
426
|
+
else if (outcome === 'closed')
|
|
427
|
+
this.ctx.log.info(`the app window closed before it painted after ${when}`);
|
|
428
|
+
else if (outcome === 'crashed')
|
|
429
|
+
this.ctx.log.warn(`the app window crashed before it painted after ${when}`);
|
|
430
|
+
else
|
|
431
|
+
this.ctx.log.warn(`the app window's paint could not be checked after ${when}`, { error: firstLine(outcome) });
|
|
291
432
|
}
|
|
292
433
|
/** Fallback for apps whose fuses block Playwright's launch: spawn with a DevTools port and connect over CDP. */
|
|
293
434
|
async startOverCdp(executable, args, env, cwd) {
|
|
@@ -306,7 +447,7 @@ export class ElectronPlaywrightDriver {
|
|
|
306
447
|
child.once('error', (error) => {
|
|
307
448
|
exited = `the app could not start: ${error.message}`;
|
|
308
449
|
});
|
|
309
|
-
const instance = { via: 'cdp', electron: null, browser: null, context: null, process: child,
|
|
450
|
+
const instance = { via: 'cdp', electron: null, browser: null, context: null, process: child, recording: null, closed: false };
|
|
310
451
|
try {
|
|
311
452
|
const endpoint = `http://127.0.0.1:${port}`;
|
|
312
453
|
const deadline = Date.now() + LAUNCH_TIMEOUT_MS;
|
|
@@ -320,7 +461,8 @@ export class ElectronPlaywrightDriver {
|
|
|
320
461
|
throw new Error(`no DevTools endpoint on port ${port} within ${LAUNCH_TIMEOUT_MS / 1000} s`);
|
|
321
462
|
await sleep(250);
|
|
322
463
|
}
|
|
323
|
-
const
|
|
464
|
+
const connect = this.options.connectOverCDP ?? ((url, options) => chromium.connectOverCDP(url, options));
|
|
465
|
+
const browser = await connect(endpoint, { timeout: LAUNCH_TIMEOUT_MS });
|
|
324
466
|
const context = browser.contexts()[0];
|
|
325
467
|
if (!context)
|
|
326
468
|
throw new Error('the app exposes no browser context');
|
|
@@ -355,14 +497,15 @@ export class ElectronPlaywrightDriver {
|
|
|
355
497
|
page.setDefaultNavigationTimeout(Math.max(this.ctx.timeouts.actionMs, 30_000));
|
|
356
498
|
this.collector?.attach(page);
|
|
357
499
|
const instance = this.instance;
|
|
358
|
-
if (instance && !instance.
|
|
500
|
+
if (instance && !instance.recording && instance.via === 'playwright') {
|
|
359
501
|
const video = page.video();
|
|
360
|
-
if (video)
|
|
361
|
-
instance.
|
|
362
|
-
this.videos.push(video);
|
|
363
|
-
}
|
|
502
|
+
if (video)
|
|
503
|
+
instance.recording = recordingOf(page, video, this.ctx.log);
|
|
364
504
|
}
|
|
365
505
|
page.once('close', () => {
|
|
506
|
+
const recording = instance?.recording;
|
|
507
|
+
if (recording?.page === page)
|
|
508
|
+
recording.endedAt ??= Date.now();
|
|
366
509
|
this.windows = this.windows.filter((p) => p !== page);
|
|
367
510
|
if (this.page === page)
|
|
368
511
|
this.page = this.windows[0] ?? null;
|
|
@@ -377,7 +520,7 @@ export class ElectronPlaywrightDriver {
|
|
|
377
520
|
if (first && this.firstLaunchDone)
|
|
378
521
|
return {};
|
|
379
522
|
await this.closeInstance();
|
|
380
|
-
await this.start((action.args
|
|
523
|
+
await this.start(renderLaunchArgs(action.args, (arg) => render(arg, this.ctx.template), RESERVED_ARG));
|
|
381
524
|
return {};
|
|
382
525
|
}
|
|
383
526
|
case 'terminate':
|
|
@@ -461,27 +604,47 @@ export class ElectronPlaywrightDriver {
|
|
|
461
604
|
return this.requirePage().content();
|
|
462
605
|
}
|
|
463
606
|
beginStep(index) {
|
|
607
|
+
this.stepStarts.push({ index, at: Date.now() });
|
|
464
608
|
this.collector?.begin(index);
|
|
465
609
|
}
|
|
610
|
+
/**
|
|
611
|
+
* The step's capture, with its place in the video stopRecording returns; a step that video does
|
|
612
|
+
* not show has no offset, and one that also captured nothing has no debug at all. Entry times
|
|
613
|
+
* (`at_secs`) count from the first window's first paint.
|
|
614
|
+
*/
|
|
466
615
|
debug(index) {
|
|
467
|
-
|
|
616
|
+
const captured = this.collector?.for(index);
|
|
617
|
+
if (!captured)
|
|
618
|
+
return undefined;
|
|
619
|
+
const { video_offset_secs: _sinceFirstPaint, ...debug } = captured;
|
|
620
|
+
const offset = this.videoOffset(index);
|
|
621
|
+
if (offset !== undefined)
|
|
622
|
+
return { ...debug, video_offset_secs: offset };
|
|
623
|
+
return Object.keys(debug).length > 0 ? debug : undefined;
|
|
624
|
+
}
|
|
625
|
+
videoOffset(index) {
|
|
626
|
+
const recording = this.recordings.at(-1);
|
|
627
|
+
const i = this.stepStarts.findLastIndex((step) => step.index === index);
|
|
628
|
+
if (!recording || i < 0)
|
|
629
|
+
return undefined;
|
|
630
|
+
return stepVideoOffset(recording, this.stepStarts[i].at, this.stepStarts[i + 1]?.at ?? null);
|
|
468
631
|
}
|
|
469
|
-
/**
|
|
632
|
+
/**
|
|
633
|
+
* Closes the app (a video is only complete then) and returns the last recording. A relaunch
|
|
634
|
+
* (`launch` again, or `terminate` then `launch`) records the new instance in a video of its own;
|
|
635
|
+
* the last one shows how the case ended, a failure included. Steps that ended before it began
|
|
636
|
+
* get no offset (see debug), and if it has no file no video is returned rather than one the
|
|
637
|
+
* offsets do not describe. A relaunch over the DevTools port records nothing, so the video before
|
|
638
|
+
* it is the last one.
|
|
639
|
+
*/
|
|
470
640
|
async stopRecording() {
|
|
471
641
|
if (this.videoStopped)
|
|
472
642
|
return this.videoPath;
|
|
473
643
|
this.videoStopped = true;
|
|
474
644
|
await this.closeInstance();
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
if (!path)
|
|
479
|
-
continue;
|
|
480
|
-
const size = await stat(path).then((s) => s.size, () => 0);
|
|
481
|
-
if (size > 0 && (!best || size > best.size))
|
|
482
|
-
best = { path, size };
|
|
483
|
-
}
|
|
484
|
-
this.videoPath = best?.path;
|
|
645
|
+
const path = await this.recordings.at(-1)?.video.path().catch(() => undefined);
|
|
646
|
+
const size = path ? await stat(path).then((s) => s.size, () => 0) : 0;
|
|
647
|
+
this.videoPath = size > 0 ? path : undefined;
|
|
485
648
|
if (!this.videoPath && this.videoDir) {
|
|
486
649
|
await rm(this.videoDir, { recursive: true, force: true }).catch(() => undefined);
|
|
487
650
|
this.videoDir = null;
|
|
@@ -510,6 +673,9 @@ export class ElectronPlaywrightDriver {
|
|
|
510
673
|
if (!instance || instance.closed)
|
|
511
674
|
return;
|
|
512
675
|
instance.closed = true;
|
|
676
|
+
// Its video ends with it: a step that begins later is not in it.
|
|
677
|
+
if (instance.recording)
|
|
678
|
+
instance.recording.endedAt ??= Date.now();
|
|
513
679
|
const pid = instance.process?.pid;
|
|
514
680
|
const graceful = instance.electron
|
|
515
681
|
? instance.electron.close()
|
|
@@ -518,7 +684,10 @@ export class ElectronPlaywrightDriver {
|
|
|
518
684
|
await page.close({ runBeforeUnload: false }).catch(() => undefined);
|
|
519
685
|
await instance.browser?.close();
|
|
520
686
|
})();
|
|
521
|
-
|
|
687
|
+
// The timer is cancelled once the app has closed, so it does not keep the process alive.
|
|
688
|
+
const timer = new AbortController();
|
|
689
|
+
await Promise.race([graceful.catch(() => undefined), sleep(10_000, undefined, { signal: timer.signal }).catch(() => undefined)]);
|
|
690
|
+
timer.abort();
|
|
522
691
|
killProcessTree(pid);
|
|
523
692
|
await this.endLeftoverProcesses();
|
|
524
693
|
}
|
|
@@ -11,6 +11,7 @@ import { loadIosSettings } from '../devices/ios/settings.js';
|
|
|
11
11
|
import { assertBundleId } from '../devices/ios/simctl.js';
|
|
12
12
|
import { parseDeviceSyslogLine, SimulatorLogSink, SyslogCollector } from '../devices/ios/syslog.js';
|
|
13
13
|
import { developerDir, isCommandLineToolsOnly, wdaRealDeviceDir, wdaStatus } from '../devices/ios/wda.js';
|
|
14
|
+
import { ensureFfmpeg } from '../devices/ffmpeg.js';
|
|
14
15
|
import { homePaths } from '../home.js';
|
|
15
16
|
import { renderTemplate } from '../template.js';
|
|
16
17
|
import { AndroidAppiumDriver, androidSdkFor, APPIUM_LOG_MAX_BYTES, appiumLogLines, configuredSdkPath, LOCAL_PATHS_REFUSED, sharedAppiumServer } from './android-appium.js';
|
|
@@ -242,6 +243,8 @@ export class IosAppiumDriver {
|
|
|
242
243
|
await recorder.start();
|
|
243
244
|
}
|
|
244
245
|
else {
|
|
246
|
+
// Appium records a real device through ffmpeg on this Mac: fetch it when missing.
|
|
247
|
+
await ensureFfmpeg({ paths: this.paths, log: this.ctx.log });
|
|
245
248
|
await client.startRecordingScreen({ timeLimit: RECORDING_LIMIT_SECS, videoType: 'h264', forceRestart: true });
|
|
246
249
|
}
|
|
247
250
|
this.recording = true;
|
|
@@ -427,16 +430,15 @@ export class IosAppiumDriver {
|
|
|
427
430
|
this.simLog?.checkCrashes();
|
|
428
431
|
const captured = this.collector?.for(index);
|
|
429
432
|
const appium = result === 'failed' || result === 'blocked' ? this.appiumLogFor(index) : null;
|
|
430
|
-
if (!captured && !appium)
|
|
431
|
-
return undefined;
|
|
432
433
|
const offset = this.offsets.get(index);
|
|
434
|
+
// While recording, the start time alone is kept: it is the step's place in the video.
|
|
433
435
|
const debug = { ...captured, ...(offset !== undefined && this.recording ? { video_offset_secs: offset } : {}) };
|
|
434
436
|
if (appium && appium.lines.length > 0) {
|
|
435
437
|
debug.appium_log = appium.lines;
|
|
436
438
|
if (appium.dropped > 0)
|
|
437
439
|
debug.dropped = { ...debug.dropped, appium_log: appium.dropped };
|
|
438
440
|
}
|
|
439
|
-
return debug;
|
|
441
|
+
return Object.keys(debug).length > 0 ? debug : undefined;
|
|
440
442
|
}
|
|
441
443
|
appiumLogFor(index) {
|
|
442
444
|
const start = this.appiumLogOffsets.get(index);
|
|
@@ -4,7 +4,7 @@ import { hostDevice } from '../devices/desktop.js';
|
|
|
4
4
|
import { ACCESSIBILITY_BLOCKED, appBundleOf, BUNDLE_ID_RE, isAccessibilityError, MACOS_NEEDS_MAC, mac2Capabilities, macosReadiness, markAccessibilityBlocked, readBundleId, } from '../devices/macos.js';
|
|
5
5
|
import { homePaths } from '../home.js';
|
|
6
6
|
import { androidSdkFor, configuredSdkPath, sharedAppiumServer } from './android-appium.js';
|
|
7
|
-
import { clip, errorMessage, expectWithin } from './common.js';
|
|
7
|
+
import { clip, errorMessage, expectWithin, renderLaunchArgs } from './common.js';
|
|
8
8
|
import { DesktopAppiumDriver, validateNativeAction } from './desktop-appium.js';
|
|
9
9
|
import { Blocked, LaunchRefused, } from './driver.js';
|
|
10
10
|
import { emptyScreen } from './snapshot/format.js';
|
|
@@ -200,7 +200,7 @@ export class Mac2AppiumDriver extends DesktopAppiumDriver {
|
|
|
200
200
|
try {
|
|
201
201
|
switch (action.type) {
|
|
202
202
|
case 'launch':
|
|
203
|
-
await this.start((action.args
|
|
203
|
+
await this.start(renderLaunchArgs(action.args, this.render));
|
|
204
204
|
return {};
|
|
205
205
|
case 'terminate':
|
|
206
206
|
await macos(this.requireClient(), 'terminateApp', { bundleId: this.bundleId });
|