@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.
@@ -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
- constructor(startedAt, redact) {
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
- /** The step's capture, or undefined when nothing but its start time was recorded. */
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: _offset, ...captured } = debug;
48
- return Object.keys(captured).length > 0 ? debug : undefined;
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
- this.client = await openSession(url, uiautomator2Capabilities({
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
- return mode === 'equals' ? actual === expected : actual.includes(expected);
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 ?? []).map(this.render);
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
- return {
258
- appium_log: appium.lines,
259
- ...(appium.dropped > 0 ? { dropped: { appium_log: appium.dropped } } : {}),
260
- ...(offset !== undefined && this.recorder ? { video_offset_secs: offset } : {}),
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
- videos = [];
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 ?? []).map((arg) => render(arg, this.ctx.template));
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(), video: null, closed: false });
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 frames = page
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 painted = await Promise.race([frames, sleep(PAINT_TIMEOUT_MS, false, { signal: timer.signal }).catch(() => false)]);
285
- timer.abort();
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 (!painted)
288
- this.ctx.log.warn(`the app window did not paint within ${Math.round(PAINT_TIMEOUT_MS / 1000)} s after ${when}`);
289
- else if (ms > 2_000)
290
- this.ctx.log.warn(`the app window took ${ms} ms to paint after ${when}`);
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, video: null, closed: false };
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 browser = await chromium.connectOverCDP(endpoint, { timeout: LAUNCH_TIMEOUT_MS });
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.video && instance.via === 'playwright') {
500
+ if (instance && !instance.recording && instance.via === 'playwright') {
359
501
  const video = page.video();
360
- if (video) {
361
- instance.video = video;
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 ?? []).map((arg) => render(arg, this.ctx.template)));
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
- return this.collector?.for(index);
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
- /** Closes the app (a video is only complete then) and returns the longest recording. */
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
- let best = null;
476
- for (const video of this.videos) {
477
- const path = await video.path().catch(() => undefined);
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
- await Promise.race([graceful.catch(() => undefined), sleep(10_000)]);
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 ?? []).map(this.render));
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 ?? []).map(this.renderText);
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 ?? []).map(this.renderText));
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
- const url = resolveUrl(render(action.url, ctx), ctx.baseUrl);
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
- /** Deep link schemes a script may open (plan §3); the server validates too. */
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 Error(`deep link '${clip(url)}' must start with a scheme such as https:// or myapp://`);
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 Error(`deep links with the ${match[1]} scheme are not allowed`);
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 ?? []).map(this.render));
188
+ await this.start(renderLaunchArgs(action.args, this.render));
189
189
  return {};
190
190
  case 'terminate':
191
191
  await this.closeApp();
@@ -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
- return { ...jobContext(job, options), session };
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 blocks the case; other launch errors fail the job (and may be retried).
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 };
@@ -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-z_.]+)\s*\}\}/g;
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)}@example.com`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leera.io/qa-runner",
3
- "version": "1.0.5",
3
+ "version": "1.1.0",
4
4
  "description": "Runs recorded QA test scripts on your own machines and reports results back to your workspace.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",