@leera.io/qa-runner 1.0.2 → 1.0.4

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,19 +50,20 @@ export class RunnerClient {
49
50
  return { result: 'unexpected', status: error.status, message: error.message };
50
51
  }
51
52
  }
52
- async heartbeat(jobId, progress) {
53
+ async heartbeat(jobId, progress, attempt) {
53
54
  const response = await this.json('/api/v1/qa/runner/heartbeat', {
54
55
  job_id: jobId,
56
+ ...(attempt ? { attempt } : {}),
55
57
  ...(progress ? { progress } : {}),
56
58
  });
57
59
  return response?.continue ?? false;
58
60
  }
59
- async uploadEvidence(jobId, name, bytes, contentType = 'image/png') {
61
+ async uploadEvidence(jobId, name, bytes, contentType = 'image/png', attempt) {
60
62
  const form = new FormData();
61
63
  form.append('file', new Blob([new Uint8Array(bytes)], { type: contentType }), name);
62
64
  // A video can take a while to send over a slow uplink.
63
65
  const timeoutMs = contentType.startsWith('video/') ? 10 * 60_000 : 60_000;
64
- 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));
65
67
  }
66
68
  async complete(body) {
67
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);
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
  });
@@ -5,7 +5,7 @@ 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';
@@ -115,7 +115,7 @@ export class Runner {
115
115
  }
116
116
  let claim;
117
117
  try {
118
- 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() });
119
119
  }
120
120
  catch (error) {
121
121
  if (error instanceof ApiError && (error.status === 401 || error.status === 403))
@@ -174,7 +174,7 @@ export class Runner {
174
174
  if (!lease) {
175
175
  void this.deps.health?.refresh('job_failed');
176
176
  await client
177
- .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` })
178
178
  .catch((error) => log.error('could not report the failure', { job_id: job.job.id, error: messageOf(error) }));
179
179
  return null;
180
180
  }
@@ -312,6 +312,7 @@ export class Runner {
312
312
  const { client, log, registry, shared } = this.deps;
313
313
  const config = this.config;
314
314
  const jobId = job.job.id;
315
+ const attempt = job.job.attempt;
315
316
  const platform = lease.device.platform;
316
317
  const redact = redactor([job.credential?.password]);
317
318
  let cancelled = false;
@@ -320,7 +321,7 @@ export class Runner {
320
321
  log.info('job started', { job_id: jobId, item_id: job.item.id, attempt: job.job.attempt, platform, device: lease.device.id });
321
322
  const heartbeat = setInterval(() => {
322
323
  client
323
- .heartbeat(jobId)
324
+ .heartbeat(jobId, undefined, attempt)
324
325
  .then((keepGoing) => {
325
326
  if (!keepGoing)
326
327
  cancelled = true;
@@ -329,7 +330,7 @@ export class Runner {
329
330
  }, job.job.heartbeat_seconds * 1_000);
330
331
  const progress = progressReporter(async (update) => {
331
332
  try {
332
- if (!(await client.heartbeat(jobId, update)))
333
+ if (!(await client.heartbeat(jobId, update, attempt)))
333
334
  cancelled = true;
334
335
  }
335
336
  catch (error) {
@@ -358,7 +359,7 @@ export class Runner {
358
359
  return;
359
360
  }
360
361
  if (report.stopped) {
361
- 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' });
362
363
  this.jobFinished(job, 'error', 'the runner shut down before the job finished', [], report.elapsedSecs);
363
364
  return;
364
365
  }
@@ -370,13 +371,15 @@ export class Runner {
370
371
  steps_failed: report.steps.length - passedSteps,
371
372
  });
372
373
  const stepResults = [];
374
+ let lostScreenshots = 0;
373
375
  for (const step of report.steps) {
374
376
  const attachments = [];
375
377
  for (const shot of step.screenshots) {
376
378
  try {
377
- attachments.push(await client.uploadEvidence(jobId, shot.name, shot.png));
379
+ attachments.push(await client.uploadEvidence(jobId, shot.name, shot.png, 'image/png', attempt));
378
380
  }
379
381
  catch (error) {
382
+ lostScreenshots++;
380
383
  log.warn('evidence upload failed', { job_id: jobId, error: redact(messageOf(error)) });
381
384
  }
382
385
  }
@@ -392,6 +395,9 @@ export class Runner {
392
395
  const notes = [
393
396
  `Automated run on ${job.environment.name} by runner ${config.name}: ${passed} of ${job.item.steps.length} steps passed.`,
394
397
  ];
398
+ if (lostScreenshots > 0) {
399
+ notes.push(`${lostScreenshots} screenshot${lostScreenshots === 1 ? '' : 's'} could not be uploaded.`);
400
+ }
395
401
  const evidence = [];
396
402
  const mode = job.capture?.video ?? 'off';
397
403
  const troubled = report.steps.some((step) => step.result !== 'passed');
@@ -403,7 +409,7 @@ export class Runner {
403
409
  else {
404
410
  try {
405
411
  const mp4 = videoPath.toLowerCase().endsWith('.mp4');
406
- 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));
407
413
  }
408
414
  catch (error) {
409
415
  log.warn('video upload failed', { job_id: jobId, error: redact(messageOf(error)) });
@@ -413,6 +419,7 @@ export class Runner {
413
419
  }
414
420
  const outcome = await client.complete({
415
421
  job_id: jobId,
422
+ attempt,
416
423
  step_results: stepResults,
417
424
  elapsed_secs: report.elapsedSecs,
418
425
  notes: notes.join(' '),
@@ -428,7 +435,7 @@ export class Runner {
428
435
  // device, a permission); refresh the report so the runners page says what broke.
429
436
  void this.deps.health?.refresh('job_failed');
430
437
  await client
431
- .complete({ job_id: jobId, error: reason })
438
+ .complete({ job_id: jobId, attempt, error: reason })
432
439
  .catch((e) => log.error('could not report the failure', { job_id: jobId, error: redact(messageOf(e)) }));
433
440
  this.jobFinished(job, 'error', reason, [reason], 0);
434
441
  }
@@ -497,7 +504,8 @@ export async function createRunner(options) {
497
504
  const home = options.useHome === false ? { file: null, token: null, fixedPermissions: false } : loadFileConfig(paths);
498
505
  if (home.fixedPermissions)
499
506
  options.log.warn('the token file was readable by others; its permissions were set to 0600', { path: paths.token });
500
- 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));
501
509
  const host = hostInfo();
502
510
  const detection = await detect(registry, host);
503
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.2",
3
+ "version": "1.0.4",
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",