rolldown-perf-kit 0.0.0-stage → 0.1.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/AGENTS.md +345 -0
- package/LICENSE +25 -0
- package/README.md +621 -2
- package/harness.mjs +4860 -0
- package/lib/build.mjs +57 -0
- package/lib/cdp.mjs +217 -0
- package/lib/chunk-discovery.mjs +226 -0
- package/lib/coverage.mjs +498 -0
- package/lib/field/analysis.mjs +225 -0
- package/lib/field/browser.mjs +232 -0
- package/lib/field/build-match.mjs +86 -0
- package/lib/field/capture.mjs +501 -0
- package/lib/field/config.mjs +84 -0
- package/lib/field/run.mjs +612 -0
- package/lib/gen-app.mjs +399 -0
- package/lib/graph-diff.mjs +136 -0
- package/lib/interrupt.mjs +25 -0
- package/lib/measure.mjs +540 -0
- package/lib/min-cut.mjs +218 -0
- package/lib/module-graph.mjs +318 -0
- package/lib/network-evidence.mjs +84 -0
- package/lib/node/launch.mjs +351 -0
- package/lib/node/measure.mjs +289 -0
- package/lib/paint-diagnostics.mjs +76 -0
- package/lib/parity/anchor.mjs +186 -0
- package/lib/parity/capture.mjs +527 -0
- package/lib/parity/compare.mjs +813 -0
- package/lib/parity/png.mjs +166 -0
- package/lib/parity/run.mjs +145 -0
- package/lib/profile.mjs +281 -0
- package/lib/replay/cert.mjs +45 -0
- package/lib/replay/model.mjs +656 -0
- package/lib/replay/run.mjs +1140 -0
- package/lib/replay/server.mjs +499 -0
- package/lib/replay/shaper.mjs +158 -0
- package/lib/rolldown-support.mjs +161 -0
- package/lib/serve.mjs +142 -0
- package/lib/worker/attrib.mjs +125 -0
- package/lib/worker/config.mjs +116 -0
- package/lib/worker/launch.mjs +337 -0
- package/lib/worker/measure.mjs +296 -0
- package/package.json +38 -4
- package/replay.mjs +79 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
// Deterministic page capture for parity: the browser half. One capture = one fresh
|
|
2
|
+
// tab that loads a page under a pinned clock and seeded Math.random, stills every
|
|
3
|
+
// motion source, waits for the page to stop changing, and records what it renders -
|
|
4
|
+
// viewport and full-page screenshots, element boxes + computed styles, visible
|
|
5
|
+
// text, images, the fonts Chrome actually drew with, accessible names, and any
|
|
6
|
+
// runtime errors on the way.
|
|
7
|
+
//
|
|
8
|
+
// The stabilization sequence is the one hosted visual-regression tools converged on
|
|
9
|
+
// (Chromatic and Percy): reduced motion, a CSS kill switch for
|
|
10
|
+
// animations/transitions, fonts.ready, rAF-idle and
|
|
11
|
+
// DOM-idle windows under ONE shared deadline, then freezing Web Animations and SMIL
|
|
12
|
+
// right before each screenshot. What it can't still (a live clock, network races)
|
|
13
|
+
// shows up as the unstable mask in compare.mjs instead of as a false difference.
|
|
14
|
+
|
|
15
|
+
import { openPage, sleep } from '../cdp.mjs';
|
|
16
|
+
import { ELEMENT_PATH_JS } from '../measure.mjs';
|
|
17
|
+
|
|
18
|
+
export const VIEWPORT = { width: 1280, height: 900 };
|
|
19
|
+
/** Full-page captures stop at this many viewport heights (and say so). */
|
|
20
|
+
export const FULL_PAGE_MAX_SCREENS = 10;
|
|
21
|
+
|
|
22
|
+
// Two capture conditions. The candidate always renders under A; the original renders
|
|
23
|
+
// under A and again under B. B moves the clock by 1d1h1m1s and reseeds Math.random,
|
|
24
|
+
// so anything driven by time or randomness differs between the two originals and
|
|
25
|
+
// lands in the unstable mask - it is never judged, rather than judged wrongly when a
|
|
26
|
+
// deferral reorders Math.random calls.
|
|
27
|
+
export const CONDITION_A = { seed: 0x2545f491, timeOrigin: Date.UTC(2026, 0, 15, 12, 0, 0) };
|
|
28
|
+
export const CONDITION_B = {
|
|
29
|
+
seed: 0x6c8e9cf5,
|
|
30
|
+
timeOrigin: CONDITION_A.timeOrigin + 86_400_000 + 3_600_000 + 60_000 + 1000,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const NAV_TIMEOUT_MS = 30_000;
|
|
34
|
+
const SETTLE_MS = 5000;
|
|
35
|
+
const STEP_SETTLE_MS = 600;
|
|
36
|
+
const RETURN_SETTLE_MS = 3000;
|
|
37
|
+
const NETWORK_QUIET_MS = 300;
|
|
38
|
+
// A finite JS animation (a chart easing in) stops requesting frames within this;
|
|
39
|
+
// a loop still running after it is infinite (a Rive/canvas hero, a spinner) and the
|
|
40
|
+
// rest of the capture stops waiting for it - the freeze and the unstable mask cover
|
|
41
|
+
// whatever it draws.
|
|
42
|
+
const RAF_CAP_MS = 2000;
|
|
43
|
+
const CAPTURE_TIMEOUT_MS = 120_000;
|
|
44
|
+
const FONT_ELEMENT_LIMIT = 120;
|
|
45
|
+
|
|
46
|
+
// Computed styles recorded per element. They explain a changed region ("background
|
|
47
|
+
// went transparent", "font-size 52px -> 16px"); pixels and text decide.
|
|
48
|
+
export const STYLE_PROPS = [
|
|
49
|
+
'display',
|
|
50
|
+
'visibility',
|
|
51
|
+
'opacity',
|
|
52
|
+
'position',
|
|
53
|
+
'z-index',
|
|
54
|
+
'color',
|
|
55
|
+
'background-color',
|
|
56
|
+
'background-image',
|
|
57
|
+
'font-family',
|
|
58
|
+
'font-size',
|
|
59
|
+
'font-weight',
|
|
60
|
+
'font-style',
|
|
61
|
+
'line-height',
|
|
62
|
+
'letter-spacing',
|
|
63
|
+
'text-transform',
|
|
64
|
+
'text-decoration-line',
|
|
65
|
+
'text-align',
|
|
66
|
+
'border-top-width',
|
|
67
|
+
'border-top-style',
|
|
68
|
+
'border-top-color',
|
|
69
|
+
'border-radius',
|
|
70
|
+
'box-shadow',
|
|
71
|
+
'transform',
|
|
72
|
+
'filter',
|
|
73
|
+
'object-fit',
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// Accessible names worth comparing: controls, landmarks, headings, images. Plain
|
|
77
|
+
// text is compared through innerText already.
|
|
78
|
+
const A11Y_ROLES = new Set([
|
|
79
|
+
'heading',
|
|
80
|
+
'link',
|
|
81
|
+
'button',
|
|
82
|
+
'img',
|
|
83
|
+
'image',
|
|
84
|
+
'textbox',
|
|
85
|
+
'searchbox',
|
|
86
|
+
'checkbox',
|
|
87
|
+
'radio',
|
|
88
|
+
'combobox',
|
|
89
|
+
'listbox',
|
|
90
|
+
'switch',
|
|
91
|
+
'slider',
|
|
92
|
+
'tab',
|
|
93
|
+
'menuitem',
|
|
94
|
+
'navigation',
|
|
95
|
+
'banner',
|
|
96
|
+
'main',
|
|
97
|
+
'contentinfo',
|
|
98
|
+
'complementary',
|
|
99
|
+
'form',
|
|
100
|
+
'search',
|
|
101
|
+
'dialog',
|
|
102
|
+
'alert',
|
|
103
|
+
'figure',
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
/** Pinned clock (advancing from a fixed origin, so busy-wait loops still end) + seeded Math.random. */
|
|
107
|
+
export function determinismScript({ seed, timeOrigin }) {
|
|
108
|
+
return `(() => {
|
|
109
|
+
let s = ${seed >>> 0};
|
|
110
|
+
Math.random = function random() {
|
|
111
|
+
s = (s + 0x6d2b79f5) >>> 0;
|
|
112
|
+
let t = s;
|
|
113
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
114
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
115
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
116
|
+
};
|
|
117
|
+
const RealDate = Date;
|
|
118
|
+
const p0 = performance.now();
|
|
119
|
+
const now = () => ${timeOrigin} + (performance.now() - p0);
|
|
120
|
+
function PinnedDate(...args) {
|
|
121
|
+
if (new.target === undefined) return new RealDate(now()).toString();
|
|
122
|
+
return Reflect.construct(RealDate, args.length ? args : [now()], new.target);
|
|
123
|
+
}
|
|
124
|
+
PinnedDate.prototype = RealDate.prototype;
|
|
125
|
+
PinnedDate.now = () => Math.floor(now());
|
|
126
|
+
PinnedDate.parse = RealDate.parse;
|
|
127
|
+
PinnedDate.UTC = RealDate.UTC;
|
|
128
|
+
Object.defineProperty(PinnedDate, 'name', { value: 'Date' });
|
|
129
|
+
globalThis.Date = PinnedDate;
|
|
130
|
+
})();`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// scroll-behavior: auto keeps our own scrolling synchronous; caret-color hides a
|
|
134
|
+
// blinking caret in an autofocused input.
|
|
135
|
+
const KILL_MOTION_JS = `(() => {
|
|
136
|
+
const style = document.createElement('style');
|
|
137
|
+
style.setAttribute('data-perf-kit', 'parity');
|
|
138
|
+
style.textContent = '*, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; animation-iteration-count: 1 !important; transition-duration: 0s !important; transition-delay: 0s !important; caret-color: transparent !important; scroll-behavior: auto !important; }';
|
|
139
|
+
(document.head || document.documentElement).append(style);
|
|
140
|
+
return true;
|
|
141
|
+
})()`;
|
|
142
|
+
|
|
143
|
+
// Finite animations jump to their end state (what a visitor ends up seeing);
|
|
144
|
+
// infinite ones (spinners) pin at t=0. SVG SMIL is a separate timeline.
|
|
145
|
+
const FREEZE_JS = `(() => {
|
|
146
|
+
for (const a of document.getAnimations ? document.getAnimations() : []) {
|
|
147
|
+
try {
|
|
148
|
+
const timing = a.effect && a.effect.getComputedTiming();
|
|
149
|
+
if (timing && timing.iterations === Infinity) a.currentTime = 0;
|
|
150
|
+
else {
|
|
151
|
+
try { a.finish(); } catch { a.currentTime = 0; }
|
|
152
|
+
}
|
|
153
|
+
a.pause();
|
|
154
|
+
} catch {}
|
|
155
|
+
}
|
|
156
|
+
for (const svg of document.querySelectorAll('svg')) {
|
|
157
|
+
if (typeof svg.pauseAnimations === 'function') {
|
|
158
|
+
svg.pauseAnimations();
|
|
159
|
+
svg.setCurrentTime(0);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
})()`;
|
|
164
|
+
|
|
165
|
+
const CONTENT_HEIGHT_JS =
|
|
166
|
+
'Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0)';
|
|
167
|
+
|
|
168
|
+
// One shared deadline across every wait (fonts, rAF-idle, DOM-idle, two paint
|
|
169
|
+
// frames): a page that needs all of them still settles within maxMs. Resolves to
|
|
170
|
+
// whether the page's animation-frame loop went quiet, so a capture stops waiting
|
|
171
|
+
// on an infinite one after the first time.
|
|
172
|
+
function settleJs(maxMs, { fonts = true, quietMs = 200, raf = true } = {}) {
|
|
173
|
+
return `(async () => {
|
|
174
|
+
const deadline = performance.now() + ${maxMs};
|
|
175
|
+
const left = () => Math.max(0, deadline - performance.now());
|
|
176
|
+
const bounded = (p) => Promise.race([p, new Promise((r) => setTimeout(r, left()))]);
|
|
177
|
+
${fonts ? 'try { await bounded(document.fonts ? document.fonts.ready : null); } catch {}' : ''}
|
|
178
|
+
const quiet = (subscribe, capMs) => new Promise((resolve) => {
|
|
179
|
+
const start = performance.now();
|
|
180
|
+
let last = start;
|
|
181
|
+
const stop = subscribe(() => { last = performance.now(); });
|
|
182
|
+
const tick = () => {
|
|
183
|
+
const now = performance.now();
|
|
184
|
+
if (now - last >= ${quietMs}) { stop(); resolve(true); }
|
|
185
|
+
else if (left() === 0 || now - start >= capMs) { stop(); resolve(false); }
|
|
186
|
+
else setTimeout(tick, 50);
|
|
187
|
+
};
|
|
188
|
+
setTimeout(tick, 50);
|
|
189
|
+
});
|
|
190
|
+
// A draw loop keeps requesting frames; wrapping rAF sees it without hooking the app.
|
|
191
|
+
const rafQuiet = ${raf} ? await quiet((bump) => {
|
|
192
|
+
const original = window.requestAnimationFrame;
|
|
193
|
+
window.requestAnimationFrame = function (cb) { bump(); return original.call(window, cb); };
|
|
194
|
+
return () => { window.requestAnimationFrame = original; };
|
|
195
|
+
}, ${RAF_CAP_MS}) : false;
|
|
196
|
+
await quiet((bump) => {
|
|
197
|
+
const observer = new MutationObserver(bump);
|
|
198
|
+
observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
199
|
+
return () => observer.disconnect();
|
|
200
|
+
}, Infinity);
|
|
201
|
+
await bounded(new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
|
202
|
+
return { rafQuiet };
|
|
203
|
+
})()`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// What the page renders, at the current scroll position. scope 'first' keeps only
|
|
207
|
+
// elements intersecting the viewport (the first screen); 'page' keeps everything
|
|
208
|
+
// plus the whole innerText. Coordinates are document coordinates.
|
|
209
|
+
function snapshotJs(scope, maskSelectors) {
|
|
210
|
+
return `(() => {
|
|
211
|
+
${ELEMENT_PATH_JS}
|
|
212
|
+
const scope = ${JSON.stringify(scope)};
|
|
213
|
+
const PROPS = ${JSON.stringify(STYLE_PROPS)};
|
|
214
|
+
const SKIP = new Set(['script', 'style', 'link', 'meta', 'template', 'noscript', 'title', 'base', 'br', 'option']);
|
|
215
|
+
const LIMIT = scope === 'first' ? 1500 : 4000;
|
|
216
|
+
const vw = innerWidth;
|
|
217
|
+
const vh = innerHeight;
|
|
218
|
+
const sx = scrollX;
|
|
219
|
+
const sy = scrollY;
|
|
220
|
+
const origin = location.origin;
|
|
221
|
+
const strip = (v) => v.split(origin).join('');
|
|
222
|
+
const shortSrc = (v) => (v.length > 160 ? v.slice(0, 120) + '...(' + v.length + ' chars)' : v);
|
|
223
|
+
const masks = [];
|
|
224
|
+
const maskedText = [];
|
|
225
|
+
const maskedEls = new Set();
|
|
226
|
+
const badMasks = [];
|
|
227
|
+
for (const selector of ${JSON.stringify(maskSelectors)}) {
|
|
228
|
+
let found;
|
|
229
|
+
try { found = document.querySelectorAll(selector); } catch { badMasks.push(selector); continue; }
|
|
230
|
+
for (const el of found) {
|
|
231
|
+
const r = el.getBoundingClientRect();
|
|
232
|
+
if (r.width > 0 && r.height > 0) masks.push({ x: r.left + sx, y: r.top + sy, w: r.width, h: r.height });
|
|
233
|
+
maskedEls.add(el);
|
|
234
|
+
for (const line of (el.innerText || '').split('\\n')) maskedText.push(line);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const isMasked = (el) => {
|
|
238
|
+
if (!maskedEls.size) return false;
|
|
239
|
+
for (let n = el; n; n = n.parentElement) if (maskedEls.has(n)) return true;
|
|
240
|
+
return false;
|
|
241
|
+
};
|
|
242
|
+
const offscreen = (r) => r.bottom <= 0 || r.top >= vh || r.right <= 0 || r.left >= vw;
|
|
243
|
+
const elements = [];
|
|
244
|
+
let truncated = false;
|
|
245
|
+
for (const el of document.body ? document.body.querySelectorAll('*') : []) {
|
|
246
|
+
const tag = el.tagName.toLowerCase();
|
|
247
|
+
if (SKIP.has(tag) || el.hasAttribute('data-perf-kit')) continue;
|
|
248
|
+
const r = el.getBoundingClientRect();
|
|
249
|
+
if (r.width < 1 || r.height < 1) continue;
|
|
250
|
+
if (scope === 'first' && offscreen(r)) continue;
|
|
251
|
+
if (isMasked(el)) continue;
|
|
252
|
+
if (elements.length >= LIMIT) { truncated = true; break; }
|
|
253
|
+
const cs = getComputedStyle(el);
|
|
254
|
+
const style = {};
|
|
255
|
+
for (const p of PROPS) style[p] = strip(cs.getPropertyValue(p));
|
|
256
|
+
let text = '';
|
|
257
|
+
for (const n of el.childNodes) if (n.nodeType === 3) text += n.nodeValue;
|
|
258
|
+
elements.push({
|
|
259
|
+
path: pathOf(el),
|
|
260
|
+
tag,
|
|
261
|
+
label: labelOf(el),
|
|
262
|
+
rect: { x: Math.round(r.left + sx), y: Math.round(r.top + sy), w: Math.round(r.width), h: Math.round(r.height) },
|
|
263
|
+
style,
|
|
264
|
+
text: text.replace(/\\s+/g, ' ').trim().slice(0, 160),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
const images = [];
|
|
268
|
+
for (const img of document.images) {
|
|
269
|
+
const r = img.getBoundingClientRect();
|
|
270
|
+
if (r.width < 1 || r.height < 1 || (scope === 'first' && offscreen(r)) || isMasked(img)) continue;
|
|
271
|
+
images.push(shortSrc(strip(img.currentSrc || img.src)) + ' ' + img.naturalWidth + 'x' + img.naturalHeight);
|
|
272
|
+
}
|
|
273
|
+
const faces = [];
|
|
274
|
+
if (document.fonts) {
|
|
275
|
+
for (const f of document.fonts) faces.push(f.family.replace(/["']/g, '') + ' ' + f.weight + ' ' + f.style + ': ' + f.status);
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
location: location.pathname + location.search,
|
|
279
|
+
elements,
|
|
280
|
+
truncated,
|
|
281
|
+
images,
|
|
282
|
+
faces,
|
|
283
|
+
text: scope === 'page' && document.body ? document.body.innerText : null,
|
|
284
|
+
masks,
|
|
285
|
+
maskedText,
|
|
286
|
+
badMasks,
|
|
287
|
+
contentHeight: ${CONTENT_HEIGHT_JS},
|
|
288
|
+
};
|
|
289
|
+
})()`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function withTimeout(promise, ms, what) {
|
|
293
|
+
let timer;
|
|
294
|
+
return Promise.race([
|
|
295
|
+
promise,
|
|
296
|
+
new Promise((_, reject) => {
|
|
297
|
+
timer = setTimeout(
|
|
298
|
+
() => reject(new Error(`${what} did not finish within ${ms / 1000}s`)),
|
|
299
|
+
ms,
|
|
300
|
+
);
|
|
301
|
+
}),
|
|
302
|
+
]).finally(() => clearTimeout(timer));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Idle = nothing awaiting a response and nothing received for NETWORK_QUIET_MS. A
|
|
306
|
+
// request that answered and went quiet counts as done even without loadingFinished:
|
|
307
|
+
// a streamed WebAssembly compile consumes its response and Chrome never reports
|
|
308
|
+
// the request finished (found via the rolldown docs' Rive hero).
|
|
309
|
+
function watchNetwork(page) {
|
|
310
|
+
const inflight = new Map(); // requestId -> answered?
|
|
311
|
+
let lastActivity = Date.now();
|
|
312
|
+
const touch = (p, answered) => {
|
|
313
|
+
if (answered === undefined && !inflight.has(p.requestId)) return;
|
|
314
|
+
inflight.set(p.requestId, answered ?? inflight.get(p.requestId));
|
|
315
|
+
lastActivity = Date.now();
|
|
316
|
+
};
|
|
317
|
+
page.on('Network.requestWillBeSent', (p) => touch(p, false));
|
|
318
|
+
page.on('Network.responseReceived', (p) => touch(p, true));
|
|
319
|
+
page.on('Network.dataReceived', (p) => touch(p));
|
|
320
|
+
const finish = (p) => {
|
|
321
|
+
if (inflight.delete(p.requestId)) lastActivity = Date.now();
|
|
322
|
+
};
|
|
323
|
+
page.on('Network.loadingFinished', finish);
|
|
324
|
+
page.on('Network.loadingFailed', finish);
|
|
325
|
+
return {
|
|
326
|
+
// A long-poll never answers; the deadline bounds the wait.
|
|
327
|
+
async idle(deadline) {
|
|
328
|
+
while (Date.now() < deadline) {
|
|
329
|
+
const waiting = Array.from(inflight.values()).some((answered) => !answered);
|
|
330
|
+
if (!waiting && Date.now() - lastActivity >= NETWORK_QUIET_MS) return;
|
|
331
|
+
await sleep(50);
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Raw error text; compare.mjs normalizes it (origins, positions, hashed names) and
|
|
338
|
+
// only errors the original never produced count against the candidate.
|
|
339
|
+
function watchRuntime(page) {
|
|
340
|
+
const errors = [];
|
|
341
|
+
const urls = new Map();
|
|
342
|
+
const argText = (a) =>
|
|
343
|
+
a.value !== undefined ? String(a.value) : (a.description ?? a.unserializableValue ?? a.type);
|
|
344
|
+
page.on('Network.requestWillBeSent', (p) => urls.set(p.requestId, p.request.url));
|
|
345
|
+
page.on('Runtime.exceptionThrown', ({ exceptionDetails: d }) => {
|
|
346
|
+
errors.push(`uncaught ${d.exception?.description ?? d.text}`);
|
|
347
|
+
});
|
|
348
|
+
page.on('Runtime.consoleAPICalled', ({ type, args }) => {
|
|
349
|
+
if (type === 'error' || type === 'assert') {
|
|
350
|
+
errors.push(`console.${type}: ${args.map(argText).join(' ')}`);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
// network failures arrive below with their URL; exceptions above with their text
|
|
354
|
+
page.on('Log.entryAdded', ({ entry }) => {
|
|
355
|
+
if (entry.level === 'error' && entry.source !== 'network' && entry.source !== 'javascript') {
|
|
356
|
+
errors.push(`${entry.source}: ${entry.text}`);
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
page.on('Network.loadingFailed', (p) => {
|
|
360
|
+
if (!p.canceled) errors.push(`request failed: ${urls.get(p.requestId) ?? '?'} ${p.errorText}`);
|
|
361
|
+
});
|
|
362
|
+
page.on('Network.responseReceived', (p) => {
|
|
363
|
+
if (p.response.status >= 400) errors.push(`HTTP ${p.response.status} ${p.response.url}`);
|
|
364
|
+
});
|
|
365
|
+
return errors;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// `motion.rafBusy` flips once a page's frame loop outlives RAF_CAP_MS; every later
|
|
369
|
+
// wait in the same capture skips that phase instead of burning its deadline again.
|
|
370
|
+
async function settle(page, net, ms, motion) {
|
|
371
|
+
const deadline = Date.now() + ms;
|
|
372
|
+
await net.idle(deadline);
|
|
373
|
+
const result = await page.evaluate(
|
|
374
|
+
settleJs(Math.max(0, deadline - Date.now()), { raf: !motion.rafBusy }),
|
|
375
|
+
);
|
|
376
|
+
if (!motion.rafBusy && result && !result.rafQuiet) motion.rafBusy = true;
|
|
377
|
+
await net.idle(deadline);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Lazy-on-visible sections only render once scrolled to; walking the page one
|
|
381
|
+
// viewport at a time renders them in BOTH builds before the full-page shot.
|
|
382
|
+
async function scrollThrough(page, net, motion) {
|
|
383
|
+
const limit = VIEWPORT.height * FULL_PAGE_MAX_SCREENS;
|
|
384
|
+
for (let y = VIEWPORT.height; y < limit; y += VIEWPORT.height) {
|
|
385
|
+
if (y >= (await page.evaluate(CONTENT_HEIGHT_JS))) break;
|
|
386
|
+
await page.evaluate(`window.scrollTo(0, ${y})`);
|
|
387
|
+
const result = await page.evaluate(
|
|
388
|
+
settleJs(STEP_SETTLE_MS, { fonts: false, quietMs: 100, raf: !motion.rafBusy }),
|
|
389
|
+
);
|
|
390
|
+
if (!motion.rafBusy && result && !result.rafQuiet) motion.rafBusy = true;
|
|
391
|
+
await net.idle(Date.now() + STEP_SETTLE_MS);
|
|
392
|
+
}
|
|
393
|
+
await page.evaluate('window.scrollTo(0, 0)');
|
|
394
|
+
await settle(page, net, RETURN_SETTLE_MS, motion);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function screenshot(page, params = {}) {
|
|
398
|
+
const { data } = await page.send('Page.captureScreenshot', { format: 'png', ...params });
|
|
399
|
+
return Buffer.from(data, 'base64');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** The fonts Chrome actually drew each first-screen text element with (fallbacks included). */
|
|
403
|
+
async function renderedFonts(page, elements) {
|
|
404
|
+
const textEls = elements
|
|
405
|
+
.filter((e) => e.text)
|
|
406
|
+
.sort((a, b) => b.rect.w * b.rect.h - a.rect.w * a.rect.h)
|
|
407
|
+
.slice(0, FONT_ELEMENT_LIMIT);
|
|
408
|
+
if (!textEls.length) return {};
|
|
409
|
+
await page.send('DOM.enable');
|
|
410
|
+
await page.send('CSS.enable');
|
|
411
|
+
const { root } = await page.send('DOM.getDocument', { depth: 0 });
|
|
412
|
+
const out = {};
|
|
413
|
+
for (const e of textEls) {
|
|
414
|
+
const selector = `html > ${e.path
|
|
415
|
+
.split('>')
|
|
416
|
+
.map((part) => {
|
|
417
|
+
const [tag, n] = part.split(':');
|
|
418
|
+
return `${tag}:nth-of-type(${n})`;
|
|
419
|
+
})
|
|
420
|
+
.join(' > ')}`;
|
|
421
|
+
try {
|
|
422
|
+
const { nodeId } = await page.send('DOM.querySelector', { nodeId: root.nodeId, selector });
|
|
423
|
+
if (!nodeId) continue;
|
|
424
|
+
const { fonts } = await page.send('CSS.getPlatformFontsForNode', { nodeId });
|
|
425
|
+
if (fonts.length) out[e.path] = fonts.map((f) => f.familyName).join(', ');
|
|
426
|
+
} catch {
|
|
427
|
+
// the element went away or its tag doesn't round-trip through a selector
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function accessibleNames(page) {
|
|
434
|
+
try {
|
|
435
|
+
const { nodes } = await page.send('Accessibility.getFullAXTree');
|
|
436
|
+
const out = [];
|
|
437
|
+
for (const node of nodes) {
|
|
438
|
+
const role = node.role?.value;
|
|
439
|
+
const name = node.name?.value;
|
|
440
|
+
if (node.ignored || !A11Y_ROLES.has(role) || typeof name !== 'string' || !name.trim()) {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
out.push(`${role} "${name.replace(/\s+/g, ' ').trim().slice(0, 100)}"`);
|
|
444
|
+
}
|
|
445
|
+
return out;
|
|
446
|
+
} catch {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Capture one page of whatever build the server currently serves. `origin` is the
|
|
453
|
+
* parity server; every capture clears its storage first, so no capture sees state
|
|
454
|
+
* (a "returning visitor" flag, a dismissed banner) written by an earlier one.
|
|
455
|
+
*/
|
|
456
|
+
export function capturePage(cdp, { origin, pagePath, condition, masks = [] }) {
|
|
457
|
+
return withTimeout(
|
|
458
|
+
captureInner(cdp, { origin, pagePath, condition, masks }),
|
|
459
|
+
CAPTURE_TIMEOUT_MS,
|
|
460
|
+
`capture of ${pagePath}`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function captureInner(cdp, { origin, pagePath, condition, masks }) {
|
|
465
|
+
const page = await openPage(cdp, { injectScript: determinismScript(condition) });
|
|
466
|
+
const errors = watchRuntime(page);
|
|
467
|
+
const net = watchNetwork(page);
|
|
468
|
+
try {
|
|
469
|
+
await page.send('Storage.clearDataForOrigin', { origin, storageTypes: 'all' }).catch(() => {});
|
|
470
|
+
await page.send('Log.enable').catch(() => {});
|
|
471
|
+
await page.send('Emulation.setDeviceMetricsOverride', {
|
|
472
|
+
width: VIEWPORT.width,
|
|
473
|
+
height: VIEWPORT.height,
|
|
474
|
+
deviceScaleFactor: 1,
|
|
475
|
+
mobile: false,
|
|
476
|
+
});
|
|
477
|
+
await page.send('Emulation.setEmulatedMedia', {
|
|
478
|
+
features: [{ name: 'prefers-reduced-motion', value: 'reduce' }],
|
|
479
|
+
});
|
|
480
|
+
await page.navigate(`${origin}${pagePath}`);
|
|
481
|
+
const deadline = Date.now() + NAV_TIMEOUT_MS;
|
|
482
|
+
for (;;) {
|
|
483
|
+
const loaded = await page
|
|
484
|
+
.evaluate(
|
|
485
|
+
`location.origin === ${JSON.stringify(origin)} && document.readyState === 'complete'`,
|
|
486
|
+
)
|
|
487
|
+
.catch(() => false);
|
|
488
|
+
if (loaded) break;
|
|
489
|
+
if (Date.now() > deadline) throw new Error(`${pagePath} did not finish loading`);
|
|
490
|
+
await sleep(50);
|
|
491
|
+
}
|
|
492
|
+
await page.evaluate(KILL_MOTION_JS);
|
|
493
|
+
const motion = { rafBusy: false };
|
|
494
|
+
await settle(page, net, SETTLE_MS, motion);
|
|
495
|
+
await page.evaluate(FREEZE_JS);
|
|
496
|
+
const first = await page.evaluate(snapshotJs('first', masks));
|
|
497
|
+
const viewport = await screenshot(page);
|
|
498
|
+
|
|
499
|
+
await scrollThrough(page, net, motion);
|
|
500
|
+
await page.evaluate(FREEZE_JS);
|
|
501
|
+
const whole = await page.evaluate(snapshotJs('page', masks));
|
|
502
|
+
let full = null;
|
|
503
|
+
let fullTruncated = false;
|
|
504
|
+
if (whole.contentHeight > VIEWPORT.height) {
|
|
505
|
+
const height = Math.min(whole.contentHeight, VIEWPORT.height * FULL_PAGE_MAX_SCREENS);
|
|
506
|
+
fullTruncated = whole.contentHeight > height;
|
|
507
|
+
full = await screenshot(page, {
|
|
508
|
+
captureBeyondViewport: true,
|
|
509
|
+
clip: { x: 0, y: 0, width: VIEWPORT.width, height, scale: 1 },
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
const fonts = await renderedFonts(page, first.elements);
|
|
513
|
+
const a11y = await accessibleNames(page);
|
|
514
|
+
return {
|
|
515
|
+
viewport,
|
|
516
|
+
full,
|
|
517
|
+
fullTruncated,
|
|
518
|
+
first,
|
|
519
|
+
page: whole,
|
|
520
|
+
fonts,
|
|
521
|
+
a11y,
|
|
522
|
+
errors: errors.slice(),
|
|
523
|
+
};
|
|
524
|
+
} finally {
|
|
525
|
+
await page.close().catch(() => {});
|
|
526
|
+
}
|
|
527
|
+
}
|