@leera.io/qa-runner 1.0.6 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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).
@@ -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,9 +53,13 @@ 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
+ * `data_refs` = this runner fills test data, API URL and job placeholders on every platform
60
+ * (contracts §Q); the server gives such scripts only to runners labelled `data-refs`.
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`.
59
63
  */
60
64
  export function featuresFor(platforms, options = {}) {
61
65
  const features = [];
@@ -71,12 +75,14 @@ export function featuresFor(platforms, options = {}) {
71
75
  if (platforms.some((platform) => platform === 'macos' || platform === 'windows')) {
72
76
  if (!features.includes('step_debug'))
73
77
  features.push('step_debug');
74
- if (options.ffmpeg && !features.includes('video_mp4'))
75
- features.push('video_mp4');
76
78
  }
79
+ if (options.ffmpeg && platforms.some((platform) => platform === 'tauri' || platform === 'macos' || platform === 'windows') && !features.includes('video_mp4')) {
80
+ features.push('video_mp4');
81
+ }
82
+ features.push('data_refs', 'credential_otp');
77
83
  return features;
78
84
  }
79
- export function buildCapabilities(devices, host, extraTools = {}, options = { ffmpeg: findFfmpeg() !== null }) {
85
+ export function buildCapabilities(devices, host, extraTools = {}, options = { ffmpeg: ffmpegAvailable() }) {
80
86
  const platforms = [...new Set(devices.map((device) => device.platform))];
81
87
  const tools = { node: host.node };
82
88
  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
  }
package/dist/src/debug.js CHANGED
@@ -13,15 +13,20 @@ const SENSITIVE_PARAM = /token|secret|password|passwd|pwd|auth|session|signature
13
13
  * Only what a person debugging a failure needs is kept: console errors and
14
14
  * warnings, uncaught page errors, and requests. Headers and bodies are never
15
15
  * recorded, since they carry cookies and the credential the script signs in with.
16
+ * Each step's start time is kept too, and is returned on its own while a video
17
+ * is being recorded, so the step can be found in the video.
16
18
  */
17
19
  export class DebugCollector {
18
20
  steps = new Map();
19
21
  current = null;
20
22
  startedAt;
21
23
  redact;
22
- constructor(startedAt, redact) {
24
+ recording;
25
+ /** `recording` is asked when a step's capture is read (Electron only knows after launch whether a window is recorded). */
26
+ constructor(startedAt, redact, recording = () => false) {
23
27
  this.startedAt = startedAt;
24
28
  this.redact = redact;
29
+ this.recording = recording;
25
30
  }
26
31
  attach(page) {
27
32
  page.on('console', (message) => this.onConsole(message));
@@ -39,13 +44,18 @@ export class DebugCollector {
39
44
  this.entry(index).video_offset_secs = offset;
40
45
  return offset;
41
46
  }
42
- /** The step's capture, or undefined when nothing but its start time was recorded. */
47
+ /**
48
+ * The step's capture. A step that captured nothing still gives its start time while a video is
49
+ * recorded, since that is its place in the video; without a video it gives undefined.
50
+ */
43
51
  for(index) {
44
52
  const debug = this.steps.get(index);
45
53
  if (!debug)
46
54
  return undefined;
47
- const { video_offset_secs: _offset, ...captured } = debug;
48
- return Object.keys(captured).length > 0 ? debug : undefined;
55
+ const { video_offset_secs: offset, ...captured } = debug;
56
+ if (Object.keys(captured).length > 0)
57
+ return debug;
58
+ return offset !== undefined && this.recording() ? debug : undefined;
49
59
  }
50
60
  onConsole(message) {
51
61
  const type = message.type();
@@ -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
  }
@@ -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;
@@ -3,47 +3,56 @@ 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';
7
6
  /**
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.
7
+ * H.264 at the capture rate, at most 1920 px wide (a Retina screen is scaled down), both sides even.
8
+ * The output rate must be set: macOS screen capture reports 1,000,000 fps, and ffmpeg would
9
+ * duplicate frames to match, growing the file by megabytes a second and never getting to `q`.
10
+ * veryfast at CRF 28 keeps a mostly still screen near 1 MB a minute for about the CPU of ultrafast.
11
11
  */
12
- export function findFfmpeg(env = process.env, platform = process.platform, exists) {
13
- return findOnPath(platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg', env, exists);
12
+ function encodeArgv(framerate) {
13
+ 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
14
  }
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) {
15
+ /** An X display name: `:99`, `:0.0`, `host:10.0`. */
16
+ const X_DISPLAY = /^[A-Za-z0-9.-]*:\d{1,5}(?:\.\d{1,3})?$/;
17
+ /** The fixed ffmpeg argv that records the main screen (on Linux, the X `display`) into `file` (an .mp4). */
18
+ export function screenRecordArgv(file, os, framerate = 10, display) {
18
19
  if (!file.endsWith('.mp4'))
19
20
  throw new Error('the recording file must end in .mp4');
20
21
  if (!Number.isInteger(framerate) || framerate < 1 || framerate > 30)
21
22
  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];
23
+ let input;
24
+ if (os === 'macos')
25
+ input = ['-f', 'avfoundation', '-capture_cursor', '1', '-framerate', String(framerate), '-i', 'Capture screen 0:none'];
26
+ else if (os === 'windows')
27
+ input = ['-f', 'gdigrab', '-draw_mouse', '1', '-framerate', String(framerate), '-i', 'desktop'];
28
+ else {
29
+ if (!display || !X_DISPLAY.test(display))
30
+ throw new Error(`invalid X display ${JSON.stringify(display ?? '')}`);
31
+ input = ['-f', 'x11grab', '-draw_mouse', '1', '-framerate', String(framerate), '-i', display];
32
+ }
33
+ return ['-hide_banner', '-loglevel', 'error', '-y', ...input, ...encodeArgv(framerate), file];
26
34
  }
27
35
  /** One ffmpeg screen recording; `stop()` asks ffmpeg to finish (`q`) so the mp4 is complete. */
28
36
  export class ScreenRecorder {
29
37
  ffmpeg;
30
38
  os;
31
- spawnImpl;
39
+ options;
32
40
  child = null;
33
41
  exited = false;
34
42
  stderr = '';
35
43
  dir;
36
44
  file;
37
- constructor(ffmpeg, os, spawnImpl = spawn) {
45
+ constructor(ffmpeg, os, options = {}) {
38
46
  this.ffmpeg = ffmpeg;
39
47
  this.os = os;
40
- this.spawnImpl = spawnImpl;
48
+ this.options = options;
41
49
  this.dir = mkdtempSync(join(tmpdir(), 'qa-video-'));
42
50
  this.file = join(this.dir, 'video.mp4');
43
51
  }
44
52
  /** Starts ffmpeg and waits briefly: an immediate exit (no permission, no device) throws. */
45
53
  async start(settleMs = 1_500) {
46
- const child = this.spawnImpl(this.ffmpeg, screenRecordArgv(this.file, this.os), { stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true });
54
+ 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 });
47
56
  this.child = child;
48
57
  child.stderr?.on('data', (chunk) => {
49
58
  this.stderr = `${this.stderr}${chunk.toString('utf8')}`.slice(-2_000);
@@ -84,6 +93,11 @@ export class ScreenRecorder {
84
93
  child.kill();
85
94
  await Promise.race([done, sleep(2_000)]);
86
95
  }
96
+ // An ffmpeg stuck in a capture call can ignore SIGTERM; never leave it running.
97
+ if (!this.exited) {
98
+ child.kill('SIGKILL');
99
+ await Promise.race([done, sleep(2_000)]);
100
+ }
87
101
  }
88
102
  if (existsSync(this.file) && statSync(this.file).size > 0)
89
103
  return this.file;
@@ -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,6 +1,6 @@
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';
@@ -11,6 +11,7 @@ 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
16
  import { renderTemplate } from '../template.js';
16
17
  import { Blocked, LaunchRefused, NotFound, } from './driver.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,
@@ -456,16 +465,15 @@ export class AndroidAppiumDriver {
456
465
  debug(index, result) {
457
466
  const captured = this.logcat?.collector.for(index);
458
467
  const appium = result === 'failed' || result === 'blocked' ? this.appiumLogFor(index) : null;
459
- if (!captured && !appium)
460
- return undefined;
461
468
  const offset = this.offsets.get(index);
469
+ // While recording, the start time alone is kept: it is the step's place in the video.
462
470
  const debug = { ...captured, ...(offset !== undefined && this.recording ? { video_offset_secs: offset } : {}) };
463
471
  if (appium && appium.lines.length > 0) {
464
472
  debug.appium_log = appium.lines;
465
473
  if (appium.dropped > 0)
466
474
  debug.dropped = { ...debug.dropped, appium_log: appium.dropped };
467
475
  }
468
- return debug;
476
+ return Object.keys(debug).length > 0 ? debug : undefined;
469
477
  }
470
478
  /** The Appium log lines written from step `index`'s start to the next step's start (or now). */
471
479
  appiumLogFor(index) {