@leera.io/qa-runner 1.0.0 → 1.0.1

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.
@@ -3,16 +3,18 @@ import { formatDoctor, runDoctor } from '../../doctor.js';
3
3
  import { isPlatform } from '../../devices/types.js';
4
4
  import { createRegistry } from '../../drivers/registry.js';
5
5
  import { ExitCode } from '../../errors.js';
6
+ import { envSecrets, toHealthReport } from '../../health.js';
6
7
  import { homePaths } from '../../home.js';
7
8
  import { printJson, usageError } from '../output.js';
8
9
  import { installBrowsers } from './setup.js';
9
- const usage = `Usage: leera-qa-runner doctor [--json] [--fix] [--platform web|android|ios|electron|tauri]
10
+ const usage = `Usage: leera-qa-runner doctor [--json] [--health] [--fix] [--platform web|android|ios|electron|tauri]
10
11
 
11
12
  Checks that this machine can run jobs: Node.js, the runner home, the server URL and
12
13
  token, and each platform's tools. Exits 4 when something needs fixing.
13
14
 
14
15
  Options:
15
16
  --json Print the report as JSON
17
+ --health Print the report the runner sends to the server (JSON)
16
18
  --fix Fix what can be fixed safely (permissions, installing Chromium)
17
19
  --platform NAME Only check one platform
