browser-commander 0.27.0 → 0.28.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 (75) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +4 -0
  3. package/eslint.config.js +11 -0
  4. package/package.json +5 -2
  5. package/src/browser/connector.js +117 -13
  6. package/src/browser/launch-options.js +2 -1
  7. package/src/browser/launch-restrictions.json +33 -2
  8. package/src/browser/launcher.js +26 -1
  9. package/src/browser/persistent-session.js +360 -0
  10. package/src/browser/profile-directory.js +20 -0
  11. package/src/browser/real-browser.js +7 -2
  12. package/src/browser/restrictions.js +13 -4
  13. package/src/browser/session-watchdog.js +71 -0
  14. package/src/browser/session-worker.js +70 -0
  15. package/src/browser/webdriver.js +4 -0
  16. package/src/capture/encoding.js +327 -0
  17. package/src/capture/errors.js +12 -0
  18. package/src/capture/files.js +9 -0
  19. package/src/capture/index.js +5 -0
  20. package/src/capture/recording.js +136 -0
  21. package/src/capture/screenshot.js +222 -0
  22. package/src/capture/video.js +186 -0
  23. package/src/capture/viewport.js +68 -0
  24. package/src/capture/worker.js +22 -0
  25. package/src/cli/args.js +66 -1
  26. package/src/cli/commands.js +113 -2
  27. package/src/cli/high-level-methods.js +40 -3
  28. package/src/cli/sessions.js +11 -3
  29. package/src/core/commander-features.js +105 -0
  30. package/src/core/constants.js +1 -0
  31. package/src/core/navigation-manager.js +4 -2
  32. package/src/core/navigation-readiness.js +30 -2
  33. package/src/core/navigation-safety.js +51 -0
  34. package/src/core/page-trigger-manager.js +115 -30
  35. package/src/core/readiness.js +10 -2
  36. package/src/elements/locators.js +26 -32
  37. package/src/elements/selectors.js +37 -81
  38. package/src/elements/visibility.js +32 -10
  39. package/src/factory.js +13 -12
  40. package/src/index.js +2 -0
  41. package/src/interactions/click.js +44 -0
  42. package/src/interactions/overlays.js +28 -0
  43. package/src/traces/bundle.js +14 -5
  44. package/src/traces/checkpoints.js +99 -0
  45. package/src/traces/links.js +117 -1
  46. package/src/traces/mutation-stream.js +1 -0
  47. package/src/traces/network.js +257 -0
  48. package/src/traces/page-capture.js +143 -52
  49. package/src/traces/reader.js +8 -10
  50. package/src/traces/recorder.js +221 -245
  51. package/src/traces/render.js +168 -0
  52. package/src/traces/rolling.js +180 -0
  53. package/src/traces/start-cleanup.js +15 -0
  54. package/src/traces/stop.js +159 -0
  55. package/src/traces/storage.js +43 -0
  56. package/src/utilities/subprocess.js +32 -0
  57. package/tests/e2e/cli.e2e.test.js +20 -1
  58. package/tests/e2e/click-readiness.e2e.test.js +1 -1
  59. package/tests/unit/browser/launch-building-blocks.test.js +5 -1
  60. package/tests/unit/browser/launcher.test.js +5 -1
  61. package/tests/unit/browser/persistent-session.test.js +59 -0
  62. package/tests/unit/browser/quiet-ui.test.js +16 -0
  63. package/tests/unit/browser/tab-selection.test.js +38 -0
  64. package/tests/unit/capture/capture.test.js +52 -0
  65. package/tests/unit/capture/cleanup.test.js +24 -0
  66. package/tests/unit/capture/viewport.test.js +168 -0
  67. package/tests/unit/cli/dispatcher.test.js +25 -0
  68. package/tests/unit/core/readiness.test.js +21 -0
  69. package/tests/unit/core/trigger-readiness.test.js +39 -0
  70. package/tests/unit/elements/visible-count.test.js +24 -0
  71. package/tests/unit/elements/wait-navigation.test.js +26 -0
  72. package/tests/unit/interactions/navigation-timeout.test.js +39 -0
  73. package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
  74. package/tests/unit/traces/bundle.test.js +16 -0
  75. package/tests/unit/traces/network.test.js +97 -0
@@ -31,6 +31,25 @@ function isInterrupted(error) {
31
31
  return isNavigationError(error) || isActionStoppedError(error);
32
32
  }
