browser-commander 0.29.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 +2 -1
- package/CHANGELOG.md +6 -0
- package/README.md +7 -0
- package/package.json +1 -1
- package/scripts/run-tests.mjs +3 -1
- package/src/browser/launcher.js +19 -8
- package/src/core/commander-features.js +27 -24
- 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/mutation-stream.js +4 -3
- package/src/traces/observers.js +50 -46
- package/src/traces/recorder.js +46 -25
- package/src/traces/user-actions.js +405 -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/events.ndjson +2 -2
- package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
- package/tests/fixtures/traces/conformance/expected/trace.lino +2 -2
- package/tests/fixtures/traces/conformance/scenario.json +1 -0
- package/tests/helpers/e2e-browser.js +2 -1
- 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/recorder.test.js +4 -1
package/src/traces/recorder.js
CHANGED
|
@@ -16,10 +16,13 @@ import { openTraceLinks } from './links.js';
|
|
|
16
16
|
import { createTraceIdentity } from './identity.js';
|
|
17
17
|
import { withDeadline } from './deadline.js';
|
|
18
18
|
import { createMutationStream } from './mutation-stream.js';
|
|
19
|
-
import {
|
|
19
|
+
import { createUserActionStream } from './user-actions.js';
|
|
20
|
+
import { startTraceDrain } from './drain-timer.js';
|
|
21
|
+
import { attachRecorderObservers } from './observers.js';
|
|
20
22
|
import { attachNetwork, validateNetworkOptions } from './network.js';
|
|
21
23
|
import { startRecording } from '../capture/recording.js';
|
|
22
24
|
import { normalizePrivacyOptions, redactValue } from './redaction.js';
|
|
25
|
+
import path from 'node:path';
|
|
23
26
|
import {
|
|
24
27
|
DEFAULT_CAPTURE_TIMEOUT,
|
|
25
28
|
TRACE_CHECKPOINT_REASON,
|
|
@@ -94,13 +97,19 @@ function normalizeTraceOptions(options) {
|
|
|
94
97
|
return {
|
|
95
98
|
...options,
|
|
96
99
|
page: options.page ?? options.commander?.page,
|
|
97
|
-
mode: options.mode ?? TRACE_MODE.
|
|
100
|
+
mode: options.mode ?? TRACE_MODE.CONTINUOUS,
|
|
98
101
|
screenshots: options.screenshots ?? 'checkpoints',
|
|
99
102
|
dom: options.dom ?? {},
|
|
100
103
|
privacy: options.privacy ?? {},
|
|
101
104
|
limits: options.limits ?? {},
|
|
102
|
-
links:
|
|
103
|
-
|
|
105
|
+
links:
|
|
106
|
+
options.links === false || options.links === null
|
|
107
|
+
? null
|
|
108
|
+
: (options.links ?? {
|
|
109
|
+
output: path.join(options.output ?? '', 'trace.lino'),
|
|
110
|
+
dom: 'full',
|
|
111
|
+
}),
|
|
112
|
+
network: options.network ?? { har: true },
|
|
104
113
|
strict: options.strict ?? false,
|
|
105
114
|
captureTimeoutMs: options.captureTimeoutMs ?? DEFAULT_CAPTURE_TIMEOUT,
|
|
106
115
|
commanderVersion: options.commanderVersion ?? null,
|
|
@@ -110,6 +119,7 @@ function normalizeTraceOptions(options) {
|
|
|
110
119
|
}
|
|
111
120
|
|
|
112
121
|
export async function startTrace(options = {}) {
|
|
122
|
+
options = normalizeTraceOptions(options);
|
|
113
123
|
if (options.limits?.rotate) {
|
|
114
124
|
const { startRollingTrace } = await import('./rolling.js');
|
|
115
125
|
return startRollingTrace(options, startTrace);
|
|
@@ -151,8 +161,8 @@ export async function startTrace(options = {}) {
|
|
|
151
161
|
// Otherwise a rejected start (for example `links: {}`) strands the bundle's
|
|
152
162
|
// events handle because no running trace is returned for the caller to stop.
|
|
153
163
|
validateLinksOptions(links);
|
|
154
|
-
const engine = commander?.engine ?? null
|
|
155
|
-
|
|
164
|
+
const engine = commander?.engine ?? null,
|
|
165
|
+
identity = createTraceIdentity({ page });
|
|
156
166
|
|
|
157
167
|
// A continuous trace records changes, and a change is only meaningful against
|
|
158
168
|
// something. Taking the base snapshot automatically is the safe default,
|
|
@@ -243,6 +253,18 @@ export async function startTrace(options = {}) {
|
|
|
243
253
|
captureTimeoutMs,
|
|
244
254
|
evaluateInPage,
|
|
245
255
|
});
|
|
256
|
+
const userActions = createUserActionStream({
|
|
257
|
+
page,
|
|
258
|
+
record,
|
|
259
|
+
identity,
|
|
260
|
+
note,
|
|
261
|
+
privacyOptions,
|
|
262
|
+
limits,
|
|
263
|
+
enabled: eventSources.includes('interaction'),
|
|
264
|
+
ignoreSelectors: domOptions.ignoreSelectors,
|
|
265
|
+
evaluateInPage,
|
|
266
|
+
captureTimeoutMs,
|
|
267
|
+
});
|
|
246
268
|
|
|
247
269
|
async function screenshot(reason) {
|
|
248
270
|
const wanted =
|
|
@@ -289,7 +311,7 @@ export async function startTrace(options = {}) {
|
|
|
289
311
|
detachNetwork = async () => {},
|
|
290
312
|
video = null;
|
|
291
313
|
try {
|
|
292
|
-
|
|
314
|
+
const observers = attachRecorderObservers({
|
|
293
315
|
commander,
|
|
294
316
|
page,
|
|
295
317
|
eventSources,
|
|
@@ -298,6 +320,7 @@ export async function startTrace(options = {}) {
|
|
|
298
320
|
identity,
|
|
299
321
|
now,
|
|
300
322
|
});
|
|
323
|
+
detachers = observers.detachers;
|
|
301
324
|
detachNetwork = attachNetwork({
|
|
302
325
|
page,
|
|
303
326
|
network,
|
|
@@ -344,7 +367,11 @@ export async function startTrace(options = {}) {
|
|
|
344
367
|
events: eventSources,
|
|
345
368
|
initialCheckpoint: Boolean(baseCheckpoint),
|
|
346
369
|
});
|
|
347
|
-
await mutations.install();
|
|
370
|
+
await Promise.all([mutations.install(), userActions.install()]);
|
|
371
|
+
detachers.push(async () => {
|
|
372
|
+
await userActions.drain();
|
|
373
|
+
await userActions.stop();
|
|
374
|
+
});
|
|
348
375
|
|
|
349
376
|
if (baseCheckpoint) {
|
|
350
377
|
await checkpoint(
|
|
@@ -356,23 +383,10 @@ export async function startTrace(options = {}) {
|
|
|
356
383
|
);
|
|
357
384
|
}
|
|
358
385
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
drainTask = mutations
|
|
364
|
-
.drain(captureState.index)
|
|
365
|
-
.catch((error) => note(error.message))
|
|
366
|
-
.finally(() => {
|
|
367
|
-
drainTask = null;
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
}, 500);
|
|
371
|
-
timer.unref?.();
|
|
372
|
-
detachers.push(async () => {
|
|
373
|
-
clearInterval(timer);
|
|
374
|
-
await drainTask;
|
|
375
|
-
});
|
|
386
|
+
if (domOptions.mutations || eventSources.includes('interaction')) {
|
|
387
|
+
detachers.push(
|
|
388
|
+
startTraceDrain({ mutations, userActions, captureState, note })
|
|
389
|
+
);
|
|
376
390
|
}
|
|
377
391
|
|
|
378
392
|
/**
|
|
@@ -424,6 +438,7 @@ export async function startTrace(options = {}) {
|
|
|
424
438
|
return bundle.bytesWritten;
|
|
425
439
|
},
|
|
426
440
|
checkpoint,
|
|
441
|
+
attachCommander: observers.attachCommander,
|
|
427
442
|
/**
|
|
428
443
|
* Record an event a caller cares about on the same timeline.
|
|
429
444
|
*
|
|
@@ -460,6 +475,12 @@ export async function startTrace(options = {}) {
|
|
|
460
475
|
|
|
461
476
|
function requireTraceSupport(options) {
|
|
462
477
|
validateNetworkOptions(options.network);
|
|
478
|
+
const maxQueued = options.limits.maxQueuedUserActions ?? 5000;
|
|
479
|
+
if (!Number.isSafeInteger(maxQueued) || maxQueued < 1) {
|
|
480
|
+
throw new RangeError(
|
|
481
|
+
'maxQueuedUserActions must be a positive safe integer'
|
|
482
|
+
);
|
|
483
|
+
}
|
|
463
484
|
(options.page ?? options.commander?.page)?.requireFeature?.('tracing');
|
|
464
485
|
}
|
|
465
486
|
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { withDeadline } from './deadline.js';
|
|
2
|
+
import { framesOf, installInitScript } from './init-script.js';
|
|
3
|
+
import { TRACE_EVENT, TRACE_DROP_REASON } from './schema.js';
|
|
4
|
+
|
|
5
|
+
const bindings = new WeakMap();
|
|
6
|
+
const USER_ACTION_BINDING = '__browserCommanderUserActionsEmit__';
|
|
7
|
+
|
|
8
|
+
async function attachBinding(page, route, emit) {
|
|
9
|
+
if (!page.exposeFunction) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
let binding = bindings.get(page);
|
|
13
|
+
if (!binding) {
|
|
14
|
+
const callbacks = new Map();
|
|
15
|
+
binding = {
|
|
16
|
+
callbacks,
|
|
17
|
+
ready: page.exposeFunction(USER_ACTION_BINDING, (id, payload) =>
|
|
18
|
+
callbacks.get(id)?.(payload)
|
|
19
|
+
),
|
|
20
|
+
};
|
|
21
|
+
bindings.set(page, binding);
|
|
22
|
+
}
|
|
23
|
+
await binding.ready;
|
|
24
|
+
binding.callbacks.set(route, emit);
|
|
25
|
+
return () => binding.callbacks.delete(route);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Runs in each document. Event descriptions never read control values. */
|
|
29
|
+
export function installUserActionRecorderInPage(options) {
|
|
30
|
+
const {
|
|
31
|
+
globalName,
|
|
32
|
+
binding,
|
|
33
|
+
maxQueued,
|
|
34
|
+
redactSelectors,
|
|
35
|
+
ignoreSelectors,
|
|
36
|
+
redacted,
|
|
37
|
+
} = options;
|
|
38
|
+
if (window[globalName]?.observing) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const state = {
|
|
42
|
+
observing: true,
|
|
43
|
+
queue: [],
|
|
44
|
+
dropped: 0,
|
|
45
|
+
frameId: `frame-${Math.random().toString(36).slice(2, 10)}`,
|
|
46
|
+
};
|
|
47
|
+
const matches = (node, selectors) => {
|
|
48
|
+
for (let at = node; at; at = at.getRootNode?.()?.host) {
|
|
49
|
+
if (
|
|
50
|
+
selectors.some((selector) => {
|
|
51
|
+
try {
|
|
52
|
+
return Boolean(at.closest?.(selector));
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
};
|
|
63
|
+
const privateNode = (node) => matches(node, redactSelectors);
|
|
64
|
+
const pathOf = (node) => {
|
|
65
|
+
const steps = [];
|
|
66
|
+
let separator = '';
|
|
67
|
+
for (let at = node; at?.nodeType === 1 && steps.length < 20;) {
|
|
68
|
+
const siblings = [...(at.parentElement?.children ?? [])].filter(
|
|
69
|
+
(child) => child.localName === at.localName
|
|
70
|
+
);
|
|
71
|
+
const step = at.id
|
|
72
|
+
? `#${window.CSS.escape(at.id)}`
|
|
73
|
+
: `${at.localName}${siblings.length > 1 ? `:nth-of-type(${siblings.indexOf(at) + 1})` : ''}`;
|
|
74
|
+
steps.unshift(`${step}${separator}`);
|
|
75
|
+
if (at.id) {
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
separator = at.parentElement ? ' > ' : ' >>> ';
|
|
79
|
+
at = at.parentElement ?? at.getRootNode()?.host;
|
|
80
|
+
}
|
|
81
|
+
return steps.join('');
|
|
82
|
+
};
|
|
83
|
+
const visibleText = (node, depth = 0, budget = { remaining: 500 }) => {
|
|
84
|
+
if (
|
|
85
|
+
!node ||
|
|
86
|
+
depth >= 20 ||
|
|
87
|
+
--budget.remaining < 0 ||
|
|
88
|
+
matches(node.nodeType === 1 ? node : node.parentElement, [
|
|
89
|
+
'input',
|
|
90
|
+
'textarea',
|
|
91
|
+
'select',
|
|
92
|
+
'[contenteditable]',
|
|
93
|
+
...ignoreSelectors,
|
|
94
|
+
])
|
|
95
|
+
) {
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
if (privateNode(node.nodeType === 1 ? node : node.parentElement)) {
|
|
99
|
+
return redacted;
|
|
100
|
+
}
|
|
101
|
+
if (node.nodeType === 3) {
|
|
102
|
+
return node.nodeValue ?? '';
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
node.nodeType === 1 &&
|
|
106
|
+
(!node.getClientRects().length ||
|
|
107
|
+
window.getComputedStyle(node).visibility === 'hidden')
|
|
108
|
+
) {
|
|
109
|
+
return '';
|
|
110
|
+
}
|
|
111
|
+
let text = '';
|
|
112
|
+
for (const child of node.childNodes ?? []) {
|
|
113
|
+
text += visibleText(child, depth + 1, budget);
|
|
114
|
+
if (text.length >= 500) {
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return text.slice(0, 500);
|
|
119
|
+
};
|
|
120
|
+
const describe = (target) => {
|
|
121
|
+
const secret = privateNode(target);
|
|
122
|
+
const text = secret ? redacted : visibleText(target).trim();
|
|
123
|
+
const labelled = (target.getAttribute('aria-labelledby') ?? '')
|
|
124
|
+
.split(/\s+/)
|
|
125
|
+
.filter(Boolean)
|
|
126
|
+
.map((id) => visibleText(target.getRootNode().getElementById?.(id)))
|
|
127
|
+
.join(' ')
|
|
128
|
+
.trim();
|
|
129
|
+
const label = [...(target.labels ?? [])]
|
|
130
|
+
.slice(0, 20)
|
|
131
|
+
.map((node) => visibleText(node))
|
|
132
|
+
.join(' ')
|
|
133
|
+
.trim();
|
|
134
|
+
const roles = {
|
|
135
|
+
button: 'button',
|
|
136
|
+
a: 'link',
|
|
137
|
+
textarea: 'textbox',
|
|
138
|
+
select: 'combobox',
|
|
139
|
+
form: 'form',
|
|
140
|
+
};
|
|
141
|
+
const inputRole =
|
|
142
|
+
target.type === 'checkbox'
|
|
143
|
+
? 'checkbox'
|
|
144
|
+
: target.type === 'radio'
|
|
145
|
+
? 'radio'
|
|
146
|
+
: ['submit', 'button', 'reset'].includes(target.type)
|
|
147
|
+
? 'button'
|
|
148
|
+
: 'textbox';
|
|
149
|
+
return {
|
|
150
|
+
path: pathOf(target),
|
|
151
|
+
role:
|
|
152
|
+
target.getAttribute('role') ??
|
|
153
|
+
(target.localName === 'input'
|
|
154
|
+
? inputRole
|
|
155
|
+
: (roles[target.localName] ?? null)),
|
|
156
|
+
name: secret
|
|
157
|
+
? redacted
|
|
158
|
+
: (
|
|
159
|
+
labelled ||
|
|
160
|
+
target.getAttribute('aria-label') ||
|
|
161
|
+
label ||
|
|
162
|
+
target.getAttribute('alt') ||
|
|
163
|
+
target.getAttribute('title') ||
|
|
164
|
+
text
|
|
165
|
+
).slice(0, 500),
|
|
166
|
+
text,
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
const nonTextKeys =
|
|
170
|
+
/^(?:Enter|Escape|Tab|Backspace|Delete|Insert|Home|End|PageUp|PageDown|Arrow(?:Up|Down|Left|Right)|F(?:[1-9]|1\d|2[0-4]))$/;
|
|
171
|
+
const listener = (event) => {
|
|
172
|
+
if (
|
|
173
|
+
!event.isTrusted ||
|
|
174
|
+
(event.type === 'keydown' &&
|
|
175
|
+
(!nonTextKeys.test(event.key) || event.isComposing))
|
|
176
|
+
) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const nodes = event.composedPath?.() ?? [event.target];
|
|
180
|
+
let target = nodes.find((node) => node?.nodeType === 1);
|
|
181
|
+
if (!target || matches(target, ignoreSelectors)) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// Only suppress engine input aimed at the commander's current target.
|
|
185
|
+
// A person using another control while an automation call waits is still
|
|
186
|
+
// recorded. Trusted input outside commander methods is always user input.
|
|
187
|
+
const commanderInput = (window.__browserCommanderActions__ ?? []).some(
|
|
188
|
+
(action) => {
|
|
189
|
+
if (!action.types.includes(event.type)) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
if (action.element) {
|
|
193
|
+
return (
|
|
194
|
+
action.element === target ||
|
|
195
|
+
action.element.contains(target) ||
|
|
196
|
+
(event.type === 'click' &&
|
|
197
|
+
event.detail === 0 &&
|
|
198
|
+
action.types.includes('keydown') &&
|
|
199
|
+
action.element.form &&
|
|
200
|
+
action.element.form === target.form) ||
|
|
201
|
+
(event.type === 'submit' && target.contains(action.element))
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (!action.selector) {
|
|
205
|
+
return event.type === 'keydown';
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
return (
|
|
209
|
+
Boolean(target.closest(action.selector)) ||
|
|
210
|
+
(event.type === 'submit' &&
|
|
211
|
+
Boolean(target.querySelector(action.selector)))
|
|
212
|
+
);
|
|
213
|
+
} catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
);
|
|
218
|
+
if (commanderInput) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (event.type !== 'keydown') {
|
|
222
|
+
target =
|
|
223
|
+
target.closest(
|
|
224
|
+
'button, a, input, select, textarea, form, [role], [contenteditable]'
|
|
225
|
+
) ?? target;
|
|
226
|
+
}
|
|
227
|
+
const payload = {
|
|
228
|
+
source: 'user',
|
|
229
|
+
actor: 'user',
|
|
230
|
+
action: event.type,
|
|
231
|
+
target: describe(target),
|
|
232
|
+
at: new Date().toISOString(),
|
|
233
|
+
url: location.href,
|
|
234
|
+
frameId: state.frameId,
|
|
235
|
+
mainFrame: window.top === window,
|
|
236
|
+
...(event.type === 'keydown'
|
|
237
|
+
? {
|
|
238
|
+
key: event.key,
|
|
239
|
+
altKey: event.altKey,
|
|
240
|
+
ctrlKey: event.ctrlKey,
|
|
241
|
+
metaKey: event.metaKey,
|
|
242
|
+
shiftKey: event.shiftKey,
|
|
243
|
+
}
|
|
244
|
+
: {}),
|
|
245
|
+
};
|
|
246
|
+
const enqueue = () => {
|
|
247
|
+
if (!state.observing) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (state.queue.length < maxQueued) {
|
|
251
|
+
state.queue.push(payload);
|
|
252
|
+
} else {
|
|
253
|
+
state.dropped += 1;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
if (binding && typeof window[binding] === 'function') {
|
|
257
|
+
// Bindings deliver the action before the app's request/response callbacks;
|
|
258
|
+
// engines without bindings use the bounded polling queue instead.
|
|
259
|
+
Promise.resolve(window[binding](globalName, payload)).catch(enqueue);
|
|
260
|
+
} else {
|
|
261
|
+
enqueue();
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
const types = ['click', 'pointerdown', 'keydown', 'submit', 'contextmenu'];
|
|
265
|
+
for (const type of types) {
|
|
266
|
+
document.addEventListener(type, listener, true);
|
|
267
|
+
}
|
|
268
|
+
state.stop = () => {
|
|
269
|
+
state.observing = false;
|
|
270
|
+
for (const type of types) {
|
|
271
|
+
document.removeEventListener(type, listener, true);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
window[globalName] = state;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function drainUserActionsInPage(globalName) {
|
|
278
|
+
const state = window[globalName];
|
|
279
|
+
if (!state) {
|
|
280
|
+
return { actions: [], dropped: 0 };
|
|
281
|
+
}
|
|
282
|
+
const result = { actions: state.queue.splice(0), dropped: state.dropped };
|
|
283
|
+
state.dropped = 0;
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function stopUserActionsInPage(globalName) {
|
|
288
|
+
window[globalName]?.stop();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Host half, shared by raw launched pages and commander traces. */
|
|
292
|
+
export function createUserActionStream(options) {
|
|
293
|
+
const {
|
|
294
|
+
page,
|
|
295
|
+
enabled,
|
|
296
|
+
record,
|
|
297
|
+
identity,
|
|
298
|
+
note,
|
|
299
|
+
privacyOptions,
|
|
300
|
+
limits,
|
|
301
|
+
evaluateInPage,
|
|
302
|
+
captureTimeoutMs,
|
|
303
|
+
} = options;
|
|
304
|
+
const globalName = `__browserCommanderUserActions_${identity.traceId}`;
|
|
305
|
+
let active = true,
|
|
306
|
+
detachInit,
|
|
307
|
+
detachBinding;
|
|
308
|
+
const pending = new Set();
|
|
309
|
+
const emit = (payload) => {
|
|
310
|
+
if (!active) {
|
|
311
|
+
return Promise.resolve();
|
|
312
|
+
}
|
|
313
|
+
if (pending.size >= (limits.maxQueuedUserActions ?? 5000)) {
|
|
314
|
+
return Promise.reject(new Error('user action writer busy'));
|
|
315
|
+
}
|
|
316
|
+
const promise = record(TRACE_EVENT.INTERACTION, {
|
|
317
|
+
...payload,
|
|
318
|
+
actionId: identity.nextActionId(),
|
|
319
|
+
});
|
|
320
|
+
pending.add(promise);
|
|
321
|
+
promise.finally(() => pending.delete(promise)).catch(() => {});
|
|
322
|
+
return promise;
|
|
323
|
+
};
|
|
324
|
+
const evaluate = async (fn, arg) => {
|
|
325
|
+
const frames = framesOf(page);
|
|
326
|
+
return await withDeadline(
|
|
327
|
+
frames.length
|
|
328
|
+
? Promise.all(
|
|
329
|
+
frames.map((frame) => frame.evaluate(fn, arg).catch(() => null))
|
|
330
|
+
)
|
|
331
|
+
: Promise.resolve(evaluateInPage(fn, arg)).then((result) => [result]),
|
|
332
|
+
captureTimeoutMs,
|
|
333
|
+
'trace user actions'
|
|
334
|
+
);
|
|
335
|
+
};
|
|
336
|
+
async function install() {
|
|
337
|
+
if (!enabled) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
detachBinding = await attachBinding(page, globalName, emit).catch(
|
|
342
|
+
(error) => {
|
|
343
|
+
note(`using queued user actions: ${error.message}`);
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
);
|
|
347
|
+
const config = {
|
|
348
|
+
globalName,
|
|
349
|
+
binding: detachBinding ? USER_ACTION_BINDING : null,
|
|
350
|
+
maxQueued: limits.maxQueuedUserActions ?? 5000,
|
|
351
|
+
redactSelectors: privacyOptions.redactSelectors,
|
|
352
|
+
ignoreSelectors: options.ignoreSelectors ?? [],
|
|
353
|
+
redacted: '[redacted]',
|
|
354
|
+
};
|
|
355
|
+
detachInit = await installInitScript({
|
|
356
|
+
page,
|
|
357
|
+
fn: installUserActionRecorderInPage,
|
|
358
|
+
arg: config,
|
|
359
|
+
note,
|
|
360
|
+
});
|
|
361
|
+
await evaluate(installUserActionRecorderInPage, config);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
note(`could not install user action recorder: ${error.message}`);
|
|
364
|
+
await record(TRACE_EVENT.DROPPED, {
|
|
365
|
+
reason: TRACE_DROP_REASON.CAPTURE_FAILED,
|
|
366
|
+
member: 'user-actions',
|
|
367
|
+
detail: error.message,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
async function drain() {
|
|
372
|
+
if (!enabled) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
for (const result of await evaluate(drainUserActionsInPage, globalName)) {
|
|
377
|
+
for (const action of result?.actions ?? []) {
|
|
378
|
+
await emit(action);
|
|
379
|
+
}
|
|
380
|
+
if (result?.dropped) {
|
|
381
|
+
await record(TRACE_EVENT.DROPPED, {
|
|
382
|
+
reason: TRACE_DROP_REASON.SIZE_LIMIT,
|
|
383
|
+
member: 'user-actions',
|
|
384
|
+
detail: `${result.dropped} user actions over the queue limit`,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
await Promise.all([...pending]);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
note(`could not drain user actions: ${error.message}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
async function stop() {
|
|
394
|
+
active = false;
|
|
395
|
+
await evaluate(stopUserActionsInPage, globalName).catch((error) =>
|
|
396
|
+
note(error.message)
|
|
397
|
+
);
|
|
398
|
+
try {
|
|
399
|
+
await detachInit?.();
|
|
400
|
+
} finally {
|
|
401
|
+
detachBinding?.();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return { install, drain, stop };
|
|
405
|
+
}
|