18
20
  `;
@@ -25,6 +27,7 @@ export const command = {
25
27
  strict: true,
26
28
  options: {
27
29
  json: { type: 'boolean' },
30
+ health: { type: 'boolean' },
28
31
  fix: { type: 'boolean' },
29
32
  platform: { type: 'string' },
30
33
  offline: { type: 'boolean' },
@@ -44,11 +47,14 @@ export const command = {
44
47
  paths,
45
48
  registry,
46
49
  fix: values.fix ?? false,
47
- offline: values.offline ?? false,
50
+ // `--health` prints exactly what `start` sends, and that report never uses the network.
51
+ offline: (values.offline ?? false) || (values.health ?? false),
48
52
  ...(platform ? { platform } : {}),
49
53
  installBrowsers: async () => (await installBrowsers(io, paths)) === 0,
50
54
  });
51
- if (values.json)
55
+ if (values.health)
56
+ printJson(io, toHealthReport(report, { secrets: envSecrets(io.env) }));
57
+ else if (values.json)
52
58
  printJson(io, report);
53
59
  else
54
60
  io.stdout(formatDoctor(report));
@@ -0,0 +1,309 @@
1
+ import { TOKEN_PREFIX } from './config.js';
2
+ import { runDoctor } from './doctor.js';
3
+ import { isPlatform } from './devices/types.js';
4
+ import { messageOf } from './errors.js';
5
+ import { redactor } from './template.js';
6
+ /** Contracts §O limits. The server refuses a report that exceeds any of them. */
7
+ export const MAX_HEALTH_CHECKS = 60;
8
+ export const MAX_HEALTH_BYTES = 16 * 1024;
9
+ export const MAX_HEALTH_DETAIL = 300;
10
+ export const MAX_HEALTH_LABEL = 80;
11
+ export const MAX_HEALTH_FIX_COMMAND = 200;
12
+ export const MAX_HEALTH_FIX_URL = 300;
13
+ const HEALTH_ID = /^[a-z][a-z0-9._-]{0,60}$/;
14
+ /**
15
+ * Checks that depend on the network. They are left out of the report: a five-second outage
16
+ * or a proxy hiccup would otherwise show up on the runners page as a setup problem the
17
+ * person cannot fix, and the page already knows whether the runner is reachable (it is
18
+ * talking to the server whenever a report arrives at all).
19
+ */
20
+ export const NETWORK_CHECK_IDS = new Set(['server']);
21
+ /** `doctor` titles the bare platform checks with the platform name; the UI wants a label. */
22
+ const PLATFORM_LABELS = {
23
+ web: 'Web jobs',
24
+ android: 'Android jobs',
25
+ ios: 'iOS jobs',
26
+ electron: 'Electron jobs',
27
+ tauri: 'Tauri jobs',
28
+ macos: 'macOS app jobs',
29
+ windows: 'Windows app jobs',
30
+ linux: 'Linux app jobs',
31
+ };
32
+ /** First words that make a fix line a command someone can paste, rather than prose. */
33
+ const COMMAND_WORDS = new Set([
34
+ 'leera-qa-runner',
35
+ 'npm',
36
+ 'npx',
37
+ 'node',
38
+ 'sdkmanager',
39
+ 'avdmanager',
40
+ 'adb',
41
+ 'brew',
42
+ 'winget',
43
+ 'choco',
44
+ 'sudo',
45
+ 'apt',
46
+ 'apt-get',
47
+ 'cargo',
48
+ 'pip',
49
+ 'pip3',
50
+ 'gem',
51
+ 'xcode-select',
52
+ 'xcrun',
53
+ 'xcodebuild',
54
+ 'softwareupdate',
55
+ 'automationmodetool',
56
+ 'sh',
57
+ 'bash',
58
+ 'zsh',
59
+ 'powershell',
60
+ 'msiexec',
61
+ 'defaults',
62
+ 'docker',
63
+ 'git',
64
+ 'chmod',
65
+ ]);
66
+ const TOKEN_PATTERN = new RegExp(`${TOKEN_PREFIX}[A-Za-z0-9_.-]+`, 'g');
67
+ /** `scheme://user:password@host` — a URL somebody configured with credentials in it. */
68
+ const URL_USERINFO = /([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi;
69
+ export function truncate(text, max) {
70
+ const trimmed = text.trim();
71
+ return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max - 1).trimEnd()}…`;
72
+ }
73
+ /** The token sources a report could otherwise quote back. */
74
+ export function envSecrets(env) {
75
+ return [env.RUNNER_TOKEN, env.LEERA_RUNNER_TOKEN, env.RUNNER_WEBHOOK_SECRET]
76
+ .map((value) => value?.trim())
77
+ .filter((value) => !!value);
78
+ }
79
+ /**
80
+ * Removes every known secret, anything shaped like a runner token, and the user:password
81
+ * part of a URL. Paths are deliberately kept (contracts §O).
82
+ */
83
+ export function scrubber(secrets) {
84
+ const redact = redactor(secrets);
85
+ return (text) => redact(text).replace(TOKEN_PATTERN, `${TOKEN_PREFIX}[redacted]`).replace(URL_USERINFO, '$1[redacted]@');
86
+ }
87
+ /** Makes an id the server accepts (`^[a-z][a-z0-9._-]{0,60}$`) without changing ids that already do. */
88
+ export function healthCheckId(id) {
89
+ if (HEALTH_ID.test(id))
90
+ return id;
91
+ const cleaned = id
92
+ .toLowerCase()
93
+ .replace(/[^a-z0-9._-]+/g, '-')
94
+ .replace(/^[^a-z]+/, '')
95
+ .slice(0, 61);
96
+ return cleaned.length > 0 ? cleaned : 'check';
97
+ }
98
+ function platformOf(id) {
99
+ const head = id.split('.')[0] ?? '';
100
+ return isPlatform(head) ? head : undefined;
101
+ }
102
+ /**
103
+ * Turns a `doctor` fix line into `{command, url}`. A line inside backticks is the command
104
+ * (`… then run \`leera-qa-runner setup ios\``); a bare line that starts with a known tool is
105
+ * itself the command; anything else is advice and stays in `detail` so nothing is lost.
106
+ */
107
+ export function parseFix(text) {
108
+ const line = text.trim();
109
+ if (!line)
110
+ return {};
111
+ const fix = {};
112
+ const quoted = /`([^`]+)`/.exec(line)?.[1]?.trim();
113
+ let command = quoted;
114
+ let prose = quoted ? undefined : line;
115
+ if (!command) {
116
+ const head = line.split(/\s+/)[0] ?? '';
117
+ if (COMMAND_WORDS.has(head)) {
118
+ // "leera-qa-runner config set tauri.macos_plugin true (only when …)" — drop the aside.
119
+ command = (line.split(' (')[0] ?? line).trim();
120
+ prose = undefined;
121
+ }
122
+ }
123
+ if (command && command.length <= MAX_HEALTH_FIX_COMMAND)
124
+ fix.command = command;
125
+ else if (command)
126
+ prose = line;
127
+ const url = /https?:\/\/[^\s)`'"<>]+/.exec(line)?.[0];
128
+ if (url && url.length <= MAX_HEALTH_FIX_URL)
129
+ fix.url = url;
130
+ return {
131
+ ...(fix.command || fix.url ? { fix } : {}),
132
+ ...(prose ? { prose } : {}),
133
+ };
134
+ }
135
+ const WORST = { ok: 0, warn: 1, fail: 2 };
136
+ export function deriveStatus(checks) {
137
+ if (checks.some((check) => check.status === 'fail'))
138
+ return 'action_needed';
139
+ if (checks.some((check) => check.status === 'warn'))
140
+ return 'warning';
141
+ return 'ok';
142
+ }
143
+ function toHealthCheck(check, scrub) {
144
+ const id = healthCheckId(check.id);
145
+ const platform = platformOf(id);
146
+ const parsed = check.fix && check.status !== 'ok' ? parseFix(scrub(check.fix)) : {};
147
+ const detail = [check.detail ? scrub(check.detail) : '', parsed.prose ? `fix: ${parsed.prose}` : '']
148
+ .filter((part) => part.length > 0)
149
+ .join(' — ');
150
+ return {
151
+ id,
152
+ label: truncate(PLATFORM_LABELS[check.id] ?? check.title, MAX_HEALTH_LABEL),
153
+ ...(platform ? { platform } : {}),
154
+ status: check.status,
155
+ ...(detail ? { detail: truncate(detail, MAX_HEALTH_DETAIL) } : {}),
156
+ ...(parsed.fix ? { fix: parsed.fix } : {}),
157
+ };
158
+ }
159
+ /** Keeps one check per id: the worst status wins, so a later failure is never hidden. */
160
+ function dedupe(checks) {
161
+ const byId = new Map();
162
+ for (const check of checks) {
163
+ const seen = byId.get(check.id);
164
+ if (!seen || WORST[check.status] > WORST[seen.status])
165
+ byId.set(check.id, check);
166
+ }
167
+ return [...byId.values()];
168
+ }
169
+ function capChecks(checks) {
170
+ if (checks.length <= MAX_HEALTH_CHECKS)
171
+ return checks;
172
+ const problems = checks.filter((check) => check.status !== 'ok');
173
+ const rest = checks.filter((check) => check.status === 'ok');
174
+ return [...problems, ...rest].slice(0, MAX_HEALTH_CHECKS);
175
+ }
176
+ export function serializedBytes(report) {
177
+ return Buffer.byteLength(JSON.stringify(report), 'utf8');
178
+ }
179
+ function withoutDetail(check) {
180
+ return {
181
+ id: check.id,
182
+ label: check.label,
183
+ ...(check.platform ? { platform: check.platform } : {}),
184
+ status: check.status,
185
+ ...(check.fix ? { fix: check.fix } : {}),
186
+ };
187
+ }
188
+ /** Ways to shrink an oversized report, tried in order; problems and their fixes go last. */
189
+ const SHRINK = [
190
+ (checks) => checks.map((check) => (check.status === 'ok' ? withoutDetail(check) : check)),
191
+ (checks) => checks.filter((check) => check.status !== 'ok'),
192
+ (checks) => checks.map((check) => (check.detail ? { ...check, detail: truncate(check.detail, 120) } : check)),
193
+ (checks) => checks.map(withoutDetail),
194
+ ];
195
+ function fit(report) {
196
+ let current = report;
197
+ for (const shrink of SHRINK) {
198
+ if (serializedBytes(current) <= MAX_HEALTH_BYTES)
199
+ return current;
200
+ current = { ...current, checks: shrink(current.checks) };
201
+ }
202
+ while (serializedBytes(current) > MAX_HEALTH_BYTES && current.checks.length > 0) {
203
+ current = { ...current, checks: current.checks.slice(0, Math.floor(current.checks.length / 2)) };
204
+ }
205
+ return current;
206
+ }
207
+ /** Builds the report the server stores from a `doctor` run (contracts §O). */
208
+ export function toHealthReport(report, options = {}) {
209
+ const scrub = scrubber(options.secrets ?? []);
210
+ const checks = dedupe(report.checks.filter((check) => !NETWORK_CHECK_IDS.has(check.id)).map((check) => toHealthCheck(check, scrub)));
211
+ // The summary covers every check, including any the size limits drop below.
212
+ return fit({ generated_at: (options.now ?? (() => new Date()))().toISOString(), status: deriveStatus(checks), checks: capChecks(checks) });
213
+ }
214
+ /**
215
+ * Runs the `doctor` checks and turns them into a health report. Never fixes anything and
216
+ * never touches the network (`offline`), so it is safe to run while jobs are in flight.
217
+ */
218
+ export async function healthReport(options) {
219
+ const report = await runDoctor({ env: options.env, paths: options.paths, registry: options.registry, fix: false, offline: true });
220
+ return toHealthReport(report, {
221
+ secrets: [...envSecrets(options.env), ...(options.secrets ?? [])],
222
+ ...(options.now ? { now: options.now } : {}),
223
+ });
224
+ }
225
+ /** How often a report may be recomputed, whatever asks for it (contracts §O). */
226
+ export const HEALTH_MIN_INTERVAL_MS = 60_000;
227
+ /** How long to wait for the checks before giving up on this round and keeping the last report. */
228
+ export const HEALTH_TIMEOUT_MS = 120_000;
229
+ function defaultTimer(fn, ms) {
230
+ const timer = setTimeout(fn, ms);
231
+ timer.unref?.();
232
+ return { cancel: () => clearTimeout(timer) };
233
+ }
234
+ /**
235
+ * Keeps the last health report and recomputes it in the background. Registration never waits
236
+ * for it: the first register carries no report, the next one does. At most one computation
237
+ * runs at a time (the checks shell out to adb, xcrun and java) and at most one a minute.
238
+ */
239
+ export class HealthMonitor {
240
+ deps;
241
+ report = null;
242
+ running = null;
243
+ lastStartedAt = null;
244
+ constructor(deps) {
245
+ this.deps = deps;
246
+ }
247
+ /** The last computed report, or null while none has finished yet. */
248
+ current() {
249
+ return this.report;
250
+ }
251
+ /** Resolves when a computation in flight has been stored or timed out (tests). */
252
+ settled() {
253
+ return this.running ?? Promise.resolve();
254
+ }
255
+ /**
256
+ * Starts a computation unless one is already running or one started less than a minute ago.
257
+ * The returned promise is for tests; callers fire and forget.
258
+ */
259
+ refresh(trigger) {
260
+ const now = (this.deps.now ?? Date.now)();
261
+ if (this.running)
262
+ return this.running;
263
+ const minInterval = this.deps.minIntervalMs ?? HEALTH_MIN_INTERVAL_MS;
264
+ if (this.lastStartedAt !== null && now - this.lastStartedAt < minInterval)
265
+ return Promise.resolve();
266
+ this.lastStartedAt = now;
267
+ let raw;
268
+ try {
269
+ raw = this.deps.compute();
270
+ }
271
+ catch (error) {
272
+ this.deps.log.warn('health checks failed', { trigger, error: messageOf(error) });
273
+ return Promise.resolve();
274
+ }
275
+ // The lock is held until the checks really finish, even if we stopped waiting for them,
276
+ // so a slow adb or xcrun is never started a second time alongside itself.
277
+ const lock = raw.then(() => undefined, () => undefined);
278
+ this.running = lock;
279
+ void lock.then(() => {
280
+ if (this.running === lock)
281
+ this.running = null;
282
+ });
283
+ return this.store(raw, trigger);
284
+ }
285
+ async store(raw, trigger) {
286
+ const timeoutMs = this.deps.timeoutMs ?? HEALTH_TIMEOUT_MS;
287
+ const timer = this.deps.setTimer ?? defaultTimer;
288
+ const handles = [];
289
+ const expired = new Promise((resolve) => {
290
+ handles.push(timer(() => resolve('timeout'), timeoutMs));
291
+ });
292
+ try {
293
+ const outcome = await Promise.race([raw, expired]);
294
+ if (outcome === 'timeout') {
295
+ this.deps.log.warn('health checks are taking too long; keeping the previous report', { trigger, timeout_ms: timeoutMs });
296
+ return;
297
+ }
298
+ this.report = outcome;
299
+ this.deps.log.debug('health report updated', { trigger, status: outcome.status, checks: outcome.checks.length });
300
+ }
301
+ catch (error) {
302
+ this.deps.log.warn('health checks failed', { trigger, error: messageOf(error) });
303
+ }
304
+ finally {
305
+ for (const handle of handles)
306
+ handle.cancel();
307
+ }
308
+ }
309
+ }
@@ -9,6 +9,7 @@ import { createRegistry } from './drivers/registry.js';
9
9
  import { BrowserPool } from './drivers/web-playwright.js';
