@leera.io/qa-runner 1.2.0 → 1.5.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.
@@ -11,9 +11,10 @@ export class BuildIntegrityError extends Error {
11
11
  */
12
12
  export async function obtainBuild(options) {
13
13
  const { cache, build } = options;
14
+ const what = options.what ?? `build ${build.id}`;
14
15
  const expected = build.sha256.toLowerCase();
15
16
  if (!SHA256_RE.test(expected))
16
- throw new BuildIntegrityError(`build ${build.id} has no valid sha256`);
17
+ throw new BuildIntegrityError(`${what} has no valid sha256`);
17
18
  const release = cache.pin(expected);
18
19
  try {
19
20
  const cached = cache.get(expected);
@@ -21,11 +22,11 @@ export async function obtainBuild(options) {
21
22
  return { path: cached, sha256: expected, fromCache: true, release };
22
23
  const info = await options.getUrl();
23
24
  if (info.sha256.toLowerCase() !== expected) {
24
- throw new BuildIntegrityError(`the server offered build ${build.id} with digest ${info.sha256}, not ${expected}`);
25
+ throw new BuildIntegrityError(`the server offered ${what} with digest ${info.sha256}, not ${expected}`);
25
26
  }
26
27
  const url = new URL(info.url);
27
28
  if (url.protocol !== 'https:' && url.protocol !== 'http:')
28
- throw new Error(`refusing to download a build from ${url.protocol} URL`);
29
+ throw new Error(`refusing to download ${what} from a ${url.protocol} URL`);
29
30
  const temp = cache.tempPath(info.file_name);
30
31
  try {
31
32
  const response = await (options.fetchImpl ?? fetch)(url, {
@@ -33,22 +34,22 @@ export async function obtainBuild(options) {
33
34
  signal: AbortSignal.timeout(options.timeoutMs ?? 30 * 60_000),
34
35
  });
35
36
  if (!response.ok || !response.body)
36
- throw new Error(`the build download failed with HTTP ${response.status}`);
37
+ throw new Error(`the download of ${what} failed with HTTP ${response.status}`);
37
38
  const hash = createHash('sha256');
38
39
  let received = 0;
39
40
  const limit = info.size_bytes;
40
41
  await writeResponseBody(response.body, temp, (chunk) => {
41
42
  received += chunk.length;
42
43
  if (received > limit)
43
- throw new BuildIntegrityError(`the build download is larger than the expected ${limit} bytes`);
44
+ throw new BuildIntegrityError(`the download of ${what} is larger than the expected ${limit} bytes`);
44
45
  hash.update(chunk);
45
46
  options.onProgress?.(received, limit);
46
47
  });
47
48
  if (received !== limit)
48
- throw new BuildIntegrityError(`the build download has ${received} bytes, expected ${limit}`);
49
+ throw new BuildIntegrityError(`the download of ${what} has ${received} bytes, expected ${limit}`);
49
50
  const digest = hash.digest('hex');
50
51
  if (digest !== expected)
51
- throw new BuildIntegrityError(`the downloaded build's sha256 ${digest} does not match ${expected}`);
52
+ throw new BuildIntegrityError(`the sha256 ${digest} of the downloaded ${what} does not match ${expected}`);
52
53
  const path = cache.put(expected, info.file_name || build.file_name, temp);
53
54
  cache.prune();
54
55
  return { path, sha256: expected, fromCache: false, release };
@@ -60,6 +60,8 @@ export function deriveLabels(devices) {
60
60
  * (contracts §Q); the server gives such scripts only to runners labelled `data-refs`.
61
61
  * `credential_otp` = this runner types the account's fixed one-time code (`{{credential.otp}}`);
62
62
  * the server gives such scripts only to runners labelled `credential-otp`.
63
+ * `test_files` = this runner downloads or generates a job's test files and hands them to the app
64
+ * (contracts §S); the server gives such scripts only to runners labelled `test-files`.
63
65
  */
64
66
  export function featuresFor(platforms, options = {}) {
65
67
  const features = [];
@@ -79,7 +81,7 @@ export function featuresFor(platforms, options = {}) {
79
81
  if (options.ffmpeg && platforms.some((platform) => platform === 'tauri' || platform === 'macos' || platform === 'windows') && !features.includes('video_mp4')) {
80
82
  features.push('video_mp4');
81
83
  }
82
- features.push('data_refs', 'credential_otp');
84
+ features.push('data_refs', 'credential_otp', 'test_files');
83
85
  return features;
84
86
  }
85
87
  export function buildCapabilities(devices, host, extraTools = {}, options = { ffmpeg: ffmpegAvailable() }) {
@@ -110,6 +110,10 @@ export class RunnerClient {
110
110
  ...(owner.jobId !== undefined ? { job_id: owner.jobId } : { session_id: owner.sessionId }),
111
111
  }));
112
112
  }
113
+ /** A short-lived download URL for a stored test file of a running job (contracts §S). */
114
+ async testFileDownloadUrl(testFileId, jobId) {
115
+ return this.expect(await this.json('/api/v1/qa/runner/files/download-url', { test_file_id: testFileId, job_id: jobId }));
116
+ }
113
117
  json(path, body) {
114
118
  return this.request(path, {
115
119
  method: 'POST',
@@ -245,6 +245,14 @@ 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
+ /** Copies a file of this machine to the device (no shell: the remote path is one argv item). */
249
+ async push(serial, local, remote, timeoutMs = 300_000) {
250
+ await this.run(['-s', assertSerial(serial), 'push', local, remote], timeoutMs);
251
+ }
252
+ /** Asks the device to add a file it holds to its media index, so photo pickers list it. */
253
+ async scanMedia(serial, remote) {
254
+ await this.shell(serial, mediaScanArgs(remote), 15_000);
255
+ }
248
256
  /** Drops adb's connection to a device so it reconnects (the fix for one stuck `offline`). */
249
257
  async reconnect(serial) {
250
258
  await this.run(['-s', assertSerial(serial), 'reconnect'], 15_000).catch(() => undefined);
@@ -253,3 +261,17 @@ export class Adb {
253
261
  await this.run(['-s', assertSerial(serial), 'emu', 'kill'], 15_000).catch(() => undefined);
254
262
  }
255
263
  }
264
+ /** The Download folder on the device, where the system file picker finds added files. */
265
+ export const DEVICE_DOWNLOADS = '/sdcard/Download';
266
+ /** Quotes one word for the device's shell. */
267
+ export function deviceShellQuote(text) {
268
+ return `'${text.replace(/'/g, `'\\''`)}'`;
269
+ }
270
+ /**
271
+ * `am broadcast` of MEDIA_SCANNER_SCAN_FILE for a file on the device; the file URI is
272
+ * percent-encoded and quoted, since `adb shell` hands its words to the device's shell.
273
+ */
274
+ export function mediaScanArgs(remote) {
275
+ const uri = `file://${encodeURI(remote)}`;
276
+ return ['am', 'broadcast', '-a', 'android.intent.action.MEDIA_SCANNER_SCAN_FILE', '-d', deviceShellQuote(uri)];
277
+ }
@@ -53,6 +53,21 @@ export function simctlBootstatusArgv(udid) {
53
53
  export function simctlShutdownArgv(udid) {
54
54
  return ['simctl', 'shutdown', assertSimUdid(udid)];
55
55
  }
56
+ /** Adds photos and videos to the simulator's Photos library. */
57
+ export function simctlAddMediaArgv(udid, paths) {
58
+ return ['simctl', 'addmedia', assertSimUdid(udid), ...paths];
59
+ }
60
+ /** The app's data container on the simulator (its `Documents` folder is inside). */
61
+ export function simctlDataContainerArgv(udid, bundleId) {
62
+ return ['simctl', 'get_app_container', assertSimUdid(udid), assertBundleId(bundleId), 'data'];
63
+ }
64
+ /** File types `simctl addmedia` puts into the Photos library. */
65
+ export const SIMULATOR_MEDIA_EXTENSIONS = ['jpg', 'jpeg', 'png', 'heic', 'gif', 'mp4', 'mov', 'm4v'];
66
+ /** Whether a file goes to the Photos library (by its extension). */
67
+ export function isSimulatorMedia(fileName) {
68
+ const ext = fileName.toLowerCase().split('.').pop() ?? '';
69
+ return fileName.includes('.') && SIMULATOR_MEDIA_EXTENSIONS.includes(ext);
70
+ }
56
71
  /** An executable name that is safe inside a log predicate string. */
57
72
  export const PROCESS_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._+-]{0,99}$/;
58
73
  /**
@@ -178,6 +193,18 @@ export class Simctl {
178
193
  async shutdown(udid) {
179
194
  await this.run(simctlShutdownArgv(udid), 60_000).catch(() => undefined);
180
195
  }
196
+ async addMedia(udid, paths) {
197
+ await this.run(simctlAddMediaArgv(udid, paths), 120_000);
198
+ }
199
+ /** The app's data container, or null when the app is not installed on the simulator. */
200
+ async dataContainer(udid, bundleId) {
201
+ try {
202
+ return (await this.run(simctlDataContainerArgv(udid, bundleId), 20_000)).trim() || null;
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ }
181
208
  async executableName(udid, bundleId) {
182
209
  try {
183
210
  return parseAppinfoExecutable(await this.run(simctlAppinfoArgv(udid, bundleId), 20_000));
@@ -0,0 +1,80 @@
1
+ import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
2
+ /** The boxes that follow each other in [from, to); stops at the first that does not fit. */
3
+ function* boxes(read, from, to) {
4
+ let at = from;
5
+ while (at + 8 <= to) {
6
+ const head = read(at, 8);
7
+ if (!head)
8
+ return;
9
+ let size = head.readUInt32BE(0);
10
+ let header = 8;
11
+ if (size === 1) {
12
+ const large = read(at + 8, 8);
13
+ if (!large)
14
+ return;
15
+ size = Number(large.readBigUInt64BE(0));
16
+ header = 16;
17
+ }
18
+ else if (size === 0) {
19
+ size = to - at;
20
+ }
21
+ if (size < header || at + size > to)
22
+ return;
23
+ yield { type: head.toString('latin1', 4, 8), start: at + header, end: at + size };
24
+ at += size;
25
+ }
26
+ }
27
+ /**
28
+ * An MP4's length in seconds, from its movie header (`moov/mvhd`), as players and `ffmpeg -i`
29
+ * report it; null for a file that is not an MP4 (a webm), has no header (a recording cut short) or
30
+ * gives no length.
31
+ */
32
+ export function mp4Seconds(file) {
33
+ let fd;
34
+ try {
35
+ fd = openSync(file, 'r');
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ try {
41
+ const size = fstatSync(fd).size;
42
+ const read = (at, length) => {
43
+ if (at + length > size)
44
+ return null;
45
+ const buffer = Buffer.alloc(length);
46
+ return readSync(fd, buffer, 0, length, at) === length ? buffer : null;
47
+ };
48
+ const top = boxes(read, 0, size);
49
+ const first = top.next();
50
+ if (first.done || first.value.type !== 'ftyp')
51
+ return null;
52
+ for (const box of top) {
53
+ if (box.type !== 'moov')
54
+ continue;
55
+ for (const child of boxes(read, box.start, box.end)) {
56
+ if (child.type !== 'mvhd')
57
+ continue;
58
+ // version 0: 32-bit times, timescale at 12 and duration at 16; version 1: 64-bit, at 20 and 24.
59
+ const header = read(child.start, Math.min(32, child.end - child.start));
60
+ const v1 = header?.[0] === 1;
61
+ if (!header || header.length < (v1 ? 32 : 20))
62
+ return null;
63
+ const timescale = header.readUInt32BE(v1 ? 20 : 12);
64
+ const duration = v1 ? header.readBigUInt64BE(24) : BigInt(header.readUInt32BE(16));
65
+ const unknown = v1 ? 0xffffffffffffffffn : 0xffffffffn;
66
+ if (timescale === 0 || duration === 0n || duration === unknown)
67
+ return null;
68
+ return Number(duration) / timescale;
69
+ }
70
+ return null;
71
+ }
72
+ return null;
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ finally {
78
+ closeSync(fd);
79
+ }
80
+ }
@@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { setTimeout as sleep } from 'node:timers/promises';
6
+ import { ensureFfmpeg } from './ffmpeg.js';
6
7
  /**
7
8
  * H.264 at the capture rate, at most 1920 px wide (a Retina screen is scaled down), both sides even.
8
9
  * The output rate must be set: macOS screen capture reports 1,000,000 fps, and ffmpeg would
@@ -12,6 +13,13 @@ import { setTimeout as sleep } from 'node:timers/promises';
12
13
  function encodeArgv(framerate) {
13
14
  return ['-r', String(framerate), '-vf', "scale='trunc(min(1920,iw)/2)*2':-2", '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '28', '-pix_fmt', 'yuv420p', '-movflags', '+faststart'];
14
15
  }
16
+ /**
17
+ * Progress reports on stdout, key=value lines that end in `progress=…`, ten a second. ffmpeg
18
+ * reports nothing until its output has started, so the first report tells when the first frame
19
+ * was captured (see ScreenRecorder.startedAt); at the default period, 0.5 s, it could come half a
20
+ * second after it.
21
+ */
22
+ const PROGRESS_ARGV = ['-progress', 'pipe:1', '-stats_period', '0.1'];
15
23
  /** An X display name: `:99`, `:0.0`, `host:10.0`. */
16
24
  const X_DISPLAY = /^[A-Za-z0-9.-]*:\d{1,5}(?:\.\d{1,3})?$/;
17
25
  /** The fixed ffmpeg argv that records the main screen (on Linux, the X `display`) into `file` (an .mp4). */
@@ -30,16 +38,32 @@ export function screenRecordArgv(file, os, framerate = 10, display) {
30
38
  throw new Error(`invalid X display ${JSON.stringify(display ?? '')}`);
31
39
  input = ['-f', 'x11grab', '-draw_mouse', '1', '-framerate', String(framerate), '-i', display];
32
40
  }
33
- return ['-hide_banner', '-loglevel', 'error', '-y', ...input, ...encodeArgv(framerate), file];
41
+ return ['-hide_banner', '-loglevel', 'error', '-y', ...PROGRESS_ARGV, ...input, ...encodeArgv(framerate), file];
34
42
  }
35
- /** One ffmpeg screen recording; `stop()` asks ffmpeg to finish (`q`) so the mp4 is complete. */
43
+ /** `argv` for an ffmpeg without `-stats_period` (Ubuntu 20.04 ships 4.2): it reports every 0.5 s. */
44
+ function withoutStatsPeriod(argv) {
45
+ const at = argv.indexOf('-stats_period');
46
+ return at < 0 ? argv : [...argv.slice(0, at), ...argv.slice(at + 2)];
47
+ }
48
+ /** How long start() waits for the first frame: on a busy machine ffmpeg can take seconds to start. */
49
+ export const FIRST_FRAME_TIMEOUT_MS = 10_000;
50
+ /**
51
+ * One ffmpeg screen recording; `stop()` asks ffmpeg to finish (`q`) so the mp4 is complete.
52
+ * `startedAt` and `endedAt` place the video in time, so a step's offset in it counts from its
53
+ * first frame rather than from when ffmpeg was started (see stepVideoOffset).
54
+ */
36
55
  export class ScreenRecorder {
37
56
  ffmpeg;
38
57
  os;
39
58
  options;
40
59
  child = null;
60
+ /** Counts ffmpeg starts; events from an earlier one are ignored. */
61
+ runs = 0;
41
62
  exited = false;
42
63
  stderr = '';
64
+ progress = '';
65
+ firstFrameAt = null;
66
+ endAt = null;
43
67
  dir;
44
68
  file;
45
69
  constructor(ffmpeg, os, options = {}) {
@@ -49,29 +73,69 @@ export class ScreenRecorder {
49
73
  this.dir = mkdtempSync(join(tmpdir(), 'qa-video-'));
50
74
  this.file = join(this.dir, 'video.mp4');
51
75
  }
52
- /** Starts ffmpeg and waits briefly: an immediate exit (no permission, no device) throws. */
53
- async start(settleMs = 1_500) {
76
+ /** When the video's first frame was captured (ms), its time 0; null until ffmpeg reports it (about 0.1 s later). */
77
+ get startedAt() {
78
+ return this.firstFrameAt;
79
+ }
80
+ /** When the video ended (ms): ffmpeg was asked to stop, or exited; null while it records. */
81
+ get endedAt() {
82
+ return this.endAt;
83
+ }
84
+ /**
85
+ * Starts ffmpeg and waits for the first frame, so the video shows what the job does next. An
86
+ * exit before it (no permission, no device) throws. A first frame later than `timeoutMs` does
87
+ * not: the job goes on, and the video starts when the frame comes (startedAt).
88
+ */
89
+ async start(timeoutMs = FIRST_FRAME_TIMEOUT_MS) {
54
90
  const argv = screenRecordArgv(this.file, this.os, 10, this.options.display);
55
- const child = (this.options.spawn ?? spawn)(this.ffmpeg, argv, { stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true });
91
+ await this.run(argv, timeoutMs);
92
+ if (this.stoppedAtOnce() && this.stderr.includes('stats_period'))
93
+ await this.run(withoutStatsPeriod(argv), timeoutMs);
94
+ if (this.stoppedAtOnce()) {
95
+ this.cleanup();
96
+ throw new Error(`ffmpeg stopped recording at once: ${this.stderr.trim().split('\n').pop() ?? 'no output'}`);
97
+ }
98
+ }
99
+ stoppedAtOnce() {
100
+ return this.exited && this.firstFrameAt === null;
101
+ }
102
+ /** Starts ffmpeg with `argv` and waits for its first frame, its exit or `timeoutMs`. */
103
+ async run(argv, timeoutMs) {
104
+ const run = ++this.runs;
105
+ const current = () => run === this.runs;
106
+ this.exited = false;
107
+ this.endAt = null;
108
+ this.stderr = '';
109
+ this.progress = '';
110
+ const child = (this.options.spawn ?? spawn)(this.ffmpeg, argv, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
56
111
  this.child = child;
112
+ // Read to the end: ffmpeg must never block on a full pipe.
113
+ child.stdout?.on('data', (chunk) => {
114
+ if (!current() || this.firstFrameAt !== null)
115
+ return;
116
+ this.progress = `${this.progress}${chunk.toString('utf8')}`.slice(-2_000);
117
+ if (/^progress=continue/m.test(this.progress))
118
+ this.firstFrameAt = Date.now();
119
+ });
57
120
  child.stderr?.on('data', (chunk) => {
58
- this.stderr = `${this.stderr}${chunk.toString('utf8')}`.slice(-2_000);
121
+ if (current())
122
+ this.stderr = `${this.stderr}${chunk.toString('utf8')}`.slice(-2_000);
59
123
  });
60
124
  child.once('exit', () => {
125
+ if (!current())
126
+ return;
61
127
  this.exited = true;
128
+ this.endAt ??= Date.now();
62
129
  });
63
130
  child.once('error', (error) => {
131
+ if (!current())
132
+ return;
64
133
  this.exited = true;
65
134
  this.stderr += error.message;
66
135
  });
67
- const deadline = Date.now() + settleMs;
68
- while (Date.now() < deadline) {
69
- if (this.exited) {
70
- this.cleanup();
71
- throw new Error(`ffmpeg stopped recording at once: ${this.stderr.trim().split('\n').pop() ?? 'no output'}`);
72
- }
73
- await sleep(100);
74
- }
136
+ const deadline = Date.now() + timeoutMs;
137
+ while (this.firstFrameAt === null && !this.exited && Date.now() < deadline)
138
+ await sleep(50);
75
139
  }
76
140
  /** Stops the recording; returns the mp4 path, or undefined when nothing usable was written. */
77
141
  async stop(timeoutMs = 15_000) {
@@ -79,6 +143,8 @@ export class ScreenRecorder {
79
143
  this.child = null;
80
144
  if (!child)
81
145
  return undefined;
146
+ // The video ends about here: ffmpeg records on until it reads the `q`.
147
+ this.endAt ??= Date.now();
82
148
  if (!this.exited) {
83
149
  const done = new Promise((resolve) => child.once('exit', () => resolve()));
84
150
  try {
@@ -107,3 +173,10 @@ export class ScreenRecorder {
107
173
  rmSync(this.dir, { recursive: true, force: true });
108
174
  }
109
175
  }
176
+ /** ScreenRecorder with the ffmpeg on PATH or in the runner home (downloaded now when missing). */
177
+ export function ffmpegRecorder(paths, log) {
178
+ return async (os, display) => {
179
+ const ffmpeg = await ensureFfmpeg({ paths, log });
180
+ return ffmpeg ? new ScreenRecorder(ffmpeg, os, display ? { display } : {}) : null;
181
+ };
182
+ }
@@ -6,14 +6,14 @@ import { AppiumServer, AppiumUnavailable, bundledAppium, MJPEG_PORTS, SYSTEM_POR
6
6
  import { bundledDrivers } from '../appium/drivers.js';
7
7
  import { AppNotAvailable, resolveAppFile } from '../builds/install.js';
8
8
  import { androidInstallable } from '../builds/unpack.js';
9
- import { Adb, resolveAndroidSdk } from '../devices/android/adb.js';
9
+ import { Adb, DEVICE_DOWNLOADS, resolveAndroidSdk } from '../devices/android/adb.js';
10
10
  import { waitForDeviceBack } from '../devices/android/emulator.js';
11
11
  import { leaseAndroidDevice, listAndroidDevices } from '../devices/android/index.js';
12
12
  import { LogcatSink } from '../devices/android/logcat.js';
13
13
  import { dismissSystemErrorDialog } from '../devices/android/system-dialog.js';
14
14
  import { ensureFfmpeg, managedFfmpegDir } from '../devices/ffmpeg.js';
15
15
  import { homePaths, readJsonFile } from '../home.js';
16
- import { renderTemplate } from '../template.js';
16
+ import { renderTemplate, resolveFileEntries } from '../template.js';
17
17
  import { Blocked, LaunchRefused, NotFound, } from './driver.js';
18
18
  import { emptyScreen } from './snapshot/format.js';
19
19
  import { ANDROID_ACTIONS, androidKeyCode, runAndroidAction } from './webdriver/actions.js';
@@ -300,6 +300,10 @@ export class AndroidAppiumDriver {
300
300
  return { ...rest, fallbacks: [...(rest.fallbacks ?? []), { xpath }] };
301
301
  }
302
302
  async run(action) {
303
+ if (action.type === 'add_files') {
304
+ await this.addFiles(action);
305
+ return {};
306
+ }
303
307
  const prepared = action.target ? { ...action, target: AndroidAppiumDriver.resolveRefTarget(action.target) } : action;
304
308
  if (this.dialogCheckPending) {
305
309
  this.dialogCheckPending = false;
@@ -321,6 +325,29 @@ export class AndroidAppiumDriver {
321
325
  throw this.translate(error);
322
326
  }
323
327
  }
328
+ /**
329
+ * `add_files`: copies the files into the device's Download folder, where the system file picker
330
+ * lists them, and asks the device to index them for photo pickers. The script then taps through
331
+ * the app's picker.
332
+ */
333
+ async addFiles(action) {
334
+ const files = resolveFileEntries(action.files, this.ctx.template);
335
+ const adb = this.adb;
336
+ const serial = this.lease?.serial;
337
+ if (!adb || !serial)
338
+ throw new Blocked('the device is not connected');
339
+ for (const file of files) {
340
+ const remote = `${DEVICE_DOWNLOADS}/${file.file.file_name}`;
341
+ try {
342
+ await adb.push(serial, file.path, remote);
343
+ }
344
+ catch (error) {
345
+ throw new Blocked(`test file ${file.name} could not be copied to ${serial}: ${webdriverMessage(error)}`);
346
+ }
347
+ // The media index is best effort: file pickers read the folder itself.
348
+ await adb.scanMedia(serial, remote).catch(() => undefined);
349
+ }
350
+ }
324
351
  /**
325
352
  * Answers a system "isn't responding" / "keeps stopping" dialog that is not about the app under
326
353
  * test (Wait, or Close app) and notes it in the step's device log. Dialogs about the app under
@@ -100,3 +100,26 @@ export function firstLine(error) {
100
100
  export function clip(text) {
101
101
  return text.length > 120 ? `${text.slice(0, 117)}...` : text;
102
102
  }
103
+ /**
104
+ * Where a step starts in a recording, in seconds, or undefined when the recording does not show
105
+ * it: the step ended before the recording's first frame, or began after the recording ended. A
106
+ * step already running when the recording started (in Electron, the one that relaunched the app)
107
+ * starts at 0.
108
+ */
109
+ export function stepVideoOffset(recording, begun, ended) {
110
+ // No frame yet: it shows no step (an Electron window that never painted records only a closing frame).
111
+ if (recording.startedAt === null)
112
+ return undefined;
113
+ if (recording.endedAt !== null && begun >= recording.endedAt)
114
+ return undefined;
115
+ if (ended !== null && ended <= recording.startedAt)
116
+ return undefined;
117
+ return Math.max(0, Math.round((begun - recording.startedAt) / 100) / 10);
118
+ }
119
+ /** Where step `index` starts in `recording` (see stepVideoOffset): it ran until the next step began. */
120
+ export function stepStartOffset(recording, starts, index) {
121
+ const i = starts.findLastIndex((step) => step.index === index);
122
+ if (i < 0)
123
+ return undefined;
124
+ return stepVideoOffset(recording, starts[i].at, starts[i + 1]?.at ?? null);
125
+ }
@@ -2,12 +2,11 @@ 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 { ensureFfmpeg } from '../devices/ffmpeg.js';
6
- import { ScreenRecorder } from '../devices/screen-record.js';
5
+ import { ffmpegRecorder } from '../devices/screen-record.js';
7
6
  import { homePaths } from '../home.js';
8
- import { renderTemplate } from '../template.js';
7
+ import { renderTemplate, resolveFileEntries } from '../template.js';
9
8
  import { APPIUM_LOG_MAX_BYTES, appiumLogLines, LOCAL_PATHS_REFUSED } from './android-appium.js';
10
- import { errorMessage, renderLaunchArgs } from './common.js';
9
+ import { errorMessage, renderLaunchArgs, stepStartOffset } from './common.js';
11
10
  import { Blocked, LaunchRefused, NotFound, Unsupported } from './driver.js';
12
11
  import { runNativeAction } from './webdriver/native-actions.js';
13
12
  import { nativeFindAll, resolveNativeRef } from './webdriver/native-locate.js';
@@ -62,6 +61,7 @@ export function validateNativeAction(platform, action) {
62
61
  export class DesktopAppiumDriver {
63
62
  ctx;
64
63
  appium;
64
+ makeRecorder;
65
65
  client = null;
66
66
  unpacked = null;
67
67
  releaseBuild = null;
@@ -71,12 +71,15 @@ export class DesktopAppiumDriver {
71
71
  firstLaunchDone = false;
72
72
  actionsRun = 0;
73
73
  lastRefs = {};
74
- startedAt = Date.now();
75
- offsets = new Map();
74
+ /** When each step began (ms), in the order they ran; offsets count from the video's first frame. */
75
+ stepStarts = [];
76
76
  appiumLogOffsets = new Map();
77
- constructor(ctx, appium) {
77
+ constructor(ctx, appium,
78
+ /** A whole-screen recorder, or null when there is no ffmpeg (default: ffmpegRecorder). */
79
+ makeRecorder) {
78
80
  this.ctx = ctx;
79
81
  this.appium = appium;
82
+ this.makeRecorder = makeRecorder;
80
83
  }
81
84
  get paths() {
82
85
  return this.ctx.paths ?? homePaths(process.env);
@@ -153,14 +156,11 @@ export class DesktopAppiumDriver {
153
156
  }
154
157
  /** Starts a whole-screen recording when the job wants video (ffmpeg is downloaded when missing). */
155
158
  async startRecording() {
156
- this.startedAt = Date.now();
157
159
  if (this.ctx.video === 'off')
158
160
  return;
159
- const ffmpeg = await ensureFfmpeg({ ...(this.ctx.paths ? { paths: this.ctx.paths } : {}), log: this.ctx.log });
160
- if (!ffmpeg)
161
+ const recorder = await (this.makeRecorder ?? ffmpegRecorder(this.paths, this.ctx.log))(this.platform, undefined);
162
+ if (!recorder)
161
163
  return;
162
- this.startedAt = Date.now();
163
- const recorder = new ScreenRecorder(ffmpeg, this.platform);
164
164
  try {
165
165
  await recorder.start();
166
166
  this.recorder = recorder;
@@ -182,6 +182,23 @@ export class DesktopAppiumDriver {
182
182
  const prepared = action.target ? { ...action, target: resolveNativeRef(action.target) } : action;
183
183
  return runNativeAction(this.actionContext(), prepared);
184
184
  }
185
+ /**
186
+ * `upload {target, files}`: clicks the target, which must open the system Open dialog, and picks
187
+ * the files there. An Open dialog picks from one folder, so the files are first gathered into a
188
+ * fresh folder of their own; the platform then points the dialog at it.
189
+ */
190
+ async upload(action) {
191
+ const files = resolveFileEntries(action.files, this.ctx.template);
192
+ const store = this.ctx.files;
193
+ if (!store)
194
+ throw new Blocked('the runner prepared no test files for this job');
195
+ const folder = store.folderWith(files.map((file) => file.path));
196
+ const timeout = action.timeout_ms ?? this.ctx.timeouts.actionMs;
197
+ if (!action.target)
198
+ throw new Error("'upload' needs a target");
199
+ await this.runShared({ type: 'click', target: action.target, timeout_ms: timeout });
200
+ await this.pickInOpenDialog(folder, files.map((file) => file.file.file_name), timeout);
201
+ }
185
202
  /** Whether this action is the leading `launch` whose args the first start already used. */
186
203
  skipFirstLaunch(action) {
187
204
  const first = this.actionsRun === 0;
@@ -241,13 +258,13 @@ export class DesktopAppiumDriver {
241
258
  throw new Unsupported(`'home' is not available on ${this.platform}`);
242
259
  }
243
260
  beginStep(index) {
244
- this.offsets.set(index, Math.round((Date.now() - this.startedAt) / 100) / 10);
261
+ this.stepStarts.push({ index, at: Date.now() });
245
262
  this.appiumLogOffsets.set(index, this.appium.logOffset());
246
263
  }
247
264
  debug(index, result) {
248
- const offset = this.offsets.get(index);
265
+ const offset = this.recorder ? stepStartOffset(this.recorder, this.stepStarts, index) : undefined;
249
266
  // 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 } : {};
267
+ const debug = offset !== undefined ? { video_offset_secs: offset } : {};
251
268
  const start = this.appiumLogOffsets.get(index);
252
269
  if ((result === 'failed' || result === 'blocked') && start !== undefined) {
253
270
  const later = [...this.appiumLogOffsets.entries()].filter(([i]) => i > index).map(([, at]) => at);