browser-commander 0.17.2 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.jscpd-baseline.json +12 -17
  2. package/CHANGELOG.md +68 -0
  3. package/README.md +132 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +5 -1
  6. package/src/bindings.js +13 -1
  7. package/src/browser/connector.js +15 -3
  8. package/src/browser/launcher.js +14 -2
  9. package/src/browser/navigation.js +63 -0
  10. package/src/core/dialog-manager.js +48 -0
  11. package/src/core/navigation-manager.js +83 -87
  12. package/src/core/navigation-readiness.js +133 -0
  13. package/src/core/readiness.js +478 -0
  14. package/src/downloads/attach.js +58 -0
  15. package/src/downloads/destination.js +125 -0
  16. package/src/downloads/manager.js +476 -0
  17. package/src/downloads/naming.js +246 -0
  18. package/src/downloads/sources.js +238 -0
  19. package/src/downloads/staging.js +146 -0
  20. package/src/downloads/store.js +256 -0
  21. package/src/exports.js +86 -0
  22. package/src/factory.js +55 -0
  23. package/src/interactions/click-activation.js +317 -0
  24. package/src/interactions/click-result.js +108 -0
  25. package/src/interactions/click.js +766 -352
  26. package/src/tests/index.js +75 -11
  27. package/src/tests/tracing.js +236 -0
  28. package/src/traces/bundle.js +338 -0
  29. package/src/traces/deadline.js +33 -0
  30. package/src/traces/identity.js +127 -0
  31. package/src/traces/init-script.js +95 -0
  32. package/src/traces/links.js +623 -0
  33. package/src/traces/mutation-stream.js +232 -0
  34. package/src/traces/observers.js +272 -0
  35. package/src/traces/page-capture.js +601 -0
  36. package/src/traces/reader.js +226 -0
  37. package/src/traces/recorder.js +454 -0
  38. package/src/traces/redaction.js +231 -0
  39. package/src/traces/schema.js +229 -0
  40. package/src/traces/viewer.js +448 -0
  41. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  42. package/tests/e2e/downloads.e2e.test.js +235 -0
  43. package/tests/e2e/traces.e2e.test.js +742 -0
  44. package/tests/helpers/click-fixtures.js +62 -0
  45. package/tests/helpers/download-fixtures.js +125 -0
  46. package/tests/helpers/download-server.js +129 -0
  47. package/tests/helpers/e2e-browser.js +67 -0
  48. package/tests/helpers/file-modes.js +31 -0
  49. package/tests/helpers/fixture-server.js +56 -0
  50. package/tests/helpers/mocks.js +63 -66
  51. package/tests/helpers/readiness-server.js +96 -0
  52. package/tests/helpers/temp-directory.js +44 -0
  53. package/tests/helpers/trace-fixtures.js +274 -0
  54. package/tests/helpers/trace-server.js +173 -0
  55. package/tests/unit/core/dialog-manager.test.js +118 -10
  56. package/tests/unit/core/navigation-manager.test.js +162 -0
  57. package/tests/unit/core/readiness.test.js +517 -0
  58. package/tests/unit/downloads/attach.test.js +220 -0
  59. package/tests/unit/downloads/destination.test.js +114 -0
  60. package/tests/unit/downloads/manager.test.js +485 -0
  61. package/tests/unit/downloads/naming.test.js +174 -0
  62. package/tests/unit/downloads/staging.test.js +117 -0
  63. package/tests/unit/downloads/store.test.js +215 -0
  64. package/tests/unit/interactions/click-activation.test.js +283 -0
  65. package/tests/unit/interactions/click-result.test.js +90 -0
  66. package/tests/unit/interactions/click.test.js +409 -200
  67. package/tests/unit/tests/tracing.test.js +299 -0
  68. package/tests/unit/traces/bundle.test.js +260 -0
  69. package/tests/unit/traces/links.test.js +557 -0
  70. package/tests/unit/traces/reader.test.js +208 -0
  71. package/tests/unit/traces/recorder.test.js +730 -0
  72. package/tests/unit/traces/redaction.test.js +170 -0
  73. package/tests/unit/traces/schema.test.js +92 -0
  74. package/tests/unit/traces/viewer.test.js +294 -0
