@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.
Files changed (110) hide show
  1. package/LICENSE +64 -0
  2. package/README.md +333 -0
  3. package/dist/src/appium/drivers.js +60 -0
  4. package/dist/src/appium/home.js +60 -0
  5. package/dist/src/appium/server.js +237 -0
  6. package/dist/src/builds/cache.js +133 -0
  7. package/dist/src/builds/cleanup.js +208 -0
  8. package/dist/src/builds/download.js +64 -0
  9. package/dist/src/builds/install.js +75 -0
  10. package/dist/src/builds/ios-app.js +99 -0
  11. package/dist/src/builds/unpack.js +347 -0
  12. package/dist/src/capabilities.js +93 -0
  13. package/dist/src/ci/builds.js +68 -0
  14. package/dist/src/ci/client.js +90 -0
  15. package/dist/src/ci/env.js +118 -0
  16. package/dist/src/ci/junit.js +79 -0
  17. package/dist/src/ci/run.js +320 -0
  18. package/dist/src/ci/summary.js +126 -0
  19. package/dist/src/cli/commands/builds.js +104 -0
  20. package/dist/src/cli/commands/ci.js +167 -0
  21. package/dist/src/cli/commands/config.js +83 -0
  22. package/dist/src/cli/commands/connect.js +70 -0
  23. package/dist/src/cli/commands/doctor.js +57 -0
  24. package/dist/src/cli/commands/service.js +165 -0
  25. package/dist/src/cli/commands/setup.js +193 -0
  26. package/dist/src/cli/commands/start.js +94 -0
  27. package/dist/src/cli/commands/update.js +189 -0
  28. package/dist/src/cli/commands/version.js +28 -0
  29. package/dist/src/cli/main.js +59 -0
  30. package/dist/src/cli/output.js +67 -0
  31. package/dist/src/cli.js +40 -0
  32. package/dist/src/client.js +146 -0
  33. package/dist/src/config.js +260 -0
  34. package/dist/src/debug.js +137 -0
  35. package/dist/src/devices/android/adb.js +251 -0
  36. package/dist/src/devices/android/avd.js +95 -0
  37. package/dist/src/devices/android/emulator.js +153 -0
  38. package/dist/src/devices/android/index.js +133 -0
  39. package/dist/src/devices/android/logcat.js +205 -0
  40. package/dist/src/devices/android/prepare.js +40 -0
  41. package/dist/src/devices/android/setup.js +162 -0
  42. package/dist/src/devices/android/system-dialog.js +78 -0
  43. package/dist/src/devices/desktop.js +145 -0
  44. package/dist/src/devices/ios/devicectl.js +87 -0
  45. package/dist/src/devices/ios/index.js +130 -0
  46. package/dist/src/devices/ios/prepare.js +39 -0
  47. package/dist/src/devices/ios/record.js +122 -0
  48. package/dist/src/devices/ios/settings.js +55 -0
  49. package/dist/src/devices/ios/setup.js +186 -0
  50. package/dist/src/devices/ios/simctl.js +201 -0
  51. package/dist/src/devices/ios/syslog.js +185 -0
  52. package/dist/src/devices/ios/wda.js +108 -0
  53. package/dist/src/devices/macos.js +248 -0
  54. package/dist/src/devices/manager.js +206 -0
  55. package/dist/src/devices/screen-record.js +95 -0
  56. package/dist/src/devices/tauri.js +189 -0
  57. package/dist/src/devices/types.js +6 -0
  58. package/dist/src/devices/windows.js +362 -0
  59. package/dist/src/doctor.js +394 -0
  60. package/dist/src/download.js +39 -0
  61. package/dist/src/drivers/android-appium.js +525 -0
  62. package/dist/src/drivers/common.js +64 -0
  63. package/dist/src/drivers/desktop-appium.js +282 -0
  64. package/dist/src/drivers/driver.js +15 -0
  65. package/dist/src/drivers/electron-playwright.js +566 -0
  66. package/dist/src/drivers/ios-appium.js +535 -0
  67. package/dist/src/drivers/mac2-appium.js +313 -0
  68. package/dist/src/drivers/registry.js +47 -0
  69. package/dist/src/drivers/snapshot/aria.js +351 -0
  70. package/dist/src/drivers/snapshot/format.js +106 -0
  71. package/dist/src/drivers/tauri-webdriver.js +678 -0
  72. package/dist/src/drivers/web-playwright.js +403 -0
  73. package/dist/src/drivers/webdriver/actions.js +328 -0
  74. package/dist/src/drivers/webdriver/desktop-keys.js +141 -0
  75. package/dist/src/drivers/webdriver/dom-locate.js +113 -0
  76. package/dist/src/drivers/webdriver/dom-snapshot.js +376 -0
  77. package/dist/src/drivers/webdriver/dom-tree.js +118 -0
  78. package/dist/src/drivers/webdriver/dom.js +213 -0
  79. package/dist/src/drivers/webdriver/ios-actions.js +299 -0
  80. package/dist/src/drivers/webdriver/ios-locate.js +121 -0
  81. package/dist/src/drivers/webdriver/locate.js +136 -0
  82. package/dist/src/drivers/webdriver/native-actions.js +175 -0
  83. package/dist/src/drivers/webdriver/native-locate.js +273 -0
  84. package/dist/src/drivers/webdriver/session.js +105 -0
  85. package/dist/src/drivers/webdriver/xml-tree-desktop.js +425 -0
  86. package/dist/src/drivers/webdriver/xml-tree-ios.js +314 -0
  87. package/dist/src/drivers/webdriver/xml-tree.js +393 -0
  88. package/dist/src/drivers/windows-appium.js +319 -0
  89. package/dist/src/errors.js +21 -0
  90. package/dist/src/executor.js +189 -0
  91. package/dist/src/home.js +76 -0
  92. package/dist/src/index.js +22 -0
  93. package/dist/src/log.js +69 -0
  94. package/dist/src/runner.js +498 -0
  95. package/dist/src/service/index.js +64 -0
  96. package/dist/src/service/launchd.js +85 -0
  97. package/dist/src/service/names.js +2 -0
  98. package/dist/src/service/schtasks.js +128 -0
  99. package/dist/src/service/systemd.js +65 -0
  100. package/dist/src/session/commands.js +165 -0
  101. package/dist/src/session/execute.js +232 -0
  102. package/dist/src/session/loop.js +148 -0
  103. package/dist/src/template.js +62 -0
  104. package/dist/src/types.js +7 -0
  105. package/dist/src/update/apply.js +223 -0
  106. package/dist/src/update/check.js +59 -0
  107. package/dist/src/update/manifest.js +93 -0
  108. package/dist/src/update/verify.js +65 -0
  109. package/dist/src/version.js +42 -0
  110. package/package.json +44 -0
