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