@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,90 @@
1
+ import { openAsBlob } from 'node:fs';
2
+ import { ApiError } from '../client.js';
3
+ export const CI_PATHS = {
4
+ runs: '/api/v1/qa/runner/runs',
5
+ status: '/api/v1/qa/runner/runs/status',
6
+ cancel: '/api/v1/qa/runner/runs/cancel',
7
+ builds: '/api/v1/qa/runner/builds',
8
+ finish: '/api/v1/qa/runner/builds/finish',
9
+ content: '/api/v1/qa/runner/builds/content',
10
+ };
11
+ /** CI-token calls: runs and build uploads (contracts §J). */
12
+ export class CiClient {
13
+ base;
14
+ token;
15
+ fetchImpl;
16
+ constructor(baseUrl, token, fetchImpl = fetch) {
17
+ this.base = baseUrl.replace(/\/+$/, '');
18
+ this.token = token;
19
+ this.fetchImpl = fetchImpl;
20
+ }
21
+ async createRun(body) {
22
+ const run = await this.post(CI_PATHS.runs, body);
23
+ return { ...run, skipped: run.skipped ?? [], report_url: run.report_url ?? null };
24
+ }
25
+ runStatus(runId) {
26
+ return this.post(CI_PATHS.status, { run_id: runId });
27
+ }
28
+ async cancelRun(runId) {
29
+ const result = await this.post(CI_PATHS.cancel, { run_id: runId });
30
+ return result.cancelled ?? 0;
31
+ }
32
+ createBuild(body) {
33
+ return this.post(CI_PATHS.builds, body);
34
+ }
35
+ finishBuild(buildId) {
36
+ return this.post(CI_PATHS.finish, { build_id: buildId }, 5 * 60_000);
37
+ }
38
+ /** Sends the file through the server (for storage the CI machine cannot reach directly). */
39
+ async relayBuild(buildId, path, fileName, contentType) {
40
+ const form = new FormData();
41
+ form.append('file', await openAsBlob(path, { type: contentType }), fileName);
42
+ return this.request(`${CI_PATHS.content}?build_id=${buildId}`, { method: 'POST', body: form }, 60 * 60_000);
43
+ }
44
+ /** PUTs the file to the presigned upload URL with the headers the server asked for. */
45
+ async putPresigned(upload, path, contentType) {
46
+ const url = new URL(upload.url);
47
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
48
+ throw new Error(`refusing to upload to a ${url.protocol} URL`);
49
+ // Case-insensitive: the server's headers (signed into the URL) win over the guessed type.
50
+ const headers = new Headers({ 'content-type': contentType });
51
+ for (const [name, value] of Object.entries(upload.headers ?? {}))
52
+ headers.set(name, value);
53
+ const response = await this.fetchImpl(url, {
54
+ method: upload.method || 'PUT',
55
+ headers,
56
+ body: await openAsBlob(path, { type: contentType }),
57
+ signal: AbortSignal.timeout(60 * 60_000),
58
+ });
59
+ if (!response.ok) {
60
+ const text = await response.text().catch(() => '');
61
+ throw new Error(`the upload URL answered HTTP ${response.status}${text ? `: ${text.slice(0, 200)}` : ''}`);
62
+ }
63
+ }
64
+ post(path, body, timeoutMs = 60_000) {
65
+ return this.request(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }, timeoutMs);
66
+ }
67
+ async request(path, init, timeoutMs) {
68
+ const response = await this.fetchImpl(this.base + path, {
69
+ ...init,
70
+ headers: { ...init.headers, authorization: `Bearer ${this.token}` },
71
+ signal: AbortSignal.timeout(timeoutMs),
72
+ });
73
+ const text = await response.text();
74
+ let payload = null;
75
+ try {
76
+ payload = text ? JSON.parse(text) : null;
77
+ }
78
+ catch {
79
+ payload = null;
80
+ }
81
+ if (!response.ok) {
82
+ const message = payload?.message || payload?.error_message || `HTTP ${response.status}`;
83
+ throw new ApiError(message, response.status, payload);
84
+ }
85
+ const data = payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload;
86
+ if (data === null || data === undefined)
87
+ throw new ApiError('the server returned no data', 502);
88
+ return data;
89
+ }
90
+ }
@@ -0,0 +1,118 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { basename } from 'node:path';
3
+ export const defaultGit = (args) => {
4
+ try {
5
+ const out = execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5_000 }).trim();
6
+ return out || null;
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ };
12
+ function value(env, ...names) {
13
+ for (const name of names) {
14
+ const text = env[name]?.trim();
15
+ if (text)
16
+ return text;
17
+ }
18
+ return null;
19
+ }
20
+ function truthy(raw) {
21
+ return ['1', 'true', 'yes'].includes((raw ?? '').trim().toLowerCase());
22
+ }
23
+ /** Detects GitHub Actions, GitLab CI, Buildkite, CircleCI, a generic `CI=true` and falls back to git. */
24
+ export function detectCi(env, git = defaultGit, now = Date.now) {
25
+ let info;
26
+ if (truthy(env.GITHUB_ACTIONS)) {
27
+ const server = value(env, 'GITHUB_SERVER_URL') ?? 'https://github.com';
28
+ const repository = value(env, 'GITHUB_REPOSITORY');
29
+ const runId = value(env, 'GITHUB_RUN_ID');
30
+ info = {
31
+ provider: 'github',
32
+ sha: value(env, 'GITHUB_SHA'),
33
+ // Pull request builds check out a merge ref; the head branch is what people recognise.
34
+ branch: value(env, 'GITHUB_HEAD_REF', 'GITHUB_REF_NAME'),
35
+ url: repository && runId ? `${server.replace(/\/+$/, '')}/${repository}/actions/runs/${runId}` : null,
36
+ repo: repository ? basename(repository) : null,
37
+ run: runId,
38
+ attempt: value(env, 'GITHUB_RUN_ATTEMPT'),
39
+ };
40
+ }
41
+ else if (truthy(env.GITLAB_CI)) {
42
+ info = {
43
+ provider: 'gitlab',
44
+ sha: value(env, 'CI_COMMIT_SHA'),
45
+ branch: value(env, 'CI_MERGE_REQUEST_SOURCE_BRANCH_NAME', 'CI_COMMIT_BRANCH', 'CI_COMMIT_REF_NAME'),
46
+ url: value(env, 'CI_JOB_URL', 'CI_PIPELINE_URL'),
47
+ repo: value(env, 'CI_PROJECT_NAME'),
48
+ run: value(env, 'CI_PIPELINE_ID'),
49
+ // A retried GitLab job gets a new job id.
50
+ attempt: value(env, 'CI_JOB_ID'),
51
+ };
52
+ }
53
+ else if (truthy(env.BUILDKITE)) {
54
+ const retries = Number.parseInt(env.BUILDKITE_RETRY_COUNT ?? '', 10);
55
+ info = {
56
+ provider: 'buildkite',
57
+ sha: value(env, 'BUILDKITE_COMMIT'),
58
+ branch: value(env, 'BUILDKITE_BRANCH'),
59
+ url: value(env, 'BUILDKITE_BUILD_URL'),
60
+ repo: value(env, 'BUILDKITE_PIPELINE_SLUG'),
61
+ run: value(env, 'BUILDKITE_BUILD_NUMBER'),
62
+ attempt: Number.isFinite(retries) ? String(retries + 1) : '1',
63
+ };
64
+ }
65
+ else if (truthy(env.CIRCLECI)) {
66
+ const node = Number.parseInt(env.CIRCLE_NODE_INDEX ?? '', 10);
67
+ info = {
68
+ provider: 'circleci',
69
+ sha: value(env, 'CIRCLE_SHA1'),
70
+ branch: value(env, 'CIRCLE_BRANCH'),
71
+ url: value(env, 'CIRCLE_BUILD_URL'),
72
+ repo: value(env, 'CIRCLE_PROJECT_REPONAME'),
73
+ run: value(env, 'CIRCLE_BUILD_NUM'),
74
+ attempt: Number.isFinite(node) ? String(node + 1) : '1',
75
+ };
76
+ }
77
+ else {
78
+ info = {
79
+ provider: truthy(env.CI) ? 'ci' : 'local',
80
+ sha: null,
81
+ branch: null,
82
+ url: null,
83
+ repo: null,
84
+ run: null,
85
+ attempt: null,
86
+ };
87
+ }
88
+ // Fill what the provider did not say from git.
89
+ if (!info.sha)
90
+ info.sha = git(['rev-parse', 'HEAD']);
91
+ if (!info.branch) {
92
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']);
93
+ info.branch = branch && branch !== 'HEAD' ? branch : null;
94
+ }
95
+ if (!info.repo) {
96
+ const top = git(['rev-parse', '--show-toplevel']);
97
+ info.repo = top ? basename(top) : null;
98
+ }
99
+ if (!info.run)
100
+ info.run = String(Math.floor(now() / 1000));
101
+ if (!info.attempt)
102
+ info.attempt = '1';
103
+ return info;
104
+ }
105
+ /** `ci-<repo>-<run>-<attempt>`, restricted to safe characters and the server's 100-character limit. */
106
+ export function ciRunnerName(info) {
107
+ const part = (text, fallback) => (text ?? fallback)
108
+ .toLowerCase()
109
+ .replace(/[^a-z0-9._-]+/g, '-')
110
+ .replace(/^-+|-+$/g, '') || fallback;
111
+ const suffix = `-${part(info.run, 'run')}-${part(info.attempt, '1')}`.slice(-60);
112
+ return `ci-${part(info.repo, 'repo').slice(0, 100 - 3 - suffix.length)}${suffix}`;
113
+ }
114
+ /** A readable default run name such as `CI main@1a2b3c4`. */
115
+ export function defaultRunName(info, platform) {
116
+ const where = [info.branch, info.sha ? info.sha.slice(0, 7) : null].filter(Boolean).join('@');
117
+ return `CI ${platform}${where ? ` ${where}` : ''}`;
118
+ }
@@ -0,0 +1,79 @@
1
+ export function xmlEscape(text) {
2
+ return (text
3
+ // Characters XML 1.0 does not allow at all.
4
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control characters is the point
5
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g, '')
6
+ .replace(/&/g, '&amp;')
7
+ .replace(/</g, '&lt;')
8
+ .replace(/>/g, '&gt;')
9
+ .replace(/"/g, '&quot;')
10
+ .replace(/'/g, '&apos;'));
11
+ }
12
+ /**
13
+ * JUnit XML: one testcase per item this runner executed (failed → `<failure>`, error →
14
+ * `<error>`, blocked/skipped → `<skipped>`) plus one skipped testcase per item the server did
15
+ * not queue. Items executed elsewhere only appear in the suite properties' counts.
16
+ */
17
+ export function formatJunit(outcome) {
18
+ const suite = outcome.runName;
19
+ const classname = `leera.${outcome.platform}`;
20
+ const cases = [];
21
+ let failures = 0;
22
+ let errors = 0;
23
+ let skipped = 0;
24
+ let time = 0;
25
+ for (const item of [...outcome.items].sort((a, b) => a.item_id - b.item_id)) {
26
+ time += item.elapsed_secs;
27
+ const open = ` <testcase name="${xmlEscape(item.title)}" classname="${xmlEscape(classname)}" time="${item.elapsed_secs.toFixed(3)}">`;
28
+ const message = item.failures[0] ?? item.notes;
29
+ const body = [item.failures.join('\n'), item.notes].filter(Boolean).join('\n\n');
30
+ switch (item.result) {
31
+ case 'passed':
32
+ cases.push(`${open}</testcase>`);
33
+ break;
34
+ case 'failed':
35
+ failures++;
36
+ cases.push(`${open}\n <failure message="${xmlEscape(message)}" type="failed">${xmlEscape(body)}</failure>\n </testcase>`);
37
+ break;
38
+ case 'error':
39
+ errors++;
40
+ cases.push(`${open}\n <error message="${xmlEscape(message)}" type="error">${xmlEscape(body)}</error>\n </testcase>`);
41
+ break;
42
+ default:
43
+ skipped++;
44
+ cases.push(`${open}\n <skipped message="${xmlEscape(`${item.result}: ${message}`)}"/>\n </testcase>`);
45
+ }
46
+ }
47
+ for (const item of outcome.created.skipped) {
48
+ skipped++;
49
+ cases.push(` <testcase name="${xmlEscape(item.title)}" classname="${xmlEscape(classname)}" time="0">\n <skipped message="${xmlEscape(`not queued: ${item.reason}`)}"/>\n </testcase>`);
50
+ }
51
+ const properties = [
52
+ ['run_id', String(outcome.runId)],
53
+ ['platform', outcome.platform],
54
+ ];
55
+ const report = outcome.status?.report_url ?? outcome.created.report_url;
56
+ if (report)
57
+ properties.push(['report_url', report]);
58
+ if (outcome.status) {
59
+ properties.push(['status', outcome.status.status]);
60
+ for (const [key, value] of Object.entries(outcome.status.items))
61
+ properties.push([`items.${key}`, String(value)]);
62
+ }
63
+ if (outcome.ended)
64
+ properties.push(['ended', outcome.ended]);
65
+ const tests = cases.length;
66
+ const attrs = `name="${xmlEscape(suite)}" tests="${tests}" failures="${failures}" errors="${errors}" skipped="${skipped}" time="${time.toFixed(3)}"`;
67
+ return [
68
+ '<?xml version="1.0" encoding="UTF-8"?>',
69
+ `<testsuites ${attrs}>`,
70
+ ` <testsuite ${attrs}>`,
71
+ ' <properties>',
72
+ ...properties.map(([name, value]) => ` <property name="${xmlEscape(name)}" value="${xmlEscape(value)}"/>`),
73
+ ' </properties>',
74
+ ...cases,
75
+ ' </testsuite>',
76
+ '</testsuites>',
77
+ '',
78
+ ].join('\n');
79
+ }
@@ -0,0 +1,320 @@
1
+ import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { buildCapabilities, detect, hostInfo } from '../capabilities.js';
4
+ import { ApiError, RunnerClient } from '../client.js';
5
+ import { registerLocalBuild, unregisterLocalBuild } from '../builds/install.js';
6
+ import { resolveConfig } from '../config.js';
7
+ import { DeviceManager } from '../devices/manager.js';
8
+ import { BROWSER_DEVICE_ID, isPlatform } from '../devices/types.js';
9
+ import { ExitCode, ExitError, messageOf } from '../errors.js';
10
+ import { Runner } from '../runner.js';
11
+ import { RUNNER_VERSION } from '../version.js';
12
+ import { describeBuildFile, projectSelector, uploadBuild } from './builds.js';
13
+ import { CiClient } from './client.js';
14
+ import { ciRunnerName, defaultRunName, detectCi } from './env.js';
15
+ import { formatJunit } from './junit.js';
16
+ import { CiExit, exitCodeFor, formatMarkdown, formatSummary } from './summary.js';
17
+ export const STATUS_POLL_MS = 10_000;
18
+ export const DEFAULT_TIMEOUT_MINUTES = 60;
19
+ export const VIDEO_MODES = ['off', 'on_failure', 'always'];
20
+ function sleep(ms) {
21
+ let timer;
22
+ let resolveFn = () => undefined;
23
+ const promise = new Promise((resolve) => {
24
+ resolveFn = resolve;
25
+ timer = setTimeout(resolve, ms);
26
+ });
27
+ return {
28
+ promise,
29
+ cancel: () => {
30
+ clearTimeout(timer);
31
+ resolveFn();
32
+ },
33
+ };
34
+ }
35
+ function numeric(text) {
36
+ return text !== undefined && /^\d+$/.test(text) ? Number(text) : undefined;
37
+ }
38
+ /** Maps HTTP refusals to the CLI's exit codes. */
39
+ function apiExit(error, what) {
40
+ if (error instanceof ApiError) {
41
+ if (error.status === 401 || error.status === 403) {
42
+ throw new ExitError(ExitCode.tokenRejected, `${what}: the server refused the token (${error.status} ${error.message}); use a CI token of the project`);
43
+ }
44
+ if (error.status === 426)
45
+ throw new ExitError(ExitCode.versionRefused, error.message);
46
+ if (error.status === 400 || error.status === 404 || error.status === 422) {
47
+ throw new ExitError(ExitCode.usage, `${what}: ${error.message}`);
48
+ }
49
+ }
50
+ throw new ExitError(ExitCode.error, `${what}: ${messageOf(error)}`);
51
+ }
52
+ /**
53
+ * `leera-qa-runner ci` (plan §8): doctor → CI facts → build → register an ephemeral runner →
54
+ * `POST /runs` → run the jobs bound to that run while polling `/runs/status` → summary,
55
+ * `$GITHUB_STEP_SUMMARY`, JUnit → exit code.
56
+ *
57
+ * Registration order: the runner registers once without `run_id` (the server needs its id to
58
+ * pin jobs of a local build), creates the run with that `runner_id`, then registers again with
59
+ * `run_id` — registration is idempotent on the runner name — before claiming with `run_id`.
60
+ */
61
+ export async function runCi(flags, deps) {
62
+ const { env, log } = deps;
63
+ const started = (deps.now ?? Date.now)();
64
+ const elapsed = () => ((deps.now ?? Date.now)() - started) / 1000;
65
+ if (!isPlatform(flags.platform) || !deps.registry.has(flags.platform)) {
66
+ throw new ExitError(ExitCode.usage, `this runner version cannot run '${flags.platform}' jobs (supported: ${[...deps.registry.keys()].join(', ')})`);
67
+ }
68
+ const platform = flags.platform;
69
+ if (flags.build && flags.buildId !== undefined)
70
+ throw new ExitError(ExitCode.usage, 'pass either --build or --build-id, not both');
71
+ if (flags.uploadBuild && !flags.build)
72
+ throw new ExitError(ExitCode.usage, '--upload-build needs --build PATH');
73
+ if (flags.build && platform === 'web')
74
+ throw new ExitError(ExitCode.usage, 'web runs do not take a build');
75
+ if (flags.uploadBuild && !flags.project) {
76
+ // The server can fall back to the token's project; say so only in the log.
77
+ log.debug('no --project: the build is uploaded to the CI token project');
78
+ }
79
+ if (!flags.skipDoctor && deps.doctor) {
80
+ const report = await deps.doctor(platform);
81
+ if (!report.ok) {
82
+ deps.print(report.text);
83
+ throw new ExitError(ExitCode.environmentNotReady, `this machine is not ready to run ${platform} jobs (see the checks above)`);
84
+ }
85
+ }
86
+ const ci = detectCi(env, deps.git, deps.now);
87
+ const runnerName = ciRunnerName(ci);
88
+ const host = deps.host ?? hostInfo();
89
+ const detection = await detect(deps.registry, host);
90
+ const config = {
91
+ ...resolveConfig({
92
+ flags: {
93
+ name: runnerName,
94
+ ...(flags.labels ? { labels: flags.labels } : {}),
95
+ ...(flags.concurrency ? { concurrency: flags.concurrency } : {}),
96
+ },
97
+ env: { ...env, RUNNER_NAME: undefined, RUNNER_RUN_ID: undefined, RUNNER_ONCE: undefined },
98
+ file: null,
99
+ detectedLabels: detection.labels,
100
+ }),
101
+ once: false,
102
+ };
103
+ const client = new RunnerClient(config.url, config.token);
104
+ const ciClient = new CiClient(config.url, config.token, deps.fetchImpl);
105
+ log.info('ci', { provider: ci.provider, sha: ci.sha, branch: ci.branch, runner: runnerName, platform });
106
+ // The build under test.
107
+ let buildId = flags.buildId ?? null;
108
+ let local = null;
109
+ if (flags.build) {
110
+ const file = await describeBuildFile(flags.build);
111
+ if (flags.uploadBuild) {
112
+ let build;
113
+ try {
114
+ build = await uploadBuild(ciClient, {
115
+ file,
116
+ platform,
117
+ ...(flags.project ? { project: flags.project } : {}),
118
+ meta: {
119
+ ...(flags.versionName ? { version_name: flags.versionName } : {}),
120
+ ...(flags.buildNumber ? { build_number: flags.buildNumber } : {}),
121
+ ...(ci.sha ? { git_sha: ci.sha } : {}),
122
+ ...(ci.branch ? { git_branch: ci.branch } : {}),
123
+ },
124
+ log,
125
+ });
126
+ }
127
+ catch (error) {
128
+ apiExit(error, 'the build upload failed');
129
+ }
130
+ buildId = build.id;
131
+ log.info('build uploaded', { build_id: build.id, status: build.status });
132
+ }
133
+ else {
134
+ local = file;
135
+ }
136
+ }
137
+ const devices = new DeviceManager(detection.devices, (device) => device.id === BROWSER_DEVICE_ID ? config.concurrency.web : 1);
138
+ const baseRegistration = {
139
+ name: runnerName,
140
+ labels: config.labels,
141
+ version: RUNNER_VERSION,
142
+ os: host.os,
143
+ arch: host.arch,
144
+ hostname: host.hostname,
145
+ ephemeral: true,
146
+ capabilities: buildCapabilities(devices.capabilityDevices(), host),
147
+ };
148
+ let runnerId;
149
+ try {
150
+ runnerId = (await client.register(baseRegistration)).runner_id;
151
+ }
152
+ catch (error) {
153
+ apiExit(error, 'registering the runner failed');
154
+ }
155
+ const project = projectSelector(flags.project);
156
+ const planId = numeric(flags.plan);
157
+ const environmentId = numeric(flags.environment);
158
+ const runName = flags.name ?? defaultRunName(ci, platform);
159
+ const body = {
160
+ ...project,
161
+ ...(flags.plan === undefined ? { plan_id: null } : planId !== undefined ? { plan_id: planId } : { plan_name: flags.plan }),
162
+ ...(environmentId !== undefined ? { environment_id: environmentId } : { environment_slug: flags.environment }),
163
+ platform,
164
+ build_id: buildId,
165
+ build: buildId === null && !local && (flags.versionName || flags.buildNumber)
166
+ ? { version_name: flags.versionName ?? null, build_number: flags.buildNumber ?? null }
167
+ : null,
168
+ name: runName,
169
+ runner_id: runnerId,
170
+ git: { sha: ci.sha, branch: ci.branch },
171
+ ci: { provider: ci.provider, url: ci.url },
172
+ video: flags.video ?? 'on_failure',
173
+ local_build: local ? { file_name: local.file_name, sha256: local.sha256, size_bytes: local.size_bytes } : null,
174
+ };
175
+ let created;
176
+ try {
177
+ created = await ciClient.createRun(body);
178
+ }
179
+ catch (error) {
180
+ apiExit(error, 'creating the test run failed');
181
+ }
182
+ const runId = created.run_id;
183
+ log.info('run created', { run_id: runId, queued: created.queued, skipped: created.skipped.length, report_url: created.report_url });
184
+ const items = new Map();
185
+ const outcome = (status, ended) => ({
186
+ runId,
187
+ runName,
188
+ platform,
189
+ created,
190
+ status,
191
+ items: [...items.values()],
192
+ ...(ended ? { ended } : {}),
193
+ elapsedSecs: elapsed(),
194
+ });
195
+ if (created.queued === 0) {
196
+ const status = await ciClient.runStatus(runId).catch(() => null);
197
+ finish(flags, deps, outcome(status), CiExit.error);
198
+ throw new ExitError(ExitCode.error, `nothing was queued: no test case of run #${runId} has a ${platform} script`);
199
+ }
200
+ if (local)
201
+ registerLocalBuild(local.sha256, local.path);
202
+ const runner = new Runner({ ...config, runId }, {
203
+ client,
204
+ registry: deps.registry,
205
+ devices,
206
+ host,
207
+ shared: deps.shared,
208
+ log,
209
+ version: RUNNER_VERSION,
210
+ ephemeral: true,
211
+ ...(deps.paths ? { paths: deps.paths } : {}),
212
+ onJobFinished: (event) => {
213
+ if (event.result === 'cancelled')
214
+ return;
215
+ const previous = items.get(event.item_id);
216
+ // A job error is retried by the server; a later result for the item replaces it.
217
+ if (previous && previous.attempt > event.attempt)
218
+ return;
219
+ items.set(event.item_id, {
220
+ item_id: event.item_id,
221
+ title: event.title,
222
+ result: event.result,
223
+ notes: event.notes,
224
+ failures: event.failures,
225
+ elapsed_secs: event.elapsed_secs,
226
+ attempt: event.attempt,
227
+ });
228
+ },
229
+ });
230
+ let runnerError = null;
231
+ let runnerDone = false;
232
+ const running = runner.run().then(() => {
233
+ runnerDone = true;
234
+ }, (error) => {
235
+ runnerDone = true;
236
+ runnerError = error;
237
+ });
238
+ const deadline = started + (flags.timeoutMinutes ?? DEFAULT_TIMEOUT_MINUTES) * 60_000;
239
+ const pollMs = deps.pollMs ?? STATUS_POLL_MS;
240
+ let status = null;
241
+ let ended;
242
+ try {
243
+ while (true) {
244
+ if (deps.interrupt?.aborted) {
245
+ ended = 'interrupted';
246
+ break;
247
+ }
248
+ if (runnerError)
249
+ break;
250
+ try {
251
+ status = await ciClient.runStatus(runId);
252
+ if (status.done)
253
+ break;
254
+ }
255
+ catch (error) {
256
+ if (error instanceof ApiError && (error.status === 401 || error.status === 403))
257
+ apiExit(error, 'reading the run status failed');
258
+ log.warn('run status failed', { run_id: runId, error: messageOf(error) });
259
+ }
260
+ if ((deps.now ?? Date.now)() >= deadline) {
261
+ ended = 'timeout';
262
+ break;
263
+ }
264
+ const wait = sleep(Math.min(pollMs, Math.max(0, deadline - (deps.now ?? Date.now)())));
265
+ const onAbort = () => wait.cancel();
266
+ deps.interrupt?.addEventListener('abort', onAbort, { once: true });
267
+ // The runner loop only ends early on a fatal error (token refused, …): stop waiting then.
268
+ if (!runnerDone)
269
+ void running.then(() => wait.cancel());
270
+ await wait.promise;
271
+ deps.interrupt?.removeEventListener('abort', onAbort);
272
+ }
273
+ if (ended || runnerError) {
274
+ const why = ended === 'timeout' ? 'timed out' : ended === 'interrupted' ? 'was interrupted' : 'failed';
275
+ log.warn(`the CI command ${why}; cancelling the run`, { run_id: runId });
276
+ try {
277
+ const cancelled = await ciClient.cancelRun(runId);
278
+ log.info('run cancelled', { run_id: runId, jobs: cancelled });
279
+ }
280
+ catch (error) {
281
+ log.error('could not cancel the run', { run_id: runId, error: messageOf(error) });
282
+ }
283
+ }
284
+ }
285
+ finally {
286
+ runner.stop();
287
+ await running;
288
+ if (local)
289
+ unregisterLocalBuild(local.sha256);
290
+ }
291
+ if (runnerError)
292
+ throw runnerError;
293
+ if (ended)
294
+ status = (await ciClient.runStatus(runId).catch(() => null)) ?? status;
295
+ const code = exitCodeFor(status, ended);
296
+ finish(flags, deps, outcome(status, ended), code);
297
+ return code;
298
+ }
299
+ /** Prints the summary and writes `$GITHUB_STEP_SUMMARY` and `--junit`. */
300
+ function finish(flags, deps, outcome, code) {
301
+ deps.print(formatSummary(outcome, code));
302
+ const stepSummary = deps.env.GITHUB_STEP_SUMMARY?.trim();
303
+ if (stepSummary) {
304
+ try {
305
+ appendFileSync(stepSummary, formatMarkdown(outcome, code));
306
+ }
307
+ catch (error) {
308
+ deps.log.warn('could not write the step summary', { path: stepSummary, error: messageOf(error) });
309
+ }
310
+ }
311
+ if (flags.junit) {
312
+ try {
313
+ mkdirSync(dirname(flags.junit), { recursive: true });
314
+ writeFileSync(flags.junit, formatJunit(outcome));
315
+ }
316
+ catch (error) {
317
+ deps.log.error('could not write the JUnit report', { path: flags.junit, error: messageOf(error) });
318
+ }
319
+ }
320
+ }