@leera.io/qa-runner 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +64 -0
- package/README.md +333 -0
- package/dist/src/appium/drivers.js +60 -0
- package/dist/src/appium/home.js +60 -0
- package/dist/src/appium/server.js +237 -0
- package/dist/src/builds/cache.js +133 -0
- package/dist/src/builds/cleanup.js +208 -0
- package/dist/src/builds/download.js +64 -0
- package/dist/src/builds/install.js +75 -0
- package/dist/src/builds/ios-app.js +99 -0
- package/dist/src/builds/unpack.js +347 -0
- package/dist/src/capabilities.js +93 -0
- package/dist/src/ci/builds.js +68 -0
- package/dist/src/ci/client.js +90 -0
- package/dist/src/ci/env.js +118 -0
- package/dist/src/ci/junit.js +79 -0
- package/dist/src/ci/run.js +320 -0
- package/dist/src/ci/summary.js +126 -0
- package/dist/src/cli/commands/builds.js +104 -0
- package/dist/src/cli/commands/ci.js +167 -0
- package/dist/src/cli/commands/config.js +83 -0
- package/dist/src/cli/commands/connect.js +70 -0
- package/dist/src/cli/commands/doctor.js +57 -0
- package/dist/src/cli/commands/service.js +165 -0
- package/dist/src/cli/commands/setup.js +193 -0
- package/dist/src/cli/commands/start.js +94 -0
- package/dist/src/cli/commands/update.js +189 -0
- package/dist/src/cli/commands/version.js +28 -0
- package/dist/src/cli/main.js +59 -0
- package/dist/src/cli/output.js +67 -0
- package/dist/src/cli.js +40 -0
- package/dist/src/client.js +146 -0
- package/dist/src/config.js +260 -0
- package/dist/src/debug.js +137 -0
- package/dist/src/devices/android/adb.js +251 -0
- package/dist/src/devices/android/avd.js +95 -0
- package/dist/src/devices/android/emulator.js +153 -0
- package/dist/src/devices/android/index.js +133 -0
- package/dist/src/devices/android/logcat.js +205 -0
- package/dist/src/devices/android/prepare.js +40 -0
- package/dist/src/devices/android/setup.js +162 -0
- package/dist/src/devices/android/system-dialog.js +78 -0
- package/dist/src/devices/desktop.js +145 -0
- package/dist/src/devices/ios/devicectl.js +87 -0
- package/dist/src/devices/ios/index.js +130 -0
- package/dist/src/devices/ios/prepare.js +39 -0
- package/dist/src/devices/ios/record.js +122 -0
- package/dist/src/devices/ios/settings.js +55 -0
- package/dist/src/devices/ios/setup.js +186 -0
- package/dist/src/devices/ios/simctl.js +201 -0
- package/dist/src/devices/ios/syslog.js +185 -0
- package/dist/src/devices/ios/wda.js +108 -0
- package/dist/src/devices/macos.js +248 -0
- package/dist/src/devices/manager.js +206 -0
- package/dist/src/devices/screen-record.js +95 -0
- package/dist/src/devices/tauri.js +189 -0
- package/dist/src/devices/types.js +6 -0
- package/dist/src/devices/windows.js +362 -0
- package/dist/src/doctor.js +394 -0
- package/dist/src/download.js +39 -0
- package/dist/src/drivers/android-appium.js +525 -0
- package/dist/src/drivers/common.js +64 -0
- package/dist/src/drivers/desktop-appium.js +282 -0
- package/dist/src/drivers/driver.js +15 -0
- package/dist/src/drivers/electron-playwright.js +566 -0
- package/dist/src/drivers/ios-appium.js +535 -0
- package/dist/src/drivers/mac2-appium.js +313 -0
- package/dist/src/drivers/registry.js +47 -0
- package/dist/src/drivers/snapshot/aria.js +351 -0
- package/dist/src/drivers/snapshot/format.js +106 -0
- package/dist/src/drivers/tauri-webdriver.js +678 -0
- package/dist/src/drivers/web-playwright.js +403 -0
- package/dist/src/drivers/webdriver/actions.js +328 -0
- package/dist/src/drivers/webdriver/desktop-keys.js +141 -0
- package/dist/src/drivers/webdriver/dom-locate.js +113 -0
- package/dist/src/drivers/webdriver/dom-snapshot.js +376 -0
- package/dist/src/drivers/webdriver/dom-tree.js +118 -0
- package/dist/src/drivers/webdriver/dom.js +213 -0
- package/dist/src/drivers/webdriver/ios-actions.js +299 -0
- package/dist/src/drivers/webdriver/ios-locate.js +121 -0
- package/dist/src/drivers/webdriver/locate.js +136 -0
- package/dist/src/drivers/webdriver/native-actions.js +175 -0
- package/dist/src/drivers/webdriver/native-locate.js +273 -0
- package/dist/src/drivers/webdriver/session.js +105 -0
- package/dist/src/drivers/webdriver/xml-tree-desktop.js +425 -0
- package/dist/src/drivers/webdriver/xml-tree-ios.js +314 -0
- package/dist/src/drivers/webdriver/xml-tree.js +393 -0
- package/dist/src/drivers/windows-appium.js +319 -0
- package/dist/src/errors.js +21 -0
- package/dist/src/executor.js +189 -0
- package/dist/src/home.js +76 -0
- package/dist/src/index.js +22 -0
- package/dist/src/log.js +69 -0
- package/dist/src/runner.js +498 -0
- package/dist/src/service/index.js +64 -0
- package/dist/src/service/launchd.js +85 -0
- package/dist/src/service/names.js +2 -0
- package/dist/src/service/schtasks.js +128 -0
- package/dist/src/service/systemd.js +65 -0
- package/dist/src/session/commands.js +165 -0
- package/dist/src/session/execute.js +232 -0
- package/dist/src/session/loop.js +148 -0
- package/dist/src/template.js +62 -0
- package/dist/src/types.js +7 -0
- package/dist/src/update/apply.js +223 -0
- package/dist/src/update/check.js +59 -0
- package/dist/src/update/manifest.js +93 -0
- package/dist/src/update/verify.js +65 -0
- package/dist/src/version.js +42 -0
- package/package.json +44 -0
package/dist/src/log.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
const ORDER = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
4
|
+
export const LOG_MAX_BYTES = 20 * 1024 * 1024;
|
|
5
|
+
export const LOG_MAX_FILES = 5;
|
|
6
|
+
/** Moves `file` to `file.1`, `file.1` to `file.2`, … keeping at most `maxFiles` old files. */
|
|
7
|
+
export function rotate(file, maxFiles) {
|
|
8
|
+
const oldest = `${file}.${maxFiles}`;
|
|
9
|
+
if (existsSync(oldest))
|
|
10
|
+
rmSync(oldest, { force: true });
|
|
11
|
+
for (let i = maxFiles - 1; i >= 1; i--) {
|
|
12
|
+
const from = `${file}.${i}`;
|
|
13
|
+
if (existsSync(from))
|
|
14
|
+
renameSync(from, `${file}.${i + 1}`);
|
|
15
|
+
}
|
|
16
|
+
if (existsSync(file))
|
|
17
|
+
renameSync(file, `${file}.1`);
|
|
18
|
+
}
|
|
19
|
+
export function createLogger(options = {}) {
|
|
20
|
+
const mode = options.console ?? 'json';
|
|
21
|
+
const threshold = ORDER[options.level ?? 'info'];
|
|
22
|
+
const maxBytes = options.maxBytes ?? LOG_MAX_BYTES;
|
|
23
|
+
const maxFiles = options.maxFiles ?? LOG_MAX_FILES;
|
|
24
|
+
const stdout = options.stdout ?? ((line) => console.log(line));
|
|
25
|
+
const stderr = options.stderr ?? ((line) => console.error(line));
|
|
26
|
+
let fileBroken = false;
|
|
27
|
+
if (options.file)
|
|
28
|
+
mkdirSync(dirname(options.file), { recursive: true, mode: 0o700 });
|
|
29
|
+
const write = (level, message, fields = {}) => {
|
|
30
|
+
if (ORDER[level] < threshold)
|
|
31
|
+
return;
|
|
32
|
+
const line = JSON.stringify({ time: new Date().toISOString(), level, message, ...fields });
|
|
33
|
+
if (mode === 'json') {
|
|
34
|
+
if (level === 'error')
|
|
35
|
+
stderr(line);
|
|
36
|
+
else
|
|
37
|
+
stdout(line);
|
|
38
|
+
}
|
|
39
|
+
else if (mode === 'pretty') {
|
|
40
|
+
const extra = Object.entries(fields)
|
|
41
|
+
.map(([key, value]) => `${key}=${typeof value === 'string' ? value : JSON.stringify(value)}`)
|
|
42
|
+
.join(' ');
|
|
43
|
+
const text = `${new Date().toLocaleTimeString()} ${level.toUpperCase().padEnd(5)} ${message}${extra ? ` ${extra}` : ''}`;
|
|
44
|
+
if (level === 'error' || level === 'warn')
|
|
45
|
+
stderr(text);
|
|
46
|
+
else
|
|
47
|
+
stdout(text);
|
|
48
|
+
}
|
|
49
|
+
if (options.file && !fileBroken) {
|
|
50
|
+
try {
|
|
51
|
+
if (existsSync(options.file) && statSync(options.file).size >= maxBytes)
|
|
52
|
+
rotate(options.file, maxFiles);
|
|
53
|
+
appendFileSync(options.file, `${line}\n`, { mode: 0o600 });
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
fileBroken = true;
|
|
57
|
+
stderr(`could not write the log file ${options.file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
debug: (message, fields) => write('debug', message, fields),
|
|
63
|
+
info: (message, fields) => write('info', message, fields),
|
|
64
|
+
warn: (message, fields) => write('warn', message, fields),
|
|
65
|
+
error: (message, fields) => write('error', message, fields),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** A logger that drops everything; for tests. */
|
|
69
|
+
export const silentLogger = { debug() { }, info() { }, warn() { }, error() { } };
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import { readFile, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { buildCapabilities, detect, hostInfo } from './capabilities.js';
|
|
4
|
+
import { ApiError, RunnerClient } from './client.js';
|
|
5
|
+
import { loadFileConfig, resolveConfig } from './config.js';
|
|
6
|
+
import { DeviceManager, INVENTORY_REFRESH_MS } from './devices/manager.js';
|
|
7
|
+
import { BROWSER_DEVICE_ID, isPlatform } from './devices/types.js';
|
|
8
|
+
import { createRegistry } from './drivers/registry.js';
|
|
9
|
+
import { BrowserPool } from './drivers/web-playwright.js';
|
|
10
|
+
import { ExitCode, ExitError, messageOf } from './errors.js';
|
|
11
|
+
import { createDriver, executeClaimedJob, jobContext, sessionContext } from './executor.js';
|
|
12
|
+
import { homePaths, readJsonFile, writePrivateFile } from './home.js';
|
|
13
|
+
import { redactor } from './template.js';
|
|
14
|
+
import { runSession } from './session/loop.js';
|
|
15
|
+
import { isSessionClaim } from './types.js';
|
|
16
|
+
import { localDay } from './update/check.js';
|
|
17
|
+
import { compareVersions, RUNNER_VERSION } from './version.js';
|
|
18
|
+
const MAX_BACKOFF_MS = 60_000;
|
|
19
|
+
/** The server's limit for a video upload. */
|
|
20
|
+
const MAX_VIDEO_BYTES = 50 * 1024 * 1024;
|
|
21
|
+
/** Registration is idempotent; repeating it keeps metadata (devices, version) fresh. */
|
|
22
|
+
const REREGISTER_MS = 5 * 60_000;
|
|
23
|
+
/** Device lists flap while an emulator boots; wait this long after the last change before registering again. */
|
|
24
|
+
const REREGISTER_DEBOUNCE_MS = 3_000;
|
|
25
|
+
/** Registers, claims jobs while devices have free slots, runs them and reports back. */
|
|
26
|
+
export class Runner {
|
|
27
|
+
config;
|
|
28
|
+
deps;
|
|
29
|
+
stopping = false;
|
|
30
|
+
wake = new Set();
|
|
31
|
+
running = new Map();
|
|
32
|
+
registration = null;
|
|
33
|
+
startedAt = new Date().toISOString();
|
|
34
|
+
stopController = new AbortController();
|
|
35
|
+
/** When the advertised devices last changed and have not been registered yet. */
|
|
36
|
+
devicesChangedAt = null;
|
|
37
|
+
/** The newest version the server reported; cached in state.json for `update --check`. */
|
|
38
|
+
latestVersion = null;
|
|
39
|
+
/** The local day the update notice was last logged (kept across restarts in state.json). */
|
|
40
|
+
updateNoticeDay = null;
|
|
41
|
+
constructor(config, deps) {
|
|
42
|
+
this.config = config;
|
|
43
|
+
this.deps = deps;
|
|
44
|
+
if (deps.statePath) {
|
|
45
|
+
try {
|
|
46
|
+
const previous = readJsonFile(deps.statePath);
|
|
47
|
+
if (typeof previous?.latest_version === 'string')
|
|
48
|
+
this.latestVersion = previous.latest_version;
|
|
49
|
+
if (typeof previous?.update_notice_day === 'string')
|
|
50
|
+
this.updateNoticeDay = previous.update_notice_day;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// A damaged state file is rewritten on the next registration.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Remembers the server's latest version and logs, once per day, when it is newer than this runner. */
|
|
58
|
+
noteLatestVersion(latest, current) {
|
|
59
|
+
if (!latest?.trim())
|
|
60
|
+
return;
|
|
61
|
+
this.latestVersion = latest.trim();
|
|
62
|
+
if (compareVersions(this.latestVersion, current) <= 0)
|
|
63
|
+
return;
|
|
64
|
+
const today = (this.deps.today ?? localDay)();
|
|
65
|
+
if (this.updateNoticeDay === today)
|
|
66
|
+
return;
|
|
67
|
+
this.updateNoticeDay = today;
|
|
68
|
+
this.deps.log.info('an update is available: run `leera-qa-runner update`', { version: current, latest_version: this.latestVersion });
|
|
69
|
+
}
|
|
70
|
+
get isStopping() {
|
|
71
|
+
return this.stopping;
|
|
72
|
+
}
|
|
73
|
+
/** Stops claiming; running jobs stop after their current action. */
|
|
74
|
+
stop() {
|
|
75
|
+
if (this.stopping)
|
|
76
|
+
return;
|
|
77
|
+
this.stopping = true;
|
|
78
|
+
this.stopController.abort();
|
|
79
|
+
this.deps.log.info('stopping after the current step');
|
|
80
|
+
for (const wake of this.wake)
|
|
81
|
+
wake();
|
|
82
|
+
}
|
|
83
|
+
async run() {
|
|
84
|
+
const { client, devices, log } = this.deps;
|
|
85
|
+
let registration = await this.register();
|
|
86
|
+
if (!registration)
|
|
87
|
+
return;
|
|
88
|
+
let registeredAt = Date.now();
|
|
89
|
+
const active = new Set();
|
|
90
|
+
let claimedOnce = false;
|
|
91
|
+
const debounceMs = this.deps.reregisterDebounceMs ?? REREGISTER_DEBOUNCE_MS;
|
|
92
|
+
devices.startRefresh(this.deps.inventory ?? (async () => (await detect(this.deps.registry, this.deps.host)).devices), () => {
|
|
93
|
+
this.devicesChangedAt = Date.now();
|
|
94
|
+
log.info('devices changed', { devices: devices.capabilityDevices().map((device) => device.id) });
|
|
95
|
+
}, (error) => log.warn('device inventory refresh failed', { error: messageOf(error) }), this.deps.inventoryRefreshMs ?? INVENTORY_REFRESH_MS);
|
|
96
|
+
try {
|
|
97
|
+
while (!this.stopping && !(this.config.once && claimedOnce)) {
|
|
98
|
+
const changedAt = this.devicesChangedAt;
|
|
99
|
+
const changed = changedAt !== null && Date.now() - changedAt >= debounceMs;
|
|
100
|
+
if (changed || Date.now() - registeredAt > REREGISTER_MS) {
|
|
101
|
+
this.devicesChangedAt = null;
|
|
102
|
+
const again = await this.register();
|
|
103
|
+
if (!again)
|
|
104
|
+
break;
|
|
105
|
+
registration = again;
|
|
106
|
+
registeredAt = Date.now();
|
|
107
|
+
}
|
|
108
|
+
const accept = devices.accept();
|
|
109
|
+
if (accept.length === 0) {
|
|
110
|
+
// Every slot is busy: wait for a job to finish (or a stop).
|
|
111
|
+
await this.pause(registration.poll_seconds * 1_000, active);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
let claim;
|
|
115
|
+
try {
|
|
116
|
+
claim = await client.claim(registration.runner_id, { accept, runId: this.config.runId });
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (error instanceof ApiError && (error.status === 401 || error.status === 403))
|
|
120
|
+
throw tokenRejected(error);
|
|
121
|
+
if (error instanceof ApiError && error.status === 400) {
|
|
122
|
+
const again = await this.register();
|
|
123
|
+
if (!again)
|
|
124
|
+
break;
|
|
125
|
+
registration = again;
|
|
126
|
+
registeredAt = Date.now();
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
log.warn('claim failed', { error: messageOf(error) });
|
|
130
|
+
await this.pause(registration.poll_seconds * 2_000);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!claim) {
|
|
134
|
+
await this.pause(registration.poll_seconds * 1_000);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const started = isSessionClaim(claim)
|
|
138
|
+
? await this.startSession(claim, registration)
|
|
139
|
+
: await this.startJob(claim);
|
|
140
|
+
if (started === 'unknown') {
|
|
141
|
+
await this.pause(registration.poll_seconds * 1_000);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
claimedOnce = true;
|
|
145
|
+
if (!started)
|
|
146
|
+
continue;
|
|
147
|
+
const tracked = started.task.finally(() => {
|
|
148
|
+
active.delete(tracked);
|
|
149
|
+
this.writeState();
|
|
150
|
+
for (const wake of this.wake)
|
|
151
|
+
wake();
|
|
152
|
+
});
|
|
153
|
+
active.add(tracked);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
devices.stopRefresh();
|
|
158
|
+
await Promise.allSettled([...active]);
|
|
159
|
+
this.writeState(true);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** Leases the job's device and starts it; null when it could not start (already reported). */
|
|
163
|
+
async startJob(job) {
|
|
164
|
+
const { client, devices, log } = this.deps;
|
|
165
|
+
const kind = job.kind ?? 'test';
|
|
166
|
+
if (kind !== 'test') {
|
|
167
|
+
log.warn('ignoring a claim this runner version cannot handle', { kind });
|
|
168
|
+
return 'unknown';
|
|
169
|
+
}
|
|
170
|
+
const platform = job.platform ?? 'web';
|
|
171
|
+
const lease = isPlatform(platform) ? devices.lease(platform, job.device?.id, { holder: `job:${job.job.id}` }) : null;
|
|
172
|
+
if (!lease) {
|
|
173
|
+
await client
|
|
174
|
+
.complete({ job_id: job.job.id, error: `this runner has no free ${platform} device for the job` })
|
|
175
|
+
.catch((error) => log.error('could not report the failure', { job_id: job.job.id, error: messageOf(error) }));
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
const task = this.runJob(job, lease)
|
|
179
|
+
.catch((error) => log.error('job crashed', { job_id: job.job.id, error: messageOf(error) }))
|
|
180
|
+
.finally(() => lease.release());
|
|
181
|
+
return { task };
|
|
182
|
+
}
|
|
183
|
+
/** Leases the session's device and runs the session loop; null when no device was free (session ended). */
|
|
184
|
+
async startSession(claim, registration) {
|
|
185
|
+
const { client, devices, log, registry, shared } = this.deps;
|
|
186
|
+
const session = claim.session;
|
|
187
|
+
const lease = isPlatform(session.platform)
|
|
188
|
+
? devices.lease(session.platform, session.device?.id, { holder: `session:${session.id}` })
|
|
189
|
+
: null;
|
|
190
|
+
if (!lease) {
|
|
191
|
+
await client
|
|
192
|
+
.endSession(session.id, `this runner has no free ${session.platform} device for the session`)
|
|
193
|
+
.catch((error) => log.error('could not end the session', { session_id: session.id, error: messageOf(error) }));
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const config = this.config;
|
|
197
|
+
this.running.set(-session.id, {
|
|
198
|
+
id: session.id,
|
|
199
|
+
kind: 'session',
|
|
200
|
+
platform: session.platform,
|
|
201
|
+
device_id: lease.device.id,
|
|
202
|
+
started_at: new Date().toISOString(),
|
|
203
|
+
});
|
|
204
|
+
this.writeState();
|
|
205
|
+
const task = runSession(session, {
|
|
206
|
+
client,
|
|
207
|
+
runnerId: registration.runner_id,
|
|
208
|
+
log,
|
|
209
|
+
shouldStop: () => this.stopping,
|
|
210
|
+
stopSignal: this.stopController.signal,
|
|
211
|
+
createDriver: () => {
|
|
212
|
+
const ctx = sessionContext(session, {
|
|
213
|
+
lease,
|
|
214
|
+
app: this.appSettings(),
|
|
215
|
+
buildDownloadUrl: (buildId) => client.buildDownloadUrl(buildId, { sessionId: session.id }),
|
|
216
|
+
actionTimeoutMs: config.actionTimeoutMs,
|
|
217
|
+
jobTimeoutMs: config.jobTimeoutMs,
|
|
218
|
+
headless: config.headless,
|
|
219
|
+
...(config.proxy ? { proxy: config.proxy } : {}),
|
|
220
|
+
redact: redactor([session.credential?.password]),
|
|
221
|
+
log,
|
|
222
|
+
});
|
|
223
|
+
return createDriver(registry, ctx, shared);
|
|
224
|
+
},
|
|
225
|
+
})
|
|
226
|
+
.then(() => undefined)
|
|
227
|
+
.catch((error) => log.error('session crashed', { session_id: session.id, error: messageOf(error) }))
|
|
228
|
+
.finally(() => {
|
|
229
|
+
this.running.delete(-session.id);
|
|
230
|
+
lease.release();
|
|
231
|
+
});
|
|
232
|
+
return { task };
|
|
233
|
+
}
|
|
234
|
+
/** App-platform settings every job and session context gets (runner home, local installs, SDK path). */
|
|
235
|
+
appSettings() {
|
|
236
|
+
return {
|
|
237
|
+
...(this.deps.paths ? { paths: this.deps.paths } : {}),
|
|
238
|
+
allowLocalPaths: this.config.allowLocalPaths,
|
|
239
|
+
...(this.config.androidSdkPath ? { androidSdkPath: this.config.androidSdkPath } : {}),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
async register() {
|
|
243
|
+
const { client, devices, host, log } = this.deps;
|
|
244
|
+
const body = {
|
|
245
|
+
name: this.config.name,
|
|
246
|
+
labels: this.config.labels,
|
|
247
|
+
version: this.deps.version ?? RUNNER_VERSION,
|
|
248
|
+
os: host.os,
|
|
249
|
+
arch: host.arch,
|
|
250
|
+
hostname: host.hostname,
|
|
251
|
+
ephemeral: this.deps.ephemeral ?? false,
|
|
252
|
+
...(this.deps.ephemeral && this.config.runId != null ? { run_id: this.config.runId } : {}),
|
|
253
|
+
capabilities: buildCapabilities(devices.capabilityDevices(), host),
|
|
254
|
+
};
|
|
255
|
+
let delay = 2_000;
|
|
256
|
+
while (!this.stopping) {
|
|
257
|
+
try {
|
|
258
|
+
const registration = await client.register(body);
|
|
259
|
+
if (this.registration?.runner_id !== registration.runner_id) {
|
|
260
|
+
log.info('registered', {
|
|
261
|
+
runner_id: registration.runner_id,
|
|
262
|
+
pool_id: registration.pool_id,
|
|
263
|
+
labels: registration.labels ?? this.config.labels,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
this.noteLatestVersion(registration.latest_version, body.version);
|
|
267
|
+
this.registration = registration;
|
|
268
|
+
this.writeState();
|
|
269
|
+
return registration;
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
if (error instanceof ApiError && (error.status === 401 || error.status === 403))
|
|
273
|
+
throw tokenRejected(error);
|
|
274
|
+
if (error instanceof ApiError && error.status === 426)
|
|
275
|
+
throw new ExitError(ExitCode.versionRefused, error.message);
|
|
276
|
+
if (error instanceof ApiError && error.status === 400) {
|
|
277
|
+
throw new ExitError(ExitCode.error, `the server refused the registration: ${error.message}`);
|
|
278
|
+
}
|
|
279
|
+
log.warn('request failed, retrying', { error: messageOf(error), retry_in_ms: delay });
|
|
280
|
+
await this.pause(delay);
|
|
281
|
+
delay = Math.min(delay * 2, MAX_BACKOFF_MS);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
/** Sleeps for `ms`, returning early on stop or when any of `tasks` settles. */
|
|
287
|
+
pause(ms, tasks) {
|
|
288
|
+
if (this.stopping)
|
|
289
|
+
return Promise.resolve();
|
|
290
|
+
return new Promise((resolve) => {
|
|
291
|
+
const done = () => {
|
|
292
|
+
clearTimeout(timer);
|
|
293
|
+
this.wake.delete(done);
|
|
294
|
+
resolve();
|
|
295
|
+
};
|
|
296
|
+
const timer = setTimeout(done, ms);
|
|
297
|
+
this.wake.add(done);
|
|
298
|
+
if (tasks && tasks.size > 0)
|
|
299
|
+
void Promise.race(tasks).then(done, done);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
async runJob(job, lease) {
|
|
303
|
+
const { client, log, registry, shared } = this.deps;
|
|
304
|
+
const config = this.config;
|
|
305
|
+
const jobId = job.job.id;
|
|
306
|
+
const platform = lease.device.platform;
|
|
307
|
+
const redact = redactor([job.credential?.password]);
|
|
308
|
+
let cancelled = false;
|
|
309
|
+
this.running.set(jobId, { id: jobId, kind: 'test', platform, device_id: lease.device.id, started_at: new Date().toISOString() });
|
|
310
|
+
this.writeState();
|
|
311
|
+
log.info('job started', { job_id: jobId, item_id: job.item.id, attempt: job.job.attempt, platform, device: lease.device.id });
|
|
312
|
+
const heartbeat = setInterval(() => {
|
|
313
|
+
client
|
|
314
|
+
.heartbeat(jobId)
|
|
315
|
+
.then((keepGoing) => {
|
|
316
|
+
if (!keepGoing)
|
|
317
|
+
cancelled = true;
|
|
318
|
+
})
|
|
319
|
+
.catch((error) => log.warn('heartbeat failed', { job_id: jobId, error: redact(messageOf(error)) }));
|
|
320
|
+
}, job.job.heartbeat_seconds * 1_000);
|
|
321
|
+
let videoPath;
|
|
322
|
+
try {
|
|
323
|
+
const ctx = jobContext(job, {
|
|
324
|
+
lease,
|
|
325
|
+
app: this.appSettings(),
|
|
326
|
+
buildDownloadUrl: (buildId) => client.buildDownloadUrl(buildId, { jobId }),
|
|
327
|
+
actionTimeoutMs: config.actionTimeoutMs,
|
|
328
|
+
jobTimeoutMs: config.jobTimeoutMs,
|
|
329
|
+
headless: config.headless,
|
|
330
|
+
...(config.proxy ? { proxy: config.proxy } : {}),
|
|
331
|
+
redact,
|
|
332
|
+
log,
|
|
333
|
+
});
|
|
334
|
+
const report = await executeClaimedJob(registry, ctx, shared, () => cancelled || this.stopping);
|
|
335
|
+
videoPath = report.videoPath;
|
|
336
|
+
if (cancelled) {
|
|
337
|
+
log.info('job cancelled', { job_id: jobId });
|
|
338
|
+
this.jobFinished(job, 'cancelled', 'the job was cancelled', [], report.elapsedSecs);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (report.stopped) {
|
|
342
|
+
await client.complete({ job_id: jobId, error: 'the runner shut down before the job finished' });
|
|
343
|
+
this.jobFinished(job, 'error', 'the runner shut down before the job finished', [], report.elapsedSecs);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const stepResults = [];
|
|
347
|
+
for (const step of report.steps) {
|
|
348
|
+
const attachments = [];
|
|
349
|
+
for (const shot of step.screenshots) {
|
|
350
|
+
try {
|
|
351
|
+
attachments.push(await client.uploadEvidence(jobId, shot.name, shot.png));
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
log.warn('evidence upload failed', { job_id: jobId, error: redact(messageOf(error)) });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
stepResults.push({
|
|
358
|
+
index: step.index,
|
|
359
|
+
result: step.result,
|
|
360
|
+
...(step.note ? { note: step.note } : {}),
|
|
361
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
362
|
+
...(step.debug ? { debug: step.debug } : {}),
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
const passed = report.steps.filter((step) => step.result === 'passed').length;
|
|
366
|
+
const notes = [
|
|
367
|
+
`Automated run on ${job.environment.name} by runner ${config.name}: ${passed} of ${job.item.steps.length} steps passed.`,
|
|
368
|
+
];
|
|
369
|
+
const evidence = [];
|
|
370
|
+
const mode = job.capture?.video ?? 'off';
|
|
371
|
+
const troubled = report.steps.some((step) => step.result !== 'passed');
|
|
372
|
+
if (videoPath && (mode === 'always' || (mode === 'on_failure' && troubled))) {
|
|
373
|
+
const size = (await stat(videoPath)).size;
|
|
374
|
+
if (size > MAX_VIDEO_BYTES) {
|
|
375
|
+
notes.push(`The video was not kept: it is ${Math.round(size / (1024 * 1024))} MB, over the 50 MB limit.`);
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
try {
|
|
379
|
+
const mp4 = videoPath.toLowerCase().endsWith('.mp4');
|
|
380
|
+
evidence.push(await client.uploadEvidence(jobId, mp4 ? 'case-video.mp4' : 'case-video.webm', await readFile(videoPath), mp4 ? 'video/mp4' : 'video/webm'));
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
log.warn('video upload failed', { job_id: jobId, error: redact(messageOf(error)) });
|
|
384
|
+
notes.push('The video could not be uploaded.');
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const outcome = await client.complete({
|
|
389
|
+
job_id: jobId,
|
|
390
|
+
step_results: stepResults,
|
|
391
|
+
elapsed_secs: report.elapsedSecs,
|
|
392
|
+
notes: notes.join(' '),
|
|
393
|
+
...(evidence.length > 0 ? { evidence } : {}),
|
|
394
|
+
});
|
|
395
|
+
log.info('job finished', { job_id: jobId, item_result: outcome.item_result, elapsed_secs: report.elapsedSecs });
|
|
396
|
+
this.jobFinished(job, outcome.item_result, notes.join(' '), report.steps.filter((step) => step.result !== 'passed' && step.note).map((step) => redact(step.note)), report.elapsedSecs);
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
const reason = redact(messageOf(error));
|
|
400
|
+
log.error('job could not run', { job_id: jobId, error: reason });
|
|
401
|
+
await client
|
|
402
|
+
.complete({ job_id: jobId, error: reason })
|
|
403
|
+
.catch((e) => log.error('could not report the failure', { job_id: jobId, error: redact(messageOf(e)) }));
|
|
404
|
+
this.jobFinished(job, 'error', reason, [reason], 0);
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
clearInterval(heartbeat);
|
|
408
|
+
this.running.delete(jobId);
|
|
409
|
+
// Each job records into its own temporary directory.
|
|
410
|
+
if (videoPath)
|
|
411
|
+
await rm(dirname(videoPath), { recursive: true, force: true }).catch(() => undefined);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
jobFinished(job, result, notes, failures, elapsedSecs) {
|
|
415
|
+
try {
|
|
416
|
+
this.deps.onJobFinished?.({
|
|
417
|
+
job_id: job.job.id,
|
|
418
|
+
attempt: job.job.attempt,
|
|
419
|
+
item_id: job.item.id,
|
|
420
|
+
title: job.item.title,
|
|
421
|
+
result,
|
|
422
|
+
notes,
|
|
423
|
+
failures,
|
|
424
|
+
elapsed_secs: elapsedSecs,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
this.deps.log.warn('job listener failed', { error: messageOf(error) });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
writeState(stopped = false) {
|
|
432
|
+
const path = this.deps.statePath;
|
|
433
|
+
if (!path)
|
|
434
|
+
return;
|
|
435
|
+
const state = {
|
|
436
|
+
pid: process.pid,
|
|
437
|
+
version: this.deps.version ?? RUNNER_VERSION,
|
|
438
|
+
name: this.config.name,
|
|
439
|
+
url: this.config.url,
|
|
440
|
+
started_at: this.startedAt,
|
|
441
|
+
updated_at: new Date().toISOString(),
|
|
442
|
+
stopped,
|
|
443
|
+
runner_id: this.registration?.runner_id ?? null,
|
|
444
|
+
pool_id: this.registration?.pool_id ?? null,
|
|
445
|
+
labels: this.registration?.labels ?? this.config.labels,
|
|
446
|
+
jobs: [...this.running.values()].filter((entry) => entry.kind === 'test'),
|
|
447
|
+
sessions: [...this.running.values()].filter((entry) => entry.kind === 'session'),
|
|
448
|
+
devices: this.deps.devices.busy(),
|
|
449
|
+
latest_version: this.latestVersion,
|
|
450
|
+
update_notice_day: this.updateNoticeDay,
|
|
451
|
+
};
|
|
452
|
+
try {
|
|
453
|
+
writePrivateFile(path, `${JSON.stringify(state, null, 2)}\n`);
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
this.deps.log.warn('could not write the state file', { path, error: messageOf(error) });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function tokenRejected(error) {
|
|
461
|
+
return new ExitError(ExitCode.tokenRejected, `the server rejected the runner token (${error.status} ${error.message}); create a new token and run \`leera-qa-runner config set-token\``);
|
|
462
|
+
}
|
|
463
|
+
/** Everything `start` and the Docker entry need: config, detected devices, client, runner. */
|
|
464
|
+
export async function createRunner(options) {
|
|
465
|
+
const env = options.env ?? process.env;
|
|
466
|
+
const paths = homePaths(env);
|
|
467
|
+
const home = options.useHome === false ? { file: null, token: null, fixedPermissions: false } : loadFileConfig(paths);
|
|
468
|
+
if (home.fixedPermissions)
|
|
469
|
+
options.log.warn('the token file was readable by others; its permissions were set to 0600', { path: paths.token });
|
|
470
|
+
const registry = options.registry ?? createRegistry();
|
|
471
|
+
const host = hostInfo();
|
|
472
|
+
const detection = await detect(registry, host);
|
|
473
|
+
for (const [platform, reasons] of Object.entries(detection.unavailable)) {
|
|
474
|
+
options.log.warn('platform unavailable', { platform, reasons });
|
|
475
|
+
}
|
|
476
|
+
const config = resolveConfig({
|
|
477
|
+
...(options.flags ? { flags: options.flags } : {}),
|
|
478
|
+
env,
|
|
479
|
+
file: home.file,
|
|
480
|
+
fileToken: home.token,
|
|
481
|
+
detectedLabels: detection.labels,
|
|
482
|
+
});
|
|
483
|
+
const devices = new DeviceManager(detection.devices, (device) => device.id === BROWSER_DEVICE_ID ? config.concurrency.web : 1);
|
|
484
|
+
const shared = {
|
|
485
|
+
browsers: new BrowserPool({ headless: config.headless, ...(config.proxy ? { proxy: config.proxy } : {}) }),
|
|
486
|
+
};
|
|
487
|
+
const runner = new Runner(config, {
|
|
488
|
+
client: new RunnerClient(config.url, config.token),
|
|
489
|
+
registry,
|
|
490
|
+
devices,
|
|
491
|
+
host,
|
|
492
|
+
shared,
|
|
493
|
+
log: options.log,
|
|
494
|
+
...(options.useHome === false ? {} : { paths }),
|
|
495
|
+
...(options.writeState ? { statePath: paths.state } : {}),
|
|
496
|
+
});
|
|
497
|
+
return { runner, config, shared };
|
|
498
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { PACKAGE_ROOT } from '../version.js';
|
|
7
|
+
import { launchdBackend } from './launchd.js';
|
|
8
|
+
import { schtasksBackend } from './schtasks.js';
|
|
9
|
+
import { systemdBackend } from './systemd.js';
|
|
10
|
+
export { SERVICE_LABEL, SERVICE_NAME } from './names.js';
|
|
11
|
+
/** Environment variables `service install` copies into the config, since services start without a login shell. */
|
|
12
|
+
export const CAPTURED_ENV = ['PATH', 'JAVA_HOME', 'ANDROID_HOME', 'ANDROID_SDK_ROOT', 'PLAYWRIGHT_BROWSERS_PATH', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY'];
|
|
13
|
+
/** The argv a service starts: the bundle launcher when present (so updates take effect), else node + cli.js. */
|
|
14
|
+
export function serviceArgv(env, execPath = process.execPath, cliPath = join(PACKAGE_ROOT, 'dist', 'src', 'cli.js')) {
|
|
15
|
+
const launcher = env.LEERA_RUNNER_LAUNCHER?.trim();
|
|
16
|
+
const base = launcher ? [launcher] : [execPath, cliPath];
|
|
17
|
+
const home = env.LEERA_RUNNER_HOME?.trim();
|
|
18
|
+
return [...base, 'start', '--service', ...(home ? ['--home', home] : [])];
|
|
19
|
+
}
|
|
20
|
+
export function serviceSpec(env, home, logDir) {
|
|
21
|
+
const serviceEnv = {};
|
|
22
|
+
if (env.PATH)
|
|
23
|
+
serviceEnv.PATH = env.PATH;
|
|
24
|
+
return { argv: serviceArgv(env), env: serviceEnv, home, logDir, userHome: homedir() };
|
|
25
|
+
}
|
|
26
|
+
export function backendFor(platform) {
|
|
27
|
+
if (platform === 'darwin')
|
|
28
|
+
return launchdBackend(process.getuid?.() ?? 501);
|
|
29
|
+
if (platform === 'win32')
|
|
30
|
+
return schtasksBackend(process.env.USERDOMAIN ? `${process.env.USERDOMAIN}\\${process.env.USERNAME ?? ''}` : (process.env.USERNAME ?? ''));
|
|
31
|
+
if (platform === 'linux')
|
|
32
|
+
return systemdBackend();
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
/** Runs a fixed argv without a shell. */
|
|
36
|
+
export const execArgv = (argv) => new Promise((resolve) => {
|
|
37
|
+
const [command, ...args] = argv;
|
|
38
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
39
|
+
let stdout = '';
|
|
40
|
+
let stderr = '';
|
|
41
|
+
child.stdout.on('data', (chunk) => (stdout += chunk));
|
|
42
|
+
child.stderr.on('data', (chunk) => (stderr += chunk));
|
|
43
|
+
child.on('error', (error) => resolve({ code: 127, stdout, stderr: stderr + error.message }));
|
|
44
|
+
child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
45
|
+
});
|
|
46
|
+
export async function applyPlan(plan, exec = execArgv) {
|
|
47
|
+
for (const write of plan.writes ?? []) {
|
|
48
|
+
await mkdir(dirname(write.path), { recursive: true });
|
|
49
|
+
await writeFile(write.path, write.contents);
|
|
50
|
+
}
|
|
51
|
+
const outputs = [];
|
|
52
|
+
for (const command of plan.commands) {
|
|
53
|
+
const result = await exec(command.argv);
|
|
54
|
+
outputs.push({ argv: command.argv, result });
|
|
55
|
+
if (result.code !== 0 && !command.ignoreFailure) {
|
|
56
|
+
return { ok: false, outputs, failed: { argv: command.argv, result } };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
for (const path of plan.removes ?? []) {
|
|
60
|
+
if (existsSync(path))
|
|
61
|
+
await rm(path, { force: true });
|
|
62
|
+
}
|
|
63
|
+
return { ok: true, outputs };
|
|
64
|
+
}
|