@leera.io/qa-runner 1.0.1 → 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.
@@ -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,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
+ }
@@ -11,6 +11,7 @@ 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';
@@ -326,6 +327,16 @@ export class Runner {
326
327
  })
327
328
  .catch((error) => log.warn('heartbeat failed', { job_id: jobId, error: redact(messageOf(error)) }));
328
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
+ });
329
340
  let videoPath;
330
341
  try {
331
342
  const ctx = jobContext(job, {
@@ -339,7 +350,7 @@ export class Runner {
339
350
  redact,
340
351
  log,
341
352
  });
342
- const report = await executeClaimedJob(registry, ctx, shared, () => cancelled || this.stopping);
353
+ const report = await executeClaimedJob(registry, ctx, shared, () => cancelled || this.stopping, progress.report);
343
354
  videoPath = report.videoPath;
344
355
  if (cancelled) {
345
356
  log.info('job cancelled', { job_id: jobId });
@@ -351,6 +362,13 @@ export class Runner {
351
362
  this.jobFinished(job, 'error', 'the runner shut down before the job finished', [], report.elapsedSecs);
352
363
  return;
353
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
+ });
354
372
  const stepResults = [];
355
373
  for (const step of report.steps) {
356
374
  const attachments = [];
@@ -416,6 +434,7 @@ export class Runner {
416
434
  }
417
435
  finally {
418
436
  clearInterval(heartbeat);
437
+ progress.stop();
419
438
  this.running.delete(jobId);
420
439
  // Each job records into its own temporary directory.
421
440
  if (videoPath)
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.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",