browser-commander 0.27.0 → 0.29.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 (93) hide show
  1. package/.jscpd-baseline.json +5 -19
  2. package/CHANGELOG.md +22 -0
  3. package/README.md +23 -9
  4. package/eslint.config.js +11 -0
  5. package/package.json +5 -2
  6. package/src/browser/browser-profile-files.js +5 -2
  7. package/src/browser/connector.js +215 -36
  8. package/src/browser/launch-options.js +2 -1
  9. package/src/browser/launch-restrictions.json +33 -2
  10. package/src/browser/launcher.js +26 -1
  11. package/src/browser/persistent-session.js +432 -0
  12. package/src/browser/profile-directory.js +20 -0
  13. package/src/browser/real-browser.js +7 -2
  14. package/src/browser/restrictions.js +13 -4
  15. package/src/browser/session-adoption.js +96 -0
  16. package/src/browser/session-watchdog.js +71 -0
  17. package/src/browser/session-worker.js +82 -0
  18. package/src/browser/webdriver.js +4 -0
  19. package/src/capture/encoding.js +327 -0
  20. package/src/capture/errors.js +12 -0
  21. package/src/capture/files.js +9 -0
  22. package/src/capture/index.js +5 -0
  23. package/src/capture/recording.js +136 -0
  24. package/src/capture/screenshot.js +222 -0
  25. package/src/capture/video.js +186 -0
  26. package/src/capture/viewport.js +68 -0
  27. package/src/capture/worker.js +22 -0
  28. package/src/cli/args.js +69 -1
  29. package/src/cli/commands.js +116 -2
  30. package/src/cli/high-level-methods.js +47 -3
  31. package/src/cli/sessions.js +14 -3
  32. package/src/core/commander-features.js +110 -0
  33. package/src/core/constants.js +1 -0
  34. package/src/core/navigation-manager.js +4 -2
  35. package/src/core/navigation-readiness.js +30 -2
  36. package/src/core/navigation-safety.js +52 -0
  37. package/src/core/page-trigger-manager.js +280 -156
  38. package/src/core/readiness.js +10 -2
  39. package/src/elements/locators.js +26 -32
  40. package/src/elements/selectors.js +37 -81
  41. package/src/elements/visibility.js +32 -10
  42. package/src/factory.js +13 -12
  43. package/src/index.js +4 -0
  44. package/src/interactions/click.js +44 -0
  45. package/src/interactions/overlays.js +73 -0
  46. package/src/traces/bundle.js +55 -8
  47. package/src/traces/checkpoints.js +103 -0
  48. package/src/traces/links.js +157 -2
  49. package/src/traces/mutation-stream.js +9 -2
  50. package/src/traces/network.js +309 -0
  51. package/src/traces/page-capture.js +203 -68
  52. package/src/traces/reader.js +8 -10
  53. package/src/traces/recorder.js +253 -247
  54. package/src/traces/redaction.js +89 -6
  55. package/src/traces/render.js +168 -0
  56. package/src/traces/rolling-bundle.js +240 -0
  57. package/src/traces/rolling.js +77 -0
  58. package/src/traces/start-cleanup.js +15 -0
  59. package/src/traces/stop.js +160 -0
  60. package/src/traces/storage.js +43 -0
  61. package/src/utilities/subprocess.js +32 -0
  62. package/tests/e2e/cli.e2e.test.js +20 -1
  63. package/tests/e2e/click-readiness.e2e.test.js +1 -1
  64. package/tests/e2e/issue-160.e2e.test.js +291 -0
  65. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
  66. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
  67. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +2 -2
  68. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
  69. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
  70. package/tests/fixtures/traces/conformance/expected/trace.lino +2 -2
  71. package/tests/unit/browser/issue-160-connector.test.js +157 -0
  72. package/tests/unit/browser/launch-building-blocks.test.js +5 -1
  73. package/tests/unit/browser/launcher.test.js +5 -1
  74. package/tests/unit/browser/persistent-session.test.js +59 -0
  75. package/tests/unit/browser/quiet-ui.test.js +16 -0
  76. package/tests/unit/browser/tab-selection.test.js +38 -0
  77. package/tests/unit/capture/capture.test.js +52 -0
  78. package/tests/unit/capture/cleanup.test.js +24 -0
  79. package/tests/unit/capture/viewport.test.js +168 -0
  80. package/tests/unit/cli/dispatcher.test.js +25 -0
  81. package/tests/unit/core/interrupted-navigation.test.js +32 -0
  82. package/tests/unit/core/readiness.test.js +21 -0
  83. package/tests/unit/core/trigger-readiness.test.js +39 -0
  84. package/tests/unit/core/trigger-stop-race.test.js +80 -0
  85. package/tests/unit/elements/visible-count.test.js +24 -0
  86. package/tests/unit/elements/wait-navigation.test.js +26 -0
  87. package/tests/unit/interactions/navigation-timeout.test.js +39 -0
  88. package/tests/unit/interactions/overlays.test.js +58 -0
  89. package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
  90. package/tests/unit/traces/bundle.test.js +16 -0
  91. package/tests/unit/traces/issue-160-privacy.test.js +110 -0
  92. package/tests/unit/traces/issue-160-rotation.test.js +140 -0
  93. package/tests/unit/traces/network.test.js +149 -0