33
33
 
34
+ function waitInterruptedByNavigation({
35
+ error,
36
+ page,
37
+ navigationManager,
38
+ startUrl,
39
+ startSessionId,
40
+ preparationFinished,
41
+ }) {
42
+ const timedOut =
43
+ error.name === 'TimeoutError' ||
44
+ error.name === 'NavigationInterruptedError' ||
45
+ /timeout|timed out/i.test(error.message);
46
+ const navigationChanged =
47
+ page.url() !== startUrl ||
48
+ (startSessionId !== undefined &&
49
+ navigationManager?.getSessionId?.() !== startSessionId);
50
+ return timedOut && navigationChanged && !preparationFinished;
51
+ }
52
+
34
53
  /**
35
54
  * Read the target element's observable state under the click's own budget.
36
55
  *
@@ -975,6 +994,7 @@ export async function clickButton(options = {}) {
975
994
 
976
995
  const startUrl = page.url();
977
996
  const startSessionId = navigationManager?.getSessionId?.();
997
+ let preparationFinished = false;
978
998
 
979
999
  try {
980
1000
  const prepareResult = await prepareElement({
@@ -1012,6 +1032,7 @@ export async function clickButton(options = {}) {
1012
1032
  });
1013
1033
  }
1014
1034
 
1035
+ preparationFinished = true;
1015
1036
  const clickResult = await clickElement({
1016
1037
  page,
1017
1038
  engine,
@@ -1055,6 +1076,29 @@ export async function clickButton(options = {}) {
1055
1076
  actionId,
1056
1077
  });
1057
1078
  } catch (error) {
1079
+ if (
1080
+ waitInterruptedByNavigation({
1081
+ error,
1082
+ page,
1083
+ navigationManager,
1084
+ startUrl,
1085
+ startSessionId,
1086
+ preparationFinished,
1087
+ })
1088
+ ) {
1089
+ return makeClickResult({
1090
+ status: CLICK_STATUS.INTERRUPTED,
1091
+ dispatched: false,
1092
+ effect: CLICK_EFFECT.NOT_OBSERVED,
1093
+ navigated: true,
1094
+ reason: 'navigation interrupted the element wait before the click',
1095
+ evidence: [
1096
+ evidence('interrupted', { message: error.message, phase: 'prepare' }),
1097
+ ],
1098
+ elapsedMs: elapsed(),
1099
+ actionId,
1100
+ });
1101
+ }
1058
1102
  if (isInterrupted(error)) {
1059
1103
  log.debug(
1060
1104
  () =>
@@ -0,0 +1,28 @@
1
+ import { isVisible } from '../elements/visibility.js';
2
+
3
+ /** Dismiss only overlays explicitly named by the caller. No page markers. */
4
+ export async function dismissOverlays({ page, engine, overlays = [] }) {
5
+ if (!Array.isArray(overlays)) {
6
+ throw new TypeError('overlays must be an array');
7
+ }
8
+ for (const item of overlays) {
9
+ const options = typeof item === 'string' ? { selector: item } : item;
10
+ if (!options?.selector) {
11
+ throw new TypeError('overlay requires selector');
12
+ }
13
+ if (engine === 'playwright') {
14
+ const target = page.locator(options.selector).first();
15
+ if (await target.isVisible()) {
16
+ await target.click({ timeout: options.timeout ?? 1000 });
17
+ }
18
+ } else {
19
+ const target = await page.$(options.selector);
20
+ if (
21
+ target &&
22
+ (await isVisible({ page, engine, selector: options.selector }))
23
+ ) {
24
+ await target.click();
25
+ }
26
+ }
27
+ }
28
+ }
@@ -181,10 +181,17 @@ export async function openTraceBundle(options = {}) {
181
181
  * @param {string|Buffer} contents - What to write
182
182
  * @returns {Promise<Object|null>} `{member, bytes}` or null when dropped
183
183
  */
184
- async function writeMember(member, contents) {
184
+ async function writeMember(member, contents, { append = false } = {}) {
185
185
  const data = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
186
+ const target = path.join(root, member);
187
+ const previous = append
188
+ ? await fs.stat(target).then(
189
+ (value) => value.size,
190
+ () => 0
191
+ )
192
+ : 0;
186
193
 
187
- if (!fits(data.length)) {
194
+ if (!fits(data.length) || previous + data.length > maxResourceBytes) {
188
195
  await drop({
189
196
  reason: TRACE_DROP_REASON.SIZE_LIMIT,
190
197
  member,
@@ -193,13 +200,15 @@ export async function openTraceBundle(options = {}) {
193
200
  return null;
194
201
  }
195
202
 
196
- const target = path.join(root, member);
197
203
  try {
198
204
  await fs.mkdir(path.dirname(target), {
199
205
  recursive: true,
200
206
  mode: TRACE_DIRECTORY_MODE,
201
207
  });
202
- await fs.writeFile(target, data, { mode: TRACE_FILE_MODE });
208
+ await fs.writeFile(target, data, {
209
+ mode: TRACE_FILE_MODE,
210
+ flag: append ? 'a' : 'w',
211
+ });
203
212
  written += data.length;
204
213
  return { member, bytes: data.length };
205
214
  } catch (error) {
@@ -267,7 +276,7 @@ export async function openTraceBundle(options = {}) {
267
276
  }
268
277
  const member = `${TRACE_FILES.MUTATIONS_DIR}/${sequenceName(index)}.ndjson`;
269
278
  const body = `${batches.map((batch) => JSON.stringify(batch)).join('\n')}\n`;
270
- const result = await writeMember(member, body);
279
+ const result = await writeMember(member, body, { append: true });
271
280
  if (result) {
272
281
  counts.mutationBatches += batches.length;
273
282
  }
@@ -0,0 +1,99 @@
1
+ import { captureSnapshotInPage } from './page-capture.js';
2
+ import { withDeadline } from './deadline.js';
3
+ import { redactValue, redactUrl, REDACTED } from './redaction.js';
4
+ import { TRACE_EVENT, TRACE_DROP_REASON } from './schema.js';
5
+ export function createCheckpointRecorder({
6
+ isStopped,
7
+ captureState,
8
+ mutations,
9
+ evaluateInPage,
10
+ privacyOptions,
11
+ domOptions,
12
+ links,
13
+ limits,
14
+ captureTimeoutMs,
15
+ bundle,
16
+ screenshot,
17
+ checkpoints,
18
+ record,
19
+ }) {
20
+ /**
21
+ * Capture a named checkpoint.
22
+ *
23
+ * @param {string} name - What this moment is
24
+ * @param {Object} [checkpointOptions] - `{actor, reason, screenshots}`
25
+ * @returns {Promise<Object|null>} `{index, name, members}`
26
+ */
27
+ async function checkpoint(name, checkpointOptions = {}) {
28
+ if (isStopped()) {
29
+ throw new Error('this trace has already been stopped');
30
+ }
31
+
32
+ const { actor = 'automation', reason = 'checkpoint' } = checkpointOptions;
33
+ const index = ++captureState.index;
34
+
35
+ // Mutations are drained first so the batches belong to the interval that
36
+ // ended here, not to the one that starts now.
37
+ await mutations.drain(index - 1 > 0 ? index - 1 : 0);
38
+
39
+ let captured = null;
40
+ try {
41
+ captured = await withDeadline(
42
+ evaluateInPage(captureSnapshotInPage, {
43
+ redactSelectors: privacyOptions.redactSelectors,
44
+ redactAttributes: privacyOptions.redactAttributes,
45
+ redacted: REDACTED,
46
+ html: domOptions.html,
47
+ liveControlState: domOptions.liveControlState,
48
+ openShadowRoots: domOptions.openShadowRoots,
49
+ ignoreSelectors: domOptions.ignoreSelectors ?? [],
50
+ captureText: links?.dom === 'text',
51
+ maxHtmlBytes: limits.maxHtmlBytes ?? 0,
52
+ }),
53
+ captureTimeoutMs,
54
+ 'trace checkpoint capture'
55
+ );
56
+ } catch (error) {
57
+ await bundle.drop({
58
+ reason: /closed/i.test(error.message)
59
+ ? TRACE_DROP_REASON.PAGE_CLOSED
60
+ : TRACE_DROP_REASON.CAPTURE_FAILED,
61
+ member: `checkpoints/${index}`,
62
+ detail: error.message,
63
+ });
64
+ }
65
+
66
+ const shot = await screenshot(reason);
67
+ const state = captured?.state
68
+ ? redactValue(captured.state, privacyOptions)
69
+ : null;
70
+
71
+ const members = await bundle.writeCheckpoint({
72
+ index,
73
+ html: captured?.html ?? undefined,
74
+ state: state ? { ...state, name, actor, reason } : undefined,
75
+ screenshot: shot ?? undefined,
76
+ });
77
+
78
+ const entry = {
79
+ index,
80
+ name,
81
+ actor,
82
+ reason,
83
+ url: state ? redactUrl(state.url, privacyOptions) : null,
84
+ truncated: Boolean(captured?.truncated),
85
+ members,
86
+ };
87
+ checkpoints.push(entry);
88
+ await record(TRACE_EVENT.CHECKPOINT, entry);
89
+
90
+ // The init script covers every document created from here on; this covers
91
+ // a frame that was attached without one, which is cheap because installing
92
+ // over a recorder that is already observing does nothing.
93
+ await mutations.install();
94
+
95
+ return entry;
96
+ }
97
+
98
+ return checkpoint;
99
+ }
@@ -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,11 @@ 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
+ const seenDom = new Set();
528
587
 
529
588
  await fs.mkdir(path.dirname(file), { recursive: true });
530
589
  const handle = await fs.open(file, 'w', 0o600);
@@ -572,6 +631,24 @@ export async function openTraceLinks(options = {}) {
572
631
  return;
573
632
  }
574
633
  await append(linksForEvent(event, sections));
634
+ if (
635
+ dom &&
636
+ bundlePath &&
637
+ [TRACE_EVENT.CHECKPOINT, TRACE_EVENT.MUTATIONS].includes(event.kind)
638
+ ) {
639
+ const opened = await readTrace(bundlePath);
640
+ await append(
641
+ await domLinks(
642
+ {
643
+ ...opened,
644
+ checkpoints: event.kind === TRACE_EVENT.CHECKPOINT ? [event] : [],
645
+ events: event.kind === TRACE_EVENT.MUTATIONS ? [event] : [],
646
+ },
647
+ dom,
648
+ seenDom
649
+ )
650
+ );
651
+ }
575
652
  },
576
653
  /**
577
654
  * Finish the export.
@@ -593,6 +670,7 @@ export async function openTraceLinks(options = {}) {
593
670
  problems.push({ member: file, detail: error.message });
594
671
  }
595
672
  }
673
+
596
674
  if (sections.has('trace')) {
597
675
  links.push(
598
676
  traceResultLink(closing.manifest, {
@@ -621,3 +699,41 @@ export async function openTraceLinks(options = {}) {
621
699
  },
622
700
  };
623
701
  }
702
+
703
+ function textMutationLinks(record, batch) {
704
+ if (record.target?.visible === false) {
705
+ return [];
706
+ }
707
+ const links = [];
708
+ const target = record.target?.path ?? '';
709
+ if (record.kind === 'attributes' || record.kind === 'characterData') {
710
+ links.push(
711
+ new Link(
712
+ record.kind === 'attributes' ? 'attribute-changed' : 'text-changed',
713
+ [
714
+ field('at', batch.at),
715
+ field('path', target),
716
+ field('attribute', record.attribute ?? null),
717
+ field('before', record.before),
718
+ field('after', record.after),
719
+ ]
720
+ )
721
+ );
722
+ }
723
+ if (record.kind === 'childList') {
724
+ for (const change of ['added', 'removed']) {
725
+ for (const node of record[change] ?? []) {
726
+ if (node.visible !== false && node.text) {
727
+ links.push(
728
+ new Link(`text-${change}`, [
729
+ field('at', batch.at),
730
+ field('path', node.path ?? target),
731
+ field('text', node.text),
732
+ ])
733
+ );
734
+ }
735
+ }
736
+ }
737
+ }
738
+ return links;
739
+ }
@@ -63,6 +63,7 @@ export function createMutationStream(options) {
63
63
  globalName: RECORDER_GLOBAL,
64
64
  redactSelectors: privacyOptions.redactSelectors,
65
65
  redacted: REDACTED,
66
+ ignoreSelectors: domOptions.ignoreSelectors ?? [],
66
67
  maxQueued: limits.maxQueuedMutations ?? DEFAULT_MAX_QUEUED_MUTATIONS,
67
68
  liveState: domOptions.liveState !== false,
68
69
  };
@@ -0,0 +1,257 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { withDeadline } from './deadline.js';
4
+
5
+ const PRIVATE_HEADERS = new Set([
6
+ 'cookie',
7
+ 'authorization',
8
+ 'proxy-authorization',
9
+ 'set-cookie',
10
+ ]);
11
+ export function redactHeaders(headers = {}) {
12
+ return Object.fromEntries(
13
+ Object.entries(headers).map(([key, value]) => [
14
+ key.toLowerCase(),
15
+ PRIVATE_HEADERS.has(key.toLowerCase()) ? '[redacted]' : value,
16
+ ])
17
+ );
18
+ }
19
+
20
+ function matches(url, pattern) {
21
+ if (pattern === undefined) {
22
+ return true;
23
+ }
24
+ if (typeof pattern === 'function') {
25
+ return Boolean(pattern(url));
26
+ }
27
+ if (pattern instanceof RegExp) {
28
+ pattern.lastIndex = 0;
29
+ return pattern.test(url);
30
+ }
31
+ return url.includes(String(pattern));
32
+ }
33
+
34
+ export function networkHar(events) {
35
+ const requests = new Map(
36
+ events
37
+ .filter((event) => event.kind === 'network.request')
38
+ .map((event) => [event.requestId, event])
39
+ );
40
+ const headers = (value) =>
41
+ Object.entries(value ?? {}).map(([name, value]) => ({
42
+ name,
43
+ value: String(value),
44
+ }));
45
+ const entries = events
46
+ .filter((event) => event.kind === 'network.response')
47
+ .map((response) => {
48
+ const request = requests.get(response.requestId) ?? response;
49
+ const timing = response.timing ?? request.timing ?? {};
50
+ return {
51
+ startedDateTime: request.at ?? new Date().toISOString(),
52
+ time: Math.max(0, timing.responseEnd ?? 0),
53
+ request: {
54
+ method: request.method,
55
+ url: request.url,
56
+ httpVersion: 'HTTP/1.1',
57
+ headers: headers(request.headers),
58
+ cookies: [],
59
+ queryString: [],
60
+ headersSize: -1,
61
+ bodySize: -1,
62
+ ...(request.postData !== undefined
63
+ ? {
64
+ postData: {
65
+ mimeType: request.headers?.['content-type'] ?? '',
66
+ text: request.postData,
67
+ _truncated: request.postDataTruncated,
68
+ },
69
+ }
70
+ : {}),
71
+ },
72
+ response: {
73
+ status: response.status,
74
+ statusText: response.statusText ?? '',
75
+ httpVersion: 'HTTP/1.1',
76
+ headers: headers(response.headers),
77
+ cookies: [],
78
+ redirectURL: response.headers?.location ?? '',
79
+ headersSize: -1,
80
+ bodySize: response.body?.size ?? -1,
81
+ content: {
82
+ size: response.body?.size ?? 0,
83
+ mimeType: response.contentType ?? '',
84
+ ...(response.body
85
+ ? {
86
+ text: response.body.data,
87
+ encoding: 'base64',
88
+ _truncated: response.body.truncated,
89
+ }
90
+ : {}),
91
+ },
92
+ },
93
+ cache: {},
94
+ timings: {
95
+ send: 0,
96
+ wait: Math.max(
97
+ 0,
98
+ (timing.responseStart ?? 0) - (timing.requestStart ?? 0)
99
+ ),
100
+ receive: Math.max(
101
+ 0,
102
+ (timing.responseEnd ?? 0) - (timing.responseStart ?? 0)
103
+ ),
104
+ },
105
+ _resourceType: response.resourceType,
106
+ };
107
+ });
108
+ return {
109
+ log: {
110
+ version: '1.2',
111
+ creator: { name: 'browser-commander', version: '1' },
112
+ entries,
113
+ },
114
+ };
115
+ }
116
+
117
+ /** Observe without fetching again, and drain responses before closing the bundle. */
118
+ export function validateNetworkOptions(network) {
119
+ if (!network) {
120
+ return;
121
+ }
122
+ const options = network === true ? {} : network;
123
+ if (
124
+ typeof options !== 'object' ||
125
+ !Number.isSafeInteger(options.maxBodyBytes ?? 1024 * 1024) ||
126
+ (options.maxBodyBytes ?? 0) < 0 ||
127
+ (options.maxBodyBytes ?? 0) > 16 * 1024 * 1024
128
+ ) {
129
+ throw new RangeError(
130
+ 'network.maxBodyBytes must be an integer between 0 and 16 MiB'
131
+ );
132
+ }
133
+ }
134
+ export function attachNetwork({ page, network, record, note }) {
135
+ validateNetworkOptions(network);
136
+ if (!network) {
137
+ return async () => {};
138
+ }
139
+ const options = network === true ? {} : network;
140
+ const maxBodyBytes = options.maxBodyBytes ?? 1024 * 1024;
141
+ if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes < 0) {
142
+ throw new RangeError('network.maxBodyBytes must be a nonnegative integer');
143
+ }
144
+ const ids = new WeakMap();
145
+ const pending = new Set();
146
+ let sequence = 0;
147
+ const details = (request) => ({
148
+ requestId: ids.get(request),
149
+ method: request.method(),
150
+ url: request.url(),
151
+ resourceType: request.resourceType?.() ?? 'other',
152
+ timing: request.timing?.() ?? null,
153
+ });
154
+ const allowed = (request) =>
155
+ (!options.resourceTypes ||
156
+ options.resourceTypes.includes(request.resourceType?.())) &&
157
+ matches(request.url(), options.urlPattern ?? options.url);
158
+ const track = (promise) => {
159
+ pending.add(promise);
160
+ promise.finally(() => pending.delete(promise)).catch(() => {});
161
+ };
162
+ const request = (request) => {
163
+ if (!allowed(request)) {
164
+ return;
165
+ }
166
+ ids.set(request, ++sequence);
167
+ track(
168
+ record('network.request', {
169
+ ...details(request),
170
+ headers: redactHeaders(request.headers?.()),
171
+ ...(options.bodies &&
172
+ ['document', 'xhr', 'fetch'].includes(request.resourceType?.()) &&
173
+ request.postData?.()
174
+ ? {
175
+ postData: Buffer.from(request.postData())
176
+ .subarray(0, maxBodyBytes)
177
+ .toString('utf8'),
178
+ postDataTruncated:
179
+ Buffer.byteLength(request.postData()) > maxBodyBytes,
180
+ }
181
+ : {}),
182
+ })
183
+ );
184
+ };
185
+ const response = (response) => {
186
+ const request = response.request();
187
+ if (!ids.has(request)) {
188
+ return;
189
+ }
190
+ const saturated = pending.size >= 32;
191
+ track(
192
+ (async () => {
193
+ const headers = redactHeaders(
194
+ await withDeadline(
195
+ Promise.resolve(response.allHeaders?.() ?? response.headers()),
196
+ 5000,
197
+ 'network headers'
198
+ )
199
+ );
200
+ const payload = {
201
+ ...details(request),
202
+ status: response.status(),
203
+ statusText: response.statusText?.() ?? '',
204
+ headers,
205
+ contentType: headers['content-type'] ?? null,
206
+ };
207
+ const resourceType = payload.resourceType;
208
+ if (
209
+ !saturated &&
210
+ options.bodies &&
211
+ ['document', 'xhr', 'fetch'].includes(resourceType)
212
+ ) {
213
+ const length = Number(headers['content-length']);
214
+ if (Number.isFinite(length) && length > maxBodyBytes) {
215
+ payload.body = { size: length, truncated: true, data: '' };
216
+ } else {
217
+ try {
218
+ const data = Buffer.from(
219
+ await withDeadline(
220
+ response.body?.() ?? response.buffer(),
221
+ 5000,
222
+ 'network body'
223
+ )
224
+ );
225
+ payload.body = {
226
+ size: data.length,
227
+ truncated: data.length > maxBodyBytes,
228
+ data: data.subarray(0, maxBodyBytes).toString('base64'),
229
+ };
230
+ } catch (error) {
231
+ payload.bodyError = error.message;
232
+ }
233
+ }
234
+ }
235
+ if (saturated && options.bodies) {
236
+ payload.bodyError = 'body capture concurrency limit';
237
+ }
238
+ await record('network.response', payload);
239
+ })().catch((error) => note(`network capture: ${error.message}`))
240
+ );
241
+ };
242
+ page.on('request', request);
243
+ page.on('response', response);
244
+ return async () => {
245
+ page.off('request', request);
246
+ page.off('response', response);
247
+ await Promise.allSettled([...pending]);
248
+ };
249
+ }
250
+
251
+ export async function writeHar(opened, output) {
252
+ await fs.writeFile(
253
+ output ?? path.join(opened.path, 'network.har'),
254
+ JSON.stringify(networkHar(opened.events), null, 2),
255
+ { mode: 0o600 }
256
+ );
257
+ }