@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.
- package/dist/src/cli/commands/ci.js +2 -2
- package/dist/src/client.js +9 -4
- package/dist/src/debug.js +13 -0
- package/dist/src/devices/manager.js +18 -0
- package/dist/src/executor.js +21 -3
- package/dist/src/index.js +7 -2
- package/dist/src/progress.js +32 -0
- package/dist/src/runner.js +37 -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,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', {
|
|
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);
|
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) {
|
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
|
});
|
|
@@ -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
|
@@ -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
|
-
|
|
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
|
-
|
|
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