@leera.io/qa-runner 1.0.0 → 1.0.2

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));
@@ -49,8 +49,11 @@ export class RunnerClient {
49
49
  return { result: 'unexpected', status: error.status, message: error.message };
50
50
  }
51
51
  }
52
- async heartbeat(jobId) {
53
- const response = await this.json('/api/v1/qa/runner/heartbeat', { job_id: jobId });
52
+ async heartbeat(jobId, progress) {
53
+ const response = await this.json('/api/v1/qa/runner/heartbeat', {
54
+ job_id: jobId,
55
+ ...(progress ? { progress } : {}),
56
+ });
54
57
  return response?.continue ?? false;
55
58
  }
56
59
  async uploadEvidence(jobId, name, bytes, contentType = 'image/png') {
@@ -76,9 +76,9 @@ export async function createDriver(registry, ctx, shared) {
76
76
  return factory.create(ctx, shared);
77
77
  }
78
78
  /** Runs a claimed job's script on the driver its platform maps to (web, android, …). */
79
- export async function executeClaimedJob(registry, ctx, shared, shouldStop) {
79
+ export async function executeClaimedJob(registry, ctx, shared, shouldStop, onProgress) {
80
80
  const driver = await createDriver(registry, ctx, shared);
81
- return executeSteps(driver, ctx.job.automation, { jobTimeoutMs: ctx.timeouts.jobMs }, shouldStop, ctx.redact);
81
+ return executeSteps(driver, ctx.job.automation, { jobTimeoutMs: ctx.timeouts.jobMs, ...(onProgress ? { onProgress } : {}) }, shouldStop, ctx.redact);
82
82
  }
83
83
  /**
84
84
  * Runs a script's steps in index order on a driver: stops at the first failed or
@@ -93,6 +93,23 @@ export async function executeSteps(driver, script, options, shouldStop, redact)
93
93
  let videoPath;
94
94
  try {
95
95
  const ordered = [...script.steps].sort((a, b) => a.index - b.index);
96
+ const report = (progress) => {
97
+ if (!options.onProgress)
98
+ return;
99
+ const passed = steps.filter((outcome) => outcome.result === 'passed').length;
100
+ try {
101
+ options.onProgress({
102
+ ...progress,
103
+ step_count: ordered.length,
104
+ steps_passed: passed,
105
+ steps_failed: steps.length - passed,
106
+ });
107
+ }
108
+ catch {
109
+ // Progress is a courtesy to whoever is watching; it never fails the job.
110
+ }
111
+ };
112
+ report({ phase: 'launching' });
96
113
  try {
97
114
  await driver.launch();
98
115
  }
@@ -104,8 +121,9 @@ export async function executeSteps(driver, script, options, shouldStop, redact)
104
121
  const outcome = { index: first.index, result: 'blocked', note: redact(errorMessage(error)), screenshots: [] };
105
122
  return { steps: [outcome], elapsedSecs: Math.max(1, Math.round((Date.now() - started) / 1000)), stopped: false };
106
123
  }
107
- for (const step of ordered) {
124
+ for (const [position, step] of ordered.entries()) {
108
125
  const outcome = { index: step.index, result: 'passed', screenshots: [] };
126
+ report({ phase: 'running', step_index: step.index, step_number: position + 1 });
109
127
  driver.beginStep(step.index);
110
128
  try {
111
129
  for (const action of step.actions) {
@@ -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
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Sends progress one request at a time, keeping only the newest update while one
3
+ * is in flight: a script with fast steps must not queue a request per step, and
4
+ * the run page only needs where the job is now, not every place it has been.
5
+ */
6
+ export function progressReporter(send) {
7
+ let pending;
8
+ let inFlight = false;
9
+ let stopped = false;
10
+ const pump = async () => {
11
+ inFlight = true;
12
+ while (pending && !stopped) {
13
+ const next = pending;
14
+ pending = undefined;
15
+ await send(next).catch(() => undefined);
16
+ }
17
+ inFlight = false;
18
+ };
19
+ return {
20
+ report(progress) {
21
+ if (stopped)
22
+ return;
23
+ pending = progress;
24
+ if (!inFlight)
25
+ void pump();
26
+ },
27
+ stop() {
28
+ stopped = true;
29
+ pending = undefined;
30
+ },
31
+ };
32
+ }
@@ -9,7 +9,9 @@ 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';
14
+ import { progressReporter } from './progress.js';
13
15
  import { redactor } from './template.js';
14
16
  import { runSession } from './session/loop.js';
15
17
  import { isSessionClaim } from './types.js';
@@ -170,6 +172,7 @@ export class Runner {
170
172
  const platform = job.platform ?? 'web';
171
173
  const lease = isPlatform(platform) ? devices.lease(platform, job.device?.id, { holder: `job:${job.job.id}` }) : null;
172
174
  if (!lease) {
175
+ void this.deps.health?.refresh('job_failed');
173
176
  await client
174
177
  .complete({ job_id: job.job.id, error: `this runner has no free ${platform} device for the job` })
175
178
  .catch((error) => log.error('could not report the failure', { job_id: job.job.id, error: messageOf(error) }));
@@ -188,6 +191,7 @@ export class Runner {
188
191
  ? devices.lease(session.platform, session.device?.id, { holder: `session:${session.id}` })
189
192
  : null;
190
193
  if (!lease) {
194
+ void this.deps.health?.refresh('job_failed');
191
195
  await client
192
196
  .endSession(session.id, `this runner has no free ${session.platform} device for the session`)
193
197
  .catch((error) => log.error('could not end the session', { session_id: session.id, error: messageOf(error) }));
@@ -255,7 +259,10 @@ export class Runner {
255
259
  let delay = 2_000;
256
260
  while (!this.stopping) {
257
261
  try {
258
- const registration = await client.register(body);
262
+ // The report is whatever the background checks last produced: the first registration
263
+ // carries none, later ones do (contracts §O). Registration never waits for it.
264
+ const health = this.deps.health?.current();
265
+ const registration = await client.register({ ...body, ...(health ? { health } : {}) });
259
266
  if (this.registration?.runner_id !== registration.runner_id) {
260
267
  log.info('registered', {
261
268
  runner_id: registration.runner_id,
@@ -266,6 +273,8 @@ export class Runner {
266
273
  this.noteLatestVersion(registration.latest_version, body.version);
267
274
  this.registration = registration;
268
275
  this.writeState();
276
+ // Recompute for the next registration (device change or the 5-minute refresh).
277
+ void this.deps.health?.refresh('register');
269
278
  return registration;
270
279
  }
271
280
  catch (error) {
@@ -318,6 +327,16 @@ export class Runner {
318
327
  })
319
328
  .catch((error) => log.warn('heartbeat failed', { job_id: jobId, error: redact(messageOf(error)) }));
320
329
  }, job.job.heartbeat_seconds * 1_000);
330
+ const progress = progressReporter(async (update) => {
331
+ try {
332
+ if (!(await client.heartbeat(jobId, update)))
333
+ cancelled = true;
334
+ }
335
+ catch (error) {
336
+ // An older server refuses the extra field or the network blipped; the timed heartbeat keeps the lease.
337
+ log.debug('progress report failed', { job_id: jobId, error: redact(messageOf(error)) });
338
+ }
339
+ });
321
340
  let videoPath;
322
341
  try {
323
342
  const ctx = jobContext(job, {
@@ -331,7 +350,7 @@ export class Runner {
331
350
  redact,
332
351
  log,
333
352
  });
334
- const report = await executeClaimedJob(registry, ctx, shared, () => cancelled || this.stopping);
353
+ const report = await executeClaimedJob(registry, ctx, shared, () => cancelled || this.stopping, progress.report);
335
354
  videoPath = report.videoPath;
336
355
  if (cancelled) {
337
356
  log.info('job cancelled', { job_id: jobId });
@@ -343,6 +362,13 @@ export class Runner {
343
362
  this.jobFinished(job, 'error', 'the runner shut down before the job finished', [], report.elapsedSecs);
344
363
  return;
345
364
  }
365
+ const passedSteps = report.steps.filter((step) => step.result === 'passed').length;
366
+ progress.report({
367
+ phase: 'uploading',
368
+ step_count: Math.max(job.item.steps.length, report.steps.length),
369
+ steps_passed: passedSteps,
370
+ steps_failed: report.steps.length - passedSteps,
371
+ });
346
372
  const stepResults = [];
347
373
  for (const step of report.steps) {
348
374
  const attachments = [];
@@ -398,6 +424,9 @@ export class Runner {
398
424
  catch (error) {
399
425
  const reason = redact(messageOf(error));
400
426
  log.error('job could not run', { job_id: jobId, error: reason });
427
+ // A job that could not start often means the machine's setup changed (a driver, a
428
+ // device, a permission); refresh the report so the runners page says what broke.
429
+ void this.deps.health?.refresh('job_failed');
401
430
  await client
402
431
  .complete({ job_id: jobId, error: reason })
403
432
  .catch((e) => log.error('could not report the failure', { job_id: jobId, error: redact(messageOf(e)) }));
@@ -405,6 +434,7 @@ export class Runner {
405
434
  }
406
435
  finally {
407
436
  clearInterval(heartbeat);
437
+ progress.stop();
408
438
  this.running.delete(jobId);
409
439
  // Each job records into its own temporary directory.
410
440
  if (videoPath)
@@ -491,6 +521,10 @@ export async function createRunner(options) {
491
521
  host,
492
522
  shared,
493
523
  log: options.log,
524
+ health: new HealthMonitor({
525
+ compute: () => healthReport({ env, paths, registry, secrets: [config.token] }),
526
+ log: options.log,
527
+ }),
494
528
  ...(options.useHome === false ? {} : { paths }),
495
529
  ...(options.writeState ? { statePath: paths.state } : {}),
496
530
  });
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.2",
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",