@leera.io/qa-runner 1.0.1 → 1.0.3

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.
@@ -4,7 +4,7 @@ import { splitList } from '../../config.js';
4
4
  import { formatDoctor, runDoctor } from '../../doctor.js';
5
5
  import { shutdownEmulatorsBootedHere } from '../../devices/android/emulator.js';
6
6
  import { stopSharedAppiumServer } from '../../drivers/android-appium.js';
7
- import { createRegistry } from '../../drivers/registry.js';
7
+ import { createRegistry, defaultFactories } from '../../drivers/registry.js';
8
8
  import { BrowserPool } from '../../drivers/web-playwright.js';
9
9
  import { homePaths } from '../../home.js';
10
10
  import { createLogger } from '../../log.js';
@@ -115,7 +115,7 @@ export const command = {
115
115
  const paths = homePaths(io.env);
116
116
  // CI logs are read by people: readable lines, not JSON.
117
117
  const log = createLogger({ console: 'pretty' });
118
- const registry = createRegistry();
118
+ const registry = createRegistry(defaultFactories(io.env));
119
119
  const shared = { browsers: new BrowserPool({ headless: true }) };
120
120
  const interrupt = new AbortController();
121
121
  let signals = 0;
@@ -24,6 +24,7 @@ export class RunnerClient {
24
24
  runner_id: runnerId,
25
25
  ...(options.runId != null ? { run_id: options.runId } : {}),
26
26
  ...(options.accept ? { accept: options.accept } : {}),
27
+ ...(options.busyDevices?.length ? { busy_devices: options.busyDevices } : {}),
27
28
  });
28
29
  }