@@ -429,6 +429,7 @@ export async function traceLinks(trace, options = {}) {
429
429
  if (sections.has('control-diffs')) {
430
430
  links.push(...(await controlDiffLinks(opened)));
431
431
  }
432
+ links.push(...(await domLinks(opened, options.dom)));
432
433
 
433
434
  if (sections.has('trace')) {
434
435
  links.push(
@@ -470,6 +471,63 @@ async function controlDiffLinks(opened) {
470
471
  return links;
471
472
  }
472
473
 
474
+ /** Self-contained DOM and mutation records in Links Notation. */
475
+ async function domLinks(opened, dom, seen = new Set()) {
476
+ if (!dom) {
477
+ return [];
478
+ }
479
+ const links = [];
480
+ for (const checkpoint of opened.checkpoints) {
481
+ if (dom === 'text') {
482
+ const state = await opened.state(checkpoint.index);
483
+ if (state?.text) {
484
+ links.push(
485
+ new Link('dom-text', [
486
+ field('checkpoint', checkpoint.index),
487
+ field('text', state.text),
488
+ ])
489
+ );
490
+ }
491
+ } else {
492
+ links.push(
493
+ new Link('dom-snapshot', [
494
+ field('checkpoint', checkpoint.index),
495
+ field('html', await opened.html(checkpoint.index)),
496
+ ])
497
+ );
498
+ }
499
+ }
500
+ for (const event of opened.events.filter(
501
+ (event) => event.kind === 'mutations'
502
+ )) {
503
+ const batches = (await opened.mutations(event.checkpoint)).filter(
504
+ (batch) => {
505
+ const key = `${event.member}:${batch.frameId}:${batch.sequence}:${batch.at}`;
506
+ if (seen.has(key)) {
507
+ return false;
508
+ }
509
+ seen.add(key);
510
+ return true;
511
+ }
512
+ );
513
+ for (const batch of batches) {
514
+ for (const record of batch.records ?? []) {
515
+ if (dom !== 'text') {
516
+ links.push(
517
+ new Link('dom-mutation', [
518
+ field('at', batch.at),
519
+ field('record', JSON.stringify(record)),
520
+ ])
521
+ );
522
+ } else {
523
+ links.push(...textMutationLinks(record, batch));
524
+ }
525
+ }
526
+ }
527
+ }
528
+ return links;
529
+ }
530
+
473
531
  /**
474
532
  * Resolve where an export should be written.
475
533
  *
@@ -521,10 +579,13 @@ export async function writeTraceLinks(trace, output, options = {}) {
521
579
  * @returns {Promise<Object>} The sink
522
580
  */
523
581
  export async function openTraceLinks(options = {}) {
524
- const { output, include, bundlePath, ...about } = options;
582
+ const { output, include, dom, bundlePath, ...about } = options;
525
583
  const file = await resolveOutput(output);
526
584
  const sections = chosenSections(include);
527
585
  const problems = [];
586
+ // Only the current interval is remembered; completed intervals never recur.
587
+ let mutationMember = null;
588
+ let mutationOffset = 0;
528
589
 
529
590
  await fs.mkdir(path.dirname(file), { recursive: true });
530
591
  const handle = await fs.open(file, 'w', 0o600);
@@ -567,11 +628,66 @@ export async function openTraceLinks(options = {}) {
567
628
  * @param {Object} event - The event as written to the bundle
568
629
  * @returns {Promise<void>}
569
630
  */
570
- async event(event) {
631
+ async event(event, source = {}) {
571
632
  if (!event) {
572
633
  return;
573
634
  }
574
635
  await append(linksForEvent(event, sections));
636
+ if (
637
+ dom &&
638
+ bundlePath &&
639
+ [TRACE_EVENT.CHECKPOINT, TRACE_EVENT.MUTATIONS].includes(event.kind)
640
+ ) {
641
+ const root = source.root ?? bundlePath;
642
+ let batches = [];
643
+ if (event.kind === TRACE_EVENT.MUTATIONS) {
644
+ const member = path.join(root, event.member);
645
+ if (member !== mutationMember) {
646
+ mutationMember = member;
647
+ mutationOffset = 0;
648
+ }
649
+ const input = await fs.open(member, 'r');
650
+ try {
651
+ const size = (await input.stat()).size;
652
+ const bytes = Buffer.alloc(Math.max(0, size - mutationOffset));
653
+ await input.read(bytes, 0, bytes.length, mutationOffset);
654
+ mutationOffset = size;
655
+ batches = bytes
656
+ .toString('utf8')
657
+ .split('\n')
658
+ .filter(Boolean)
659
+ .map(JSON.parse);
660
+ } finally {
661
+ await input.close();
662
+ }
663
+ }
664
+ const opened = {
665
+ html: () =>
666
+ event.members?.html
667
+ ? fs.readFile(path.join(root, event.members.html), 'utf8')
668
+ : null,
669
+ state: async () =>
670
+ event.members?.state
671
+ ? JSON.parse(
672
+ await fs.readFile(
673
+ path.join(root, event.members.state),
674
+ 'utf8'
675
+ )
676
+ )
677
+ : null,
678
+ mutations: () => batches,
679
+ };
680
+ await append(
681
+ await domLinks(
682
+ {
683
+ ...opened,
684
+ checkpoints: event.kind === TRACE_EVENT.CHECKPOINT ? [event] : [],
685
+ events: event.kind === TRACE_EVENT.MUTATIONS ? [event] : [],
686
+ },
687
+ dom
688
+ )
689
+ );
690
+ }
575
691
  },
576
692
  /**
577
693
  * Finish the export.
@@ -593,6 +709,7 @@ export async function openTraceLinks(options = {}) {
593
709
  problems.push({ member: file, detail: error.message });
594
710
  }
595
711
  }
712
+
596
713
  if (sections.has('trace')) {
597
714
  links.push(
598
715
  traceResultLink(closing.manifest, {
@@ -621,3 +738,41 @@ export async function openTraceLinks(options = {}) {
621
738
  },
622
739
  };
623
740
  }
741
+
742
+ function textMutationLinks(record, batch) {
743
+ if (record.target?.visible === false) {
744
+ return [];
745
+ }
746
+ const links = [];
747
+ const target = record.target?.path ?? '';
748
+ if (record.kind === 'attributes' || record.kind === 'characterData') {
749
+ links.push(
750
+ new Link(
751
+ record.kind === 'attributes' ? 'attribute-changed' : 'text-changed',
752
+ [
753
+ field('at', batch.at),
754
+ field('path', target),
755
+ field('attribute', record.attribute ?? null),
756
+ field('before', record.before),
757
+ field('after', record.after),
758
+ ]
759
+ )
760
+ );
761
+ }
762
+ if (record.kind === 'childList') {
763
+ for (const change of ['added', 'removed']) {
764
+ for (const node of record[change] ?? []) {
765
+ if (node.visible !== false && node.text) {
766
+ links.push(
767
+ new Link(`text-${change}`, [
768
+ field('at', batch.at),
769
+ field('path', node.path ?? target),
770
+ field('text', node.text),
771
+ ])
772
+ );
773
+ }
774
+ }
775
+ }
776
+ }
777
+ return links;
778
+ }
@@ -20,7 +20,7 @@ import {
20
20
  RECORDER_GLOBAL,
21
21
  stopMutationRecorderInPage,
22
22
  } from './page-capture.js';
23
- import { REDACTED } from './redaction.js';
23
+ import { redactValue, REDACTED } from './redaction.js';
24
24
  import { TRACE_DROP_REASON, TRACE_EVENT } from './schema.js';
25
25
 
26
26
  /** In-page records kept before the recorder starts counting drops instead. */
@@ -62,7 +62,10 @@ export function createMutationStream(options) {
62
62
  const recorderOptions = {
63
63
  globalName: RECORDER_GLOBAL,
64
64
  redactSelectors: privacyOptions.redactSelectors,
65
+ redactAttributes: privacyOptions.redactAttributes,
66
+ useDefaults: privacyOptions.useDefaults,
65
67
  redacted: REDACTED,
68
+ ignoreSelectors: domOptions.ignoreSelectors ?? [],
66
69
  maxQueued: limits.maxQueuedMutations ?? DEFAULT_MAX_QUEUED_MUTATIONS,
67
70
  liveState: domOptions.liveState !== false,
68
71
  };
@@ -194,13 +197,17 @@ export function createMutationStream(options) {
194
197
  // frame; one ordered file is what a replay reads.
195
198
  batches.sort((left, right) => (left.at ?? 0) - (right.at ?? 0));
196
199
 
197
- const member = await bundle.writeMutations(index, batches);
200
+ const member = await bundle.writeMutations(
201
+ index,
202
+ redactValue(batches, privacyOptions)
203
+ );
198
204
  if (member) {
199
205
  await record(TRACE_EVENT.MUTATIONS, {
200
206
  member,
201
207
  batches: batches.length,
202
208
  checkpoint: index,
203
209
  frames: drained.length,
210
+ truncated: bundle.mutationTruncated,
204
211
  });
205
212
  }
206
213
  return member;
@@ -0,0 +1,309 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { withDeadline } from './deadline.js';
4
+ import {
5
+ normalizePrivacyOptions,
6
+ redactText,
7
+ redactHeaders as privateHeaders,
8
+ } from './redaction.js';
9
+
10
+ const PRIVATE_HEADERS = new Set([
11
+ 'cookie',
12
+ 'authorization',
13
+ 'proxy-authorization',
14
+ 'set-cookie',
15
+ ]);
16
+ export function redactHeaders(headers = {}) {
17
+ return privateHeaders(
18
+ Object.fromEntries(
19
+ Object.entries(headers).map(([key, value]) => [
20
+ key.toLowerCase(),
21
+ PRIVATE_HEADERS.has(key.toLowerCase()) ? '[redacted]' : value,
22
+ ])
23
+ ),
24
+ normalizePrivacyOptions()
25
+ );
26
+ }
27
+
28
+ export function isTextBody(contentType = '') {
29
+ return /^(?:text\/|application\/(?:[\w.+-]*json|[\w.+-]*xml|javascript|x-www-form-urlencoded))|multipart\/form-data/i.test(
30
+ contentType
31
+ );
32
+ }
33
+
34
+ function matches(url, pattern) {
35
+ if (pattern === undefined) {
36
+ return true;
37
+ }
38
+ if (typeof pattern === 'function') {
39
+ return Boolean(pattern(url));
40
+ }
41
+ if (pattern instanceof RegExp) {
42
+ pattern.lastIndex = 0;
43
+ return pattern.test(url);
44
+ }
45
+ return url.includes(String(pattern));
46
+ }
47
+
48
+ export function networkHar(events) {
49
+ const requests = new Map(
50
+ events
51
+ .filter((event) => event.kind === 'network.request')
52
+ .map((event) => [event.requestId, event])
53
+ );
54
+ const headers = (value) =>
55
+ Object.entries(value ?? {}).map(([name, value]) => ({
56
+ name,
57
+ value: String(value),
58
+ }));
59
+ const entries = events
60
+ .filter((event) => event.kind === 'network.response')
61
+ .map((response) => {
62
+ const request = requests.get(response.requestId) ?? response;
63
+ const timing = response.timing ?? request.timing ?? {};
64
+ return {
65
+ startedDateTime: request.at ?? new Date().toISOString(),
66
+ time: Math.max(0, timing.responseEnd ?? 0),
67
+ request: {
68
+ method: request.method,
69
+ url: request.url,
70
+ httpVersion: 'HTTP/1.1',
71
+ headers: headers(request.headers),
72
+ cookies: [],
73
+ queryString: [],
74
+ headersSize: -1,
75
+ bodySize: -1,
76
+ ...(request.postData !== undefined
77
+ ? {
78
+ postData: {
79
+ mimeType: request.headers?.['content-type'] ?? '',
80
+ text: request.postData,
81
+ _truncated: request.postDataTruncated,
82
+ },
83
+ }
84
+ : {}),
85
+ },
86
+ response: {
87
+ status: response.status,
88
+ statusText: response.statusText ?? '',
89
+ httpVersion: 'HTTP/1.1',
90
+ headers: headers(response.headers),
91
+ cookies: [],
92
+ redirectURL: response.headers?.location ?? '',
93
+ headersSize: -1,
94
+ bodySize: response.body?.size ?? -1,
95
+ content: {
96
+ size: response.body?.size ?? 0,
97
+ mimeType: response.contentType ?? '',
98
+ ...(response.body?.data !== undefined
99
+ ? {
100
+ text: response.body.data,
101
+ ...(response.body.encoding === 'utf8'
102
+ ? {}
103
+ : { encoding: 'base64' }),
104
+ _truncated: response.body.truncated,
105
+ }
106
+ : {}),
107
+ },
108
+ },
109
+ cache: {},
110
+ timings: {
111
+ send: 0,
112
+ wait: Math.max(
113
+ 0,
114
+ (timing.responseStart ?? 0) - (timing.requestStart ?? 0)
115
+ ),
116
+ receive: Math.max(
117
+ 0,
118
+ (timing.responseEnd ?? 0) - (timing.responseStart ?? 0)
119
+ ),
120
+ },
121
+ _resourceType: response.resourceType,
122
+ };
123
+ });
124
+ return {
125
+ log: {
126
+ version: '1.2',
127
+ creator: { name: 'browser-commander', version: '1' },
128
+ entries,
129
+ },
130
+ };
131
+ }
132
+
133
+ /** Observe without fetching again, and drain responses before closing the bundle. */
134
+ export function validateNetworkOptions(network) {
135
+ if (!network) {
136
+ return;
137
+ }
138
+ const options = network === true ? {} : network;
139
+ if (
140
+ typeof options !== 'object' ||
141
+ !Number.isSafeInteger(options.maxBodyBytes ?? 1024 * 1024) ||
142
+ (options.maxBodyBytes ?? 0) < 0 ||
143
+ (options.maxBodyBytes ?? 0) > 16 * 1024 * 1024
144
+ ) {
145
+ throw new RangeError(
146
+ 'network.maxBodyBytes must be an integer between 0 and 16 MiB'
147
+ );
148
+ }
149
+ }
150
+ export function attachNetwork({
151
+ page,
152
+ network,
153
+ record,
154
+ note,
155
+ privacy = normalizePrivacyOptions(),
156
+ }) {
157
+ validateNetworkOptions(network);
158
+ if (!network) {
159
+ return async () => {};
160
+ }
161
+ const options = network === true ? {} : network;
162
+ const maxBodyBytes = options.maxBodyBytes ?? 1024 * 1024;
163
+ if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes < 0) {
164
+ throw new RangeError('network.maxBodyBytes must be a nonnegative integer');
165
+ }
166
+ const ids = new WeakMap();
167
+ const pending = new Set();
168
+ let sequence = 0;
169
+ const details = (request) => ({
170
+ requestId: ids.get(request),
171
+ method: request.method(),
172
+ url: request.url(),
173
+ resourceType: request.resourceType?.() ?? 'other',
174
+ timing: request.timing?.() ?? null,
175
+ });
176
+ const allowed = (request) =>
177
+ (!options.resourceTypes ||
178
+ options.resourceTypes.includes(request.resourceType?.())) &&
179
+ matches(request.url(), options.urlPattern ?? options.url);
180
+ const track = (promise) => {
181
+ pending.add(promise);
182
+ promise.finally(() => pending.delete(promise)).catch(() => {});
183
+ };
184
+ const request = (request) => {
185
+ if (!allowed(request)) {
186
+ return;
187
+ }
188
+ ids.set(request, ++sequence);
189
+ track(
190
+ record('network.request', {
191
+ ...details(request),
192
+ headers: redactHeaders(request.headers?.()),
193
+ ...(options.bodies &&
194
+ ['document', 'xhr', 'fetch'].includes(request.resourceType?.()) &&
195
+ request.postData?.()
196
+ ? {
197
+ postData: Buffer.from(
198
+ redactText(request.postData(), privacy, {
199
+ kind: 'network',
200
+ name: 'postData',
201
+ })
202
+ )
203
+ .subarray(0, maxBodyBytes)
204
+ .toString('utf8'),
205
+ postDataTruncated:
206
+ Buffer.byteLength(request.postData()) > maxBodyBytes,
207
+ }
208
+ : {}),
209
+ })
210
+ );
211
+ };
212
+ const response = (response) => {
213
+ const request = response.request();
214
+ if (!ids.has(request)) {
215
+ return;
216
+ }
217
+ const saturated = pending.size >= 32;
218
+ track(
219
+ (async () => {
220
+ const headers = redactHeaders(
221
+ await withDeadline(
222
+ Promise.resolve(response.allHeaders?.() ?? response.headers()),
223
+ 5000,
224
+ 'network headers'
225
+ )
226
+ );
227
+ const payload = {
228
+ ...details(request),
229
+ status: response.status(),
230
+ statusText: response.statusText?.() ?? '',
231
+ headers,
232
+ contentType: headers['content-type'] ?? null,
233
+ };
234
+ const resourceType = payload.resourceType;
235
+ if (
236
+ !saturated &&
237
+ options.bodies &&
238
+ ['document', 'xhr', 'fetch'].includes(resourceType)
239
+ ) {
240
+ let length = Number(headers['content-length']);
241
+ if (!Number.isFinite(length) && request.sizes) {
242
+ const sizes = await withDeadline(
243
+ request.sizes(),
244
+ 5000,
245
+ 'network sizes'
246
+ ).catch(() => ({}));
247
+ length = sizes.responseBodySize ?? NaN;
248
+ }
249
+ if (!isTextBody(payload.contentType ?? '')) {
250
+ payload.body = {
251
+ size: Number.isFinite(length) ? length : null,
252
+ omitted: 'binary',
253
+ };
254
+ } else if (Number.isFinite(length) && length > maxBodyBytes) {
255
+ payload.body = {
256
+ size: length,
257
+ truncated: true,
258
+ omitted: 'size limit',
259
+ };
260
+ } else {
261
+ try {
262
+ const data = Buffer.from(
263
+ await withDeadline(
264
+ response.body?.() ?? response.buffer(),
265
+ 5000,
266
+ 'network body'
267
+ )
268
+ );
269
+ payload.body = {
270
+ size: data.length,
271
+ truncated: data.length > maxBodyBytes,
272
+ encoding: 'utf8',
273
+ data: Buffer.from(
274
+ redactText(data.toString('utf8'), privacy, {
275
+ kind: 'network',
276
+ name: 'responseBody',
277
+ })
278
+ )
279
+ .subarray(0, maxBodyBytes)
280
+ .toString('utf8'),
281
+ };
282
+ } catch (error) {
283
+ payload.bodyError = error.message;
284
+ }
285
+ }
286
+ }
287
+ if (saturated && options.bodies) {
288
+ payload.bodyError = 'body capture concurrency limit';
289
+ }
290
+ await record('network.response', payload);
291
+ })().catch((error) => note(`network capture: ${error.message}`))
292
+ );
293
+ };
294
+ page.on('request', request);
295
+ page.on('response', response);
296
+ return async () => {
297
+ page.off('request', request);
298
+ page.off('response', response);
299
+ await Promise.allSettled([...pending]);
300
+ };
301
+ }
302
+
303
+ export async function writeHar(opened, output) {
304
+ await fs.writeFile(
305
+ output ?? path.join(opened.path, 'network.har'),
306
+ JSON.stringify(networkHar(opened.events), null, 2),
307
+ { mode: 0o600 }
308
+ );
309
+ }