@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,75 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join } from 'node:path';
|
|
3
|
+
import { BuildCache } from './cache.js';
|
|
4
|
+
import { obtainBuild } from './download.js';
|
|
5
|
+
/** `~/.leera-qa-runner/cache/builds`. */
|
|
6
|
+
export function buildCacheDir(paths) {
|
|
7
|
+
return join(paths.root, 'cache', 'builds');
|
|
8
|
+
}
|
|
9
|
+
const caches = new Map();
|
|
10
|
+
/** One cache object per directory per process, so pins are shared between jobs. */
|
|
11
|
+
export function buildCacheFor(paths, maxBytes) {
|
|
12
|
+
const dir = buildCacheDir(paths);
|
|
13
|
+
let cache = caches.get(dir);
|
|
14
|
+
if (!cache) {
|
|
15
|
+
cache = new BuildCache(dir, maxBytes);
|
|
16
|
+
caches.set(dir, cache);
|
|
17
|
+
}
|
|
18
|
+
return cache;
|
|
19
|
+
}
|
|
20
|
+
export class AppNotAvailable extends Error {
|
|
21
|
+
}
|
|
22
|
+
/** Builds `ci --build PATH` hashed on this machine, by sha256 (jobs name them with `install: runner-local`). */
|
|
23
|
+
const localBuilds = new Map();
|
|
24
|
+
export function registerLocalBuild(sha256, path) {
|
|
25
|
+
localBuilds.set(sha256.toLowerCase(), path);
|
|
26
|
+
}
|
|
27
|
+
export function unregisterLocalBuild(sha256) {
|
|
28
|
+
localBuilds.delete(sha256.toLowerCase());
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Where the app comes from for a job or session:
|
|
32
|
+
* - `build`: the uploaded build, from the cache or downloaded and verified;
|
|
33
|
+
* - `installed`: nothing to install, the app must already be on the device;
|
|
34
|
+
* - `path`: a file on this machine, only when the runner allows local paths;
|
|
35
|
+
* - `runner-local`: a build `ci --build` hashed on this machine (matched by sha256).
|
|
36
|
+
*/
|
|
37
|
+
export async function resolveAppFile(options) {
|
|
38
|
+
const { app } = options;
|
|
39
|
+
switch (app.install) {
|
|
40
|
+
case 'installed':
|
|
41
|
+
return { path: null, release: () => undefined };
|
|
42
|
+
case 'build': {
|
|
43
|
+
if (!app.build)
|
|
44
|
+
throw new AppNotAvailable('the environment installs a build, but the job names none (was it deleted?)');
|
|
45
|
+
const build = app.build;
|
|
46
|
+
const downloaded = await obtainBuild({
|
|
47
|
+
cache: buildCacheFor(options.paths),
|
|
48
|
+
build,
|
|
49
|
+
getUrl: () => options.getUrl(build.id),
|
|
50
|
+
});
|
|
51
|
+
return { path: downloaded.path, release: downloaded.release };
|
|
52
|
+
}
|
|
53
|
+
case 'path': {
|
|
54
|
+
if (!options.allowLocalPaths) {
|
|
55
|
+
throw new AppNotAvailable('this runner does not install apps from local paths (set builds.allow_local_paths to true)');
|
|
56
|
+
}
|
|
57
|
+
const path = app.path ?? '';
|
|
58
|
+
if (!isAbsolute(path) || !existsSync(path) || !statSync(path).isFile()) {
|
|
59
|
+
throw new AppNotAvailable(`the app file ${path || '(none)'} does not exist on this runner`);
|
|
60
|
+
}
|
|
61
|
+
return { path, release: () => undefined };
|
|
62
|
+
}
|
|
63
|
+
case 'runner-local': {
|
|
64
|
+
// The CI command registered the file; the server pinned the job to this runner.
|
|
65
|
+
const sha256 = (app.build?.sha256 ?? app.sha256 ?? '').toLowerCase();
|
|
66
|
+
const path = localBuilds.get(sha256);
|
|
67
|
+
if (!path || !existsSync(path)) {
|
|
68
|
+
throw new AppNotAvailable(`the CI build ${sha256 || '(no sha256)'} is not on this runner`);
|
|
69
|
+
}
|
|
70
|
+
return { path, release: () => undefined };
|
|
71
|
+
}
|
|
72
|
+
default:
|
|
73
|
+
throw new AppNotAvailable(`unknown app install mode '${app.install}'`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
|
+
import { buildKind, unquarantineArgv, unzip } from './unpack.js';
|
|
6
|
+
/**
|
|
7
|
+
* iOS builds (contracts §I): `.ipa` for real devices, a zipped `.app` built with
|
|
8
|
+
* `-sdk iphonesimulator` for simulators. The runner decides from the device kind.
|
|
9
|
+
*/
|
|
10
|
+
export const IPA_ON_SIMULATOR = 'this build is an .ipa; simulators need a zipped .app built with -sdk iphonesimulator';
|
|
11
|
+
export const SIMULATOR_APP_ON_DEVICE = 'this build is a simulator .app; real devices need an .ipa signed for your team';
|
|
12
|
+
/** Whether a build file can be installed on a simulator or a real device, from its name. */
|
|
13
|
+
export function iosBuildDecision(fileName, deviceKind) {
|
|
14
|
+
const kind = buildKind(fileName);
|
|
15
|
+
if (kind === 'ipa')
|
|
16
|
+
return deviceKind === 'simulator' ? { ok: false, message: IPA_ON_SIMULATOR } : { ok: true, kind: 'ipa' };
|
|
17
|
+
if (kind === 'app_zip' || kind === 'zip') {
|
|
18
|
+
return deviceKind === 'real' ? { ok: false, message: SIMULATOR_APP_ON_DEVICE } : { ok: true, kind: 'app_zip' };
|
|
19
|
+
}
|
|
20
|
+
return { ok: false, message: `'${basename(fileName)}' is not an iOS build; upload an .ipa (real devices) or a zipped .app (simulators)` };
|
|
21
|
+
}
|
|
22
|
+
/** Whether an unpacked `.app` was built for the simulator or for devices, from `CFBundleSupportedPlatforms`. */
|
|
23
|
+
export function appPlatformFromPlatforms(platforms) {
|
|
24
|
+
if (!Array.isArray(platforms))
|
|
25
|
+
return null;
|
|
26
|
+
if (platforms.includes('iPhoneSimulator'))
|
|
27
|
+
return 'simulator';
|
|
28
|
+
if (platforms.includes('iPhoneOS'))
|
|
29
|
+
return 'device';
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
export function plutilPlatformsArgv(appDir) {
|
|
33
|
+
return ['plutil', '-extract', 'CFBundleSupportedPlatforms', 'json', '-o', '-', join(appDir, 'Info.plist')];
|
|
34
|
+
}
|
|
35
|
+
export function plutilExecutableArgv(appDir) {
|
|
36
|
+
return ['plutil', '-extract', 'CFBundleExecutable', 'raw', '-o', '-', join(appDir, 'Info.plist')];
|
|
37
|
+
}
|
|
38
|
+
function plutil(argv) {
|
|
39
|
+
const result = spawnSync(argv[0], argv.slice(1), { encoding: 'utf8', timeout: 20_000 });
|
|
40
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
41
|
+
}
|
|
42
|
+
export function appPlatform(appDir) {
|
|
43
|
+
const out = plutil(plutilPlatformsArgv(appDir));
|
|
44
|
+
if (!out)
|
|
45
|
+
return null;
|
|
46
|
+
try {
|
|
47
|
+
return appPlatformFromPlatforms(JSON.parse(out));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** The app's executable (its process name in the device log). */
|
|
54
|
+
export function appExecutable(appDir) {
|
|
55
|
+
return plutil(plutilExecutableArgv(appDir)) || null;
|
|
56
|
+
}
|
|
57
|
+
/** The first `*.app` directory at the top of `dir` or one level below it (zips often wrap it in a folder). */
|
|
58
|
+
export function findAppBundle(dir, list = readdirSync, isDir = defaultIsDir) {
|
|
59
|
+
const top = list(dir).filter((name) => !name.startsWith('__MACOSX'));
|
|
60
|
+
const direct = top.find((name) => name.endsWith('.app') && isDir(join(dir, name)));
|
|
61
|
+
if (direct)
|
|
62
|
+
return join(dir, direct);
|
|
63
|
+
for (const name of top) {
|
|
64
|
+
const path = join(dir, name);
|
|
65
|
+
if (!isDir(path))
|
|
66
|
+
continue;
|
|
67
|
+
const nested = list(path).find((child) => child.endsWith('.app') && isDir(join(path, child)));
|
|
68
|
+
if (nested)
|
|
69
|
+
return join(path, nested);
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function defaultIsDir(path) {
|
|
74
|
+
try {
|
|
75
|
+
return statSync(path).isDirectory();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Unzips a simulator build into a private temp directory and returns its `.app`. */
|
|
82
|
+
export function unpackAppZip(zip) {
|
|
83
|
+
const dir = mkdtempSync(join(tmpdir(), 'qa-ios-app-'));
|
|
84
|
+
const cleanup = () => rmSync(dir, { recursive: true, force: true });
|
|
85
|
+
try {
|
|
86
|
+
unzip(zip, dir);
|
|
87
|
+
const app = findAppBundle(dir);
|
|
88
|
+
if (!app || !existsSync(join(app, 'Info.plist')))
|
|
89
|
+
throw new Error('the zip holds no .app bundle');
|
|
90
|
+
// The user's own build: macOS would otherwise refuse to run a quarantined download.
|
|
91
|
+
const [cmd, ...args] = unquarantineArgv(app);
|
|
92
|
+
spawnSync(cmd, args, { stdio: 'ignore', timeout: 60_000 });
|
|
93
|
+
return { app, cleanup };
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
cleanup();
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { accessSync, chmodSync, closeSync, constants, mkdirSync, mkdtempSync, openSync, readdirSync, readSync, realpathSync, rmSync, statSync, } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { basename, dirname, isAbsolute, join, sep, win32 } from 'node:path';
|
|
5
|
+
/** What a build file is, from its name. */
|
|
6
|
+
export function buildKind(fileName) {
|
|
7
|
+
const lower = fileName.toLowerCase();
|
|
8
|
+
if (lower.endsWith('.apk'))
|
|
9
|
+
return 'apk';
|
|
10
|
+
if (lower.endsWith('.aab'))
|
|
11
|
+
return 'aab';
|
|
12
|
+
if (lower.endsWith('.ipa'))
|
|
13
|
+
return 'ipa';
|
|
14
|
+
if (lower.endsWith('.app.zip'))
|
|
15
|
+
return 'app_zip';
|
|
16
|
+
if (lower.endsWith('.zip'))
|
|
17
|
+
return 'zip';
|
|
18
|
+
if (lower.endsWith('.dmg'))
|
|
19
|
+
return 'dmg';
|
|
20
|
+
if (lower.endsWith('.appimage'))
|
|
21
|
+
return 'appimage';
|
|
22
|
+
if (lower.endsWith('.exe'))
|
|
23
|
+
return 'exe';
|
|
24
|
+
return 'unknown';
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Windows' own bsdtar, which reads zips. Named by path because a GNU tar earlier on PATH (Git for
|
|
28
|
+
* Windows puts one there) cannot read zips and takes `C:` in a path for a remote host.
|
|
29
|
+
*/
|
|
30
|
+
export function windowsTar(env = process.env) {
|
|
31
|
+
return win32.join(env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe');
|
|
32
|
+
}
|
|
33
|
+
/** The fixed argv that extracts a zip into `dest` on this OS (bsdtar on macOS/Windows, unzip on Linux). */
|
|
34
|
+
export function unzipArgv(archive, dest, platform = process.platform, env = process.env) {
|
|
35
|
+
if (platform === 'linux')
|
|
36
|
+
return ['unzip', '-q', '-o', archive, '-d', dest];
|
|
37
|
+
return [platform === 'win32' ? windowsTar(env) : 'tar', '-xf', archive, '-C', dest];
|
|
38
|
+
}
|
|
39
|
+
/** `hdiutil attach` argv for a read-only, hidden mount at `mountPoint` (macOS). */
|
|
40
|
+
export function hdiutilAttachArgv(dmg, mountPoint) {
|
|
41
|
+
return ['hdiutil', 'attach', '-nobrowse', '-readonly', '-noautoopen', '-mountpoint', mountPoint, dmg];
|
|
42
|
+
}
|
|
43
|
+
export function hdiutilDetachArgv(mountPoint) {
|
|
44
|
+
return ['hdiutil', 'detach', '-force', mountPoint];
|
|
45
|
+
}
|
|
46
|
+
/** Removes the quarantine flag from the user's own build so macOS lets it launch (documented). */
|
|
47
|
+
export function unquarantineArgv(path) {
|
|
48
|
+
return ['xattr', '-dr', 'com.apple.quarantine', path];
|
|
49
|
+
}
|
|
50
|
+
function run(argv, timeoutMs = 300_000) {
|
|
51
|
+
const result = spawnSync(argv[0], argv.slice(1), { stdio: ['ignore', 'ignore', 'pipe'], timeout: timeoutMs, windowsHide: true });
|
|
52
|
+
if (result.error)
|
|
53
|
+
throw new Error(`${argv[0]} could not run: ${result.error.message}`);
|
|
54
|
+
if (result.status !== 0) {
|
|
55
|
+
throw new Error(`${argv[0]} failed (exit ${result.status}): ${result.stderr?.toString('utf8').trim().split('\n').pop() ?? ''}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function unzip(archive, dest) {
|
|
59
|
+
mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
60
|
+
run(unzipArgv(archive, dest));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The file a platform installs from a build. Android installs `.apk` files as they are;
|
|
64
|
+
* app bundles (`.aab`) need signing keys the runner does not have.
|
|
65
|
+
*/
|
|
66
|
+
export function androidInstallable(fileName) {
|
|
67
|
+
const kind = buildKind(fileName);
|
|
68
|
+
if (kind === 'apk')
|
|
69
|
+
return;
|
|
70
|
+
if (kind === 'aab')
|
|
71
|
+
throw new Error('Android App Bundles (.aab) cannot be installed directly; upload an .apk build');
|
|
72
|
+
throw new Error(`'${fileName}' is not an Android build; upload an .apk`);
|
|
73
|
+
}
|
|
74
|
+
export function desktopOs(platform = process.platform) {
|
|
75
|
+
if (platform === 'darwin')
|
|
76
|
+
return 'macos';
|
|
77
|
+
if (platform === 'win32')
|
|
78
|
+
return 'windows';
|
|
79
|
+
return 'linux';
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The fixed argv that extracts a desktop zip: `ditto -x -k` on macOS (keeps the symlinks and
|
|
83
|
+
* permissions of `.app` frameworks), System32 `tar.exe -xf` on Windows 10+ (bsdtar), `unzip` on Linux.
|
|
84
|
+
*/
|
|
85
|
+
export function desktopUnzipArgv(archive, dest, os = desktopOs(), env = process.env) {
|
|
86
|
+
if (os === 'macos')
|
|
87
|
+
return ['ditto', '-x', '-k', archive, dest];
|
|
88
|
+
if (os === 'windows')
|
|
89
|
+
return [windowsTar(env), '-xf', archive, '-C', dest];
|
|
90
|
+
return ['unzip', '-q', '-o', archive, '-d', dest];
|
|
91
|
+
}
|
|
92
|
+
/** Copies an `.app` bundle with its symlinks, permissions and signature intact (macOS). */
|
|
93
|
+
export function dittoCopyArgv(source, dest) {
|
|
94
|
+
return ['ditto', source, dest];
|
|
95
|
+
}
|
|
96
|
+
export class DesktopBuildError extends Error {
|
|
97
|
+
}
|
|
98
|
+
/** The build kinds an Electron job accepts, from the file name. */
|
|
99
|
+
export function desktopBuildKind(fileName) {
|
|
100
|
+
const kind = buildKind(fileName);
|
|
101
|
+
if (kind === 'zip' || kind === 'app_zip')
|
|
102
|
+
return 'zip';
|
|
103
|
+
if (kind === 'dmg' || kind === 'appimage' || kind === 'exe')
|
|
104
|
+
return kind;
|
|
105
|
+
throw new DesktopBuildError(`'${basename(fileName)}' is not a desktop build; upload a .zip, .dmg, .AppImage or portable .exe`);
|
|
106
|
+
}
|
|
107
|
+
export const nodeDesktopFs = {
|
|
108
|
+
list: (path) => readdirSync(path).sort((a, b) => a.localeCompare(b)),
|
|
109
|
+
isDir: (path) => {
|
|
110
|
+
try {
|
|
111
|
+
return statSync(path).isDirectory();
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
isFile: (path) => {
|
|
118
|
+
try {
|
|
119
|
+
return statSync(path).isFile();
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
isExecutable: (path) => {
|
|
126
|
+
if (process.platform === 'win32')
|
|
127
|
+
return true;
|
|
128
|
+
try {
|
|
129
|
+
accessSync(path, constants.X_OK);
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
isElf: (path) => {
|
|
137
|
+
let fd = null;
|
|
138
|
+
try {
|
|
139
|
+
fd = openSync(path, 'r');
|
|
140
|
+
const header = Buffer.alloc(4);
|
|
141
|
+
return readSync(fd, header, 0, 4, 0) === 4 && header.equals(ELF_MAGIC);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
if (fd !== null)
|
|
148
|
+
closeSync(fd);
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
realpath: (path) => realpathSync(path),
|
|
152
|
+
};
|
|
153
|
+
const ELF_MAGIC = Buffer.from([0x7f, 0x45, 0x4c, 0x46]);
|
|
154
|
+
/** Helpers Electron and Chromium ship next to the app binary; never the app itself. */
|
|
155
|
+
const LINUX_HELPERS = new Set(['chrome-sandbox', 'chrome_crashpad_handler', 'crashpad_handler']);
|
|
156
|
+
function isWindowsHelper(name) {
|
|
157
|
+
const lower = name.toLowerCase();
|
|
158
|
+
return lower.startsWith('uninstall') || /update.*\.exe$/.test(lower);
|
|
159
|
+
}
|
|
160
|
+
/** The entries to search: the root, or the single folder a zip wrapped everything in. */
|
|
161
|
+
function searchRoots(root, fs) {
|
|
162
|
+
const entries = fs.list(root).filter((name) => !name.startsWith('__MACOSX') && !name.startsWith('.'));
|
|
163
|
+
const roots = [root];
|
|
164
|
+
if (entries.length === 1 && fs.isDir(join(root, entries[0])) && !entries[0].endsWith('.app'))
|
|
165
|
+
roots.push(join(root, entries[0]));
|
|
166
|
+
return roots;
|
|
167
|
+
}
|
|
168
|
+
/** `Foo.app/Contents/MacOS/<executable>`: the one named like the bundle, else the first executable file. */
|
|
169
|
+
export function bundleExecutable(appDir, fs = nodeDesktopFs) {
|
|
170
|
+
const macos = join(appDir, 'Contents', 'MacOS');
|
|
171
|
+
if (!fs.isDir(macos))
|
|
172
|
+
return null;
|
|
173
|
+
const files = fs.list(macos).filter((name) => fs.isFile(join(macos, name)) && fs.isExecutable(join(macos, name)));
|
|
174
|
+
const named = basename(appDir).replace(/\.app$/i, '');
|
|
175
|
+
const pick = files.find((name) => name === named) ?? files[0];
|
|
176
|
+
return pick ? join(macos, pick) : null;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Finds the app's executable in an unpacked desktop build (contracts §K): macOS
|
|
180
|
+
* `*.app/Contents/MacOS/<executable>`; Windows the first `*.exe` not named `Uninstall*` or
|
|
181
|
+
* `*Update*.exe`; Linux an `.AppImage` or the first ELF executable. Each is looked for at the
|
|
182
|
+
* top level, then inside a single folder the archive wrapped the app in.
|
|
183
|
+
*/
|
|
184
|
+
export function detectExecutable(root, os, fs = nodeDesktopFs) {
|
|
185
|
+
for (const dir of searchRoots(root, fs)) {
|
|
186
|
+
const names = fs.list(dir);
|
|
187
|
+
if (os === 'macos') {
|
|
188
|
+
for (const name of names.filter((n) => n.toLowerCase().endsWith('.app') && fs.isDir(join(dir, n)))) {
|
|
189
|
+
const executable = bundleExecutable(join(dir, name), fs);
|
|
190
|
+
if (executable)
|
|
191
|
+
return executable;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else if (os === 'windows') {
|
|
195
|
+
const exe = names.find((n) => n.toLowerCase().endsWith('.exe') && !isWindowsHelper(n) && fs.isFile(join(dir, n)));
|
|
196
|
+
if (exe)
|
|
197
|
+
return join(dir, exe);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
const appImage = names.find((n) => n.toLowerCase().endsWith('.appimage') && fs.isFile(join(dir, n)));
|
|
201
|
+
if (appImage)
|
|
202
|
+
return join(dir, appImage);
|
|
203
|
+
const elf = names.find((n) => {
|
|
204
|
+
const path = join(dir, n);
|
|
205
|
+
return !LINUX_HELPERS.has(n) && !/\.so(\.|$)/.test(n) && fs.isFile(path) && fs.isExecutable(path) && fs.isElf(path);
|
|
206
|
+
});
|
|
207
|
+
if (elf)
|
|
208
|
+
return join(dir, elf);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The environment's "Executable" override (`launch_activity`, contracts §K): a path relative
|
|
215
|
+
* to the unpacked build root. Absolute paths and `..` are refused; a `.app` names its bundle
|
|
216
|
+
* executable. The result must stay inside the root after resolving symlinks.
|
|
217
|
+
*/
|
|
218
|
+
export function executableOverride(root, relative, os, fs = nodeDesktopFs) {
|
|
219
|
+
const trimmed = relative.trim();
|
|
220
|
+
if (!trimmed)
|
|
221
|
+
throw new DesktopBuildError('the executable path is empty');
|
|
222
|
+
const parts = trimmed.split(/[\\/]+/).filter((part) => part !== '' && part !== '.');
|
|
223
|
+
if (isAbsolute(trimmed) || win32.isAbsolute(trimmed) || /^[A-Za-z]:/.test(trimmed) || trimmed.startsWith('/') || trimmed.startsWith('\\')) {
|
|
224
|
+
throw new DesktopBuildError(`the executable path '${trimmed}' must be relative to the build`);
|
|
225
|
+
}
|
|
226
|
+
if (parts.some((part) => part === '..') || parts.length === 0) {
|
|
227
|
+
throw new DesktopBuildError(`the executable path '${trimmed}' must stay inside the build (no '..')`);
|
|
228
|
+
}
|
|
229
|
+
let path = join(root, ...parts);
|
|
230
|
+
if (os === 'macos' && path.toLowerCase().endsWith('.app') && fs.isDir(path)) {
|
|
231
|
+
const inner = bundleExecutable(path, fs);
|
|
232
|
+
if (!inner)
|
|
233
|
+
throw new DesktopBuildError(`'${trimmed}' has no executable in Contents/MacOS`);
|
|
234
|
+
path = inner;
|
|
235
|
+
}
|
|
236
|
+
if (!fs.isFile(path))
|
|
237
|
+
throw new DesktopBuildError(`the executable '${trimmed}' is not in the build`);
|
|
238
|
+
const realRoot = fs.realpath(root);
|
|
239
|
+
const real = fs.realpath(path);
|
|
240
|
+
if (real !== realRoot && !real.startsWith(realRoot.endsWith(sep) ? realRoot : `${realRoot}${sep}`)) {
|
|
241
|
+
throw new DesktopBuildError(`the executable '${trimmed}' points outside the build`);
|
|
242
|
+
}
|
|
243
|
+
if (!fs.isExecutable(path))
|
|
244
|
+
throw new DesktopBuildError(`'${trimmed}' is not executable`);
|
|
245
|
+
return path;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Unpacks a desktop build file into a private temp directory and finds its executable:
|
|
249
|
+
* `.zip` extracted, `.dmg` mounted read-only and its `.app` copied out, `.AppImage` and
|
|
250
|
+
* portable `.exe` used in place. On macOS the quarantine flag is removed (the user's own build).
|
|
251
|
+
*/
|
|
252
|
+
export function unpackDesktopBuild(file, override, deps = {}) {
|
|
253
|
+
const os = deps.os ?? desktopOs();
|
|
254
|
+
const fs = deps.fs ?? nodeDesktopFs;
|
|
255
|
+
const runArgv = deps.run ?? ((argv) => run(argv));
|
|
256
|
+
const remove = deps.remove ?? ((path) => rmSync(path, { recursive: true, force: true }));
|
|
257
|
+
const kind = desktopBuildKind(file);
|
|
258
|
+
if (kind === 'appimage' || kind === 'exe') {
|
|
259
|
+
if (kind === 'appimage' && os !== 'linux')
|
|
260
|
+
throw new DesktopBuildError('.AppImage builds run on Linux runners only');
|
|
261
|
+
if (kind === 'exe' && os !== 'windows')
|
|
262
|
+
throw new DesktopBuildError('.exe builds run on Windows runners only');
|
|
263
|
+
// The cached copy is private to the runner (0600); it needs the executable bit to start.
|
|
264
|
+
if (kind === 'appimage')
|
|
265
|
+
(deps.chmod ?? chmodSync)(file, 0o700);
|
|
266
|
+
return { executable: file, root: dirname(file), tempDir: null, cleanup: () => undefined };
|
|
267
|
+
}
|
|
268
|
+
const dir = (deps.tempDir ?? (() => mkdtempSync(join(tmpdir(), 'qa-desktop-app-'))))();
|
|
269
|
+
const cleanup = () => remove(dir);
|
|
270
|
+
try {
|
|
271
|
+
if (kind === 'zip') {
|
|
272
|
+
runArgv(desktopUnzipArgv(file, dir, os));
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
if (os !== 'macos')
|
|
276
|
+
throw new DesktopBuildError('.dmg builds run on macOS runners only');
|
|
277
|
+
const mount = join(dir, '.mount');
|
|
278
|
+
mkdirSync(mount, { recursive: true, mode: 0o700 });
|
|
279
|
+
runArgv(hdiutilAttachArgv(file, mount));
|
|
280
|
+
try {
|
|
281
|
+
const app = fs.list(mount).find((name) => name.toLowerCase().endsWith('.app') && fs.isDir(join(mount, name)));
|
|
282
|
+
if (!app)
|
|
283
|
+
throw new DesktopBuildError('the .dmg holds no .app bundle');
|
|
284
|
+
runArgv(dittoCopyArgv(join(mount, app), join(dir, app)));
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
try {
|
|
288
|
+
runArgv(hdiutilDetachArgv(mount));
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// A busy mount detaches with -force; nothing more to do here.
|
|
292
|
+
}
|
|
293
|
+
remove(mount);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (os === 'macos') {
|
|
297
|
+
try {
|
|
298
|
+
runArgv(unquarantineArgv(dir));
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
// Not quarantined, or xattr missing: launching tells the real story.
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const executable = override ? executableOverride(dir, override, os, fs) : detectExecutable(dir, os, fs);
|
|
305
|
+
if (!executable)
|
|
306
|
+
throw new DesktopBuildError(noExecutableMessage(os));
|
|
307
|
+
return { executable, root: dir, tempDir: dir, cleanup };
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
cleanup();
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
export function noExecutableMessage(os) {
|
|
315
|
+
if (os === 'macos')
|
|
316
|
+
return 'the build holds no .app bundle with an executable in Contents/MacOS (set the executable path in the environment)';
|
|
317
|
+
if (os === 'windows')
|
|
318
|
+
return 'the build holds no .exe at its top level (set the executable path in the environment)';
|
|
319
|
+
return 'the build holds no .AppImage or executable at its top level (set the executable path in the environment)';
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* An app installed at a path on the runner (`install: path`, contracts §K): the executable, a
|
|
323
|
+
* `.app` bundle, or a directory to search like an unpacked build.
|
|
324
|
+
*/
|
|
325
|
+
export function localDesktopApp(path, override, os = desktopOs(), fs = nodeDesktopFs) {
|
|
326
|
+
if (!isAbsolute(path) && !win32.isAbsolute(path))
|
|
327
|
+
throw new DesktopBuildError(`the app path '${path}' must be absolute`);
|
|
328
|
+
if (fs.isDir(path)) {
|
|
329
|
+
if (override)
|
|
330
|
+
return executableOverride(path, override, os, fs);
|
|
331
|
+
if (os === 'macos' && path.toLowerCase().replace(/[\\/]+$/, '').endsWith('.app')) {
|
|
332
|
+
const executable = bundleExecutable(path, fs);
|
|
333
|
+
if (!executable)
|
|
334
|
+
throw new DesktopBuildError(`${path} has no executable in Contents/MacOS`);
|
|
335
|
+
return executable;
|
|
336
|
+
}
|
|
337
|
+
const found = detectExecutable(path, os, fs);
|
|
338
|
+
if (!found)
|
|
339
|
+
throw new DesktopBuildError(noExecutableMessage(os));
|
|
340
|
+
return found;
|
|
341
|
+
}
|
|
342
|
+
if (!fs.isFile(path))
|
|
343
|
+
throw new DesktopBuildError(`the app ${path} does not exist on this runner`);
|
|
344
|
+
if (!fs.isExecutable(path))
|
|
345
|
+
throw new DesktopBuildError(`the app ${path} is not executable`);
|
|
346
|
+
return path;
|
|
347
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { hostname } from 'node:os';
|
|
2
|
+
import { registryAvailability } from './drivers/registry.js';
|
|
3
|
+
import { findFfmpeg } from './devices/screen-record.js';
|
|
4
|
+
import { playwrightVersion } from './version.js';
|
|
5
|
+
export const PROTOCOL_VERSION = 2;
|
|
6
|
+
export function osName(platform) {
|
|
7
|
+
if (platform === 'darwin')
|
|
8
|
+
return 'macos';
|
|
9
|
+
if (platform === 'win32')
|
|
10
|
+
return 'windows';
|
|
11
|
+
return platform;
|
|
12
|
+
}
|
|
13
|
+
export function hostInfo() {
|
|
14
|
+
return { os: osName(process.platform), arch: process.arch, hostname: hostname(), node: process.versions.node };
|
|
15
|
+
}
|
|
16
|
+
/** Asks every driver whether its platform is ready on this host. */
|
|
17
|
+
export async function detect(registry, host) {
|
|
18
|
+
const devices = [];
|
|
19
|
+
const labels = new Set();
|
|
20
|
+
const unavailable = {};
|
|
21
|
+
for (const [platform, availability] of await registryAvailability(registry, host)) {
|
|
22
|
+
devices.push(...availability.devices);
|
|
23
|
+
const usable = availability.devices.filter((device) => device.state === 'available' || device.can_boot);
|
|
24
|
+
for (const label of [...availability.labels, ...deriveLabels(usable)])
|
|
25
|
+
labels.add(label);
|
|
26
|
+
if (!availability.ok)
|
|
27
|
+
unavailable[platform] = availability.reasons;
|
|
28
|
+
}
|
|
29
|
+
return { devices, labels: [...labels], unavailable };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Labels the server derives from devices too (contracts §C, §H): `web-chromium` for a web
|
|
33
|
+
* device; the platform name plus `<platform>-<kind>` for mobile devices (`android`,
|
|
34
|
+
* `android-emulator`, `android-real`, `ios`, `ios-simulator`, `ios-real`); the platform
|
|
35
|
+
* name for desktop host devices.
|
|
36
|
+
*/
|
|
37
|
+
export function deriveLabels(devices) {
|
|
38
|
+
const labels = new Set();
|
|
39
|
+
for (const device of devices) {
|
|
40
|
+
if (device.platform === 'web') {
|
|
41
|
+
labels.add('web-chromium');
|
|
42
|
+
}
|
|
43
|
+
else if (device.platform === 'android' || device.platform === 'ios') {
|
|
44
|
+
labels.add(device.platform);
|
|
45
|
+
if (device.kind !== 'host')
|
|
46
|
+
labels.add(`${device.platform}-${device.kind}`);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
labels.add(device.platform);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return [...labels];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Protocol features by platform present; `sessions` = this runner handles device sessions
|
|
56
|
+
* (contracts §H3). Web and Electron record webm (Playwright, contracts §K); mobile records mp4;
|
|
57
|
+
* Tauri records no video and adds no feature beyond sessions (contracts §L). Native macOS and
|
|
58
|
+
* Windows apps carry the Appium log on failed steps and record mp4 only with ffmpeg (contracts §M).
|
|
59
|
+
*/
|
|
60
|
+
export function featuresFor(platforms, options = {}) {
|
|
61
|
+
const features = [];
|
|
62
|
+
if (platforms.length > 0)
|
|
63
|
+
features.push('sessions');
|
|
64
|
+
if (platforms.includes('web') || platforms.includes('electron'))
|
|
65
|
+
features.push('video_webm', 'step_debug');
|
|
66
|
+
if (platforms.some((platform) => platform === 'android' || platform === 'ios')) {
|
|
67
|
+
features.push('video_mp4', 'device_log');
|
|
68
|
+
if (!features.includes('step_debug'))
|
|
69
|
+
features.push('step_debug');
|
|
70
|
+
}
|
|
71
|
+
if (platforms.some((platform) => platform === 'macos' || platform === 'windows')) {
|
|
72
|
+
if (!features.includes('step_debug'))
|
|
73
|
+
features.push('step_debug');
|
|
74
|
+
if (options.ffmpeg && !features.includes('video_mp4'))
|
|
75
|
+
features.push('video_mp4');
|
|
76
|
+
}
|
|
77
|
+
return features;
|
|
78
|
+
}
|
|
79
|
+
export function buildCapabilities(devices, host, extraTools = {}, options = { ffmpeg: findFfmpeg() !== null }) {
|
|
80
|
+
const platforms = [...new Set(devices.map((device) => device.platform))];
|
|
81
|
+
const tools = { node: host.node };
|
|
82
|
+
const playwright = playwrightVersion();
|
|
83
|
+
if (playwright)
|
|
84
|
+
tools.playwright = playwright;
|
|
85
|
+
Object.assign(tools, extraTools);
|
|
86
|
+
return {
|
|
87
|
+
protocol: PROTOCOL_VERSION,
|
|
88
|
+
features: featuresFor(platforms, options),
|
|
89
|
+
platforms,
|
|
90
|
+
tools,
|
|
91
|
+
devices,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createReadStream, statSync } from 'node:fs';
|
|
3
|
+
import { basename, extname } from 'node:path';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
import { ExitCode, ExitError, messageOf } from '../errors.js';
|
|
6
|
+
/** Hashes a build file while streaming it. */
|
|
7
|
+
export async function describeBuildFile(path) {
|
|
8
|
+
let size;
|
|
9
|
+
try {
|
|
10
|
+
const stats = statSync(path);
|
|
11
|
+
if (!stats.isFile())
|
|
12
|
+
throw new Error('not a file');
|
|
13
|
+
size = stats.size;
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
throw new ExitError(ExitCode.usage, `the build ${path} cannot be read: ${messageOf(error)}`);
|
|
17
|
+
}
|
|
18
|
+
const hash = createHash('sha256');
|
|
19
|
+
await pipeline(createReadStream(path), hash);
|
|
20
|
+
return { path, file_name: basename(path), sha256: hash.digest('hex'), size_bytes: size };
|
|
21
|
+
}
|
|
22
|
+
const CONTENT_TYPES = {
|
|
23
|
+
'.apk': 'application/vnd.android.package-archive',
|
|
24
|
+
'.ipa': 'application/octet-stream',
|
|
25
|
+
'.zip': 'application/zip',
|
|
26
|
+
'.dmg': 'application/x-apple-diskimage',
|
|
27
|
+
'.exe': 'application/vnd.microsoft.portable-executable',
|
|
28
|
+
'.msi': 'application/x-msi',
|
|
29
|
+
'.appimage': 'application/octet-stream',
|
|
30
|
+
};
|
|
31
|
+
export function contentTypeFor(fileName) {
|
|
32
|
+
return CONTENT_TYPES[extname(fileName).toLowerCase()] ?? 'application/octet-stream';
|
|
33
|
+
}
|
|
34
|
+
/** A project flag is an id when it is all digits, otherwise a project key. */
|
|
35
|
+
export function projectSelector(project) {
|
|
36
|
+
if (!project)
|
|
37
|
+
return {};
|
|
38
|
+
return /^\d+$/.test(project) ? { project_id: Number(project) } : { project_key: project };
|
|
39
|
+
}
|
|
40
|
+
/** create → presigned PUT → finish; falls back to the server relay when the PUT fails. */
|
|
41
|
+
export async function uploadBuild(client, options) {
|
|
42
|
+
const { file, log } = options;
|
|
43
|
+
const contentType = contentTypeFor(file.file_name);
|
|
44
|
+
const selector = projectSelector(options.project);
|
|
45
|
+
const meta = Object.fromEntries(Object.entries(options.meta ?? {}).filter(([, value]) => value));
|
|
46
|
+
const created = await client.createBuild({
|
|
47
|
+
...(selector.project_id !== undefined ? { project: selector.project_id } : {}),
|
|
48
|
+
...(selector.project_key !== undefined ? { project_key: selector.project_key } : {}),
|
|
49
|
+
platform: options.platform,
|
|
50
|
+
file_name: file.file_name,
|
|
51
|
+
size_bytes: file.size_bytes,
|
|
52
|
+
sha256: file.sha256,
|
|
53
|
+
content_type: contentType,
|
|
54
|
+
...meta,
|
|
55
|
+
});
|
|
56
|
+
const buildId = created.build.id;
|
|
57
|
+
log.info('build created', { build_id: buildId, sha256: file.sha256, size_bytes: file.size_bytes });
|
|
58
|
+
if (!options.relay && created.upload?.url) {
|
|
59
|
+
try {
|
|
60
|
+
await client.putPresigned(created.upload, file.path, contentType);
|
|
61
|
+
return await client.finishBuild(buildId);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
log.warn('direct upload failed, sending the build through the server', { build_id: buildId, error: messageOf(error) });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return client.relayBuild(buildId, file.path, file.file_name, contentType);
|
|
68
|
+
}
|