@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,59 @@
|
|
|
1
|
+
import { ExitCode, ExitError } from '../errors.js';
|
|
2
|
+
import { processIo } from './output.js';
|
|
3
|
+
const COMMANDS = {
|
|
4
|
+
start: async () => (await import('./commands/start.js')).command,
|
|
5
|
+
connect: async () => (await import('./commands/connect.js')).command,
|
|
6
|
+
ci: async () => (await import('./commands/ci.js')).command,
|
|
7
|
+
builds: async () => (await import('./commands/builds.js')).command,
|
|
8
|
+
doctor: async () => (await import('./commands/doctor.js')).command,
|
|
9
|
+
setup: async () => (await import('./commands/setup.js')).command,
|
|
10
|
+
service: async () => (await import('./commands/service.js')).command,
|
|
11
|
+
config: async () => (await import('./commands/config.js')).command,
|
|
12
|
+
version: async () => (await import('./commands/version.js')).command,
|
|
13
|
+
update: async () => (await import('./commands/update.js')).command,
|
|
14
|
+
};
|
|
15
|
+
async function helpText() {
|
|
16
|
+
const lines = ['Usage: leera-qa-runner <command> [options]', '', 'Commands:'];
|
|
17
|
+
for (const [name, load] of Object.entries(COMMANDS)) {
|
|
18
|
+
lines.push(` ${name.padEnd(10)} ${(await load()).summary}`);
|
|
19
|
+
}
|
|
20
|
+
lines.push('', "Run 'leera-qa-runner <command> --help' for a command's options.");
|
|
21
|
+
return lines.join('\n');
|
|
22
|
+
}
|
|
23
|
+
/** Runs the CLI and returns the exit code. */
|
|
24
|
+
export async function main(argv, io = processIo()) {
|
|
25
|
+
const [name, ...rest] = argv;
|
|
26
|
+
try {
|
|
27
|
+
if (!name || name === 'help' || name === '--help' || name === '-h') {
|
|
28
|
+
io.stdout(await helpText());
|
|
29
|
+
return name ? ExitCode.ok : ExitCode.usage;
|
|
30
|
+
}
|
|
31
|
+
if (name === '--version' || name === '-v')
|
|
32
|
+
return await (await COMMANDS.version()).run([], io);
|
|
33
|
+
const load = COMMANDS[name];
|
|
34
|
+
if (!load) {
|
|
35
|
+
io.stderr(`unknown command '${name}'\n\n${await helpText()}`);
|
|
36
|
+
return ExitCode.usage;
|
|
37
|
+
}
|
|
38
|
+
const command = await load();
|
|
39
|
+
if (rest.includes('--help') || rest.includes('-h')) {
|
|
40
|
+
io.stdout(command.usage);
|
|
41
|
+
return ExitCode.ok;
|
|
42
|
+
}
|
|
43
|
+
return await command.run(rest, io);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error instanceof ExitError) {
|
|
47
|
+
io.stderr(`error: ${error.message}`);
|
|
48
|
+
return error.code;
|
|
49
|
+
}
|
|
50
|
+
// node:util parseArgs reports unknown or malformed options with these codes.
|
|
51
|
+
const code = error.code;
|
|
52
|
+
if (typeof code === 'string' && code.startsWith('ERR_PARSE_ARGS')) {
|
|
53
|
+
io.stderr(`error: ${error.message}`);
|
|
54
|
+
return ExitCode.usage;
|
|
55
|
+
}
|
|
56
|
+
io.stderr(`error: ${error instanceof Error ? error.message : String(error)}`);
|
|
57
|
+
return ExitCode.error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { ExitCode, ExitError } from '../errors.js';
|
|
2
|
+
export function processIo() {
|
|
3
|
+
return {
|
|
4
|
+
stdout: (text) => process.stdout.write(text.endsWith('\n') ? text : `${text}\n`),
|
|
5
|
+
stderr: (text) => process.stderr.write(text.endsWith('\n') ? text : `${text}\n`),
|
|
6
|
+
env: process.env,
|
|
7
|
+
stdin: process.stdin,
|
|
8
|
+
interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
9
|
+
platform: process.platform,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function printJson(io, value) {
|
|
13
|
+
io.stdout(JSON.stringify(value, null, 2));
|
|
14
|
+
}
|
|
15
|
+
export function usageError(message, usage) {
|
|
16
|
+
return new ExitError(ExitCode.usage, usage ? `${message}\n\n${usage}` : message);
|
|
17
|
+
}
|
|
18
|
+
/** Reads everything piped to stdin. */
|
|
19
|
+
export async function readStdin(stdin) {
|
|
20
|
+
const chunks = [];
|
|
21
|
+
for await (const chunk of stdin)
|
|
22
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
|
23
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
24
|
+
}
|
|
25
|
+
const ENTER = new Set([13, 10, 4]); // CR, LF, Ctrl-D
|
|
26
|
+
const CTRL_C = 3;
|
|
27
|
+
const BACKSPACE = new Set([127, 8]);
|
|
28
|
+
/** Asks for a secret without echoing it. */
|
|
29
|
+
export function promptHidden(io, question) {
|
|
30
|
+
const stdin = io.stdin;
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
if (!stdin.setRawMode) {
|
|
33
|
+
reject(usageError('cannot prompt without a terminal; pipe the token and pass --token-stdin'));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
process.stdout.write(question);
|
|
37
|
+
let value = '';
|
|
38
|
+
const finish = () => {
|
|
39
|
+
stdin.removeListener('data', onData);
|
|
40
|
+
stdin.setRawMode?.(false);
|
|
41
|
+
stdin.pause();
|
|
42
|
+
process.stdout.write('\n');
|
|
43
|
+
};
|
|
44
|
+
const onData = (chunk) => {
|
|
45
|
+
for (const char of chunk.toString()) {
|
|
46
|
+
const code = char.charCodeAt(0);
|
|
47
|
+
if (ENTER.has(code)) {
|
|
48
|
+
finish();
|
|
49
|
+
resolve(value);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (code === CTRL_C) {
|
|
53
|
+
finish();
|
|
54
|
+
reject(new ExitError(ExitCode.interrupted, 'interrupted'));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (BACKSPACE.has(code))
|
|
58
|
+
value = value.slice(0, -1);
|
|
59
|
+
else
|
|
60
|
+
value += char;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
stdin.setRawMode(true);
|
|
64
|
+
stdin.resume();
|
|
65
|
+
stdin.on('data', onData);
|
|
66
|
+
});
|
|
67
|
+
}
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI entry. Environment that must be in place before Playwright loads is set here,
|
|
3
|
+
// then the commands are imported.
|
|
4
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { loadFileConfig } from './config.js';
|
|
6
|
+
import { homePaths } from './home.js';
|
|
7
|
+
function bootstrapEnv(argv) {
|
|
8
|
+
const homeIndex = argv.indexOf('--home');
|
|
9
|
+
if (homeIndex >= 0 && argv[homeIndex + 1])
|
|
10
|
+
process.env.LEERA_RUNNER_HOME = argv[homeIndex + 1];
|
|
11
|
+
const paths = homePaths(process.env);
|
|
12
|
+
// A service starts without the login shell's environment; `service install` saved it.
|
|
13
|
+
if (argv.includes('--service')) {
|
|
14
|
+
try {
|
|
15
|
+
const saved = loadFileConfig(paths).file?.env ?? {};
|
|
16
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
17
|
+
if (key === 'PATH' || process.env[key] === undefined)
|
|
18
|
+
process.env[key] = value;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// `start` reports an unreadable config itself.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// Browsers installed by `setup browsers` live in the runner home.
|
|
26
|
+
if (!process.env.PLAYWRIGHT_BROWSERS_PATH) {
|
|
27
|
+
try {
|
|
28
|
+
if (existsSync(paths.browsers) && readdirSync(paths.browsers).length > 0) {
|
|
29
|
+
process.env.PLAYWRIGHT_BROWSERS_PATH = paths.browsers;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Fall back to Playwright's default cache.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const argv = process.argv.slice(2);
|
|
38
|
+
bootstrapEnv(argv);
|
|
39
|
+
const { main } = await import('./cli/main.js');
|
|
40
|
+
process.exitCode = await main(argv);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
export class ApiError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
/** The parsed error body (unwrapped from `data` when the server wraps it), when it was JSON. */
|
|
4
|
+
body;
|
|
5
|
+
constructor(message, status, body = null) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
const wrapped = body;
|
|
9
|
+
this.body = wrapped && typeof wrapped === 'object' && wrapped.data && typeof wrapped.data === 'object' ? wrapped.data : body;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class RunnerClient {
|
|
13
|
+
base;
|
|
14
|
+
token;
|
|
15
|
+
constructor(baseUrl, token) {
|
|
16
|
+
this.base = baseUrl.replace(/\/+$/, '');
|
|
17
|
+
this.token = token;
|
|
18
|
+
}
|
|
19
|
+
async register(body) {
|
|
20
|
+
return this.expect(await this.json('/api/v1/qa/runner/register', body));
|
|
21
|
+
}
|
|
22
|
+
claim(runnerId, options = {}) {
|
|
23
|
+
return this.json('/api/v1/qa/runner/claim', {
|
|
24
|
+
runner_id: runnerId,
|
|
25
|
+
...(options.runId != null ? { run_id: options.runId } : {}),
|
|
26
|
+
...(options.accept ? { accept: options.accept } : {}),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Checks the token without registering: a claim for runner 0 is refused with 400
|
|
31
|
+
* ("unknown runner") when the token is valid and 401/403 when it is not.
|
|
32
|
+
*/
|
|
33
|
+
async checkToken() {
|
|
34
|
+
try {
|
|
35
|
+
await this.request('/api/v1/qa/runner/claim', {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'content-type': 'application/json' },
|
|
38
|
+
body: JSON.stringify({ runner_id: 0, accept: [] }),
|
|
39
|
+
}, 15_000);
|
|
40
|
+
return { result: 'unexpected', status: 200 };
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (!(error instanceof ApiError))
|
|
44
|
+
throw error;
|
|
45
|
+
if (error.status === 401 || error.status === 403)
|
|
46
|
+
return { result: 'rejected', status: error.status, message: error.message };
|
|
47
|
+
if (error.status === 400)
|
|
48
|
+
return { result: 'accepted', status: 400 };
|
|
49
|
+
return { result: 'unexpected', status: error.status, message: error.message };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async heartbeat(jobId) {
|
|
53
|
+
const response = await this.json('/api/v1/qa/runner/heartbeat', { job_id: jobId });
|
|
54
|
+
return response?.continue ?? false;
|
|
55
|
+
}
|
|
56
|
+
async uploadEvidence(jobId, name, bytes, contentType = 'image/png') {
|
|
57
|
+
const form = new FormData();
|
|
58
|
+
form.append('file', new Blob([new Uint8Array(bytes)], { type: contentType }), name);
|
|
59
|
+
// A video can take a while to send over a slow uplink.
|
|
60
|
+
const timeoutMs = contentType.startsWith('video/') ? 10 * 60_000 : 60_000;
|
|
61
|
+
return this.expect(await this.request(`/api/v1/qa/runner/evidence?job_id=${jobId}`, { method: 'POST', body: form }, timeoutMs));
|
|
62
|
+
}
|
|
63
|
+
async complete(body) {
|
|
64
|
+
return this.expect(await this.json('/api/v1/qa/runner/complete', body));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Long-polls for the session's next command. The server holds the request up to
|
|
68
|
+
* `waitSeconds` (and extends the session lease meanwhile); 410 means the session ended.
|
|
69
|
+
*/
|
|
70
|
+
async nextCommand(sessionId, runnerId, waitSeconds, signal) {
|
|
71
|
+
try {
|
|
72
|
+
const response = await this.request('/api/v1/qa/runner/sessions/commands/next', {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'content-type': 'application/json' },
|
|
75
|
+
body: JSON.stringify({ session_id: sessionId, runner_id: runnerId, wait_seconds: waitSeconds }),
|
|
76
|
+
}, (waitSeconds + 20) * 1_000, signal);
|
|
77
|
+
return response?.command ? { kind: 'command', command: response.command } : { kind: 'none' };
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof ApiError && error.status === 410) {
|
|
81
|
+
const body = (error.body ?? {});
|
|
82
|
+
return { kind: 'gone', status: body.status ?? 'ended', reason: body.reason ?? null };
|
|
83
|
+
}
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Sends a command's result as multipart: a `result` JSON part and an optional `screenshot` part. */
|
|
88
|
+
async postCommandResult(sessionId, commandId, result, screenshot) {
|
|
89
|
+
const form = new FormData();
|
|
90
|
+
form.append('result', new Blob([JSON.stringify(result)], { type: 'application/json' }), 'result.json');
|
|
91
|
+
if (screenshot) {
|
|
92
|
+
const extension = screenshot.contentType === 'image/jpeg' ? 'jpg' : 'png';
|
|
93
|
+
form.append('screenshot', new Blob([new Uint8Array(screenshot.bytes)], { type: screenshot.contentType }), `command-${commandId}.${extension}`);
|
|
94
|
+
}
|
|
95
|
+
await this.request(`/api/v1/qa/runner/sessions/commands/result?session_id=${sessionId}&command_id=${commandId}`, { method: 'POST', body: form }, 60_000);
|
|
96
|
+
}
|
|
97
|
+
/** Ends a session; idempotent on the server. */
|
|
98
|
+
async endSession(sessionId, reason) {
|
|
99
|
+
await this.json('/api/v1/qa/runner/sessions/end', { session_id: sessionId, ...(reason ? { reason } : {}) });
|
|
100
|
+
}
|
|
101
|
+
/** A short-lived download URL for a build of a running job or active session (contracts §H4). */
|
|
102
|
+
async buildDownloadUrl(buildId, owner) {
|
|
103
|
+
return this.expect(await this.json('/api/v1/qa/runner/builds/download-url', {
|
|
104
|
+
build_id: buildId,
|
|
105
|
+
...(owner.jobId !== undefined ? { job_id: owner.jobId } : { session_id: owner.sessionId }),
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
json(path, body) {
|
|
109
|
+
return this.request(path, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'content-type': 'application/json' },
|
|
112
|
+
body: JSON.stringify(body),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
async request(path, init, timeoutMs = 60_000, signal) {
|
|
116
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
117
|
+
const response = await fetch(this.base + path, {
|
|
118
|
+
...init,
|
|
119
|
+
headers: { ...init.headers, authorization: `Bearer ${this.token}` },
|
|
120
|
+
signal: signal ? AbortSignal.any([timeout, signal]) : timeout,
|
|
121
|
+
});
|
|
122
|
+
if (response.status === 204)
|
|
123
|
+
return null;
|
|
124
|
+
const text = await response.text();
|
|
125
|
+
let payload = null;
|
|
126
|
+
try {
|
|
127
|
+
payload = JSON.parse(text);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
payload = null;
|
|
131
|
+
}
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
const message = payload?.message || payload?.error_message || payload?.reason || `HTTP ${response.status}`;
|
|
134
|
+
throw new ApiError(message, response.status, payload);
|
|
135
|
+
}
|
|
136
|
+
// Every endpoint wraps its payload in `data`; tolerate an unwrapped body for the session calls' `{command}`.
|
|
137
|
+
if (payload && typeof payload === 'object' && !('data' in payload) && 'command' in payload)
|
|
138
|
+
return payload;
|
|
139
|
+
return payload?.data ?? null;
|
|
140
|
+
}
|
|
141
|
+
expect(value) {
|
|
142
|
+
if (value === null)
|
|
143
|
+
throw new ApiError('the server returned no data', 502);
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { hostname } from 'node:os';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { ExitCode, ExitError } from './errors.js';
|
|
4
|
+
import { ensureHome, readJsonFile, readTokenFile, writePrivateFile } from './home.js';
|
|
5
|
+
export const DEFAULTS = {
|
|
6
|
+
labels: ['web-chromium'],
|
|
7
|
+
headless: true,
|
|
8
|
+
actionTimeoutMs: 15_000,
|
|
9
|
+
jobTimeoutMs: 15 * 60_000,
|
|
10
|
+
concurrencyWeb: 1,
|
|
11
|
+
allowLocalPaths: false,
|
|
12
|
+
};
|
|
13
|
+
export const TOKEN_PREFIX = 'pm_run_';
|
|
14
|
+
export const MAX_CONCURRENCY = 16;
|
|
15
|
+
/**
|
|
16
|
+
* Precedence: flags > `RUNNER_*` > legacy `LEERA_*` > config file > defaults.
|
|
17
|
+
* `--labels` / `RUNNER_LABELS` replace the label list exactly; otherwise it is
|
|
18
|
+
* detected ∪ `labels.extra` − `labels.exclude`.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveConfig(input) {
|
|
21
|
+
const flags = input.flags ?? {};
|
|
22
|
+
const env = input.env ?? {};
|
|
23
|
+
const file = input.file ?? {};
|
|
24
|
+
const url = pick(flags.url, env.RUNNER_URL, env.LEERA_URL, file.url);
|
|
25
|
+
if (!url) {
|
|
26
|
+
throw new ExitError(ExitCode.usage, 'RUNNER_URL is required (or run `leera-qa-runner connect --url <server>` first)');
|
|
27
|
+
}
|
|
28
|
+
const token = pick(flags.token, env.RUNNER_TOKEN, env.LEERA_RUNNER_TOKEN, input.fileToken ?? undefined);
|
|
29
|
+
if (!token) {
|
|
30
|
+
throw new ExitError(ExitCode.usage, 'RUNNER_TOKEN is required (or run `leera-qa-runner connect` to store one)');
|
|
31
|
+
}
|
|
32
|
+
if (!token.startsWith(TOKEN_PREFIX)) {
|
|
33
|
+
throw new ExitError(ExitCode.usage, `RUNNER_TOKEN must be a runner pool token (${TOKEN_PREFIX}...)`);
|
|
34
|
+
}
|
|
35
|
+
let labels;
|
|
36
|
+
const labelOverride = flags.labels ?? (env.RUNNER_LABELS !== undefined ? splitList(env.RUNNER_LABELS) : undefined);
|
|
37
|
+
if (labelOverride) {
|
|
38
|
+
labels = unique(labelOverride);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const exclude = new Set(file.labels?.exclude ?? []);
|
|
42
|
+
labels = unique([...(input.detectedLabels ?? DEFAULTS.labels), ...(file.labels?.extra ?? [])]).filter((label) => !exclude.has(label));
|
|
43
|
+
}
|
|
44
|
+
const proxy = pick(env.RUNNER_PROXY, file.proxy);
|
|
45
|
+
const concurrency = flags.concurrency ?? integer(env.RUNNER_CONCURRENCY, 'RUNNER_CONCURRENCY') ?? file.concurrency?.web ?? DEFAULTS.concurrencyWeb;
|
|
46
|
+
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) {
|
|
47
|
+
throw new ExitError(ExitCode.usage, `concurrency must be a whole number from 1 to ${MAX_CONCURRENCY}`);
|
|
48
|
+
}
|
|
49
|
+
const runId = flags.runId ?? integer(env.RUNNER_RUN_ID, 'RUNNER_RUN_ID') ?? null;
|
|
50
|
+
return {
|
|
51
|
+
url,
|
|
52
|
+
token,
|
|
53
|
+
name: pick(flags.name, env.RUNNER_NAME, file.name) ?? hostname(),
|
|
54
|
+
labels,
|
|
55
|
+
once: flags.once ?? flag(env.RUNNER_ONCE) ?? false,
|
|
56
|
+
headless: flags.headed ? false : (flag(env.RUNNER_HEADLESS) ?? file.headless ?? DEFAULTS.headless),
|
|
57
|
+
...(proxy ? { proxy } : {}),
|
|
58
|
+
actionTimeoutMs: integer(env.RUNNER_ACTION_TIMEOUT_MS, 'RUNNER_ACTION_TIMEOUT_MS') ?? file.action_timeout_ms ?? DEFAULTS.actionTimeoutMs,
|
|
59
|
+
jobTimeoutMs: integer(env.RUNNER_JOB_TIMEOUT_MS, 'RUNNER_JOB_TIMEOUT_MS') ?? file.job_timeout_ms ?? DEFAULTS.jobTimeoutMs,
|
|
60
|
+
concurrency: { web: concurrency },
|
|
61
|
+
runId,
|
|
62
|
+
service: flags.service ?? false,
|
|
63
|
+
allowLocalPaths: flag(env.RUNNER_ALLOW_LOCAL_PATHS) ?? file.builds?.allow_local_paths ?? DEFAULTS.allowLocalPaths,
|
|
64
|
+
androidSdkPath: pick(file.android?.sdk_path) ?? null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** Reads config.json and the token file from the home directory. */
|
|
68
|
+
export function loadFileConfig(paths) {
|
|
69
|
+
let file;
|
|
70
|
+
try {
|
|
71
|
+
file = readJsonFile(paths.config);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
throw new ExitError(ExitCode.usage, `${paths.config} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
75
|
+
}
|
|
76
|
+
const { token, fixedPermissions } = readTokenFile(paths);
|
|
77
|
+
return { file, token, fixedPermissions };
|
|
78
|
+
}
|
|
79
|
+
export function saveFileConfig(paths, config) {
|
|
80
|
+
ensureHome(paths);
|
|
81
|
+
writePrivateFile(paths.config, `${JSON.stringify(config, null, 2)}\n`);
|
|
82
|
+
}
|
|
83
|
+
/** Keys `config get|set|unset` understand, with how to parse a value typed on the command line. */
|
|
84
|
+
export const CONFIG_KEYS = {
|
|
85
|
+
url: { kind: 'url', help: 'Server URL (RUNNER_URL)' },
|
|
86
|
+
name: { kind: 'string', help: 'Runner name shown in the workspace (RUNNER_NAME; default: host name)' },
|
|
87
|
+
'labels.extra': { kind: 'list', help: 'Labels added to the detected ones (comma separated)' },
|
|
88
|
+
'labels.exclude': { kind: 'list', help: 'Detected labels to leave out (comma separated)' },
|
|
89
|
+
'concurrency.web': { kind: 'integer', help: 'Browser jobs at once (RUNNER_CONCURRENCY; default 1)' },
|
|
90
|
+
headless: { kind: 'boolean', help: 'Run browsers without a window (RUNNER_HEADLESS; default true)' },
|
|
91
|
+
proxy: { kind: 'string', help: 'Proxy server for browsers (RUNNER_PROXY)' },
|
|
92
|
+
action_timeout_ms: { kind: 'integer', help: 'Per-action timeout (RUNNER_ACTION_TIMEOUT_MS; default 15000)' },
|
|
93
|
+
job_timeout_ms: { kind: 'integer', help: 'Whole-job timeout (RUNNER_JOB_TIMEOUT_MS; default 900000)' },
|
|
94
|
+
'builds.allow_local_paths': {
|
|
95
|
+
kind: 'boolean',
|
|
96
|
+
help: 'Install apps from a file path on this machine (RUNNER_ALLOW_LOCAL_PATHS; default false)',
|
|
97
|
+
},
|
|
98
|
+
'android.sdk_path': { kind: 'path', help: 'Android SDK directory; wins over ANDROID_HOME (absolute path)' },
|
|
99
|
+
'ios.team_id': { kind: 'string', help: 'Apple Developer Team ID that signs WebDriverAgent for real iOS devices (RUNNER_IOS_TEAM_ID)' },
|
|
100
|
+
'ios.wda_bundle_id': { kind: 'string', help: 'Bundle id to sign WebDriverAgent under on real iOS devices (RUNNER_IOS_WDA_BUNDLE_ID)' },
|
|
101
|
+
'ios.signing_id': { kind: 'string', help: 'Code signing identity for WebDriverAgent (RUNNER_IOS_SIGNING_ID; default Apple Development)' },
|
|
102
|
+
'ios.simulator_boot_timeout_secs': {
|
|
103
|
+
kind: 'integer',
|
|
104
|
+
help: 'Seconds one iOS simulator boot may take before it is shut down and tried once more (RUNNER_IOS_BOOT_TIMEOUT_SECS; default 300)',
|
|
105
|
+
},
|
|
106
|
+
'tauri.driver_path': { kind: 'path', help: 'tauri-driver executable (RUNNER_TAURI_DRIVER; default ~/.cargo/bin/tauri-driver, then PATH)' },
|
|
107
|
+
'tauri.native_driver_path': {
|
|
108
|
+
kind: 'path',
|
|
109
|
+
help: 'Native WebDriver for tauri-driver: msedgedriver.exe on Windows, WebKitWebDriver on Linux (RUNNER_TAURI_NATIVE_DRIVER; default PATH)',
|
|
110
|
+
},
|
|
111
|
+
'tauri.macos_plugin': {
|
|
112
|
+
kind: 'boolean',
|
|
113
|
+
help: 'macOS: run Tauri jobs for builds that embed the WebDriver plugin (RUNNER_TAURI_MACOS_PLUGIN; default false)',
|
|
114
|
+
},
|
|
115
|
+
'windows.winappdriver_path': {
|
|
116
|
+
kind: 'path',
|
|
117
|
+
help: 'WinAppDriver.exe for Windows app jobs (RUNNER_WINAPPDRIVER; default the msi install folder)',
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
/** Returns the value a key holds; `env.NAME` addresses one captured environment variable. */
|
|
121
|
+
export function getConfigValue(config, key) {
|
|
122
|
+
let node = config;
|
|
123
|
+
for (const part of key.split('.')) {
|
|
124
|
+
if (node === null || typeof node !== 'object')
|
|
125
|
+
return undefined;
|
|
126
|
+
node = node[part];
|
|
127
|
+
}
|
|
128
|
+
return node;
|
|
129
|
+
}
|
|
130
|
+
export function setConfigValue(config, key, raw) {
|
|
131
|
+
const value = parseConfigValue(key, raw);
|
|
132
|
+
const next = structuredClone(config);
|
|
133
|
+
const parts = key.split('.');
|
|
134
|
+
let node = next;
|
|
135
|
+
for (const part of parts.slice(0, -1)) {
|
|
136
|
+
if (node[part] === null || typeof node[part] !== 'object')
|
|
137
|
+
node[part] = {};
|
|
138
|
+
node = node[part];
|
|
139
|
+
}
|
|
140
|
+
node[parts.at(-1)] = value;
|
|
141
|
+
return next;
|
|
142
|
+
}
|
|
143
|
+
export function unsetConfigValue(config, key) {
|
|
144
|
+
checkKey(key);
|
|
145
|
+
const next = structuredClone(config);
|
|
146
|
+
const parts = key.split('.');
|
|
147
|
+
let node = next;
|
|
148
|
+
for (const part of parts.slice(0, -1)) {
|
|
149
|
+
const child = node?.[part];
|
|
150
|
+
node = child !== null && typeof child === 'object' ? child : undefined;
|
|
151
|
+
}
|
|
152
|
+
if (node) {
|
|
153
|
+
delete node[parts.at(-1)];
|
|
154
|
+
// Drop an emptied parent such as `labels: {}`.
|
|
155
|
+
if (parts.length === 2 && Object.keys(node).length === 0)
|
|
156
|
+
delete next[parts[0]];
|
|
157
|
+
}
|
|
158
|
+
return next;
|
|
159
|
+
}
|
|
160
|
+
export function checkKey(key) {
|
|
161
|
+
if (CONFIG_KEYS[key])
|
|
162
|
+
return;
|
|
163
|
+
if (/^env\.[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
164
|
+
return;
|
|
165
|
+
throw new ExitError(ExitCode.usage, `unknown config key '${key}' (known: ${Object.keys(CONFIG_KEYS).join(', ')}, env.<NAME>)`);
|
|
166
|
+
}
|
|
167
|
+
export function parseConfigValue(key, raw) {
|
|
168
|
+
checkKey(key);
|
|
169
|
+
const kind = CONFIG_KEYS[key]?.kind ?? 'string';
|
|
170
|
+
const text = raw.trim();
|
|
171
|
+
if (key === 'ios.team_id' && !/^[A-Z0-9]{10}$/.test(text)) {
|
|
172
|
+
throw new ExitError(ExitCode.usage, `${key} must be a 10-character Apple Team ID such as ABCDE12345`);
|
|
173
|
+
}
|
|
174
|
+
if (key === 'ios.wda_bundle_id' && !/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(text)) {
|
|
175
|
+
throw new ExitError(ExitCode.usage, `${key} must be a bundle id such as com.example.WebDriverAgentRunner`);
|
|
176
|
+
}
|
|
177
|
+
if (key === 'ios.signing_id' && (!text || text.length > 100 || /["\r\n]/.test(text))) {
|
|
178
|
+
throw new ExitError(ExitCode.usage, `${key} must be a code signing identity name such as "Apple Development"`);
|
|
179
|
+
}
|
|
180
|
+
if (key === 'ios.simulator_boot_timeout_secs' && !(/^\d+$/.test(text) && Number(text) >= 30 && Number(text) <= 3600)) {
|
|
181
|
+
throw new ExitError(ExitCode.usage, `${key} must be a whole number of seconds from 30 to 3600`);
|
|
182
|
+
}
|
|
183
|
+
switch (kind) {
|
|
184
|
+
case 'url': {
|
|
185
|
+
let url;
|
|
186
|
+
try {
|
|
187
|
+
url = new URL(text);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
throw new ExitError(ExitCode.usage, `${key} must be a URL such as https://app.example.com`);
|
|
191
|
+
}
|
|
192
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
193
|
+
throw new ExitError(ExitCode.usage, `${key} must start with https:// or http://`);
|
|
194
|
+
}
|
|
195
|
+
return text.replace(/\/+$/, '');
|
|
196
|
+
}
|
|
197
|
+
case 'list':
|
|
198
|
+
return splitList(text);
|
|
199
|
+
case 'path': {
|
|
200
|
+
if (!text)
|
|
201
|
+
throw new ExitError(ExitCode.usage, `${key} must not be empty`);
|
|
202
|
+
if (!isAbsolute(text))
|
|
203
|
+
throw new ExitError(ExitCode.usage, `${key} must be an absolute path`);
|
|
204
|
+
return text.replace(/(.)[\\/]+$/, '$1');
|
|
205
|
+
}
|
|
206
|
+
case 'boolean': {
|
|
207
|
+
const normalized = text.toLowerCase();
|
|
208
|
+
if (['1', 'true', 'yes', 'on'].includes(normalized))
|
|
209
|
+
return true;
|
|
210
|
+
if (['0', 'false', 'no', 'off'].includes(normalized))
|
|
211
|
+
return false;
|
|
212
|
+
throw new ExitError(ExitCode.usage, `${key} must be true or false`);
|
|
213
|
+
}
|
|
214
|
+
case 'integer': {
|
|
215
|
+
const value = Number(text);
|
|
216
|
+
if (!Number.isInteger(value) || value <= 0)
|
|
217
|
+
throw new ExitError(ExitCode.usage, `${key} must be a positive whole number`);
|
|
218
|
+
return value;
|
|
219
|
+
}
|
|
220
|
+
default:
|
|
221
|
+
return raw;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
export function splitList(value) {
|
|
225
|
+
return value
|
|
226
|
+
.split(',')
|
|
227
|
+
.map((item) => item.trim())
|
|
228
|
+
.filter(Boolean);
|
|
229
|
+
}
|
|
230
|
+
function unique(values) {
|
|
231
|
+
return [...new Set(values)];
|
|
232
|
+
}
|
|
233
|
+
function pick(...values) {
|
|
234
|
+
for (const value of values) {
|
|
235
|
+
const trimmed = value?.trim();
|
|
236
|
+
if (trimmed)
|
|
237
|
+
return trimmed;
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
function flag(value) {
|
|
242
|
+
if (value === undefined || value.trim() === '')
|
|
243
|
+
return undefined;
|
|
244
|
+
const normalized = value.trim().toLowerCase();
|
|
245
|
+
if (['1', 'true', 'yes', 'on'].includes(normalized))
|
|
246
|
+
return true;
|
|
247
|
+
if (['0', 'false', 'no', 'off'].includes(normalized))
|
|
248
|
+
return false;
|
|
249
|
+
// Matches the previous behaviour: anything unrecognised reads as false.
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
function integer(raw, name) {
|
|
253
|
+
const text = raw?.trim();
|
|
254
|
+
if (!text)
|
|
255
|
+
return undefined;
|
|
256
|
+
const value = Number.parseInt(text, 10);
|
|
257
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
258
|
+
throw new ExitError(ExitCode.usage, `${name} must be a positive integer`);
|
|
259
|
+
return value;
|
|
260
|
+
}
|