@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
package/README.md CHANGED
@@ -197,7 +197,8 @@ ready and `4` when something marked `[fail]` needs fixing, with the command that
197
197
  The runner advertises phones (`android`, `android-real`), running emulators and emulators that
198
198
  exist but are off (`android`, `android-emulator`); an emulator is booted when a job needs it and
199
199
  stays booted. Each job installs the build, clears the app's data, records an MP4 when asked
200
- (runs over 3 minutes need `ffmpeg` on `PATH`) and captures the app's logcat warnings, errors and
200
+ (runs over 3 minutes are joined with ffmpeg, which the runner downloads when none is installed)
201
+ and captures the app's logcat warnings, errors and
201
202
  crashes per step. The same devices serve **device sessions**, in which an AI agent explores the
202
203
  app over MCP before writing a script. Details: [Android](https://github.com/leera-app/leera-qa-runner/blob/main/docs/test-runners.md#android),
203
204
  [device sessions](https://github.com/leera-app/leera-qa-runner/blob/main/docs/qa-automation-authoring.md#device-sessions).
@@ -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 };
@@ -1,6 +1,6 @@
1
1
  import { hostname } from 'node:os';
2
2
  import { registryAvailability } from './drivers/registry.js';
3
- import { findFfmpeg } from './devices/screen-record.js';
3
+ import { ffmpegAvailable } from './devices/ffmpeg.js';
4
4
  import { playwrightVersion } from './version.js';
5
5
  export const PROTOCOL_VERSION = 2;
6
6
  export function osName(platform) {
@@ -53,11 +53,15 @@ export function deriveLabels(devices) {
53
53
  }
54
54
  /**
55
55
  * Protocol features by platform present; `sessions` = this runner handles device sessions
56
- * (contracts §H3). Web and Electron record webm (Playwright, contracts §K); mobile records mp4;
57
- * Tauri records no video and adds no feature beyond sessions (contracts §L). Native macOS and
58
- * Windows apps carry the Appium log on failed steps and record mp4 only with ffmpeg (contracts §M).
56
+ * (contracts §H3). Web and Electron record webm (Playwright, contracts §K); mobile records mp4.
57
+ * Tauri, native macOS and Windows apps record mp4 with ffmpeg, which the runner downloads when
58
+ * none is installed (contracts §L, §M); native apps also carry the Appium log on failed steps.
59
59
  * `data_refs` = this runner fills test data, API URL and job placeholders on every platform
60
60
  * (contracts §Q); the server gives such scripts only to runners labelled `data-refs`.
61
+ * `credential_otp` = this runner types the account's fixed one-time code (`{{credential.otp}}`);
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`.
61
65
  */
62
66
  export function featuresFor(platforms, options = {}) {
63
67
  const features = [];
@@ -73,13 +77,14 @@ export function featuresFor(platforms, options = {}) {
73
77
  if (platforms.some((platform) => platform === 'macos' || platform === 'windows')) {
74
78
  if (!features.includes('step_debug'))
75
79
  features.push('step_debug');
76
- if (options.ffmpeg && !features.includes('video_mp4'))
77
- features.push('video_mp4');
78
80
  }
79
- features.push('data_refs');
81
+ if (options.ffmpeg && platforms.some((platform) => platform === 'tauri' || platform === 'macos' || platform === 'windows') && !features.includes('video_mp4')) {
82
+ features.push('video_mp4');
83
+ }
84
+ features.push('data_refs', 'credential_otp', 'test_files');
80
85
  return features;
81
86
  }
82
- export function buildCapabilities(devices, host, extraTools = {}, options = { ffmpeg: findFfmpeg() !== null }) {
87
+ export function buildCapabilities(devices, host, extraTools = {}, options = { ffmpeg: ffmpegAvailable() }) {
83
88
  const platforms = [...new Set(devices.map((device) => device.platform))];
84
89
  const tools = { node: host.node };
85
90
  const playwright = playwrightVersion();
@@ -7,11 +7,12 @@ import { ensureHome, homePaths } from '../../home.js';
7
7
  import { usageError } from '../output.js';
8
8
  import { setupAndroid } from '../../devices/android/setup.js';
9
9
  import { displayNeed, findOnPath, XVFB_DISPLAY } from '../../devices/desktop.js';
10
+ import { downloadFfmpeg, ffmpegBuild, ffmpegCheckDetail, findFfmpeg } from '../../devices/ffmpeg.js';
10
11
  import { setupIos } from '../../devices/ios/setup.js';
11
12
  import { setupMacos } from '../../devices/macos.js';
12
13
  import { setupWindows } from '../../devices/windows.js';
13
14
  import { findNativeDriver, findTauriDriver, loadTauriSettings, MSEDGEDRIVER_URL, TAURI_DRIVER_INSTALL, TAURI_DRIVER_VERSION, WEBKIT_DRIVER_INSTALL, } from '../../devices/tauri.js';
14
- const usage = `Usage: leera-qa-runner setup browsers|android|ios|electron|tauri|macos|windows [options]
15
+ const usage = `Usage: leera-qa-runner setup browsers|android|ios|electron|tauri|macos|windows|ffmpeg [options]
15
16
 
16
17
  Installs what a platform needs. This version supports:
17
18
  browsers Chromium for web jobs (into the runner home unless PLAYWRIGHT_BROWSERS_PATH is set)
@@ -30,6 +31,10 @@ Installs what a platform needs. This version supports:
30
31
  windows (Windows) Prepares Appium's home with the bundled Windows driver, downloads
31
32
  WinAppDriver 1.2.1 (checked by sha256) into the runner home and prints the
32
33
  install and Developer Mode steps
34
+ ffmpeg Downloads the ffmpeg video recording uses (Tauri, Windows and macOS apps,
35
+ real iPhones, Android cases over 3 minutes) into the runner home, checked by
36
+ sha256. Jobs download it themselves when it is missing; an ffmpeg on the
37
+ PATH is used instead
33
38
 
34
39
  Options for android:
35
40
  --install-sdk Install platform-tools, emulator, build-tools and a system image with the
@@ -90,6 +95,9 @@ export function setupTauri(io, paths, lookup = {}) {
90
95
  settings.macosPlugin
91
96
  ? 'tauri.macos_plugin is on: this runner advertises a Tauri device.'
92
97
  : 'When your builds embed the plugin, turn it on:\n leera-qa-runner config set tauri.macos_plugin true',
98
+ '',
99
+ `Video: ${ffmpegCheckDetail(io.env, io.platform).detail}. Recording the screen needs Screen Recording`,
100
+ 'permission for the app that runs the runner (System Settings → Privacy & Security → Screen Recording).',
93
101
  ].join('\n'));
94
102
  return settings.macosPlugin ? ExitCode.ok : ExitCode.environmentNotReady;
95
103
  }
@@ -132,11 +140,40 @@ export function setupTauri(io, paths, lookup = {}) {
132
140
  ' 2. leera-qa-runner config set tauri.native_driver_path C:\\path\\to\\msedgedriver.exe',
133
141
  ].join('\n'));
134
142
  }
143
+ io.stdout(`Video: ${ffmpegCheckDetail(io.env, io.platform).detail}.`);
135
144
  io.stdout(ready ? 'Tauri is ready.' : 'Tauri is not ready: install what is listed above, then run `leera-qa-runner doctor --platform tauri`.');
136
145
  return ready ? ExitCode.ok : ExitCode.environmentNotReady;
137
146
  }
