@leera.io/qa-runner 1.0.6 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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';
@@ -2,11 +2,12 @@ import { basename } from 'node:path';
2
2
  import { disposeUnpacked } from '../builds/cleanup.js';
3
3
  import { AppNotAvailable, resolveAppFile } from '../builds/install.js';
4
4
  import { buildKind, DesktopBuildError, localDesktopApp, unpackDesktopBuild } from '../builds/unpack.js';
5
- import { findFfmpeg, ScreenRecorder } from '../devices/screen-record.js';
5
+ import { ensureFfmpeg } from '../devices/ffmpeg.js';
6
+ import { ScreenRecorder } from '../devices/screen-record.js';
6
7
  import { homePaths } from '../home.js';
7
8
  import { renderTemplate } from '../template.js';
8
9
  import { APPIUM_LOG_MAX_BYTES, appiumLogLines, LOCAL_PATHS_REFUSED } from './android-appium.js';
9
- import { errorMessage } from './common.js';
10
+ import { errorMessage, renderLaunchArgs } from './common.js';
10
11
  import { Blocked, LaunchRefused, NotFound, Unsupported } from './driver.js';
11
12
  import { runNativeAction } from './webdriver/native-actions.js';
12
13
  import { nativeFindAll, resolveNativeRef } from './webdriver/native-locate.js';
@@ -148,16 +149,17 @@ export class DesktopAppiumDriver {
148
149
  return [];
149
150
  validateNativeAction(this.platform, first);
150
151
  this.firstLaunchDone = true;
151
- return (first.args ?? []).map(this.render);
152
+ return renderLaunchArgs(first.args, this.render);
152
153
  }
153
- /** Starts a whole-screen recording when the job wants video and ffmpeg is installed. */
154
+ /** Starts a whole-screen recording when the job wants video (ffmpeg is downloaded when missing). */
154
155
  async startRecording() {
155
156
  this.startedAt = Date.now();
156
157
  if (this.ctx.video === 'off')
157
158
  return;
158
- const ffmpeg = findFfmpeg();
159
+ const ffmpeg = await ensureFfmpeg({ ...(this.ctx.paths ? { paths: this.ctx.paths } : {}), log: this.ctx.log });
159
160
  if (!ffmpeg)
160
161
  return;
162
+ this.startedAt = Date.now();
161
163
  const recorder = new ScreenRecorder(ffmpeg, this.platform);
162
164
  try {
163
165
  await recorder.start();
@@ -243,22 +245,21 @@ export class DesktopAppiumDriver {
243
245
  this.appiumLogOffsets.set(index, this.appium.logOffset());
244
246
  }
245
247
  debug(index, result) {
246
- if (result !== 'failed' && result !== 'blocked')
247
- return undefined;
248
- const start = this.appiumLogOffsets.get(index);
249
- if (start === undefined)
250
- return undefined;
251
- const later = [...this.appiumLogOffsets.entries()].filter(([i]) => i > index).map(([, offset]) => offset);
252
- const end = later.length > 0 ? Math.min(...later) : undefined;
253
- const appium = appiumLogLines(this.appium.logSince(start, APPIUM_LOG_MAX_BYTES, end), this.ctx.redact);
254
- if (appium.lines.length === 0)
255
- return undefined;
256
248
  const offset = this.offsets.get(index);
257
- 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
- };
249
+ // While recording, the start time alone is kept: it is the step's place in the video.
250
+ const debug = offset !== undefined && this.recorder ? { video_offset_secs: offset } : {};
251
+ const start = this.appiumLogOffsets.get(index);
252
+ if ((result === 'failed' || result === 'blocked') && start !== undefined) {
253
+ const later = [...this.appiumLogOffsets.entries()].filter(([i]) => i > index).map(([, at]) => at);
254
+ const end = later.length > 0 ? Math.min(...later) : undefined;
255
+ const appium = appiumLogLines(this.appium.logSince(start, APPIUM_LOG_MAX_BYTES, end), this.ctx.redact);
256
+ if (appium.lines.length > 0) {
257
+ debug.appium_log = appium.lines;
258
+ if (appium.dropped > 0)
259
+ debug.dropped = { appium_log: appium.dropped };
260
+ }
261
+ }
262
+ return Object.keys(debug).length > 0 ? debug : undefined;
262
263
  }
263
264
  async stopRecording() {
264
265
  if (!this.recorder || this.videoStopped)
@@ -13,7 +13,7 @@ import { ensureDisplay, hostDevice } from '../devices/desktop.js';
13
13
  import { homePaths } from '../home.js';
14
14
  import { playwrightVersion } from '../version.js';
15
15
  import { LOCAL_PATHS_REFUSED } from './android-appium.js';
16
- import { clip, errorMessage, expectWithin, firstLine, matches, render } from './common.js';
16
+ import { clip, errorMessage, expectWithin, firstLine, matches, render, renderLaunchArgs } from './common.js';
17
17
  import { Blocked, LaunchRefused, Unsupported, } from './driver.js';
18
18
  import { webSnapshot } from './snapshot/aria.js';
19
19
  import { findOnPage, NO_FFMPEG_VIDEO, playwrightFfmpeg, runWebAction, WEB_ACTIONS } from './web-playwright.js';
@@ -26,9 +26,13 @@ export const MAX_WINDOW_INDEX = 100;
26
26
  export const INSTALLED_REFUSED = 'electron apps need an uploaded build or a path on the runner';
27
27
  const LAUNCH_TIMEOUT_MS = 60_000;
28
28
  const PAINT_TIMEOUT_MS = 15_000;
29
+ /** How long the paint wait pauses before checking a window whose page navigated again. */
30
+ const PAINT_RETRY_MS = 100;
31
+ /** How long after the paint a launch waits for its video's first frame (held-back frames come within about 0.5 s). */
32
+ const FIRST_FRAME_WAIT_MS = 1_500;
29
33
  const VIDEO_SIZE = { width: 1024, height: 576 };
30
34
  /** Switches the runner controls itself (debugging ports); a script cannot pass them. */
31
- const RESERVED_ARG = /^--(inspect|inspect-brk|inspect-port|remote-debugging-port|remote-debugging-pipe|remote-allow-origins)(=|$)/i;
35
+ export const RESERVED_ARG = /^--(inspect|inspect-brk|inspect-port|remote-debugging-port|remote-debugging-pipe|remote-allow-origins)(=|$)/i;
32
36
  /** Runner secrets never reach the app's environment. */
33
37
  const SECRET_ENV = new Set(['RUNNER_TOKEN', 'LEERA_RUNNER_TOKEN', 'ELECTRON_RUN_AS_NODE', 'NODE_OPTIONS']);
34
38
  /** Checks an Electron-only action before it runs; throws what the step reports. */
@@ -106,6 +110,65 @@ function freePort() {
106
110
  });
107
111
  });
