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.
Files changed (66) hide show
  1. package/.jscpd-baseline.json +4 -17
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +84 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +4 -1
  6. package/src/bindings.js +13 -1
  7. package/src/browser/connector.js +15 -3
  8. package/src/browser/launcher.js +14 -2
  9. package/src/browser/navigation.js +63 -0
  10. package/src/core/dialog-manager.js +48 -0
  11. package/src/core/navigation-manager.js +83 -87
  12. package/src/core/navigation-readiness.js +133 -0
  13. package/src/core/readiness.js +478 -0
  14. package/src/downloads/attach.js +58 -0
  15. package/src/downloads/destination.js +125 -0
  16. package/src/downloads/manager.js +453 -0
  17. package/src/downloads/naming.js +246 -0
  18. package/src/downloads/sources.js +188 -0
  19. package/src/downloads/store.js +256 -0
  20. package/src/exports.js +72 -0
  21. package/src/factory.js +55 -0
  22. package/src/interactions/click-activation.js +317 -0
  23. package/src/interactions/click-result.js +108 -0
  24. package/src/interactions/click.js +766 -352
  25. package/src/tests/index.js +75 -11
  26. package/src/tests/tracing.js +236 -0
  27. package/src/traces/bundle.js +323 -0
  28. package/src/traces/observers.js +246 -0
  29. package/src/traces/page-capture.js +365 -0
  30. package/src/traces/reader.js +226 -0
  31. package/src/traces/recorder.js +437 -0
  32. package/src/traces/redaction.js +231 -0
  33. package/src/traces/schema.js +165 -0
  34. package/src/traces/viewer.js +302 -0
  35. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  36. package/tests/e2e/downloads.e2e.test.js +188 -0
  37. package/tests/e2e/traces.e2e.test.js +457 -0
  38. package/tests/helpers/click-fixtures.js +62 -0
  39. package/tests/helpers/download-fixtures.js +125 -0
  40. package/tests/helpers/download-server.js +109 -0
  41. package/tests/helpers/e2e-browser.js +67 -0
  42. package/tests/helpers/file-modes.js +31 -0
  43. package/tests/helpers/fixture-server.js +56 -0
  44. package/tests/helpers/mocks.js +63 -66
  45. package/tests/helpers/readiness-server.js +96 -0
  46. package/tests/helpers/temp-directory.js +44 -0
  47. package/tests/helpers/trace-fixtures.js +198 -0
  48. package/tests/helpers/trace-server.js +117 -0
  49. package/tests/unit/core/dialog-manager.test.js +118 -10
  50. package/tests/unit/core/navigation-manager.test.js +162 -0
  51. package/tests/unit/core/readiness.test.js +517 -0
  52. package/tests/unit/downloads/attach.test.js +220 -0
  53. package/tests/unit/downloads/destination.test.js +114 -0
  54. package/tests/unit/downloads/manager.test.js +354 -0
  55. package/tests/unit/downloads/naming.test.js +174 -0
  56. package/tests/unit/downloads/store.test.js +215 -0
  57. package/tests/unit/interactions/click-activation.test.js +283 -0
  58. package/tests/unit/interactions/click-result.test.js +90 -0
  59. package/tests/unit/interactions/click.test.js +409 -200
  60. package/tests/unit/tests/tracing.test.js +299 -0
  61. package/tests/unit/traces/bundle.test.js +260 -0
  62. package/tests/unit/traces/reader.test.js +208 -0
  63. package/tests/unit/traces/recorder.test.js +562 -0
  64. package/tests/unit/traces/redaction.test.js +170 -0
  65. package/tests/unit/traces/schema.test.js +92 -0
  66. package/tests/unit/traces/viewer.test.js +289 -0
@@ -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: mergeOptions(
577
- launchOptions,
578
- normalizedScenario.launchOptions
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
- return await runWithTimeout(
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 writeFailureArtifacts({
606
- page: fixture.page,
661
+ const stopped = await finishScenarioTrace(started, {
607
662
  error,
608
- testId,
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
+ }