browser-commander 0.17.2 → 0.18.0
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/.jscpd-baseline.json +4 -17
- package/CHANGELOG.md +34 -0
- package/README.md +84 -0
- package/eslint.config.js +5 -0
- package/package.json +4 -1
- package/src/bindings.js +13 -1
- package/src/browser/connector.js +15 -3
- package/src/browser/launcher.js +14 -2
- package/src/browser/navigation.js +63 -0
- package/src/core/dialog-manager.js +48 -0
- package/src/core/navigation-manager.js +83 -87
- package/src/core/navigation-readiness.js +133 -0
- package/src/core/readiness.js +478 -0
- package/src/downloads/attach.js +58 -0
- package/src/downloads/destination.js +125 -0
- package/src/downloads/manager.js +453 -0
- package/src/downloads/naming.js +246 -0
- package/src/downloads/sources.js +188 -0
- package/src/downloads/store.js +256 -0
- package/src/exports.js +72 -0
- package/src/factory.js +55 -0
- package/src/interactions/click-activation.js +317 -0
- package/src/interactions/click-result.js +108 -0
- package/src/interactions/click.js +766 -352
- package/src/tests/index.js +75 -11
- package/src/tests/tracing.js +236 -0
- package/src/traces/bundle.js +323 -0
- package/src/traces/observers.js +246 -0
- package/src/traces/page-capture.js +365 -0
- package/src/traces/reader.js +226 -0
- package/src/traces/recorder.js +437 -0
- package/src/traces/redaction.js +231 -0
- package/src/traces/schema.js +165 -0
- package/src/traces/viewer.js +302 -0
- package/tests/e2e/click-readiness.e2e.test.js +240 -0
- package/tests/e2e/downloads.e2e.test.js +188 -0
- package/tests/e2e/traces.e2e.test.js +457 -0
- package/tests/helpers/click-fixtures.js +62 -0
- package/tests/helpers/download-fixtures.js +125 -0
- package/tests/helpers/download-server.js +109 -0
- package/tests/helpers/e2e-browser.js +67 -0
- package/tests/helpers/file-modes.js +31 -0
- package/tests/helpers/fixture-server.js +56 -0
- package/tests/helpers/mocks.js +63 -66
- package/tests/helpers/readiness-server.js +96 -0
- package/tests/helpers/temp-directory.js +44 -0
- package/tests/helpers/trace-fixtures.js +198 -0
- package/tests/helpers/trace-server.js +117 -0
- package/tests/unit/core/dialog-manager.test.js +118 -10
- package/tests/unit/core/navigation-manager.test.js +162 -0
- package/tests/unit/core/readiness.test.js +517 -0
- package/tests/unit/downloads/attach.test.js +220 -0
- package/tests/unit/downloads/destination.test.js +114 -0
- package/tests/unit/downloads/manager.test.js +354 -0
- package/tests/unit/downloads/naming.test.js +174 -0
- package/tests/unit/downloads/store.test.js +215 -0
- package/tests/unit/interactions/click-activation.test.js +283 -0
- package/tests/unit/interactions/click-result.test.js +90 -0
- package/tests/unit/interactions/click.test.js +409 -200
- package/tests/unit/tests/tracing.test.js +299 -0
- package/tests/unit/traces/bundle.test.js +260 -0
- package/tests/unit/traces/reader.test.js +208 -0
- package/tests/unit/traces/recorder.test.js +562 -0
- package/tests/unit/traces/redaction.test.js +170 -0
- package/tests/unit/traces/schema.test.js +92 -0
- package/tests/unit/traces/viewer.test.js +289 -0
package/src/tests/index.js
CHANGED
|
@@ -11,6 +11,25 @@ import path from 'node:path';
|
|
|
11
11
|
import { test as baseTest } from 'test-anywhere';
|
|
12
12
|
import { launchBrowser } from '../browser/launcher.js';
|
|
13
13
|
import { makeBrowserCommander } from '../factory.js';
|
|
14
|
+
import {
|
|
15
|
+
finishScenarioTrace,
|
|
16
|
+
resolveTestDownloads,
|
|
17
|
+
startScenarioTrace,
|
|
18
|
+
} from './tracing.js';
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
TEST_SCREENSHOT_MODES,
|
|
22
|
+
TEST_TRACE_MODES,
|
|
23
|
+
TRACE_BUNDLE_SUFFIX,
|
|
24
|
+
TEST_DOWNLOADS_DIRNAME,
|
|
25
|
+
finishScenarioTrace,
|
|
26
|
+
normalizeTestScreenshots,
|
|
27
|
+
normalizeTestTrace,
|
|
28
|
+
resolveTestDownloads,
|
|
29
|
+
resolveTraceSetting,
|
|
30
|
+
startScenarioTrace,
|
|
31
|
+
traceOutputPath,
|
|
32
|
+
} from './tracing.js';
|
|
14
33
|
|
|
15
34
|
export {
|
|
16
35
|
test,
|
|
@@ -406,11 +425,14 @@ export async function createBrowserFixture(options = {}) {
|
|
|
406
425
|
...launchOptions,
|
|
407
426
|
engine: normalizedEngine,
|
|
408
427
|
});
|
|
409
|
-
const { browser, page } = launched;
|
|
428
|
+
const { browser, page, downloads = null } = launched;
|
|
410
429
|
|
|
411
430
|
try {
|
|
412
431
|
const commander = makeCommander({
|
|
413
432
|
page,
|
|
433
|
+
// A managed download manager belongs to the commander that runs the
|
|
434
|
+
// test, so `commander.downloads` and the trace see the same files.
|
|
435
|
+
...(downloads ? { downloads } : {}),
|
|
414
436
|
...commanderOptions,
|
|
415
437
|
});
|
|
416
438
|
|
|
@@ -418,6 +440,7 @@ export async function createBrowserFixture(options = {}) {
|
|
|
418
440
|
engine: normalizedEngine,
|
|
419
441
|
browser,
|
|
420
442
|
page,
|
|
443
|
+
downloads,
|
|
421
444
|
commander,
|
|
422
445
|
async close() {
|
|
423
446
|
if (typeof commander.destroy === 'function') {
|
|
@@ -563,36 +586,67 @@ export function runBrowserScenario(scenario, options = {}) {
|
|
|
563
586
|
launch,
|
|
564
587
|
makeCommander,
|
|
565
588
|
artifactsDir = DEFAULT_ARTIFACTS_DIR,
|
|
589
|
+
trace = 'off',
|
|
590
|
+
screenshots,
|
|
591
|
+
traceOptions = {},
|
|
566
592
|
timeoutMs,
|
|
567
593
|
attempt = 1,
|
|
568
594
|
} = options;
|
|
569
595
|
const testId = browserTestId(normalizedScenario, engine);
|
|
596
|
+
const scenarioArtifactsDir = normalizedScenario.artifactsDir ?? artifactsDir;
|
|
597
|
+
const safeName = sanitizeArtifactName(`${testId}-${engine}`);
|
|
598
|
+
const mergedLaunchOptions = mergeOptions(
|
|
599
|
+
launchOptions,
|
|
600
|
+
normalizedScenario.launchOptions
|
|
601
|
+
);
|
|
602
|
+
// A download a test made is an artifact of that test, so it is kept beside
|
|
603
|
+
// the trace that references it rather than in the user's Downloads folder.
|
|
604
|
+
const downloads = resolveTestDownloads(mergedLaunchOptions.downloads, {
|
|
605
|
+
artifactsDir: scenarioArtifactsDir,
|
|
606
|
+
safeName,
|
|
607
|
+
});
|
|
570
608
|
|
|
571
609
|
return withBrowserCommander(
|
|
572
610
|
{
|
|
573
611
|
engine,
|
|
574
612
|
launch,
|
|
575
613
|
makeCommander,
|
|
576
|
-
launchOptions:
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
),
|
|
614
|
+
launchOptions: downloads
|
|
615
|
+
? { ...mergedLaunchOptions, downloads }
|
|
616
|
+
: mergedLaunchOptions,
|
|
580
617
|
commanderOptions: mergeOptions(
|
|
581
618
|
commanderOptions,
|
|
582
619
|
normalizedScenario.commanderOptions
|
|
583
620
|
),
|
|
584
621
|
},
|
|
585
622
|
async (fixture) => {
|
|
623
|
+
const started = await startScenarioTrace({
|
|
624
|
+
commander: fixture.commander,
|
|
625
|
+
page: fixture.page,
|
|
626
|
+
trace: normalizedScenario.trace ?? trace,
|
|
627
|
+
screenshots: normalizedScenario.screenshots ?? screenshots,
|
|
628
|
+
artifactsDir: scenarioArtifactsDir,
|
|
629
|
+
safeName,
|
|
630
|
+
attempt,
|
|
631
|
+
traceOptions: mergeOptions(
|
|
632
|
+
traceOptions,
|
|
633
|
+
normalizedScenario.traceOptions
|
|
634
|
+
),
|
|
635
|
+
});
|
|
636
|
+
|
|
586
637
|
try {
|
|
587
|
-
|
|
638
|
+
const result = await runWithTimeout(
|
|
588
639
|
() =>
|
|
589
640
|
normalizedScenario.fn({
|
|
590
641
|
...fixture,
|
|
642
|
+
trace: started?.trace ?? null,
|
|
591
643
|
testInfo: {
|
|
592
644
|
id: testId,
|
|
593
645
|
name: normalizedScenario.name,
|
|
594
646
|
engine,
|
|
595
647
|
attempt,
|
|
648
|
+
artifactsDir: scenarioArtifactsDir,
|
|
649
|
+
tracePath: started?.path ?? null,
|
|
596
650
|
tags: normalizedScenario.tags || [],
|
|
597
651
|
},
|
|
598
652
|
}),
|
|
@@ -601,14 +655,24 @@ export function runBrowserScenario(scenario, options = {}) {
|
|
|
601
655
|
testId,
|
|
602
656
|
}
|
|
603
657
|
);
|
|
658
|
+
await finishScenarioTrace(started, { log: fixture.commander?.log });
|
|
659
|
+
return result;
|
|
604
660
|
} catch (error) {
|
|
605
|
-
await
|
|
606
|
-
page: fixture.page,
|
|
661
|
+
const stopped = await finishScenarioTrace(started, {
|
|
607
662
|
error,
|
|
608
|
-
|
|
609
|
-
engine,
|
|
610
|
-
artifactsDir: normalizedScenario.artifactsDir ?? artifactsDir,
|
|
663
|
+
log: fixture.commander?.log,
|
|
611
664
|
});
|
|
665
|
+
// The trace bundle is the failure artifact when one was recorded; the
|
|
666
|
+
// standalone error file and screenshot stay for runs without a trace.
|
|
667
|
+
if (!stopped) {
|
|
668
|
+
await writeFailureArtifacts({
|
|
669
|
+
page: fixture.page,
|
|
670
|
+
error,
|
|
671
|
+
testId,
|
|
672
|
+
engine,
|
|
673
|
+
artifactsDir: scenarioArtifactsDir,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
612
676
|
throw error;
|
|
613
677
|
}
|
|
614
678
|
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trace and download artifacts for the Browser Commander test runner
|
|
3
|
+
* (issues #87 and #88).
|
|
4
|
+
*
|
|
5
|
+
* A failing browser test is only useful if it can be read afterwards. The
|
|
6
|
+
* runner therefore records the same portable trace bundle every other
|
|
7
|
+
* consumer gets, instead of inventing a second artifact format, and points
|
|
8
|
+
* managed downloads at the same artifact directory so a trace can reference
|
|
9
|
+
* files that are still on disk.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { startTrace } from '../traces/recorder.js';
|
|
14
|
+
import { writeTraceViewer } from '../traces/viewer.js';
|
|
15
|
+
import { TRACE_MODE } from '../traces/schema.js';
|
|
16
|
+
|
|
17
|
+
/** Trace settings a scenario may ask for, in Playwright's vocabulary. */
|
|
18
|
+
export const TEST_TRACE_MODES = Object.freeze([
|
|
19
|
+
'off',
|
|
20
|
+
'on',
|
|
21
|
+
'retain-on-failure',
|
|
22
|
+
'on-first-retry',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/** Screenshot settings a scenario may ask for. */
|
|
26
|
+
export const TEST_SCREENSHOT_MODES = Object.freeze([
|
|
27
|
+
'off',
|
|
28
|
+
'on',
|
|
29
|
+
'only-on-failure',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/** Suffix that marks a trace bundle directory. */
|
|
33
|
+
export const TRACE_BUNDLE_SUFFIX = '.bc-trace';
|
|
34
|
+
|
|
35
|
+
/** Directory managed downloads use inside a test's artifact directory. */
|
|
36
|
+
export const TEST_DOWNLOADS_DIRNAME = 'downloads';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read a scenario's `trace` option.
|
|
40
|
+
*
|
|
41
|
+
* @param {boolean|string} [trace] - `true`, `false` or one of TEST_TRACE_MODES
|
|
42
|
+
* @returns {string} One of TEST_TRACE_MODES
|
|
43
|
+
*/
|
|
44
|
+
export function normalizeTestTrace(trace) {
|
|
45
|
+
if (trace === undefined || trace === null || trace === false) {
|
|
46
|
+
return 'off';
|
|
47
|
+
}
|
|
48
|
+
if (trace === true) {
|
|
49
|
+
return 'on';
|
|
50
|
+
}
|
|
51
|
+
if (!TEST_TRACE_MODES.includes(trace)) {
|
|
52
|
+
throw new Error(`trace must be one of ${TEST_TRACE_MODES.join(', ')}`);
|
|
53
|
+
}
|
|
54
|
+
return trace;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read a scenario's `screenshots` option.
|
|
59
|
+
*
|
|
60
|
+
* @param {boolean|string} [screenshots] - `true`, `false` or one of
|
|
61
|
+
* TEST_SCREENSHOT_MODES
|
|
62
|
+
* @returns {string} One of TEST_SCREENSHOT_MODES
|
|
63
|
+
*/
|
|
64
|
+
export function normalizeTestScreenshots(screenshots) {
|
|
65
|
+
if (screenshots === undefined || screenshots === null) {
|
|
66
|
+
return 'only-on-failure';
|
|
67
|
+
}
|
|
68
|
+
if (screenshots === true) {
|
|
69
|
+
return 'on';
|
|
70
|
+
}
|
|
71
|
+
if (screenshots === false) {
|
|
72
|
+
return 'off';
|
|
73
|
+
}
|
|
74
|
+
if (!TEST_SCREENSHOT_MODES.includes(screenshots)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`screenshots must be one of ${TEST_SCREENSHOT_MODES.join(', ')}`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return screenshots;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Decide whether this attempt records, and in which recorder mode.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} trace - A value from `normalizeTestTrace()`
|
|
86
|
+
* @param {number} attempt - 1 for the first run, 2 for the first retry
|
|
87
|
+
* @returns {{recorderMode: string, retainOnFailure: boolean}|null} The
|
|
88
|
+
* recorder settings, or null when this attempt does not record
|
|
89
|
+
*/
|
|
90
|
+
export function resolveTraceSetting(trace, attempt = 1) {
|
|
91
|
+
if (trace === 'off') {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
if (trace === 'on-first-retry' && attempt < 2) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
recorderMode:
|
|
99
|
+
trace === 'on' ? TRACE_MODE.CONTINUOUS : TRACE_MODE.RETAIN_ON_FAILURE,
|
|
100
|
+
retainOnFailure: trace !== 'on',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function screenshotSettingForRecorder(screenshots) {
|
|
105
|
+
if (screenshots === 'on') {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
if (screenshots === 'off') {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
return 'only-on-failure';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Where one attempt's trace bundle lives.
|
|
116
|
+
*
|
|
117
|
+
* @param {Object} options - `{artifactsDir, safeName, attempt}`
|
|
118
|
+
* @returns {string} The bundle directory
|
|
119
|
+
*/
|
|
120
|
+
export function traceOutputPath(options = {}) {
|
|
121
|
+
const { artifactsDir, safeName, attempt = 1 } = options;
|
|
122
|
+
const suffix = attempt > 1 ? `.attempt-${attempt}` : '';
|
|
123
|
+
return path.resolve(
|
|
124
|
+
artifactsDir,
|
|
125
|
+
`${safeName}${suffix}${TRACE_BUNDLE_SUFFIX}`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Point a scenario's managed downloads at its own artifact directory, so a
|
|
131
|
+
* completed download is still there when the trace that references it is read
|
|
132
|
+
* (issue #88).
|
|
133
|
+
*
|
|
134
|
+
* @param {boolean|Object} downloads - The scenario's `downloads` option
|
|
135
|
+
* @param {Object} options - `{artifactsDir, safeName}`
|
|
136
|
+
* @returns {boolean|Object|undefined} The download options to launch with
|
|
137
|
+
*/
|
|
138
|
+
export function resolveTestDownloads(downloads, options = {}) {
|
|
139
|
+
const { artifactsDir, safeName } = options;
|
|
140
|
+
if (!downloads || !artifactsDir) {
|
|
141
|
+
return downloads;
|
|
142
|
+
}
|
|
143
|
+
const settings = downloads === true ? {} : downloads;
|
|
144
|
+
if (settings.directory) {
|
|
145
|
+
return settings;
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
...settings,
|
|
149
|
+
directory: path.resolve(artifactsDir, safeName, TEST_DOWNLOADS_DIRNAME),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Start a trace for one scenario attempt.
|
|
155
|
+
*
|
|
156
|
+
* @param {Object} options - `{commander, page, trace, screenshots,
|
|
157
|
+
* artifactsDir, safeName, attempt, traceOptions}`
|
|
158
|
+
* @returns {Promise<Object|null>} `{trace, path, retainOnFailure}`, or null
|
|
159
|
+
* when this attempt does not record
|
|
160
|
+
*/
|
|
161
|
+
export async function startScenarioTrace(options = {}) {
|
|
162
|
+
const {
|
|
163
|
+
commander,
|
|
164
|
+
page,
|
|
165
|
+
trace,
|
|
166
|
+
screenshots,
|
|
167
|
+
artifactsDir,
|
|
168
|
+
safeName,
|
|
169
|
+
attempt = 1,
|
|
170
|
+
traceOptions = {},
|
|
171
|
+
} = options;
|
|
172
|
+
|
|
173
|
+
const setting = resolveTraceSetting(normalizeTestTrace(trace), attempt);
|
|
174
|
+
if (!setting || !artifactsDir) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const running = await startTrace({
|
|
179
|
+
commander,
|
|
180
|
+
page,
|
|
181
|
+
output: traceOutputPath({ artifactsDir, safeName, attempt }),
|
|
182
|
+
mode: setting.recorderMode,
|
|
183
|
+
screenshots: screenshotSettingForRecorder(
|
|
184
|
+
normalizeTestScreenshots(screenshots)
|
|
185
|
+
),
|
|
186
|
+
// A test that fails halfway is exactly where ordered DOM mutations pay
|
|
187
|
+
// for themselves, so they are on whenever the runner records.
|
|
188
|
+
dom: { mutations: true },
|
|
189
|
+
...traceOptions,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
trace: running,
|
|
194
|
+
path: running.path,
|
|
195
|
+
retainOnFailure: setting.retainOnFailure,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Stop a scenario's trace, keeping it only when it is worth keeping.
|
|
201
|
+
*
|
|
202
|
+
* @param {Object|null} started - What `startScenarioTrace()` returned
|
|
203
|
+
* @param {Object} [options] - `{error, log}`
|
|
204
|
+
* @returns {Promise<Object|null>} The stopped trace, or null
|
|
205
|
+
*/
|
|
206
|
+
export async function finishScenarioTrace(started, options = {}) {
|
|
207
|
+
if (!started) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const { error = null, log } = options;
|
|
212
|
+
const { trace, retainOnFailure } = started;
|
|
213
|
+
const note = (message) => log?.debug?.(`[trace] ${message}`);
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
await trace.checkpoint(error ? 'failure' : 'final', {
|
|
217
|
+
actor: 'runner',
|
|
218
|
+
reason: error ? 'failure' : 'final',
|
|
219
|
+
});
|
|
220
|
+
} catch (checkpointError) {
|
|
221
|
+
note(`could not capture the final checkpoint: ${checkpointError.message}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const discard = Boolean(retainOnFailure) && !error;
|
|
225
|
+
const stopped = await trace.stop({ error, discard });
|
|
226
|
+
|
|
227
|
+
if (!discard) {
|
|
228
|
+
try {
|
|
229
|
+
await writeTraceViewer(stopped.path);
|
|
230
|
+
} catch (viewerError) {
|
|
231
|
+
note(`could not write the viewer: ${viewerError.message}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return stopped;
|
|
236
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writing a trace bundle to disk (issue #87).
|
|
3
|
+
*
|
|
4
|
+
* The bundle is a directory with the documented layout, which `zip -r` turns
|
|
5
|
+
* into the archive form without re-encoding anything, and which `ls` and
|
|
6
|
+
* `cat` can already read.
|
|
7
|
+
*
|
|
8
|
+
* Every write here is best-effort by default: a trace exists to explain a run,
|
|
9
|
+
* so failing to record something must leave a visible gap rather than break
|
|
10
|
+
* the automation that was being recorded.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import fs from 'node:fs/promises';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_MAX_BUNDLE_BYTES,
|
|
18
|
+
DEFAULT_MAX_RESOURCE_BYTES,
|
|
19
|
+
TRACE_DROP_REASON,
|
|
20
|
+
TRACE_EVENT,
|
|
21
|
+
TRACE_FILES,
|
|
22
|
+
TRACE_OUTCOME,
|
|
23
|
+
sequenceName,
|
|
24
|
+
} from './schema.js';
|
|
25
|
+
|
|
26
|
+
/** Owner-only file mode: a trace may hold form values and page content. */
|
|
27
|
+
export const TRACE_FILE_MODE = 0o600;
|
|
28
|
+
|
|
29
|
+
/** Owner-only directory mode for the bundle root. */
|
|
30
|
+
export const TRACE_DIRECTORY_MODE = 0o700;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Open a bundle for writing.
|
|
34
|
+
*
|
|
35
|
+
* @param {Object} options - `{output, limits, strict, now}`
|
|
36
|
+
* @returns {Promise<Object>} The bundle writer
|
|
37
|
+
*/
|
|
38
|
+
export async function openTraceBundle(options = {}) {
|
|
39
|
+
const {
|
|
40
|
+
output,
|
|
41
|
+
strict = false,
|
|
42
|
+
limits = {},
|
|
43
|
+
now = () => Date.now(),
|
|
44
|
+
} = options;
|
|
45
|
+
|
|
46
|
+
if (typeof output !== 'string' || output === '') {
|
|
47
|
+
throw new Error('trace output must be a path');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const root = path.resolve(output);
|
|
51
|
+
const maxBundleBytes = limits.maxBundleBytes ?? DEFAULT_MAX_BUNDLE_BYTES;
|
|
52
|
+
const maxResourceBytes =
|
|
53
|
+
limits.maxResourceBytes ?? DEFAULT_MAX_RESOURCE_BYTES;
|
|
54
|
+
const maxEventBytes = limits.maxEventBytes ?? maxResourceBytes;
|
|
55
|
+
|
|
56
|
+
await fs.mkdir(root, { recursive: true, mode: TRACE_DIRECTORY_MODE });
|
|
57
|
+
|
|
58
|
+
const eventsPath = path.join(root, TRACE_FILES.EVENTS);
|
|
59
|
+
const eventsHandle = await fs.open(eventsPath, 'a', TRACE_FILE_MODE);
|
|
60
|
+
|
|
61
|
+
let written = 0;
|
|
62
|
+
let sequence = 0;
|
|
63
|
+
// Appends are serialized. Listeners report what happened without waiting
|
|
64
|
+
// for the previous record to reach the disk, and a timeline whose lines
|
|
65
|
+
// arrive in a different order than the events did is not a timeline.
|
|
66
|
+
let appendQueue = Promise.resolve();
|
|
67
|
+
let dropped = 0;
|
|
68
|
+
const counts = { checkpoints: 0, events: 0, mutationBatches: 0 };
|
|
69
|
+
const problems = [];
|
|
70
|
+
|
|
71
|
+
const fits = (bytes) =>
|
|
72
|
+
bytes <= maxResourceBytes && written + bytes <= maxBundleBytes;
|
|
73
|
+
|
|
74
|
+
// A record of something that was dropped is the one write that must not be
|
|
75
|
+
// dropped in turn, so it answers to the bundle budget alone.
|
|
76
|
+
const fitsEvent = (bytes, retry) =>
|
|
77
|
+
written + bytes <= maxBundleBytes && (!retry || bytes <= maxEventBytes);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Record that something could not be written.
|
|
81
|
+
*
|
|
82
|
+
* @param {Object} record - `{reason, detail, member}`
|
|
83
|
+
* @returns {Promise<void>}
|
|
84
|
+
*/
|
|
85
|
+
async function drop(record) {
|
|
86
|
+
dropped += 1;
|
|
87
|
+
problems.push(record);
|
|
88
|
+
if (strict) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`trace ${record.member ?? 'record'} dropped: ${record.reason}${
|
|
91
|
+
record.detail ? ` (${record.detail})` : ''
|
|
92
|
+
}`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
// The drop is itself an event, so a reader sees the gap in the timeline
|
|
96
|
+
// instead of silently reading a shorter story than the one that happened.
|
|
97
|
+
await appendEvent(
|
|
98
|
+
{ kind: TRACE_EVENT.DROPPED, ...record },
|
|
99
|
+
{ retry: false }
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Append one event to the timeline.
|
|
105
|
+
*
|
|
106
|
+
* @param {Object} event - Event fields; `sequence` and `at` are added
|
|
107
|
+
* @param {Object} [options] - `{retry}`
|
|
108
|
+
* @returns {Promise<Object|null>} The event as written
|
|
109
|
+
*/
|
|
110
|
+
async function appendEvent(event, { retry = true } = {}) {
|
|
111
|
+
const record = {
|
|
112
|
+
sequence: ++sequence,
|
|
113
|
+
at: new Date(now()).toISOString(),
|
|
114
|
+
monotonicMs: Math.round(performance.now()),
|
|
115
|
+
...event,
|
|
116
|
+
};
|
|
117
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
118
|
+
const bytes = Buffer.byteLength(line);
|
|
119
|
+
|
|
120
|
+
if (!fitsEvent(bytes, retry)) {
|
|
121
|
+
if (retry) {
|
|
122
|
+
await drop({
|
|
123
|
+
reason: TRACE_DROP_REASON.SIZE_LIMIT,
|
|
124
|
+
member: TRACE_FILES.EVENTS,
|
|
125
|
+
detail: `${bytes} bytes`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const pending = appendQueue.then(() => eventsHandle.write(line));
|
|
132
|
+
appendQueue = pending.then(
|
|
133
|
+
() => {},
|
|
134
|
+
() => {}
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await pending;
|
|
139
|
+
written += bytes;
|
|
140
|
+
counts.events += 1;
|
|
141
|
+
return record;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (retry) {
|
|
144
|
+
await drop({
|
|
145
|
+
reason: TRACE_DROP_REASON.WRITE_FAILED,
|
|
146
|
+
member: TRACE_FILES.EVENTS,
|
|
147
|
+
detail: error.message,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Write one member of the bundle.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} member - Path relative to the bundle root
|
|
158
|
+
* @param {string|Buffer} contents - What to write
|
|
159
|
+
* @returns {Promise<Object|null>} `{member, bytes}` or null when dropped
|
|
160
|
+
*/
|
|
161
|
+
async function writeMember(member, contents) {
|
|
162
|
+
const data = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
|
|
163
|
+
|
|
164
|
+
if (!fits(data.length)) {
|
|
165
|
+
await drop({
|
|
166
|
+
reason: TRACE_DROP_REASON.SIZE_LIMIT,
|
|
167
|
+
member,
|
|
168
|
+
detail: `${data.length} bytes`,
|
|
169
|
+
});
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const target = path.join(root, member);
|
|
174
|
+
try {
|
|
175
|
+
await fs.mkdir(path.dirname(target), {
|
|
176
|
+
recursive: true,
|
|
177
|
+
mode: TRACE_DIRECTORY_MODE,
|
|
178
|
+
});
|
|
179
|
+
await fs.writeFile(target, data, { mode: TRACE_FILE_MODE });
|
|
180
|
+
written += data.length;
|
|
181
|
+
return { member, bytes: data.length };
|
|
182
|
+
} catch (error) {
|
|
183
|
+
await drop({
|
|
184
|
+
reason: TRACE_DROP_REASON.WRITE_FAILED,
|
|
185
|
+
member,
|
|
186
|
+
detail: error.message,
|
|
187
|
+
});
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Write a checkpoint's HTML, state and screenshot.
|
|
194
|
+
*
|
|
195
|
+
* @param {Object} checkpoint - `{index, html, state, screenshot}`
|
|
196
|
+
* @returns {Promise<Object>} What was written, by member name
|
|
197
|
+
*/
|
|
198
|
+
async function writeCheckpoint({ index, html, state, screenshot }) {
|
|
199
|
+
const name = sequenceName(index);
|
|
200
|
+
const members = {};
|
|
201
|
+
|
|
202
|
+
if (typeof html === 'string') {
|
|
203
|
+
const result = await writeMember(
|
|
204
|
+
`${TRACE_FILES.CHECKPOINTS_DIR}/${name}.html`,
|
|
205
|
+
html
|
|
206
|
+
);
|
|
207
|
+
if (result) {
|
|
208
|
+
members.html = result.member;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (state) {
|
|
212
|
+
const result = await writeMember(
|
|
213
|
+
`${TRACE_FILES.CHECKPOINTS_DIR}/${name}.state.json`,
|
|
214
|
+
`${JSON.stringify(state, null, 2)}\n`
|
|
215
|
+
);
|
|
216
|
+
if (result) {
|
|
217
|
+
members.state = result.member;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (screenshot) {
|
|
221
|
+
const result = await writeMember(
|
|
222
|
+
`${TRACE_FILES.CHECKPOINTS_DIR}/${name}.png`,
|
|
223
|
+
screenshot
|
|
224
|
+
);
|
|
225
|
+
if (result) {
|
|
226
|
+
members.screenshot = result.member;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
counts.checkpoints += 1;
|
|
231
|
+
return members;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Write one checkpoint's mutation batches.
|
|
236
|
+
*
|
|
237
|
+
* @param {number} index - Checkpoint the batches follow
|
|
238
|
+
* @param {Object[]} batches - Ordered mutation batches
|
|
239
|
+
* @returns {Promise<string|null>} Member name, or null when nothing was written
|
|
240
|
+
*/
|
|
241
|
+
async function writeMutations(index, batches) {
|
|
242
|
+
if (!batches || batches.length === 0) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
const member = `${TRACE_FILES.MUTATIONS_DIR}/${sequenceName(index)}.ndjson`;
|
|
246
|
+
const body = `${batches.map((batch) => JSON.stringify(batch)).join('\n')}\n`;
|
|
247
|
+
const result = await writeMember(member, body);
|
|
248
|
+
if (result) {
|
|
249
|
+
counts.mutationBatches += batches.length;
|
|
250
|
+
}
|
|
251
|
+
return result ? result.member : null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Store a resource by the hash of its contents.
|
|
256
|
+
*
|
|
257
|
+
* Content addressing is what keeps one screenshot taken twice, or a file
|
|
258
|
+
* downloaded in three scenarios, from being stored three times.
|
|
259
|
+
*
|
|
260
|
+
* @param {Buffer|string} contents - Resource bytes
|
|
261
|
+
* @param {string} [extension] - Extension to keep, such as `.png`
|
|
262
|
+
* @returns {Promise<Object|null>} `{member, sha256, bytes}`
|
|
263
|
+
*/
|
|
264
|
+
async function writeArtifact(contents, extension = '') {
|
|
265
|
+
const data = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
|
|
266
|
+
const sha256 = createHash('sha256').update(data).digest('hex');
|
|
267
|
+
const member = `${TRACE_FILES.ARTIFACTS_DIR}/${sha256}${extension}`;
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
await fs.access(path.join(root, member));
|
|
271
|
+
return { member, sha256, bytes: data.length, deduplicated: true };
|
|
272
|
+
} catch {
|
|
273
|
+
// Not stored yet.
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const result = await writeMember(member, data);
|
|
277
|
+
return result ? { ...result, sha256, deduplicated: false } : null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
root,
|
|
282
|
+
counts,
|
|
283
|
+
problems,
|
|
284
|
+
get dropped() {
|
|
285
|
+
return dropped;
|
|
286
|
+
},
|
|
287
|
+
get bytesWritten() {
|
|
288
|
+
return written;
|
|
289
|
+
},
|
|
290
|
+
appendEvent,
|
|
291
|
+
writeMember,
|
|
292
|
+
writeCheckpoint,
|
|
293
|
+
writeMutations,
|
|
294
|
+
writeArtifact,
|
|
295
|
+
drop,
|
|
296
|
+
/**
|
|
297
|
+
* Write the manifest and close the timeline.
|
|
298
|
+
*
|
|
299
|
+
* @param {Object} manifest - Manifest built by `createManifest`
|
|
300
|
+
* @returns {Promise<Object>} The manifest as written
|
|
301
|
+
*/
|
|
302
|
+
close: async (manifest) => {
|
|
303
|
+
const finished = {
|
|
304
|
+
...manifest,
|
|
305
|
+
counts: { ...counts },
|
|
306
|
+
dropped,
|
|
307
|
+
outcome:
|
|
308
|
+
dropped > 0 && manifest.outcome === TRACE_OUTCOME.COMPLETE
|
|
309
|
+
? TRACE_OUTCOME.PARTIAL
|
|
310
|
+
: manifest.outcome,
|
|
311
|
+
};
|
|
312
|
+
// Anything a listener reported on its way out is still queued.
|
|
313
|
+
await appendQueue;
|
|
314
|
+
await eventsHandle.close();
|
|
315
|
+
await fs.writeFile(
|
|
316
|
+
path.join(root, TRACE_FILES.MANIFEST),
|
|
317
|
+
`${JSON.stringify(finished, null, 2)}\n`,
|
|
318
|
+
{ mode: TRACE_FILE_MODE }
|
|
319
|
+
);
|
|
320
|
+
return finished;
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|