@@ -0,0 +1,232 @@
1
+ /**
2
+ * The continuous half of a trace (issue #93).
3
+ *
4
+ * A checkpoint is a photograph; this is the film between two of them. Keeping
5
+ * it here rather than inside the recorder is what lets it answer the three
6
+ * questions issue #93 said it could not: what happened while a new document was
7
+ * loading, what happened inside an iframe, and what happened to state the DOM
8
+ * never mentions.
9
+ *
10
+ * The in-page half lives in `page-capture.js` and is installed twice over: once
11
+ * into every document that already exists, and once as an init script so every
12
+ * document created from now on is observed before its own code runs.
13
+ */
14
+
15
+ import { withDeadline } from './deadline.js';
16
+ import { framesOf, installInitScript } from './init-script.js';
17
+ import {
18
+ drainMutationsInPage,
19
+ installMutationRecorderInPage,
20
+ RECORDER_GLOBAL,
21
+ stopMutationRecorderInPage,
22
+ } from './page-capture.js';
23
+ import { REDACTED } from './redaction.js';
24
+ import { TRACE_DROP_REASON, TRACE_EVENT } from './schema.js';
25
+
26
+ /** In-page records kept before the recorder starts counting drops instead. */
27
+ export const DEFAULT_MAX_QUEUED_MUTATIONS = 5000;
28
+
29
+ /**
30
+ * Build the recorder's continuous stream.
31
+ *
32
+ * @param {Object} options - Collaborators the stream needs
33
+ * @param {Object} options.page - The page being traced
34
+ * @param {Object} options.bundle - Open trace bundle
35
+ * @param {Function} options.record - Writes one timeline record
36
+ * @param {Object} options.identity - Trace identity, see `identity.js`
37
+ * @param {Function} options.note - Reports something that could not be done
38
+ * @param {Object} options.domOptions - Resolved `dom` options
39
+ * @param {Object} options.privacyOptions - Resolved privacy options
40
+ * @param {Object} options.limits - Resolved limits
41
+ * @param {number} options.captureTimeoutMs - Budget for one drain
42
+ * @param {Function} options.evaluateInPage - Fallback for a page without frames
43
+ * @returns {Object} `{install, installPersistent, drain, stop}`
44
+ */
45
+ export function createMutationStream(options) {
46
+ const {
47
+ page,
48
+ bundle,
49
+ record,
50
+ identity,
51
+ note,
52
+ domOptions,
53
+ privacyOptions,
54
+ limits,
55
+ captureTimeoutMs,
56
+ evaluateInPage,
57
+ } = options;
58
+
59
+ const enabled = Boolean(domOptions.mutations);
60
+
61
+ /** What the in-page recorder is built with, wherever it is installed. */
62
+ const recorderOptions = {
63
+ globalName: RECORDER_GLOBAL,
64
+ redactSelectors: privacyOptions.redactSelectors,
65
+ redacted: REDACTED,
66
+ maxQueued: limits.maxQueuedMutations ?? DEFAULT_MAX_QUEUED_MUTATIONS,
67
+ liveState: domOptions.liveState !== false,
68
+ };
69
+
70
+ /**
71
+ * Run something in every frame of the page.
72
+ *
73
+ * An iframe is a separate document with a separate observer, so a trace that
74
+ * only ever spoke to the main frame recorded nothing that happened inside
75
+ * one. A frame that goes away mid-call is expected rather than a failure: a
76
+ * frame that has already gone has nothing left to report.
77
+ *
78
+ * @param {Function} fn - The function to evaluate
79
+ * @param {*} argument - Its argument
80
+ * @returns {Promise<Array>} One result per frame that answered
81
+ */
82
+ async function evaluateInFrames(fn, argument) {
83
+ const frames = framesOf(page);
84
+ if (frames.length === 0) {
85
+ return [await evaluateInPage(fn, argument)];
86
+ }
87
+ const results = await Promise.all(
88
+ frames.map(async (frame) => {
89
+ try {
90
+ return await frame.evaluate(fn, argument);
91
+ } catch {
92
+ return null;
93
+ }
94
+ })
95
+ );
96
+ return results.filter((result) => result !== null);
97
+ }
98
+
99
+ async function dropped(
100
+ member,
101
+ error,
102
+ reason = TRACE_DROP_REASON.CAPTURE_FAILED
103
+ ) {
104
+ await bundle.drop({
105
+ reason,
106
+ member,
107
+ detail: error.message ?? String(error),
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Install the recorder into the documents that already exist.
113
+ *
114
+ * New documents are covered by the init script; this covers the one the trace
115
+ * started in, and any frame that was already open when it did.
116
+ *
117
+ * @returns {Promise<void>} Resolves once every frame has been asked
118
+ */
119
+ async function install() {
120
+ if (!enabled) {
121
+ return;
122
+ }
123
+ try {
124
+ await evaluateInFrames(installMutationRecorderInPage, recorderOptions);
125
+ } catch (error) {
126
+ await dropped('mutation-recorder', error);
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Register the recorder to run before the code of every future document.
132
+ *
133
+ * This is what closes the gap between a navigation and the next checkpoint:
134
+ * without it a new document's own scripts run unobserved, and everything they
135
+ * build looks, on replay, like it had been there all along.
136
+ *
137
+ * @returns {Promise<Function|null>} Detach, when the engine offers one
138
+ */
139
+ async function installPersistent() {
140
+ if (!enabled) {
141
+ return null;
142
+ }
143
+ try {
144
+ return await installInitScript({
145
+ page,
146
+ fn: installMutationRecorderInPage,
147
+ arg: recorderOptions,
148
+ note,
149
+ });
150
+ } catch (error) {
151
+ await dropped('mutation-recorder-init', error);
152
+ return null;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Take everything every frame has queued and write it as one interval.
158
+ *
159
+ * @param {number} index - Checkpoint the interval ends at
160
+ * @returns {Promise<string|null>} The member written, when there was anything
161
+ */
162
+ async function drain(index) {
163
+ if (!enabled) {
164
+ return null;
165
+ }
166
+ try {
167
+ const drained = await withDeadline(
168
+ evaluateInFrames(drainMutationsInPage, RECORDER_GLOBAL),
169
+ captureTimeoutMs,
170
+ 'trace mutation drain'
171
+ );
172
+
173
+ const batches = [];
174
+ let over = 0;
175
+ for (const frame of drained) {
176
+ over += frame?.dropped ?? 0;
177
+ for (const batch of frame?.batches ?? []) {
178
+ // The identity of the trace comes from here and the identity of the
179
+ // document comes from inside it: a frame handle is not something an
180
+ // NDJSON record can hold, so each document names itself.
181
+ batches.push({ ...identity.owner(), ...batch });
182
+ }
183
+ }
184
+
185
+ if (over) {
186
+ await dropped(
187
+ 'mutations',
188
+ new Error(`${over} records over the in-page queue limit`),
189
+ TRACE_DROP_REASON.SIZE_LIMIT
190
+ );
191
+ }
192
+
193
+ // Frames are drained in parallel, so the batches come back grouped by
194
+ // frame; one ordered file is what a replay reads.
195
+ batches.sort((left, right) => (left.at ?? 0) - (right.at ?? 0));
196
+
197
+ const member = await bundle.writeMutations(index, batches);
198
+ if (member) {
199
+ await record(TRACE_EVENT.MUTATIONS, {
200
+ member,
201
+ batches: batches.length,
202
+ checkpoint: index,
203
+ frames: drained.length,
204
+ });
205
+ }
206
+ return member;
207
+ } catch (error) {
208
+ await dropped('mutations', error);
209
+ return null;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Switch the recorder off in every document that has one.
215
+ *
216
+ * A recorder left observing would keep queueing into a queue nobody drains.
217
+ *
218
+ * @returns {Promise<void>} Resolves once every frame has been asked
219
+ */
220
+ async function stop() {
221
+ if (!enabled) {
222
+ return;
223
+ }
224
+ try {
225
+ await evaluateInFrames(stopMutationRecorderInPage, RECORDER_GLOBAL);
226
+ } catch (error) {
227
+ note?.(`could not stop the in-page recorder: ${error.message}`);
228
+ }
229
+ }
230
+
231
+ return { install, installPersistent, drain, stop };
232
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * The observers behind a trace's shared timeline (issue #87).
3
+ *
4
+ * Navigation, Browser Commander interactions, console output, page errors,
5
+ * dialogs, failed requests and downloads are reported by different objects in
6
+ * different engines. They all end up as records on one ordered timeline, and
7
+ * this module is the only place that knows how each of them is reached.
8
+ */
9
+
10
+ import { TRACE_EVENT } from './schema.js';
11
+
12
+ /** Commander methods recorded as interactions while a trace is running. */
13
+ export const TRACED_INTERACTIONS = Object.freeze([
14
+ 'click',
15
+ 'clickButton',
16
+ 'fill',
17
+ 'fillTextArea',
18
+ 'goto',
19
+ 'pressKey',
20
+ 'typeText',
21
+ 'setContent',
22
+ 'scrollIntoView',
23
+ ]);
24
+
25
+ /**
26
+ * What an interaction was aimed at.
27
+ *
28
+ * A selector and a URL are the useful part of an interaction; typed text is
29
+ * never taken, because that is where passwords are. Most commander methods
30
+ * take an options object, and a few legacy ones take the selector directly,
31
+ * so both shapes are read here.
32
+ *
33
+ * @param {*} argument - The interaction's first argument
34
+ * @returns {string|null} The selector or URL it acted on
35
+ */
36
+ export function interactionTarget(argument) {
37
+ if (typeof argument === 'string') {
38
+ return argument;
39
+ }
40
+ if (!argument || typeof argument !== 'object') {
41
+ return null;
42
+ }
43
+ for (const key of ['selector', 'url', 'locatorOrElement']) {
44
+ if (typeof argument[key] === 'string') {
45
+ return argument[key];
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * Subscribe to everything a running trace listens to.
53
+ *
54
+ * @param {Object} options - `{commander, page, eventSources, record, note, identity}`
55
+ * @param {Object} [options.commander] - The commander being traced, when there
56
+ * is one; interactions and managed downloads are reached through it
57
+ * @param {Object} options.page - The page being traced
58
+ * @param {string[]} options.eventSources - Sources the caller asked for
59
+ * @param {Function} options.record - Writes one timeline record
60
+ * @param {Function} options.note - Reports an observer that could not attach
61
+ * @param {Object} [options.identity] - Trace identity, see `identity.js`; the
62
+ * navigation observer is what moves it on to the next navigation, and the
63
+ * interaction observer is what names an action (issue #93)
64
+ * @returns {Function[]} One detach function per attached observer
65
+ */
66
+ export function attachTimelineObservers(options) {
67
+ const { commander, page, eventSources, record, note, identity } = options;
68
+ const detachers = [];
69
+
70
+ function subscribe(source, attach) {
71
+ if (!eventSources.includes(source)) {
72
+ return;
73
+ }
74
+ try {
75
+ const detach = attach();
76
+ if (typeof detach === 'function') {
77
+ detachers.push(detach);
78
+ }
79
+ } catch (error) {
80
+ note(`could not observe ${source}: ${error.message}`);
81
+ }
82
+ }
83
+
84
+ function onPage(eventName, listener) {
85
+ page.on?.(eventName, listener);
86
+ return () =>
87
+ page.off?.(eventName, listener) ??
88
+ page.removeListener?.(eventName, listener);
89
+ }
90
+
91
+ subscribe('navigation', () => {
92
+ const manager = commander?.navigationManager;
93
+ if (!manager) {
94
+ // Without the manager the engine's own event still gives ordering.
95
+ return onPage('framenavigated', (frame) => {
96
+ const isMain =
97
+ typeof frame?.parentFrame === 'function'
98
+ ? frame.parentFrame() === null
99
+ : true;
100
+ // A record's navigation is the one it happened during, so the counter
101
+ // moves on before the record that reports the move is written.
102
+ const navigationId = isMain
103
+ ? identity?.navigated()
104
+ : identity?.navigationId;
105
+ void record(TRACE_EVENT.NAVIGATION, {
106
+ phase: 'framenavigated',
107
+ navigationId,
108
+ mainFrame: isMain,
109
+ url: typeof frame?.url === 'function' ? frame.url() : String(frame),
110
+ });
111
+ });
112
+ }
113
+ const started = (info) =>
114
+ void record(TRACE_EVENT.NAVIGATION, { phase: 'start', ...info });
115
+ const completed = (info) =>
116
+ void record(TRACE_EVENT.NAVIGATION, {
117
+ phase: 'complete',
118
+ // Everything recorded after this belongs to the document that just
119
+ // loaded, which is the distinction a replay needs to stop mixing two
120
+ // pages' changes together (issue #93).
121
+ navigationId: identity?.navigated(),
122
+ ...info,
123
+ });
124
+ const changed = (info) =>
125
+ void record(TRACE_EVENT.NAVIGATION, { phase: 'urlchange', ...info });
126
+ manager.on('onNavigationStart', started);
127
+ manager.on('onNavigationComplete', completed);
128
+ manager.on('onUrlChange', changed);
129
+ return () => {
130
+ manager.off?.('onNavigationStart', started);
131
+ manager.off?.('onNavigationComplete', completed);
132
+ manager.off?.('onUrlChange', changed);
133
+ };
134
+ });
135
+
136
+ subscribe('console', () =>
137
+ onPage('console', (message) => {
138
+ void record(TRACE_EVENT.CONSOLE, {
139
+ level: typeof message?.type === 'function' ? message.type() : null,
140
+ text:
141
+ typeof message?.text === 'function'
142
+ ? message.text()
143
+ : String(message),
144
+ });
145
+ })
146
+ );
147
+
148
+ subscribe('pageerror', () =>
149
+ onPage('pageerror', (error) => {
150
+ void record(TRACE_EVENT.PAGE_ERROR, {
151
+ message: error?.message ?? String(error),
152
+ stack: error?.stack ?? null,
153
+ });
154
+ })
155
+ );
156
+
157
+ subscribe('requestfailed', () =>
158
+ onPage('requestfailed', (request) => {
159
+ void record(TRACE_EVENT.REQUEST_FAILED, {
160
+ url: typeof request?.url === 'function' ? request.url() : null,
161
+ method: typeof request?.method === 'function' ? request.method() : null,
162
+ failure:
163
+ typeof request?.failure === 'function'
164
+ ? (request.failure()?.errorText ?? null)
165
+ : null,
166
+ });
167
+ })
168
+ );
169
+
170
+ subscribe('dialog', () => {
171
+ const manager = commander?.dialogManager;
172
+ if (!manager?.observeDialogs && !manager?.onDialog) {
173
+ return null;
174
+ }
175
+ const listener = (dialog) => {
176
+ void record(TRACE_EVENT.DIALOG, {
177
+ type: typeof dialog?.type === 'function' ? dialog.type() : null,
178
+ message:
179
+ typeof dialog?.message === 'function' ? dialog.message() : null,
180
+ });
181
+ };
182
+ // Recording a dialog must not decide what happens to it: a passive
183
+ // observer leaves the manager's auto-dismissal in place, so a traced run
184
+ // answers dialogs exactly the way the same untraced run would.
185
+ if (manager.observeDialogs) {
186
+ manager.observeDialogs(listener);
187
+ return () => manager.unobserveDialogs?.(listener);
188
+ }
189
+ manager.onDialog(listener);
190
+ return () => manager.offDialog?.(listener);
191
+ });
192
+
193
+ subscribe('download', () => {
194
+ const downloads = commander?.downloads;
195
+ if (!downloads?.on) {
196
+ return null;
197
+ }
198
+ const listeners = ['started', 'completed', 'failed', 'cancelled'].map(
199
+ (phase) => {
200
+ const listener = (artifact) => {
201
+ void record(TRACE_EVENT.DOWNLOAD, {
202
+ phase,
203
+ id: artifact?.id ?? null,
204
+ // The path and checksum are the reference; the bytes stay where
205
+ // the download manager put them.
206
+ suggestedFilename: artifact?.suggestedFilename ?? null,
207
+ path: artifact?.path ?? null,
208
+ checksum: artifact?.checksum ?? null,
209
+ bytes: artifact?.bytes ?? null,
210
+ url: artifact?.url ?? null,
211
+ failure: artifact?.failure ?? null,
212
+ });
213
+ };
214
+ downloads.on(phase, listener);
215
+ return [phase, listener];
216
+ }
217
+ );
218
+ return () => {
219
+ for (const [phase, listener] of listeners) {
220
+ downloads.off?.(phase, listener);
221
+ }
222
+ };
223
+ });
224
+
225
+ subscribe('interaction', () => {
226
+ if (!commander) {
227
+ return null;
228
+ }
229
+ const originals = new Map();
230
+ for (const name of TRACED_INTERACTIONS) {
231
+ const original = commander[name];
232
+ if (typeof original !== 'function') {
233
+ continue;
234
+ }
235
+ originals.set(name, original);
236
+ commander[name] = async (...args) => {
237
+ const startedMs = Date.now();
238
+ const target = interactionTarget(args[0]);
239
+ // Named before the call, so everything the action causes can be traced
240
+ // back to it even when the action itself ends in an exception.
241
+ const actionId = identity?.nextActionId() ?? null;
242
+ try {
243
+ const result = await original(...args);
244
+ await record(TRACE_EVENT.INTERACTION, {
245
+ actionId,
246
+ action: name,
247
+ target,
248
+ durationMs: Date.now() - startedMs,
249
+ ok: true,
250
+ });
251
+ return result;
252
+ } catch (error) {
253
+ await record(TRACE_EVENT.INTERACTION, {
254
+ actionId,
255
+ action: name,
256
+ target,
257
+ durationMs: Date.now() - startedMs,
258
+ ok: false,
259
+ error: error.message,
260
+ });
261
+ throw error;
262
+ }
263
+ };
264
+ }
265
+ return () => {
266
+ for (const [name, original] of originals) {
267
+ commander[name] = original;
268
+ }
269
+ };
270
+ });
271
+ return detachers;
272
+ }