@leera.io/qa-runner 1.0.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/LICENSE +64 -0
- package/README.md +333 -0
- package/dist/src/appium/drivers.js +60 -0
- package/dist/src/appium/home.js +60 -0
- package/dist/src/appium/server.js +237 -0
- package/dist/src/builds/cache.js +133 -0
- package/dist/src/builds/cleanup.js +208 -0
- package/dist/src/builds/download.js +64 -0
- package/dist/src/builds/install.js +75 -0
- package/dist/src/builds/ios-app.js +99 -0
- package/dist/src/builds/unpack.js +347 -0
- package/dist/src/capabilities.js +93 -0
- package/dist/src/ci/builds.js +68 -0
- package/dist/src/ci/client.js +90 -0
- package/dist/src/ci/env.js +118 -0
- package/dist/src/ci/junit.js +79 -0
- package/dist/src/ci/run.js +320 -0
- package/dist/src/ci/summary.js +126 -0
- package/dist/src/cli/commands/builds.js +104 -0
- package/dist/src/cli/commands/ci.js +167 -0
- package/dist/src/cli/commands/config.js +83 -0
- package/dist/src/cli/commands/connect.js +70 -0
- package/dist/src/cli/commands/doctor.js +57 -0
- package/dist/src/cli/commands/service.js +165 -0
- package/dist/src/cli/commands/setup.js +193 -0
- package/dist/src/cli/commands/start.js +94 -0
- package/dist/src/cli/commands/update.js +189 -0
- package/dist/src/cli/commands/version.js +28 -0
- package/dist/src/cli/main.js +59 -0
- package/dist/src/cli/output.js +67 -0
- package/dist/src/cli.js +40 -0
- package/dist/src/client.js +146 -0
- package/dist/src/config.js +260 -0
- package/dist/src/debug.js +137 -0
- package/dist/src/devices/android/adb.js +251 -0
- package/dist/src/devices/android/avd.js +95 -0
- package/dist/src/devices/android/emulator.js +153 -0
- package/dist/src/devices/android/index.js +133 -0
- package/dist/src/devices/android/logcat.js +205 -0
- package/dist/src/devices/android/prepare.js +40 -0
- package/dist/src/devices/android/setup.js +162 -0
- package/dist/src/devices/android/system-dialog.js +78 -0
- package/dist/src/devices/desktop.js +145 -0
- package/dist/src/devices/ios/devicectl.js +87 -0
- package/dist/src/devices/ios/index.js +130 -0
- package/dist/src/devices/ios/prepare.js +39 -0
- package/dist/src/devices/ios/record.js +122 -0
- package/dist/src/devices/ios/settings.js +55 -0
- package/dist/src/devices/ios/setup.js +186 -0
- package/dist/src/devices/ios/simctl.js +201 -0
- package/dist/src/devices/ios/syslog.js +185 -0
- package/dist/src/devices/ios/wda.js +108 -0
- package/dist/src/devices/macos.js +248 -0
- package/dist/src/devices/manager.js +206 -0
- package/dist/src/devices/screen-record.js +95 -0
- package/dist/src/devices/tauri.js +189 -0
- package/dist/src/devices/types.js +6 -0
- package/dist/src/devices/windows.js +362 -0
- package/dist/src/doctor.js +394 -0
- package/dist/src/download.js +39 -0
- package/dist/src/drivers/android-appium.js +525 -0
- package/dist/src/drivers/common.js +64 -0
- package/dist/src/drivers/desktop-appium.js +282 -0
- package/dist/src/drivers/driver.js +15 -0
- package/dist/src/drivers/electron-playwright.js +566 -0
- package/dist/src/drivers/ios-appium.js +535 -0
- package/dist/src/drivers/mac2-appium.js +313 -0
- package/dist/src/drivers/registry.js +47 -0
- package/dist/src/drivers/snapshot/aria.js +351 -0
- package/dist/src/drivers/snapshot/format.js +106 -0
- package/dist/src/drivers/tauri-webdriver.js +678 -0
- package/dist/src/drivers/web-playwright.js +403 -0
- package/dist/src/drivers/webdriver/actions.js +328 -0
- package/dist/src/drivers/webdriver/desktop-keys.js +141 -0
- package/dist/src/drivers/webdriver/dom-locate.js +113 -0
- package/dist/src/drivers/webdriver/dom-snapshot.js +376 -0
- package/dist/src/drivers/webdriver/dom-tree.js +118 -0
- package/dist/src/drivers/webdriver/dom.js +213 -0
- package/dist/src/drivers/webdriver/ios-actions.js +299 -0
- package/dist/src/drivers/webdriver/ios-locate.js +121 -0
- package/dist/src/drivers/webdriver/locate.js +136 -0
- package/dist/src/drivers/webdriver/native-actions.js +175 -0
- package/dist/src/drivers/webdriver/native-locate.js +273 -0
- package/dist/src/drivers/webdriver/session.js +105 -0
- package/dist/src/drivers/webdriver/xml-tree-desktop.js +425 -0
- package/dist/src/drivers/webdriver/xml-tree-ios.js +314 -0
- package/dist/src/drivers/webdriver/xml-tree.js +393 -0
- package/dist/src/drivers/windows-appium.js +319 -0
- package/dist/src/errors.js +21 -0
- package/dist/src/executor.js +189 -0
- package/dist/src/home.js +76 -0
- package/dist/src/index.js +22 -0
- package/dist/src/log.js +69 -0
- package/dist/src/runner.js +498 -0
- package/dist/src/service/index.js +64 -0
- package/dist/src/service/launchd.js +85 -0
- package/dist/src/service/names.js +2 -0
- package/dist/src/service/schtasks.js +128 -0
- package/dist/src/service/systemd.js +65 -0
- package/dist/src/session/commands.js +165 -0
- package/dist/src/session/execute.js +232 -0
- package/dist/src/session/loop.js +148 -0
- package/dist/src/template.js +62 -0
- package/dist/src/types.js +7 -0
- package/dist/src/update/apply.js +223 -0
- package/dist/src/update/check.js +59 -0
- package/dist/src/update/manifest.js +93 -0
- package/dist/src/update/verify.js +65 -0
- package/dist/src/version.js +42 -0
- package/package.json +44 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { accessSync, constants, existsSync } from 'node:fs';
|
|
3
|
+
import { hostname, release } from 'node:os';
|
|
4
|
+
import { delimiter, join } from 'node:path';
|
|
5
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
6
|
+
/**
|
|
7
|
+
* The host pseudo-device desktop platforms run on (contracts §K): one per runner, one job at a
|
|
8
|
+
* time, plus the virtual display a Linux runner without `DISPLAY` needs.
|
|
9
|
+
*/
|
|
10
|
+
export const HOST_DEVICE_ID = 'host';
|
|
11
|
+
/** Xvfb display number and screen used when a Linux runner has no `DISPLAY`. */
|
|
12
|
+
export const XVFB_DISPLAY = ':99';
|
|
13
|
+
let cachedOsVersion;
|
|
14
|
+
/** The host OS version: `sw_vers -productVersion` on macOS (`26.5`), the kernel release elsewhere. */
|
|
15
|
+
export function hostOsVersion(platform = process.platform) {
|
|
16
|
+
if (cachedOsVersion !== undefined)
|
|
17
|
+
return cachedOsVersion;
|
|
18
|
+
let version = null;
|
|
19
|
+
if (platform === 'darwin') {
|
|
20
|
+
const result = spawnSync('sw_vers', ['-productVersion'], { encoding: 'utf8', timeout: 5_000 });
|
|
21
|
+
version = result.status === 0 ? result.stdout.trim() || null : null;
|
|
22
|
+
}
|
|
23
|
+
cachedOsVersion = version ?? (release() || null);
|
|
24
|
+
return cachedOsVersion;
|
|
25
|
+
}
|
|
26
|
+
/** `{id: "host", platform, name: <hostname>, kind: "host", os_version}` with the platform as its only label. */
|
|
27
|
+
export function hostDevice(platform, options = {}) {
|
|
28
|
+
return {
|
|
29
|
+
id: HOST_DEVICE_ID,
|
|
30
|
+
platform,
|
|
31
|
+
kind: 'host',
|
|
32
|
+
name: options.name ?? hostname(),
|
|
33
|
+
os_version: options.osVersion === undefined ? hostOsVersion() : options.osVersion,
|
|
34
|
+
state: 'available',
|
|
35
|
+
labels: [platform],
|
|
36
|
+
can_boot: false,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Where an executable named `name` is on PATH, or null. */
|
|
40
|
+
export function findOnPath(name, env = process.env, exists = isExecutableFile) {
|
|
41
|
+
for (const dir of (env.PATH ?? '').split(delimiter)) {
|
|
42
|
+
if (!dir)
|
|
43
|
+
continue;
|
|
44
|
+
const path = join(dir, name);
|
|
45
|
+
if (exists(path))
|
|
46
|
+
return path;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
function isExecutableFile(path) {
|
|
51
|
+
try {
|
|
52
|
+
accessSync(path, constants.X_OK);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** The fixed argv of the virtual display. */
|
|
60
|
+
export function xvfbArgv(display = XVFB_DISPLAY) {
|
|
61
|
+
if (!/^:\d{1,4}$/.test(display))
|
|
62
|
+
throw new Error(`'${display}' is not an X display`);
|
|
63
|
+
return [display, '-screen', '0', '1920x1080x24'];
|
|
64
|
+
}
|
|
65
|
+
export function displayNeed(platform = process.platform, env = process.env, find = (name) => findOnPath(name, env)) {
|
|
66
|
+
if (platform !== 'linux')
|
|
67
|
+
return { kind: 'none' };
|
|
68
|
+
const display = env.DISPLAY?.trim() || env.WAYLAND_DISPLAY?.trim();
|
|
69
|
+
if (display)
|
|
70
|
+
return { kind: 'existing', display };
|
|
71
|
+
const xvfb = find('Xvfb');
|
|
72
|
+
return xvfb ? { kind: 'xvfb', xvfb } : { kind: 'missing' };
|
|
73
|
+
}
|
|
74
|
+
export const NO_DISPLAY = 'this Linux runner has no display: set DISPLAY, or install Xvfb (for example `sudo apt-get install xvfb`) so the runner can start one';
|
|
75
|
+
/**
|
|
76
|
+
* Makes sure desktop apps can open windows for one job: on Linux without `DISPLAY` starts
|
|
77
|
+
* `Xvfb :99 -screen 0 1920x1080x24` and stops it after the job (a display :99 that already
|
|
78
|
+
* exists is used as is and left running).
|
|
79
|
+
*/
|
|
80
|
+
export async function ensureDisplay(options = {}) {
|
|
81
|
+
const need = displayNeed(options.platform, options.env, options.find);
|
|
82
|
+
if (need.kind === 'none' || need.kind === 'existing')
|
|
83
|
+
return { env: {}, stop: async () => undefined };
|
|
84
|
+
if (need.kind === 'missing')
|
|
85
|
+
throw new Error(NO_DISPLAY);
|
|
86
|
+
const socket = `/tmp/.X11-unix/X${XVFB_DISPLAY.slice(1)}`;
|
|
87
|
+
if (existsSync(socket))
|
|
88
|
+
return { env: { DISPLAY: XVFB_DISPLAY }, stop: async () => undefined };
|
|
89
|
+
const child = spawn(need.xvfb, xvfbArgv(), { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
90
|
+
let stderr = '';
|
|
91
|
+
let exited = false;
|
|
92
|
+
child.stderr?.on('data', (chunk) => {
|
|
93
|
+
stderr = `${stderr}${chunk.toString('utf8')}`.slice(-2_000);
|
|
94
|
+
});
|
|
95
|
+
child.once('exit', () => {
|
|
96
|
+
exited = true;
|
|
97
|
+
});
|
|
98
|
+
child.once('error', (error) => {
|
|
99
|
+
exited = true;
|
|
100
|
+
stderr += error.message;
|
|
101
|
+
});
|
|
102
|
+
const stop = async () => {
|
|
103
|
+
if (exited)
|
|
104
|
+
return;
|
|
105
|
+
const done = new Promise((resolve) => child.once('exit', () => resolve()));
|
|
106
|
+
child.kill('SIGTERM');
|
|
107
|
+
await Promise.race([done, sleep(5_000)]);
|
|
108
|
+
if (!exited)
|
|
109
|
+
child.kill('SIGKILL');
|
|
110
|
+
};
|
|
111
|
+
const deadline = Date.now() + (options.timeoutMs ?? 10_000);
|
|
112
|
+
while (!existsSync(socket)) {
|
|
113
|
+
if (exited)
|
|
114
|
+
throw new Error(`Xvfb could not start: ${stderr.trim().split('\n').pop() ?? 'exited'}`);
|
|
115
|
+
if (Date.now() > deadline) {
|
|
116
|
+
await stop();
|
|
117
|
+
throw new Error('Xvfb did not open display :99 within 10 s');
|
|
118
|
+
}
|
|
119
|
+
await sleep(100);
|
|
120
|
+
}
|
|
121
|
+
return { env: { DISPLAY: XVFB_DISPLAY }, stop };
|
|
122
|
+
}
|
|
123
|
+
/** `doctor` checks for Electron: Playwright's Electron support and, on Linux, a display. */
|
|
124
|
+
export function electronChecks(options) {
|
|
125
|
+
const checks = [];
|
|
126
|
+
checks.push(options.playwright && options.electronSupported
|
|
127
|
+
? { id: 'electron.playwright', title: 'Electron: Playwright', status: 'ok', detail: `playwright-core ${options.playwright}` }
|
|
128
|
+
: {
|
|
129
|
+
id: 'electron.playwright',
|
|
130
|
+
title: 'Electron: Playwright',
|
|
131
|
+
status: 'fail',
|
|
132
|
+
detail: 'playwright-core with Electron support is not installed with this runner',
|
|
133
|
+
fix: 'Reinstall the runner',
|
|
134
|
+
});
|
|
135
|
+
const need = displayNeed(options.platform, options.env, options.find);
|
|
136
|
+
if (need.kind === 'existing')
|
|
137
|
+
checks.push({ id: 'electron.display', title: 'Electron: display', status: 'ok', detail: `DISPLAY ${need.display}` });
|
|
138
|
+
else if (need.kind === 'xvfb') {
|
|
139
|
+
checks.push({ id: 'electron.display', title: 'Electron: display', status: 'ok', detail: `no DISPLAY; jobs start ${need.xvfb} ${XVFB_DISPLAY}` });
|
|
140
|
+
}
|
|
141
|
+
else if (need.kind === 'missing') {
|
|
142
|
+
checks.push({ id: 'electron.display', title: 'Electron: display', status: 'warn', detail: NO_DISPLAY, fix: 'sudo apt-get install xvfb' });
|
|
143
|
+
}
|
|
144
|
+
return checks;
|
|
145
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { runTool } from '../android/adb.js';
|
|
5
|
+
export const REAL_UDID_RE = /^[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{16}$|^[0-9A-Fa-f]{40}$|^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
|
|
6
|
+
function obj(value) {
|
|
7
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
8
|
+
}
|
|
9
|
+
function str(value) {
|
|
10
|
+
return typeof value === 'string' && value.trim() !== '' ? value : null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Why a real device is not ready (pairing, Developer Mode, connection), with the state the
|
|
14
|
+
* runner advertises: `unauthorized` when the person must act on the phone, `offline` when it is
|
|
15
|
+
* not reachable.
|
|
16
|
+
*/
|
|
17
|
+
export function readiness(device) {
|
|
18
|
+
if (device.pairingState && device.pairingState !== 'paired') {
|
|
19
|
+
return {
|
|
20
|
+
state: 'unauthorized',
|
|
21
|
+
notReady: `${device.name} is not paired with this Mac: unlock it, connect it with a cable and tap Trust`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (device.developerModeStatus === 'disabled') {
|
|
25
|
+
return {
|
|
26
|
+
state: 'unauthorized',
|
|
27
|
+
notReady: `Developer Mode is off on ${device.name}: turn it on in Settings > Privacy & Security > Developer Mode and restart the device`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (!device.transportType || device.tunnelState === 'unavailable') {
|
|
31
|
+
return { state: 'offline', notReady: `${device.name} is not connected (connect it with a cable or on the same network)` };
|
|
32
|
+
}
|
|
33
|
+
if (device.bootState && device.bootState !== 'booted') {
|
|
34
|
+
return { state: 'offline', notReady: `${device.name} is ${device.bootState}` };
|
|
35
|
+
}
|
|
36
|
+
return { state: 'available', notReady: null };
|
|
37
|
+
}
|
|
38
|
+
/** Parses the `devicectl list devices --json-output` file: iOS and iPadOS devices only. */
|
|
39
|
+
export function parseDevicectlDevices(json) {
|
|
40
|
+
const doc = obj(typeof json === 'string' ? JSON.parse(json) : json);
|
|
41
|
+
const list = obj(doc.result).devices;
|
|
42
|
+
if (!Array.isArray(list))
|
|
43
|
+
return [];
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const entry of list) {
|
|
46
|
+
const raw = obj(entry);
|
|
47
|
+
const hardware = obj(raw.hardwareProperties);
|
|
48
|
+
const props = obj(raw.deviceProperties);
|
|
49
|
+
const connection = obj(raw.connectionProperties);
|
|
50
|
+
if (str(hardware.platform) !== 'iOS')
|
|
51
|
+
continue;
|
|
52
|
+
const udid = str(hardware.udid);
|
|
53
|
+
if (!udid || !REAL_UDID_RE.test(udid))
|
|
54
|
+
continue;
|
|
55
|
+
const base = {
|
|
56
|
+
udid,
|
|
57
|
+
identifier: str(raw.identifier),
|
|
58
|
+
name: str(props.name) ?? udid,
|
|
59
|
+
osVersion: str(props.osVersionNumber),
|
|
60
|
+
productType: str(hardware.productType),
|
|
61
|
+
deviceType: str(hardware.deviceType),
|
|
62
|
+
pairingState: str(connection.pairingState),
|
|
63
|
+
transportType: str(connection.transportType),
|
|
64
|
+
tunnelState: str(connection.tunnelState),
|
|
65
|
+
bootState: str(props.bootState),
|
|
66
|
+
developerModeStatus: str(props.developerModeStatus),
|
|
67
|
+
ddiServicesAvailable: typeof props.ddiServicesAvailable === 'boolean' ? props.ddiServicesAvailable : null,
|
|
68
|
+
};
|
|
69
|
+
out.push({ ...base, ...readiness(base) });
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
export function devicectlListArgv(jsonPath) {
|
|
74
|
+
return ['devicectl', 'list', 'devices', '--quiet', '--timeout', '20', '--json-output', jsonPath];
|
|
75
|
+
}
|
|
76
|
+
/** Lists real devices; an Xcode without devicectl (before 15) yields an error the caller reports. */
|
|
77
|
+
export async function listRealDevices(xcrun = 'xcrun') {
|
|
78
|
+
const dir = mkdtempSync(join(tmpdir(), 'qa-devicectl-'));
|
|
79
|
+
try {
|
|
80
|
+
const file = join(dir, 'devices.json');
|
|
81
|
+
await runTool(xcrun, devicectlListArgv(file), { timeoutMs: 40_000 });
|
|
82
|
+
return parseDevicectlDevices(readFileSync(file, 'utf8'));
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
rmSync(dir, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { listRealDevices } from './devicectl.js';
|
|
3
|
+
import { prepareSimulator } from './prepare.js';
|
|
4
|
+
import { Simctl, simulatorAppRunning, UDID_RE } from './simctl.js';
|
|
5
|
+
export { Simctl } from './simctl.js';
|
|
6
|
+
export function iosLabels(kind) {
|
|
7
|
+
return kind === 'real' ? ['ios', 'ios-real'] : ['ios', 'ios-simulator'];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A simulator as the runner advertises it (contracts §I): id = UDID, shut-down simulators are
|
|
11
|
+
* listed with `can_boot` and booted on lease.
|
|
12
|
+
*/
|
|
13
|
+
export function deviceFromSimulator(sim) {
|
|
14
|
+
const booted = sim.state === 'Booted';
|
|
15
|
+
const booting = sim.state === 'Booting';
|
|
16
|
+
return {
|
|
17
|
+
id: sim.udid,
|
|
18
|
+
platform: 'ios',
|
|
19
|
+
kind: 'simulator',
|
|
20
|
+
name: sim.name,
|
|
21
|
+
os_version: sim.osVersion,
|
|
22
|
+
state: !sim.available ? 'offline' : booting ? 'booting' : booted || sim.state === 'Shutdown' ? 'available' : 'offline',
|
|
23
|
+
labels: iosLabels('simulator'),
|
|
24
|
+
can_boot: sim.available && sim.state === 'Shutdown',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function deviceFromReal(real) {
|
|
28
|
+
return {
|
|
29
|
+
id: real.udid,
|
|
30
|
+
platform: 'ios',
|
|
31
|
+
kind: 'real',
|
|
32
|
+
name: real.name,
|
|
33
|
+
os_version: real.osVersion,
|
|
34
|
+
state: real.state,
|
|
35
|
+
labels: iosLabels('real'),
|
|
36
|
+
can_boot: false,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Simulators (`simctl list --json`) and real devices (`devicectl list devices`). */
|
|
40
|
+
export async function listIosDevices(options = {}) {
|
|
41
|
+
const simctl = options.simctl ?? new Simctl();
|
|
42
|
+
const devices = [];
|
|
43
|
+
const notReady = [];
|
|
44
|
+
const errors = [];
|
|
45
|
+
try {
|
|
46
|
+
for (const sim of await simctl.list()) {
|
|
47
|
+
if (sim.available)
|
|
48
|
+
devices.push(deviceFromSimulator(sim));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
errors.push(`simctl could not list simulators: ${error instanceof Error ? error.message : String(error)}`);
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
for (const real of await (options.real ?? listRealDevices)()) {
|
|
56
|
+
// Only ready devices are advertised: the server derives `ios-real` from what is advertised.
|
|
57
|
+
if (real.notReady || real.state !== 'available')
|
|
58
|
+
notReady.push(real.notReady ?? `${real.name} is ${real.state}`);
|
|
59
|
+
else
|
|
60
|
+
devices.push(deviceFromReal(real));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
errors.push(`devicectl could not list devices: ${error instanceof Error ? error.message : String(error)}`);
|
|
65
|
+
}
|
|
66
|
+
return { devices, notReady, errors };
|
|
67
|
+
}
|
|
68
|
+
/** `open -a Simulator --args -CurrentDeviceUDID <udid>`: shows the booted simulator. */
|
|
69
|
+
export function openSimulatorAppArgv(udid) {
|
|
70
|
+
if (!UDID_RE.test(udid))
|
|
71
|
+
throw new Error(`'${udid}' is not a simulator UDID`);
|
|
72
|
+
return ['-a', 'Simulator', '--args', '-CurrentDeviceUDID', udid];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Makes a device usable for one job or session: boots a shut-down simulator (headless,
|
|
76
|
+
* `bootstatus -b`), prepares it once per boot, or checks a real device is paired and ready.
|
|
77
|
+
*/
|
|
78
|
+
export async function leaseIosDevice(device, options = {}) {
|
|
79
|
+
const simctl = options.simctl ?? new Simctl();
|
|
80
|
+
if (UDID_RE.test(device.id) && device.kind !== 'real') {
|
|
81
|
+
const sims = await simctl.list();
|
|
82
|
+
const sim = sims.find((s) => s.udid === device.id);
|
|
83
|
+
if (!sim)
|
|
84
|
+
throw new Error(`the simulator ${device.id} does not exist on this Mac`);
|
|
85
|
+
if (!sim.available)
|
|
86
|
+
throw new Error(`the simulator ${sim.name} is not available (its runtime may be missing)`);
|
|
87
|
+
const booted = sim.state !== 'Booted';
|
|
88
|
+
const bootAttempts = booted
|
|
89
|
+
? await simctl.bootAndWait(sim.udid, { ...(options.bootTimeoutMs ? { timeoutMs: options.bootTimeoutMs } : {}), ...(options.log ? { log: options.log } : {}) })
|
|
90
|
+
: [];
|
|
91
|
+
const after = (await simctl.list()).find((s) => s.udid === device.id);
|
|
92
|
+
if (after?.state !== 'Booted')
|
|
93
|
+
throw new Error(`the simulator ${sim.name} did not boot (state ${after?.state ?? 'unknown'})`);
|
|
94
|
+
const uiRunning = options.uiClientRunning ?? simulatorAppRunning;
|
|
95
|
+
if (options.headed) {
|
|
96
|
+
spawn('open', openSimulatorAppArgv(sim.udid), { stdio: 'ignore', detached: true }).on('error', () => undefined).unref();
|
|
97
|
+
}
|
|
98
|
+
// With a window wanted, wait for Simulator.app so Appium sees it and does not reboot the simulator.
|
|
99
|
+
let uiClientRunning = await uiRunning();
|
|
100
|
+
for (let i = 0; options.headed && !uiClientRunning && i < 30; i++) {
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
102
|
+
uiClientRunning = await uiRunning();
|
|
103
|
+
}
|
|
104
|
+
const { errors } = await (options.prepare ?? prepareSimulator)(sim.udid, after.lastBootedAt ?? 'unknown');
|
|
105
|
+
let released = false;
|
|
106
|
+
return {
|
|
107
|
+
udid: sim.udid,
|
|
108
|
+
kind: 'simulator',
|
|
109
|
+
booted,
|
|
110
|
+
osVersion: sim.osVersion,
|
|
111
|
+
warnings: errors,
|
|
112
|
+
bootAttempts,
|
|
113
|
+
uiClientRunning,
|
|
114
|
+
async release() {
|
|
115
|
+
if (released)
|
|
116
|
+
return;
|
|
117
|
+
released = true;
|
|
118
|
+
if (booted && options.shutdownOnRelease !== false)
|
|
119
|
+
await simctl.shutdown(sim.udid);
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const reals = await (options.real ?? listRealDevices)();
|
|
124
|
+
const real = reals.find((r) => r.udid === device.id);
|
|
125
|
+
if (!real)
|
|
126
|
+
throw new Error(`the iOS device ${device.id} is not connected`);
|
|
127
|
+
if (real.notReady)
|
|
128
|
+
throw new Error(real.notReady);
|
|
129
|
+
return { udid: real.udid, kind: 'real', booted: false, osVersion: real.osVersion, warnings: [], bootAttempts: [], uiClientRunning: false, release: async () => undefined };
|
|
130
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { runTool } from '../android/adb.js';
|
|
2
|
+
import { assertSimUdid } from './simctl.js';
|
|
3
|
+
/**
|
|
4
|
+
* Settings written once per simulator boot so typing is predictable: no autocorrection or
|
|
5
|
+
* predictions, and the one-time "slide to type" introduction that covers the keyboard on a
|
|
6
|
+
* fresh simulator is marked as seen. Fixed strings only.
|
|
7
|
+
*/
|
|
8
|
+
export const SIMULATOR_PREFERENCES = [
|
|
9
|
+
['KeyboardAutocorrection', '-bool', 'NO'],
|
|
10
|
+
['KeyboardPrediction', '-bool', 'NO'],
|
|
11
|
+
['KeyboardShowPredictionBar', '-bool', 'NO'],
|
|
12
|
+
['KeyboardDidShowContinuousPathIntroduction', '-bool', 'YES'],
|
|
13
|
+
];
|
|
14
|
+
export function simulatorPrepareArgv(udid) {
|
|
15
|
+
return SIMULATOR_PREFERENCES.map((pref) => ['simctl', 'spawn', assertSimUdid(udid), 'defaults', 'write', 'com.apple.Preferences', ...pref]);
|
|
16
|
+
}
|
|
17
|
+
/** `udid:lastBootedAt` of simulators this process prepared. */
|
|
18
|
+
const prepared = new Set();
|
|
19
|
+
/** Prepares a booted simulator unless this process already did since its last boot. Failures are reported, not thrown. */
|
|
20
|
+
export async function prepareSimulator(udid, bootKey, xcrun = 'xcrun') {
|
|
21
|
+
const key = `${udid}:${bootKey}`;
|
|
22
|
+
if (prepared.has(key))
|
|
23
|
+
return { prepared: false, errors: [] };
|
|
24
|
+
const errors = [];
|
|
25
|
+
for (const argv of simulatorPrepareArgv(udid)) {
|
|
26
|
+
try {
|
|
27
|
+
await runTool(xcrun, argv, { timeoutMs: 20_000 });
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
errors.push(`${argv.slice(4).join(' ')}: ${error instanceof Error ? error.message : String(error)}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
prepared.add(key);
|
|
34
|
+
return { prepared: true, errors };
|
|
35
|
+
}
|
|
36
|
+
/** Forgets which simulators were prepared (tests). */
|
|
37
|
+
export function resetPreparedSimulators() {
|
|
38
|
+
prepared.clear();
|
|
39
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdtempSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
6
|
+
import { assertSimUdid } from './simctl.js';
|
|
7
|
+
/**
|
|
8
|
+
* Records a simulator's screen with `simctl io recordVideo` (H.264). simctl always writes a
|
|
9
|
+
* QuickTime container whatever the extension (it has no `--type`), so the file is remuxed into
|
|
10
|
+
* a real MP4 afterwards (`remuxToMp4`). The XCUITest driver's own recording needs ffmpeg; this does not.
|
|
11
|
+
*/
|
|
12
|
+
export function simctlRecordArgv(udid, file) {
|
|
13
|
+
if (!file.endsWith('.mp4'))
|
|
14
|
+
throw new Error('the recording file must end in .mp4');
|
|
15
|
+
return ['simctl', 'io', assertSimUdid(udid), 'recordVideo', '--codec=h264', '--force', file];
|
|
16
|
+
}
|
|
17
|
+
/** macOS's built-in remux: `avconvert` passthrough keeps the H.264 stream and writes an MP4 container. */
|
|
18
|
+
export function avconvertRemuxArgv(source, dest) {
|
|
19
|
+
return ['--source', source, '--output', dest, '--preset', 'PresetPassthrough', '--replace'];
|
|
20
|
+
}
|
|
21
|
+
export function ffmpegRemuxArgv(source, dest) {
|
|
22
|
+
return ['-y', '-loglevel', 'error', '-i', source, '-c', 'copy', '-movflags', '+faststart', dest];
|
|
23
|
+
}
|
|
24
|
+
/** The major brand of an ISO media file (`qt ` for QuickTime, `mp42`/`isom` for MP4), or null. */
|
|
25
|
+
export function majorBrand(header) {
|
|
26
|
+
return header.length >= 12 && header.toString('latin1', 4, 8) === 'ftyp' ? header.toString('latin1', 8, 12) : null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Rewrites a QuickTime recording as an MP4 container in place, with `avconvert` (macOS) or else
|
|
30
|
+
* ffmpeg when present. Returns how, or null when neither worked (the QuickTime file is kept;
|
|
31
|
+
* browsers and the server still accept it as video/mp4).
|
|
32
|
+
*/
|
|
33
|
+
export function remuxToMp4(file, run = runQuiet) {
|
|
34
|
+
const temp = `${file}.remux.mp4`;
|
|
35
|
+
for (const [tool, argv] of [
|
|
36
|
+
['avconvert', avconvertRemuxArgv(file, temp)],
|
|
37
|
+
['ffmpeg', ffmpegRemuxArgv(file, temp)],
|
|
38
|
+
]) {
|
|
39
|
+
rmSync(temp, { force: true });
|
|
40
|
+
if (run(tool, [...argv]) && existsSync(temp) && statSync(temp).size > 0) {
|
|
41
|
+
renameSync(temp, file);
|
|
42
|
+
return tool;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
rmSync(temp, { force: true });
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
function runQuiet(cmd, args) {
|
|
49
|
+
const result = spawnSync(cmd, args, { stdio: 'ignore', timeout: 120_000 });
|
|
50
|
+
return !result.error && result.status === 0;
|
|
51
|
+
}
|
|
52
|
+
export class SimulatorRecorder {
|
|
53
|
+
udid;
|
|
54
|
+
xcrun;
|
|
55
|
+
child = null;
|
|
56
|
+
dir;
|
|
57
|
+
file;
|
|
58
|
+
exited = false;
|
|
59
|
+
constructor(udid, xcrun = 'xcrun') {
|
|
60
|
+
this.udid = udid;
|
|
61
|
+
this.xcrun = xcrun;
|
|
62
|
+
this.dir = mkdtempSync(join(tmpdir(), 'qa-video-'));
|
|
63
|
+
this.file = join(this.dir, 'video.mp4');
|
|
64
|
+
}
|
|
65
|
+
/** Starts recording; resolves once simctl reports it is recording (or throws when it exits first). */
|
|
66
|
+
async start(timeoutMs = 15_000) {
|
|
67
|
+
const child = spawn(this.xcrun, simctlRecordArgv(this.udid, this.file), { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
68
|
+
this.child = child;
|
|
69
|
+
let output = '';
|
|
70
|
+
const onData = (chunk) => {
|
|
71
|
+
output += chunk.toString('utf8');
|
|
72
|
+
};
|
|
73
|
+
child.stdout?.on('data', onData);
|
|
74
|
+
child.stderr?.on('data', onData);
|
|
75
|
+
child.once('exit', () => {
|
|
76
|
+
this.exited = true;
|
|
77
|
+
});
|
|
78
|
+
child.once('error', (error) => {
|
|
79
|
+
this.exited = true;
|
|
80
|
+
output += error.message;
|
|
81
|
+
});
|
|
82
|
+
const deadline = Date.now() + timeoutMs;
|
|
83
|
+
while (!/Recording started/i.test(output)) {
|
|
84
|
+
if (this.exited) {
|
|
85
|
+
this.cleanup();
|
|
86
|
+
throw new Error(`simctl could not record the screen: ${output.trim().split('\n').pop() ?? 'exited'}`);
|
|
87
|
+
}
|
|
88
|
+
if (Date.now() > deadline)
|
|
89
|
+
break;
|
|
90
|
+
await sleep(100);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Stops recording (SIGINT lets simctl finish the file) and returns the video path, or undefined. */
|
|
94
|
+
async stop(timeoutMs = 20_000) {
|
|
95
|
+
const child = this.child;
|
|
96
|
+
this.child = null;
|
|
97
|
+
if (!child)
|
|
98
|
+
return undefined;
|
|
99
|
+
if (!this.exited) {
|
|
100
|
+
const done = new Promise((resolve) => child.once('exit', () => resolve()));
|
|
101
|
+
child.kill('SIGINT');
|
|
102
|
+
await Promise.race([done, sleep(timeoutMs)]);
|
|
103
|
+
if (!this.exited)
|
|
104
|
+
child.kill('SIGKILL');
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
if (existsSync(this.file) && statSync(this.file).size > 0) {
|
|
108
|
+
remuxToMp4(this.file);
|
|
109
|
+
return this.file;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// fall through
|
|
114
|
+
}
|
|
115
|
+
this.cleanup();
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
/** Removes the recording directory (when the caller does not take the video). */
|
|
119
|
+
cleanup() {
|
|
120
|
+
rmSync(this.dir, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readJsonFile } from '../../home.js';
|
|
2
|
+
export const DEFAULT_SIMULATOR_BOOT_TIMEOUT_SECS = 300;
|
|
3
|
+
export const MIN_SIMULATOR_BOOT_TIMEOUT_SECS = 30;
|
|
4
|
+
export const MAX_SIMULATOR_BOOT_TIMEOUT_SECS = 3_600;
|
|
5
|
+
export const TEAM_ID_RE = /^[A-Z0-9]{10}$/;
|
|
6
|
+
export const WDA_BUNDLE_ID_RE = /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/;
|
|
7
|
+
export const DEFAULT_SIGNING_ID = 'Apple Development';
|
|
8
|
+
function pick(...values) {
|
|
9
|
+
for (const value of values) {
|
|
10
|
+
const trimmed = value?.trim();
|
|
11
|
+
if (trimmed)
|
|
12
|
+
return trimmed;
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
/** Reads the settings; values that are not valid are reported, not used. */
|
|
17
|
+
export function iosSettings(env, file) {
|
|
18
|
+
const problems = [];
|
|
19
|
+
let teamId = pick(env.RUNNER_IOS_TEAM_ID, file?.ios?.team_id);
|
|
20
|
+
if (teamId && !TEAM_ID_RE.test(teamId)) {
|
|
21
|
+
problems.push(`ios.team_id '${teamId}' is not a 10-character Apple Team ID`);
|
|
22
|
+
teamId = null;
|
|
23
|
+
}
|
|
24
|
+
let wdaBundleId = pick(env.RUNNER_IOS_WDA_BUNDLE_ID, file?.ios?.wda_bundle_id);
|
|
25
|
+
if (wdaBundleId && !WDA_BUNDLE_ID_RE.test(wdaBundleId)) {
|
|
26
|
+
problems.push(`ios.wda_bundle_id '${wdaBundleId}' is not a bundle id`);
|
|
27
|
+
wdaBundleId = null;
|
|
28
|
+
}
|
|
29
|
+
let signingId = pick(env.RUNNER_IOS_SIGNING_ID, file?.ios?.signing_id);
|
|
30
|
+
if (signingId && (signingId.length > 100 || /[\n\r"]/.test(signingId))) {
|
|
31
|
+
problems.push('ios.signing_id is not a code signing identity name');
|
|
32
|
+
signingId = null;
|
|
33
|
+
}
|
|
34
|
+
let simulatorBootTimeoutMs = DEFAULT_SIMULATOR_BOOT_TIMEOUT_SECS * 1000;
|
|
35
|
+
const rawBoot = pick(env.RUNNER_IOS_BOOT_TIMEOUT_SECS, file?.ios?.simulator_boot_timeout_secs == null ? null : String(file.ios.simulator_boot_timeout_secs));
|
|
36
|
+
if (rawBoot) {
|
|
37
|
+
const secs = Number(rawBoot);
|
|
38
|
+
if (Number.isInteger(secs) && secs >= MIN_SIMULATOR_BOOT_TIMEOUT_SECS && secs <= MAX_SIMULATOR_BOOT_TIMEOUT_SECS)
|
|
39
|
+
simulatorBootTimeoutMs = secs * 1000;
|
|
40
|
+
else
|
|
41
|
+
problems.push(`ios.simulator_boot_timeout_secs '${rawBoot}' is not a whole number of seconds from ${MIN_SIMULATOR_BOOT_TIMEOUT_SECS} to ${MAX_SIMULATOR_BOOT_TIMEOUT_SECS}`);
|
|
42
|
+
}
|
|
43
|
+
return { teamId, wdaBundleId, signingId, simulatorBootTimeoutMs, problems };
|
|
44
|
+
}
|
|
45
|
+
/** The settings from the runner home's config.json plus the environment. */
|
|
46
|
+
export function loadIosSettings(env, paths) {
|
|
47
|
+
let file = null;
|
|
48
|
+
try {
|
|
49
|
+
file = readJsonFile(paths.config);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
file = null;
|
|
53
|
+
}
|
|
54
|
+
return iosSettings(env, file);
|
|
55
|
+
}
|