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,231 @@
1
+ /**
2
+ * Redaction applied before a trace byte reaches disk (issue #87).
3
+ *
4
+ * Browser traces routinely contain credentials. Redaction therefore runs
5
+ * where the data is produced — inside the page for DOM and control state, and
6
+ * on every string this module touches for URLs, headers and messages — rather
7
+ * than as a pass over a file that has already been written.
8
+ */
9
+
10
+ /** What replaces a redacted value. Recognizable, and never a valid secret. */
11
+ export const REDACTED = '[redacted]';
12
+
13
+ /** Controls whose value is a secret unless the caller says otherwise. */
14
+ export const DEFAULT_REDACT_SELECTORS = Object.freeze([
15
+ 'input[type=password]',
16
+ '[data-private]',
17
+ '[data-bc-redact]',
18
+ ]);
19
+
20
+ /** Attributes and headers that carry credentials. */
21
+ export const DEFAULT_REDACT_ATTRIBUTES = Object.freeze([
22
+ 'authorization',
23
+ 'proxy-authorization',
24
+ 'cookie',
25
+ 'set-cookie',
26
+ 'x-api-key',
27
+ 'x-auth-token',
28
+ 'x-csrf-token',
29
+ ]);
30
+
31
+ /** Query parameters that carry credentials in a URL. */
32
+ export const DEFAULT_REDACT_QUERY_PARAMS = Object.freeze([
33
+ 'access_token',
34
+ 'api_key',
35
+ 'apikey',
36
+ 'auth',
37
+ 'code',
38
+ 'id_token',
39
+ 'password',
40
+ 'refresh_token',
41
+ 'secret',
42
+ 'session',
43
+ 'signature',
44
+ 'token',
45
+ ]);
46
+
47
+ function lowerSet(values) {
48
+ return [...new Set(values.map((value) => String(value).toLowerCase()))];
49
+ }
50
+
51
+ /**
52
+ * Turn the caller's `privacy` option into the shape every writer uses.
53
+ *
54
+ * @param {Object} [privacy] - Caller privacy options
55
+ * @returns {Object} Normalized privacy options
56
+ */
57
+ export function normalizePrivacyOptions(privacy = {}) {
58
+ if (privacy === null || typeof privacy !== 'object') {
59
+ throw new Error('trace privacy options must be an object');
60
+ }
61
+
62
+ const {
63
+ redactSelectors = [],
64
+ redactAttributes = [],
65
+ redactQueryParams = [],
66
+ redactPatterns = [],
67
+ redact = null,
68
+ // Defaults are additive: a caller adds to the safe set rather than
69
+ // silently replacing it, so a new selector cannot uncover passwords.
70
+ useDefaults = true,
71
+ } = privacy;
72
+
73
+ if (redact !== null && typeof redact !== 'function') {
74
+ throw new Error('privacy.redact must be a function');
75
+ }
76
+
77
+ return {
78
+ redactSelectors: lowerSet([
79
+ ...(useDefaults ? DEFAULT_REDACT_SELECTORS : []),
80
+ ...redactSelectors,
81
+ ]),
82
+ redactAttributes: lowerSet([
83
+ ...(useDefaults ? DEFAULT_REDACT_ATTRIBUTES : []),
84
+ ...redactAttributes,
85
+ ]),
86
+ redactQueryParams: lowerSet([
87
+ ...(useDefaults ? DEFAULT_REDACT_QUERY_PARAMS : []),
88
+ ...redactQueryParams,
89
+ ]),
90
+ redactPatterns: redactPatterns.map((pattern) =>
91
+ pattern instanceof RegExp ? pattern : new RegExp(String(pattern), 'g')
92
+ ),
93
+ redact,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Apply the caller's patterns and callback to a string.
99
+ *
100
+ * @param {string} value - Text about to be persisted
101
+ * @param {Object} privacy - Normalized privacy options
102
+ * @param {Object} [context] - What the text is, passed to the callback
103
+ * @returns {string} Redacted text
104
+ */
105
+ export function redactText(value, privacy, context = {}) {
106
+ if (typeof value !== 'string' || value === '') {
107
+ return value;
108
+ }
109
+
110
+ let text = value;
111
+ for (const pattern of privacy.redactPatterns || []) {
112
+ // A caller's regex may be stateful; resetting keeps one trace's output
113
+ // from depending on the previous record's match position.
114
+ pattern.lastIndex = 0;
115
+ text = text.replace(pattern, REDACTED);
116
+ }
117
+
118
+ if (privacy.redact) {
119
+ const replaced = privacy.redact({ ...context, value: text });
120
+ if (typeof replaced === 'string') {
121
+ text = replaced;
122
+ }
123
+ }
124
+
125
+ return text;
126
+ }
127
+
128
+ /**
129
+ * Redact credentials carried in a URL.
130
+ *
131
+ * `https://user:pass@host/path?token=abc` becomes a URL that still identifies
132
+ * the page without handing over the session it belonged to.
133
+ *
134
+ * @param {string} url - URL about to be persisted
135
+ * @param {Object} privacy - Normalized privacy options
136
+ * @returns {string} Redacted URL
137
+ */
138
+ export function redactUrl(url, privacy) {
139
+ if (typeof url !== 'string' || url === '') {
140
+ return url;
141
+ }
142
+
143
+ let text = url;
144
+ try {
145
+ const parsed = new URL(url);
146
+ if (parsed.username || parsed.password) {
147
+ parsed.username = parsed.username ? REDACTED : '';
148
+ parsed.password = parsed.password ? REDACTED : '';
149
+ }
150
+ for (const key of [...parsed.searchParams.keys()]) {
151
+ if (privacy.redactQueryParams.includes(key.toLowerCase())) {
152
+ parsed.searchParams.set(key, REDACTED);
153
+ }
154
+ }
155
+ if (parsed.hash) {
156
+ // Implicit OAuth flows put the token in the fragment.
157
+ const fragment = new URLSearchParams(parsed.hash.slice(1));
158
+ let changed = false;
159
+ for (const key of [...fragment.keys()]) {
160
+ if (privacy.redactQueryParams.includes(key.toLowerCase())) {
161
+ fragment.set(key, REDACTED);
162
+ changed = true;
163
+ }
164
+ }
165
+ if (changed) {
166
+ parsed.hash = `#${fragment.toString()}`;
167
+ }
168
+ }
169
+ // `URL` percent-encodes whatever it is given, so the marker is put back
170
+ // in readable form: a trace is read by people.
171
+ text = parsed.toString().split(encodeURIComponent(REDACTED)).join(REDACTED);
172
+ } catch {
173
+ // A relative or malformed URL is still worth keeping, just unparsed.
174
+ }
175
+
176
+ return redactText(text, privacy, { kind: 'url' });
177
+ }
178
+
179
+ /**
180
+ * Redact a header or attribute map.
181
+ *
182
+ * @param {Object} headers - Header name/value pairs
183
+ * @param {Object} privacy - Normalized privacy options
184
+ * @returns {Object} Map with sensitive values replaced
185
+ */
186
+ export function redactHeaders(headers, privacy) {
187
+ if (!headers || typeof headers !== 'object') {
188
+ return headers;
189
+ }
190
+
191
+ const result = {};
192
+ for (const [name, value] of Object.entries(headers)) {
193
+ result[name] = privacy.redactAttributes.includes(name.toLowerCase())
194
+ ? REDACTED
195
+ : redactText(String(value), privacy, { kind: 'header', name });
196
+ }
197
+ return result;
198
+ }
199
+
200
+ /**
201
+ * Redact every string in an event payload.
202
+ *
203
+ * Applied to whatever a caller or engine put in an event, because a trace is
204
+ * only as private as its least careful field.
205
+ *
206
+ * @param {*} value - Any JSON-compatible value
207
+ * @param {Object} privacy - Normalized privacy options
208
+ * @param {string} [key] - Field name the value was found under
209
+ * @returns {*} The value with strings redacted
210
+ */
211
+ export function redactValue(value, privacy, key = '') {
212
+ if (typeof value === 'string') {
213
+ if (privacy.redactAttributes.includes(key.toLowerCase())) {
214
+ return REDACTED;
215
+ }
216
+ return /url$/i.test(key)
217
+ ? redactUrl(value, privacy)
218
+ : redactText(value, privacy, { kind: 'field', name: key });
219
+ }
220
+ if (Array.isArray(value)) {
221
+ return value.map((entry) => redactValue(entry, privacy, key));
222
+ }
223
+ if (value && typeof value === 'object') {
224
+ const result = {};
225
+ for (const [name, entry] of Object.entries(value)) {
226
+ result[name] = redactValue(entry, privacy, name);
227
+ }
228
+ return result;
229
+ }
230
+ return value;
231
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * The portable browser trace format (issue #87).
3
+ *
4
+ * One engine-neutral, versioned layout is written by JavaScript and read by
5
+ * JavaScript, Python and Rust. Everything about the format that a reader has
6
+ * to agree on lives here, so a change to the layout is a change to one file.
7
+ */
8
+
9
+ /**
10
+ * Schema version of the bundle.
11
+ *
12
+ * Readers refuse a major version they were not written for rather than
13
+ * guessing at the meaning of unknown records.
14
+ *
15
+ * Version 2 (issue #93) is what makes continuous replay complete: mutation
16
+ * batches name the frame they came from, child-list records carry the position
17
+ * a node was inserted at or removed from, and live control state - typing,
18
+ * checking, selecting, focus and scroll - is recorded as it happens instead of
19
+ * only at checkpoints. A version 1 reader given a version 2 bundle would
20
+ * quietly replay a different page, so it refuses instead.
21
+ */
22
+ export const TRACE_SCHEMA_VERSION = 2;
23
+
24
+ /** Name every reader looks for inside a bundle. */
25
+ export const TRACE_FILES = Object.freeze({
26
+ MANIFEST: 'manifest.json',
27
+ EVENTS: 'events.ndjson',
28
+ CHECKPOINTS_DIR: 'checkpoints',
29
+ MUTATIONS_DIR: 'mutations',
30
+ ARTIFACTS_DIR: 'artifacts',
31
+ VIEWER: 'viewer.html',
32
+ });
33
+
34
+ /** What the recorder captures. */
35
+ export const TRACE_MODE = Object.freeze({
36
+ OFF: 'off',
37
+ CHECKPOINTS: 'checkpoints',
38
+ CONTINUOUS: 'continuous',
39
+ RETAIN_ON_FAILURE: 'retain-on-failure',
40
+ });
41
+
42
+ /** Event kinds that share the one ordered timeline. */
43
+ export const TRACE_EVENT = Object.freeze({
44
+ TRACE_START: 'trace.start',
45
+ TRACE_STOP: 'trace.stop',
46
+ CHECKPOINT: 'checkpoint',
47
+ MUTATIONS: 'mutations',
48
+ NAVIGATION: 'navigation',
49
+ INTERACTION: 'interaction',
50
+ CONSOLE: 'console',
51
+ PAGE_ERROR: 'pageerror',
52
+ DIALOG: 'dialog',
53
+ REQUEST_FAILED: 'requestfailed',
54
+ DOWNLOAD: 'download',
55
+ /** Something could not be recorded. The run continues; the gap is visible. */
56
+ DROPPED: 'dropped',
57
+ });
58
+
59
+ /** Record kinds inside a mutation batch. */
60
+ export const TRACE_MUTATION_KIND = Object.freeze({
61
+ ATTRIBUTES: 'attributes',
62
+ CHARACTER_DATA: 'characterData',
63
+ CHILD_LIST: 'childList',
64
+ /**
65
+ * A change to what an element holds rather than to the document.
66
+ *
67
+ * Typing into an input, checking a box, choosing an option, moving focus and
68
+ * scrolling all change what the user sees and none of them mutate the DOM,
69
+ * so no observer reports them and replay skipped them entirely (issue #93).
70
+ */
71
+ LIVE_STATE: 'live-state',
72
+ });
73
+
74
+ /** What a `live-state` record changed. */
75
+ export const TRACE_LIVE_STATE = Object.freeze({
76
+ VALUE: 'value',
77
+ CHECKED: 'checked',
78
+ SELECTED: 'selected',
79
+ FOCUS: 'focus',
80
+ SCROLL: 'scroll',
81
+ });
82
+
83
+ /** Why a checkpoint was taken. */
84
+ export const TRACE_CHECKPOINT_REASON = Object.freeze({
85
+ /** The base snapshot every later record is a change against. */
86
+ INITIAL: 'initial',
87
+ /** A caller named this moment. */
88
+ CHECKPOINT: 'checkpoint',
89
+ /** The run failed here. */
90
+ FAILURE: 'failure',
91
+ });
92
+
93
+ /** Event families a caller can subscribe the recorder to. */
94
+ export const TRACE_EVENT_SOURCES = Object.freeze([
95
+ 'navigation',
96
+ 'interaction',
97
+ 'console',
98
+ 'pageerror',
99
+ 'dialog',
100
+ 'requestfailed',
101
+ 'download',
102
+ ]);
103
+
104
+ /** Why a record was dropped. */
105
+ export const TRACE_DROP_REASON = Object.freeze({
106
+ SIZE_LIMIT: 'size-limit',
107
+ WRITE_FAILED: 'write-failed',
108
+ CAPTURE_FAILED: 'capture-failed',
109
+ PAGE_CLOSED: 'page-closed',
110
+ TIMEOUT: 'timeout',
111
+ });
112
+
113
+ /** How a trace ended, recorded in the manifest. */
114
+ export const TRACE_OUTCOME = Object.freeze({
115
+ /** `stop()` was called and every record was written. */
116
+ COMPLETE: 'complete',
117
+ /** The trace is readable but records are missing. */
118
+ PARTIAL: 'partial',
119
+ /** No manifest was ever written; readers repair this on open. */
120
+ TRUNCATED: 'truncated',
121
+ });
122
+
123
+ /** Default ceiling on a bundle, so a runaway page cannot fill a disk. */
124
+ export const DEFAULT_MAX_BUNDLE_BYTES = 256 * 1024 * 1024;
125
+
126
+ /** Default ceiling on any single member of a bundle. */
127
+ export const DEFAULT_MAX_RESOURCE_BYTES = 32 * 1024 * 1024;
128
+
129
+ /** How long a single capture may take before it is dropped. */
130
+ export const DEFAULT_CAPTURE_TIMEOUT = 15000;
131
+
132
+ /**
133
+ * Format a bundle member's sequence number.
134
+ *
135
+ * Zero padding keeps `ls` and any reader that sorts lexically in the same
136
+ * order as the sequence itself.
137
+ *
138
+ * @param {number} index - 1-based sequence number
139
+ * @returns {string} Zero-padded name, such as `0001`
140
+ */
141
+ export function sequenceName(index) {
142
+ return String(index).padStart(4, '0');
143
+ }
144
+
145
+ /**
146
+ * What a bundle's records let a viewer reproduce.
147
+ *
148
+ * Issue #93 asked for the viewer to stop claiming more than it can do. Saying
149
+ * it in the manifest is better than saying it in the viewer's markup: a bundle
150
+ * recorded by an older version is honestly described by its own manifest, and
151
+ * any of the three readers can tell a caller what they are looking at.
152
+ */
153
+ export const DEFAULT_REPLAY_SUPPORT = Object.freeze({
154
+ /** Checkpoints are snapshots, always replayable. */
155
+ checkpoints: true,
156
+ /** DOM mutations are recorded between checkpoints. */
157
+ mutations: false,
158
+ /** Child-list records say where a node went, so removals and moves replay. */
159
+ childListPositions: false,
160
+ /** Typing, checking, selecting, focus and scroll are recorded as they happen. */
161
+ liveState: false,
162
+ /** Every record names the context, page, navigation and frame it belongs to. */
163
+ identifiers: false,
164
+ });
165
+
166
+ /**
167
+ * Build the manifest of a trace.
168
+ *
169
+ * @param {Object} options - Manifest fields
170
+ * @returns {Object} The manifest as it is written to disk
171
+ */
172
+ export function createManifest(options = {}) {
173
+ const {
174
+ mode,
175
+ startedAt,
176
+ stoppedAt = null,
177
+ outcome = TRACE_OUTCOME.COMPLETE,
178
+ commanderVersion = null,
179
+ engine = null,
180
+ browser = null,
181
+ platform = `${process.platform} ${process.arch}`,
182
+ runtime = `node ${process.versions?.node ?? 'unknown'}`,
183
+ counts = {},
184
+ limits = {},
185
+ privacy = {},
186
+ dom = {},
187
+ events = [],
188
+ dropped = 0,
189
+ replay = {},
190
+ } = options;
191
+
192
+ return {
193
+ schemaVersion: TRACE_SCHEMA_VERSION,
194
+ format: 'browser-commander-trace',
195
+ mode,
196
+ outcome,
197
+ startedAt,
198
+ stoppedAt,
199
+ commanderVersion,
200
+ engine,
201
+ browser,
202
+ platform,
203
+ runtime,
204
+ events: [...events],
205
+ dom: { ...dom },
206
+ replay: { ...DEFAULT_REPLAY_SUPPORT, ...replay },
207
+ privacy: { ...privacy },
208
+ limits: { ...limits },
209
+ counts: { checkpoints: 0, events: 0, mutationBatches: 0, ...counts },
210
+ dropped,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Check that a manifest can be read by this version of the format.
216
+ *
217
+ * @param {Object} manifest - Parsed manifest
218
+ * @throws {Error} When the bundle is not a readable trace
219
+ */
220
+ export function assertReadableManifest(manifest) {
221
+ if (!manifest || manifest.format !== 'browser-commander-trace') {
222
+ throw new Error('not a Browser Commander trace bundle');
223
+ }
224
+ if (manifest.schemaVersion > TRACE_SCHEMA_VERSION) {
225
+ throw new Error(
226
+ `trace schema version ${manifest.schemaVersion} is newer than this reader (${TRACE_SCHEMA_VERSION})`
227
+ );
228
+ }
229
+ }