@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 +2 -1
- package/dist/src/capabilities.js +13 -7
- package/dist/src/cli/commands/setup.js +41 -2
- package/dist/src/debug.js +14 -4
- package/dist/src/devices/ffmpeg.js +135 -0
- package/dist/src/devices/ios/record.js +8 -7
- package/dist/src/devices/macos.js +8 -9
- package/dist/src/devices/screen-record.js +31 -17
- package/dist/src/devices/tauri.js +11 -0
- package/dist/src/devices/windows.js +6 -7
- package/dist/src/drivers/android-appium.js +14 -6
- package/dist/src/drivers/common.js +40 -2
- package/dist/src/drivers/desktop-appium.js +21 -20
- package/dist/src/drivers/electron-playwright.js +216 -47
- package/dist/src/drivers/ios-appium.js +5 -3
- package/dist/src/drivers/mac2-appium.js +2 -2
- package/dist/src/drivers/tauri-webdriver.js +177 -70
- package/dist/src/drivers/web-playwright.js +21 -3
- package/dist/src/drivers/webdriver/actions.js +8 -4
- package/dist/src/drivers/webdriver/dom-element.js +47 -0
- package/dist/src/drivers/webdriver/dom-locate.js +54 -7
- package/dist/src/drivers/webdriver/dom-snapshot.js +8 -5
- package/dist/src/drivers/webdriver/dom-tree.js +7 -15
- package/dist/src/drivers/webdriver/dom.js +40 -27
- package/dist/src/drivers/windows-appium.js +2 -2
- package/dist/src/executor.js +14 -3
- package/dist/src/home.js +1 -0
- package/dist/src/runner.js +3 -3
- package/dist/src/session/loop.js +2 -2
- package/dist/src/template.js +72 -2
- 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
|
|
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).
|
package/dist/src/capabilities.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { hostname } from 'node:os';
|
|
2
2
|
import { registryAvailability } from './drivers/registry.js';
|
|
3
|
-
import {
|
|
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
|
|
58
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|
-
/**
|
|
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:
|
|
48
|
-
|
|
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
|
|
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(
|
|
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 {
|
|
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.
|
|
114
|
-
'
|
|
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
|
|
176
|
-
checks.push(
|
|
177
|
-
? { id: 'macos.video', title: 'macOS apps: video', status: 'ok', detail:
|
|
178
|
-
: { id: 'macos.video', title: 'macOS apps: video', status: 'warn', detail:
|
|
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
|
-
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
13
|
-
return
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
39
|
+
options;
|
|
32
40
|
child = null;
|
|
33
41
|
exited = false;
|
|
34
42
|
stderr = '';
|
|
35
43
|
dir;
|
|
36
44
|
file;
|
|
37
|
-
constructor(ffmpeg, os,
|
|
45
|
+
constructor(ffmpeg, os, options = {}) {
|
|
38
46
|
this.ffmpeg = ffmpeg;
|
|
39
47
|
this.os = os;
|
|
40
|
-
this.
|
|
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
|
|
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 {
|
|
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
|
|
259
|
-
checks.push(
|
|
260
|
-
? { id: 'windows.video', title: 'Windows apps: video', status: 'ok', detail:
|
|
261
|
-
: { id: 'windows.video', title: 'Windows apps: video', status: 'warn', detail:
|
|
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
|
-
|
|
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) {
|