147
+ /**
148
+ * `setup ffmpeg`: downloads the pinned ffmpeg build video recording uses into the runner home,
149
+ * or reports the one already there (on PATH or downloaded). Exit 4 when this OS and CPU have no
150
+ * build to download, 1 when the download fails.
151
+ */
152
+ export async function setupFfmpeg(io, paths, options = {}) {
153
+ const found = findFfmpeg(io.env, io.platform, undefined, paths);
154
+ if (found) {
155
+ io.stdout(`ffmpeg: ${found}`);
156
+ return ExitCode.ok;
157
+ }
158
+ const arch = options.arch ?? process.arch;
159
+ const build = options.build ?? ffmpegBuild(io.platform, arch);
160
+ if (!build) {
161
+ io.stderr(`There is no ffmpeg build to download for ${io.platform} ${arch}: install ffmpeg on the PATH to record video.`);
162
+ return ExitCode.environmentNotReady;
163
+ }
164
+ io.stdout(`Downloading ffmpeg (${Math.round(build.size / 1_000_000)} MB) from ${build.url} ...`);
165
+ try {
166
+ ensureHome(paths);
167
+ io.stdout(`ffmpeg: ${await downloadFfmpeg(paths, { ...options, platform: io.platform, arch })}`);
168
+ return ExitCode.ok;
169
+ }
170
+ catch (error) {
171
+ io.stderr(`ffmpeg could not be downloaded: ${error instanceof Error ? error.message : String(error)}`);
172
+ return ExitCode.error;
173
+ }
174
+ }
138
175
  export const command = {
139
- summary: 'Install platform tools (browsers, android, ios, electron, tauri, macos, windows)',
176
+ summary: 'Install platform tools (browsers, android, ios, electron, tauri, macos, windows, ffmpeg)',
140
177
  usage,
141
178
  async run(args, io) {
142
179
  const { positionals, values } = parseArgs({
@@ -185,6 +222,8 @@ export const command = {
185
222
  return setupMacos(io, homePaths(io.env));
186
223
  if (what === 'windows')
187
224
  return setupWindows(io, homePaths(io.env), { install: values.install ?? false });
225
+ if (what === 'ffmpeg')
226
+ return setupFfmpeg(io, homePaths(io.env));
188
227
  if (what === 'all') {
189
228
  throw usageError(`'setup all' is not available in this runner version; set up one platform at a time`, usage);
190
229
  }
@@ -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
+ }
@@ -0,0 +1,135 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { chmodSync, createReadStream, createWriteStream, existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { pipeline } from 'node:stream/promises';
5
+ import { createGunzip } from 'node:zlib';
6
+ import { writeResponseBody } from '../download.js';
7
+ import { homePaths } from '../home.js';
8
+ import { findOnPath } from './desktop.js';
9
+ /**
10
+ * The ffmpeg video recording needs (contracts §L, §M): whole-screen capture for Tauri, Windows
11
+ * and macOS app jobs, and what Appium uses for real iPhones and for Android cases over 3 minutes.
12
+ * An ffmpeg on PATH is used as it is. Otherwise the runner downloads a static build once into its
13
+ * home, checked by size and sha256: the `ffmpeg-static` project's GitHub release, which carries
14
+ * the builds of johnvansickle.com (Linux), gyan.dev (Windows) and a macOS build, all GPL. The
15
+ * runner does not ship ffmpeg itself.
16
+ */
17
+ export const FFMPEG_RELEASE = 'b6.1.1';
18
+ const RELEASE_URL = `https://github.com/eugeneware/ffmpeg-static/releases/download/${FFMPEG_RELEASE}`;
19
+ /** How long a job waits for the first download on a machine. */
20
+ export const FFMPEG_JOB_DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
21
+ /** Pinned builds by `<platform>-<arch>` (sizes and digests from the GitHub release). */
22
+ export const FFMPEG_BUILDS = {
23
+ 'darwin-arm64': { url: `${RELEASE_URL}/ffmpeg-darwin-arm64.gz`, sha256: '8923876afa8db5585022d7860ec7e589af192f441c56793971276d450ed3bbfa', size: 19_246_198 },
24
+ 'darwin-x64': { url: `${RELEASE_URL}/ffmpeg-darwin-x64.gz`, sha256: '929b375c1182d956c51f7ac25e0b2b0411fb01f6f407aa15c9758efeb4242106', size: 25_296_431 },
25
+ 'linux-arm64': { url: `${RELEASE_URL}/ffmpeg-linux-arm64.gz`, sha256: '754a678672298bc68156adff58aa7385a592c2b30b1d0ae8750c45c915c4bac0', size: 25_568_691 },
26
+ 'linux-x64': { url: `${RELEASE_URL}/ffmpeg-linux-x64.gz`, sha256: 'bfe8a8fc511530457b528c48d77b5737527b504a3797a9bc4866aeca69c2dffa', size: 29_354_986 },
27
+ 'win32-x64': { url: `${RELEASE_URL}/ffmpeg-win32-x64.gz`, sha256: '8883a3dffbd0a16cf4ef95206ea05283f78908dbfb118f73c83f4951dcc06d77', size: 29_581_307 },
28
+ };
29
+ /** The pinned build for this OS and CPU (Windows on Arm runs the x64 one), or null. */
30
+ export function ffmpegBuild(platform = process.platform, arch = process.arch) {
31
+ const key = platform === 'win32' && arch === 'arm64' ? 'win32-x64' : `${platform}-${arch}`;
32
+ return FFMPEG_BUILDS[key] ?? null;
33
+ }
34
+ function executableName(platform) {
35
+ return platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg';
36
+ }
37
+ /** Where the downloaded ffmpeg lives: one directory per pinned release, so an update fetches its own. */
38
+ export function managedFfmpegDir(paths) {
39
+ return join(paths.ffmpeg, FFMPEG_RELEASE);
40
+ }
41
+ export function managedFfmpegPath(paths, platform = process.platform) {
42
+ return join(managedFfmpegDir(paths), executableName(platform));
43
+ }
44
+ /** ffmpeg on PATH, else the one the runner downloaded; null when there is neither. */
45
+ export function findFfmpeg(env = process.env, platform = process.platform, exists, paths = homePaths(env)) {
46
+ const onPath = findOnPath(executableName(platform), env, exists);
47
+ if (onPath)
48
+ return onPath;
49
+ const managed = managedFfmpegPath(paths, platform);
50
+ return (exists ?? existsSync)(managed) ? managed : null;
51
+ }
52
+ /** Whether jobs can record with ffmpeg: one is there, or this OS and CPU have a build to download. */
53
+ export function ffmpegAvailable(env = process.env, platform = process.platform, arch = process.arch) {
54
+ return findFfmpeg(env, platform) !== null || ffmpegBuild(platform, arch) !== null;
55
+ }
56
+ const downloads = new Map();
57
+ /**
58
+ * Downloads the pinned build for this machine into the runner home and returns its path; one
59
+ * already there is kept. Callers at the same time share one download. Throws when this OS and CPU
60
+ * have no build, or the download fails or does not match its pin.
61
+ */
62
+ export function downloadFfmpeg(paths, options = {}) {
63
+ const target = managedFfmpegPath(paths, options.platform ?? process.platform);
64
+ const pending = downloads.get(target);
65
+ if (pending)
66
+ return pending;
67
+ const download = fetchBuild(target, options).finally(() => downloads.delete(target));
68
+ downloads.set(target, download);
69
+ return download;
70
+ }
71
+ async function fetchBuild(target, options) {
72
+ if (existsSync(target))
73
+ return target;
74
+ const platform = options.platform ?? process.platform;
75
+ const arch = options.arch ?? process.arch;
76
+ const build = options.build ?? ffmpegBuild(platform, arch);
77
+ if (!build)
78
+ throw new Error(`there is no ffmpeg build to download for ${platform} ${arch}; install ffmpeg on the PATH`);
79
+ const dir = dirname(target);
80
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
81
+ const tag = `${process.pid}-${randomBytes(4).toString('hex')}`;
82
+ const gzip = join(dir, `.download-${tag}.gz`);
83
+ const unpacked = join(dir, `.download-${tag}`);
84
+ try {
85
+ options.log?.info('downloading ffmpeg for video recording', { url: build.url, bytes: build.size });
86
+ const response = await (options.fetch ?? fetch)(build.url, { redirect: 'follow', signal: AbortSignal.timeout(options.timeoutMs ?? 10 * 60_000) });
87
+ if (!response.ok || !response.body)
88
+ throw new Error(`the ffmpeg download failed with HTTP ${response.status}`);
89
+ const hash = createHash('sha256');
90
+ const size = await writeResponseBody(response.body, gzip, (chunk) => hash.update(chunk));
91
+ const digest = hash.digest('hex');
92
+ if (size !== build.size || digest !== build.sha256) {
93
+ throw new Error(`the ffmpeg download does not match its pin (${size} bytes, sha256 ${digest})`);
94
+ }
95
+ await pipeline(createReadStream(gzip), createGunzip(), createWriteStream(unpacked, { mode: 0o755 }));
96
+ if (platform !== 'win32')
97
+ chmodSync(unpacked, 0o755);
98
+ renameSync(unpacked, target);
99
+ options.log?.info('ffmpeg downloaded', { path: target });
100
+ return target;
101
+ }
102
+ finally {
103
+ rmSync(gzip, { force: true });
104
+ rmSync(unpacked, { force: true });
105
+ }
106
+ }
107
+ /**
108
+ * The ffmpeg to record with: one on PATH or already downloaded, else the pinned build, downloaded
109
+ * now (the first video job on a machine waits for it). Null, with a warning, when there is none.
110
+ */
111
+ export async function ensureFfmpeg(options = {}) {
112
+ const env = options.env ?? process.env;
113
+ const paths = options.paths ?? homePaths(env);
114
+ const platform = options.platform ?? process.platform;
115
+ const found = findFfmpeg(env, platform, undefined, paths);
116
+ if (found)
117
+ return found;
118
+ try {
119
+ return await downloadFfmpeg(paths, { timeoutMs: FFMPEG_JOB_DOWNLOAD_TIMEOUT_MS, ...options, platform });
120
+ }
121
+ catch (error) {
122
+ options.log?.warn('video is not recorded: ffmpeg could not be downloaded', { error: error instanceof Error ? error.message : String(error) });
123
+ return null;
124
+ }
125
+ }
126
+ /** The doctor line for video on a platform that records with ffmpeg. */
127
+ export function ffmpegCheckDetail(env, platform, arch = process.arch) {
128
+ const found = findFfmpeg(env, platform);
129
+ if (found)
130
+ return { ok: true, detail: `ffmpeg ${found}` };
131
+ if (ffmpegBuild(platform, arch)) {
132
+ return { ok: true, detail: 'ffmpeg is downloaded on the first job with video (or now with `leera-qa-runner setup ffmpeg`)' };
133
+ }
134
+ return { ok: false, detail: `ffmpeg is not installed and there is no build to download for ${platform} ${arch}; jobs record no video` };
135
+ }
@@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, renameSync, 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 { findFfmpeg } from '../ffmpeg.js';
6
7
  import { assertSimUdid } from './simctl.js';
7
8
  /**
8
9
  * Records a simulator's screen with `simctl io recordVideo` (H.264). simctl always writes a
@@ -27,17 +28,17 @@ export function majorBrand(header) {
27
28
  }
28
29
  /**
29
30
  * Rewrites a QuickTime recording as an MP4 container in place, with `avconvert` (macOS) or else
30
- * ffmpeg when present. Returns how, or null when neither worked (the QuickTime file is kept;
31
- * browsers and the server still accept it as video/mp4).
31
+ * ffmpeg (on PATH or the runner's download) when present. Returns how, or null when neither
32
+ * worked (the QuickTime file is kept; browsers and the server still accept it as video/mp4).
32
33
  */
33
- export function remuxToMp4(file, run = runQuiet) {
34
+ export function remuxToMp4(file, run = runQuiet, ffmpeg = findFfmpeg() ?? 'ffmpeg') {
34
35
  const temp = `${file}.remux.mp4`;
35
- for (const [tool, argv] of [
36
- ['avconvert', avconvertRemuxArgv(file, temp)],
37
- ['ffmpeg', ffmpegRemuxArgv(file, temp)],
36
+ for (const [tool, command, argv] of [
37
+ ['avconvert', 'avconvert', avconvertRemuxArgv(file, temp)],
38
+ ['ffmpeg', ffmpeg, ffmpegRemuxArgv(file, temp)],
38
39
  ]) {
39
40
  rmSync(temp, { force: true });
40
- if (run(tool, [...argv]) && existsSync(temp) && statSync(temp).size > 0) {
41
+ if (run(command, [...argv]) && existsSync(temp) && statSync(temp).size > 0) {
41
42
  renameSync(temp, file);
42
43
  return tool;
43
44
  }
@@ -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));
@@ -8,7 +8,7 @@ import { ensureHome } from '../home.js';
8
8
  import { runTool } from './android/adb.js';
9
9
  import { appiumDriverListArgv } from './android/setup.js';
10
10
  import { developerDir, isCommandLineToolsOnly, xcodeInfo } from './ios/wda.js';
11
- import { findFfmpeg } from './screen-record.js';
11
+ import { ffmpegCheckDetail } from './ffmpeg.js';
12
12
  /**
13
13
  * Native macOS apps (contracts §M): Appium's Mac2 driver, which builds and runs
14
14
  * WebDriverAgentMac (an XCTest UI-testing bundle) with Xcode on the first session. XCTest needs
@@ -110,8 +110,8 @@ export const ACCESSIBILITY_STEPS = [
110
110
  ' when macOS asks.',
111
111
  ' 3. The first session enables Automation Mode; macOS may ask for an administrator password on this',
112
112
  ' screen. Keep the Mac logged in and unlocked while jobs run (a locked screen blocks UI testing).',
113
- ' 4. Optional: install ffmpeg (`brew install ffmpeg`) and allow Screen Recording for the same app to',
114
- ' record job videos.',
113
+ ' 4. For job videos: System Settings → Privacy & Security → Screen Recording: allow the same app. The',
114
+ ' runner downloads the ffmpeg it records with on the first job with video.',
115
115
  ];
116
116
  /** Whether this Mac can run macOS app jobs now: Xcode, Appium with Mac2, no recorded Accessibility refusal. */
117
117
  export async function macosReadiness(paths, platform = process.platform) {
@@ -172,10 +172,10 @@ export async function macosChecks(env, paths, platform = process.platform) {
172
172
  else if (mode) {
173
173
  checks.push({ id: 'macos.automation_mode', title: 'macOS apps: Automation Mode', status: 'ok', detail: mode.enabled ? 'enabled' : 'enabled on demand without authentication' });
174
174
  }
175
- const ffmpeg = findFfmpeg(env, platform);
176
- checks.push(ffmpeg
177
- ? { id: 'macos.video', title: 'macOS apps: video', status: 'ok', detail: `ffmpeg ${ffmpeg} (needs Screen Recording permission)` }
178
- : { id: 'macos.video', title: 'macOS apps: video', status: 'warn', detail: 'ffmpeg is not installed; jobs record no video', fix: 'brew install ffmpeg' });
175
+ const video = ffmpegCheckDetail(env, platform);
176
+ checks.push(video.ok
177
+ ? { id: 'macos.video', title: 'macOS apps: video', status: 'ok', detail: `${video.detail} (needs Screen Recording permission)` }
178
+ : { id: 'macos.video', title: 'macOS apps: video', status: 'warn', detail: video.detail, fix: 'brew install ffmpeg' });
179
179
  return checks;
180
180
  }
181
181
  /**
@@ -240,8 +240,7 @@ export async function setupMacos(io, paths) {
240
240
  io.stdout('');
241
241
  io.stdout('Automation Mode is disabled and asks for an administrator password when a session enables it: be at the Mac for the first job.');
242
242
  }
243
- if (!findFfmpeg(io.env, io.platform))
244
- io.stdout('ffmpeg is not installed: macOS app jobs record no video.');
243
+ io.stdout(`Video: ${ffmpegCheckDetail(io.env, io.platform).detail}.`);
245
244
  io.stdout('');
246
245
  io.stdout(ok ? 'macOS apps are set up. Check with `leera-qa-runner doctor --platform macos`.' : 'macOS setup is incomplete; see the messages above.');
247
246
  return ok ? ExitCode.ok : ExitCode.environmentNotReady;
@@ -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
+ }