@leera.io/qa-runner 1.2.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/dist/src/builds/download.js +8 -7
- package/dist/src/capabilities.js +3 -1
- package/dist/src/client.js +4 -0
- package/dist/src/devices/android/adb.js +22 -0
- package/dist/src/devices/ios/simctl.js +27 -0
- package/dist/src/devices/mp4.js +80 -0
- package/dist/src/devices/screen-record.js +87 -14
- package/dist/src/drivers/android-appium.js +29 -2
- package/dist/src/drivers/common.js +23 -0
- package/dist/src/drivers/desktop-appium.js +32 -15
- package/dist/src/drivers/electron-playwright.js +89 -21
- package/dist/src/drivers/ios-appium.js +41 -4
- package/dist/src/drivers/mac2-appium.js +28 -2
- package/dist/src/drivers/tauri-webdriver.js +83 -20
- 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/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 +22 -1
- package/dist/src/files/store.js +181 -0
- package/dist/src/runner.js +12 -1
- package/dist/src/template.js +39 -0
- package/package.json +1 -1
|
@@ -11,9 +11,10 @@ export class BuildIntegrityError extends Error {
|
|
|
11
11
|
*/
|
|
12
12
|
export async function obtainBuild(options) {
|
|
13
13
|
const { cache, build } = options;
|
|
14
|
+
const what = options.what ?? `build ${build.id}`;
|
|
14
15
|
const expected = build.sha256.toLowerCase();
|
|
15
16
|
if (!SHA256_RE.test(expected))
|
|
16
|
-
throw new BuildIntegrityError(
|
|
17
|
+
throw new BuildIntegrityError(`${what} has no valid sha256`);
|
|
17
18
|
const release = cache.pin(expected);
|
|
18
19
|
try {
|
|
19
20
|
const cached = cache.get(expected);
|
|
@@ -21,11 +22,11 @@ export async function obtainBuild(options) {
|
|
|
21
22
|
return { path: cached, sha256: expected, fromCache: true, release };
|
|
22
23
|
const info = await options.getUrl();
|
|
23
24
|
if (info.sha256.toLowerCase() !== expected) {
|
|
24
|
-
throw new BuildIntegrityError(`the server offered
|
|
25
|
+
throw new BuildIntegrityError(`the server offered ${what} with digest ${info.sha256}, not ${expected}`);
|
|
25
26
|
}
|
|
26
27
|
const url = new URL(info.url);
|
|
27
28
|
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
28
|
-
throw new Error(`refusing to download
|
|
29
|
+
throw new Error(`refusing to download ${what} from a ${url.protocol} URL`);
|
|
29
30
|
const temp = cache.tempPath(info.file_name);
|
|
30
31
|
try {
|
|
31
32
|
const response = await (options.fetchImpl ?? fetch)(url, {
|
|
@@ -33,22 +34,22 @@ export async function obtainBuild(options) {
|
|
|
33
34
|
signal: AbortSignal.timeout(options.timeoutMs ?? 30 * 60_000),
|
|
34
35
|
});
|
|
35
36
|
if (!response.ok || !response.body)
|
|
36
|
-
throw new Error(`the
|
|
37
|
+
throw new Error(`the download of ${what} failed with HTTP ${response.status}`);
|
|
37
38
|
const hash = createHash('sha256');
|
|
38
39
|
let received = 0;
|
|
39
40
|
const limit = info.size_bytes;
|
|
40
41
|
await writeResponseBody(response.body, temp, (chunk) => {
|
|
41
42
|
received += chunk.length;
|
|
42
43
|
if (received > limit)
|
|
43
|
-
throw new BuildIntegrityError(`the
|
|
44
|
+
throw new BuildIntegrityError(`the download of ${what} is larger than the expected ${limit} bytes`);
|
|
44
45
|
hash.update(chunk);
|
|
45
46
|
options.onProgress?.(received, limit);
|
|
46
47
|
});
|
|
47
48
|
if (received !== limit)
|
|
48
|
-
throw new BuildIntegrityError(`the
|
|
49
|
+
throw new BuildIntegrityError(`the download of ${what} has ${received} bytes, expected ${limit}`);
|
|
49
50
|
const digest = hash.digest('hex');
|
|
50
51
|
if (digest !== expected)
|
|
51
|
-
throw new BuildIntegrityError(`the
|
|
52
|
+
throw new BuildIntegrityError(`the sha256 ${digest} of the downloaded ${what} does not match ${expected}`);
|
|
52
53
|
const path = cache.put(expected, info.file_name || build.file_name, temp);
|
|
53
54
|
cache.prune();
|
|
54
55
|
return { path, sha256: expected, fromCache: false, release };
|
package/dist/src/capabilities.js
CHANGED
|
@@ -60,6 +60,8 @@ export function deriveLabels(devices) {
|
|
|
60
60
|
* (contracts §Q); the server gives such scripts only to runners labelled `data-refs`.
|
|
61
61
|
* `credential_otp` = this runner types the account's fixed one-time code (`{{credential.otp}}`);
|
|
62
62
|
* the server gives such scripts only to runners labelled `credential-otp`.
|
|
63
|
+
* `test_files` = this runner downloads or generates a job's test files and hands them to the app
|
|
64
|
+
* (contracts §S); the server gives such scripts only to runners labelled `test-files`.
|
|
63
65
|
*/
|
|
64
66
|
export function featuresFor(platforms, options = {}) {
|
|
65
67
|
const features = [];
|
|
@@ -79,7 +81,7 @@ export function featuresFor(platforms, options = {}) {
|
|
|
79
81
|
if (options.ffmpeg && platforms.some((platform) => platform === 'tauri' || platform === 'macos' || platform === 'windows') && !features.includes('video_mp4')) {
|
|
80
82
|
features.push('video_mp4');
|
|
81
83
|
}
|
|
82
|
-
features.push('data_refs', 'credential_otp');
|
|
84
|
+
features.push('data_refs', 'credential_otp', 'test_files');
|
|
83
85
|
return features;
|
|
84
86
|
}
|
|
85
87
|
export function buildCapabilities(devices, host, extraTools = {}, options = { ffmpeg: ffmpegAvailable() }) {
|
package/dist/src/client.js
CHANGED
|
@@ -110,6 +110,10 @@ export class RunnerClient {
|
|
|
110
110
|
...(owner.jobId !== undefined ? { job_id: owner.jobId } : { session_id: owner.sessionId }),
|
|
111
111
|
}));
|
|
112
112
|
}
|
|
113
|
+
/** A short-lived download URL for a stored test file of a running job (contracts §S). */
|
|
114
|
+
async testFileDownloadUrl(testFileId, jobId) {
|
|
115
|
+
return this.expect(await this.json('/api/v1/qa/runner/files/download-url', { test_file_id: testFileId, job_id: jobId }));
|
|
116
|
+
}
|
|
113
117
|
json(path, body) {
|
|
114
118
|
return this.request(path, {
|
|
115
119
|
method: 'POST',
|
|
@@ -245,6 +245,14 @@ export class Adb {
|
|
|
245
245
|
async pidof(serial, pkg) {
|
|
246
246
|
return parsePidof(await this.shell(serial, ['pidof', assertPackage(pkg)], 10_000).catch(() => ''));
|
|
247
247
|
}
|
|
248
|
+
/** Copies a file of this machine to the device (no shell: the remote path is one argv item). */
|
|
249
|
+
async push(serial, local, remote, timeoutMs = 300_000) {
|
|
250
|
+
await this.run(['-s', assertSerial(serial), 'push', local, remote], timeoutMs);
|
|
251
|
+
}
|
|
252
|
+
/** Asks the device to add a file it holds to its media index, so photo pickers list it. */
|
|
253
|
+
async scanMedia(serial, remote) {
|
|
254
|
+
await this.shell(serial, mediaScanArgs(remote), 15_000);
|
|
255
|
+
}
|
|
248
256
|
/** Drops adb's connection to a device so it reconnects (the fix for one stuck `offline`). */
|
|
249
257
|
async reconnect(serial) {
|
|
250
258
|
await this.run(['-s', assertSerial(serial), 'reconnect'], 15_000).catch(() => undefined);
|
|
@@ -253,3 +261,17 @@ export class Adb {
|
|
|
253
261
|
await this.run(['-s', assertSerial(serial), 'emu', 'kill'], 15_000).catch(() => undefined);
|
|
254
262
|
}
|
|
255
263
|
}
|
|
264
|
+
/** The Download folder on the device, where the system file picker finds added files. */
|
|
265
|
+
export const DEVICE_DOWNLOADS = '/sdcard/Download';
|
|
266
|
+
/** Quotes one word for the device's shell. */
|
|
267
|
+
export function deviceShellQuote(text) {
|
|
268
|
+
return `'${text.replace(/'/g, `'\\''`)}'`;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* `am broadcast` of MEDIA_SCANNER_SCAN_FILE for a file on the device; the file URI is
|
|
272
|
+
* percent-encoded and quoted, since `adb shell` hands its words to the device's shell.
|
|
273
|
+
*/
|
|
274
|
+
export function mediaScanArgs(remote) {
|
|
275
|
+
const uri = `file://${encodeURI(remote)}`;
|
|
276
|
+
return ['am', 'broadcast', '-a', 'android.intent.action.MEDIA_SCANNER_SCAN_FILE', '-d', deviceShellQuote(uri)];
|
|
277
|
+
}
|
|
@@ -53,6 +53,21 @@ export function simctlBootstatusArgv(udid) {
|
|
|
53
53
|
export function simctlShutdownArgv(udid) {
|
|
54
54
|
return ['simctl', 'shutdown', assertSimUdid(udid)];
|
|
55
55
|
}
|
|
56
|
+
/** Adds photos and videos to the simulator's Photos library. */
|
|
57
|
+
export function simctlAddMediaArgv(udid, paths) {
|
|
58
|
+
return ['simctl', 'addmedia', assertSimUdid(udid), ...paths];
|
|
59
|
+
}
|
|
60
|
+
/** The app's data container on the simulator (its `Documents` folder is inside). */
|
|
61
|
+
export function simctlDataContainerArgv(udid, bundleId) {
|
|
62
|
+
return ['simctl', 'get_app_container', assertSimUdid(udid), assertBundleId(bundleId), 'data'];
|
|
63
|
+
}
|
|
64
|
+
/** File types `simctl addmedia` puts into the Photos library. */
|
|
65
|
+
export const SIMULATOR_MEDIA_EXTENSIONS = ['jpg', 'jpeg', 'png', 'heic', 'gif', 'mp4', 'mov', 'm4v'];
|
|
66
|
+
/** Whether a file goes to the Photos library (by its extension). */
|
|
67
|
+
export function isSimulatorMedia(fileName) {
|
|
68
|
+
const ext = fileName.toLowerCase().split('.').pop() ?? '';
|
|
69
|
+
return fileName.includes('.') && SIMULATOR_MEDIA_EXTENSIONS.includes(ext);
|
|
70
|
+
}
|
|
56
71
|
/** An executable name that is safe inside a log predicate string. */
|
|
57
72
|
export const PROCESS_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._+-]{0,99}$/;
|
|
58
73
|
/**
|
|
@@ -178,6 +193,18 @@ export class Simctl {
|
|
|
178
193
|
async shutdown(udid) {
|
|
179
194
|
await this.run(simctlShutdownArgv(udid), 60_000).catch(() => undefined);
|
|
180
195
|
}
|
|
196
|
+
async addMedia(udid, paths) {
|
|
197
|
+
await this.run(simctlAddMediaArgv(udid, paths), 120_000);
|
|
198
|
+
}
|
|
199
|
+
/** The app's data container, or null when the app is not installed on the simulator. */
|
|
200
|
+
async dataContainer(udid, bundleId) {
|
|
201
|
+
try {
|
|
202
|
+
return (await this.run(simctlDataContainerArgv(udid, bundleId), 20_000)).trim() || null;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
181
208
|
async executableName(udid, bundleId) {
|
|
182
209
|
try {
|
|
183
210
|
return parseAppinfoExecutable(await this.run(simctlAppinfoArgv(udid, bundleId), 20_000));
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
|
|
2
|
+
/** The boxes that follow each other in [from, to); stops at the first that does not fit. */
|
|
3
|
+
function* boxes(read, from, to) {
|
|
4
|
+
let at = from;
|
|
5
|
+
while (at + 8 <= to) {
|
|
6
|
+
const head = read(at, 8);
|
|
7
|
+
if (!head)
|
|
8
|
+
return;
|
|
9
|
+
let size = head.readUInt32BE(0);
|
|
10
|
+
let header = 8;
|
|
11
|
+
if (size === 1) {
|
|
12
|
+
const large = read(at + 8, 8);
|
|
13
|
+
if (!large)
|
|
14
|
+
return;
|
|
15
|
+
size = Number(large.readBigUInt64BE(0));
|
|
16
|
+
header = 16;
|
|
17
|
+
}
|
|
18
|
+
else if (size === 0) {
|
|
19
|
+
size = to - at;
|
|
20
|
+
}
|
|
21
|
+
if (size < header || at + size > to)
|
|
22
|
+
return;
|
|
23
|
+
yield { type: head.toString('latin1', 4, 8), start: at + header, end: at + size };
|
|
24
|
+
at += size;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* An MP4's length in seconds, from its movie header (`moov/mvhd`), as players and `ffmpeg -i`
|
|
29
|
+
* report it; null for a file that is not an MP4 (a webm), has no header (a recording cut short) or
|
|
30
|
+
* gives no length.
|
|
31
|
+
*/
|
|
32
|
+
export function mp4Seconds(file) {
|
|
33
|
+
let fd;
|
|
34
|
+
try {
|
|
35
|
+
fd = openSync(file, 'r');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const size = fstatSync(fd).size;
|
|
42
|
+
const read = (at, length) => {
|
|
43
|
+
if (at + length > size)
|
|
44
|
+
return null;
|
|
45
|
+
const buffer = Buffer.alloc(length);
|
|
46
|
+
return readSync(fd, buffer, 0, length, at) === length ? buffer : null;
|
|
47
|
+
};
|
|
48
|
+
const top = boxes(read, 0, size);
|
|
49
|
+
const first = top.next();
|
|
50
|
+
if (first.done || first.value.type !== 'ftyp')
|
|
51
|
+
return null;
|
|
52
|
+
for (const box of top) {
|
|
53
|
+
if (box.type !== 'moov')
|
|
54
|
+
continue;
|
|
55
|
+
for (const child of boxes(read, box.start, box.end)) {
|
|
56
|
+
if (child.type !== 'mvhd')
|
|
57
|
+
continue;
|
|
58
|
+
// version 0: 32-bit times, timescale at 12 and duration at 16; version 1: 64-bit, at 20 and 24.
|
|
59
|
+
const header = read(child.start, Math.min(32, child.end - child.start));
|
|
60
|
+
const v1 = header?.[0] === 1;
|
|
61
|
+
if (!header || header.length < (v1 ? 32 : 20))
|
|
62
|
+
return null;
|
|
63
|
+
const timescale = header.readUInt32BE(v1 ? 20 : 12);
|
|
64
|
+
const duration = v1 ? header.readBigUInt64BE(24) : BigInt(header.readUInt32BE(16));
|
|
65
|
+
const unknown = v1 ? 0xffffffffffffffffn : 0xffffffffn;
|
|
66
|
+
if (timescale === 0 || duration === 0n || duration === unknown)
|
|
67
|
+
return null;
|
|
68
|
+
return Number(duration) / timescale;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
closeSync(fd);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -3,6 +3,7 @@ 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 { ensureFfmpeg } from './ffmpeg.js';
|
|
6
7
|
/**
|
|
7
8
|
* H.264 at the capture rate, at most 1920 px wide (a Retina screen is scaled down), both sides even.
|
|
8
9
|
* The output rate must be set: macOS screen capture reports 1,000,000 fps, and ffmpeg would
|
|
@@ -12,6 +13,13 @@ import { setTimeout as sleep } from 'node:timers/promises';
|
|
|
12
13
|
function encodeArgv(framerate) {
|
|
13
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
|
}
|
|
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'];
|
|
15
23
|
/** An X display name: `:99`, `:0.0`, `host:10.0`. */
|
|
16
24
|
const X_DISPLAY = /^[A-Za-z0-9.-]*:\d{1,5}(?:\.\d{1,3})?$/;
|
|
17
25
|
/** The fixed ffmpeg argv that records the main screen (on Linux, the X `display`) into `file` (an .mp4). */
|
|
@@ -30,16 +38,32 @@ export function screenRecordArgv(file, os, framerate = 10, display) {
|
|
|
30
38
|
throw new Error(`invalid X display ${JSON.stringify(display ?? '')}`);
|
|
31
39
|
input = ['-f', 'x11grab', '-draw_mouse', '1', '-framerate', String(framerate), '-i', display];
|
|
32
40
|
}
|
|
33
|
-
return ['-hide_banner', '-loglevel', 'error', '-y', ...input, ...encodeArgv(framerate), file];
|
|
41
|
+
return ['-hide_banner', '-loglevel', 'error', '-y', ...PROGRESS_ARGV, ...input, ...encodeArgv(framerate), file];
|
|
34
42
|
}
|
|
35
|
-
/**
|
|
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
|
+
*/
|
|
36
55
|
export class ScreenRecorder {
|
|
37
56
|
ffmpeg;
|
|
38
57
|
os;
|
|
39
58
|
options;
|
|
40
59
|
child = null;
|
|
60
|
+
/** Counts ffmpeg starts; events from an earlier one are ignored. */
|
|
61
|
+
runs = 0;
|
|
41
62
|
exited = false;
|
|
42
63
|
stderr = '';
|
|
64
|
+
progress = '';
|
|
65
|
+
firstFrameAt = null;
|
|
66
|
+
endAt = null;
|
|
43
67
|
dir;
|
|
44
68
|
file;
|
|
45
69
|
constructor(ffmpeg, os, options = {}) {
|
|
@@ -49,29 +73,69 @@ export class ScreenRecorder {
|
|
|
49
73
|
this.dir = mkdtempSync(join(tmpdir(), 'qa-video-'));
|
|
50
74
|
this.file = join(this.dir, 'video.mp4');
|
|
51
75
|
}
|
|
52
|
-
/**
|
|
53
|
-
|
|
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) {
|
|
54
90
|
const argv = screenRecordArgv(this.file, this.os, 10, this.options.display);
|
|
55
|
-
|
|
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 });
|
|
56
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
|
+
});
|
|
57
120
|
child.stderr?.on('data', (chunk) => {
|
|
58
|
-
|
|
121
|
+
if (current())
|
|
122
|
+
this.stderr = `${this.stderr}${chunk.toString('utf8')}`.slice(-2_000);
|
|
59
123
|
});
|
|
60
124
|
child.once('exit', () => {
|
|
125
|
+
if (!current())
|
|
126
|
+
return;
|
|
61
127
|
this.exited = true;
|
|
128
|
+
this.endAt ??= Date.now();
|
|
62
129
|
});
|
|
63
130
|
child.once('error', (error) => {
|
|
131
|
+
if (!current())
|
|
132
|
+
return;
|
|
64
133
|
this.exited = true;
|
|
65
134
|
this.stderr += error.message;
|
|
66
135
|
});
|
|
67
|
-
const deadline = Date.now() +
|
|
68
|
-
while (Date.now() < deadline)
|
|
69
|
-
|
|
70
|
-
this.cleanup();
|
|
71
|
-
throw new Error(`ffmpeg stopped recording at once: ${this.stderr.trim().split('\n').pop() ?? 'no output'}`);
|
|
72
|
-
}
|
|
73
|
-
await sleep(100);
|
|
74
|
-
}
|
|
136
|
+
const deadline = Date.now() + timeoutMs;
|
|
137
|
+
while (this.firstFrameAt === null && !this.exited && Date.now() < deadline)
|
|
138
|
+
await sleep(50);
|
|
75
139
|
}
|
|
76
140
|
/** Stops the recording; returns the mp4 path, or undefined when nothing usable was written. */
|
|
77
141
|
async stop(timeoutMs = 15_000) {
|
|
@@ -79,6 +143,8 @@ export class ScreenRecorder {
|
|
|
79
143
|
this.child = null;
|
|
80
144
|
if (!child)
|
|
81
145
|
return undefined;
|
|
146
|
+
// The video ends about here: ffmpeg records on until it reads the `q`.
|
|
147
|
+
this.endAt ??= Date.now();
|
|
82
148
|
if (!this.exited) {
|
|
83
149
|
const done = new Promise((resolve) => child.once('exit', () => resolve()));
|
|
84
150
|
try {
|
|
@@ -107,3 +173,10 @@ export class ScreenRecorder {
|
|
|
107
173
|
rmSync(this.dir, { recursive: true, force: true });
|
|
108
174
|
}
|
|
109
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
|
+
}
|
|
@@ -6,14 +6,14 @@ import { AppiumServer, AppiumUnavailable, bundledAppium, MJPEG_PORTS, SYSTEM_POR
|
|
|
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
14
|
import { ensureFfmpeg, managedFfmpegDir } from '../devices/ffmpeg.js';
|
|
15
15
|
import { homePaths, readJsonFile } from '../home.js';
|
|
16
|
-
import { renderTemplate } from '../template.js';
|
|
16
|
+
import { renderTemplate, resolveFileEntries } from '../template.js';
|
|
17
17
|
import { Blocked, LaunchRefused, NotFound, } from './driver.js';
|
|
18
18
|
import { emptyScreen } from './snapshot/format.js';
|
|
19
19
|
import { ANDROID_ACTIONS, androidKeyCode, runAndroidAction } from './webdriver/actions.js';
|
|
@@ -300,6 +300,10 @@ export class AndroidAppiumDriver {
|
|
|
300
300
|
return { ...rest, fallbacks: [...(rest.fallbacks ?? []), { xpath }] };
|
|
301
301
|
}
|
|
302
302
|
async run(action) {
|
|
303
|
+
if (action.type === 'add_files') {
|
|
304
|
+
await this.addFiles(action);
|
|
305
|
+
return {};
|
|
306
|
+
}
|
|
303
307
|
const prepared = action.target ? { ...action, target: AndroidAppiumDriver.resolveRefTarget(action.target) } : action;
|
|
304
308
|
if (this.dialogCheckPending) {
|
|
305
309
|
this.dialogCheckPending = false;
|
|
@@ -321,6 +325,29 @@ export class AndroidAppiumDriver {
|
|
|
321
325
|
throw this.translate(error);
|
|
322
326
|
}
|
|
323
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
|
+
}
|
|
324
351
|
/**
|
|
325
352
|
* Answers a system "isn't responding" / "keeps stopping" dialog that is not about the app under
|
|
326
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,12 +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 {
|
|
6
|
-
import { ScreenRecorder } from '../devices/screen-record.js';
|
|
5
|
+
import { ffmpegRecorder } from '../devices/screen-record.js';
|
|
7
6
|
import { homePaths } from '../home.js';
|
|
8
|
-
import { renderTemplate } from '../template.js';
|
|
7
|
+
import { renderTemplate, resolveFileEntries } from '../template.js';
|
|
9
8
|
import { APPIUM_LOG_MAX_BYTES, appiumLogLines, LOCAL_PATHS_REFUSED } from './android-appium.js';
|
|
10
|
-
import { errorMessage, renderLaunchArgs } from './common.js';
|
|
9
|
+
import { errorMessage, renderLaunchArgs, stepStartOffset } from './common.js';
|
|
11
10
|
import { Blocked, LaunchRefused, NotFound, Unsupported } from './driver.js';
|
|
12
11
|
import { runNativeAction } from './webdriver/native-actions.js';
|
|
13
12
|
import { nativeFindAll, resolveNativeRef } from './webdriver/native-locate.js';
|
|
@@ -62,6 +61,7 @@ export function validateNativeAction(platform, action) {
|
|
|
62
61
|
export class DesktopAppiumDriver {
|
|
63
62
|
ctx;
|
|
64
63
|
appium;
|
|
64
|
+
makeRecorder;
|
|
65
65
|
client = null;
|
|
66
66
|
unpacked = null;
|
|
67
67
|
releaseBuild = null;
|
|
@@ -71,12 +71,15 @@ export class DesktopAppiumDriver {
|
|
|
71
71
|
firstLaunchDone = false;
|
|
72
72
|
actionsRun = 0;
|
|
73
73
|
lastRefs = {};
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
/** When each step began (ms), in the order they ran; offsets count from the video's first frame. */
|
|
75
|
+
stepStarts = [];
|
|
76
76
|
appiumLogOffsets = new Map();
|
|
77
|
-
constructor(ctx, appium
|
|
77
|
+
constructor(ctx, appium,
|
|
78
|
+
/** A whole-screen recorder, or null when there is no ffmpeg (default: ffmpegRecorder). */
|
|
79
|
+
makeRecorder) {
|
|
78
80
|
this.ctx = ctx;
|
|
79
81
|
this.appium = appium;
|
|
82
|
+
this.makeRecorder = makeRecorder;
|
|
80
83
|
}
|
|
81
84
|
get paths() {
|
|
82
85
|
return this.ctx.paths ?? homePaths(process.env);
|
|
@@ -153,14 +156,11 @@ export class DesktopAppiumDriver {
|
|
|
153
156
|
}
|
|
154
157
|
/** Starts a whole-screen recording when the job wants video (ffmpeg is downloaded when missing). */
|
|
155
158
|
async startRecording() {
|
|
156
|
-
this.startedAt = Date.now();
|
|
157
159
|
if (this.ctx.video === 'off')
|
|
158
160
|
return;
|
|
159
|
-
const
|
|
160
|
-
if (!
|
|
161
|
+
const recorder = await (this.makeRecorder ?? ffmpegRecorder(this.paths, this.ctx.log))(this.platform, undefined);
|
|
162
|
+
if (!recorder)
|
|
161
163
|
return;
|
|
162
|
-
this.startedAt = Date.now();
|
|
163
|
-
const recorder = new ScreenRecorder(ffmpeg, this.platform);
|
|
164
164
|
try {
|
|
165
165
|
await recorder.start();
|
|
166
166
|
this.recorder = recorder;
|
|
@@ -182,6 +182,23 @@ export class DesktopAppiumDriver {
|
|
|
182
182
|
const prepared = action.target ? { ...action, target: resolveNativeRef(action.target) } : action;
|
|
183
183
|
return runNativeAction(this.actionContext(), prepared);
|
|
184
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
|
+
}
|
|
185
202
|
/** Whether this action is the leading `launch` whose args the first start already used. */
|
|
186
203
|
skipFirstLaunch(action) {
|
|
187
204
|
const first = this.actionsRun === 0;
|
|
@@ -241,13 +258,13 @@ export class DesktopAppiumDriver {
|
|
|
241
258
|
throw new Unsupported(`'home' is not available on ${this.platform}`);
|
|
242
259
|
}
|
|
243
260
|
beginStep(index) {
|
|
244
|
-
this.
|
|
261
|
+
this.stepStarts.push({ index, at: Date.now() });
|
|
245
262
|
this.appiumLogOffsets.set(index, this.appium.logOffset());
|
|
246
263
|
}
|
|
247
264
|
debug(index, result) {
|
|
248
|
-
const offset = this.
|
|
265
|
+
const offset = this.recorder ? stepStartOffset(this.recorder, this.stepStarts, index) : undefined;
|
|
249
266
|
// While recording, the start time alone is kept: it is the step's place in the video.
|
|
250
|
-
const debug = offset !== undefined
|
|
267
|
+
const debug = offset !== undefined ? { video_offset_secs: offset } : {};
|
|
251
268
|
const start = this.appiumLogOffsets.get(index);
|
|
252
269
|
if ((result === 'failed' || result === 'blocked') && start !== undefined) {
|
|
253
270
|
const later = [...this.appiumLogOffsets.entries()].filter(([i]) => i > index).map(([, at]) => at);
|