@leera.io/qa-runner 1.1.0 → 1.4.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.
Files changed (39) hide show
  1. package/README.md +2 -1
  2. package/dist/src/builds/download.js +8 -7
  3. package/dist/src/capabilities.js +13 -8
  4. package/dist/src/cli/commands/setup.js +41 -2
  5. package/dist/src/client.js +4 -0
  6. package/dist/src/devices/android/adb.js +22 -0
  7. package/dist/src/devices/ffmpeg.js +135 -0
  8. package/dist/src/devices/ios/record.js +8 -7
  9. package/dist/src/devices/ios/simctl.js +27 -0
  10. package/dist/src/devices/macos.js +8 -9
  11. package/dist/src/devices/mp4.js +80 -0
  12. package/dist/src/devices/screen-record.js +116 -29
  13. package/dist/src/devices/tauri.js +11 -0
  14. package/dist/src/devices/windows.js +6 -7
  15. package/dist/src/drivers/android-appium.js +41 -5
  16. package/dist/src/drivers/common.js +23 -0
  17. package/dist/src/drivers/desktop-appium.js +33 -14
  18. package/dist/src/drivers/electron-playwright.js +89 -21
  19. package/dist/src/drivers/ios-appium.js +44 -4
  20. package/dist/src/drivers/mac2-appium.js +28 -2
  21. package/dist/src/drivers/tauri-webdriver.js +237 -67
  22. package/dist/src/drivers/web-playwright.js +100 -1
  23. package/dist/src/drivers/webdriver/actions.js +1 -0
  24. package/dist/src/drivers/webdriver/desktop-keys.js +27 -0
  25. package/dist/src/drivers/webdriver/dom-element.js +47 -0
  26. package/dist/src/drivers/webdriver/dom-locate.js +52 -7
  27. package/dist/src/drivers/webdriver/dom-snapshot.js +8 -5
  28. package/dist/src/drivers/webdriver/dom-tree.js +7 -15
  29. package/dist/src/drivers/webdriver/dom.js +40 -27
  30. package/dist/src/drivers/webdriver/ios-actions.js +1 -0
  31. package/dist/src/drivers/webdriver/native-actions.js +1 -0
  32. package/dist/src/drivers/windows-appium.js +38 -3
  33. package/dist/src/executor.js +23 -1
  34. package/dist/src/files/store.js +181 -0
  35. package/dist/src/home.js +1 -0
  36. package/dist/src/runner.js +15 -4
  37. package/dist/src/session/loop.js +2 -2
  38. package/dist/src/template.js +48 -0
  39. package/package.json +2 -2
@@ -3,66 +3,139 @@ 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 { findOnPath } from './desktop.js';
6
+ import { ensureFfmpeg } from './ffmpeg.js';
7
7
  /**
8
- * Whole-screen video for native desktop jobs (contracts §M): only when ffmpeg is installed —
9
- * `avfoundation` on macOS (needs Screen Recording permission for the runner), `gdigrab` on
10
- * Windows. Without ffmpeg jobs record no video.
8
+ * H.264 at the capture rate, at most 1920 px wide (a Retina screen is scaled down), both sides even.
9
+ * The output rate must be set: macOS screen capture reports 1,000,000 fps, and ffmpeg would
10
+ * duplicate frames to match, growing the file by megabytes a second and never getting to `q`.
11
+ * veryfast at CRF 28 keeps a mostly still screen near 1 MB a minute for about the CPU of ultrafast.
11
12
  */
