browser-commander 0.28.0 → 0.30.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 +7 -20
- package/CHANGELOG.md +12 -0
- package/README.md +27 -10
- package/package.json +1 -1
- package/scripts/run-tests.mjs +3 -1
- package/src/browser/browser-profile-files.js +5 -2
- package/src/browser/connector.js +140 -65
- package/src/browser/launcher.js +19 -8
- package/src/browser/persistent-session.js +110 -38
- package/src/browser/session-adoption.js +96 -0
- package/src/browser/session-worker.js +12 -0
- package/src/cli/args.js +3 -0
- package/src/cli/commands.js +3 -0
- package/src/cli/high-level-methods.js +7 -0
- package/src/cli/sessions.js +3 -0
- package/src/core/commander-features.js +33 -25
- package/src/core/navigation-safety.js +1 -0
- package/src/core/page-trigger-manager.js +173 -134
- package/src/index.js +2 -0
- package/src/interactions/overlays.js +59 -14
- package/src/traces/bundle.js +41 -3
- package/src/traces/checkpoints.js +7 -3
- package/src/traces/commander-actions.js +146 -0
- package/src/traces/debug-session.js +119 -0
- package/src/traces/drain-timer.js +25 -0
- package/src/traces/links.js +44 -5
- package/src/traces/mutation-stream.js +12 -5
- package/src/traces/network.js +65 -13
- package/src/traces/observers.js +50 -46
- package/src/traces/page-capture.js +65 -21
- package/src/traces/recorder.js +79 -28
- package/src/traces/redaction.js +89 -6
- package/src/traces/rolling-bundle.js +240 -0
- package/src/traces/rolling.js +10 -113
- package/src/traces/stop.js +1 -0
- package/src/traces/user-actions.js +405 -0
- package/tests/e2e/issue-160.e2e.test.js +291 -0
- package/tests/e2e/issue-162-recording.e2e.test.js +175 -0
- package/tests/e2e/traces.e2e.test.js +4 -1
- 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 +4 -4
- 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 +4 -4
- package/tests/fixtures/traces/conformance/scenario.json +1 -0
- package/tests/helpers/e2e-browser.js +2 -1
- package/tests/unit/browser/issue-160-connector.test.js +157 -0
- package/tests/unit/core/interrupted-navigation.test.js +32 -0
- package/tests/unit/core/trigger-stop-race.test.js +80 -0
- package/tests/unit/interactions/overlays.test.js +58 -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/issue-162-defaults.test.js +223 -0
- package/tests/unit/traces/issue-162-user-actions.test.js +181 -0
- package/tests/unit/traces/network.test.js +55 -3
- package/tests/unit/traces/recorder.test.js +4 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/** One wrapper per commander, even when explicit and automatic traces overlap. */
|
|
2
|
+
import { withDeadline } from './deadline.js';
|
|
3
|
+
import { framesOf } from './init-script.js';
|
|
4
|
+
|
|
5
|
+
const commanders = new WeakMap();
|
|
6
|
+
|
|
7
|
+
let actionSequence = 0;
|
|
8
|
+
|
|
9
|
+
function labelActionInPage(argument, elementOptions) {
|
|
10
|
+
const { marker, remove } = elementOptions ?? argument;
|
|
11
|
+
const actions = (window.__browserCommanderActions__ ??= []);
|
|
12
|
+
if (remove) {
|
|
13
|
+
const index = actions.findIndex((action) => action.id === marker.id);
|
|
14
|
+
if (index >= 0) {
|
|
15
|
+
actions.splice(index, 1);
|
|
16
|
+
}
|
|
17
|
+
} else {
|
|
18
|
+
const focused =
|
|
19
|
+
!marker.selector && marker.types.includes('keydown')
|
|
20
|
+
? document.activeElement
|
|
21
|
+
: null;
|
|
22
|
+
actions.push({
|
|
23
|
+
...marker,
|
|
24
|
+
element: elementOptions
|
|
25
|
+
? argument
|
|
26
|
+
: focused !== document.body && focused !== document.documentElement
|
|
27
|
+
? focused
|
|
28
|
+
: null,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function observeCommanderActions({
|
|
34
|
+
commander,
|
|
35
|
+
page,
|
|
36
|
+
names,
|
|
37
|
+
targetOf,
|
|
38
|
+
record,
|
|
39
|
+
identity,
|
|
40
|
+
now,
|
|
41
|
+
note,
|
|
42
|
+
}) {
|
|
43
|
+
if (!commander) {
|
|
44
|
+
return () => {};
|
|
45
|
+
}
|
|
46
|
+
let state = commanders.get(commander);
|
|
47
|
+
if (!state) {
|
|
48
|
+
state = { observers: new Set(), originals: new Map() };
|
|
49
|
+
commanders.set(commander, state);
|
|
50
|
+
for (const name of names) {
|
|
51
|
+
const original = commander[name];
|
|
52
|
+
if (typeof original !== 'function') {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const wrapper = async (...args) => {
|
|
56
|
+
await commander.ready;
|
|
57
|
+
const observing = [...state.observers].map((observer) => ({
|
|
58
|
+
observer,
|
|
59
|
+
started: observer.now(),
|
|
60
|
+
actionId: observer.identity?.nextActionId() ?? null,
|
|
61
|
+
}));
|
|
62
|
+
const marker = {
|
|
63
|
+
id: ++actionSequence,
|
|
64
|
+
selector: targetOf(args[0]),
|
|
65
|
+
types: /^(click|check)/.test(name)
|
|
66
|
+
? ['pointerdown', 'click', 'submit', 'contextmenu']
|
|
67
|
+
: /^(fill|typeText|pressKey)/.test(name)
|
|
68
|
+
? ['keydown', 'submit', 'click']
|
|
69
|
+
: [],
|
|
70
|
+
};
|
|
71
|
+
const element = args[0]?.locatorOrElement ?? args[0]?.selector;
|
|
72
|
+
const labelAction = async (remove) => {
|
|
73
|
+
try {
|
|
74
|
+
if (marker.types.length) {
|
|
75
|
+
const config = { marker, remove };
|
|
76
|
+
const frames =
|
|
77
|
+
remove || (!marker.selector && marker.types.includes('keydown'))
|
|
78
|
+
? framesOf(page)
|
|
79
|
+
: [];
|
|
80
|
+
const target = element?.first?.() ?? element;
|
|
81
|
+
const work = frames.length
|
|
82
|
+
? Promise.all(
|
|
83
|
+
frames.map((frame) =>
|
|
84
|
+
frame.evaluate(labelActionInPage, config)
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
: !remove && typeof target?.evaluate === 'function'
|
|
88
|
+
? target.evaluate(labelActionInPage, config, {
|
|
89
|
+
timeout: 1000,
|
|
90
|
+
})
|
|
91
|
+
: page.evaluate?.(labelActionInPage, config);
|
|
92
|
+
await withDeadline(
|
|
93
|
+
Promise.resolve(work),
|
|
94
|
+
1000,
|
|
95
|
+
'commander input label'
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
note?.(`could not label commander input: ${error.message}`);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
await labelAction(false);
|
|
103
|
+
let error;
|
|
104
|
+
try {
|
|
105
|
+
return await original.apply(commander, args);
|
|
106
|
+
} catch (failure) {
|
|
107
|
+
error = failure;
|
|
108
|
+
throw failure;
|
|
109
|
+
} finally {
|
|
110
|
+
await labelAction(true);
|
|
111
|
+
await Promise.all(
|
|
112
|
+
observing.map(({ observer, started, actionId }) =>
|
|
113
|
+
observer.record('interaction', {
|
|
114
|
+
actionId,
|
|
115
|
+
source: 'commander',
|
|
116
|
+
action: name,
|
|
117
|
+
target: targetOf(args[0]),
|
|
118
|
+
durationMs: observer.now() - started,
|
|
119
|
+
ok: !error,
|
|
120
|
+
...(error ? { error: error.message } : {}),
|
|
121
|
+
})
|
|
122
|
+
)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
state.originals.set(name, { original, wrapper });
|
|
127
|
+
commander[name] = wrapper;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const observer = { record, identity, now };
|
|
131
|
+
if (record) {
|
|
132
|
+
state.observers.add(observer);
|
|
133
|
+
}
|
|
134
|
+
return () => {
|
|
135
|
+
state.observers.delete(observer);
|
|
136
|
+
if (state.observers.size) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
for (const [name, { original, wrapper }] of state.originals) {
|
|
140
|
+
if (commander[name] === wrapper) {
|
|
141
|
+
commander[name] = original;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
commanders.delete(commander);
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { startTrace } from './recorder.js';
|
|
4
|
+
|
|
5
|
+
const sessions = new WeakMap();
|
|
6
|
+
const disabledPages = new WeakSet();
|
|
7
|
+
|
|
8
|
+
export function debugEnabled(options = {}) {
|
|
9
|
+
return (
|
|
10
|
+
options.debug !== false &&
|
|
11
|
+
process.env.BROWSER_COMMANDER_DEBUG !== '0' &&
|
|
12
|
+
!(options.debug === undefined && disabledPages.has(options.page))
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A launched page and its commander share one recording and one output path. */
|
|
17
|
+
export async function ensureDebugSession(options) {
|
|
18
|
+
const { page, commander } = options;
|
|
19
|
+
const existing = sessions.get(page);
|
|
20
|
+
if (!debugEnabled(options)) {
|
|
21
|
+
disabledPages.add(page);
|
|
22
|
+
await existing?.stop();
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
disabledPages.delete(page);
|
|
26
|
+
let session = existing;
|
|
27
|
+
if (!session) {
|
|
28
|
+
const config =
|
|
29
|
+
options.debug && typeof options.debug === 'object' ? options.debug : {};
|
|
30
|
+
const output = path.resolve(
|
|
31
|
+
config.output ??
|
|
32
|
+
options.output ??
|
|
33
|
+
process.env.BROWSER_COMMANDER_TRACE ??
|
|
34
|
+
path.join(
|
|
35
|
+
'browser-commander-traces',
|
|
36
|
+
`${new Date().toISOString().replaceAll(':', '-')}-${process.pid}-${randomUUID().slice(0, 8)}`
|
|
37
|
+
)
|
|
38
|
+
);
|
|
39
|
+
session = { path: output };
|
|
40
|
+
sessions.set(page, session);
|
|
41
|
+
session.ready = startTrace({
|
|
42
|
+
...config,
|
|
43
|
+
output,
|
|
44
|
+
page,
|
|
45
|
+
commander: commander ?? {
|
|
46
|
+
page,
|
|
47
|
+
engine: options.engine,
|
|
48
|
+
downloads: options.downloads,
|
|
49
|
+
},
|
|
50
|
+
checkpointOnNavigation: config.checkpointOnNavigation ?? true,
|
|
51
|
+
})
|
|
52
|
+
.then((trace) => {
|
|
53
|
+
console.error(`[browser-commander] Recording session to ${output}`);
|
|
54
|
+
return trace;
|
|
55
|
+
})
|
|
56
|
+
.catch((error) => {
|
|
57
|
+
sessions.delete(page);
|
|
58
|
+
console.warn(
|
|
59
|
+
`[browser-commander] Could not start recording: ${error.message}`
|
|
60
|
+
);
|
|
61
|
+
return null;
|
|
62
|
+
});
|
|
63
|
+
let stopping;
|
|
64
|
+
session.stop = () =>
|
|
65
|
+
(stopping ??= session.ready.then(async (trace) => {
|
|
66
|
+
try {
|
|
67
|
+
return await trace?.stop();
|
|
68
|
+
} finally {
|
|
69
|
+
if (sessions.get(page) === session) {
|
|
70
|
+
sessions.delete(page);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
const trace = await session.ready;
|
|
76
|
+
if (!trace) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
if (commander) {
|
|
80
|
+
trace.attachCommander(commander);
|
|
81
|
+
}
|
|
82
|
+
return session;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Finish trace files before closing/detaching the engine that supplies them. */
|
|
86
|
+
export async function attachLaunchDebug(result, options) {
|
|
87
|
+
const session = await ensureDebugSession({
|
|
88
|
+
...options,
|
|
89
|
+
engine:
|
|
90
|
+
options.engine ??
|
|
91
|
+
(result.page.isWebDriverPage ? 'selenium' : 'playwright'),
|
|
92
|
+
page: result.page,
|
|
93
|
+
downloads: result.downloads,
|
|
94
|
+
});
|
|
95
|
+
result.tracePath = session?.path ?? null;
|
|
96
|
+
if (!session) {
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
const wrap = (owner, name) => {
|
|
100
|
+
const method = owner?.[name];
|
|
101
|
+
if (typeof method !== 'function') {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
owner[name] = async (...args) => {
|
|
105
|
+
let result;
|
|
106
|
+
try {
|
|
107
|
+
await session.stop();
|
|
108
|
+
} finally {
|
|
109
|
+
result = await method.apply(owner, args);
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
wrap(result, 'close');
|
|
115
|
+
wrap(result, 'detach');
|
|
116
|
+
wrap(result.browser, 'close');
|
|
117
|
+
wrap(result.browser, 'disconnect');
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Serialize background drains and wait for the active drain during teardown. */
|
|
2
|
+
export function startTraceDrain({
|
|
3
|
+
mutations,
|
|
4
|
+
userActions,
|
|
5
|
+
captureState,
|
|
6
|
+
note,
|
|
7
|
+
}) {
|
|
8
|
+
let task;
|
|
9
|
+
const timer = setInterval(() => {
|
|
10
|
+
if (!task) {
|
|
11
|
+
task = Promise.resolve()
|
|
12
|
+
.then(() => userActions.drain())
|
|
13
|
+
.then(() => mutations.drain(captureState.index))
|
|
14
|
+
.catch((error) => note(error.message))
|
|
15
|
+
.finally(() => {
|
|
16
|
+
task = null;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}, 500);
|
|
20
|
+
timer.unref?.();
|
|
21
|
+
return async () => {
|
|
22
|
+
clearInterval(timer);
|
|
23
|
+
await task;
|
|
24
|
+
};
|
|
25
|
+
}
|
package/src/traces/links.js
CHANGED
|
@@ -583,7 +583,9 @@ export async function openTraceLinks(options = {}) {
|
|
|
583
583
|
const file = await resolveOutput(output);
|
|
584
584
|
const sections = chosenSections(include);
|
|
585
585
|
const problems = [];
|
|
586
|
-
|
|
586
|
+
// Only the current interval is remembered; completed intervals never recur.
|
|
587
|
+
let mutationMember = null;
|
|
588
|
+
let mutationOffset = 0;
|
|
587
589
|
|
|
588
590
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
589
591
|
const handle = await fs.open(file, 'w', 0o600);
|
|
@@ -626,7 +628,7 @@ export async function openTraceLinks(options = {}) {
|
|
|
626
628
|
* @param {Object} event - The event as written to the bundle
|
|
627
629
|
* @returns {Promise<void>}
|
|
628
630
|
*/
|
|
629
|
-
async event(event) {
|
|
631
|
+
async event(event, source = {}) {
|
|
630
632
|
if (!event) {
|
|
631
633
|
return;
|
|
632
634
|
}
|
|
@@ -636,7 +638,45 @@ export async function openTraceLinks(options = {}) {
|
|
|
636
638
|
bundlePath &&
|
|
637
639
|
[TRACE_EVENT.CHECKPOINT, TRACE_EVENT.MUTATIONS].includes(event.kind)
|
|
638
640
|
) {
|
|
639
|
-
const
|
|
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
|
+
};
|
|
640
680
|
await append(
|
|
641
681
|
await domLinks(
|
|
642
682
|
{
|
|
@@ -644,8 +684,7 @@ export async function openTraceLinks(options = {}) {
|
|
|
644
684
|
checkpoints: event.kind === TRACE_EVENT.CHECKPOINT ? [event] : [],
|
|
645
685
|
events: event.kind === TRACE_EVENT.MUTATIONS ? [event] : [],
|
|
646
686
|
},
|
|
647
|
-
dom
|
|
648
|
-
seenDom
|
|
687
|
+
dom
|
|
649
688
|
)
|
|
650
689
|
);
|
|
651
690
|
}
|
|
@@ -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. */
|
|
@@ -57,11 +57,14 @@ export function createMutationStream(options) {
|
|
|
57
57
|
} = options;
|
|
58
58
|
|
|
59
59
|
const enabled = Boolean(domOptions.mutations);
|
|
60
|
+
const globalName = `${RECORDER_GLOBAL}${identity.traceId}`;
|
|
60
61
|
|
|
61
62
|
/** What the in-page recorder is built with, wherever it is installed. */
|
|
62
63
|
const recorderOptions = {
|
|
63
|
-
globalName
|
|
64
|
+
globalName,
|
|
64
65
|
redactSelectors: privacyOptions.redactSelectors,
|
|
66
|
+
redactAttributes: privacyOptions.redactAttributes,
|
|
67
|
+
useDefaults: privacyOptions.useDefaults,
|
|
65
68
|
redacted: REDACTED,
|
|
66
69
|
ignoreSelectors: domOptions.ignoreSelectors ?? [],
|
|
67
70
|
maxQueued: limits.maxQueuedMutations ?? DEFAULT_MAX_QUEUED_MUTATIONS,
|
|
@@ -166,7 +169,7 @@ export function createMutationStream(options) {
|
|
|
166
169
|
}
|
|
167
170
|
try {
|
|
168
171
|
const drained = await withDeadline(
|
|
169
|
-
evaluateInFrames(drainMutationsInPage,
|
|
172
|
+
evaluateInFrames(drainMutationsInPage, globalName),
|
|
170
173
|
captureTimeoutMs,
|
|
171
174
|
'trace mutation drain'
|
|
172
175
|
);
|
|
@@ -195,13 +198,17 @@ export function createMutationStream(options) {
|
|
|
195
198
|
// frame; one ordered file is what a replay reads.
|
|
196
199
|
batches.sort((left, right) => (left.at ?? 0) - (right.at ?? 0));
|
|
197
200
|
|
|
198
|
-
const member = await bundle.writeMutations(
|
|
201
|
+
const member = await bundle.writeMutations(
|
|
202
|
+
index,
|
|
203
|
+
redactValue(batches, privacyOptions)
|
|
204
|
+
);
|
|
199
205
|
if (member) {
|
|
200
206
|
await record(TRACE_EVENT.MUTATIONS, {
|
|
201
207
|
member,
|
|
202
208
|
batches: batches.length,
|
|
203
209
|
checkpoint: index,
|
|
204
210
|
frames: drained.length,
|
|
211
|
+
truncated: bundle.mutationTruncated,
|
|
205
212
|
});
|
|
206
213
|
}
|
|
207
214
|
return member;
|
|
@@ -223,7 +230,7 @@ export function createMutationStream(options) {
|
|
|
223
230
|
return;
|
|
224
231
|
}
|
|
225
232
|
try {
|
|
226
|
-
await evaluateInFrames(stopMutationRecorderInPage,
|
|
233
|
+
await evaluateInFrames(stopMutationRecorderInPage, globalName);
|
|
227
234
|
} catch (error) {
|
|
228
235
|
note?.(`could not stop the in-page recorder: ${error.message}`);
|
|
229
236
|
}
|
package/src/traces/network.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { withDeadline } from './deadline.js';
|
|
4
|
+
import {
|
|
5
|
+
normalizePrivacyOptions,
|
|
6
|
+
redactText,
|
|
7
|
+
redactHeaders as privateHeaders,
|
|
8
|
+
} from './redaction.js';
|
|
4
9
|
|
|
5
10
|
const PRIVATE_HEADERS = new Set([
|
|
6
11
|
'cookie',
|
|
@@ -9,11 +14,20 @@ const PRIVATE_HEADERS = new Set([
|
|
|
9
14
|
'set-cookie',
|
|
10
15
|
]);
|
|
11
16
|
export function redactHeaders(headers = {}) {
|
|
12
|
-
return
|
|
13
|
-
Object.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
17
31
|
);
|
|
18
32
|
}
|
|
19
33
|
|
|
@@ -81,10 +95,12 @@ export function networkHar(events) {
|
|
|
81
95
|
content: {
|
|
82
96
|
size: response.body?.size ?? 0,
|
|
83
97
|
mimeType: response.contentType ?? '',
|
|
84
|
-
...(response.body
|
|
98
|
+
...(response.body?.data !== undefined
|
|
85
99
|
? {
|
|
86
100
|
text: response.body.data,
|
|
87
|
-
encoding
|
|
101
|
+
...(response.body.encoding === 'utf8'
|
|
102
|
+
? {}
|
|
103
|
+
: { encoding: 'base64' }),
|
|
88
104
|
_truncated: response.body.truncated,
|
|
89
105
|
}
|
|
90
106
|
: {}),
|
|
@@ -131,7 +147,13 @@ export function validateNetworkOptions(network) {
|
|
|
131
147
|
);
|
|
132
148
|
}
|
|
133
149
|
}
|
|
134
|
-
export function attachNetwork({
|
|
150
|
+
export function attachNetwork({
|
|
151
|
+
page,
|
|
152
|
+
network,
|
|
153
|
+
record,
|
|
154
|
+
note,
|
|
155
|
+
privacy = normalizePrivacyOptions(),
|
|
156
|
+
}) {
|
|
135
157
|
validateNetworkOptions(network);
|
|
136
158
|
if (!network) {
|
|
137
159
|
return async () => {};
|
|
@@ -172,7 +194,12 @@ export function attachNetwork({ page, network, record, note }) {
|
|
|
172
194
|
['document', 'xhr', 'fetch'].includes(request.resourceType?.()) &&
|
|
173
195
|
request.postData?.()
|
|
174
196
|
? {
|
|
175
|
-
postData: Buffer.from(
|
|
197
|
+
postData: Buffer.from(
|
|
198
|
+
redactText(request.postData(), privacy, {
|
|
199
|
+
kind: 'network',
|
|
200
|
+
name: 'postData',
|
|
201
|
+
})
|
|
202
|
+
)
|
|
176
203
|
.subarray(0, maxBodyBytes)
|
|
177
204
|
.toString('utf8'),
|
|
178
205
|
postDataTruncated:
|
|
@@ -210,9 +237,26 @@ export function attachNetwork({ page, network, record, note }) {
|
|
|
210
237
|
options.bodies &&
|
|
211
238
|
['document', 'xhr', 'fetch'].includes(resourceType)
|
|
212
239
|
) {
|
|
213
|
-
|
|
214
|
-
if (Number.isFinite(length) &&
|
|
215
|
-
|
|
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
|
+
};
|
|
216
260
|
} else {
|
|
217
261
|
try {
|
|
218
262
|
const data = Buffer.from(
|
|
@@ -225,7 +269,15 @@ export function attachNetwork({ page, network, record, note }) {
|
|
|
225
269
|
payload.body = {
|
|
226
270
|
size: data.length,
|
|
227
271
|
truncated: data.length > maxBodyBytes,
|
|
228
|
-
|
|
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'),
|
|
229
281
|
};
|
|
230
282
|
} catch (error) {
|
|
231
283
|
payload.bodyError = error.message;
|
package/src/traces/observers.js
CHANGED
|
@@ -8,11 +8,49 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { TRACE_EVENT } from './schema.js';
|
|
11
|
+
import { observeCommanderActions } from './commander-actions.js';
|
|
12
|
+
|
|
13
|
+
/** Keep page observations when a launched trace is adopted by a commander. */
|
|
14
|
+
export function attachRecorderObservers(options) {
|
|
15
|
+
const ownerSources = ['interaction', 'dialog', 'download', 'navigation'];
|
|
16
|
+
const detachers = attachTimelineObservers({
|
|
17
|
+
...options,
|
|
18
|
+
commander: null,
|
|
19
|
+
eventSources: options.eventSources.filter(
|
|
20
|
+
(source) => !ownerSources.includes(source)
|
|
21
|
+
),
|
|
22
|
+
});
|
|
23
|
+
let commanderDetachers = [],
|
|
24
|
+
attachedCommander,
|
|
25
|
+
hasCommander = false;
|
|
26
|
+
const attachCommander = (commander) => {
|
|
27
|
+
if (hasCommander && commander === attachedCommander) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
hasCommander = true;
|
|
31
|
+
attachedCommander = commander;
|
|
32
|
+
for (const detach of commanderDetachers) {
|
|
33
|
+
detach();
|
|
34
|
+
}
|
|
35
|
+
commanderDetachers = attachTimelineObservers({
|
|
36
|
+
...options,
|
|
37
|
+
commander,
|
|
38
|
+
eventSources: options.eventSources.filter((source) =>
|
|
39
|
+
ownerSources.includes(source)
|
|
40
|
+
),
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
attachCommander(options.commander);
|
|
44
|
+
detachers.push(() => commanderDetachers.forEach((detach) => detach()));
|
|
45
|
+
return { detachers, attachCommander };
|
|
46
|
+
}
|
|
11
47
|
|
|
12
48
|
/** Commander methods recorded as interactions while a trace is running. */
|
|
13
49
|
export const TRACED_INTERACTIONS = Object.freeze([
|
|
14
50
|
'click',
|
|
15
51
|
'clickButton',
|
|
52
|
+
'clickElement',
|
|
53
|
+
'check',
|
|
16
54
|
'fill',
|
|
17
55
|
'fillTextArea',
|
|
18
56
|
'goto',
|
|
@@ -231,51 +269,17 @@ export function attachTimelineObservers(options) {
|
|
|
231
269
|
};
|
|
232
270
|
});
|
|
233
271
|
|
|
234
|
-
subscribe('interaction', () =>
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const startedMs = now();
|
|
247
|
-
const target = interactionTarget(args[0]);
|
|
248
|
-
// Named before the call, so everything the action causes can be traced
|
|
249
|
-
// back to it even when the action itself ends in an exception.
|
|
250
|
-
const actionId = identity?.nextActionId() ?? null;
|
|
251
|
-
try {
|
|
252
|
-
const result = await original(...args);
|
|
253
|
-
await record(TRACE_EVENT.INTERACTION, {
|
|
254
|
-
actionId,
|
|
255
|
-
action: name,
|
|
256
|
-
target,
|
|
257
|
-
durationMs: now() - startedMs,
|
|
258
|
-
ok: true,
|
|
259
|
-
});
|
|
260
|
-
return result;
|
|
261
|
-
} catch (error) {
|
|
262
|
-
await record(TRACE_EVENT.INTERACTION, {
|
|
263
|
-
actionId,
|
|
264
|
-
action: name,
|
|
265
|
-
target,
|
|
266
|
-
durationMs: now() - startedMs,
|
|
267
|
-
ok: false,
|
|
268
|
-
error: error.message,
|
|
269
|
-
});
|
|
270
|
-
throw error;
|
|
271
|
-
}
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
return () => {
|
|
275
|
-
for (const [name, original] of originals) {
|
|
276
|
-
commander[name] = original;
|
|
277
|
-
}
|
|
278
|
-
};
|
|
279
|
-
});
|
|
272
|
+
subscribe('interaction', () =>
|
|
273
|
+
observeCommanderActions({
|
|
274
|
+
commander,
|
|
275
|
+
page,
|
|
276
|
+
names: TRACED_INTERACTIONS,
|
|
277
|
+
targetOf: interactionTarget,
|
|
278
|
+
record,
|
|
279
|
+
identity,
|
|
280
|
+
now,
|
|
281
|
+
note,
|
|
282
|
+
})
|
|
283
|
+
);
|
|
280
284
|
return detachers;
|
|
281
285
|
}
|