10
10
  import { ExitCode, ExitError, messageOf } from './errors.js';
11
11
  import { createDriver, executeClaimedJob, jobContext, sessionContext } from './executor.js';
12
+ import { HealthMonitor, healthReport } from './health.js';
12
13
  import { homePaths, readJsonFile, writePrivateFile } from './home.js';
13
14
  import { redactor } from './template.js';
14
15
  import { runSession } from './session/loop.js';
@@ -170,6 +171,7 @@ export class Runner {
170
171
  const platform = job.platform ?? 'web';
171
172
  const lease = isPlatform(platform) ? devices.lease(platform, job.device?.id, { holder: `job:${job.job.id}` }) : null;
172
173
  if (!lease) {
174
+ void this.deps.health?.refresh('job_failed');
173
175
  await client
174
176
  .complete({ job_id: job.job.id, error: `this runner has no free ${platform} device for the job` })
175
177
  .catch((error) => log.error('could not report the failure', { job_id: job.job.id, error: messageOf(error) }));
@@ -188,6 +190,7 @@ export class Runner {
188
190
  ? devices.lease(session.platform, session.device?.id, { holder: `session:${session.id}` })
189
191
  : null;
190
192
  if (!lease) {
193
+ void this.deps.health?.refresh('job_failed');
191
194
  await client
192
195
  .endSession(session.id, `this runner has no free ${session.platform} device for the session`)
193
196
  .catch((error) => log.error('could not end the session', { session_id: session.id, error: messageOf(error) }));
@@ -255,7 +258,10 @@ export class Runner {
255
258
  let delay = 2_000;
256
259
  while (!this.stopping) {
257
260
  try {
258
- const registration = await client.register(body);
261
+ // The report is whatever the background checks last produced: the first registration
262
+ // carries none, later ones do (contracts §O). Registration never waits for it.
263
+ const health = this.deps.health?.current();
264
+ const registration = await client.register({ ...body, ...(health ? { health } : {}) });
259
265
  if (this.registration?.runner_id !== registration.runner_id) {
260
266
  log.info('registered', {
261
267
  runner_id: registration.runner_id,
@@ -266,6 +272,8 @@ export class Runner {
266
272
  this.noteLatestVersion(registration.latest_version, body.version);
267
273
  this.registration = registration;
268
274
  this.writeState();
275
+ // Recompute for the next registration (device change or the 5-minute refresh).
276
+ void this.deps.health?.refresh('register');
269
277
  return registration;
270
278
  }
271
279
  catch (error) {
@@ -398,6 +406,9 @@ export class Runner {
398
406
  catch (error) {
399
407
  const reason = redact(messageOf(error));
400
408
  log.error('job could not run', { job_id: jobId, error: reason });
409
+ // A job that could not start often means the machine's setup changed (a driver, a
410
+ // device, a permission); refresh the report so the runners page says what broke.
411
+ void this.deps.health?.refresh('job_failed');
401
412
  await client
402
413
  .complete({ job_id: jobId, error: reason })
403
414
  .catch((e) => log.error('could not report the failure', { job_id: jobId, error: redact(messageOf(e)) }));
@@ -491,6 +502,10 @@ export async function createRunner(options) {
491
502
  host,
492
503
  shared,
493
504
  log: options.log,
505
+ health: new HealthMonitor({
506
+ compute: () => healthReport({ env, paths, registry, secrets: [config.token] }),
507
+ log: options.log,
508
+ }),
494
509
  ...(options.useHome === false ? {} : { paths }),
495
510
  ...(options.writeState ? { statePath: paths.state } : {}),
496
511
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leera.io/qa-runner",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Runs recorded QA test scripts on your own machines and reports results back to your workspace.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",