29
30
  /**
@@ -49,16 +50,20 @@ export class RunnerClient {
49
50
  return { result: 'unexpected', status: error.status, message: error.message };
50
51
  }
51
52
  }
52
- async heartbeat(jobId) {
53
- const response = await this.json('/api/v1/qa/runner/heartbeat', { job_id: jobId });
53
+ async heartbeat(jobId, progress, attempt) {
54
+ const response = await this.json('/api/v1/qa/runner/heartbeat', {
55
+ job_id: jobId,
56
+ ...(attempt ? { attempt } : {}),
57
+ ...(progress ? { progress } : {}),
58
+ });
54
59
  return response?.continue ?? false;
55
60
  }
56
- async uploadEvidence(jobId, name, bytes, contentType = 'image/png') {
61
+ async uploadEvidence(jobId, name, bytes, contentType = 'image/png', attempt) {
57
62
  const form = new FormData();
58
63
  form.append('file', new Blob([new Uint8Array(bytes)], { type: contentType }), name);
59
64
  // A video can take a while to send over a slow uplink.
60
65
  const timeoutMs = contentType.startsWith('video/') ? 10 * 60_000 : 60_000;
61
- return this.expect(await this.request(`/api/v1/qa/runner/evidence?job_id=${jobId}`, { method: 'POST', body: form }, timeoutMs));
66
+ return this.expect(await this.request(`/api/v1/qa/runner/evidence?job_id=${jobId}${attempt ? `&attempt=${attempt}` : ''}`, { method: 'POST', body: form }, timeoutMs));
62
67
  }
63
68
  async complete(body) {
64
69
  return this.expect(await this.json('/api/v1/qa/runner/complete', body));
package/dist/src/debug.js CHANGED
@@ -133,5 +133,18 @@ export function maskUrl(raw) {
133
133
  if (SENSITIVE_PARAM.test(name))
134
134
  url.searchParams.set(name, '***');
135
135
  }
136
+ // OAuth implicit and magic-link flows put tokens in the fragment (`#access_token=…`).
137
+ if (url.hash.includes('=')) {
138
+ const fragment = new URLSearchParams(url.hash.slice(1));
139
+ let changed = false;
140
+ for (const name of [...fragment.keys()]) {
141
+ if (SENSITIVE_PARAM.test(name)) {
142
+ fragment.set(name, '***');
143
+ changed = true;
144
+ }
145
+ }
146
+ if (changed)
147
+ url.hash = fragment.toString();
148
+ }
136
149
  return url.toString();
137
150
  }
@@ -146,6 +146,24 @@ export class DeviceManager {
146
146
  },
147
147
  };
148
148
  }
149
+ /**
150
+ * Devices with no free slot, under every id their leases carry (an emulator is leased as
151
+ * `avd:<name>` and runs as `emulator-5554`). Sent with each claim so the server does not
152
+ * hand out a device this runner is still releasing — a cancelled job's, for instance.
153
+ */
154
+ full() {
155
+ const ids = new Set();
156
+ for (const slot of this.slots.values()) {
157
+ if (this.used(slot.device.id) >= slot.capacity)
158
+ ids.add(slot.device.id);
159
+ }
160
+ for (const record of this.leases.values()) {
161
+ if (record.platform !== 'web')
162
+ for (const id of record.ids)
163
+ ids.add(id);
164
+ }
165
+ return [...ids];
166
+ }
149
167
  busy() {
150
168
  return [...this.slots.values()].map((slot) => {
151
169
  const holders = this.holdersOf(slot.device.id);
@@ -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) {
package/dist/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Docker entry: starts the runner from the environment (RUNNER_URL, RUNNER_TOKEN, …).
2
2
  import { stopSharedAppiumServer } from './drivers/android-appium.js';
3
- import { messageOf } from './errors.js';
3
+ import { ExitCode, ExitError, messageOf } from './errors.js';
4
4
  import { createLogger } from './log.js';
5
5
  import { createRunner } from './runner.js';
6
6
  const log = createLogger({ console: 'json' });
@@ -16,7 +16,12 @@ async function main() {
16
16
  await stopSharedAppiumServer();
17
17
  }
18
18
  }
19
+ function exitCodeFor(error) {
20
+ // The same codes as the CLI (3: token rejected, 5: server needs a newer runner), so a
21
+ // container's exit status says why it stopped, as docs/test-runners.md promises.
22
+ return error instanceof ExitError ? error.code : ExitCode.error;
23
+ }
19
24
  main().catch((error) => {
20
25
  log.error('runner stopped', { error: messageOf(error) });
21
- process.exitCode = 1;
26
+ process.exitCode = exitCodeFor(error);
22
27
  });
@@ -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
+ }
@@ -5,12 +5,13 @@ import { ApiError, RunnerClient } from './client.js';
5
5
  import { loadFileConfig, resolveConfig } from './config.js';
6
6
  import { DeviceManager, INVENTORY_REFRESH_MS } from './devices/manager.js';
7
7
  import { BROWSER_DEVICE_ID, isPlatform } from './devices/types.js';
8
- import { createRegistry } from './drivers/registry.js';
8
+ import { createRegistry, defaultFactories } 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
12
  import { HealthMonitor, healthReport } from './health.js';
13
13
  import { homePaths, readJsonFile, writePrivateFile } from './home.js';
14
+ import { progressReporter } from './progress.js';
14
15
  import { redactor } from './template.js';
15
16
  import { runSession } from './session/loop.js';
16
17
  import { isSessionClaim } from './types.js';
@@ -114,7 +115,7 @@ export class Runner {
114
115
  }
115
116
  let claim;
116
117
  try {
117
- claim = await client.claim(registration.runner_id, { accept, runId: this.config.runId });
118
+ claim = await client.claim(registration.runner_id, { accept, runId: this.config.runId, busyDevices: devices.full() });
118
119
  }
119
120
  catch (error) {
120
121
  if (error instanceof ApiError && (error.status === 401 || error.status === 403))
@@ -173,7 +174,7 @@ export class Runner {
173
174
  if (!lease) {
174
175
  void this.deps.health?.refresh('job_failed');
175
176
  await client
176
- .complete({ job_id: job.job.id, error: `this runner has no free ${platform} device for the job` })
177
+ .complete({ job_id: job.job.id, attempt: job.job.attempt, error: `this runner has no free ${platform} device for the job` })
177
178
  .catch((error) => log.error('could not report the failure', { job_id: job.job.id, error: messageOf(error) }));
178
179
  return null;
179
180
  }
@@ -311,6 +312,7 @@ export class Runner {
311
312
  const { client, log, registry, shared } = this.deps;
312
313
  const config = this.config;
313
314
  const jobId = job.job.id;
315
+ const attempt = job.job.attempt;
314
316
  const platform = lease.device.platform;
315
317
  const redact = redactor([job.credential?.password]);
316
318
  let cancelled = false;
@@ -319,13 +321,23 @@ export class Runner {
319
321
  log.info('job started', { job_id: jobId, item_id: job.item.id, attempt: job.job.attempt, platform, device: lease.device.id });
320
322
  const heartbeat = setInterval(() => {
321
323
  client
322
- .heartbeat(jobId)
324
+ .heartbeat(jobId, undefined, attempt)
323
325
  .then((keepGoing) => {
324
326
  if (!keepGoing)
325
327
  cancelled = true;
326
328
  })
327
329
  .catch((error) => log.warn('heartbeat failed', { job_id: jobId, error: redact(messageOf(error)) }));
328
330
  }, job.job.heartbeat_seconds * 1_000);
331
+ const progress = progressReporter(async (update) => {
332
+ try {
333
+ if (!(await client.heartbeat(jobId, update, attempt)))
334
+ cancelled = true;
335
+ }
336
+ catch (error) {
337
+ // An older server refuses the extra field or the network blipped; the timed heartbeat keeps the lease.
338
+ log.debug('progress report failed', { job_id: jobId, error: redact(messageOf(error)) });
339
+ }
340
+ });
329
341
  let videoPath;
330
342
  try {
331
343
  const ctx = jobContext(job, {
@@ -339,7 +351,7 @@ export class Runner {
339
351
  redact,
340
352
  log,
341
353
  });
342
- const report = await executeClaimedJob(registry, ctx, shared, () => cancelled || this.stopping);
354
+ const report = await executeClaimedJob(registry, ctx, shared, () => cancelled || this.stopping, progress.report);
343
355
  videoPath = report.videoPath;
344
356
  if (cancelled) {
345
357
  log.info('job cancelled', { job_id: jobId });
@@ -347,18 +359,27 @@ export class Runner {
347
359
  return;
348
360
  }
349
361
  if (report.stopped) {
350
- await client.complete({ job_id: jobId, error: 'the runner shut down before the job finished' });
362
+ await client.complete({ job_id: jobId, attempt, error: 'the runner shut down before the job finished' });
351
363
  this.jobFinished(job, 'error', 'the runner shut down before the job finished', [], report.elapsedSecs);
352
364
  return;
353
365
  }
366
+ const passedSteps = report.steps.filter((step) => step.result === 'passed').length;
367
+ progress.report({
368
+ phase: 'uploading',
369
+ step_count: Math.max(job.item.steps.length, report.steps.length),
370
+ steps_passed: passedSteps,
371
+ steps_failed: report.steps.length - passedSteps,
372
+ });
354
373
  const stepResults = [];
374
+ let lostScreenshots = 0;
355
375
  for (const step of report.steps) {
356
376
  const attachments = [];
357
377
  for (const shot of step.screenshots) {
358
378
  try {
359
- attachments.push(await client.uploadEvidence(jobId, shot.name, shot.png));
379
+ attachments.push(await client.uploadEvidence(jobId, shot.name, shot.png, 'image/png', attempt));
360
380
  }
361
381
  catch (error) {
382
+ lostScreenshots++;
362
383
  log.warn('evidence upload failed', { job_id: jobId, error: redact(messageOf(error)) });
363
384
  }
364
385
  }
@@ -374,6 +395,9 @@ export class Runner {
374
395
  const notes = [
375
396
  `Automated run on ${job.environment.name} by runner ${config.name}: ${passed} of ${job.item.steps.length} steps passed.`,
376
397
  ];
398
+ if (lostScreenshots > 0) {
399
+ notes.push(`${lostScreenshots} screenshot${lostScreenshots === 1 ? '' : 's'} could not be uploaded.`);
400
+ }
377
401
  const evidence = [];
378
402
  const mode = job.capture?.video ?? 'off';
379
403
  const troubled = report.steps.some((step) => step.result !== 'passed');
@@ -385,7 +409,7 @@ export class Runner {
385
409
  else {
386
410
  try {
387
411
  const mp4 = videoPath.toLowerCase().endsWith('.mp4');
388
- evidence.push(await client.uploadEvidence(jobId, mp4 ? 'case-video.mp4' : 'case-video.webm', await readFile(videoPath), mp4 ? 'video/mp4' : 'video/webm'));
412
+ evidence.push(await client.uploadEvidence(jobId, mp4 ? 'case-video.mp4' : 'case-video.webm', await readFile(videoPath), mp4 ? 'video/mp4' : 'video/webm', attempt));
389
413
  }
390
414
  catch (error) {
391
415
  log.warn('video upload failed', { job_id: jobId, error: redact(messageOf(error)) });
@@ -395,6 +419,7 @@ export class Runner {
395
419
  }
396
420
  const outcome = await client.complete({
397
421
  job_id: jobId,
422
+ attempt,
398
423
  step_results: stepResults,
399
424
  elapsed_secs: report.elapsedSecs,
400
425
  notes: notes.join(' '),
@@ -410,12 +435,13 @@ export class Runner {
410
435
  // device, a permission); refresh the report so the runners page says what broke.
411
436
  void this.deps.health?.refresh('job_failed');
412
437
  await client
413
- .complete({ job_id: jobId, error: reason })
438
+ .complete({ job_id: jobId, attempt, error: reason })
414
439
  .catch((e) => log.error('could not report the failure', { job_id: jobId, error: redact(messageOf(e)) }));
415
440
  this.jobFinished(job, 'error', reason, [reason], 0);
416
441
  }
417
442
  finally {
418
443
  clearInterval(heartbeat);
444
+ progress.stop();
419
445
  this.running.delete(jobId);
420
446
  // Each job records into its own temporary directory.
421
447
  if (videoPath)
@@ -478,7 +504,8 @@ export async function createRunner(options) {
478
504
  const home = options.useHome === false ? { file: null, token: null, fixedPermissions: false } : loadFileConfig(paths);
479
505
  if (home.fixedPermissions)
480
506
  options.log.warn('the token file was readable by others; its permissions were set to 0600', { path: paths.token });
481
- const registry = options.registry ?? createRegistry();
507
+ // The factories follow the runner's environment (not the process's): `RUNNER_DOCKER=1` is web-only.
508
+ const registry = options.registry ?? createRegistry(defaultFactories(env));
482
509
  const host = hostInfo();
483
510
  const detection = await detect(registry, host);
484
511
  for (const [platform, reasons] of Object.entries(detection.unavailable)) {
@@ -1,6 +1,6 @@
1
1
  import { Blocked, NotFound, Unsupported } from '../drivers/driver.js';
2
2
  import { clampMaxChars, emptyScreen, toSnapshotResult } from '../drivers/snapshot/format.js';
3
- import { TemplateError } from '../template.js';
3
+ import { redactDeep, TemplateError } from '../template.js';
4
4
  import { actionTimeoutMs, CommandError, DEFAULT_COMMAND_TIMEOUT_MS, hardTimeoutMs, validateCommand, } from './commands.js';
5
5
  export const MAX_LOG_LINES = 50;
6
6
  export const MAX_LOG_CHARS = 500;
@@ -66,7 +66,9 @@ export class SessionExecutor {
66
66
  result.logs = this.logs();
67
67
  const screenshot = wantsScreenshot ? await this.driver.screenshot().catch(() => null) : null;
68
68
  result.duration_ms = Date.now() - started;
69
- return { result, screenshot, end };
69
+ // Everything a session sends is read by an agent and stored; a password shown on the
70
+ // page can reach any field (element refs, suggestions, the page title), not only the text.
71
+ return { result: redactDeep(result, this.redact), screenshot, end };
70
72
  }
71
73
  async perform(command, result) {
72
74
  const actions = this.driver.capabilities.actions;
@@ -17,6 +17,17 @@ export function redactor(secrets) {
17
17
  .sort((a, b) => b.length - a.length);
18
18
  return (text) => values.reduce((out, secret) => out.split(secret).join('[redacted]'), text);
19
19
  }
20
+ /** Applies `redact` to every string inside `value` (object keys included), leaving its shape alone. */
21
+ export function redactDeep(value, redact) {
22
+ if (typeof value === 'string')
23
+ return redact(value);
24
+ if (Array.isArray(value))
25
+ return value.map((item) => redactDeep(item, redact));
26
+ if (value && typeof value === 'object') {
27
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [redact(k), redactDeep(v, redact)]));
28
+ }
29
+ return value;
30
+ }
20
31
  function placeholderValue(name, ctx) {
21
32
  switch (name) {
22
33
  case 'env.base_url':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leera.io/qa-runner",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
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",