@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.
- package/dist/src/client.js +5 -2
- package/dist/src/executor.js +21 -3
- package/dist/src/progress.js +32 -0
- package/dist/src/runner.js +20 -1
- package/package.json +1 -1
package/dist/src/client.js
CHANGED
|
@@ -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', {
|
|
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') {
|
package/dist/src/executor.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/src/runner.js
CHANGED
|
@@ -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