108
112
  }
113
+ /**
114
+ * Starts a recording's clock. A video starts with its first frame, not with its window: often a
115
+ * blank frame while the app's script loads, a frame Chromium held back until there was content, or,
116
+ * for a window that shows nothing new, the first change. That frame's time stamp comes from
117
+ * Playwright's screencast of the page, the stream its recorder writes; the listener stops after it.
118
+ */
119
+ function recordingOf(page, video, log) {
120
+ const recording = { video, page, openedAt: Date.now(), startedAt: null, started: Promise.resolve(), endedAt: null };
121
+ recording.started = new Promise((resolve) => {
122
+ let settled = false;
123
+ let listener = Promise.resolve(null);
124
+ const settle = (at) => {
125
+ if (settled)
126
+ return;
127
+ settled = true;
128
+ if (at !== null)
129
+ recording.startedAt = at;
130
+ resolve();
131
+ void listener.then((started) => started?.dispose()).catch(() => undefined);
132
+ };
133
+ page.once('close', () => settle(null));
134
+ listener = Promise.resolve()
135
+ .then(() => page.screencast.start({ onFrame: ({ timestamp }) => settle(timestamp) }))
136
+ .catch((error) => {
137
+ // Without the time stamp, the window's opening is the earliest the video can start.
138
+ log.warn("the app window's video start could not be read; step offsets count from the window's opening", { error: firstLine(error) });
139
+ settle(recording.openedAt);
140
+ return null;
141
+ });
142
+ });
143
+ return recording;
144
+ }
145
+ /** The paint check, run in the window: settles once it is visible and has run two animation frames. */
146
+ function paintCheck() {
147
+ return new Promise((resolve) => {
148
+ const tick = () => {
149
+ if (document.visibilityState === 'visible')
150
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve(true)));
151
+ else
152
+ setTimeout(tick, 250);
153
+ };
154
+ tick();
155
+ });
156
+ }
157
+ /**
158
+ * Where a step starts in a recording, in seconds, or undefined when the recording does not show
159
+ * it: the step ended before the recording's first frame, or began after the recording ended. A
160
+ * step already running when the recording started (the one that relaunched the app) starts at 0.
161
+ */
162
+ export function stepVideoOffset(recording, begun, ended) {
163
+ // No frame yet: a window that never painted records only a closing frame, which shows no step.
164
+ if (recording.startedAt === null)
165
+ return undefined;
166
+ if (recording.endedAt !== null && begun >= recording.endedAt)
167
+ return undefined;
168
+ if (ended !== null && ended <= recording.startedAt)
169
+ return undefined;
170
+ return Math.max(0, Math.round((begun - recording.startedAt) / 100) / 10);
171
+ }
109
172
  export class ElectronPlaywrightDriver {
110
173
  ctx;
111
174
  options;
@@ -121,7 +184,10 @@ export class ElectronPlaywrightDriver {
121
184
  page = null;
122
185
  collector = null;
123
186
  videoDir = null;
124
- 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
  }
@@ -11,6 +11,7 @@ import { loadIosSettings } from '../devices/ios/settings.js';
11
11
  import { assertBundleId } from '../devices/ios/simctl.js';
12
12
  import { parseDeviceSyslogLine, SimulatorLogSink, SyslogCollector } from '../devices/ios/syslog.js';
13
13
  import { developerDir, isCommandLineToolsOnly, wdaRealDeviceDir, wdaStatus } from '../devices/ios/wda.js';
14
+ import { ensureFfmpeg } from '../devices/ffmpeg.js';
14
15
  import { homePaths } from '../home.js';
15
16
  import { renderTemplate } from '../template.js';
16
17
  import { AndroidAppiumDriver, androidSdkFor, APPIUM_LOG_MAX_BYTES, appiumLogLines, configuredSdkPath, LOCAL_PATHS_REFUSED, sharedAppiumServer } from './android-appium.js';
@@ -242,6 +243,8 @@ export class IosAppiumDriver {
242
243
  await recorder.start();
243
244
  }
244
245
  else {
246
+ // Appium records a real device through ffmpeg on this Mac: fetch it when missing.
247
+ await ensureFfmpeg({ paths: this.paths, log: this.ctx.log });
245
248
  await client.startRecordingScreen({ timeLimit: RECORDING_LIMIT_SECS, videoType: 'h264', forceRestart: true });
246
249
  }
247
250
  this.recording = true;
@@ -427,16 +430,15 @@ export class IosAppiumDriver {
427
430
  this.simLog?.checkCrashes();
428
431
  const captured = this.collector?.for(index);
429
432
  const appium = result === 'failed' || result === 'blocked' ? this.appiumLogFor(index) : null;
430
- if (!captured && !appium)
431
- return undefined;
432
433
  const offset = this.offsets.get(index);
434
+ // While recording, the start time alone is kept: it is the step's place in the video.
433
435
  const debug = { ...captured, ...(offset !== undefined && this.recording ? { video_offset_secs: offset } : {}) };
434
436
  if (appium && appium.lines.length > 0) {
435
437
  debug.appium_log = appium.lines;
436
438
  if (appium.dropped > 0)
437
439
  debug.dropped = { ...debug.dropped, appium_log: appium.dropped };
438
440
  }
439
- return debug;
441
+ return Object.keys(debug).length > 0 ? debug : undefined;
440
442
  }
441
443
  appiumLogFor(index) {
442
444
  const start = this.appiumLogOffsets.get(index);
@@ -4,7 +4,7 @@ import { hostDevice } from '../devices/desktop.js';
4
4
  import { ACCESSIBILITY_BLOCKED, appBundleOf, BUNDLE_ID_RE, isAccessibilityError, MACOS_NEEDS_MAC, mac2Capabilities, macosReadiness, markAccessibilityBlocked, readBundleId, } from '../devices/macos.js';
5
5
  import { homePaths } from '../home.js';
6
6
  import { androidSdkFor, configuredSdkPath, sharedAppiumServer } from './android-appium.js';
7
- import { clip, errorMessage, expectWithin } from './common.js';
7
+ import { clip, errorMessage, expectWithin, renderLaunchArgs } from './common.js';
8
8
  import { DesktopAppiumDriver, validateNativeAction } from './desktop-appium.js';
9
9
  import { Blocked, LaunchRefused, } from './driver.js';
10
10
  import { emptyScreen } from './snapshot/format.js';
@@ -200,7 +200,7 @@ export class Mac2AppiumDriver extends DesktopAppiumDriver {
200
200
  try {
201
201
  switch (action.type) {
202
202
  case 'launch':
203
- await this.start((action.args ?? []).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 });