@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,237 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { closeSync, mkdirSync, openSync, readSync, statSync } from 'node:fs';
3
+ import { createServer } from 'node:net';
4
+ import { join } from 'node:path';
5
+ import { setTimeout as sleep } from 'node:timers/promises';
6
+ import { bundledDrivers, resolvePackage } from './drivers.js';
7
+ import { seedAppiumHome } from './home.js';
8
+ /** The Appium package bundled with the runner, or null when optional dependencies were omitted. */
9
+ export function bundledAppium() {
10
+ return resolvePackage('appium');
11
+ }
12
+ /**
13
+ * The argv that starts Appium bound to loopback. Never passes `--allow-insecure` or
14
+ * `--relaxed-security`: sessions cannot run shell commands or other insecure features.
15
+ */
16
+ export function appiumServerArgv(options) {
17
+ if (!Number.isInteger(options.port) || options.port <= 0 || options.port > 65_535)
18
+ throw new Error(`invalid port ${options.port}`);
19
+ return [
20
+ options.node,
21
+ join(options.appiumDir, 'index.js'),
22
+ 'server',
23
+ '--address',
24
+ '127.0.0.1',
25
+ '--port',
26
+ String(options.port),
27
+ '--base-path',
28
+ '/',
29
+ '--use-drivers',
30
+ options.drivers.join(','),
31
+ '--log',
32
+ options.logFile,
33
+ '--log-level',
34
+ 'error:debug',
35
+ '--log-no-colors',
36
+ '--log-timestamp',
37
+ '--local-timezone',
38
+ ];
39
+ }
40
+ /** A free TCP port on loopback, chosen by the OS. */
41
+ export function freePort() {
42
+ return new Promise((resolve, reject) => {
43
+ const server = createServer();
44
+ server.once('error', reject);
45
+ server.listen({ port: 0, host: '127.0.0.1' }, () => {
46
+ const address = server.address();
47
+ const port = typeof address === 'object' && address ? address.port : 0;
48
+ server.close(() => resolve(port));
49
+ });
50
+ });
51
+ }
52
+ function portFree(port) {
53
+ return new Promise((resolve) => {
54
+ const server = createServer();
55
+ server.once('error', () => resolve(false));
56
+ server.listen({ port, host: '127.0.0.1', exclusive: true }, () => server.close(() => resolve(true)));
57
+ });
58
+ }
59
+ /**
60
+ * Hands out ports from a fixed range, one holder at a time (UiAutomator2 `systemPort`
61
+ * 8200–8299, `mjpegServerPort` 7810–7909), skipping ports something else listens on.
62
+ */
63
+ export class PortRange {
64
+ min;
65
+ max;
66
+ isFree;
67
+ used = new Set();
68
+ constructor(min, max, isFree = portFree) {
69
+ this.min = min;
70
+ this.max = max;
71
+ this.isFree = isFree;
72
+ }
73
+ async take() {
74
+ for (let port = this.min; port <= this.max; port += 1) {
75
+ if (this.used.has(port))
76
+ continue;
77
+ this.used.add(port);
78
+ if (await this.isFree(port))
79
+ return port;
80
+ this.used.delete(port);
81
+ }
82
+ throw new Error(`no free port between ${this.min} and ${this.max}`);
83
+ }
84
+ release(port) {
85
+ this.used.delete(port);
86
+ }
87
+ }
88
+ export const SYSTEM_PORTS = new PortRange(8200, 8299);
89
+ export const MJPEG_PORTS = new PortRange(7810, 7909);
90
+ export class AppiumUnavailable extends Error {
91
+ }
92
+ /**
93
+ * One Appium server per runner process: started lazily on the first non-web job, bound to
94
+ * 127.0.0.1 on a free port, health-checked via `/status`, restarted when it has died.
95
+ */
96
+ export class AppiumServer {
97
+ options;
98
+ child = null;
99
+ starting = null;
100
+ port = 0;
101
+ exited = null;
102
+ logFile;
103
+ constructor(options) {
104
+ this.options = options;
105
+ this.logFile = options.logFile ?? join(options.home, 'appium.log');
106
+ }
107
+ get url() {
108
+ return this.child && !this.exited ? `http://127.0.0.1:${this.port}` : null;
109
+ }
110
+ /** Starts the server when needed and returns its base URL. */
111
+ async ensure() {
112
+ if (this.child && !this.exited && (await this.healthy()))
113
+ return `http://127.0.0.1:${this.port}`;
114
+ this.starting ??= this.start().finally(() => {
115
+ this.starting = null;
116
+ });
117
+ return this.starting;
118
+ }
119
+ async start() {
120
+ await this.stop();
121
+ const appium = bundledAppium();
122
+ if (!appium) {
123
+ throw new AppiumUnavailable('Appium is not installed with this runner: reinstall it with its optional dependencies (npm install without --omit=optional)');
124
+ }
125
+ const drivers = bundledDrivers();
126
+ if (drivers.length === 0)
127
+ throw new AppiumUnavailable('no Appium drivers are installed with this runner');
128
+ seedAppiumHome(this.options.home, appium, drivers);
129
+ mkdirSync(this.options.home, { recursive: true, mode: 0o700 });
130
+ this.port = await freePort();
131
+ const argv = appiumServerArgv({
132
+ node: this.options.nodePath ?? process.execPath,
133
+ appiumDir: appium.dir,
134
+ port: this.port,
135
+ drivers: drivers.map((d) => d.name),
136
+ logFile: this.logFile,
137
+ });
138
+ this.exited = null;
139
+ const child = spawn(argv[0], argv.slice(1), {
140
+ cwd: this.options.home,
141
+ env: { ...(this.options.env ?? process.env), APPIUM_HOME: this.options.home },
142
+ stdio: ['ignore', 'ignore', 'pipe'],
143
+ windowsHide: true,
144
+ });
145
+ let stderr = '';
146
+ child.stderr?.on('data', (chunk) => {
147
+ stderr = (stderr + chunk.toString('utf8')).slice(-4_000);
148
+ });
149
+ child.once('exit', (code, signal) => {
150
+ this.exited = `Appium exited (${signal ?? `code ${code}`})${stderr.trim() ? `: ${stderr.trim().split('\n').slice(-3).join(' ')}` : ''}`;
151
+ });
152
+ child.once('error', (error) => {
153
+ this.exited = `Appium could not start: ${error.message}`;
154
+ });
155
+ this.child = child;
156
+ // Never leave Appium behind when the runner exits without stopping it.
157
+ const killOnExit = () => child.kill('SIGTERM');
158
+ process.once('exit', killOnExit);
159
+ child.once('exit', () => process.removeListener('exit', killOnExit));
160
+ const deadline = Date.now() + (this.options.startTimeoutMs ?? 60_000);
161
+ for (;;) {
162
+ if (this.exited)
163
+ throw new Error(`${this.exited}; see ${this.logFile}`);
164
+ if (await this.healthy())
165
+ return `http://127.0.0.1:${this.port}`;
166
+ if (Date.now() > deadline) {
167
+ await this.stop();
168
+ throw new Error(`Appium did not answer /status within ${Math.round((this.options.startTimeoutMs ?? 60_000) / 1000)} s; see ${this.logFile}`);
169
+ }
170
+ await sleep(250);
171
+ }
172
+ }
173
+ async healthy() {
174
+ if (!this.port)
175
+ return false;
176
+ try {
177
+ const response = await fetch(`http://127.0.0.1:${this.port}/status`, { signal: AbortSignal.timeout(2_000) });
178
+ if (!response.ok)
179
+ return false;
180
+ const body = (await response.json());
181
+ return body.value?.ready !== false;
182
+ }
183
+ catch {
184
+ return false;
185
+ }
186
+ }
187
+ async stop() {
188
+ const child = this.child;
189
+ this.child = null;
190
+ if (!child || child.exitCode !== null || this.exited)
191
+ return;
192
+ const done = new Promise((resolve) => child.once('exit', () => resolve()));
193
+ child.kill('SIGTERM');
194
+ const timer = sleep(5_000).then(() => {
195
+ if (child.exitCode === null)
196
+ child.kill('SIGKILL');
197
+ });
198
+ await Promise.race([done, timer]);
199
+ }
200
+ /** The log file's current size: pass it to `logSince` to get what was logged afterwards. */
201
+ logOffset() {
202
+ try {
203
+ return statSync(this.logFile).size;
204
+ }
205
+ catch {
206
+ return 0;
207
+ }
208
+ }
209
+ /** What Appium logged since `offset` (up to `end` when given), the last `maxBytes` of it (attached to failed steps). */
210
+ logSince(offset, maxBytes = 16_000, end) {
211
+ return readLogSlice(this.logFile, offset, maxBytes, end);
212
+ }
213
+ }
214
+ export function readLogSlice(file, offset, maxBytes, end) {
215
+ let fd = null;
216
+ try {
217
+ const size = Math.min(statSync(file).size, end ?? Number.POSITIVE_INFINITY);
218
+ const start = Math.max(offset, size - maxBytes, 0);
219
+ const length = size - start;
220
+ if (length <= 0)
221
+ return '';
222
+ fd = openSync(file, 'r');
223
+ const buffer = Buffer.alloc(length);
224
+ readSync(fd, buffer, 0, length, start);
225
+ let text = buffer.toString('utf8');
226
+ if (start > offset)
227
+ text = text.slice(text.indexOf('\n') + 1);
228
+ return text;
229
+ }
230
+ catch {
231
+ return '';
232
+ }
233
+ finally {
234
+ if (fd !== null)
235
+ closeSync(fd);
236
+ }
237
+ }
@@ -0,0 +1,133 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { basename, join } from 'node:path';
4
+ /** 10 GB of builds are kept by default; least recently used ones go first. */
5
+ export const DEFAULT_CACHE_BYTES = 10 * 1024 * 1024 * 1024;
6
+ export const SHA256_RE = /^[0-9a-f]{64}$/;
7
+ /** Keeps a file name usable on every OS and inside the cache directory. */
8
+ export function safeFileName(name) {
9
+ const base = basename(name.replace(/\\/g, '/'))
10
+ .replace(/[^A-Za-z0-9._-]+/g, '_')
11
+ .replace(/^\.+/, '')
12
+ .slice(0, 120);
13
+ return base || 'build';
14
+ }
15
+ export function sha256File(path) {
16
+ return new Promise((resolve, reject) => {
17
+ const hash = createHash('sha256');
18
+ createReadStream(path)
19
+ .on('data', (chunk) => hash.update(chunk))
20
+ .on('error', reject)
21
+ .on('end', () => resolve(hash.digest('hex')));
22
+ });
23
+ }
24
+ /**
25
+ * Builds on disk under `cache/builds/<sha256>/<file name>` with a `meta.json`. Entries in
26
+ * use by a running job are pinned and never evicted.
27
+ */
28
+ export class BuildCache {
29
+ dir;
30
+ maxBytes;
31
+ now;
32
+ pins = new Map();
33
+ constructor(dir, maxBytes = DEFAULT_CACHE_BYTES, now = Date.now) {
34
+ this.dir = dir;
35
+ this.maxBytes = maxBytes;
36
+ this.now = now;
37
+ }
38
+ entryDir(sha256) {
39
+ if (!SHA256_RE.test(sha256))
40
+ throw new Error(`'${sha256}' is not a sha256 digest`);
41
+ return join(this.dir, sha256);
42
+ }
43
+ readMeta(sha256) {
44
+ try {
45
+ const meta = JSON.parse(readFileSync(join(this.entryDir(sha256), 'meta.json'), 'utf8'));
46
+ return meta.sha256 === sha256 ? meta : null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ writeMeta(entry) {
53
+ writeFileSync(join(this.entryDir(entry.sha256), 'meta.json'), `${JSON.stringify(entry)}\n`, { mode: 0o600 });
54
+ }
55
+ /** The cached file, or null. A file whose size no longer matches is dropped. */
56
+ get(sha256) {
57
+ const meta = this.readMeta(sha256);
58
+ if (!meta)
59
+ return null;
60
+ const file = join(this.entryDir(sha256), meta.file_name);
61
+ try {
62
+ if (statSync(file).size !== meta.size_bytes)
63
+ throw new Error('size changed');
64
+ }
65
+ catch {
66
+ this.remove(sha256);
67
+ return null;
68
+ }
69
+ this.writeMeta({ ...meta, last_used: this.now() });
70
+ return file;
71
+ }
72
+ /** A temp path inside the cache directory (same filesystem, so `put` is a rename). */
73
+ tempPath(label) {
74
+ mkdirSync(join(this.dir, '.tmp'), { recursive: true, mode: 0o700 });
75
+ return join(this.dir, '.tmp', `${safeFileName(label)}.${process.pid}.${this.now()}.part`);
76
+ }
77
+ /** Moves a verified file into the cache and returns its final path. */
78
+ put(sha256, fileName, source) {
79
+ const dir = this.entryDir(sha256);
80
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
81
+ const name = safeFileName(fileName);
82
+ const target = join(dir, name);
83
+ renameSync(source, target);
84
+ this.writeMeta({ sha256, file_name: name, size_bytes: statSync(target).size, last_used: this.now() });
85
+ return target;
86
+ }
87
+ remove(sha256) {
88
+ rmSync(this.entryDir(sha256), { recursive: true, force: true });
89
+ }
90
+ list() {
91
+ if (!existsSync(this.dir))
92
+ return [];
93
+ const entries = [];
94
+ for (const name of readdirSync(this.dir)) {
95
+ if (!SHA256_RE.test(name))
96
+ continue;
97
+ const meta = this.readMeta(name);
98
+ if (meta)
99
+ entries.push(meta);
100
+ }
101
+ return entries;
102
+ }
103
+ pin(sha256) {
104
+ this.pins.set(sha256, (this.pins.get(sha256) ?? 0) + 1);
105
+ let released = false;
106
+ return () => {
107
+ if (released)
108
+ return;
109
+ released = true;
110
+ const left = (this.pins.get(sha256) ?? 1) - 1;
111
+ if (left <= 0)
112
+ this.pins.delete(sha256);
113
+ else
114
+ this.pins.set(sha256, left);
115
+ };
116
+ }
117
+ /** Evicts least recently used, unpinned entries until the cache fits `maxBytes`. Returns the evicted digests. */
118
+ prune() {
119
+ const entries = this.list().sort((a, b) => a.last_used - b.last_used);
120
+ let total = entries.reduce((sum, e) => sum + e.size_bytes, 0);
121
+ const evicted = [];
122
+ for (const entry of entries) {
123
+ if (total <= this.maxBytes)
124
+ break;
125
+ if (this.pins.has(entry.sha256))
126
+ continue;
127
+ this.remove(entry.sha256);
128
+ total -= entry.size_bytes;
129
+ evicted.push(entry.sha256);
130
+ }
131
+ return evicted;
132
+ }
133
+ }
@@ -0,0 +1,208 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { realpathSync, rmSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ /**
6
+ * Ending the processes that hold a runner-created folder and removing it, without ever failing
7
+ * the job. On Windows a folder cannot be deleted while a process runs an executable from it,
8
+ * has it on its command line (Electron helpers get `--user-data-dir` and friends) or is scanned
9
+ * by antivirus; the delete then fails with EBUSY or EPERM for a while.
10
+ */
11
+ /** Bounded wait for processes to go away after they were killed. */
12
+ export const PROCESS_WAIT_MS = 15_000;
13
+ /** Bounded time spent retrying the delete itself. */
14
+ export const REMOVE_BUDGET_MS = 10_000;
15
+ /** `taskkill` argv that ends a Windows process and its children. */
16
+ export function taskkillArgv(pid) {
17
+ return ['/pid', String(pid), '/T', '/F'];
18
+ }
19
+ /**
20
+ * PowerShell argv listing `pid<TAB>name<TAB>path` of every process whose executable lies under
21
+ * one of the folders in QA_APP_DIRS (`|`-separated; `|` cannot occur in a Windows path) or, with
22
+ * QA_MATCH_COMMAND_LINE=1, whose command line names one of them. The folders travel in the
23
+ * environment, never spliced into the command. The query itself and QA_EXCLUDE_PID are skipped.
24
+ */
25
+ export function windowsProcessesUnderArgv() {
26
+ return [
27
+ '-NoProfile',
28
+ '-NonInteractive',
29
+ '-Command',
30
+ "$dirs = @($env:QA_APP_DIRS -split '\\|' | Where-Object { $_ } | ForEach-Object { [IO.Path]::GetFullPath($_).TrimEnd('\\') } | Where-Object { $_.Length -gt 3 }); " +
31
+ "$cmd = $env:QA_MATCH_COMMAND_LINE -eq '1'; " +
32
+ '$skip = @($PID, [int]("0" + $env:QA_EXCLUDE_PID)); ' +
33
+ 'Get-CimInstance Win32_Process | Where-Object { ' +
34
+ '$p = $_; if ($skip -contains $p.ProcessId) { return $false }; ' +
35
+ "@($dirs | Where-Object { ($p.ExecutablePath -and $p.ExecutablePath.StartsWith($_ + '\\', [StringComparison]::OrdinalIgnoreCase)) -or " +
36
+ '($cmd -and $p.CommandLine -and $p.CommandLine.IndexOf($_, [StringComparison]::OrdinalIgnoreCase) -ge 0) }).Count -gt 0 } | ' +
37
+ 'ForEach-Object { "$($_.ProcessId)`t$($_.Name)`t$($_.ExecutablePath)" }',
38
+ ];
39
+ }
40
+ /** Parses the output of `windowsProcessesUnderArgv`. */
41
+ export function parseWindowsProcesses(stdout) {
42
+ const out = [];
43
+ for (const line of stdout.split(/\r?\n/)) {
44
+ const [pid, name, path] = line.trim().split('\t');
45
+ const id = Number(pid);
46
+ if (Number.isInteger(id) && id > 0 && name)
47
+ out.push({ pid: id, name, path: path ?? '' });
48
+ }
49
+ return out;
50
+ }
51
+ /**
52
+ * The spellings a folder may appear under: as given, absolute, and with 8.3 short names expanded
53
+ * (the hosted runner's temp dir is `C:\Users\RUNNER~1\...`, while processes report
54
+ * `C:\Users\runneradmin\...`, so a prefix match on one spelling alone finds nothing).
55
+ */
56
+ export function dirSpellings(dir, realpath = (p) => realpathSync.native(p)) {
57
+ const out = [dir, resolve(dir)];
58
+ try {
59
+ out.push(realpath(dir));
60
+ }
61
+ catch {
62
+ // Already gone, or not accessible: the given spellings are all there is.
63
+ }
64
+ return [...new Set(out.filter(Boolean))];
65
+ }
66
+ /** Processes running from (or, with `commandLine`, naming) `dir`. Windows only; empty elsewhere or when the query fails. */
67
+ export function windowsProcessesUnder(dir, platform = process.platform, options = {}) {
68
+ if (platform !== 'win32' || !dir)
69
+ return [];
70
+ const result = spawnSync('powershell.exe', windowsProcessesUnderArgv(), {
71
+ encoding: 'utf8',
72
+ env: {
73
+ ...process.env,
74
+ QA_APP_DIRS: dirSpellings(dir).join('|'),
75
+ QA_MATCH_COMMAND_LINE: options.commandLine ? '1' : '0',
76
+ QA_EXCLUDE_PID: String(process.pid),
77
+ },
78
+ windowsHide: true,
79
+ timeout: 30_000,
80
+ });
81
+ return result.status === 0 ? parseWindowsProcesses(result.stdout ?? '') : [];
82
+ }
83
+ function killTreeWindows(pid) {
84
+ spawnSync('taskkill', taskkillArgv(pid), { stdio: 'ignore', windowsHide: true, timeout: 15_000 });
85
+ }
86
+ /**
87
+ * Windows: ends every process still running from the folder and waits (bounded) until none is
88
+ * left. `taskkill /T` only reaches children of a live parent; once Electron's main process has
89
+ * quit, helpers such as crashpad_handler are orphans that keep the folder's files open. Returns
90
+ * what had to be killed and what was still running at the deadline.
91
+ */
92
+ export async function endWindowsProcessesUnder(dir, options = {}) {
93
+ if ((options.platform ?? process.platform) !== 'win32')
94
+ return { killed: [], remaining: [] };
95
+ const list = options.list ?? ((d) => windowsProcessesUnder(d, 'win32', { commandLine: options.commandLine }));
96
+ const kill = options.kill ?? killTreeWindows;
97
+ const sleep = options.sleep ?? delay;
98
+ const now = options.now ?? Date.now;
99
+ const deadline = now() + (options.timeoutMs ?? PROCESS_WAIT_MS);
100
+ const killed = new Map();
101
+ for (;;) {
102
+ const running = list(dir);
103
+ if (running.length === 0)
104
+ return { killed: [...killed.values()], remaining: [] };
105
+ if (now() > deadline)
106
+ return { killed: [...killed.values()], remaining: running };
107
+ for (const proc of running) {
108
+ killed.set(proc.pid, proc);
109
+ kill(proc.pid);
110
+ }
111
+ await sleep(500);
112
+ }
113
+ }
114
+ /** Errors a delete may recover from after a moment (a handle closing, an antivirus scan ending). */
115
+ export function isRetryableRemoveError(error) {
116
+ const code = error?.code;
117
+ return code === 'EBUSY' || code === 'EPERM' || code === 'ENOTEMPTY' || code === 'EACCES' || code === 'EMFILE';
118
+ }
119
+ /**
120
+ * Removes `dir`, retrying retryable errors with backoff (100 ms doubling, capped at 2 s) until
121
+ * `budgetMs` has passed. Never throws; returns the last error when the folder could not go.
122
+ */
123
+ export async function removeDirWithRetries(dir, options = {}) {
124
+ const remove = options.remove ?? ((d) => rmSync(d, { recursive: true, force: true }));
125
+ const sleep = options.sleep ?? delay;
126
+ const now = options.now ?? Date.now;
127
+ const deadline = now() + (options.budgetMs ?? REMOVE_BUDGET_MS);
128
+ let wait = 100;
129
+ for (let attempts = 1;; attempts++) {
130
+ try {
131
+ remove(dir);
132
+ return { removed: true, attempts };
133
+ }
134
+ catch (error) {
135
+ const left = deadline - now();
136
+ if (!isRetryableRemoveError(error) || left <= 0)
137
+ return { removed: false, attempts, error };
138
+ await sleep(Math.min(wait, left));
139
+ wait = Math.min(wait * 2, 2_000);
140
+ }
141
+ }
142
+ }
143
+ /**
144
+ * Cleans up a folder the runner created (an unpacked build, a job's scratch folder). On Windows:
145
+ * ends every process whose executable or command line references it (tree kill), waits up to
146
+ * 15 s for them to go, then deletes with retries for up to 10 s. When the folder still cannot be
147
+ * deleted, logs a warning naming it and the processes left, and returns: a job's result never
148
+ * depends on temp cleanup. Never throws.
149
+ */
150
+ export async function cleanupRunnerDir(dir, options = {}) {
151
+ const platform = options.platform ?? process.platform;
152
+ const commandLine = options.commandLine ?? true;
153
+ const query = options.list ?? ((d) => windowsProcessesUnder(d, 'win32', { commandLine }));
154
+ // A failing process query must not stop the delete from being tried.
155
+ const list = (d) => safeList(query, d);
156
+ let killed = [];
157
+ let remaining = [];
158
+ try {
159
+ if (platform === 'win32')
160
+ ({ killed, remaining } = await endWindowsProcessesUnder(dir, { ...options, platform, commandLine, list }));
161
+ if (killed.length > 0)
162
+ options.log?.warn('ended processes still using a runner folder', { dir, processes: killed.map(describe) });
163
+ const outcome = await removeDirWithRetries(dir, { budgetMs: options.removeBudgetMs, remove: options.remove, sleep: options.sleep, now: options.now });
164
+ if (outcome.removed)
165
+ return { removed: true, killed, remaining };
166
+ if (platform === 'win32' && remaining.length === 0) {
167
+ // Something may have started using the folder while the delete was retried: name it.
168
+ remaining = list(dir);
169
+ }
170
+ const error = firstLineOf(outcome.error);
171
+ options.log?.warn('could not remove a runner folder; leaving it behind', { dir, error, processes: remaining.map(describe) });
172
+ return { removed: false, killed, remaining, error };
173
+ }
174
+ catch (unexpected) {
175
+ const error = firstLineOf(unexpected);
176
+ options.log?.warn('could not remove a runner folder; leaving it behind', { dir, error, processes: remaining.map(describe) });
177
+ return { removed: false, killed, remaining, error };
178
+ }
179
+ }
180
+ function safeList(list, dir) {
181
+ try {
182
+ return list(dir);
183
+ }
184
+ catch {
185
+ return [];
186
+ }
187
+ }
188
+ function describe(proc) {
189
+ return `${proc.pid} ${proc.name}${proc.path ? ` (${proc.path})` : ''}`;
190
+ }
191
+ function firstLineOf(error) {
192
+ return (error instanceof Error ? error.message : String(error)).split('\n')[0] ?? '';
193
+ }
194
+ /** Removes an unpacked build's private folder through `cleanupRunnerDir` (never throws). */
195
+ export async function disposeUnpacked(unpacked, log) {
196
+ if (!unpacked)
197
+ return;
198
+ if (unpacked.tempDir) {
199
+ await cleanupRunnerDir(unpacked.tempDir, { log });
200
+ return;
201
+ }
202
+ try {
203
+ unpacked.cleanup();
204
+ }
205
+ catch (error) {
206
+ log?.warn('could not clean up the unpacked build; leaving it behind', { error: firstLineOf(error) });
207
+ }
208
+ }
@@ -0,0 +1,64 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { rmSync } from 'node:fs';
3
+ import { writeResponseBody } from '../download.js';
4
+ import { SHA256_RE } from './cache.js';
5
+ export class BuildIntegrityError extends Error {
6
+ }
7
+ /**
8
+ * Returns a local copy of a build, downloading it into the digest-keyed cache on a miss.
9
+ * The download is hashed while streaming and must match both the job's and the server's
10
+ * digest and size before it enters the cache.
11
+ */
12
+ export async function obtainBuild(options) {
13
+ const { cache, build } = options;
14
+ const expected = build.sha256.toLowerCase();
15
+ if (!SHA256_RE.test(expected))
16
+ throw new BuildIntegrityError(`build ${build.id} has no valid sha256`);
17
+ const release = cache.pin(expected);
18
+ try {
19
+ const cached = cache.get(expected);
20
+ if (cached)
21
+ return { path: cached, sha256: expected, fromCache: true, release };
22
+ const info = await options.getUrl();
23
+ if (info.sha256.toLowerCase() !== expected) {
24
+ throw new BuildIntegrityError(`the server offered build ${build.id} with digest ${info.sha256}, not ${expected}`);
25
+ }
26
+ const url = new URL(info.url);
27
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
28
+ throw new Error(`refusing to download a build from ${url.protocol} URL`);
29
+ const temp = cache.tempPath(info.file_name);
30
+ try {
31
+ const response = await (options.fetchImpl ?? fetch)(url, {
32
+ redirect: 'follow',
33
+ signal: AbortSignal.timeout(options.timeoutMs ?? 30 * 60_000),
34
+ });
35
+ if (!response.ok || !response.body)
36
+ throw new Error(`the build download failed with HTTP ${response.status}`);
37
+ const hash = createHash('sha256');
38
+ let received = 0;
39
+ const limit = info.size_bytes;
40
+ await writeResponseBody(response.body, temp, (chunk) => {
41
+ received += chunk.length;
42
+ if (received > limit)
43
+ throw new BuildIntegrityError(`the build download is larger than the expected ${limit} bytes`);
44
+ hash.update(chunk);
45
+ options.onProgress?.(received, limit);
46
+ });
47
+ if (received !== limit)
48
+ throw new BuildIntegrityError(`the build download has ${received} bytes, expected ${limit}`);
49
+ const digest = hash.digest('hex');
50
+ if (digest !== expected)
51
+ throw new BuildIntegrityError(`the downloaded build's sha256 ${digest} does not match ${expected}`);
52
+ const path = cache.put(expected, info.file_name || build.file_name, temp);
53
+ cache.prune();
54
+ return { path, sha256: expected, fromCache: false, release };
55
+ }
56
+ finally {
57
+ rmSync(temp, { force: true });
58
+ }
59
+ }
60
+ catch (error) {
61
+ release();
62
+ throw error;
63
+ }
64
+ }