@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,148 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
+ import { ApiError } from '../client.js';
3
+ import { redactor } from '../template.js';
4
+ import { DEFAULT_COMMAND_TIMEOUT_MS } from './commands.js';
5
+ import { SessionExecutor } from './execute.js';
6
+ /** The long-poll never asks for more than the server allows (plan §4). */
7
+ const MAX_WAIT_SECONDS = 25;
8
+ /**
9
+ * Runs one device session (plan §7.5): launches the app on the leased device, takes an
10
+ * initial snapshot, then long-polls commands, executes each (default 30 s timeout,
11
+ * `wait_for` ≤ 60 s) and posts its result with a screenshot. Ends on an `end` command,
12
+ * 410 from the server, idle timeout, maximum lifetime, runner shutdown or a device/driver
13
+ * failure; the driver is always disposed. The caller releases the device lease.
14
+ */
15
+ export async function runSession(session, deps) {
16
+ const { client, log } = deps;
17
+ const now = deps.now ?? Date.now;
18
+ const retryDelayMs = deps.retryDelayMs ?? 2_000;
19
+ const redact = redactor([session.credential?.password]);
20
+ const started = now();
21
+ const idleMs = Math.max(1, session.idle_timeout_seconds) * 1_000;
22
+ const maxMs = Math.max(1, session.max_seconds) * 1_000;
23
+ const unreachableMs = Math.max(30, session.lease_seconds) * 1_000;
24
+ let lastActivity = started;
25
+ let commands = 0;
26
+ let serverEnded = false;
27
+ let outcome = null;
28
+ let driver = null;
29
+ const finish = (reason, detail = reason) => ({ reason, detail, commands });
30
+ log.info('session started', { session_id: session.id, platform: session.platform, device: session.device?.id ?? null });
31
+ try {
32
+ driver = await deps.createDriver();
33
+ await driver.launch();
34
+ const executor = new SessionExecutor(driver, {
35
+ commandTimeoutMs: session.command_timeout_seconds > 0 ? session.command_timeout_seconds * 1_000 : DEFAULT_COMMAND_TIMEOUT_MS,
36
+ redact,
37
+ });
38
+ await executor.prime().catch((error) => log.debug('initial snapshot failed', { session_id: session.id, error: redact(message(error)) }));
39
+ let failingSince = null;
40
+ while (!outcome) {
41
+ if (deps.shouldStop()) {
42
+ outcome = finish('runner_stopping');
43
+ break;
44
+ }
45
+ const t = now();
46
+ if (t - started >= maxMs) {
47
+ outcome = finish('expired');
48
+ break;
49
+ }
50
+ if (t - lastActivity >= idleMs) {
51
+ outcome = finish('idle');
52
+ break;
53
+ }
54
+ const remaining = Math.min(idleMs - (t - lastActivity), maxMs - (t - started));
55
+ const wait = Math.max(1, Math.min(session.poll_wait_seconds || MAX_WAIT_SECONDS, MAX_WAIT_SECONDS, Math.ceil(remaining / 1_000)));
56
+ let next;
57
+ try {
58
+ next = await client.nextCommand(session.id, deps.runnerId, wait, deps.stopSignal);
59
+ failingSince = null;
60
+ }
61
+ catch (error) {
62
+ if (deps.shouldStop())
63
+ continue;
64
+ if (error instanceof ApiError && [401, 403, 404].includes(error.status)) {
65
+ outcome = finish('ended_by_server', error.message);
66
+ serverEnded = true;
67
+ break;
68
+ }
69
+ failingSince ??= now();
70
+ if (now() - failingSince >= unreachableMs) {
71
+ outcome = finish('server_unreachable');
72
+ break;
73
+ }
74
+ log.warn('session poll failed, retrying', { session_id: session.id, error: redact(message(error)) });
75
+ await sleep(retryDelayMs);
76
+ continue;
77
+ }
78
+ if (next.kind === 'gone') {
79
+ serverEnded = true;
80
+ outcome = finish('ended_by_server', next.reason ?? next.status);
81
+ break;
82
+ }
83
+ if (next.kind === 'none')
84
+ continue;
85
+ const command = next.command;
86
+ commands += 1;
87
+ lastActivity = now();
88
+ const executed = await executor.execute(command);
89
+ log.debug('session command', {
90
+ session_id: session.id,
91
+ command_id: command.id,
92
+ type: command.type,
93
+ ok: executed.result.ok,
94
+ error: executed.result.error?.code ?? null,
95
+ });
96
+ const posted = await postResult(client, session.id, command.id, executed.result, executed.screenshot, retryDelayMs, log);
97
+ lastActivity = now();
98
+ if (posted === 'gone') {
99
+ serverEnded = true;
100
+ outcome = finish('ended_by_server', 'the session ended while a command ran');
101
+ break;
102
+ }
103
+ if (executed.end)
104
+ outcome = finish('end_command');
105
+ }
106
+ }
107
+ catch (error) {
108
+ const detail = redact(message(error)).slice(0, 500);
109
+ log.error('session failed', { session_id: session.id, error: detail });
110
+ outcome = finish('failed', `failed: ${detail}`);
111
+ }
112
+ finally {
113
+ const final = outcome ?? finish('failed');
114
+ if (!serverEnded) {
115
+ await client
116
+ .endSession(session.id, final.detail)
117
+ .catch((error) => log.warn('could not end the session', { session_id: session.id, error: redact(message(error)) }));
118
+ }
119
+ if (driver) {
120
+ await driver.stopRecording().catch(() => undefined);
121
+ await driver.dispose().catch(() => undefined);
122
+ }
123
+ log.info('session ended', { session_id: session.id, reason: final.reason, commands: final.commands });
124
+ }
125
+ return outcome ?? finish('failed');
126
+ }
127
+ async function postResult(client, sessionId, commandId, result, screenshot, retryDelayMs, log) {
128
+ const shot = screenshot ? { bytes: screenshot, contentType: 'image/png' } : null;
129
+ for (let attempt = 1;; attempt += 1) {
130
+ try {
131
+ await client.postCommandResult(sessionId, commandId, result, shot);
132
+ return 'ok';
133
+ }
134
+ catch (error) {
135
+ if (error instanceof ApiError && error.status === 410)
136
+ return 'gone';
137
+ const permanent = error instanceof ApiError && error.status >= 400 && error.status < 500;
138
+ if (permanent || attempt >= 3) {
139
+ log.warn('could not send a command result', { session_id: sessionId, command_id: commandId, error: message(error) });
140
+ return 'dropped';
141
+ }
142
+ await sleep(retryDelayMs * attempt);
143
+ }
144
+ }
145
+ }
146
+ function message(error) {
147
+ return error instanceof Error ? error.message : String(error);
148
+ }
@@ -0,0 +1,62 @@
1
+ import { randomBytes, randomInt } from 'node:crypto';
2
+ export class TemplateError extends Error {
3
+ }
4
+ const PLACEHOLDER = /\{\{\s*([a-z_.]+)\s*\}\}/g;
5
+ export function renderTemplate(text, ctx) {
6
+ return text.replace(PLACEHOLDER, (_match, name) => placeholderValue(name, ctx));
7
+ }
8
+ export function resolveUrl(url, baseUrl) {
9
+ if (url.startsWith('/') && !url.startsWith('//'))
10
+ return trimSlash(baseUrl) + url;
11
+ return url;
12
+ }
13
+ /** Replaces every secret in `text`; secrets under 4 characters are skipped because they would mangle ordinary words. */
14
+ export function redactor(secrets) {
15
+ const values = secrets
16
+ .filter((secret) => typeof secret === 'string' && secret.length >= 4)
17
+ .sort((a, b) => b.length - a.length);
18
+ return (text) => values.reduce((out, secret) => out.split(secret).join('[redacted]'), text);
19
+ }
20
+ function placeholderValue(name, ctx) {
21
+ switch (name) {
22
+ case 'env.base_url':
23
+ return trimSlash(ctx.baseUrl);
24
+ case 'credential.username':
25
+ return present(ctx.username, name);
26
+ case 'credential.password':
27
+ return present(ctx.password, name);
28
+ case 'run.id':
29
+ return String(ctx.runId);
30
+ case 'app.id':
31
+ if (!ctx.appId)
32
+ throw new TemplateError('the script uses {{app.id}} but the job has no app');
33
+ return ctx.appId;
34
+ case 'random.email':
35
+ return remembered(ctx, name, () => `qa.${token(10)}@example.com`);
36
+ case 'random.string':
37
+ return remembered(ctx, name, () => token(12));
38
+ case 'random.number':
39
+ return remembered(ctx, name, () => String(randomInt(100_000, 1_000_000)));
40
+ default:
41
+ throw new TemplateError(`unknown placeholder {{${name}}}`);
42
+ }
43
+ }
44
+ function present(value, name) {
45
+ if (value === null)
46
+ throw new TemplateError(`the script uses {{${name}}} but the job has no credential`);
47
+ return value;
48
+ }
49
+ function remembered(ctx, name, make) {
50
+ let value = ctx.random.get(name);
51
+ if (value === undefined) {
52
+ value = make();
53
+ ctx.random.set(name, value);
54
+ }
55
+ return value;
56
+ }
57
+ function token(length) {
58
+ return randomBytes(length).toString('hex').slice(0, length);
59
+ }
60
+ function trimSlash(url) {
61
+ return url.replace(/\/+$/, '');
62
+ }
@@ -0,0 +1,7 @@
1
+ /** The platform a script is written for: v1 is always web. */
2
+ export function scriptPlatform(script) {
3
+ return script.version === 2 ? script.platform : 'web';
4
+ }
5
+ export function isSessionClaim(claim) {
6
+ return claim.kind === 'session';
7
+ }
@@ -0,0 +1,223 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
3
+ import { basename, join, resolve, sep } from 'node:path';
4
+ import { writeResponseBody } from '../download.js';
5
+ import { readJsonFile, writePrivateFile } from '../home.js';
6
+ import { applyPlan, backendFor, execArgv, serviceSpec } from '../service/index.js';
7
+ import { compareVersions } from '../version.js';
8
+ import { releaseDir } from './manifest.js';
9
+ import { sha256File } from './verify.js';
10
+ export const NPM_PACKAGE = '@leera.io/qa-runner';
11
+ export const BREW_CASK = 'leera-qa-runner';
12
+ export const WINGET_ID = 'Leera.QARunner';
13
+ export const DOCKER_IMAGE = 'ghcr.io/leera-app/leera-qa-runner';
14
+ /** Versions kept in `versions/` after an update (the new one included). */
15
+ export const KEEP_VERSIONS = 2;
16
+ const VERSION_DIR = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
17
+ /** The upgrade command for a package-manager install (fixed argv), or null for other methods. */
18
+ export function packageManagerArgv(method, version) {
19
+ switch (method) {
20
+ case 'npm':
21
+ return ['npm', 'install', '-g', `${NPM_PACKAGE}@${version ?? 'latest'}`];
22
+ case 'brew':
23
+ return ['brew', 'upgrade', '--cask', BREW_CASK];
24
+ case 'winget':
25
+ return ['winget', 'upgrade', '--id', WINGET_ID, '--exact', ...(version ? ['--version', version] : [])];
26
+ default:
27
+ return null;
28
+ }
29
+ }
30
+ /** Runs a package manager with inherited output; npm is a .cmd on Windows, which Node only starts through a shell (fixed, validated arguments). */
31
+ export function runInherited(argv, platform = process.platform) {
32
+ return new Promise((done) => {
33
+ const [command, ...args] = argv;
34
+ const child = spawn(command, args, { stdio: 'inherit', shell: platform === 'win32' && command === 'npm', windowsHide: true });
35
+ child.on('error', () => done(127));
36
+ child.on('close', (code) => done(code ?? 1));
37
+ });
38
+ }
39
+ /** The `current` pointer: a symlink on unix, `current.txt` on Windows (read by the .cmd launcher). */
40
+ export function currentPointer(paths, platform) {
41
+ return join(paths.versions, platform === 'win32' ? 'current.txt' : 'current');
42
+ }
43
+ /** The version `current` points at, or null. */
44
+ export function readCurrentVersion(paths, platform) {
45
+ const pointer = currentPointer(paths, platform);
46
+ try {
47
+ if (platform === 'win32')
48
+ return readFileSync(pointer, 'utf8').trim() || null;
49
+ return basename(readlinkSync(pointer));
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ /** Atomically points `current` at `versions/<version>`. */
56
+ export function flipCurrent(paths, version, platform) {
57
+ const pointer = currentPointer(paths, platform);
58
+ const temp = `${pointer}.${process.pid}.tmp`;
59
+ rmSync(temp, { force: true });
60
+ if (platform === 'win32') {
61
+ writeFileSync(temp, `${version}\r\n`);
62
+ }
63
+ else {
64
+ symlinkSync(version, temp);
65
+ }
66
+ renameSync(temp, pointer);
67
+ }
68
+ /** Removes all but the `keep` newest version directories; `target` and directories containing a `protect` path are never removed. */
69
+ export function pruneVersions(paths, target, keep = KEEP_VERSIONS, protect = []) {
70
+ if (!existsSync(paths.versions))
71
+ return [];
72
+ const versions = readdirSync(paths.versions).filter((name) => {
73
+ if (!VERSION_DIR.test(name))
74
+ return false;
75
+ const full = join(paths.versions, name);
76
+ return lstatSync(full).isDirectory();
77
+ });
78
+ versions.sort((a, b) => compareVersions(b, a));
79
+ const kept = new Set([target]);
80
+ for (const version of versions) {
81
+ if (kept.size >= keep)
82
+ break;
83
+ kept.add(version);
84
+ }
85
+ const protectedDirs = protect.map((dir) => resolve(dir));
86
+ const isProtected = (dir) => protectedDirs.some((entry) => entry === dir || entry.startsWith(`${dir}${sep}`));
87
+ const removed = [];
88
+ for (const version of versions) {
89
+ const full = join(paths.versions, version);
90
+ if (kept.has(version) || isProtected(resolve(full)))
91
+ continue;
92
+ try {
93
+ rmSync(full, { recursive: true, force: true });
94
+ removed.push(version);
95
+ }
96
+ catch {
97
+ // In use (Windows); the next update tries again.
98
+ }
99
+ }
100
+ return removed;
101
+ }
102
+ /** The system tar: bsdtar at %SystemRoot%\System32\tar.exe on Windows (reads .zip), else `tar` on PATH. */
103
+ export function tarArgv(archive, into, platform, env = process.env) {
104
+ if (platform === 'win32') {
105
+ return [join(env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe'), '-xf', archive, '-C', into];
106
+ }
107
+ return ['tar', '-xzf', archive, '-C', into];
108
+ }
109
+ function bundleVersion(dir) {
110
+ try {
111
+ const info = readJsonFile(join(dir, 'build-info.json'));
112
+ return typeof info?.version === 'string' ? info.version : null;
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ function launcherIn(dir, platform) {
119
+ return join(dir, 'bin', platform === 'win32' ? 'leera-qa-runner.cmd' : 'leera-qa-runner');
120
+ }
121
+ async function download(fetchImpl, url, path, maxBytes) {
122
+ const response = await fetchImpl(url, { redirect: 'follow', signal: AbortSignal.timeout(30 * 60_000), headers: { 'user-agent': 'leera-qa-runner' } });
123
+ if (!response.ok || !response.body)
124
+ throw new Error(`${url} answered HTTP ${response.status}`);
125
+ const length = Number(response.headers.get('content-length') ?? '0');
126
+ if (length > maxBytes)
127
+ throw new Error(`${url} is ${length} bytes, but the manifest says ${maxBytes}`);
128
+ let received = 0;
129
+ await writeResponseBody(response.body, path, (chunk) => {
130
+ received += chunk.length;
131
+ if (received > maxBytes)
132
+ throw new Error(`${url} is larger than the manifest says (${maxBytes} bytes)`);
133
+ });
134
+ }
135
+ /**
136
+ * Downloads a release archive, checks its size and sha256 against the (already trusted) manifest,
137
+ * unpacks it into `versions/<version>`, flips `current` and prunes old versions.
138
+ */
139
+ export async function installArchive(options) {
140
+ const { paths, version, asset, platform } = options;
141
+ const exec = options.exec ?? execArgv;
142
+ if (!VERSION_DIR.test(version))
143
+ throw new Error(`invalid version ${JSON.stringify(version)}`);
144
+ mkdirSync(paths.versions, { recursive: true, mode: 0o700 });
145
+ const target = join(paths.versions, version);
146
+ let reused = false;
147
+ if (existsSync(target) && bundleVersion(target) === version && existsSync(launcherIn(target, platform))) {
148
+ reused = true;
149
+ }
150
+ else {
151
+ const archive = join(paths.versions, `.download-${version}-${asset.file}`);
152
+ const staging = join(paths.versions, `.staging-${version}-${process.pid}`);
153
+ rmSync(archive, { force: true });
154
+ rmSync(staging, { recursive: true, force: true });
155
+ try {
156
+ const url = `${releaseDir(options.base, version)}/${asset.file}`;
157
+ await download(options.fetch ?? fetch, url, archive, asset.size);
158
+ const size = statSync(archive).size;
159
+ if (size !== asset.size)
160
+ throw new Error(`${asset.file} is ${size} bytes, but the manifest says ${asset.size}; refusing to install it`);
161
+ const actual = await sha256File(archive);
162
+ if (actual !== asset.sha256)
163
+ throw new Error(`${asset.file} has sha256 ${actual}, but the manifest says ${asset.sha256}; refusing to install it`);
164
+ mkdirSync(staging, { recursive: true, mode: 0o700 });
165
+ const argv = tarArgv(archive, staging, platform, options.env);
166
+ const result = await exec(argv);
167
+ if (result.code !== 0)
168
+ throw new Error(`\`${argv.join(' ')}\` failed (exit ${result.code}): ${`${result.stdout}${result.stderr}`.trim()}`);
169
+ const entries = readdirSync(staging);
170
+ const bundle = existsSync(join(staging, 'build-info.json'))
171
+ ? staging
172
+ : entries.length === 1 && existsSync(join(staging, entries[0], 'build-info.json'))
173
+ ? join(staging, entries[0])
174
+ : null;
175
+ if (!bundle)
176
+ throw new Error(`${asset.file} does not contain a runner bundle (no build-info.json)`);
177
+ const found = bundleVersion(bundle);
178
+ if (found !== version)
179
+ throw new Error(`${asset.file} contains version ${found ?? 'unknown'}, not ${version}; refusing to install it`);
180
+ if (!existsSync(launcherIn(bundle, platform)))
181
+ throw new Error(`${asset.file} has no launcher at bin/${basename(launcherIn(bundle, platform))}`);
182
+ rmSync(target, { recursive: true, force: true });
183
+ renameSync(bundle, target);
184
+ }
185
+ finally {
186
+ rmSync(archive, { force: true });
187
+ rmSync(staging, { recursive: true, force: true });
188
+ }
189
+ }
190
+ flipCurrent(paths, version, platform);
191
+ const removed = pruneVersions(paths, version, options.keep ?? KEEP_VERSIONS, options.protect ?? []);
192
+ return { dir: target, removed, reused };
193
+ }
194
+ /** Records the new version in install.json, keeping the method and prefix the installer wrote. */
195
+ export function recordUpdate(paths, method, version, now = new Date()) {
196
+ let record = null;
197
+ try {
198
+ record = readJsonFile(paths.install);
199
+ }
200
+ catch {
201
+ record = null;
202
+ }
203
+ const next = { ...(record ?? { method }), method: record?.method ?? method, version, updated_at: now.toISOString() };
204
+ writePrivateFile(paths.install, `${JSON.stringify(next)}\n`);
205
+ }
206
+ /** Restarts the background service when `service install` created one. */
207
+ export async function restartServiceIfInstalled(platform, env, paths, exec = execArgv) {
208
+ const backend = backendFor(platform);
209
+ if (!backend)
210
+ return { restarted: false, reason: 'unsupported' };
211
+ const spec = serviceSpec(env, paths.root, paths.logs);
212
+ if (!existsSync(backend.definitionPath(spec)))
213
+ return { restarted: false, reason: 'not-installed' };
214
+ const result = await applyPlan(backend.plan('restart', spec), exec);
215
+ if (result.ok)
216
+ return { restarted: true };
217
+ const failed = result.failed;
218
+ return {
219
+ restarted: false,
220
+ reason: 'failed',
221
+ message: failed ? `\`${failed.argv.join(' ')}\` exited with ${failed.result.code}: ${`${failed.result.stdout}${failed.result.stderr}`.trim()}` : 'unknown error',
222
+ };
223
+ }
@@ -0,0 +1,59 @@
1
+ import { homePaths, readJsonFile } from '../home.js';
2
+ import { BUILD_INFO, compareVersions, PACKAGE_ROOT } from '../version.js';
3
+ import { fetchManifest } from './manifest.js';
4
+ export const INSTALL_METHODS = ['pkg', 'exe', 'tar', 'npm', 'brew', 'winget', 'docker'];
5
+ function isTrue(value) {
6
+ return ['1', 'true', 'yes', 'on'].includes((value ?? '').trim().toLowerCase());
7
+ }
8
+ /** install.json, else an npm install by its path, else Docker by `RUNNER_DOCKER`, else a bundle without a record (tar). */
9
+ export function detectInstall(options = {}) {
10
+ const env = options.env ?? process.env;
11
+ const paths = options.paths ?? homePaths(env);
12
+ let record = null;
13
+ try {
14
+ record = readJsonFile(paths.install);
15
+ }
16
+ catch {
17
+ record = null;
18
+ }
19
+ if (record && INSTALL_METHODS.includes(record.method)) {
20
+ return { method: record.method, source: 'install.json', record };
21
+ }
22
+ const root = (options.packageRoot ?? PACKAGE_ROOT).replaceAll('\\', '/');
23
+ if (root.includes('/node_modules/@leera.io/qa-runner'))
24
+ return { method: 'npm', source: 'npm path', record: null };
25
+ if (isTrue(env.RUNNER_DOCKER))
26
+ return { method: 'docker', source: 'docker', record: null };
27
+ const info = options.buildInfo ?? BUILD_INFO;
28
+ if (info.os && info.arch)
29
+ return { method: 'tar', source: 'bundle', record: null };
30
+ return { method: 'unknown', source: 'none', record: null };
31
+ }
32
+ /** The newest version: the server's `latest_version` cached in state.json, else the latest release manifest. */
33
+ export async function checkForUpdate(options) {
34
+ const cached = cachedLatestVersion(options.paths);
35
+ if (cached) {
36
+ return { current: options.current, latest: cached, source: 'server', available: compareVersions(cached, options.current) > 0 };
37
+ }
38
+ const { manifest } = await fetchManifest(options.base, undefined, options.fetch);
39
+ return {
40
+ current: options.current,
41
+ latest: manifest.version,
42
+ source: 'manifest',
43
+ available: compareVersions(manifest.version, options.current) > 0,
44
+ };
45
+ }
46
+ export function cachedLatestVersion(paths) {
47
+ try {
48
+ const state = readJsonFile(paths.state);
49
+ return typeof state?.latest_version === 'string' && state.latest_version.trim() ? state.latest_version.trim() : null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ /** The local calendar day (`YYYY-MM-DD`) used to log the update notice once per day. */
56
+ export function localDay(now = new Date()) {
57
+ const pad = (n) => String(n).padStart(2, '0');
58
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
59
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The release manifest (`releases.json`) attached to every runner GitHub Release (contracts §N).
3
+ *
4
+ * {"version":"0.4.7","published_at":"…","assets":{"macos-arm64":{"file":"leera-qa-runner-macos-arm64.tar.gz","sha256":"…","size":123}, …}}
5
+ */
6
+ export const DEFAULT_UPDATE_BASE = 'https://github.com/leera-app/leera-qa-runner/releases';
7
+ export const MANIFEST_FILE = 'releases.json';
8
+ export const SIGNATURE_FILE = 'releases.json.sig';
9
+ const VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
10
+ const FILE = /^leera-qa-runner-[a-z0-9]+-[a-z0-9]+\.(?:tar\.gz|zip)$/;
11
+ /** Parses and validates a manifest; throws an Error naming what is wrong. */
12
+ export function parseManifest(text) {
13
+ let value;
14
+ try {
15
+ value = JSON.parse(text);
16
+ }
17
+ catch (error) {
18
+ throw new Error(`the release manifest is not JSON: ${error.message}`);
19
+ }
20
+ if (!value || typeof value !== 'object' || Array.isArray(value))
21
+ throw new Error('the release manifest is not an object');
22
+ const raw = value;
23
+ const version = typeof raw.version === 'string' ? raw.version.replace(/^v/, '') : '';
24
+ if (!VERSION.test(version))
25
+ throw new Error(`the release manifest has an invalid version ${JSON.stringify(raw.version)}`);
26
+ if (!raw.assets || typeof raw.assets !== 'object' || Array.isArray(raw.assets))
27
+ throw new Error('the release manifest has no assets');
28
+ const assets = {};
29
+ for (const [key, entry] of Object.entries(raw.assets)) {
30
+ const asset = entry;
31
+ if (!asset || typeof asset.file !== 'string' || !FILE.test(asset.file)) {
32
+ throw new Error(`the release manifest asset ${key} has an invalid file name`);
33
+ }
34
+ if (typeof asset.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(asset.sha256)) {
35
+ throw new Error(`the release manifest asset ${key} has an invalid sha256`);
36
+ }
37
+ if (typeof asset.size !== 'number' || !Number.isInteger(asset.size) || asset.size <= 0) {
38
+ throw new Error(`the release manifest asset ${key} has an invalid size`);
39
+ }
40
+ assets[key] = { file: asset.file, sha256: asset.sha256, size: asset.size };
41
+ }
42
+ return { version, published_at: typeof raw.published_at === 'string' ? raw.published_at : '', assets };
43
+ }
44
+ /** `macos|windows|linux` for a Node platform, or null when there is no bundle for it. */
45
+ export function bundleOs(platform) {
46
+ if (platform === 'darwin')
47
+ return 'macos';
48
+ if (platform === 'win32')
49
+ return 'windows';
50
+ if (platform === 'linux')
51
+ return 'linux';
52
+ return null;
53
+ }
54
+ /** The manifest key (`macos-arm64`) for this machine, or null when unsupported. */
55
+ export function assetKey(platform, arch) {
56
+ const os = bundleOs(platform);
57
+ if (!os || (arch !== 'x64' && arch !== 'arm64'))
58
+ return null;
59
+ return `${os}-${arch}`;
60
+ }
61
+ /** The archive name a release publishes for a target. */
62
+ export function archiveName(os, arch) {
63
+ return `leera-qa-runner-${os}-${arch}.${os === 'windows' ? 'zip' : 'tar.gz'}`;
64
+ }
65
+ /** `RUNNER_UPDATE_BASE`, else the GitHub releases page; no trailing slash. */
66
+ export function updateBase(env) {
67
+ return (env.RUNNER_UPDATE_BASE?.trim() || DEFAULT_UPDATE_BASE).replace(/\/+$/, '');
68
+ }
69
+ /** Where a release's assets are downloaded from: `…/download/vX` or `…/latest/download`. */
70
+ export function releaseDir(base, version) {
71
+ return version ? `${base}/download/v${version.replace(/^v/, '')}` : `${base}/latest/download`;
72
+ }
73
+ async function get(fetchImpl, url) {
74
+ return fetchImpl(url, { redirect: 'follow', signal: AbortSignal.timeout(60_000), headers: { 'user-agent': 'leera-qa-runner' } });
75
+ }
76
+ /** Downloads `releases.json` (and its signature when published) for a version, or the latest release. */
77
+ export async function fetchManifest(base, version, fetchImpl = fetch) {
78
+ const dir = releaseDir(base, version);
79
+ const url = `${dir}/${MANIFEST_FILE}`;
80
+ const response = await get(fetchImpl, url);
81
+ if (!response.ok) {
82
+ throw new Error(response.status === 404 ? `no release manifest at ${url}${version ? ` (is ${version} a released version?)` : ''}` : `${url} answered HTTP ${response.status}`);
83
+ }
84
+ const bytes = Buffer.from(await response.arrayBuffer());
85
+ const manifest = parseManifest(bytes.toString('utf8'));
86
+ let signature = null;
87
+ const sig = await get(fetchImpl, `${dir}/${SIGNATURE_FILE}`);
88
+ if (sig.ok)
89
+ signature = (await sig.text()).trim() || null;
90
+ else if (sig.status !== 404)
91
+ throw new Error(`${dir}/${SIGNATURE_FILE} answered HTTP ${sig.status}`);
92
+ return { manifest, bytes, signature, url };
93
+ }
@@ -0,0 +1,65 @@
1
+ import { createHash, createPublicKey, verify } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ /**
4
+ * Reads an Ed25519 public key: base64 SPKI DER (what `packaging/sign-manifest.mjs --public-key` prints),
5
+ * a PEM block, or a raw 32-byte key in base64.
6
+ */
7
+ export function parsePublicKey(value) {
8
+ const text = value.trim();
9
+ if (text.startsWith('-----BEGIN'))
10
+ return checkEd25519(createPublicKey(text));
11
+ const der = Buffer.from(text, 'base64');
12
+ if (der.length === 32) {
13
+ // SubjectPublicKeyInfo prefix for Ed25519 (OID 1.3.101.112) followed by the raw key.
14
+ const prefix = Buffer.from('302a300506032b6570032100', 'hex');
15
+ return checkEd25519(createPublicKey({ key: Buffer.concat([prefix, der]), format: 'der', type: 'spki' }));
16
+ }
17
+ return checkEd25519(createPublicKey({ key: der, format: 'der', type: 'spki' }));
18
+ }
19
+ function checkEd25519(key) {
20
+ if (key.asymmetricKeyType !== 'ed25519')
21
+ throw new Error(`the release public key is ${key.asymmetricKeyType ?? 'unknown'}, not Ed25519`);
22
+ return key;
23
+ }
24
+ /** True when `signature` (base64) is a valid Ed25519 signature of `bytes` by `publicKey`. */
25
+ export function verifySignature(bytes, signature, publicKey) {
26
+ const key = parsePublicKey(publicKey);
27
+ const sig = Buffer.from(signature.trim(), 'base64');
28
+ if (sig.length !== 64)
29
+ return false;
30
+ return verify(null, bytes, key, sig);
31
+ }
32
+ /**
33
+ * Applies the contract rule: with a `release_public_key` in build-info a valid signature is required;
34
+ * without one the manifest is trusted over HTTPS and a warning is returned.
35
+ */
36
+ export function checkManifestTrust(bytes, signature, publicKey) {
37
+ if (publicKey?.trim()) {
38
+ if (!signature)
39
+ throw new Error('the release manifest is not signed, but this runner requires signed updates; refusing to update');
40
+ let valid;
41
+ try {
42
+ valid = verifySignature(bytes, signature, publicKey);
43
+ }
44
+ catch (error) {
45
+ throw new Error(`the release manifest signature could not be checked (${error.message}); refusing to update`);
46
+ }
47
+ if (!valid)
48
+ throw new Error('the release manifest signature is invalid; refusing to update');
49
+ return { signed: true };
50
+ }
51
+ return {
52
+ signed: false,
53
+ warning: 'this runner has no release public key: the download is checked against the manifest sha256 only (fetched over HTTPS), not a signature',
54
+ };
55
+ }
56
+ /** The sha256 (hex) of a file, streamed. */
57
+ export function sha256File(path) {
58
+ return new Promise((resolve, reject) => {
59
+ const hash = createHash('sha256');
60
+ createReadStream(path)
61
+ .on('data', (chunk) => hash.update(chunk))
62
+ .on('error', reject)
63
+ .on('end', () => resolve(hash.digest('hex')));
64
+ });
65
+ }