@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.
- package/dist/src/cli/commands/ci.js +2 -2
- package/dist/src/client.js +5 -3
- package/dist/src/debug.js +13 -0
- package/dist/src/devices/manager.js +18 -0
- package/dist/src/index.js +7 -2
- package/dist/src/runner.js +18 -10
- package/dist/src/session/execute.js +4 -2
- package/dist/src/template.js +11 -0
- package/package.json +1 -1
|
@@ -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;
|
package/dist/src/client.js
CHANGED
|
@@ -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 =
|
|
26
|
+
process.exitCode = exitCodeFor(error);
|
|
22
27
|
});
|
package/dist/src/runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/src/template.js
CHANGED
|
@@ -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