@leera.io/qa-runner 1.0.5 → 1.1.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/dist/src/capabilities.js +3 -0
- package/dist/src/debug.js +14 -4
- package/dist/src/devices/android/adb.js +4 -0
- package/dist/src/devices/android/emulator.js +22 -0
- package/dist/src/drivers/android-appium.js +37 -5
- package/dist/src/drivers/common.js +40 -2
- package/dist/src/drivers/desktop-appium.js +16 -17
- package/dist/src/drivers/electron-playwright.js +216 -47
- package/dist/src/drivers/ios-appium.js +2 -3
- package/dist/src/drivers/mac2-appium.js +2 -2
- package/dist/src/drivers/tauri-webdriver.js +4 -4
- package/dist/src/drivers/web-playwright.js +21 -3
- package/dist/src/drivers/webdriver/actions.js +8 -4
- package/dist/src/drivers/webdriver/dom-locate.js +2 -0
- package/dist/src/drivers/windows-appium.js +2 -2
- package/dist/src/executor.js +13 -3
- package/dist/src/template.js +63 -2
- package/package.json +1 -1
package/dist/src/capabilities.js
CHANGED
|
@@ -56,6 +56,8 @@ export function deriveLabels(devices) {
|
|
|
56
56
|
* (contracts §H3). Web and Electron record webm (Playwright, contracts §K); mobile records mp4;
|
|
57
57
|
* Tauri records no video and adds no feature beyond sessions (contracts §L). Native macOS and
|
|
58
58
|
* Windows apps carry the Appium log on failed steps and record mp4 only with ffmpeg (contracts §M).
|
|
59
|
+
* `data_refs` = this runner fills test data, API URL and job placeholders on every platform
|
|
60
|
+
* (contracts §Q); the server gives such scripts only to runners labelled `data-refs`.
|
|
59
61
|
*/
|
|
60
62
|
export function featuresFor(platforms, options = {}) {
|
|
61
63
|
const features = [];
|
|
@@ -74,6 +76,7 @@ export function featuresFor(platforms, options = {}) {
|
|
|
74
76
|
if (options.ffmpeg && !features.includes('video_mp4'))
|
|
75
77
|
features.push('video_mp4');
|
|
76
78
|
}
|
|
79
|
+
features.push('data_refs');
|
|
77
80
|
return features;
|
|
78
81
|
}
|
|
79
82
|
export function buildCapabilities(devices, host, extraTools = {}, options = { ffmpeg: findFfmpeg() !== null }) {
|
package/dist/src/debug.js
CHANGED
|
@@ -13,15 +13,20 @@ const SENSITIVE_PARAM = /token|secret|password|passwd|pwd|auth|session|signature
|
|
|
13
13
|
* Only what a person debugging a failure needs is kept: console errors and
|
|
14
14
|
* warnings, uncaught page errors, and requests. Headers and bodies are never
|
|
15
15
|
* recorded, since they carry cookies and the credential the script signs in with.
|
|
16
|
+
* Each step's start time is kept too, and is returned on its own while a video
|
|
17
|
+
* is being recorded, so the step can be found in the video.
|
|
16
18
|
*/
|
|
17
19
|
export class DebugCollector {
|
|
18
20
|
steps = new Map();
|
|
19
21
|
current = null;
|
|
20
22
|
startedAt;
|
|
21
23
|
redact;
|
|
22
|
-
|
|
24
|
+
recording;
|
|
25
|
+
/** `recording` is asked when a step's capture is read (Electron only knows after launch whether a window is recorded). */
|
|
26
|
+
constructor(startedAt, redact, recording = () => false) {
|
|
23
27
|
this.startedAt = startedAt;
|
|
24
28
|
this.redact = redact;
|
|
29
|
+
this.recording = recording;
|
|
25
30
|
}
|
|
26
31
|
attach(page) {
|
|
27
32
|
page.on('console', (message) => this.onConsole(message));
|
|
@@ -39,13 +44,18 @@ export class DebugCollector {
|
|
|
39
44
|
this.entry(index).video_offset_secs = offset;
|
|
40
45
|
return offset;
|
|
41
46
|
}
|
|
42
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* The step's capture. A step that captured nothing still gives its start time while a video is
|
|
49
|
+
* recorded, since that is its place in the video; without a video it gives undefined.
|
|
50
|
+
*/
|
|
43
51
|
for(index) {
|
|
44
52
|
const debug = this.steps.get(index);
|
|
45
53
|
if (!debug)
|
|
46
54
|
return undefined;
|
|
47
|
-
const { video_offset_secs:
|
|
48
|
-
|
|
55
|
+
const { video_offset_secs: offset, ...captured } = debug;
|
|
56
|
+
if (Object.keys(captured).length > 0)
|
|
57
|
+
return debug;
|
|
58
|
+
return offset !== undefined && this.recording() ? debug : undefined;
|
|
49
59
|
}
|
|
50
60
|
onConsole(message) {
|
|
51
61
|
const type = message.type();
|
|
@@ -245,6 +245,10 @@ export class Adb {
|
|
|
245
245
|
async pidof(serial, pkg) {
|
|
246
246
|
return parsePidof(await this.shell(serial, ['pidof', assertPackage(pkg)], 10_000).catch(() => ''));
|
|
247
247
|
}
|
|
248
|
+
/** Drops adb's connection to a device so it reconnects (the fix for one stuck `offline`). */
|
|
249
|
+
async reconnect(serial) {
|
|
250
|
+
await this.run(['-s', assertSerial(serial), 'reconnect'], 15_000).catch(() => undefined);
|
|
251
|
+
}
|
|
248
252
|
async killEmulator(serial) {
|
|
249
253
|
await this.run(['-s', assertSerial(serial), 'emu', 'kill'], 15_000).catch(() => undefined);
|
|
250
254
|
}
|
|
@@ -140,6 +140,28 @@ export async function waitForBoot(adb, serial, timeoutMs, failed = () => null) {
|
|
|
140
140
|
await sleep(2_000);
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Waits for a device adb lost for a moment (a busy emulator, a USB reconnect) to come back and
|
|
145
|
+
* finish booting. A device still `offline` after a few seconds gets its connection reset once.
|
|
146
|
+
*/
|
|
147
|
+
export async function waitForDeviceBack(adb, serial, { timeoutMs = 120_000, nudgeAfterMs = 10_000, intervalMs = 2_000 } = {}) {
|
|
148
|
+
const deadline = Date.now() + timeoutMs;
|
|
149
|
+
const nudgeAt = Date.now() + nudgeAfterMs;
|
|
150
|
+
let nudged = false;
|
|
151
|
+
for (;;) {
|
|
152
|
+
const state = (await adb.devices().catch(() => [])).find((d) => d.serial === serial)?.state;
|
|
153
|
+
if (state === 'device' && (await adb.getprop(serial, 'sys.boot_completed').catch(() => '')) === '1')
|
|
154
|
+
return;
|
|
155
|
+
if (state === 'offline' && !nudged && Date.now() >= nudgeAt) {
|
|
156
|
+
nudged = true;
|
|
157
|
+
await adb.reconnect(serial);
|
|
158
|
+
}
|
|
159
|
+
if (Date.now() > deadline) {
|
|
160
|
+
throw new Error(`${serial} is ${state ?? 'not connected'} and did not come back within ${Math.round(timeoutMs / 1000)} s`);
|
|
161
|
+
}
|
|
162
|
+
await sleep(intervalMs);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
143
165
|
export async function shutdownEmulator(adb, serial, timeoutMs = 60_000) {
|
|
144
166
|
bootedHere.delete(serial);
|
|
145
167
|
await adb.killEmulator(serial);
|
|
@@ -7,6 +7,7 @@ import { bundledDrivers } from '../appium/drivers.js';
|
|
|
7
7
|
import { AppNotAvailable, resolveAppFile } from '../builds/install.js';
|
|
8
8
|
import { androidInstallable } from '../builds/unpack.js';
|
|
9
9
|
import { Adb, resolveAndroidSdk } from '../devices/android/adb.js';
|
|
10
|
+
import { waitForDeviceBack } from '../devices/android/emulator.js';
|
|
10
11
|
import { leaseAndroidDevice, listAndroidDevices } from '../devices/android/index.js';
|
|
11
12
|
import { LogcatSink } from '../devices/android/logcat.js';
|
|
12
13
|
import { dismissSystemErrorDialog } from '../devices/android/system-dialog.js';
|
|
@@ -33,6 +34,33 @@ export function appiumLogLines(text, redact) {
|
|
|
33
34
|
});
|
|
34
35
|
return { lines: kept, dropped: all.length - kept.length };
|
|
35
36
|
}
|
|
37
|
+
/** adb's words for a device it lost for the moment: `offline`, or missing from its list. */
|
|
38
|
+
export function lostDevice(error) {
|
|
39
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
40
|
+
return /\bdevice offline\b|\bdevice ('[^']*' )?not found\b/i.test(message);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Opens the Appium session, and once more when adb lost the device while Appium set it up. A busy
|
|
44
|
+
* emulator or a USB reconnect drops the device for a few seconds, and Appium (which retries
|
|
45
|
+
* "not found" but not "offline") then fails the whole session. `recover` waits for the device;
|
|
46
|
+
* when it does not come back, the first error stands.
|
|
47
|
+
*/
|
|
48
|
+
export async function startSession(open, recover) {
|
|
49
|
+
try {
|
|
50
|
+
return await open();
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (!lostDevice(error))
|
|
54
|
+
throw error;
|
|
55
|
+
try {
|
|
56
|
+
await recover();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
return open();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
36
64
|
/** `android.sdk_path` from config.json, when the runner config has one. */
|
|
37
65
|
export function configuredSdkPath(paths) {
|
|
38
66
|
try {
|
|
@@ -191,14 +219,19 @@ export class AndroidAppiumDriver {
|
|
|
191
219
|
const url = await this.appium.ensure();
|
|
192
220
|
this.systemPort = await SYSTEM_PORTS.take();
|
|
193
221
|
this.mjpegPort = await MJPEG_PORTS.take();
|
|
194
|
-
|
|
222
|
+
const capabilities = uiautomator2Capabilities({
|
|
195
223
|
serial,
|
|
196
224
|
appPackage: app.id,
|
|
197
225
|
appActivity: app.launch_activity,
|
|
198
226
|
systemPort: this.systemPort,
|
|
199
227
|
mjpegServerPort: this.mjpegPort,
|
|
200
228
|
newCommandTimeoutSecs: Math.max(300, Math.ceil((this.ctx.session?.idle_timeout_seconds ?? 0) + 60)),
|
|
201
|
-
})
|
|
229
|
+
});
|
|
230
|
+
const adb = this.adb;
|
|
231
|
+
this.client = await startSession(() => openSession(url, capabilities), async () => {
|
|
232
|
+
this.ctx.log.warn('adb lost the device while Appium started the session; waiting for it to retry', { serial });
|
|
233
|
+
await waitForDeviceBack(adb, serial);
|
|
234
|
+
});
|
|
202
235
|
}
|
|
203
236
|
catch (error) {
|
|
204
237
|
const log = this.appium.logSince(logOffset, 2_000).trim().split('\n').slice(-5).join(' | ');
|
|
@@ -423,16 +456,15 @@ export class AndroidAppiumDriver {
|
|
|
423
456
|
debug(index, result) {
|
|
424
457
|
const captured = this.logcat?.collector.for(index);
|
|
425
458
|
const appium = result === 'failed' || result === 'blocked' ? this.appiumLogFor(index) : null;
|
|
426
|
-
if (!captured && !appium)
|
|
427
|
-
return undefined;
|
|
428
459
|
const offset = this.offsets.get(index);
|
|
460
|
+
// While recording, the start time alone is kept: it is the step's place in the video.
|
|
429
461
|
const debug = { ...captured, ...(offset !== undefined && this.recording ? { video_offset_secs: offset } : {}) };
|
|
430
462
|
if (appium && appium.lines.length > 0) {
|
|
431
463
|
debug.appium_log = appium.lines;
|
|
432
464
|
if (appium.dropped > 0)
|
|
433
465
|
debug.dropped = { ...debug.dropped, appium_log: appium.dropped };
|
|
434
466
|
}
|
|
435
|
-
return debug;
|
|
467
|
+
return Object.keys(debug).length > 0 ? debug : undefined;
|
|
436
468
|
}
|
|
437
469
|
/** The Appium log lines written from step `index`'s start to the next step's start (or now). */
|
|
438
470
|
appiumLogFor(index) {
|
|
@@ -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';
|
|
@@ -6,7 +6,7 @@ import { findFfmpeg, ScreenRecorder } from '../devices/screen-record.js';
|
|
|
6
6
|
import { homePaths } from '../home.js';
|
|
7
7
|
import { renderTemplate } from '../template.js';
|
|
8
8
|
import { APPIUM_LOG_MAX_BYTES, appiumLogLines, LOCAL_PATHS_REFUSED } from './android-appium.js';
|
|
9
|
-
import { errorMessage } from './common.js';
|
|
9
|
+
import { errorMessage, renderLaunchArgs } from './common.js';
|
|
10
10
|
import { Blocked, LaunchRefused, NotFound, Unsupported } from './driver.js';
|
|
11
11
|
import { runNativeAction } from './webdriver/native-actions.js';
|
|
12
12
|
import { nativeFindAll, resolveNativeRef } from './webdriver/native-locate.js';
|
|
@@ -148,7 +148,7 @@ export class DesktopAppiumDriver {
|
|
|
148
148
|
return [];
|
|
149
149
|
validateNativeAction(this.platform, first);
|
|
150
150
|
this.firstLaunchDone = true;
|
|
151
|
-
return (first.args
|
|
151
|
+
return renderLaunchArgs(first.args, this.render);
|
|
152
152
|
}
|
|
153
153
|
/** Starts a whole-screen recording when the job wants video and ffmpeg is installed. */
|
|
154
154
|
async startRecording() {
|
|
@@ -243,22 +243,21 @@ export class DesktopAppiumDriver {
|
|
|
243
243
|
this.appiumLogOffsets.set(index, this.appium.logOffset());
|
|
244
244
|
}
|
|
245
245
|
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
246
|
const offset = this.offsets.get(index);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
247
|
+
// While recording, the start time alone is kept: it is the step's place in the video.
|
|
248
|
+
const debug = offset !== undefined && this.recorder ? { video_offset_secs: offset } : {};
|
|
249
|
+
const start = this.appiumLogOffsets.get(index);
|
|
250
|
+
if ((result === 'failed' || result === 'blocked') && start !== undefined) {
|
|
251
|
+
const later = [...this.appiumLogOffsets.entries()].filter(([i]) => i > index).map(([, at]) => at);
|
|
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
|
+
debug.appium_log = appium.lines;
|
|
256
|
+
if (appium.dropped > 0)
|
|
257
|
+
debug.dropped = { appium_log: appium.dropped };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return Object.keys(debug).length > 0 ? debug : undefined;
|
|
262
261
|
}
|
|
263
262
|
async stopRecording() {
|
|
264
263
|
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
|
}
|
|
@@ -427,16 +427,15 @@ export class IosAppiumDriver {
|
|
|
427
427
|
this.simLog?.checkCrashes();
|
|
428
428
|
const captured = this.collector?.for(index);
|
|
429
429
|
const appium = result === 'failed' || result === 'blocked' ? this.appiumLogFor(index) : null;
|
|
430
|
-
if (!captured && !appium)
|
|
431
|
-
return undefined;
|
|
432
430
|
const offset = this.offsets.get(index);
|
|
431
|
+
// While recording, the start time alone is kept: it is the step's place in the video.
|
|
433
432
|
const debug = { ...captured, ...(offset !== undefined && this.recording ? { video_offset_secs: offset } : {}) };
|
|
434
433
|
if (appium && appium.lines.length > 0) {
|
|
435
434
|
debug.appium_log = appium.lines;
|
|
436
435
|
if (appium.dropped > 0)
|
|
437
436
|
debug.dropped = { ...debug.dropped, appium_log: appium.dropped };
|
|
438
437
|
}
|
|
439
|
-
return debug;
|
|
438
|
+
return Object.keys(debug).length > 0 ? debug : undefined;
|
|
440
439
|
}
|
|
441
440
|
appiumLogFor(index) {
|
|
442
441
|
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 });
|
|
@@ -9,9 +9,9 @@ import { ensureDisplay, hostDevice } from '../devices/desktop.js';
|
|
|
9
9
|
import { loadTauriSettings, MACOS_PLUGIN_BLOCKED, tauriBackend, tauriCapabilities, tauriDriverArgv, } from '../devices/tauri.js';
|
|
10
10
|
import { homePaths } from '../home.js';
|
|
11
11
|
import { LOCAL_PATHS_REFUSED } from './android-appium.js';
|
|
12
|
-
import { clip, errorMessage, expectWithin, matches, render, requireTarget, verb } from './common.js';
|
|
12
|
+
import { clip, errorMessage, expectWithin, matches, render, renderLaunchArgs, requireTarget, verb } from './common.js';
|
|
13
13
|
import { Blocked, LaunchRefused, NotFound, Unsupported, } from './driver.js';
|
|
14
|
-
import { ELECTRON_ACTIONS, electronEnv, killProcessTree, validateElectronAction } from './electron-playwright.js';
|
|
14
|
+
import { ELECTRON_ACTIONS, electronEnv, killProcessTree, RESERVED_ARG, validateElectronAction } from './electron-playwright.js';
|
|
15
15
|
import { DOM_LOCATORS, describeDomTarget, domCandidates, domFindAll, domLocate, domLocateOnce, domScript, elementRef, keyActions, validateDomTarget, webdriverKeys, } from './webdriver/dom.js';
|
|
16
16
|
import { buildDomSnapshot, domRefOf, domScreen } from './webdriver/dom-tree.js';
|
|
17
17
|
import { elementIdOf } from './webdriver/locate.js';
|
|
@@ -224,7 +224,7 @@ export class TauriWebDriver {
|
|
|
224
224
|
let args = [];
|
|
225
225
|
if (first?.type === 'launch') {
|
|
226
226
|
validateTauriAction(first);
|
|
227
|
-
args = (first.args
|
|
227
|
+
args = renderLaunchArgs(first.args, this.renderText, RESERVED_ARG);
|
|
228
228
|
this.firstLaunchDone = true;
|
|
229
229
|
}
|
|
230
230
|
await this.start(args);
|
|
@@ -323,7 +323,7 @@ export class TauriWebDriver {
|
|
|
323
323
|
if (first && this.firstLaunchDone)
|
|
324
324
|
return {};
|
|
325
325
|
await this.closeApp();
|
|
326
|
-
await this.start((action.args
|
|
326
|
+
await this.start(renderLaunchArgs(action.args, this.renderText, RESERVED_ARG));
|
|
327
327
|
return {};
|
|
328
328
|
case 'terminate':
|
|
329
329
|
await this.closeApp();
|
|
@@ -138,9 +138,9 @@ export class WebPlaywrightDriver {
|
|
|
138
138
|
const page = await this.context.newPage();
|
|
139
139
|
this.page = page;
|
|
140
140
|
// The video starts with the page, so step offsets are measured from here.
|
|
141
|
-
this.collector = new DebugCollector(Date.now(), this.ctx.redact);
|
|
142
|
-
this.collector.attach(page);
|
|
143
141
|
this.video = page.video();
|
|
142
|
+
this.collector = new DebugCollector(Date.now(), this.ctx.redact, () => this.video !== null);
|
|
143
|
+
this.collector.attach(page);
|
|
144
144
|
page.setDefaultTimeout(this.ctx.timeouts.actionMs);
|
|
145
145
|
page.setDefaultNavigationTimeout(Math.max(this.ctx.timeouts.actionMs, NAVIGATION_TIMEOUT_MS));
|
|
146
146
|
}
|
|
@@ -260,12 +260,30 @@ export async function findOnPage(page, target, ctx, snapshotTaken) {
|
|
|
260
260
|
await page.ariaSnapshot({ mode: 'ai' }).catch(() => undefined);
|
|
261
261
|
return { count, refs };
|
|
262
262
|
}
|
|
263
|
+
/**
|
|
264
|
+
* The URL a `goto` opens, checked after rendering and resolving: only http(s). The server checks
|
|
265
|
+
* the script, but a URL that starts with a data value or the API base URL can render any scheme.
|
|
266
|
+
*/
|
|
267
|
+
export function checkGotoUrl(url) {
|
|
268
|
+
let protocol;
|
|
269
|
+
try {
|
|
270
|
+
protocol = new URL(url).protocol;
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
throw new Blocked(`'${clip(url)}' is not a URL the browser can open; use http(s)://, or a path on an environment with a base URL`);
|
|
274
|
+
}
|
|
275
|
+
if (protocol !== 'http:' && protocol !== 'https:') {
|
|
276
|
+
throw new Blocked(`only http(s) URLs can be opened; '${clip(url)}' uses ${protocol}`);
|
|
277
|
+
}
|
|
278
|
+
return url;
|
|
279
|
+
}
|
|
263
280
|
/** Performs one web DSL action on a page (shared by the web and Electron drivers). */
|
|
264
281
|
export async function runWebAction(page, action, ctx, defaultTimeout) {
|
|
265
282
|
const timeout = action.timeout_ms ?? defaultTimeout;
|
|
266
283
|
switch (action.type) {
|
|
267
284
|
case 'goto': {
|
|
268
|
-
|
|
285
|
+
// Trimmed as the server reads it, so a path with a stray leading space still opens on the environment.
|
|
286
|
+
const url = checkGotoUrl(resolveUrl(render(action.url, ctx).trim(), ctx.baseUrl));
|
|
269
287
|
try {
|
|
270
288
|
await page.goto(url, { waitUntil: 'load', timeout: Math.max(timeout, NAVIGATION_TIMEOUT_MS) });
|
|
271
289
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
2
|
import { clip, expectWithin, matches, verb } from '../common.js';
|
|
3
|
-
import { NotFound, Unsupported } from '../driver.js';
|
|
3
|
+
import { Blocked, NotFound, Unsupported } from '../driver.js';
|
|
4
4
|
import { describeAndroidTarget, findAll, locate } from './locate.js';
|
|
5
5
|
import { mobile, webdriverMessage } from './session.js';
|
|
6
6
|
/** Named android keys (plan §3) → Android key codes. */
|
|
@@ -68,13 +68,17 @@ export const ANDROID_ACTIONS = [
|
|
|
68
68
|
'expect_hidden',
|
|
69
69
|
'expect_text',
|
|
70
70
|
];
|
|
71
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Deep link schemes a script may open (plan §3), checked on the rendered URL (android and ios):
|
|
73
|
+
* the server checks the script, but a link that starts with a data value can render any scheme.
|
|
74
|
+
* A refused link blocks the step.
|
|
75
|
+
*/
|
|
72
76
|
export function checkDeepLink(url) {
|
|
73
77
|
const match = /^([a-z][a-z0-9+.-]{1,63}):\/\//i.exec(url);
|
|
74
78
|
if (!match)
|
|
75
|
-
throw new
|
|
79
|
+
throw new Blocked(`deep link '${clip(url)}' must start with a scheme such as https:// or myapp://`);
|
|
76
80
|
if (/^(javascript|file|data|blob|content|intent)$/i.test(match[1]))
|
|
77
|
-
throw new
|
|
81
|
+
throw new Blocked(`deep links with the ${match[1]} scheme are not allowed`);
|
|
78
82
|
return url;
|
|
79
83
|
}
|
|
80
84
|
function requireTarget(action) {
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
const limit = Math.max(1, Math.min(1000, Number((request?.limit) || 50)));
|
|
14
14
|
const SKIP = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEMPLATE: 1, HEAD: 1, META: 1, LINK: 1, TITLE: 1 };
|
|
15
15
|
|
|
16
|
+
/** Collapses whitespace and composes accents (NFC), so the target and the page compare alike. */
|
|
16
17
|
function normalize(value) {
|
|
17
18
|
return String(value == null ? '' : value)
|
|
19
|
+
.normalize('NFC')
|
|
18
20
|
.replace(/\s+/g, ' ')
|
|
19
21
|
.trim();
|
|
20
22
|
}
|
|
@@ -5,7 +5,7 @@ import { hostDevice } from '../devices/desktop.js';
|
|
|
5
5
|
import { installedWindowsApp, loadWindowsSettings, sharedWinAppDriver, WINAPPDRIVER_VERSION, WINDOWS_NEEDS_WINDOWS, windowsCapabilities, windowsReadiness, } from '../devices/windows.js';
|
|
6
6
|
import { homePaths } from '../home.js';
|
|
7
7
|
import { androidSdkFor, configuredSdkPath, sharedAppiumServer } from './android-appium.js';
|
|
8
|
-
import { clip, errorMessage, expectWithin, matches } from './common.js';
|
|
8
|
+
import { clip, errorMessage, expectWithin, matches, renderLaunchArgs } from './common.js';
|
|
9
9
|
import { DesktopAppiumDriver, validateNativeAction } from './desktop-appium.js';
|
|
10
10
|
import { Blocked, LaunchRefused, } from './driver.js';
|
|
11
11
|
import { emptyScreen } from './snapshot/format.js';
|
|
@@ -185,7 +185,7 @@ export class WindowsAppiumDriver extends DesktopAppiumDriver {
|
|
|
185
185
|
switch (action.type) {
|
|
186
186
|
case 'launch':
|
|
187
187
|
await this.closeApp();
|
|
188
|
-
await this.start((action.args
|
|
188
|
+
await this.start(renderLaunchArgs(action.args, this.render));
|
|
189
189
|
return {};
|
|
190
190
|
case 'terminate':
|
|
191
191
|
await this.closeApp();
|
package/dist/src/executor.js
CHANGED
|
@@ -11,10 +11,17 @@ export { launchBrowser } from './drivers/web-playwright.js';
|
|
|
11
11
|
export function templateContextFor(job) {
|
|
12
12
|
return {
|
|
13
13
|
baseUrl: job.environment.base_url ?? '',
|
|
14
|
+
apiBaseUrl: job.environment.api_base_url ?? null,
|
|
15
|
+
randomEmailDomain: job.environment.random_email_domain ?? null,
|
|
14
16
|
username: job.credential?.username ?? null,
|
|
15
17
|
password: job.credential?.password ?? null,
|
|
16
18
|
runId: job.run.id,
|
|
17
19
|
appId: job.app?.id ?? null,
|
|
20
|
+
data: job.data?.refs ?? {},
|
|
21
|
+
tag: job.data?.tag ?? null,
|
|
22
|
+
itemId: job.item.id,
|
|
23
|
+
jobId: job.job.id,
|
|
24
|
+
attempt: job.job.attempt,
|
|
18
25
|
random: new Map(),
|
|
19
26
|
};
|
|
20
27
|
}
|
|
@@ -48,6 +55,7 @@ export function jobContext(job, options) {
|
|
|
48
55
|
/**
|
|
49
56
|
* A driver context for a device session. Drivers read the app, device and environment
|
|
50
57
|
* from `ctx.job`, so the session is also presented as a job with no steps (id 0, run 0).
|
|
58
|
+
* Its placeholders know they belong to a session: job values and test data are refused.
|
|
51
59
|
*/
|
|
52
60
|
export function sessionContext(session, options) {
|
|
53
61
|
if (!isPlatform(session.platform))
|
|
@@ -66,7 +74,8 @@ export function sessionContext(session, options) {
|
|
|
66
74
|
automation: platform === 'web' ? { version: 1, steps: [] } : { version: 2, platform, steps: [] },
|
|
67
75
|
capture: { video: 'off' },
|
|
68
76
|
};
|
|
69
|
-
|
|
77
|
+
const ctx = jobContext(job, options);
|
|
78
|
+
return { ...ctx, template: { ...ctx.template, session: true }, session };
|
|
70
79
|
}
|
|
71
80
|
/** Creates the driver for a context through the registry; a platform without a factory is blocked. */
|
|
72
81
|
export async function createDriver(registry, ctx, shared) {
|
|
@@ -114,9 +123,10 @@ export async function executeSteps(driver, script, options, shouldStop, redact)
|
|
|
114
123
|
await driver.launch();
|
|
115
124
|
}
|
|
116
125
|
catch (error) {
|
|
117
|
-
// A launch this runner refuses as configured
|
|
126
|
+
// A launch this runner refuses as configured, or launch args it cannot fill, block the case;
|
|
127
|
+
// other launch errors fail the job (and may be retried).
|
|
118
128
|
const first = ordered[0];
|
|
119
|
-
if (!(error instanceof LaunchRefused) || !first)
|
|
129
|
+
if (!(error instanceof LaunchRefused || error instanceof TemplateError) || !first)
|
|
120
130
|
throw error;
|
|
121
131
|
const outcome = { index: first.index, result: 'blocked', note: redact(errorMessage(error)), screenshots: [] };
|
|
122
132
|
return { steps: [outcome], elapsedSecs: Math.max(1, Math.round((Date.now() - started) / 1000)), stopped: false };
|
package/dist/src/template.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { randomBytes, randomInt } from 'node:crypto';
|
|
2
2
|
export class TemplateError extends Error {
|
|
3
3
|
}
|
|
4
|
-
const PLACEHOLDER = /\{\{\s*([a-
|
|
4
|
+
const PLACEHOLDER = /\{\{\s*([a-z][a-z0-9_.]*)\s*\}\}/g;
|
|
5
|
+
/** The field a bare `{{data.<ref>}}` reads. */
|
|
6
|
+
export const DEFAULT_DATA_FIELD = 'value';
|
|
7
|
+
/** Fills every placeholder in one pass: a value that itself contains `{{…}}` is inserted as it is. */
|
|
5
8
|
export function renderTemplate(text, ctx) {
|
|
6
9
|
return text.replace(PLACEHOLDER, (_match, name) => placeholderValue(name, ctx));
|
|
7
10
|
}
|
|
@@ -29,29 +32,81 @@ export function redactDeep(value, redact) {
|
|
|
29
32
|
return value;
|
|
30
33
|
}
|
|
31
34
|
function placeholderValue(name, ctx) {
|
|
35
|
+
if (name.startsWith('data.'))
|
|
36
|
+
return dataValue(name, ctx);
|
|
32
37
|
switch (name) {
|
|
33
38
|
case 'env.base_url':
|
|
34
39
|
return trimSlash(ctx.baseUrl);
|
|
40
|
+
case 'env.api_base_url': {
|
|
41
|
+
const apiBaseUrl = ctx.apiBaseUrl?.trim();
|
|
42
|
+
if (!apiBaseUrl)
|
|
43
|
+
throw new TemplateError(`the script uses {{${name}}} but the environment has no API base URL`);
|
|
44
|
+
return trimSlash(apiBaseUrl);
|
|
45
|
+
}
|
|
35
46
|
case 'credential.username':
|
|
36
47
|
return present(ctx.username, name);
|
|
37
48
|
case 'credential.password':
|
|
38
49
|
return present(ctx.password, name);
|
|
39
50
|
case 'run.id':
|
|
40
51
|
return String(ctx.runId);
|
|
52
|
+
case 'item.id':
|
|
53
|
+
return jobValue(ctx, name, ctx.itemId);
|
|
54
|
+
case 'job.id':
|
|
55
|
+
return jobValue(ctx, name, ctx.jobId);
|
|
56
|
+
case 'job.attempt':
|
|
57
|
+
return jobValue(ctx, name, ctx.attempt);
|
|
58
|
+
case 'job.tag':
|
|
59
|
+
return jobValue(ctx, name, ctx.tag);
|
|
41
60
|
case 'app.id':
|
|
42
61
|
if (!ctx.appId)
|
|
43
62
|
throw new TemplateError('the script uses {{app.id}} but the job has no app');
|
|
44
63
|
return ctx.appId;
|
|
45
64
|
case 'random.email':
|
|
46
|
-
return remembered(ctx, name, () => `qa.${token(10)}
|
|
65
|
+
return remembered(ctx, name, () => `qa.${token(10)}@${emailDomain(ctx)}`);
|
|
47
66
|
case 'random.string':
|
|
48
67
|
return remembered(ctx, name, () => token(12));
|
|
49
68
|
case 'random.number':
|
|
50
69
|
return remembered(ctx, name, () => String(randomInt(100_000, 1_000_000)));
|
|
70
|
+
case 'now.iso':
|
|
71
|
+
return now(ctx);
|
|
72
|
+
case 'date.today':
|
|
73
|
+
// Taken from the same remembered instant as {{now.iso}}, so the two always agree (UTC).
|
|
74
|
+
return now(ctx).slice(0, 10);
|
|
51
75
|
default:
|
|
52
76
|
throw new TemplateError(`unknown placeholder {{${name}}}`);
|
|
53
77
|
}
|
|
54
78
|
}
|
|
79
|
+
/** `{{data.<ref>}}` (field `value`) or `{{data.<ref>.<field>}}`; a missing value is an error, never empty text. */
|
|
80
|
+
function dataValue(name, ctx) {
|
|
81
|
+
const rest = name.slice('data.'.length);
|
|
82
|
+
const dot = rest.indexOf('.');
|
|
83
|
+
const reference = dot === -1 ? rest : rest.slice(0, dot);
|
|
84
|
+
const field = dot === -1 ? DEFAULT_DATA_FIELD : rest.slice(dot + 1);
|
|
85
|
+
if (!reference || !field)
|
|
86
|
+
throw new TemplateError(`unknown placeholder {{${name}}}`);
|
|
87
|
+
if (ctx.session)
|
|
88
|
+
throw new TemplateError(`only a queued job has test data; a device session cannot fill {{${name}}}`);
|
|
89
|
+
const refs = ctx.data ?? {};
|
|
90
|
+
const fields = Object.hasOwn(refs, reference) ? refs[reference] : undefined;
|
|
91
|
+
// The server sends a reference it found no value for as an empty map.
|
|
92
|
+
if (!fields || typeof fields !== 'object' || Object.keys(fields).length === 0) {
|
|
93
|
+
throw new TemplateError(`the script uses {{${name}}} but the job has no value for the test data reference ${reference}`);
|
|
94
|
+
}
|
|
95
|
+
const value = Object.hasOwn(fields, field) ? fields[field] : undefined;
|
|
96
|
+
if (typeof value !== 'string') {
|
|
97
|
+
throw new TemplateError(`the script uses {{${name}}} but the value of the test data reference ${reference} has no field ${field}`);
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
/** A value only a queued job has: device sessions refuse it, and older servers may not send it. */
|
|
102
|
+
function jobValue(ctx, name, value) {
|
|
103
|
+
if (ctx.session)
|
|
104
|
+
throw new TemplateError(`only a queued job has {{${name}}}`);
|
|
105
|
+
if (value === null || value === undefined || value === '') {
|
|
106
|
+
throw new TemplateError(`the script uses {{${name}}} but the server did not send it with this job`);
|
|
107
|
+
}
|
|
108
|
+
return String(value);
|
|
109
|
+
}
|
|
55
110
|
function present(value, name) {
|
|
56
111
|
if (value === null)
|
|
57
112
|
throw new TemplateError(`the script uses {{${name}}} but the job has no credential`);
|
|
@@ -65,6 +120,12 @@ function remembered(ctx, name, make) {
|
|
|
65
120
|
}
|
|
66
121
|
return value;
|
|
67
122
|
}
|
|
123
|
+
function now(ctx) {
|
|
124
|
+
return remembered(ctx, 'now.iso', () => new Date().toISOString());
|
|
125
|
+
}
|
|
126
|
+
function emailDomain(ctx) {
|
|
127
|
+
return ctx.randomEmailDomain?.trim() || 'example.com';
|
|
128
|
+
}
|
|
68
129
|
function token(length) {
|
|
69
130
|
return randomBytes(length).toString('hex').slice(0, length);
|
|
70
131
|
}
|
package/package.json
CHANGED