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.
- package/.jscpd-baseline.json +5 -19
- package/CHANGELOG.md +22 -0
- package/README.md +23 -9
- package/eslint.config.js +11 -0
- package/package.json +5 -2
- package/src/browser/browser-profile-files.js +5 -2
- package/src/browser/connector.js +215 -36
- package/src/browser/launch-options.js +2 -1
- package/src/browser/launch-restrictions.json +33 -2
- package/src/browser/launcher.js +26 -1
- package/src/browser/persistent-session.js +432 -0
- package/src/browser/profile-directory.js +20 -0
- package/src/browser/real-browser.js +7 -2
- package/src/browser/restrictions.js +13 -4
- package/src/browser/session-adoption.js +96 -0
- package/src/browser/session-watchdog.js +71 -0
- package/src/browser/session-worker.js +82 -0
- package/src/browser/webdriver.js +4 -0
- package/src/capture/encoding.js +327 -0
- package/src/capture/errors.js +12 -0
- package/src/capture/files.js +9 -0
- package/src/capture/index.js +5 -0
- package/src/capture/recording.js +136 -0
- package/src/capture/screenshot.js +222 -0
- package/src/capture/video.js +186 -0
- package/src/capture/viewport.js +68 -0
- package/src/capture/worker.js +22 -0
- package/src/cli/args.js +69 -1
- package/src/cli/commands.js +116 -2
- package/src/cli/high-level-methods.js +47 -3
- package/src/cli/sessions.js +14 -3
- package/src/core/commander-features.js +110 -0
- package/src/core/constants.js +1 -0
- package/src/core/navigation-manager.js +4 -2
- package/src/core/navigation-readiness.js +30 -2
- package/src/core/navigation-safety.js +52 -0
- package/src/core/page-trigger-manager.js +280 -156
- package/src/core/readiness.js +10 -2
- package/src/elements/locators.js +26 -32
- package/src/elements/selectors.js +37 -81
- package/src/elements/visibility.js +32 -10
- package/src/factory.js +13 -12
- package/src/index.js +4 -0
- package/src/interactions/click.js +44 -0
- package/src/interactions/overlays.js +73 -0
- package/src/traces/bundle.js +55 -8
- package/src/traces/checkpoints.js +103 -0
- package/src/traces/links.js +157 -2
- package/src/traces/mutation-stream.js +9 -2
- package/src/traces/network.js +309 -0
- package/src/traces/page-capture.js +203 -68
- package/src/traces/reader.js +8 -10
- package/src/traces/recorder.js +253 -247
- package/src/traces/redaction.js +89 -6
- package/src/traces/render.js +168 -0
- package/src/traces/rolling-bundle.js +240 -0
- package/src/traces/rolling.js +77 -0
- package/src/traces/start-cleanup.js +15 -0
- package/src/traces/stop.js +160 -0
- package/src/traces/storage.js +43 -0
- package/src/utilities/subprocess.js +32 -0
- package/tests/e2e/cli.e2e.test.js +20 -1
- package/tests/e2e/click-readiness.e2e.test.js +1 -1
- package/tests/e2e/issue-160.e2e.test.js +291 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
- package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +2 -2
- package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
- package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
- package/tests/fixtures/traces/conformance/expected/trace.lino +2 -2
- package/tests/unit/browser/issue-160-connector.test.js +157 -0
- package/tests/unit/browser/launch-building-blocks.test.js +5 -1
- package/tests/unit/browser/launcher.test.js +5 -1
- package/tests/unit/browser/persistent-session.test.js +59 -0
- package/tests/unit/browser/quiet-ui.test.js +16 -0
- package/tests/unit/browser/tab-selection.test.js +38 -0
- package/tests/unit/capture/capture.test.js +52 -0
- package/tests/unit/capture/cleanup.test.js +24 -0
- package/tests/unit/capture/viewport.test.js +168 -0
- package/tests/unit/cli/dispatcher.test.js +25 -0
- package/tests/unit/core/interrupted-navigation.test.js +32 -0
- package/tests/unit/core/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -0
- package/tests/unit/core/trigger-stop-race.test.js +80 -0
- package/tests/unit/elements/visible-count.test.js +24 -0
- package/tests/unit/elements/wait-navigation.test.js +26 -0
- package/tests/unit/interactions/navigation-timeout.test.js +39 -0
- package/tests/unit/interactions/overlays.test.js +58 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
- package/tests/unit/traces/bundle.test.js +16 -0
- package/tests/unit/traces/issue-160-privacy.test.js +110 -0
- package/tests/unit/traces/issue-160-rotation.test.js +140 -0
- package/tests/unit/traces/network.test.js +149 -0
package/src/traces/links.js
CHANGED
|
@@ -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(
|
|
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
|
+
}
|