@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,165 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
|
|
2
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { loadFileConfig, saveFileConfig } from '../../config.js';
|
|
5
|
+
import { accessibilityMarker } from '../../devices/macos.js';
|
|
6
|
+
import { ExitCode } from '../../errors.js';
|
|
7
|
+
import { ensureHome, homePaths, readJsonFile } from '../../home.js';
|
|
8
|
+
import { applyPlan, backendFor, CAPTURED_ENV, serviceSpec, } from '../../service/index.js';
|
|
9
|
+
import { usageError } from '../output.js';
|
|
10
|
+
const usage = `Usage: leera-qa-runner service <install|uninstall|start|stop|restart|status|logs> [-f]
|
|
11
|
+
|
|
12
|
+
Runs the runner in the background for the logged-in user, starting at login:
|
|
13
|
+
macOS a LaunchAgent (~/Library/LaunchAgents/io.leera.qa-runner.plist)
|
|
14
|
+
Windows a scheduled task at logon (leera-qa-runner)
|
|
15
|
+
Linux a systemd user unit (leera-qa-runner.service)
|
|
16
|
+
|
|
17
|
+
service logs [-f] Print the runner log; -f keeps following it
|
|
18
|
+
`;
|
|
19
|
+
const ACTIONS = ['install', 'uninstall', 'start', 'stop', 'restart', 'status'];
|
|
20
|
+
/** Last `lines` lines of a file, read from the end. */
|
|
21
|
+
export function tail(path, lines) {
|
|
22
|
+
const size = statSync(path).size;
|
|
23
|
+
const chunk = Math.min(size, 256 * 1024);
|
|
24
|
+
const buffer = Buffer.alloc(chunk);
|
|
25
|
+
const fd = openSync(path, 'r');
|
|
26
|
+
try {
|
|
27
|
+
readSync(fd, buffer, 0, chunk, size - chunk);
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
closeSync(fd);
|
|
31
|
+
}
|
|
32
|
+
const text = buffer.toString('utf8').split('\n');
|
|
33
|
+
if (text.at(-1) === '')
|
|
34
|
+
text.pop();
|
|
35
|
+
return text.slice(-lines).join('\n');
|
|
36
|
+
}
|
|
37
|
+
async function follow(io, path) {
|
|
38
|
+
let position = existsSync(path) ? statSync(path).size : 0;
|
|
39
|
+
for (;;) {
|
|
40
|
+
await sleep(1_000);
|
|
41
|
+
if (!existsSync(path))
|
|
42
|
+
continue;
|
|
43
|
+
const size = statSync(path).size;
|
|
44
|
+
if (size < position)
|
|
45
|
+
position = 0; // rotated
|
|
46
|
+
if (size === position)
|
|
47
|
+
continue;
|
|
48
|
+
const buffer = Buffer.alloc(size - position);
|
|
49
|
+
const fd = openSync(path, 'r');
|
|
50
|
+
try {
|
|
51
|
+
readSync(fd, buffer, 0, buffer.length, position);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
closeSync(fd);
|
|
55
|
+
}
|
|
56
|
+
position = size;
|
|
57
|
+
io.stdout(buffer.toString('utf8').replace(/\n$/, ''));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function processAlive(pid) {
|
|
61
|
+
if (typeof pid !== 'number')
|
|
62
|
+
return false;
|
|
63
|
+
try {
|
|
64
|
+
process.kill(pid, 0);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export const command = {
|
|
72
|
+
summary: 'Install and control the background service',
|
|
73
|
+
usage,
|
|
74
|
+
async run(args, io) {
|
|
75
|
+
const { values, positionals } = parseArgs({
|
|
76
|
+
args,
|
|
77
|
+
strict: true,
|
|
78
|
+
allowPositionals: true,
|
|
79
|
+
options: { follow: { type: 'boolean', short: 'f' }, lines: { type: 'string', short: 'n' } },
|
|
80
|
+
});
|
|
81
|
+
const [action, ...extra] = positionals;
|
|
82
|
+
if (!action || extra.length > 0)
|
|
83
|
+
throw usageError('say what to do', usage);
|
|
84
|
+
const paths = homePaths(io.env);
|
|
85
|
+
if (action === 'logs') {
|
|
86
|
+
const lines = values.lines ? Number(values.lines) : 100;
|
|
87
|
+
if (!Number.isInteger(lines) || lines <= 0)
|
|
88
|
+
throw usageError('--lines must be a positive whole number');
|
|
89
|
+
if (existsSync(paths.log))
|
|
90
|
+
io.stdout(tail(paths.log, lines));
|
|
91
|
+
else
|
|
92
|
+
io.stdout(`No log yet at ${paths.log}.`);
|
|
93
|
+
if (values.follow)
|
|
94
|
+
await follow(io, paths.log);
|
|
95
|
+
return ExitCode.ok;
|
|
96
|
+
}
|
|
97
|
+
if (!ACTIONS.includes(action))
|
|
98
|
+
throw usageError(`unknown service action '${action}'`, usage);
|
|
99
|
+
const backend = backendFor(io.platform);
|
|
100
|
+
if (!backend)
|
|
101
|
+
throw usageError(`background services are not supported on ${io.platform}; run \`leera-qa-runner start\``);
|
|
102
|
+
const spec = serviceSpec(io.env, paths.root, paths.logs);
|
|
103
|
+
if (action === 'install') {
|
|
104
|
+
const { file, token } = loadFileConfig(paths);
|
|
105
|
+
if (!file?.url || !token) {
|
|
106
|
+
throw usageError('connect first: `leera-qa-runner connect --url <server>`');
|
|
107
|
+
}
|
|
108
|
+
// Services start without the login shell; keep the variables jobs rely on.
|
|
109
|
+
const env = { ...(file.env ?? {}) };
|
|
110
|
+
for (const name of CAPTURED_ENV) {
|
|
111
|
+
const value = io.env[name];
|
|
112
|
+
if (value)
|
|
113
|
+
env[name] = value;
|
|
114
|
+
}
|
|
115
|
+
ensureHome(paths);
|
|
116
|
+
saveFileConfig(paths, { ...file, env });
|
|
117
|
+
}
|
|
118
|
+
const plan = backend.plan(action, spec);
|
|
119
|
+
const result = await applyPlan(plan);
|
|
120
|
+
if (action === 'status' && result.ok) {
|
|
121
|
+
for (const output of result.outputs) {
|
|
122
|
+
const text = `${output.result.stdout}${output.result.stderr}`.trim();
|
|
123
|
+
if (text)
|
|
124
|
+
io.stdout(text);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (!result.ok && result.failed) {
|
|
128
|
+
const text = `${result.failed.result.stdout}${result.failed.result.stderr}`.trim();
|
|
129
|
+
if (action === 'status') {
|
|
130
|
+
io.stdout('The service is not installed or not loaded.');
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
io.stderr(`\`${result.failed.argv.join(' ')}\` failed (exit ${result.failed.result.code})${text ? `: ${text}` : ''}`);
|
|
134
|
+
return ExitCode.error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (action === 'status') {
|
|
138
|
+
const state = readJsonFile(paths.state);
|
|
139
|
+
if (state) {
|
|
140
|
+
const alive = processAlive(state.pid) && state.stopped !== true;
|
|
141
|
+
io.stdout('');
|
|
142
|
+
io.stdout(`Runner process: ${alive ? `running (pid ${String(state.pid)})` : 'not running'}`);
|
|
143
|
+
io.stdout(`Runner id: ${String(state.runner_id ?? 'not registered')} version: ${String(state.version ?? '?')}`);
|
|
144
|
+
const jobs = Array.isArray(state.jobs) ? state.jobs : [];
|
|
145
|
+
io.stdout(`Jobs running: ${alive ? jobs.length : 0}`);
|
|
146
|
+
}
|
|
147
|
+
if (io.platform === 'darwin' && existsSync(accessibilityMarker(paths))) {
|
|
148
|
+
io.stdout('');
|
|
149
|
+
io.stdout(`macOS apps: not advertised — macOS refused UI testing (${accessibilityMarker(paths)}); grant the permission, then run \`leera-qa-runner setup macos\``);
|
|
150
|
+
}
|
|
151
|
+
return result.ok ? ExitCode.ok : ExitCode.error;
|
|
152
|
+
}
|
|
153
|
+
for (const note of plan.notes ?? [])
|
|
154
|
+
io.stdout(note);
|
|
155
|
+
const done = {
|
|
156
|
+
install: `Installed and started the service (${backend.definitionPath(spec)}).`,
|
|
157
|
+
uninstall: 'Removed the service.',
|
|
158
|
+
start: 'Started the service.',
|
|
159
|
+
stop: 'Stopped the service.',
|
|
160
|
+
restart: 'Restarted the service.',
|
|
161
|
+
};
|
|
162
|
+
io.stdout(done[action] ?? 'Done.');
|
|
163
|
+
return ExitCode.ok;
|
|
164
|
+
},
|
|
165
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { ExitCode } from '../../errors.js';
|
|
6
|
+
import { ensureHome, homePaths } from '../../home.js';
|
|
7
|
+
import { usageError } from '../output.js';
|
|
8
|
+
import { setupAndroid } from '../../devices/android/setup.js';
|
|
9
|
+
import { displayNeed, findOnPath, XVFB_DISPLAY } from '../../devices/desktop.js';
|
|
10
|
+
import { setupIos } from '../../devices/ios/setup.js';
|
|
11
|
+
import { setupMacos } from '../../devices/macos.js';
|
|
12
|
+
import { setupWindows } from '../../devices/windows.js';
|
|
13
|
+
import { findNativeDriver, findTauriDriver, loadTauriSettings, MSEDGEDRIVER_URL, TAURI_DRIVER_INSTALL, TAURI_DRIVER_VERSION, WEBKIT_DRIVER_INSTALL, } from '../../devices/tauri.js';
|
|
14
|
+
const usage = `Usage: leera-qa-runner setup browsers|android|ios|electron|tauri|macos|windows [options]
|
|
15
|
+
|
|
16
|
+
Installs what a platform needs. This version supports:
|
|
17
|
+
browsers Chromium for web jobs (into the runner home unless PLAYWRIGHT_BROWSERS_PATH is set)
|
|
18
|
+
android Prepares Appium's home with the bundled UiAutomator2 driver, checks the
|
|
19
|
+
Android SDK and lists devices and emulators
|
|
20
|
+
ios (macOS) Checks Xcode, prepares Appium's home with the bundled XCUITest driver,
|
|
21
|
+
prebuilds WebDriverAgent for simulators and lists simulators and devices
|
|
22
|
+
electron Nothing to install: Electron apps run with the bundled Playwright (Linux
|
|
23
|
+
runners without a display need Xvfb)
|
|
24
|
+
tauri Checks tauri-driver and the native WebDriver (Linux: WebKitWebDriver,
|
|
25
|
+
Windows: msedgedriver) and prints how to install what is missing; on
|
|
26
|
+
macOS explains the embedded WebDriver plugin (installs nothing)
|
|
27
|
+
macos (macOS) Checks Xcode, prepares Appium's home with the bundled Mac2 driver and
|
|
28
|
+
prints the Accessibility and Automation permission steps (changes no
|
|
29
|
+
system settings)
|
|
30
|
+
windows (Windows) Prepares Appium's home with the bundled Windows driver, downloads
|
|
31
|
+
WinAppDriver 1.2.1 (checked by sha256) into the runner home and prints the
|
|
32
|
+
install and Developer Mode steps
|
|
33
|
+
|
|
34
|
+
Options for android:
|
|
35
|
+
--install-sdk Install platform-tools, emulator, build-tools and a system image with the
|
|
36
|
+
SDK's sdkmanager, and create an emulator when none exists
|
|
37
|
+
--image ID System image for --install-sdk (default for this computer's CPU)
|
|
38
|
+
--avd NAME Name of the emulator --install-sdk creates (default leera_pixel)
|
|
39
|
+
|
|
40
|
+
Options for ios:
|
|
41
|
+
--rebuild Build WebDriverAgent again even when it is already built
|
|
42
|
+
|
|
43
|
+
Options for windows:
|
|
44
|
+
--install Also install WinAppDriver with msiexec /qn (run from an elevated shell)
|
|
45
|
+
`;
|
|
46
|
+
/** The argv that installs Chromium with the Playwright version the runner uses. */
|
|
47
|
+
export function browserInstallArgv(execPath = process.execPath) {
|
|
48
|
+
const require = createRequire(import.meta.url);
|
|
49
|
+
// cli.js is not in the package's exports map, so it is found next to package.json.
|
|
50
|
+
const cli = join(dirname(require.resolve('playwright-core/package.json')), 'cli.js');
|
|
51
|
+
return [execPath, cli, 'install', 'chromium'];
|
|
52
|
+
}
|
|
53
|
+
export async function installBrowsers(io, paths) {
|
|
54
|
+
const browsersPath = io.env.PLAYWRIGHT_BROWSERS_PATH?.trim() || paths.browsers;
|
|
55
|
+
ensureHome(paths);
|
|
56
|
+
const argv = browserInstallArgv();
|
|
57
|
+
io.stdout(`Installing Chromium into ${browsersPath} ...`);
|
|
58
|
+
const code = await new Promise((resolve) => {
|
|
59
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
60
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
61
|
+
env: { ...io.env, PLAYWRIGHT_BROWSERS_PATH: browsersPath },
|
|
62
|
+
});
|
|
63
|
+
child.on('error', () => resolve(ExitCode.error));
|
|
64
|
+
child.on('close', (exit) => resolve(exit ?? ExitCode.error));
|
|
65
|
+
});
|
|
66
|
+
if (code !== 0) {
|
|
67
|
+
io.stderr('Chromium could not be installed; see the output above.');
|
|
68
|
+
return ExitCode.error;
|
|
69
|
+
}
|
|
70
|
+
if (io.platform === 'linux') {
|
|
71
|
+
io.stdout(`Chromium needs system libraries on Linux. If jobs fail to launch the browser, run as an administrator:\n sudo ${argv[0]} ${argv[1]} install-deps chromium`);
|
|
72
|
+
}
|
|
73
|
+
io.stdout('Chromium is installed.');
|
|
74
|
+
return ExitCode.ok;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* `setup tauri` (contracts §L): checks what Tauri jobs need and prints the commands that install
|
|
78
|
+
* it; runs nothing with elevated rights. Exit 0 when ready, 4 when something is missing.
|
|
79
|
+
*/
|
|
80
|
+
export function setupTauri(io, paths, lookup = {}) {
|
|
81
|
+
const settings = loadTauriSettings(io.env, paths);
|
|
82
|
+
const options = { platform: io.platform, env: io.env, ...lookup };
|
|
83
|
+
if (io.platform === 'darwin') {
|
|
84
|
+
io.stdout([
|
|
85
|
+
'macOS has no tauri-driver: WKWebView offers no WebDriver, so a plain Tauri build cannot be tested on a Mac.',
|
|
86
|
+
'A Mac runs Tauri jobs only for builds that embed the WebDriver plugin (a debug or feature-gated build, never',
|
|
87
|
+
'production). The runner starts such a build with TAURI_WEBDRIVER_PORT set and attaches to that port; a build',
|
|
88
|
+
'without the plugin is reported blocked ("Tauri on macOS needs a build with the embedded WebDriver plugin").',
|
|
89
|
+
'',
|
|
90
|
+
settings.macosPlugin
|
|
91
|
+
? 'tauri.macos_plugin is on: this runner advertises a Tauri device.'
|
|
92
|
+
: 'When your builds embed the plugin, turn it on:\n leera-qa-runner config set tauri.macos_plugin true',
|
|
93
|
+
].join('\n'));
|
|
94
|
+
return settings.macosPlugin ? ExitCode.ok : ExitCode.environmentNotReady;
|
|
95
|
+
}
|
|
96
|
+
if (io.platform !== 'linux' && io.platform !== 'win32') {
|
|
97
|
+
io.stderr(`Tauri jobs do not run on ${io.platform}.`);
|
|
98
|
+
return ExitCode.environmentNotReady;
|
|
99
|
+
}
|
|
100
|
+
let ready = true;
|
|
101
|
+
const driver = findTauriDriver(settings, options);
|
|
102
|
+
if (driver)
|
|
103
|
+
io.stdout(`tauri-driver: ${driver}`);
|
|
104
|
+
else {
|
|
105
|
+
ready = false;
|
|
106
|
+
io.stdout(`tauri-driver ${TAURI_DRIVER_VERSION} is not installed. Install Rust (https://rustup.rs), then run:\n ${TAURI_DRIVER_INSTALL}\n(or point the runner at it: leera-qa-runner config set tauri.driver_path /path/to/tauri-driver)`);
|
|
107
|
+
}
|
|
108
|
+
const native = findNativeDriver(settings, options);
|
|
109
|
+
if (io.platform === 'linux') {
|
|
110
|
+
if (native)
|
|
111
|
+
io.stdout(`WebKitWebDriver: ${native}`);
|
|
112
|
+
else {
|
|
113
|
+
ready = false;
|
|
114
|
+
io.stdout(`WebKitWebDriver is not installed. Run as an administrator:\n ${WEBKIT_DRIVER_INSTALL}`);
|
|
115
|
+
}
|
|
116
|
+
const display = displayNeed('linux', io.env, (name) => findOnPath(name, io.env, options.exists));
|
|
117
|
+
if (display.kind === 'xvfb')
|
|
118
|
+
io.stdout(`No DISPLAY: jobs start ${display.xvfb} ${XVFB_DISPLAY}.`);
|
|
119
|
+
if (display.kind === 'missing') {
|
|
120
|
+
io.stdout('This Linux machine has no DISPLAY; install Xvfb (for example `sudo apt-get install xvfb`) so jobs can open windows.');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else if (native) {
|
|
124
|
+
io.stdout(`msedgedriver: ${native}`);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
ready = false;
|
|
128
|
+
io.stdout([
|
|
129
|
+
'msedgedriver.exe was not found. Tauri on Windows drives WebView2 through Microsoft Edge WebDriver:',
|
|
130
|
+
` 1. Download the msedgedriver whose version matches your WebView2 Runtime from ${MSEDGEDRIVER_URL}`,
|
|
131
|
+
' (or run `cargo install --git https://github.com/chippers/msedgedriver-tool` and `msedgedriver-tool`).',
|
|
132
|
+
' 2. leera-qa-runner config set tauri.native_driver_path C:\\path\\to\\msedgedriver.exe',
|
|
133
|
+
].join('\n'));
|
|
134
|
+
}
|
|
135
|
+
io.stdout(ready ? 'Tauri is ready.' : 'Tauri is not ready: install what is listed above, then run `leera-qa-runner doctor --platform tauri`.');
|
|
136
|
+
return ready ? ExitCode.ok : ExitCode.environmentNotReady;
|
|
137
|
+
}
|
|
138
|
+
export const command = {
|
|
139
|
+
summary: 'Install platform tools (browsers, android, ios, electron, tauri, macos, windows)',
|
|
140
|
+
usage,
|
|
141
|
+
async run(args, io) {
|
|
142
|
+
const { positionals, values } = parseArgs({
|
|
143
|
+
args,
|
|
144
|
+
strict: true,
|
|
145
|
+
allowPositionals: true,
|
|
146
|
+
options: {
|
|
147
|
+
'install-sdk': { type: 'boolean' },
|
|
148
|
+
image: { type: 'string' },
|
|
149
|
+
avd: { type: 'string' },
|
|
150
|
+
rebuild: { type: 'boolean' },
|
|
151
|
+
install: { type: 'boolean' },
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
const [what, ...extra] = positionals;
|
|
155
|
+
if (!what || extra.length > 0)
|
|
156
|
+
throw usageError('say what to set up', usage);
|
|
157
|
+
if (what !== 'android' && (values['install-sdk'] || values.image || values.avd)) {
|
|
158
|
+
throw usageError('--install-sdk, --image and --avd only apply to setup android', usage);
|
|
159
|
+
}
|
|
160
|
+
if (what !== 'ios' && values.rebuild)
|
|
161
|
+
throw usageError('--rebuild only applies to setup ios', usage);
|
|
162
|
+
if (what !== 'windows' && values.install)
|
|
163
|
+
throw usageError('--install only applies to setup windows', usage);
|
|
164
|
+
if (what === 'browsers')
|
|
165
|
+
return installBrowsers(io, homePaths(io.env));
|
|
166
|
+
if (what === 'ios')
|
|
167
|
+
return setupIos(io, homePaths(io.env), { rebuild: values.rebuild ?? false });
|
|
168
|
+
if (what === 'android') {
|
|
169
|
+
return setupAndroid(io, homePaths(io.env), {
|
|
170
|
+
installSdk: values['install-sdk'] ?? false,
|
|
171
|
+
...(values.image ? { image: values.image } : {}),
|
|
172
|
+
...(values.avd ? { avd: values.avd } : {}),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (what === 'electron') {
|
|
176
|
+
io.stdout('Nothing to set up for Electron: apps run with the Playwright bundled with this runner.');
|
|
177
|
+
if (io.platform === 'linux' && !io.env.DISPLAY?.trim()) {
|
|
178
|
+
io.stdout('This Linux machine has no DISPLAY; install Xvfb (for example `sudo apt-get install xvfb`) so jobs can open windows.');
|
|
179
|
+
}
|
|
180
|
+
return ExitCode.ok;
|
|
181
|
+
}
|
|
182
|
+
if (what === 'tauri')
|
|
183
|
+
return setupTauri(io, homePaths(io.env));
|
|
184
|
+
if (what === 'macos')
|
|
185
|
+
return setupMacos(io, homePaths(io.env));
|
|
186
|
+
if (what === 'windows')
|
|
187
|
+
return setupWindows(io, homePaths(io.env), { install: values.install ?? false });
|
|
188
|
+
if (what === 'all') {
|
|
189
|
+
throw usageError(`'setup all' is not available in this runner version; set up one platform at a time`, usage);
|
|
190
|
+
}
|
|
191
|
+
throw usageError(`unknown setup target '${what}'`, usage);
|
|
192
|
+
},
|
|
193
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { splitList } from '../../config.js';
|
|
3
|
+
import { stopSharedAppiumServer } from '../../drivers/android-appium.js';
|
|
4
|
+
import { ExitCode } from '../../errors.js';
|
|
5
|
+
import { homePaths } from '../../home.js';
|
|
6
|
+
import { createLogger } from '../../log.js';
|
|
7
|
+
import { createRunner } from '../../runner.js';
|
|
8
|
+
import { usageError } from '../output.js';
|
|
9
|
+
const usage = `Usage: leera-qa-runner start [options]
|
|
10
|
+
|
|
11
|
+
Registers with the server and runs queued jobs until stopped.
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--once Exit after the first job
|
|
15
|
+
--service Run as the background service (logs go to the runner home)
|
|
16
|
+
--headed Show browser windows
|
|
17
|
+
--labels a,b Use exactly these labels instead of the detected ones
|
|
18
|
+
--concurrency N Browser jobs at once (default 1)
|
|
19
|
+
--run-id N Only take jobs from this test run
|
|
20
|
+
`;
|
|
21
|
+
export function parseStartFlags(args) {
|
|
22
|
+
const { values } = parseArgs({
|
|
23
|
+
args,
|
|
24
|
+
strict: true,
|
|
25
|
+
allowPositionals: false,
|
|
26
|
+
options: {
|
|
27
|
+
once: { type: 'boolean' },
|
|
28
|
+
service: { type: 'boolean' },
|
|
29
|
+
headed: { type: 'boolean' },
|
|
30
|
+
labels: { type: 'string' },
|
|
31
|
+
concurrency: { type: 'string' },
|
|
32
|
+
'run-id': { type: 'string' },
|
|
33
|
+
// Set by service definitions when the home directory is not the default.
|
|
34
|
+
home: { type: 'string' },
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
const flags = {};
|
|
38
|
+
if (values.once)
|
|
39
|
+
flags.once = true;
|
|
40
|
+
if (values.service)
|
|
41
|
+
flags.service = true;
|
|
42
|
+
if (values.headed)
|
|
43
|
+
flags.headed = true;
|
|
44
|
+
if (values.labels !== undefined)
|
|
45
|
+
flags.labels = splitList(values.labels);
|
|
46
|
+
if (values.concurrency !== undefined)
|
|
47
|
+
flags.concurrency = positive(values.concurrency, '--concurrency');
|
|
48
|
+
if (values['run-id'] !== undefined)
|
|
49
|
+
flags.runId = positive(values['run-id'], '--run-id');
|
|
50
|
+
return flags;
|
|
51
|
+
}
|
|
52
|
+
function positive(raw, name) {
|
|
53
|
+
const value = Number(raw);
|
|
54
|
+
if (!Number.isInteger(value) || value <= 0)
|
|
55
|
+
throw usageError(`${name} must be a positive whole number`, usage);
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
export const command = {
|
|
59
|
+
summary: 'Run queued jobs until stopped',
|
|
60
|
+
usage,
|
|
61
|
+
async run(args, io) {
|
|
62
|
+
const flags = parseStartFlags(args);
|
|
63
|
+
const paths = homePaths(io.env);
|
|
64
|
+
const log = createLogger(flags.service
|
|
65
|
+
? { console: 'off', file: paths.log }
|
|
66
|
+
: { console: io.interactive ? 'pretty' : 'json' });
|
|
67
|
+
const { runner, shared } = await createRunner({ flags, env: io.env, log, writeState: true });
|
|
68
|
+
let signal = null;
|
|
69
|
+
const onSignal = (received) => {
|
|
70
|
+
if (signal === 'SIGINT' && received === 'SIGINT') {
|
|
71
|
+
log.warn('interrupted twice, exiting now');
|
|
72
|
+
process.exit(ExitCode.interrupted);
|
|
73
|
+
}
|
|
74
|
+
signal = received;
|
|
75
|
+
runner.stop();
|
|
76
|
+
};
|
|
77
|
+
process.on('SIGINT', onSignal);
|
|
78
|
+
process.on('SIGTERM', onSignal);
|
|
79
|
+
try {
|
|
80
|
+
await runner.run();
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
log.error('runner stopped', { error: error instanceof Error ? error.message : String(error) });
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
process.off('SIGINT', onSignal);
|
|
88
|
+
process.off('SIGTERM', onSignal);
|
|
89
|
+
await shared.browsers.close();
|
|
90
|
+
await stopSharedAppiumServer();
|
|
91
|
+
}
|
|
92
|
+
return signal === 'SIGINT' ? ExitCode.interrupted : ExitCode.ok;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { ExitCode, ExitError, messageOf } from '../../errors.js';
|
|
4
|
+
import { homePaths } from '../../home.js';
|
|
5
|
+
import { installArchive, packageManagerArgv, recordUpdate, restartServiceIfInstalled, runInherited, DOCKER_IMAGE, } from '../../update/apply.js';
|
|
6
|
+
import { checkForUpdate, detectInstall } from '../../update/check.js';
|
|
7
|
+
import { assetKey, fetchManifest, updateBase } from '../../update/manifest.js';
|
|
8
|
+
import { checkManifestTrust } from '../../update/verify.js';
|
|
9
|
+
import { BUILD_INFO, compareVersions, PACKAGE_ROOT, RUNNER_VERSION } from '../../version.js';
|
|
10
|
+
import { usageError } from '../output.js';
|
|
11
|
+
/** `update --check` exit code when a newer version exists. */
|
|
12
|
+
export const UPDATE_AVAILABLE = 10;
|
|
13
|
+
const usage = `Usage: leera-qa-runner update [--check] [--version X] [--yes]
|
|
14
|
+
|
|
15
|
+
Updates the runner in the way it was installed:
|
|
16
|
+
.pkg / .exe / tarball downloads the release archive, verifies it against the signed
|
|
17
|
+
release manifest, unpacks it into ~/.leera-qa-runner/versions/<version>,
|
|
18
|
+
switches to it and restarts the background service (no administrator rights)
|
|
19
|
+
npm / Homebrew / winget runs (or, without --yes, prints) the package manager's upgrade
|
|
20
|
+
Docker pull the new image instead
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
--check Only report whether an update is available (exit 0: up to date, 10: update available)
|
|
24
|
+
--version X Install this version instead of the latest one
|
|
25
|
+
--yes, -y Do not ask for confirmation
|
|
26
|
+
`;
|
|
27
|
+
async function ask(io, question) {
|
|
28
|
+
io.stdout(question);
|
|
29
|
+
const rl = createInterface({ input: io.stdin });
|
|
30
|
+
try {
|
|
31
|
+
for await (const line of rl)
|
|
32
|
+
return /^y(es)?$/i.test(line.trim());
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
rl.close();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function confirmed(io, yes, question) {
|
|
40
|
+
if (yes)
|
|
41
|
+
return true;
|
|
42
|
+
if (!io.interactive)
|
|
43
|
+
return false;
|
|
44
|
+
return ask(io, `${question} [y/N]`);
|
|
45
|
+
}
|
|
46
|
+
export function updateCommand(deps = {}) {
|
|
47
|
+
return {
|
|
48
|
+
summary: 'Update the runner to the latest release',
|
|
49
|
+
usage,
|
|
50
|
+
async run(args, io) {
|
|
51
|
+
const { values, positionals } = parseArgs({
|
|
52
|
+
args,
|
|
53
|
+
strict: true,
|
|
54
|
+
allowPositionals: true,
|
|
55
|
+
options: { check: { type: 'boolean' }, version: { type: 'string' }, yes: { type: 'boolean', short: 'y' } },
|
|
56
|
+
});
|
|
57
|
+
if (positionals.length > 0)
|
|
58
|
+
throw usageError(`unexpected argument '${positionals[0]}'`, usage);
|
|
59
|
+
const requested = values.version?.trim().replace(/^v/, '');
|
|
60
|
+
if (requested !== undefined && !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(requested)) {
|
|
61
|
+
throw usageError(`--version must look like 1.2.3, not '${values.version}'`, usage);
|
|
62
|
+
}
|
|
63
|
+
if (values.check && requested)
|
|
64
|
+
throw usageError('--check and --version cannot be combined', usage);
|
|
65
|
+
const paths = homePaths(io.env);
|
|
66
|
+
const base = updateBase(io.env);
|
|
67
|
+
const current = deps.current ?? RUNNER_VERSION;
|
|
68
|
+
const fetchImpl = deps.fetch ?? fetch;
|
|
69
|
+
if (values.check) {
|
|
70
|
+
let check;
|
|
71
|
+
try {
|
|
72
|
+
check = await checkForUpdate({ current, paths, base, fetch: fetchImpl });
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
throw new ExitError(ExitCode.error, `could not check for updates: ${messageOf(error)}`);
|
|
76
|
+
}
|
|
77
|
+
io.stdout(`Current version: ${check.current}`);
|
|
78
|
+
io.stdout(`Latest version: ${check.latest} (${check.source === 'server' ? 'reported by the server' : 'latest release'})`);
|
|
79
|
+
if (!check.available) {
|
|
80
|
+
io.stdout('The runner is up to date.');
|
|
81
|
+
return ExitCode.ok;
|
|
82
|
+
}
|
|
83
|
+
io.stdout('An update is available: run `leera-qa-runner update`.');
|
|
84
|
+
return UPDATE_AVAILABLE;
|
|
85
|
+
}
|
|
86
|
+
const install = detectInstall({
|
|
87
|
+
paths,
|
|
88
|
+
env: io.env,
|
|
89
|
+
packageRoot: deps.packageRoot ?? PACKAGE_ROOT,
|
|
90
|
+
buildInfo: deps.buildInfo ?? BUILD_INFO,
|
|
91
|
+
});
|
|
92
|
+
if (install.method === 'unknown') {
|
|
93
|
+
throw new ExitError(ExitCode.error, `cannot tell how this runner was installed (no ${paths.install}); update it the way you installed it, or reinstall with the installer from the releases page`);
|
|
94
|
+
}
|
|
95
|
+
if (install.method === 'docker') {
|
|
96
|
+
io.stdout(`This runner runs in Docker: pull the new image and recreate the container:\n docker pull ${DOCKER_IMAGE}:${requested ?? 'latest'}`);
|
|
97
|
+
return ExitCode.ok;
|
|
98
|
+
}
|
|
99
|
+
const managerArgv = packageManagerArgv(install.method, requested);
|
|
100
|
+
if (managerArgv) {
|
|
101
|
+
if (install.method === 'brew' && requested)
|
|
102
|
+
io.stdout('Homebrew installs the cask\'s current version; --version is ignored.');
|
|
103
|
+
const line = managerArgv.join(' ');
|
|
104
|
+
if (!(await confirmed(io, values.yes === true, `Run \`${line}\`?`))) {
|
|
105
|
+
io.stdout(`To update, run:\n ${line}\n(or rerun with --yes to run it now)`);
|
|
106
|
+
return ExitCode.ok;
|
|
107
|
+
}
|
|
108
|
+
io.stdout(`$ ${line}`);
|
|
109
|
+
const code = await (deps.runPackageManager ?? runInherited)(managerArgv, io.platform);
|
|
110
|
+
if (code !== 0)
|
|
111
|
+
throw new ExitError(ExitCode.error, `\`${line}\` exited with ${code}`);
|
|
112
|
+
return ExitCode.ok;
|
|
113
|
+
}
|
|
114
|
+
// pkg, exe and tar installs update into the runner home.
|
|
115
|
+
const arch = deps.arch ?? process.arch;
|
|
116
|
+
const key = assetKey(io.platform, arch);
|
|
117
|
+
if (!key)
|
|
118
|
+
throw new ExitError(ExitCode.error, `there is no runner release for ${io.platform}-${arch}`);
|
|
119
|
+
let fetched;
|
|
120
|
+
try {
|
|
121
|
+
fetched = await fetchManifest(base, requested, fetchImpl);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
throw new ExitError(ExitCode.error, `could not read the release manifest: ${messageOf(error)}`);
|
|
125
|
+
}
|
|
126
|
+
const { manifest } = fetched;
|
|
127
|
+
if (!requested && compareVersions(manifest.version, current) <= 0) {
|
|
128
|
+
io.stdout(`The runner is up to date (${current}).`);
|
|
129
|
+
return ExitCode.ok;
|
|
130
|
+
}
|
|
131
|
+
if (requested && manifest.version !== requested) {
|
|
132
|
+
throw new ExitError(ExitCode.error, `the release manifest for ${requested} describes version ${manifest.version}; refusing to update`);
|
|
133
|
+
}
|
|
134
|
+
const buildInfo = deps.buildInfo ?? BUILD_INFO;
|
|
135
|
+
let trust;
|
|
136
|
+
try {
|
|
137
|
+
trust = checkManifestTrust(fetched.bytes, fetched.signature, buildInfo.release_public_key);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
throw new ExitError(ExitCode.error, messageOf(error));
|
|
141
|
+
}
|
|
142
|
+
if (trust.warning)
|
|
143
|
+
io.stderr(`warning: ${trust.warning}`);
|
|
144
|
+
const asset = manifest.assets[key];
|
|
145
|
+
if (!asset)
|
|
146
|
+
throw new ExitError(ExitCode.error, `release ${manifest.version} has no archive for ${key}`);
|
|
147
|
+
if (!(await confirmed(io, values.yes === true, `Update leera-qa-runner ${current} → ${manifest.version}?`))) {
|
|
148
|
+
if (io.interactive) {
|
|
149
|
+
io.stdout('Not updated.');
|
|
150
|
+
return ExitCode.ok;
|
|
151
|
+
}
|
|
152
|
+
throw usageError('pass --yes to update without a prompt', usage);
|
|
153
|
+
}
|
|
154
|
+
io.stdout(`Downloading ${asset.file} (${Math.round(asset.size / (1024 * 1024))} MB)…`);
|
|
155
|
+
let result;
|
|
156
|
+
try {
|
|
157
|
+
result = await installArchive({
|
|
158
|
+
paths,
|
|
159
|
+
version: manifest.version,
|
|
160
|
+
asset,
|
|
161
|
+
base,
|
|
162
|
+
platform: io.platform,
|
|
163
|
+
env: io.env,
|
|
164
|
+
fetch: fetchImpl,
|
|
165
|
+
...(deps.exec ? { exec: deps.exec } : {}),
|
|
166
|
+
protect: [deps.packageRoot ?? PACKAGE_ROOT],
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
throw new ExitError(ExitCode.error, `update failed: ${messageOf(error)}`);
|
|
171
|
+
}
|
|
172
|
+
recordUpdate(paths, install.method, manifest.version);
|
|
173
|
+
io.stdout(`${result.reused ? 'Switched to' : 'Installed'} leera-qa-runner ${manifest.version} in ${result.dir}${trust.signed ? ' (signature verified)' : ''}.`);
|
|
174
|
+
if (result.removed.length > 0)
|
|
175
|
+
io.stdout(`Removed old versions: ${result.removed.join(', ')}.`);
|
|
176
|
+
if (compareVersions(manifest.version, current) < 0) {
|
|
177
|
+
io.stdout('Note: the launcher only switches to versions newer than the installed bundle; reinstall the older release to downgrade it.');
|
|
178
|
+
}
|
|
179
|
+
const restart = await (deps.restartService ?? ((value) => restartServiceIfInstalled(value.platform, value.env, paths, deps.exec)))(io);
|
|
180
|
+
if (restart.restarted)
|
|
181
|
+
io.stdout('Restarted the background service.');
|
|
182
|
+
else if (restart.reason === 'failed') {
|
|
183
|
+
io.stderr(`warning: the background service could not be restarted (${restart.message}); run \`leera-qa-runner service restart\``);
|
|
184
|
+
}
|
|
185
|
+
return ExitCode.ok;
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
export const command = updateCommand();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { hostInfo } from '../../capabilities.js';
|
|
3
|
+
import { ExitCode } from '../../errors.js';
|
|
4
|
+
import { BUILD_INFO, playwrightVersion, RUNNER_VERSION } from '../../version.js';
|
|
5
|
+
import { printJson } from '../output.js';
|
|
6
|
+
export const command = {
|
|
7
|
+
summary: 'Print the runner version',
|
|
8
|
+
usage: 'Usage: leera-qa-runner version [--json]\n',
|
|
9
|
+
async run(args, io) {
|
|
10
|
+
const { values } = parseArgs({ args, strict: true, options: { json: { type: 'boolean' } } });
|
|
11
|
+
const host = hostInfo();
|
|
12
|
+
if (values.json) {
|
|
13
|
+
printJson(io, {
|
|
14
|
+
version: RUNNER_VERSION,
|
|
15
|
+
node: host.node,
|
|
16
|
+
playwright: playwrightVersion(),
|
|
17
|
+
os: host.os,
|
|
18
|
+
arch: host.arch,
|
|
19
|
+
...(BUILD_INFO.commit ? { commit: BUILD_INFO.commit } : {}),
|
|
20
|
+
...(BUILD_INFO.built_at ? { built_at: BUILD_INFO.built_at } : {}),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
io.stdout(`leera-qa-runner ${RUNNER_VERSION}`);
|
|
25
|
+
}
|
|
26
|
+
return ExitCode.ok;
|
|
27
|
+
},
|
|
28
|
+
};
|