@@ -0,0 +1,126 @@
1
+ /** CI exit codes (plan §7.1). */
2
+ export const CiExit = {
3
+ ok: 0,
4
+ error: 1,
5
+ failed: 10,
6
+ blocked: 11,
7
+ timeout: 12,
8
+ cancelled: 13,
9
+ interrupted: 130,
10
+ };
11
+ /** Exit code for a finished (or abandoned) run. */
12
+ export function exitCodeFor(status, ended) {
13
+ if (ended === 'interrupted')
14
+ return CiExit.interrupted;
15
+ if (ended === 'timeout')
16
+ return CiExit.timeout;
17
+ if (!status)
18
+ return CiExit.error;
19
+ if (status.status === 'cancelled')
20
+ return CiExit.cancelled;
21
+ if (status.items.failed > 0)
22
+ return CiExit.failed;
23
+ if (status.items.blocked > 0 || status.items.pending > 0)
24
+ return CiExit.blocked;
25
+ return CiExit.ok;
26
+ }
27
+ export function verdict(code) {
28
+ switch (code) {
29
+ case CiExit.ok:
30
+ return 'passed';
31
+ case CiExit.failed:
32
+ return 'failed';
33
+ case CiExit.blocked:
34
+ return 'blocked';
35
+ case CiExit.timeout:
36
+ return 'timed out';
37
+ case CiExit.cancelled:
38
+ return 'cancelled';
39
+ case CiExit.interrupted:
40
+ return 'interrupted';
41
+ default:
42
+ return 'error';
43
+ }
44
+ }
45
+ function counts(outcome) {
46
+ const items = outcome.status?.items;
47
+ if (!items)
48
+ return [];
49
+ return [
50
+ ['Total', items.total],
51
+ ['Passed', items.passed],
52
+ ['Failed', items.failed],
53
+ ['Blocked', items.blocked],
54
+ ['Skipped', items.skipped],
55
+ ['Pending', items.pending],
56
+ ];
57
+ }
58
+ function sortedItems(outcome) {
59
+ const order = { failed: 0, error: 1, blocked: 2, skipped: 3, passed: 4 };
60
+ return [...outcome.items].sort((a, b) => (order[a.result] ?? 5) - (order[b.result] ?? 5) || a.item_id - b.item_id);
61
+ }
62
+ function oneLine(text, max) {
63
+ const flat = text.replace(/\s+/g, ' ').trim();
64
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
65
+ }
66
+ /** The plain-text table printed at the end of `ci`. */
67
+ export function formatSummary(outcome, code) {
68
+ const lines = [
69
+ '',
70
+ `Run #${outcome.runId} "${outcome.runName}" (${outcome.platform}): ${verdict(code)} in ${Math.round(outcome.elapsedSecs)}s`,
71
+ ];
72
+ const table = counts(outcome);
73
+ if (table.length > 0) {
74
+ lines.push(table.map(([label]) => label.padStart(8)).join(''));
75
+ lines.push(table.map(([, value]) => String(value).padStart(8)).join(''));
76
+ }
77
+ const items = sortedItems(outcome);
78
+ if (items.length > 0) {
79
+ lines.push('');
80
+ for (const item of items) {
81
+ const detail = item.failures[0] ?? (item.result !== 'passed' ? item.notes : '');
82
+ lines.push(` ${item.result.padEnd(8)} ${oneLine(item.title, 70)}${detail ? ` — ${oneLine(detail, 100)}` : ''}`);
83
+ }
84
+ }
85
+ if (outcome.created.skipped.length > 0) {
86
+ lines.push('', `Not queued (${outcome.created.skipped.length}):`);
87
+ for (const skipped of outcome.created.skipped)
88
+ lines.push(` ${oneLine(skipped.title, 70)} — ${oneLine(skipped.reason, 100)}`);
89
+ }
90
+ const report = outcome.status?.report_url ?? outcome.created.report_url;
91
+ if (report)
92
+ lines.push('', `Report: ${report}`);
93
+ return lines.join('\n');
94
+ }
95
+ function cell(text, max = 200) {
96
+ return oneLine(text, max).replace(/\|/g, '\\|');
97
+ }
98
+ const ICONS = { passed: '✅', failed: '❌', blocked: '⛔', skipped: '⏭️', error: '⚠️' };
99
+ /** Markdown for `$GITHUB_STEP_SUMMARY`. */
100
+ export function formatMarkdown(outcome, code) {
101
+ const report = outcome.status?.report_url ?? outcome.created.report_url;
102
+ const title = report ? `[${cell(outcome.runName)}](${report})` : cell(outcome.runName);
103
+ const lines = [`### QA run ${title}: ${verdict(code)}`, '', `Platform \`${outcome.platform}\` · run #${outcome.runId} · ${Math.round(outcome.elapsedSecs)}s`, ''];
104
+ const table = counts(outcome);
105
+ if (table.length > 0) {
106
+ lines.push(`| ${table.map(([label]) => label).join(' | ')} |`);
107
+ lines.push(`|${table.map(() => '---:').join('|')}|`);
108
+ lines.push(`| ${table.map(([, value]) => value).join(' | ')} |`, '');
109
+ }
110
+ const items = sortedItems(outcome);
111
+ if (items.length > 0) {
112
+ lines.push('| Result | Test | Notes |', '|---|---|---|');
113
+ for (const item of items) {
114
+ const detail = item.failures.length > 0 ? item.failures.join('; ') : item.result === 'passed' ? '' : item.notes;
115
+ lines.push(`| ${ICONS[item.result] ?? ''} ${item.result} | ${cell(item.title)} | ${cell(detail, 300)} |`);
116
+ }
117
+ lines.push('');
118
+ }
119
+ if (outcome.created.skipped.length > 0) {
120
+ lines.push('<details><summary>Not queued ('.concat(String(outcome.created.skipped.length), ')</summary>'), '');
121
+ for (const skipped of outcome.created.skipped)
122
+ lines.push(`- ${cell(skipped.title)}: ${cell(skipped.reason)}`);
123
+ lines.push('', '</details>', '');
124
+ }
125
+ return `${lines.join('\n')}\n`;
126
+ }
@@ -0,0 +1,104 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { describeBuildFile, uploadBuild } from '../../ci/builds.js';
3
+ import { CiClient } from '../../ci/client.js';
4
+ import { detectCi } from '../../ci/env.js';
5
+ import { ApiError } from '../../client.js';
6
+ import { isPlatform } from '../../devices/types.js';
7
+ import { ExitCode, ExitError, messageOf } from '../../errors.js';
8
+ import { createLogger } from '../../log.js';
9
+ import { printJson, usageError } from '../output.js';
10
+ const usage = `Usage: leera-qa-runner builds upload PATH --platform PLATFORM [options]
11
+
12
+ Uploads an app build with a CI token (RUNNER_URL, RUNNER_TOKEN) and prints its id, for
13
+ \`leera-qa-runner ci --build-id\` or the workspace's environment settings.
14
+
15
+ Options:
16
+ --project KEY|ID Project (default: the CI token's project)
17
+ --platform NAME android, ios, ... (required)
18
+ --version-name TEXT Version name shown in the workspace
19
+ --build-number TEXT Build number
20
+ --app-id ID Package name or bundle id
21
+ --notes TEXT Notes
22
+ --git-sha SHA Commit the build came from (default: detected from the CI environment or git)
23
+ --git-branch NAME Branch the build came from (likewise)
24
+ --relay Send the file through the server instead of the direct upload URL
25
+ --json Print the build as JSON
26
+ `;
27
+ export const command = {
28
+ summary: 'Upload an app build (CI token)',
29
+ usage,
30
+ async run(args, io) {
31
+ const [action, ...rest] = args;
32
+ if (action !== 'upload')
33
+ throw usageError(action ? `unknown builds action '${action}'` : 'missing action', usage);
34
+ const { values, positionals } = parseArgs({
35
+ args: rest,
36
+ strict: true,
37
+ allowPositionals: true,
38
+ options: {
39
+ project: { type: 'string' },
40
+ platform: { type: 'string' },
41
+ 'version-name': { type: 'string' },
42
+ 'build-number': { type: 'string' },
43
+ 'app-id': { type: 'string' },
44
+ notes: { type: 'string' },
45
+ 'git-sha': { type: 'string' },
46
+ 'git-branch': { type: 'string' },
47
+ relay: { type: 'boolean' },
48
+ json: { type: 'boolean' },
49
+ },
50
+ });
51
+ if (positionals.length !== 1)
52
+ throw usageError('pass exactly one build file', usage);
53
+ const platform = values.platform;
54
+ if (!platform)
55
+ throw usageError('--platform is required', usage);
56
+ if (!isPlatform(platform) || platform === 'web')
57
+ throw usageError(`'${platform}' does not take app builds`, usage);
58
+ const url = io.env.RUNNER_URL?.trim() || io.env.LEERA_URL?.trim();
59
+ const token = io.env.RUNNER_TOKEN?.trim() || io.env.LEERA_RUNNER_TOKEN?.trim();
60
+ if (!url)
61
+ throw new ExitError(ExitCode.usage, 'RUNNER_URL is required');
62
+ if (!token)
63
+ throw new ExitError(ExitCode.usage, 'RUNNER_TOKEN is required (a CI token)');
64
+ const log = createLogger({ console: io.interactive ? 'pretty' : 'json', stdout: (line) => io.stderr(line) });
65
+ const explicitGit = values['git-sha'] !== undefined && values['git-branch'] !== undefined;
66
+ const ci = explicitGit ? null : detectCi(io.env);
67
+ const gitSha = values['git-sha'] ?? ci?.sha ?? undefined;
68
+ const gitBranch = values['git-branch'] ?? ci?.branch ?? undefined;
69
+ const file = await describeBuildFile(positionals[0]);
70
+ const project = values.project ?? io.env.RUNNER_PROJECT?.trim();
71
+ let build;
72
+ try {
73
+ build = await uploadBuild(new CiClient(url, token), {
74
+ file,
75
+ platform,
76
+ ...(project ? { project } : {}),
77
+ meta: {
78
+ ...(values['version-name'] ? { version_name: values['version-name'] } : {}),
79
+ ...(values['build-number'] ? { build_number: values['build-number'] } : {}),
80
+ ...(values['app-id'] ? { app_id: values['app-id'] } : {}),
81
+ ...(values.notes ? { notes: values.notes } : {}),
82
+ ...(gitSha ? { git_sha: gitSha } : {}),
83
+ ...(gitBranch ? { git_branch: gitBranch } : {}),
84
+ },
85
+ relay: values.relay ?? false,
86
+ log,
87
+ });
88
+ }
89
+ catch (error) {
90
+ if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
91
+ throw new ExitError(ExitCode.tokenRejected, `the server refused the token (${error.status} ${error.message})`);
92
+ }
93
+ if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
94
+ throw new ExitError(ExitCode.usage, `the upload was refused: ${error.message}`);
95
+ }
96
+ throw new ExitError(ExitCode.error, `the upload failed: ${messageOf(error)}`);
97
+ }
98
+ if (values.json)
99
+ printJson(io, build);
100
+ else
101
+ io.stdout(String(build.id));
102
+ return ExitCode.ok;
103
+ },
104
+ };
@@ -0,0 +1,167 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { DEFAULT_TIMEOUT_MINUTES, runCi, VIDEO_MODES } from '../../ci/run.js';
3
+ import { splitList } from '../../config.js';
4
+ import { formatDoctor, runDoctor } from '../../doctor.js';
5
+ import { shutdownEmulatorsBootedHere } from '../../devices/android/emulator.js';
6
+ import { stopSharedAppiumServer } from '../../drivers/android-appium.js';
7
+ import { createRegistry } from '../../drivers/registry.js';
8
+ import { BrowserPool } from '../../drivers/web-playwright.js';
9
+ import { homePaths } from '../../home.js';
10
+ import { createLogger } from '../../log.js';
11
+ import { usageError } from '../output.js';
12
+ import { installBrowsers } from './setup.js';
13
+ const usage = `Usage: leera-qa-runner ci --environment ENV --platform PLATFORM [options]
14
+
15
+ Creates a test run for this commit, runs its jobs on this machine as an ephemeral runner,
16
+ waits for the verdict and exits with it. Reads RUNNER_URL and RUNNER_TOKEN (a CI token).
17
+
18
+ Options:
19
+ --project KEY|ID Project (default: the CI token's project)
20
+ --plan NAME|ID Test plan (default: every active test case with a script for the platform)
21
+ --environment SLUG|ID Test environment (required)
22
+ --platform NAME web, android, ios, electron, tauri, windows or macos (required)
23
+ --build PATH App build to test; stays on this runner unless --upload-build
24
+ --upload-build Upload --build to the workspace first (other runners can use it)
25
+ --build-id N Test an uploaded build
26
+ --version-name TEXT Build version name (with --upload-build, or to pick an uploaded build)
27
+ --build-number TEXT Build number (likewise)
28
+ --name TEXT Test run name (default: "CI <platform> <branch>@<sha>")
29
+ --video MODE off, on_failure (default) or always
30
+ --timeout MINUTES Cancel the run after this long (default ${DEFAULT_TIMEOUT_MINUTES})
31
+ --junit PATH Write a JUnit XML report
32
+ --labels a,b Use exactly these runner labels
33
+ --concurrency N Browser jobs at once (default 1)
34
+ --skip-doctor Do not check the machine first
35
+
36
+ Exit codes: 0 passed · 1 error · 2 usage · 3 token refused · 4 machine not ready ·
37
+ 5 runner too old · 10 failed · 11 blocked · 12 timed out · 13 cancelled · 130 interrupted
38
+ `;
39
+ function positive(raw, name) {
40
+ const value = Number(raw);
41
+ if (!Number.isInteger(value) || value <= 0)
42
+ throw usageError(`${name} must be a positive whole number`, usage);
43
+ return value;
44
+ }
45
+ export function parseCiFlags(args, env = {}) {
46
+ const { values } = parseArgs({
47
+ args,
48
+ strict: true,
49
+ allowPositionals: false,
50
+ options: {
51
+ project: { type: 'string' },
52
+ plan: { type: 'string' },
53
+ environment: { type: 'string' },
54
+ platform: { type: 'string' },
55
+ build: { type: 'string' },
56
+ 'upload-build': { type: 'boolean' },
57
+ 'build-id': { type: 'string' },
58
+ 'version-name': { type: 'string' },
59
+ 'build-number': { type: 'string' },
60
+ name: { type: 'string' },
61
+ video: { type: 'string' },
62
+ timeout: { type: 'string' },
63
+ junit: { type: 'string' },
64
+ labels: { type: 'string' },
65
+ concurrency: { type: 'string' },
66
+ 'skip-doctor': { type: 'boolean' },
67
+ },
68
+ });
69
+ const environment = values.environment ?? env.RUNNER_ENVIRONMENT?.trim();
70
+ const platform = values.platform ?? env.RUNNER_PLATFORM?.trim();
71
+ if (!environment)
72
+ throw usageError('--environment is required', usage);
73
+ if (!platform)
74
+ throw usageError('--platform is required', usage);
75
+ const flags = { environment, platform };
76
+ const project = values.project ?? env.RUNNER_PROJECT?.trim();
77
+ if (project)
78
+ flags.project = project;
79
+ if (values.plan !== undefined)
80
+ flags.plan = values.plan;
81
+ if (values.build !== undefined)
82
+ flags.build = values.build;
83
+ if (values['upload-build'])
84
+ flags.uploadBuild = true;
85
+ if (values['build-id'] !== undefined)
86
+ flags.buildId = positive(values['build-id'], '--build-id');
87
+ if (values['version-name'] !== undefined)
88
+ flags.versionName = values['version-name'];
89
+ if (values['build-number'] !== undefined)
90
+ flags.buildNumber = values['build-number'];
91
+ if (values.name !== undefined)
92
+ flags.name = values.name;
93
+ if (values.video !== undefined) {
94
+ if (!VIDEO_MODES.includes(values.video))
95
+ throw usageError(`--video must be one of ${VIDEO_MODES.join(', ')}`, usage);
96
+ flags.video = values.video;
97
+ }
98
+ if (values.timeout !== undefined)
99
+ flags.timeoutMinutes = positive(values.timeout, '--timeout');
100
+ if (values.junit !== undefined)
101
+ flags.junit = values.junit;
102
+ if (values.labels !== undefined)
103
+ flags.labels = splitList(values.labels);
104
+ if (values.concurrency !== undefined)
105
+ flags.concurrency = positive(values.concurrency, '--concurrency');
106
+ if (values['skip-doctor'])
107
+ flags.skipDoctor = true;
108
+ return flags;
109
+ }
110
+ export const command = {
111
+ summary: 'Run this commit\'s tests in CI and exit with the verdict',
112
+ usage,
113
+ async run(args, io) {
114
+ const flags = parseCiFlags(args, io.env);
115
+ const paths = homePaths(io.env);
116
+ // CI logs are read by people: readable lines, not JSON.
117
+ const log = createLogger({ console: 'pretty' });
118
+ const registry = createRegistry();
119
+ const shared = { browsers: new BrowserPool({ headless: true }) };
120
+ const interrupt = new AbortController();
121
+ let signals = 0;
122
+ const onSignal = () => {
123
+ signals++;
124
+ if (signals > 1) {
125
+ log.warn('interrupted twice, exiting now');
126
+ process.exit(130);
127
+ }
128
+ log.warn('interrupted: cancelling the run');
129
+ interrupt.abort();
130
+ };
131
+ process.on('SIGINT', onSignal);
132
+ process.on('SIGTERM', onSignal);
133
+ try {
134
+ return await runCi(flags, {
135
+ env: io.env,
136
+ log,
137
+ print: (text) => io.stdout(text),
138
+ registry,
139
+ shared,
140
+ paths,
141
+ interrupt: interrupt.signal,
142
+ doctor: async (platform) => {
143
+ // CI machines start clean: create the home and install Chromium when missing.
144
+ const report = await runDoctor({
145
+ env: io.env,
146
+ paths,
147
+ registry,
148
+ platform,
149
+ fix: true,
150
+ installBrowsers: async () => (await installBrowsers(io, paths)) === 0,
151
+ });
152
+ return { ok: report.ok, text: formatDoctor(report) };
153
+ },
154
+ });
155
+ }
156
+ finally {
157
+ process.off('SIGINT', onSignal);
158
+ process.off('SIGTERM', onSignal);
159
+ await shared.browsers.close();
160
+ await stopSharedAppiumServer();
161
+ // An ephemeral runner owns the emulators it booted (iOS simulators are shut down when their lease ends).
162
+ const stopped = await shutdownEmulatorsBootedHere();
163
+ if (stopped.length > 0)
164
+ log.info('emulators shut down', { serials: stopped });
165
+ }
166
+ },
167
+ };
@@ -0,0 +1,83 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { CONFIG_KEYS, checkKey, getConfigValue, loadFileConfig, saveFileConfig, setConfigValue, unsetConfigValue, } from '../../config.js';
3
+ import { ExitCode } from '../../errors.js';
4
+ import { homePaths, writeTokenFile } from '../../home.js';
5
+ import { printJson, usageError } from '../output.js';
6
+ import { readToken } from './connect.js';
7
+ const usage = `Usage: leera-qa-runner config <get|set|unset|list|path|set-token> ...
8
+
9
+ config get KEY Print one value
10
+ config set KEY VALUE Store a value
11
+ config unset KEY Remove a value
12
+ config list [--json] Print the stored configuration (the token is never printed)
13
+ config path Print where the configuration lives
14
+ config set-token [--token-stdin]
15
+ Replace the runner token (prompted without echo)
16
+
17
+ Keys:
18
+ ${Object.entries(CONFIG_KEYS)
19
+ .map(([key, { help }]) => ` ${key.padEnd(25)} ${help}`)
20
+ .join('\n')}
21
+ env.NAME Environment variable applied when the service starts
22
+
23
+ Environment variables (RUNNER_*) and flags override stored values.
24
+ `;
25
+ export const command = {
26
+ summary: 'Read or change the stored configuration',
27
+ usage,
28
+ async run(args, io) {
29
+ const { values, positionals } = parseArgs({
30
+ args,
31
+ strict: true,
32
+ allowPositionals: true,
33
+ options: { json: { type: 'boolean' }, 'token-stdin': { type: 'boolean' } },
34
+ });
35
+ const [action, key, ...rest] = positionals;
36
+ const paths = homePaths(io.env);
37
+ const { file, token } = loadFileConfig(paths);
38
+ const config = file ?? {};
39
+ switch (action) {
40
+ case 'get': {
41
+ if (!key || rest.length > 0)
42
+ throw usageError('config get needs one KEY', usage);
43
+ checkKey(key);
44
+ const value = getConfigValue(config, key);
45
+ if (value === undefined)
46
+ return ExitCode.error;
47
+ io.stdout(Array.isArray(value) ? value.join(',') : typeof value === 'object' ? JSON.stringify(value) : String(value));
48
+ return ExitCode.ok;
49
+ }
50
+ case 'set': {
51
+ if (!key || rest.length !== 1)
52
+ throw usageError('config set needs KEY and VALUE', usage);
53
+ saveFileConfig(paths, setConfigValue(config, key, rest[0]));
54
+ return ExitCode.ok;
55
+ }
56
+ case 'unset': {
57
+ if (!key || rest.length > 0)
58
+ throw usageError('config unset needs one KEY', usage);
59
+ saveFileConfig(paths, unsetConfigValue(config, key));
60
+ return ExitCode.ok;
61
+ }
62
+ case 'list': {
63
+ const shown = { ...config, token: token ? 'stored' : 'not set' };
64
+ if (values.json)
65
+ printJson(io, shown);
66
+ else
67
+ io.stdout(JSON.stringify(shown, null, 2));
68
+ return ExitCode.ok;
69
+ }
70
+ case 'path':
71
+ io.stdout(paths.config);
72
+ return ExitCode.ok;
73
+ case 'set-token': {
74
+ const next = await readToken(io, values['token-stdin'] ?? false);
75
+ writeTokenFile(paths, next);
76
+ io.stdout(`Saved the token to ${paths.token}.`);
77
+ return ExitCode.ok;
78
+ }
79
+ default:
80
+ throw usageError(action ? `unknown config action '${action}'` : 'say what to do', usage);
81
+ }
82
+ },
83
+ };
@@ -0,0 +1,70 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { loadFileConfig, parseConfigValue, saveFileConfig, TOKEN_PREFIX } from '../../config.js';
3
+ import { formatDoctor, runDoctor } from '../../doctor.js';
4
+ import { createRegistry } from '../../drivers/registry.js';
5
+ import { ExitCode } from '../../errors.js';
6
+ import { homePaths, writeTokenFile } from '../../home.js';
7
+ import { promptHidden, readStdin, usageError } from '../output.js';
8
+ const usage = `Usage: leera-qa-runner connect --url <server> [--token-stdin] [--name <name>]
9
+
10
+ Saves the server URL and a runner token, then checks the machine with \`doctor\`.
11
+ The token is prompted for without echo, or read from stdin with --token-stdin.
12
+
13
+ Options:
14
+ --url URL The workspace server, e.g. https://app.example.com
15
+ --token-stdin Read the token from stdin (for scripts)
16
+ --name NAME Runner name shown in the workspace (default: host name)
17
+ --skip-doctor Do not run the checks afterwards
18
+ `;
19
+ /** Reads a token from stdin or a hidden prompt and checks its shape. */
20
+ export async function readToken(io, fromStdin) {
21
+ let token;
22
+ if (fromStdin)
23
+ token = (await readStdin(io.stdin)).trim();
24
+ else if (io.interactive)
25
+ token = (await promptHidden(io, 'Runner token: ')).trim();
26
+ else
27
+ throw usageError('no terminal to prompt for the token; pipe it and pass --token-stdin');
28
+ if (!token)
29
+ throw usageError('no token given');
30
+ if (!token.startsWith(TOKEN_PREFIX))
31
+ throw usageError(`that is not a runner pool token (${TOKEN_PREFIX}...)`);
32
+ return token;
33
+ }
34
+ export const command = {
35
+ summary: 'Save the server URL and token, then run doctor',
36
+ usage,
37
+ async run(args, io) {
38
+ const { values } = parseArgs({
39
+ args,
40
+ strict: true,
41
+ options: {
42
+ url: { type: 'string' },
43
+ 'token-stdin': { type: 'boolean' },
44
+ name: { type: 'string' },
45
+ 'skip-doctor': { type: 'boolean' },
46
+ },
47
+ });
48
+ const paths = homePaths(io.env);
49
+ const { file } = loadFileConfig(paths);
50
+ const rawUrl = values.url ?? file?.url;
51
+ if (!rawUrl)
52
+ throw usageError('--url is required', usage);
53
+ const url = parseConfigValue('url', rawUrl);
54
+ const token = await readToken(io, values['token-stdin'] ?? false);
55
+ const next = { ...(file ?? {}), url };
56
+ if (values.name !== undefined)
57
+ next.name = values.name.trim();
58
+ saveFileConfig(paths, next);
59
+ writeTokenFile(paths, token);
60
+ io.stdout(`Saved the server URL to ${paths.config} and the token to ${paths.token}.`);
61
+ if (!values['skip-doctor']) {
62
+ const report = await runDoctor({ env: io.env, paths, registry: createRegistry() });
63
+ io.stdout('');
64
+ io.stdout(formatDoctor(report));
65
+ }
66
+ io.stdout('');
67
+ io.stdout('Next: run `leera-qa-runner service install` to run in the background, or `leera-qa-runner start` to run in this terminal.');
68
+ return ExitCode.ok;
69
+ },
70
+ };
@@ -0,0 +1,57 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { formatDoctor, runDoctor } from '../../doctor.js';
3
+ import { isPlatform } from '../../devices/types.js';
4
+ import { createRegistry } from '../../drivers/registry.js';
5
+ import { ExitCode } from '../../errors.js';
6
+ import { homePaths } from '../../home.js';
7
+ import { printJson, usageError } from '../output.js';
8
+ import { installBrowsers } from './setup.js';
9
+ const usage = `Usage: leera-qa-runner doctor [--json] [--fix] [--platform web|android|ios|electron|tauri]
10
+
11
+ Checks that this machine can run jobs: Node.js, the runner home, the server URL and
12
+ token, and each platform's tools. Exits 4 when something needs fixing.
13
+
14
+ Options:
15
+ --json Print the report as JSON
16
+ --fix Fix what can be fixed safely (permissions, installing Chromium)
17
+ --platform NAME Only check one platform
18
+ `;
19
+ export const command = {
20
+ summary: 'Check that this machine is ready to run jobs',
21
+ usage,
22
+ async run(args, io) {
23
+ const { values } = parseArgs({
24
+ args,
25
+ strict: true,
26
+ options: {
27
+ json: { type: 'boolean' },
28
+ fix: { type: 'boolean' },
29
+ platform: { type: 'string' },
30
+ offline: { type: 'boolean' },
31
+ },
32
+ });
33
+ const registry = createRegistry();
34
+ let platform;
35
+ if (values.platform !== undefined) {
36
+ if (!isPlatform(values.platform) || !registry.has(values.platform)) {
37
+ throw usageError(`this runner version cannot run '${values.platform}' jobs (supported: ${[...registry.keys()].join(', ')})`);
38
+ }
39
+ platform = values.platform;
40
+ }
41
+ const paths = homePaths(io.env);
42
+ const report = await runDoctor({
43
+ env: io.env,
44
+ paths,
45
+ registry,
46
+ fix: values.fix ?? false,
47
+ offline: values.offline ?? false,
48
+ ...(platform ? { platform } : {}),
49
+ installBrowsers: async () => (await installBrowsers(io, paths)) === 0,
50
+ });
51
+ if (values.json)
52
+ printJson(io, report);
53
+ else
54
+ io.stdout(formatDoctor(report));
55
+ return report.ok ? ExitCode.ok : ExitCode.environmentNotReady;
56
+ },
57
+ };