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.
- package/CHANGELOG.md +16 -0
- package/README.md +4 -0
- package/eslint.config.js +11 -0
- package/package.json +5 -2
- package/src/browser/connector.js +117 -13
- 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 +360 -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-watchdog.js +71 -0
- package/src/browser/session-worker.js +70 -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 +66 -1
- package/src/cli/commands.js +113 -2
- package/src/cli/high-level-methods.js +40 -3
- package/src/cli/sessions.js +11 -3
- package/src/core/commander-features.js +105 -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 +51 -0
- package/src/core/page-trigger-manager.js +115 -30
- 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 +2 -0
- package/src/interactions/click.js +44 -0
- package/src/interactions/overlays.js +28 -0
- package/src/traces/bundle.js +14 -5
- package/src/traces/checkpoints.js +99 -0
- package/src/traces/links.js +117 -1
- package/src/traces/mutation-stream.js +1 -0
- package/src/traces/network.js +257 -0
- package/src/traces/page-capture.js +143 -52
- package/src/traces/reader.js +8 -10
- package/src/traces/recorder.js +221 -245
- package/src/traces/render.js +168 -0
- package/src/traces/rolling.js +180 -0
- package/src/traces/start-cleanup.js +15 -0
- package/src/traces/stop.js +159 -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/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/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -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/scripts/ci-timeout-budgets.test.js +1 -0
- package/tests/unit/traces/bundle.test.js +16 -0
- 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
|
+
}
|
package/src/traces/bundle.js
CHANGED
|
@@ -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, {
|
|
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
|
+
}
|
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,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
|
+
}
|