12
- export function findFfmpeg(env = process.env, platform = process.platform, exists) {
13
- return findOnPath(platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg', env, exists);
13
+ function encodeArgv(framerate) {
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
  }
15
- const ENCODE = ['-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2', '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-movflags', '+faststart'];
16
- /** The fixed ffmpeg argv that records the main screen into `file` (an .mp4). */
17
- export function screenRecordArgv(file, os, framerate = 10) {
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'];
23
+ /** An X display name: `:99`, `:0.0`, `host:10.0`. */
24
+ const X_DISPLAY = /^[A-Za-z0-9.-]*:\d{1,5}(?:\.\d{1,3})?$/;
25
+ /** The fixed ffmpeg argv that records the main screen (on Linux, the X `display`) into `file` (an .mp4). */
26
+ export function screenRecordArgv(file, os, framerate = 10, display) {
18
27
  if (!file.endsWith('.mp4'))
19
28
  throw new Error('the recording file must end in .mp4');
20
29
  if (!Number.isInteger(framerate) || framerate < 1 || framerate > 30)
21
30
  throw new Error(`invalid frame rate ${framerate}`);
22
- const input = os === 'macos'
23
- ? ['-f', 'avfoundation', '-capture_cursor', '1', '-framerate', String(framerate), '-i', 'Capture screen 0:none']
24
- : ['-f', 'gdigrab', '-draw_mouse', '1', '-framerate', String(framerate), '-i', 'desktop'];
25
- return ['-hide_banner', '-loglevel', 'error', '-y', ...input, ...ENCODE, file];
31
+ let input;
32
+ if (os === 'macos')
33
+ input = ['-f', 'avfoundation', '-capture_cursor', '1', '-framerate', String(framerate), '-i', 'Capture screen 0:none'];
34
+ else if (os === 'windows')
35
+ input = ['-f', 'gdigrab', '-draw_mouse', '1', '-framerate', String(framerate), '-i', 'desktop'];
36
+ else {
37
+ if (!display || !X_DISPLAY.test(display))
38
+ throw new Error(`invalid X display ${JSON.stringify(display ?? '')}`);
39
+ input = ['-f', 'x11grab', '-draw_mouse', '1', '-framerate', String(framerate), '-i', display];
40
+ }
41
+ return ['-hide_banner', '-loglevel', 'error', '-y', ...PROGRESS_ARGV, ...input, ...encodeArgv(framerate), file];
26
42
  }
27
- /** 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
+ */
28
55
  export class ScreenRecorder {
29
56
  ffmpeg;
30
57
  os;
31
- spawnImpl;
58
+ options;
32
59
  child = null;
60
+ /** Counts ffmpeg starts; events from an earlier one are ignored. */
61
+ runs = 0;
33
62
  exited = false;
34
63
  stderr = '';
64
+ progress = '';
65
+ firstFrameAt = null;
66
+ endAt = null;
35
67
  dir;
36
68
  file;
37
- constructor(ffmpeg, os, spawnImpl = spawn) {
69
+ constructor(ffmpeg, os, options = {}) {
38
70
  this.ffmpeg = ffmpeg;
39
71
  this.os = os;
40
- this.spawnImpl = spawnImpl;
72
+ this.options = options;
41
73
  this.dir = mkdtempSync(join(tmpdir(), 'qa-video-'));
42
74
  this.file = join(this.dir, 'video.mp4');
43
75
  }
44
- /** Starts ffmpeg and waits briefly: an immediate exit (no permission, no device) throws. */
45
- async start(settleMs = 1_500) {
46
- const child = this.spawnImpl(this.ffmpeg, screenRecordArgv(this.file, this.os), { stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true });
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) {
90
+ const argv = screenRecordArgv(this.file, this.os, 10, this.options.display);
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 });
47
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
+ });
48
120
  child.stderr?.on('data', (chunk) => {
49
- this.stderr = `${this.stderr}${chunk.toString('utf8')}`.slice(-2_000);
121
+ if (current())
122
+ this.stderr = `${this.stderr}${chunk.toString('utf8')}`.slice(-2_000);
50
123
  });
51
124
  child.once('exit', () => {
125
+ if (!current())
126
+ return;
52
127
  this.exited = true;
128
+ this.endAt ??= Date.now();
53
129
  });
54
130
  child.once('error', (error) => {
131
+ if (!current())
132
+ return;
55
133
  this.exited = true;
56
134
  this.stderr += error.message;
57
135
  });
58
- const deadline = Date.now() + settleMs;
59
- while (Date.now() < deadline) {
60
- if (this.exited) {
61
- this.cleanup();
62
- throw new Error(`ffmpeg stopped recording at once: ${this.stderr.trim().split('\n').pop() ?? 'no output'}`);
63
- }
64
- await sleep(100);
65
- }
136
+ const deadline = Date.now() + timeoutMs;
137
+ while (this.firstFrameAt === null && !this.exited && Date.now() < deadline)
138
+ await sleep(50);
66
139
  }
67
140
  /** Stops the recording; returns the mp4 path, or undefined when nothing usable was written. */
68
141
  async stop(timeoutMs = 15_000) {
@@ -70,6 +143,8 @@ export class ScreenRecorder {
70
143
  this.child = null;
71
144
  if (!child)
72
145
  return undefined;
146
+ // The video ends about here: ffmpeg records on until it reads the `q`.
147
+ this.endAt ??= Date.now();
73
148
  if (!this.exited) {
74
149
  const done = new Promise((resolve) => child.once('exit', () => resolve()));
75
150
  try {
@@ -84,6 +159,11 @@ export class ScreenRecorder {
84
159
  child.kill();
85
160
  await Promise.race([done, sleep(2_000)]);
86
161
  }
162
+ // An ffmpeg stuck in a capture call can ignore SIGTERM; never leave it running.
163
+ if (!this.exited) {
164
+ child.kill('SIGKILL');
165
+ await Promise.race([done, sleep(2_000)]);
166
+ }
87
167
  }
88
168
  if (existsSync(this.file) && statSync(this.file).size > 0)
89
169
  return this.file;
@@ -93,3 +173,10 @@ export class ScreenRecorder {
93
173
  rmSync(this.dir, { recursive: true, force: true });
94
174
  }
95
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
+ }
@@ -3,6 +3,7 @@ import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { readJsonFile } from '../home.js';
5
5
  import { displayNeed, findOnPath, NO_DISPLAY, XVFB_DISPLAY } from './desktop.js';
6
+ import { ffmpegCheckDetail } from './ffmpeg.js';
6
7
  /**
7
8
  * Where Tauri jobs run (contracts §L): `tauri-driver` 2.0.6 with the platform's native
8
9
  * WebDriver (WebKitWebDriver on Linux, msedgedriver on Windows), or on macOS an app build that
@@ -151,6 +152,8 @@ export function tauriChecks(settings, options = {}) {
151
152
  detail: 'macOS has no tauri-driver; this Mac runs Tauri jobs only for builds that embed the WebDriver plugin',
152
153
  fix: 'leera-qa-runner config set tauri.macos_plugin true (only when your builds embed the plugin)',
153
154
  });
155
+ if (settings.macosPlugin)
156
+ checks.push(videoCheck(env, platform, ' (needs Screen Recording permission)'));
154
157
  return checks;
155
158
  }
156
159
  if (platform !== 'linux' && platform !== 'win32') {
@@ -185,5 +188,13 @@ export function tauriChecks(settings, options = {}) {
185
188
  fix: `download the msedgedriver matching your WebView2 Runtime from ${MSEDGEDRIVER_URL}, then leera-qa-runner config set tauri.native_driver_path C:\\path\\to\\msedgedriver.exe`,
186
189
  });
187
190
  }
191
+ checks.push(videoCheck(env, platform));
188
192
  return checks;
189
193
  }
194
+ /** Tauri video: ffmpeg on PATH or downloaded, or downloadable on the first job with video. */
195
+ function videoCheck(env, platform, note = '') {
196
+ const video = ffmpegCheckDetail(env, platform);
197
+ return video.ok
198
+ ? { id: 'tauri.video', title: 'Tauri: video', status: 'ok', detail: `${video.detail}${note}` }
199
+ : { id: 'tauri.video', title: 'Tauri: video', status: 'warn', detail: video.detail, fix: 'install ffmpeg on the PATH' };
200
+ }
@@ -12,7 +12,7 @@ import { ExitCode, messageOf } from '../errors.js';
12
12
  import { ensureHome, readJsonFile } from '../home.js';
13
13
  import { runTool } from './android/adb.js';
14
14
  import { findOnPath } from './desktop.js';
15
- import { findFfmpeg } from './screen-record.js';
15
+ import { ffmpegCheckDetail } from './ffmpeg.js';
16
16
  /**
17
17
  * Native Windows apps (contracts §M): Appium's Windows driver in front of WinAppDriver 1.2.1,
18
18
  * which the runner starts itself on a free loopback port. WinAppDriver needs Developer Mode and
@@ -255,10 +255,10 @@ export async function windowsChecks(env, paths, platform = process.platform) {
255
255
  checks.push((await developerModeOn())
256
256
  ? { id: 'windows.developer_mode', title: 'Windows apps: Developer Mode', status: 'ok', detail: 'on' }
257
257
  : { id: 'windows.developer_mode', title: 'Windows apps: Developer Mode', status: 'fail', detail: 'off (WinAppDriver needs it)', fix: DEVELOPER_MODE_STEPS });
258
- const ffmpeg = findFfmpeg(env, platform);
259
- checks.push(ffmpeg
260
- ? { id: 'windows.video', title: 'Windows apps: video', status: 'ok', detail: `ffmpeg ${ffmpeg}` }
261
- : { id: 'windows.video', title: 'Windows apps: video', status: 'warn', detail: 'ffmpeg is not installed; jobs record no video', fix: 'winget install ffmpeg' });
258
+ const video = ffmpegCheckDetail(env, platform);
259
+ checks.push(video.ok
260
+ ? { id: 'windows.video', title: 'Windows apps: video', status: 'ok', detail: video.detail }
261
+ : { id: 'windows.video', title: 'Windows apps: video', status: 'warn', detail: video.detail, fix: 'winget install ffmpeg' });
262
262
  return checks;
263
263
  }
264
264
  export async function sha256File(path) {
@@ -355,8 +355,7 @@ export async function setupWindows(io, paths, options = {}, deps = {}) {
355
355
  io.stdout('Developer Mode: on');
356
356
  }
357
357
  io.stdout('Windows app jobs need an interactive desktop: run the runner in a logged-in session (not as a Windows service without a desktop) and keep the screen unlocked.');
358
- if (!findFfmpeg(io.env, io.platform))
359
- io.stdout('ffmpeg is not installed: Windows app jobs record no video.');
358
+ io.stdout(`Video: ${ffmpegCheckDetail(io.env, io.platform).detail}.`);
360
359
  io.stdout(ok ? 'Windows apps are set up. Check with `leera-qa-runner doctor --platform windows`.' : 'Windows setup is incomplete; see the messages above.');
361
360
  return ok ? ExitCode.ok : ExitCode.environmentNotReady;
362
361
  }
@@ -1,18 +1,19 @@
1
1
  import { mkdtempSync, writeFileSync } from 'node:fs';
2
2
  import { tmpdir } from 'node:os';
3
- import { join } from 'node:path';
3
+ import { delimiter, join } from 'node:path';
4
4
  import { appiumHomeDir } from '../appium/home.js';
5
5
  import { AppiumServer, AppiumUnavailable, bundledAppium, MJPEG_PORTS, SYSTEM_PORTS } from '../appium/server.js';
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
+ import { ensureFfmpeg, managedFfmpegDir } from '../devices/ffmpeg.js';
14
15
  import { homePaths, readJsonFile } from '../home.js';
15
- import { renderTemplate } from '../template.js';
16
+ import { renderTemplate, resolveFileEntries } from '../template.js';
16
17
  import { Blocked, LaunchRefused, NotFound, } from './driver.js';
17
18
  import { emptyScreen } from './snapshot/format.js';
18
19
  import { ANDROID_ACTIONS, androidKeyCode, runAndroidAction } from './webdriver/actions.js';
@@ -75,12 +76,18 @@ export function androidSdkFor(env, configured) {
75
76
  return resolveAndroidSdk({ env, ...(configured ? { configured } : {}) });
76
77
  }
77
78
  /** Environment for Appium and its driver: the SDK they need, whatever the service started with. */
78
- export function appiumEnv(env, sdk) {
79
+ export function appiumEnv(env, sdk, ffmpegDir) {
79
80
  const out = { ...env };
80
81
  if (sdk?.root) {
81
82
  out.ANDROID_HOME = sdk.root;
82
83
  out.ANDROID_SDK_ROOT = sdk.root;
83
84
  }
85
+ // Appium finds ffmpeg on PATH (joining Android recordings, real iPhones): after one already
86
+ // there, the one the runner downloads. (Windows spells the key Path.)
87
+ if (ffmpegDir) {
88
+ const key = Object.keys(out).find((name) => name.toUpperCase() === 'PATH') ?? 'PATH';
89
+ out[key] = out[key] ? `${out[key]}${delimiter}${ffmpegDir}` : ffmpegDir;
90
+ }
84
91
  return out;
85
92
  }
86
93
  let processAppium = null;
@@ -89,7 +96,7 @@ export function sharedAppiumServer(paths, sdk) {
89
96
  processAppium ??= new AppiumServer({
90
97
  home: appiumHomeDir(paths),
91
98
  logFile: join(paths.logs, 'appium.log'),
92
- env: appiumEnv(process.env, sdk),
99
+ env: appiumEnv(process.env, sdk, managedFfmpegDir(paths)),
93
100
  });
94
101
  return processAppium;
95
102
  }
@@ -245,6 +252,8 @@ export class AndroidAppiumDriver {
245
252
  this.logcat = new LogcatSink(this.adb, serial, app.id, this.ctx.redact);
246
253
  this.logcat.start();
247
254
  if (this.ctx.video !== 'off') {
255
+ // Appium records in 3-minute parts and joins them with ffmpeg: fetch it when missing.
256
+ await ensureFfmpeg({ paths: this.paths, log: this.ctx.log });
248
257
  try {
249
258
  await this.client.startRecordingScreen({
250
259
  timeLimit: RECORDING_LIMIT_SECS,
@@ -291,6 +300,10 @@ export class AndroidAppiumDriver {
291
300
  return { ...rest, fallbacks: [...(rest.fallbacks ?? []), { xpath }] };
292
301
  }
293
302
  async run(action) {
303
+ if (action.type === 'add_files') {
304
+ await this.addFiles(action);
305
+ return {};
306
+ }
294
307
  const prepared = action.target ? { ...action, target: AndroidAppiumDriver.resolveRefTarget(action.target) } : action;
295
308
  if (this.dialogCheckPending) {
296
309
  this.dialogCheckPending = false;
@@ -312,6 +325,29 @@ export class AndroidAppiumDriver {
312
325
  throw this.translate(error);
313
326
  }
314
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
+ }
315
351
  /**
316
352
  * Answers a system "isn't responding" / "keeps stopping" dialog that is not about the app under
317
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,11 +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 { findFfmpeg, ScreenRecorder } from '../devices/screen-record.js';
5
+ import { ffmpegRecorder } from '../devices/screen-record.js';
6
6
  import { homePaths } from '../home.js';
7
- import { renderTemplate } from '../template.js';
7
+ import { renderTemplate, resolveFileEntries } from '../template.js';
8
8
  import { APPIUM_LOG_MAX_BYTES, appiumLogLines, LOCAL_PATHS_REFUSED } from './android-appium.js';
9
- import { errorMessage, renderLaunchArgs } from './common.js';
9
+ import { errorMessage, renderLaunchArgs, stepStartOffset } 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';
@@ -61,6 +61,7 @@ export function validateNativeAction(platform, action) {
61
61
  export class DesktopAppiumDriver {
62
62
  ctx;
63
63
  appium;
64
+ makeRecorder;
64
65
  client = null;
65
66
  unpacked = null;
66
67
  releaseBuild = null;
@@ -70,12 +71,15 @@ export class DesktopAppiumDriver {
70
71
  firstLaunchDone = false;
71
72
  actionsRun = 0;
72
73
  lastRefs = {};
73
- startedAt = Date.now();
74
- offsets = new Map();
74
+ /** When each step began (ms), in the order they ran; offsets count from the video's first frame. */
75
+ stepStarts = [];
75
76
  appiumLogOffsets = new Map();
76
- constructor(ctx, appium) {
77
+ constructor(ctx, appium,
78
+ /** A whole-screen recorder, or null when there is no ffmpeg (default: ffmpegRecorder). */
79
+ makeRecorder) {
77
80
  this.ctx = ctx;
78
81
  this.appium = appium;
82
+ this.makeRecorder = makeRecorder;
79
83
  }
80
84
  get paths() {
81
85
  return this.ctx.paths ?? homePaths(process.env);
@@ -150,15 +154,13 @@ export class DesktopAppiumDriver {
150
154
  this.firstLaunchDone = true;
151
155
  return renderLaunchArgs(first.args, this.render);
152
156
  }
153
- /** Starts a whole-screen recording when the job wants video and ffmpeg is installed. */
157
+ /** Starts a whole-screen recording when the job wants video (ffmpeg is downloaded when missing). */
154
158
  async startRecording() {
155
- this.startedAt = Date.now();
156
159
  if (this.ctx.video === 'off')
157
160
  return;
158
- const ffmpeg = findFfmpeg();
159
- if (!ffmpeg)
161
+ const recorder = await (this.makeRecorder ?? ffmpegRecorder(this.paths, this.ctx.log))(this.platform, undefined);
162
+ if (!recorder)
160
163
  return;
161
- const recorder = new ScreenRecorder(ffmpeg, this.platform);
162
164
  try {
163
165
  await recorder.start();
164
166
  this.recorder = recorder;
@@ -180,6 +182,23 @@ export class DesktopAppiumDriver {
180
182
  const prepared = action.target ? { ...action, target: resolveNativeRef(action.target) } : action;
181
183
  return runNativeAction(this.actionContext(), prepared);
182
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
+ }
183
202
  /** Whether this action is the leading `launch` whose args the first start already used. */
184
203
  skipFirstLaunch(action) {
185
204
  const first = this.actionsRun === 0;
@@ -239,13 +258,13 @@ export class DesktopAppiumDriver {
239
258
  throw new Unsupported(`'home' is not available on ${this.platform}`);
240
259
  }
241
260
  beginStep(index) {
242
- this.offsets.set(index, Math.round((Date.now() - this.startedAt) / 100) / 10);
261
+ this.stepStarts.push({ index, at: Date.now() });
243
262
  this.appiumLogOffsets.set(index, this.appium.logOffset());
244
263
  }
245
264
  debug(index, result) {
246
- const offset = this.offsets.get(index);
265
+ const offset = this.recorder ? stepStartOffset(this.recorder, this.stepStarts, index) : undefined;
247
266
  // 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 } : {};
267
+ const debug = offset !== undefined ? { video_offset_secs: offset } : {};
249
268
  const start = this.appiumLogOffsets.get(index);
250
269
  if ((result === 'failed' || result === 'blocked') && start !== undefined) {
251
270
  const later = [...this.appiumLogOffsets.entries()].filter(([i]) => i > index).map(([, at]) => at);
@@ -13,10 +13,11 @@ 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, renderLaunchArgs } from './common.js';
16
+ import { clip, errorMessage, expectWithin, firstLine, matches, render, renderLaunchArgs, stepStartOffset } from './common.js';
17
17
  import { Blocked, LaunchRefused, Unsupported, } from './driver.js';
18
18
  import { webSnapshot } from './snapshot/aria.js';
19
- import { findOnPage, NO_FFMPEG_VIDEO, playwrightFfmpeg, runWebAction, WEB_ACTIONS } from './web-playwright.js';
19
+ import { resolveFileEntries } from '../template.js';
20
+ import { fileInputOf, findOnPage, locateAction, NO_FFMPEG_VIDEO, playwrightFfmpeg, runWebAction, uploadFiles, WEB_ACTIONS, } from './web-playwright.js';
20
21
  /** Electron DSL (contracts §K): web actions minus `goto`, plus launch, terminate and focus_window. */
21
22
  export const ELECTRON_ACTIONS = [...WEB_ACTIONS.filter((action) => action !== 'goto'), 'launch', 'terminate', 'focus_window'];
22
23
  export const ELECTRON_TARGETS = ['role', 'label', 'placeholder', 'text', 'test_id', 'css'];
@@ -142,6 +143,47 @@ function recordingOf(page, video, log) {
142
143
  });
143
144
  return recording;
144
145
  }
146
+ /**
147
+ * Runs in the app's main process: the next Open dialog answers with `paths` instead of opening,
148
+ * once, then the real dialog is back. Self-contained, since Playwright sends its source.
149
+ */
150
+ export function answerOpenDialog(electron, paths) {
151
+ const { dialog } = electron;
152
+ const holder = globalThis;
153
+ holder.__qaOpenDialog ??= {
154
+ showOpenDialog: dialog.showOpenDialog,
155
+ showOpenDialogSync: dialog.showOpenDialogSync,
156
+ answered: false,
157
+ };
158
+ const state = holder.__qaOpenDialog;
159
+ state.answered = false;
160
+ const restore = () => {
161
+ dialog.showOpenDialog = state.showOpenDialog;
162
+ dialog.showOpenDialogSync = state.showOpenDialogSync;
163
+ };
164
+ dialog.showOpenDialog = async () => {
165
+ state.answered = true;
166
+ restore();
167
+ return { canceled: false, filePaths: paths };
168
+ };
169
+ dialog.showOpenDialogSync = () => {
170
+ state.answered = true;
171
+ restore();
172
+ return paths;
173
+ };
174
+ }
175
+ /** Runs in the main process: whether the swapped dialog answered. */
176
+ export function openDialogAnswered() {
177
+ return globalThis.__qaOpenDialog?.answered === true;
178
+ }
179
+ /** Runs in the main process: puts the real Open dialog back. */
180
+ export function restoreOpenDialog(electron) {
181
+ const state = globalThis.__qaOpenDialog;
182
+ if (!state)
183
+ return;
184
+ electron.dialog.showOpenDialog = state.showOpenDialog;
185
+ electron.dialog.showOpenDialogSync = state.showOpenDialogSync;
186
+ }
145
187
  /** The paint check, run in the window: settles once it is visible and has run two animation frames. */
146
188
  function paintCheck() {
147
189
  return new Promise((resolve) => {
@@ -154,21 +196,6 @@ function paintCheck() {
154
196
  tick();
155
197
  });
156
198
  }
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
- }
172
199
  export class ElectronPlaywrightDriver {
173
200
  ctx;
174
201
  options;
@@ -529,10 +556,54 @@ export class ElectronPlaywrightDriver {
529
556
  case 'focus_window':
530
557
  await this.focusWindow(action);
531
558
  return {};
559
+ case 'upload':
560
+ await this.upload(action);
561
+ return {};
532
562
  default:
533
563
  return runWebAction(this.requirePage(), action, this.ctx.template, this.ctx.timeouts.actionMs);
534
564
  }
535
565
  }
566
+ /**
567
+ * `upload` as on the web, and also through the app's own Open dialog: before the click, the main
568
+ * process's `dialog.showOpenDialog` and `showOpenDialogSync` are swapped for ones that answer with
569
+ * the files, then put back. Apps started over CDP have no main process to reach, so they only get
570
+ * the web way.
571
+ */
572
+ async upload(action) {
573
+ const page = this.requirePage();
574
+ const timeout = action.timeout_ms ?? this.ctx.timeouts.actionMs;
575
+ const paths = resolveFileEntries(action.files, this.ctx.template).map((file) => file.path);
576
+ const locator = await locateAction(page, action, this.ctx.template, timeout);
577
+ const electron = this.instance?.electron ?? null;
578
+ if (!electron || (await fileInputOf(locator, timeout)).input) {
579
+ await uploadFiles(page, locator, paths, timeout);
580
+ return;
581
+ }
582
+ await electron.evaluate(answerOpenDialog, paths);
583
+ try {
584
+ const chooser = page.waitForEvent('filechooser', { timeout }).catch(() => null);
585
+ await locator.click({ timeout });
586
+ const deadline = Date.now() + timeout;
587
+ for (;;) {
588
+ if (await electron.evaluate(openDialogAnswered).catch(() => false))
589
+ return;
590
+ const opened = await Promise.race([chooser, sleep(200).then(() => undefined)]);
591
+ if (opened) {
592
+ if (paths.length > 1 && !opened.isMultiple()) {
593
+ throw new Error(`the file input takes one file, but the action hands over ${paths.length}`);
594
+ }
595
+ await opened.setFiles(paths, { timeout });
596
+ return;
597
+ }
598
+ if (opened === null || Date.now() > deadline)
599
+ break;
600
+ }
601
+ throw new Error("clicking the element opened neither a file chooser nor the app's Open dialog; target the <input type=file> or the button that opens the dialog");
602
+ }
603
+ finally {
604
+ await electron.evaluate(restoreOpenDialog).catch(() => undefined);
605
+ }
606
+ }
536
607
  async focusWindow(action) {
537
608
  if (!this.instance)
538
609
  throw new Error('the app is not running; start it with launch');
@@ -624,10 +695,7 @@ export class ElectronPlaywrightDriver {
624
695
  }
625
696
  videoOffset(index) {
626
697
  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);
698
+ return recording ? stepStartOffset(recording, this.stepStarts, index) : undefined;
631
699
  }
632
700
  /**
633
701
  * Closes the app (a video is only complete then) and returns the last recording. A relaunch