browser-commander 0.17.2 → 0.18.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 +4 -17
- package/CHANGELOG.md +34 -0
- package/README.md +84 -0
- package/eslint.config.js +5 -0
- package/package.json +4 -1
- package/src/bindings.js +13 -1
- package/src/browser/connector.js +15 -3
- package/src/browser/launcher.js +14 -2
- package/src/browser/navigation.js +63 -0
- package/src/core/dialog-manager.js +48 -0
- package/src/core/navigation-manager.js +83 -87
- package/src/core/navigation-readiness.js +133 -0
- package/src/core/readiness.js +478 -0
- package/src/downloads/attach.js +58 -0
- package/src/downloads/destination.js +125 -0
- package/src/downloads/manager.js +453 -0
- package/src/downloads/naming.js +246 -0
- package/src/downloads/sources.js +188 -0
- package/src/downloads/store.js +256 -0
- package/src/exports.js +72 -0
- package/src/factory.js +55 -0
- package/src/interactions/click-activation.js +317 -0
- package/src/interactions/click-result.js +108 -0
- package/src/interactions/click.js +766 -352
- package/src/tests/index.js +75 -11
- package/src/tests/tracing.js +236 -0
- package/src/traces/bundle.js +323 -0
- package/src/traces/observers.js +246 -0
- package/src/traces/page-capture.js +365 -0
- package/src/traces/reader.js +226 -0
- package/src/traces/recorder.js +437 -0
- package/src/traces/redaction.js +231 -0
- package/src/traces/schema.js +165 -0
- package/src/traces/viewer.js +302 -0
- package/tests/e2e/click-readiness.e2e.test.js +240 -0
- package/tests/e2e/downloads.e2e.test.js +188 -0
- package/tests/e2e/traces.e2e.test.js +457 -0
- package/tests/helpers/click-fixtures.js +62 -0
- package/tests/helpers/download-fixtures.js +125 -0
- package/tests/helpers/download-server.js +109 -0
- package/tests/helpers/e2e-browser.js +67 -0
- package/tests/helpers/file-modes.js +31 -0
- package/tests/helpers/fixture-server.js +56 -0
- package/tests/helpers/mocks.js +63 -66
- package/tests/helpers/readiness-server.js +96 -0
- package/tests/helpers/temp-directory.js +44 -0
- package/tests/helpers/trace-fixtures.js +198 -0
- package/tests/helpers/trace-server.js +117 -0
- package/tests/unit/core/dialog-manager.test.js +118 -10
- package/tests/unit/core/navigation-manager.test.js +162 -0
- package/tests/unit/core/readiness.test.js +517 -0
- package/tests/unit/downloads/attach.test.js +220 -0
- package/tests/unit/downloads/destination.test.js +114 -0
- package/tests/unit/downloads/manager.test.js +354 -0
- package/tests/unit/downloads/naming.test.js +174 -0
- package/tests/unit/downloads/store.test.js +215 -0
- package/tests/unit/interactions/click-activation.test.js +283 -0
- package/tests/unit/interactions/click-result.test.js +90 -0
- package/tests/unit/interactions/click.test.js +409 -200
- package/tests/unit/tests/tracing.test.js +299 -0
- package/tests/unit/traces/bundle.test.js +260 -0
- package/tests/unit/traces/reader.test.js +208 -0
- package/tests/unit/traces/recorder.test.js +562 -0
- package/tests/unit/traces/redaction.test.js +170 -0
- package/tests/unit/traces/schema.test.js +92 -0
- package/tests/unit/traces/viewer.test.js +289 -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,165 @@
|
|
|
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
|
+
export const TRACE_SCHEMA_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
/** Name every reader looks for inside a bundle. */
|
|
18
|
+
export const TRACE_FILES = Object.freeze({
|
|
19
|
+
MANIFEST: 'manifest.json',
|
|
20
|
+
EVENTS: 'events.ndjson',
|
|
21
|
+
CHECKPOINTS_DIR: 'checkpoints',
|
|
22
|
+
MUTATIONS_DIR: 'mutations',
|
|
23
|
+
ARTIFACTS_DIR: 'artifacts',
|
|
24
|
+
VIEWER: 'viewer.html',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** What the recorder captures. */
|
|
28
|
+
export const TRACE_MODE = Object.freeze({
|
|
29
|
+
OFF: 'off',
|
|
30
|
+
CHECKPOINTS: 'checkpoints',
|
|
31
|
+
CONTINUOUS: 'continuous',
|
|
32
|
+
RETAIN_ON_FAILURE: 'retain-on-failure',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** Event kinds that share the one ordered timeline. */
|
|
36
|
+
export const TRACE_EVENT = Object.freeze({
|
|
37
|
+
TRACE_START: 'trace.start',
|
|
38
|
+
TRACE_STOP: 'trace.stop',
|
|
39
|
+
CHECKPOINT: 'checkpoint',
|
|
40
|
+
MUTATIONS: 'mutations',
|
|
41
|
+
NAVIGATION: 'navigation',
|
|
42
|
+
INTERACTION: 'interaction',
|
|
43
|
+
CONSOLE: 'console',
|
|
44
|
+
PAGE_ERROR: 'pageerror',
|
|
45
|
+
DIALOG: 'dialog',
|
|
46
|
+
REQUEST_FAILED: 'requestfailed',
|
|
47
|
+
DOWNLOAD: 'download',
|
|
48
|
+
/** Something could not be recorded. The run continues; the gap is visible. */
|
|
49
|
+
DROPPED: 'dropped',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** Event families a caller can subscribe the recorder to. */
|
|
53
|
+
export const TRACE_EVENT_SOURCES = Object.freeze([
|
|
54
|
+
'navigation',
|
|
55
|
+
'interaction',
|
|
56
|
+
'console',
|
|
57
|
+
'pageerror',
|
|
58
|
+
'dialog',
|
|
59
|
+
'requestfailed',
|
|
60
|
+
'download',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/** Why a record was dropped. */
|
|
64
|
+
export const TRACE_DROP_REASON = Object.freeze({
|
|
65
|
+
SIZE_LIMIT: 'size-limit',
|
|
66
|
+
WRITE_FAILED: 'write-failed',
|
|
67
|
+
CAPTURE_FAILED: 'capture-failed',
|
|
68
|
+
PAGE_CLOSED: 'page-closed',
|
|
69
|
+
TIMEOUT: 'timeout',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** How a trace ended, recorded in the manifest. */
|
|
73
|
+
export const TRACE_OUTCOME = Object.freeze({
|
|
74
|
+
/** `stop()` was called and every record was written. */
|
|
75
|
+
COMPLETE: 'complete',
|
|
76
|
+
/** The trace is readable but records are missing. */
|
|
77
|
+
PARTIAL: 'partial',
|
|
78
|
+
/** No manifest was ever written; readers repair this on open. */
|
|
79
|
+
TRUNCATED: 'truncated',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** Default ceiling on a bundle, so a runaway page cannot fill a disk. */
|
|
83
|
+
export const DEFAULT_MAX_BUNDLE_BYTES = 256 * 1024 * 1024;
|
|
84
|
+
|
|
85
|
+
/** Default ceiling on any single member of a bundle. */
|
|
86
|
+
export const DEFAULT_MAX_RESOURCE_BYTES = 32 * 1024 * 1024;
|
|
87
|
+
|
|
88
|
+
/** How long a single capture may take before it is dropped. */
|
|
89
|
+
export const DEFAULT_CAPTURE_TIMEOUT = 15000;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Format a bundle member's sequence number.
|
|
93
|
+
*
|
|
94
|
+
* Zero padding keeps `ls` and any reader that sorts lexically in the same
|
|
95
|
+
* order as the sequence itself.
|
|
96
|
+
*
|
|
97
|
+
* @param {number} index - 1-based sequence number
|
|
98
|
+
* @returns {string} Zero-padded name, such as `0001`
|
|
99
|
+
*/
|
|
100
|
+
export function sequenceName(index) {
|
|
101
|
+
return String(index).padStart(4, '0');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build the manifest of a trace.
|
|
106
|
+
*
|
|
107
|
+
* @param {Object} options - Manifest fields
|
|
108
|
+
* @returns {Object} The manifest as it is written to disk
|
|
109
|
+
*/
|
|
110
|
+
export function createManifest(options = {}) {
|
|
111
|
+
const {
|
|
112
|
+
mode,
|
|
113
|
+
startedAt,
|
|
114
|
+
stoppedAt = null,
|
|
115
|
+
outcome = TRACE_OUTCOME.COMPLETE,
|
|
116
|
+
commanderVersion = null,
|
|
117
|
+
engine = null,
|
|
118
|
+
browser = null,
|
|
119
|
+
platform = `${process.platform} ${process.arch}`,
|
|
120
|
+
runtime = `node ${process.versions?.node ?? 'unknown'}`,
|
|
121
|
+
counts = {},
|
|
122
|
+
limits = {},
|
|
123
|
+
privacy = {},
|
|
124
|
+
dom = {},
|
|
125
|
+
events = [],
|
|
126
|
+
dropped = 0,
|
|
127
|
+
} = options;
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
schemaVersion: TRACE_SCHEMA_VERSION,
|
|
131
|
+
format: 'browser-commander-trace',
|
|
132
|
+
mode,
|
|
133
|
+
outcome,
|
|
134
|
+
startedAt,
|
|
135
|
+
stoppedAt,
|
|
136
|
+
commanderVersion,
|
|
137
|
+
engine,
|
|
138
|
+
browser,
|
|
139
|
+
platform,
|
|
140
|
+
runtime,
|
|
141
|
+
events: [...events],
|
|
142
|
+
dom: { ...dom },
|
|
143
|
+
privacy: { ...privacy },
|
|
144
|
+
limits: { ...limits },
|
|
145
|
+
counts: { checkpoints: 0, events: 0, mutationBatches: 0, ...counts },
|
|
146
|
+
dropped,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Check that a manifest can be read by this version of the format.
|
|
152
|
+
*
|
|
153
|
+
* @param {Object} manifest - Parsed manifest
|
|
154
|
+
* @throws {Error} When the bundle is not a readable trace
|
|
155
|
+
*/
|
|
156
|
+
export function assertReadableManifest(manifest) {
|
|
157
|
+
if (!manifest || manifest.format !== 'browser-commander-trace') {
|
|
158
|
+
throw new Error('not a Browser Commander trace bundle');
|
|
159
|
+
}
|
|
160
|
+
if (manifest.schemaVersion > TRACE_SCHEMA_VERSION) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`trace schema version ${manifest.schemaVersion} is newer than this reader (${TRACE_SCHEMA_VERSION})`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The offline trace viewer (issue #87).
|
|
3
|
+
*
|
|
4
|
+
* The viewer is one static HTML file written into the bundle. It opens from
|
|
5
|
+
* the filesystem with no server, and it is inert by construction: captured
|
|
6
|
+
* markup is rendered inside a sandboxed frame that inherits a
|
|
7
|
+
* `default-src 'none'` policy, so the recorded page cannot run its scripts,
|
|
8
|
+
* submit its forms, or reach the network from a reviewer's machine.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { TRACE_FILES } from './schema.js';
|
|
14
|
+
import { TRACE_FILE_MODE } from './bundle.js';
|
|
15
|
+
import { diffControlState, readTrace } from './reader.js';
|
|
16
|
+
|
|
17
|
+
/** How much captured HTML is embedded per checkpoint before it is elided. */
|
|
18
|
+
export const DEFAULT_MAX_INLINE_BYTES = 8 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Escape a value so it can sit inside a `<script type="application/json">`.
|
|
22
|
+
*
|
|
23
|
+
* @param {*} data - JSON-compatible value
|
|
24
|
+
* @returns {string} Safe JSON text
|
|
25
|
+
*/
|
|
26
|
+
function embed(data) {
|
|
27
|
+
// `<` is escaped so the JSON can never close the script element, and the
|
|
28
|
+
// two Unicode line separators are escaped because they are line breaks to a
|
|
29
|
+
// JavaScript parser but not to `JSON.stringify`.
|
|
30
|
+
const LINE_SEPARATORS = new RegExp('[\u2028\u2029]', 'g');
|
|
31
|
+
return JSON.stringify(data)
|
|
32
|
+
.replace(/</g, '\\u003c')
|
|
33
|
+
.replace(/>/g, '\\u003e')
|
|
34
|
+
.replace(LINE_SEPARATORS, (match) =>
|
|
35
|
+
match === '\u2028' ? '\\u2028' : '\\u2029'
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const VIEWER_STYLE = `
|
|
40
|
+
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
|
41
|
+
body { margin: 0; display: grid; grid-template-columns: 22rem 1fr;
|
|
42
|
+
grid-template-rows: auto 1fr; height: 100vh; }
|
|
43
|
+
header { grid-column: 1 / -1; padding: 0.5rem 1rem; border-bottom: 1px solid #8884; }
|
|
44
|
+
h1 { font-size: 1rem; margin: 0 0 0.25rem; }
|
|
45
|
+
.meta { font-size: 0.75rem; opacity: 0.75; }
|
|
46
|
+
aside { overflow: auto; border-right: 1px solid #8884; }
|
|
47
|
+
main { display: grid; grid-template-rows: auto 1fr auto; overflow: hidden; }
|
|
48
|
+
ol { list-style: none; margin: 0; padding: 0; font-size: 0.8rem; }
|
|
49
|
+
li { padding: 0.35rem 0.75rem; border-bottom: 1px solid #8882; cursor: pointer; }
|
|
50
|
+
li.selected { background: #3b82f633; }
|
|
51
|
+
li .kind { font-weight: 600; }
|
|
52
|
+
li .at { opacity: 0.6; float: right; }
|
|
53
|
+
iframe { width: 100%; height: 100%; border: 0; background: #fff; }
|
|
54
|
+
.panel { padding: 0.5rem 1rem; border-top: 1px solid #8884; max-height: 14rem;
|
|
55
|
+
overflow: auto; font-size: 0.8rem; }
|
|
56
|
+
table { border-collapse: collapse; width: 100%; }
|
|
57
|
+
td, th { text-align: left; padding: 0.15rem 0.5rem; border-bottom: 1px solid #8882;
|
|
58
|
+
font-family: ui-monospace, monospace; font-size: 0.75rem; }
|
|
59
|
+
.controls { padding: 0.5rem 1rem; display: flex; gap: 0.5rem; align-items: center; }
|
|
60
|
+
.dropped { color: #b91c1c; }
|
|
61
|
+
button { font: inherit; }
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
const VIEWER_SCRIPT = String.raw`
|
|
65
|
+
const trace = JSON.parse(document.getElementById('trace-data').textContent);
|
|
66
|
+
const timeline = document.getElementById('timeline');
|
|
67
|
+
const frame = document.getElementById('stage');
|
|
68
|
+
const details = document.getElementById('details');
|
|
69
|
+
const diffPanel = document.getElementById('diff');
|
|
70
|
+
const stepLabel = document.getElementById('step');
|
|
71
|
+
let selectedCheckpoint = null;
|
|
72
|
+
let mutationStep = 0;
|
|
73
|
+
|
|
74
|
+
function checkpointFor(event) {
|
|
75
|
+
if (event.kind === 'checkpoint') return event.index;
|
|
76
|
+
let latest = null;
|
|
77
|
+
for (const other of trace.events) {
|
|
78
|
+
if (other.sequence > event.sequence) break;
|
|
79
|
+
if (other.kind === 'checkpoint') latest = other.index;
|
|
80
|
+
}
|
|
81
|
+
return latest;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function renderHtml(index, html) {
|
|
85
|
+
// The captured document is handed to a sandboxed frame with no
|
|
86
|
+
// allow-scripts and no allow-forms: it is shown, never run.
|
|
87
|
+
frame.srcdoc = html == null ? '<p style="font:1rem system-ui">no HTML captured for this checkpoint</p>' : html;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function applyMutations(index, upTo) {
|
|
91
|
+
const html = trace.html[index];
|
|
92
|
+
if (html == null) return null;
|
|
93
|
+
const parsed = new DOMParser().parseFromString(html, 'text/html');
|
|
94
|
+
const batches = trace.mutations[index] || [];
|
|
95
|
+
let applied = 0;
|
|
96
|
+
for (const batch of batches.slice(0, upTo)) {
|
|
97
|
+
for (const record of batch.records) {
|
|
98
|
+
const target = record.target && record.target.path
|
|
99
|
+
? parsed.querySelector(record.target.path)
|
|
100
|
+
: null;
|
|
101
|
+
if (!target) continue;
|
|
102
|
+
if (record.kind === 'attributes' && record.attribute) {
|
|
103
|
+
if (record.after == null) target.removeAttribute(record.attribute);
|
|
104
|
+
else target.setAttribute(record.attribute, record.after);
|
|
105
|
+
applied++;
|
|
106
|
+
} else if (record.kind === 'characterData') {
|
|
107
|
+
target.textContent = record.after == null ? '' : record.after;
|
|
108
|
+
applied++;
|
|
109
|
+
} else if (record.kind === 'childList') {
|
|
110
|
+
for (const added of record.added || []) {
|
|
111
|
+
if (added && added.type === 'element' && added.html) {
|
|
112
|
+
target.insertAdjacentHTML('beforeend', added.html);
|
|
113
|
+
applied++;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (target.style) target.style.outline = '2px solid #f59e0b';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { html: '<!DOCTYPE html>' + parsed.documentElement.outerHTML, applied };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function showCheckpoint(index) {
|
|
124
|
+
selectedCheckpoint = index;
|
|
125
|
+
mutationStep = 0;
|
|
126
|
+
renderHtml(index, trace.html[index]);
|
|
127
|
+
const state = trace.state[index];
|
|
128
|
+
const previous = trace.state[index - 1];
|
|
129
|
+
details.innerHTML = state
|
|
130
|
+
? '<table><tr><th>url</th><td>' + state.url + '</td></tr>' +
|
|
131
|
+
'<tr><th>title</th><td>' + (state.title || '') + '</td></tr>' +
|
|
132
|
+
'<tr><th>controls</th><td>' + (state.controls || []).length + '</td></tr></table>'
|
|
133
|
+
: '<p>no state captured</p>';
|
|
134
|
+
const changes = trace.diffs[index] || [];
|
|
135
|
+
diffPanel.innerHTML = previous && changes.length
|
|
136
|
+
? '<table><tr><th>control</th><th>before</th><th>after</th></tr>' +
|
|
137
|
+
changes.map((c) => '<tr><td>' + c.path + '</td><td>' + (c.before ?? '') +
|
|
138
|
+
'</td><td>' + (c.after ?? '') + '</td></tr>').join('') + '</table>'
|
|
139
|
+
: '<p>no control changes against the previous checkpoint</p>';
|
|
140
|
+
stepLabel.textContent = (trace.mutations[index] || []).length + ' mutation batches';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function select(event, element) {
|
|
144
|
+
for (const li of timeline.children) li.classList.remove('selected');
|
|
145
|
+
element.classList.add('selected');
|
|
146
|
+
const index = checkpointFor(event);
|
|
147
|
+
if (index != null && index !== selectedCheckpoint) showCheckpoint(index);
|
|
148
|
+
if (event.kind !== 'checkpoint') {
|
|
149
|
+
details.innerHTML = '<table>' + Object.entries(event)
|
|
150
|
+
.map(([k, v]) => '<tr><th>' + k + '</th><td>' + String(
|
|
151
|
+
typeof v === 'object' ? JSON.stringify(v) : v).slice(0, 400) + '</td></tr>')
|
|
152
|
+
.join('') + '</table>';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const event of trace.events) {
|
|
157
|
+
const li = document.createElement('li');
|
|
158
|
+
if (event.kind === 'dropped') li.className = 'dropped';
|
|
159
|
+
li.innerHTML = '<span class="kind"></span> <span class="at"></span><div class="what"></div>';
|
|
160
|
+
li.querySelector('.kind').textContent = event.kind;
|
|
161
|
+
li.querySelector('.at').textContent = (event.at || '').slice(11, 23);
|
|
162
|
+
li.querySelector('.what').textContent =
|
|
163
|
+
event.name || event.action || event.text || event.message || event.url || event.reason || '';
|
|
164
|
+
li.addEventListener('click', () => select(event, li));
|
|
165
|
+
timeline.append(li);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
document.getElementById('play').addEventListener('click', async () => {
|
|
169
|
+
if (selectedCheckpoint == null) return;
|
|
170
|
+
const batches = trace.mutations[selectedCheckpoint] || [];
|
|
171
|
+
for (let step = 1; step <= batches.length; step++) {
|
|
172
|
+
const result = applyMutations(selectedCheckpoint, step);
|
|
173
|
+
if (result) frame.srcdoc = result.html;
|
|
174
|
+
stepLabel.textContent = 'batch ' + step + ' of ' + batches.length;
|
|
175
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
document.getElementById('step-forward').addEventListener('click', () => {
|
|
180
|
+
if (selectedCheckpoint == null) return;
|
|
181
|
+
const batches = trace.mutations[selectedCheckpoint] || [];
|
|
182
|
+
mutationStep = Math.min(mutationStep + 1, batches.length);
|
|
183
|
+
const result = applyMutations(selectedCheckpoint, mutationStep);
|
|
184
|
+
if (result) frame.srcdoc = result.html;
|
|
185
|
+
stepLabel.textContent = 'batch ' + mutationStep + ' of ' + batches.length;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
document.getElementById('reset').addEventListener('click', () => {
|
|
189
|
+
if (selectedCheckpoint != null) showCheckpoint(selectedCheckpoint);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (trace.checkpoints.length) showCheckpoint(trace.checkpoints[0].index);
|
|
193
|
+
`;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Collect everything the viewer embeds.
|
|
197
|
+
*
|
|
198
|
+
* @param {Object} reader - An open trace, from `readTrace()`
|
|
199
|
+
* @param {number} maxInlineBytes - Ceiling per embedded member
|
|
200
|
+
* @returns {Promise<Object>} The viewer's data
|
|
201
|
+
*/
|
|
202
|
+
async function collectViewerData(reader, maxInlineBytes) {
|
|
203
|
+
const html = {};
|
|
204
|
+
const state = {};
|
|
205
|
+
const mutations = {};
|
|
206
|
+
const diffs = {};
|
|
207
|
+
const elided = [];
|
|
208
|
+
|
|
209
|
+
for (const checkpoint of reader.checkpoints) {
|
|
210
|
+
const body = await reader.html(checkpoint.index);
|
|
211
|
+
if (body !== null && body.length > maxInlineBytes) {
|
|
212
|
+
elided.push(checkpoint.index);
|
|
213
|
+
html[checkpoint.index] = null;
|
|
214
|
+
} else {
|
|
215
|
+
html[checkpoint.index] = body;
|
|
216
|
+
}
|
|
217
|
+
state[checkpoint.index] = await reader.state(checkpoint.index);
|
|
218
|
+
mutations[checkpoint.index] = await reader.mutations(checkpoint.index);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
for (const checkpoint of reader.checkpoints) {
|
|
222
|
+
const previous = state[checkpoint.index - 1];
|
|
223
|
+
diffs[checkpoint.index] = previous
|
|
224
|
+
? diffControlState(previous, state[checkpoint.index])
|
|
225
|
+
: [];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
manifest: reader.manifest,
|
|
230
|
+
events: reader.events,
|
|
231
|
+
checkpoints: reader.checkpoints,
|
|
232
|
+
html,
|
|
233
|
+
state,
|
|
234
|
+
mutations,
|
|
235
|
+
diffs,
|
|
236
|
+
elided,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Render the viewer for an open trace.
|
|
242
|
+
*
|
|
243
|
+
* @param {Object} reader - An open trace, from `readTrace()`
|
|
244
|
+
* @param {Object} [options] - `{maxInlineBytes}`
|
|
245
|
+
* @returns {Promise<string>} The viewer document
|
|
246
|
+
*/
|
|
247
|
+
export async function renderTraceViewer(reader, options = {}) {
|
|
248
|
+
const { maxInlineBytes = DEFAULT_MAX_INLINE_BYTES } = options;
|
|
249
|
+
const data = await collectViewerData(reader, maxInlineBytes);
|
|
250
|
+
const manifest = reader.manifest;
|
|
251
|
+
|
|
252
|
+
return `<!DOCTYPE html>
|
|
253
|
+
<html lang="en">
|
|
254
|
+
<head>
|
|
255
|
+
<meta charset="utf-8">
|
|
256
|
+
<!-- Inert by default: no network, no captured scripts, images only from data URLs. -->
|
|
257
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; frame-src 'self' data:;">
|
|
258
|
+
<title>Browser Commander trace</title>
|
|
259
|
+
<style>${VIEWER_STYLE}</style>
|
|
260
|
+
</head>
|
|
261
|
+
<body>
|
|
262
|
+
<header>
|
|
263
|
+
<h1>Browser Commander trace</h1>
|
|
264
|
+
<div class="meta">
|
|
265
|
+
mode ${manifest.mode} · outcome ${manifest.outcome} · engine ${manifest.engine ?? 'unknown'} ·
|
|
266
|
+
${manifest.counts?.checkpoints ?? 0} checkpoints · ${manifest.counts?.events ?? 0} events ·
|
|
267
|
+
${manifest.dropped ?? 0} dropped · started ${manifest.startedAt ?? 'unknown'}
|
|
268
|
+
</div>
|
|
269
|
+
</header>
|
|
270
|
+
<aside><ol id="timeline"></ol></aside>
|
|
271
|
+
<main>
|
|
272
|
+
<div class="controls">
|
|
273
|
+
<button id="play" type="button">Replay mutations</button>
|
|
274
|
+
<button id="step-forward" type="button">Step</button>
|
|
275
|
+
<button id="reset" type="button">Reset</button>
|
|
276
|
+
<span id="step"></span>
|
|
277
|
+
</div>
|
|
278
|
+
<iframe id="stage" sandbox referrerpolicy="no-referrer" title="captured page"></iframe>
|
|
279
|
+
<div class="panel" id="details"></div>
|
|
280
|
+
<div class="panel" id="diff"></div>
|
|
281
|
+
</main>
|
|
282
|
+
<script id="trace-data" type="application/json">${embed(data)}</script>
|
|
283
|
+
<script>${VIEWER_SCRIPT}</script>
|
|
284
|
+
</body>
|
|
285
|
+
</html>
|
|
286
|
+
`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Write the viewer into a bundle.
|
|
291
|
+
*
|
|
292
|
+
* @param {string} bundlePath - Path to the bundle directory
|
|
293
|
+
* @param {Object} [options] - `{maxInlineBytes}`
|
|
294
|
+
* @returns {Promise<string>} Path to the written viewer
|
|
295
|
+
*/
|
|
296
|
+
export async function writeTraceViewer(bundlePath, options = {}) {
|
|
297
|
+
const reader = await readTrace(bundlePath);
|
|
298
|
+
const document = await renderTraceViewer(reader, options);
|
|
299
|
+
const target = path.join(reader.path, TRACE_FILES.VIEWER);
|
|
300
|
+
await fs.writeFile(target, document, { mode: TRACE_FILE_MODE });
|
|
301
|
+
return target;
|
|
302
|
+
}
|