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.
- package/.jscpd-baseline.json +12 -17
- package/CHANGELOG.md +68 -0
- package/README.md +132 -0
- package/eslint.config.js +5 -0
- package/package.json +5 -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 +476 -0
- package/src/downloads/naming.js +246 -0
- package/src/downloads/sources.js +238 -0
- package/src/downloads/staging.js +146 -0
- package/src/downloads/store.js +256 -0
- package/src/exports.js +86 -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 +338 -0
- package/src/traces/deadline.js +33 -0
- package/src/traces/identity.js +127 -0
- package/src/traces/init-script.js +95 -0
- package/src/traces/links.js +623 -0
- package/src/traces/mutation-stream.js +232 -0
- package/src/traces/observers.js +272 -0
- package/src/traces/page-capture.js +601 -0
- package/src/traces/reader.js +226 -0
- package/src/traces/recorder.js +454 -0
- package/src/traces/redaction.js +231 -0
- package/src/traces/schema.js +229 -0
- package/src/traces/viewer.js +448 -0
- package/tests/e2e/click-readiness.e2e.test.js +240 -0
- package/tests/e2e/downloads.e2e.test.js +235 -0
- package/tests/e2e/traces.e2e.test.js +742 -0
- package/tests/helpers/click-fixtures.js +62 -0
- package/tests/helpers/download-fixtures.js +125 -0
- package/tests/helpers/download-server.js +129 -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 +274 -0
- package/tests/helpers/trace-server.js +173 -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 +485 -0
- package/tests/unit/downloads/naming.test.js +174 -0
- package/tests/unit/downloads/staging.test.js +117 -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/links.test.js +557 -0
- package/tests/unit/traces/reader.test.js +208 -0
- package/tests/unit/traces/recorder.test.js +730 -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 +294 -0
|
@@ -0,0 +1,448 @@
|
|
|
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
|
+
.meta.replay { opacity: 0.9; font-style: italic; }
|
|
47
|
+
aside { overflow: auto; border-right: 1px solid #8884; }
|
|
48
|
+
main { display: grid; grid-template-rows: auto 1fr auto; overflow: hidden; }
|
|
49
|
+
ol { list-style: none; margin: 0; padding: 0; font-size: 0.8rem; }
|
|
50
|
+
li { padding: 0.35rem 0.75rem; border-bottom: 1px solid #8882; cursor: pointer; }
|
|
51
|
+
li.selected { background: #3b82f633; }
|
|
52
|
+
li .kind { font-weight: 600; }
|
|
53
|
+
li .at { opacity: 0.6; float: right; }
|
|
54
|
+
iframe { width: 100%; height: 100%; border: 0; background: #fff; }
|
|
55
|
+
.panel { padding: 0.5rem 1rem; border-top: 1px solid #8884; max-height: 14rem;
|
|
56
|
+
overflow: auto; font-size: 0.8rem; }
|
|
57
|
+
table { border-collapse: collapse; width: 100%; }
|
|
58
|
+
td, th { text-align: left; padding: 0.15rem 0.5rem; border-bottom: 1px solid #8882;
|
|
59
|
+
font-family: ui-monospace, monospace; font-size: 0.75rem; }
|
|
60
|
+
.controls { padding: 0.5rem 1rem; display: flex; gap: 0.5rem; align-items: center; }
|
|
61
|
+
.dropped { color: #b91c1c; }
|
|
62
|
+
button { font: inherit; }
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
const VIEWER_SCRIPT = String.raw`
|
|
66
|
+
const trace = JSON.parse(document.getElementById('trace-data').textContent);
|
|
67
|
+
const timeline = document.getElementById('timeline');
|
|
68
|
+
const frame = document.getElementById('stage');
|
|
69
|
+
const details = document.getElementById('details');
|
|
70
|
+
const diffPanel = document.getElementById('diff');
|
|
71
|
+
const stepLabel = document.getElementById('step');
|
|
72
|
+
let selectedCheckpoint = null;
|
|
73
|
+
let mutationStep = 0;
|
|
74
|
+
|
|
75
|
+
function checkpointFor(event) {
|
|
76
|
+
if (event.kind === 'checkpoint') return event.index;
|
|
77
|
+
let latest = null;
|
|
78
|
+
for (const other of trace.events) {
|
|
79
|
+
if (other.sequence > event.sequence) break;
|
|
80
|
+
if (other.kind === 'checkpoint') latest = other.index;
|
|
81
|
+
}
|
|
82
|
+
return latest;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderHtml(index, html) {
|
|
86
|
+
// The captured document is handed to a sandboxed frame with no
|
|
87
|
+
// allow-scripts and no allow-forms: it is shown, never run.
|
|
88
|
+
frame.srcdoc = html == null ? '<p style="font:1rem system-ui">no HTML captured for this checkpoint</p>' : html;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function nodeOf(parsed, described) {
|
|
92
|
+
if (!described) return null;
|
|
93
|
+
if (described.type === 'text') {
|
|
94
|
+
return parsed.createTextNode(described.text == null ? '' : described.text);
|
|
95
|
+
}
|
|
96
|
+
if (described.type === 'element' && described.html) {
|
|
97
|
+
const holder = parsed.createElement('template');
|
|
98
|
+
holder.innerHTML = described.html;
|
|
99
|
+
return holder.content.firstChild;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function looksLike(node, described) {
|
|
105
|
+
if (!node || !described) return false;
|
|
106
|
+
if (described.type === 'text') return node.nodeType === 3;
|
|
107
|
+
if (described.type !== 'element') return false;
|
|
108
|
+
if (node.nodeType !== 1 || node.localName !== described.tag) return false;
|
|
109
|
+
return described.id ? node.id === described.id : true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function childLike(parent, described) {
|
|
113
|
+
if (!described) return null;
|
|
114
|
+
// An id is identity; an index is only a hint. A node that moved is reported
|
|
115
|
+
// at the position it moved *to*, so the index alone would find the wrong one.
|
|
116
|
+
if (described.id) {
|
|
117
|
+
for (const child of parent.childNodes) {
|
|
118
|
+
if (child.nodeType === 1 && child.id === described.id) return child;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const at = described.index == null ? null : parent.childNodes[described.index];
|
|
122
|
+
if (looksLike(at, described)) return at;
|
|
123
|
+
for (const child of parent.childNodes) {
|
|
124
|
+
if (looksLike(child, described)) return child;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function insertAt(parent, node, index) {
|
|
130
|
+
if (!node) return false;
|
|
131
|
+
const reference = index == null ? null : parent.childNodes[index] || null;
|
|
132
|
+
parent.insertBefore(node, reference);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function applyLiveState(parsed, target, record) {
|
|
137
|
+
const value = record.after;
|
|
138
|
+
const text = value == null ? '' : String(value);
|
|
139
|
+
if (record.property === 'scroll') {
|
|
140
|
+
// A static copy of a document cannot be scrolled, so where it was
|
|
141
|
+
// scrolled to is written onto the element rather than quietly lost.
|
|
142
|
+
const element = target || parsed.documentElement;
|
|
143
|
+
const at = value || {};
|
|
144
|
+
element.setAttribute('data-bc-scroll', at.top + ',' + at.left);
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
if (!target) return false;
|
|
148
|
+
if (record.property === 'value') {
|
|
149
|
+
// Serialized HTML carries attributes, not properties, so what was typed
|
|
150
|
+
// is written where a re-parse will still find it.
|
|
151
|
+
if (target.localName === 'textarea' || target.hasAttribute('contenteditable')) {
|
|
152
|
+
target.textContent = text;
|
|
153
|
+
} else {
|
|
154
|
+
target.setAttribute('value', text);
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
if (record.property === 'checked') {
|
|
159
|
+
if (value) target.setAttribute('checked', '');
|
|
160
|
+
else target.removeAttribute('checked');
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
if (record.property === 'selected') {
|
|
164
|
+
const chosen = (Array.isArray(value) ? value : []).map(String);
|
|
165
|
+
for (const option of target.querySelectorAll('option')) {
|
|
166
|
+
if (chosen.indexOf(option.value) !== -1) option.setAttribute('selected', '');
|
|
167
|
+
else option.removeAttribute('selected');
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
if (record.property === 'focus') {
|
|
172
|
+
for (const had of parsed.querySelectorAll('[data-bc-focus]')) {
|
|
173
|
+
had.removeAttribute('data-bc-focus');
|
|
174
|
+
}
|
|
175
|
+
if (value) target.setAttribute('data-bc-focus', '');
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function applyMutations(index, upTo) {
|
|
182
|
+
const html = trace.html[index];
|
|
183
|
+
if (html == null) return null;
|
|
184
|
+
const parsed = new DOMParser().parseFromString(html, 'text/html');
|
|
185
|
+
const batches = trace.mutations[index] || [];
|
|
186
|
+
let applied = 0;
|
|
187
|
+
let skipped = 0;
|
|
188
|
+
for (const batch of batches.slice(0, upTo)) {
|
|
189
|
+
// The stage holds one document. A batch from an iframe belongs to a
|
|
190
|
+
// different one, so it is counted rather than applied to the wrong page.
|
|
191
|
+
if (batch.mainFrame === false) { skipped += (batch.records || []).length; continue; }
|
|
192
|
+
for (const record of batch.records) {
|
|
193
|
+
const target = record.target && record.target.path
|
|
194
|
+
? parsed.querySelector(record.target.path)
|
|
195
|
+
: null;
|
|
196
|
+
if (record.kind === 'live-state') {
|
|
197
|
+
if (applyLiveState(parsed, target, record)) applied++; else skipped++;
|
|
198
|
+
} else if (!target) {
|
|
199
|
+
skipped++;
|
|
200
|
+
continue;
|
|
201
|
+
} else if (record.kind === 'attributes' && record.attribute) {
|
|
202
|
+
if (record.after == null) target.removeAttribute(record.attribute);
|
|
203
|
+
else target.setAttribute(record.attribute, record.after);
|
|
204
|
+
applied++;
|
|
205
|
+
} else if (record.kind === 'characterData') {
|
|
206
|
+
target.textContent = record.after == null ? '' : record.after;
|
|
207
|
+
applied++;
|
|
208
|
+
} else if (record.kind === 'childList') {
|
|
209
|
+
// Where a node went is as much of the change as what it was: appending
|
|
210
|
+
// every addition to the end replays an insertion before a sibling in
|
|
211
|
+
// the wrong place, and drops removals entirely (issue #93).
|
|
212
|
+
for (const removed of record.removed || []) {
|
|
213
|
+
const node = childLike(target, removed);
|
|
214
|
+
if (node) { target.removeChild(node); applied++; } else { skipped++; }
|
|
215
|
+
}
|
|
216
|
+
const added = (record.added || []).filter(Boolean)
|
|
217
|
+
.slice().sort((a, b) => (a.index == null ? 1e9 : a.index) - (b.index == null ? 1e9 : b.index));
|
|
218
|
+
for (const entry of added) {
|
|
219
|
+
if (insertAt(target, nodeOf(parsed, entry), entry.index)) applied++;
|
|
220
|
+
else skipped++;
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
skipped++;
|
|
224
|
+
}
|
|
225
|
+
if (target && target.style) target.style.outline = '2px solid #f59e0b';
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { html: '<!DOCTYPE html>' + parsed.documentElement.outerHTML, applied, skipped };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function stepText(step, total, result) {
|
|
232
|
+
const base = 'batch ' + step + ' of ' + total;
|
|
233
|
+
if (!result || !result.skipped) return base;
|
|
234
|
+
return base + ' · ' + result.skipped + ' not replayable here';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function showCheckpoint(index) {
|
|
238
|
+
selectedCheckpoint = index;
|
|
239
|
+
mutationStep = 0;
|
|
240
|
+
renderHtml(index, trace.html[index]);
|
|
241
|
+
const state = trace.state[index];
|
|
242
|
+
const previous = trace.state[index - 1];
|
|
243
|
+
details.innerHTML = state
|
|
244
|
+
? '<table><tr><th>url</th><td>' + state.url + '</td></tr>' +
|
|
245
|
+
'<tr><th>title</th><td>' + (state.title || '') + '</td></tr>' +
|
|
246
|
+
'<tr><th>controls</th><td>' + (state.controls || []).length + '</td></tr></table>'
|
|
247
|
+
: '<p>no state captured</p>';
|
|
248
|
+
const changes = trace.diffs[index] || [];
|
|
249
|
+
diffPanel.innerHTML = previous && changes.length
|
|
250
|
+
? '<table><tr><th>control</th><th>before</th><th>after</th></tr>' +
|
|
251
|
+
changes.map((c) => '<tr><td>' + c.path + '</td><td>' + (c.before ?? '') +
|
|
252
|
+
'</td><td>' + (c.after ?? '') + '</td></tr>').join('') + '</table>'
|
|
253
|
+
: '<p>no control changes against the previous checkpoint</p>';
|
|
254
|
+
stepLabel.textContent = (trace.mutations[index] || []).length + ' mutation batches';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function select(event, element) {
|
|
258
|
+
for (const li of timeline.children) li.classList.remove('selected');
|
|
259
|
+
element.classList.add('selected');
|
|
260
|
+
const index = checkpointFor(event);
|
|
261
|
+
if (index != null && index !== selectedCheckpoint) showCheckpoint(index);
|
|
262
|
+
if (event.kind !== 'checkpoint') {
|
|
263
|
+
details.innerHTML = '<table>' + Object.entries(event)
|
|
264
|
+
.map(([k, v]) => '<tr><th>' + k + '</th><td>' + String(
|
|
265
|
+
typeof v === 'object' ? JSON.stringify(v) : v).slice(0, 400) + '</td></tr>')
|
|
266
|
+
.join('') + '</table>';
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
for (const event of trace.events) {
|
|
271
|
+
const li = document.createElement('li');
|
|
272
|
+
if (event.kind === 'dropped') li.className = 'dropped';
|
|
273
|
+
li.innerHTML = '<span class="kind"></span> <span class="at"></span><div class="what"></div>';
|
|
274
|
+
li.querySelector('.kind').textContent = event.kind;
|
|
275
|
+
li.querySelector('.at').textContent = (event.at || '').slice(11, 23);
|
|
276
|
+
li.querySelector('.what').textContent =
|
|
277
|
+
event.name || event.action || event.text || event.message || event.url || event.reason || '';
|
|
278
|
+
li.addEventListener('click', () => select(event, li));
|
|
279
|
+
timeline.append(li);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
document.getElementById('play').addEventListener('click', async () => {
|
|
283
|
+
if (selectedCheckpoint == null) return;
|
|
284
|
+
const batches = trace.mutations[selectedCheckpoint] || [];
|
|
285
|
+
for (let step = 1; step <= batches.length; step++) {
|
|
286
|
+
const result = applyMutations(selectedCheckpoint, step);
|
|
287
|
+
if (result) frame.srcdoc = result.html;
|
|
288
|
+
stepLabel.textContent = stepText(step, batches.length, result);
|
|
289
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
document.getElementById('step-forward').addEventListener('click', () => {
|
|
294
|
+
if (selectedCheckpoint == null) return;
|
|
295
|
+
const batches = trace.mutations[selectedCheckpoint] || [];
|
|
296
|
+
mutationStep = Math.min(mutationStep + 1, batches.length);
|
|
297
|
+
const result = applyMutations(selectedCheckpoint, mutationStep);
|
|
298
|
+
if (result) frame.srcdoc = result.html;
|
|
299
|
+
stepLabel.textContent = stepText(mutationStep, batches.length, result);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
document.getElementById('reset').addEventListener('click', () => {
|
|
303
|
+
if (selectedCheckpoint != null) showCheckpoint(selectedCheckpoint);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
if (trace.checkpoints.length) showCheckpoint(trace.checkpoints[0].index);
|
|
307
|
+
`;
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* What this bundle can be replayed from, in the viewer's own words.
|
|
311
|
+
*
|
|
312
|
+
* Issue #93 asked for the viewer to say plainly that it reconstructs a run
|
|
313
|
+
* rather than replaying a recording of one, and to stop implying it can show
|
|
314
|
+
* things the bundle does not contain. A checkpoints-only trace has nothing
|
|
315
|
+
* between its checkpoints; a continuous one has as much as it recorded.
|
|
316
|
+
*
|
|
317
|
+
* @param {Object} manifest - The bundle's manifest
|
|
318
|
+
* @returns {string} A one-line description of what replay covers
|
|
319
|
+
*/
|
|
320
|
+
export function replaySummary(manifest) {
|
|
321
|
+
const replay = manifest?.replay ?? {};
|
|
322
|
+
const covered = [];
|
|
323
|
+
if (replay.mutations) {
|
|
324
|
+
covered.push('DOM mutations');
|
|
325
|
+
}
|
|
326
|
+
if (replay.childListPositions) {
|
|
327
|
+
covered.push('insertion positions and removals');
|
|
328
|
+
}
|
|
329
|
+
if (replay.liveState) {
|
|
330
|
+
covered.push('live control state');
|
|
331
|
+
}
|
|
332
|
+
if (replay.identifiers) {
|
|
333
|
+
covered.push('page and frame identities');
|
|
334
|
+
}
|
|
335
|
+
return covered.length === 0
|
|
336
|
+
? 'partial diagnostic replay: checkpoints only, nothing between them'
|
|
337
|
+
: `partial diagnostic replay: checkpoints, ${covered.join(', ')}`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Collect everything the viewer embeds.
|
|
342
|
+
*
|
|
343
|
+
* @param {Object} reader - An open trace, from `readTrace()`
|
|
344
|
+
* @param {number} maxInlineBytes - Ceiling per embedded member
|
|
345
|
+
* @returns {Promise<Object>} The viewer's data
|
|
346
|
+
*/
|
|
347
|
+
async function collectViewerData(reader, maxInlineBytes) {
|
|
348
|
+
const html = {};
|
|
349
|
+
const state = {};
|
|
350
|
+
const mutations = {};
|
|
351
|
+
const diffs = {};
|
|
352
|
+
const elided = [];
|
|
353
|
+
|
|
354
|
+
for (const checkpoint of reader.checkpoints) {
|
|
355
|
+
const body = await reader.html(checkpoint.index);
|
|
356
|
+
if (body !== null && body.length > maxInlineBytes) {
|
|
357
|
+
elided.push(checkpoint.index);
|
|
358
|
+
html[checkpoint.index] = null;
|
|
359
|
+
} else {
|
|
360
|
+
html[checkpoint.index] = body;
|
|
361
|
+
}
|
|
362
|
+
state[checkpoint.index] = await reader.state(checkpoint.index);
|
|
363
|
+
mutations[checkpoint.index] = await reader.mutations(checkpoint.index);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
for (const checkpoint of reader.checkpoints) {
|
|
367
|
+
const previous = state[checkpoint.index - 1];
|
|
368
|
+
diffs[checkpoint.index] = previous
|
|
369
|
+
? diffControlState(previous, state[checkpoint.index])
|
|
370
|
+
: [];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
manifest: reader.manifest,
|
|
375
|
+
events: reader.events,
|
|
376
|
+
checkpoints: reader.checkpoints,
|
|
377
|
+
html,
|
|
378
|
+
state,
|
|
379
|
+
mutations,
|
|
380
|
+
diffs,
|
|
381
|
+
elided,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Render the viewer for an open trace.
|
|
387
|
+
*
|
|
388
|
+
* @param {Object} reader - An open trace, from `readTrace()`
|
|
389
|
+
* @param {Object} [options] - `{maxInlineBytes}`
|
|
390
|
+
* @returns {Promise<string>} The viewer document
|
|
391
|
+
*/
|
|
392
|
+
export async function renderTraceViewer(reader, options = {}) {
|
|
393
|
+
const { maxInlineBytes = DEFAULT_MAX_INLINE_BYTES } = options;
|
|
394
|
+
const data = await collectViewerData(reader, maxInlineBytes);
|
|
395
|
+
const manifest = reader.manifest;
|
|
396
|
+
|
|
397
|
+
return `<!DOCTYPE html>
|
|
398
|
+
<html lang="en">
|
|
399
|
+
<head>
|
|
400
|
+
<meta charset="utf-8">
|
|
401
|
+
<!-- Inert by default: no network, no captured scripts, images only from data URLs. -->
|
|
402
|
+
<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:;">
|
|
403
|
+
<title>Browser Commander trace</title>
|
|
404
|
+
<style>${VIEWER_STYLE}</style>
|
|
405
|
+
</head>
|
|
406
|
+
<body>
|
|
407
|
+
<header>
|
|
408
|
+
<h1>Browser Commander trace</h1>
|
|
409
|
+
<div class="meta">
|
|
410
|
+
mode ${manifest.mode} · outcome ${manifest.outcome} · engine ${manifest.engine ?? 'unknown'} ·
|
|
411
|
+
${manifest.counts?.checkpoints ?? 0} checkpoints · ${manifest.counts?.events ?? 0} events ·
|
|
412
|
+
${manifest.dropped ?? 0} dropped · started ${manifest.startedAt ?? 'unknown'}
|
|
413
|
+
</div>
|
|
414
|
+
<div class="meta replay">${replaySummary(manifest)}</div>
|
|
415
|
+
</header>
|
|
416
|
+
<aside><ol id="timeline"></ol></aside>
|
|
417
|
+
<main>
|
|
418
|
+
<div class="controls">
|
|
419
|
+
<button id="play" type="button">Replay mutations</button>
|
|
420
|
+
<button id="step-forward" type="button">Step</button>
|
|
421
|
+
<button id="reset" type="button">Reset</button>
|
|
422
|
+
<span id="step"></span>
|
|
423
|
+
</div>
|
|
424
|
+
<iframe id="stage" sandbox referrerpolicy="no-referrer" title="captured page"></iframe>
|
|
425
|
+
<div class="panel" id="details"></div>
|
|
426
|
+
<div class="panel" id="diff"></div>
|
|
427
|
+
</main>
|
|
428
|
+
<script id="trace-data" type="application/json">${embed(data)}</script>
|
|
429
|
+
<script>${VIEWER_SCRIPT}</script>
|
|
430
|
+
</body>
|
|
431
|
+
</html>
|
|
432
|
+
`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Write the viewer into a bundle.
|
|
437
|
+
*
|
|
438
|
+
* @param {string} bundlePath - Path to the bundle directory
|
|
439
|
+
* @param {Object} [options] - `{maxInlineBytes}`
|
|
440
|
+
* @returns {Promise<string>} Path to the written viewer
|
|
441
|
+
*/
|
|
442
|
+
export async function writeTraceViewer(bundlePath, options = {}) {
|
|
443
|
+
const reader = await readTrace(bundlePath);
|
|
444
|
+
const document = await renderTraceViewer(reader, options);
|
|
445
|
+
const target = path.join(reader.path, TRACE_FILES.VIEWER);
|
|
446
|
+
await fs.writeFile(target, document, { mode: TRACE_FILE_MODE });
|
|
447
|
+
return target;
|
|
448
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-browser tests for issue #89.
|
|
3
|
+
*
|
|
4
|
+
* The three repros in that issue are all about claims the library made
|
|
5
|
+
* without checking: a page reported ready after the wait failed, a no-op
|
|
6
|
+
* button reported as verified, and `noAutoScroll` scrolling the page anyway.
|
|
7
|
+
* Only a real browser can disprove the last one, so these run against
|
|
8
|
+
* Chromium and a local fixture server.
|
|
9
|
+
*
|
|
10
|
+
* Run with: npm run test:e2e:click-readiness
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, before, after } from 'node:test';
|
|
14
|
+
import assert from 'node:assert';
|
|
15
|
+
|
|
16
|
+
import { startFixtureServer } from '../helpers/readiness-server.js';
|
|
17
|
+
|
|
18
|
+
const VIEWPORT = { width: 800, height: 600 };
|
|
19
|
+
|
|
20
|
+
describe(
|
|
21
|
+
'E2E - truthful readiness and click results (issue #89)',
|
|
22
|
+
{ skip: !process.env.RUN_E2E, timeout: 120000 },
|
|
23
|
+
() => {
|
|
24
|
+
let browser;
|
|
25
|
+
let context;
|
|
26
|
+
let page;
|
|
27
|
+
let commander;
|
|
28
|
+
let server;
|
|
29
|
+
|
|
30
|
+
before(async () => {
|
|
31
|
+
const { chromium } = await import('playwright');
|
|
32
|
+
const { createCommander } = await import('../../src/index.js');
|
|
33
|
+
|
|
34
|
+
server = await startFixtureServer();
|
|
35
|
+
browser = await chromium.launch({
|
|
36
|
+
headless: process.env.HEADLESS !== 'false',
|
|
37
|
+
});
|
|
38
|
+
context = await browser.newContext({ viewport: VIEWPORT });
|
|
39
|
+
page = await context.newPage();
|
|
40
|
+
commander = createCommander({ page, engine: 'playwright' });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
after(async () => {
|
|
44
|
+
await browser?.close();
|
|
45
|
+
await server?.close();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** Load a fixture page and reset the scroll position. */
|
|
49
|
+
const open = async (path) => {
|
|
50
|
+
await page.goto(`${server.baseUrl}${path}`, {
|
|
51
|
+
waitUntil: 'domcontentloaded',
|
|
52
|
+
});
|
|
53
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const scrollY = () => page.evaluate(() => window.scrollY);
|
|
57
|
+
const clicks = () => page.evaluate(() => window.__clicks);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Open a fixture page and click one of its elements.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} path - Fixture page path
|
|
63
|
+
* @param {string} selector - Element to click
|
|
64
|
+
* @param {Object} options - Click options passed straight through
|
|
65
|
+
* @returns {Promise<Object>} Click result
|
|
66
|
+
*/
|
|
67
|
+
const clickOn = async (path, selector, options) => {
|
|
68
|
+
await open(path);
|
|
69
|
+
return commander.clickElement({
|
|
70
|
+
locatorOrElement: page.locator(selector),
|
|
71
|
+
...options,
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Wait for readiness and report how long the wait actually took.
|
|
77
|
+
*
|
|
78
|
+
* @param {Object} options - Options for `waitForReady`
|
|
79
|
+
* @returns {Promise<{result: Object, elapsed: number}>} Outcome and duration
|
|
80
|
+
*/
|
|
81
|
+
const timeReadiness = async (options) => {
|
|
82
|
+
const started = Date.now();
|
|
83
|
+
const result = await commander.waitForReady(options);
|
|
84
|
+
return { result, elapsed: Date.now() - started };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
describe("scroll: 'none'", () => {
|
|
88
|
+
it('does not scroll the page when the target is already visible', async () => {
|
|
89
|
+
await open('/far-below-fold');
|
|
90
|
+
// Put the target on screen without the click doing it for us.
|
|
91
|
+
await page.evaluate(() =>
|
|
92
|
+
document.getElementById('target').scrollIntoView({ block: 'center' })
|
|
93
|
+
);
|
|
94
|
+
const before = await scrollY();
|
|
95
|
+
|
|
96
|
+
const result = await commander.clickElement({
|
|
97
|
+
locatorOrElement: page.locator('#target'),
|
|
98
|
+
scroll: 'none',
|
|
99
|
+
verify: false,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
assert.strictEqual(result.dispatched, true, result.reason);
|
|
103
|
+
assert.strictEqual(await clicks(), 1);
|
|
104
|
+
assert.strictEqual(
|
|
105
|
+
await scrollY(),
|
|
106
|
+
before,
|
|
107
|
+
'scroll: none must not move the page'
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('fails clearly instead of scrolling to an off-screen target', async () => {
|
|
112
|
+
// Regression test for issue #89 repro 3: this used to map to
|
|
113
|
+
// Playwright's `force: true`, which skips actionability checks but
|
|
114
|
+
// still scrolls, so the reported "no scroll" click scrolled to 3911.
|
|
115
|
+
const result = await clickOn('/far-below-fold', '#target', {
|
|
116
|
+
scroll: 'none',
|
|
117
|
+
verify: false,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
assert.strictEqual(result.status, 'failed');
|
|
121
|
+
assert.strictEqual(result.dispatched, false);
|
|
122
|
+
assert.match(result.reason, /viewport|obscure/i);
|
|
123
|
+
assert.strictEqual(await scrollY(), 0, 'the page must not have moved');
|
|
124
|
+
assert.strictEqual(await clicks(), 0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('treats the legacy noAutoScroll flag the same way', async () => {
|
|
128
|
+
const result = await clickOn('/far-below-fold', '#target', {
|
|
129
|
+
noAutoScroll: true,
|
|
130
|
+
verify: false,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
assert.strictEqual(result.dispatched, false);
|
|
134
|
+
assert.strictEqual(await scrollY(), 0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("scroll: 'auto' still scrolls, as it always did", async () => {
|
|
138
|
+
const result = await clickOn('/far-below-fold', '#target', {
|
|
139
|
+
scroll: 'auto',
|
|
140
|
+
verify: false,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
assert.strictEqual(result.dispatched, true, result.reason);
|
|
144
|
+
assert.ok((await scrollY()) > 0, 'scroll: auto is allowed to scroll');
|
|
145
|
+
assert.strictEqual(await clicks(), 1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("scroll: 'preserve' restores the position it started from", async () => {
|
|
149
|
+
await clickOn('/far-below-fold', '#target', {
|
|
150
|
+
scroll: 'preserve',
|
|
151
|
+
verify: false,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
assert.strictEqual(await clicks(), 1);
|
|
155
|
+
assert.strictEqual(await scrollY(), 0);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('click verification', () => {
|
|
160
|
+
it('reports unverified for a button that does nothing', async () => {
|
|
161
|
+
// Regression test for issue #89 repro 2: "element still connected"
|
|
162
|
+
// used to be reported as `verified: true`.
|
|
163
|
+
const result = await clickOn('/no-op', '#noop', { verify: true });
|
|
164
|
+
|
|
165
|
+
assert.strictEqual(await clicks(), 1, 'the click must still happen');
|
|
166
|
+
assert.strictEqual(result.dispatched, true);
|
|
167
|
+
assert.strictEqual(result.status, 'unverified');
|
|
168
|
+
assert.strictEqual(result.effect, 'not-observed');
|
|
169
|
+
assert.strictEqual(result.verified, false);
|
|
170
|
+
assert.ok(result.evidence.length > 0, 'a verdict needs evidence');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('confirms a click that actually changed the element', async () => {
|
|
174
|
+
const result = await clickOn('/far-below-fold', '#target', {
|
|
175
|
+
scroll: 'auto',
|
|
176
|
+
verify: true,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
assert.strictEqual(result.status, 'succeeded');
|
|
180
|
+
assert.strictEqual(result.effect, 'confirmed');
|
|
181
|
+
assert.strictEqual(result.verified, true);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('carries an action id and an elapsed time on every result', async () => {
|
|
185
|
+
const result = await clickOn('/no-op', '#noop', { verify: true });
|
|
186
|
+
|
|
187
|
+
assert.strictEqual(typeof result.actionId, 'string');
|
|
188
|
+
assert.ok(result.actionId.length > 0);
|
|
189
|
+
assert.ok(Number.isFinite(result.elapsedMs));
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('does not claim a navigating click was verified by the navigation', async () => {
|
|
193
|
+
const result = await clickOn('/navigates', '#go', { verify: true });
|
|
194
|
+
|
|
195
|
+
await page.waitForSelector('#arrived');
|
|
196
|
+
// The click did navigate, but nothing proved the click caused it, so
|
|
197
|
+
// the result must not be dressed up as a confirmed effect.
|
|
198
|
+
assert.notStrictEqual(result.effect, 'confirmed');
|
|
199
|
+
assert.ok(
|
|
200
|
+
['unverified', 'interrupted', 'succeeded'].includes(result.status),
|
|
201
|
+
`unexpected status ${result.status}`
|
|
202
|
+
);
|
|
203
|
+
assert.strictEqual(typeof result.actionId, 'string');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('readiness deadlines', () => {
|
|
208
|
+
it('reports timed_out instead of ready when the network never settles', async () => {
|
|
209
|
+
// Regression test for issue #89 repro 1: the wait failed and the page
|
|
210
|
+
// was announced as ready anyway.
|
|
211
|
+
await open('/never-idle');
|
|
212
|
+
|
|
213
|
+
const { result, elapsed } = await timeReadiness({
|
|
214
|
+
timeout: 1500,
|
|
215
|
+
reason: 'e2e deadline check',
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
assert.strictEqual(result.ready, false);
|
|
219
|
+
assert.strictEqual(result.status, 'timed_out');
|
|
220
|
+
assert.ok(
|
|
221
|
+
elapsed < 6000,
|
|
222
|
+
`the wait took ${elapsed}ms for a 1500ms budget`
|
|
223
|
+
);
|
|
224
|
+
assert.ok(result.evidence.length > 0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('never spends more than the budget it was given', async () => {
|
|
228
|
+
await open('/never-idle');
|
|
229
|
+
|
|
230
|
+
const { result, elapsed } = await timeReadiness({ timeout: 200 });
|
|
231
|
+
|
|
232
|
+
assert.strictEqual(result.ready, false);
|
|
233
|
+
assert.ok(
|
|
234
|
+
elapsed < 3000,
|
|
235
|
+
`a 200ms budget must not become ${elapsed}ms`
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
);
|