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.
Files changed (43) hide show
  1. package/AGENTS.md +345 -0
  2. package/LICENSE +25 -0
  3. package/README.md +621 -2
  4. package/harness.mjs +4860 -0
  5. package/lib/build.mjs +57 -0
  6. package/lib/cdp.mjs +217 -0
  7. package/lib/chunk-discovery.mjs +226 -0
  8. package/lib/coverage.mjs +498 -0
  9. package/lib/field/analysis.mjs +225 -0
  10. package/lib/field/browser.mjs +232 -0
  11. package/lib/field/build-match.mjs +86 -0
  12. package/lib/field/capture.mjs +501 -0
  13. package/lib/field/config.mjs +84 -0
  14. package/lib/field/run.mjs +612 -0
  15. package/lib/gen-app.mjs +399 -0
  16. package/lib/graph-diff.mjs +136 -0
  17. package/lib/interrupt.mjs +25 -0
  18. package/lib/measure.mjs +540 -0
  19. package/lib/min-cut.mjs +218 -0
  20. package/lib/module-graph.mjs +318 -0
  21. package/lib/network-evidence.mjs +84 -0
  22. package/lib/node/launch.mjs +351 -0
  23. package/lib/node/measure.mjs +289 -0
  24. package/lib/paint-diagnostics.mjs +76 -0
  25. package/lib/parity/anchor.mjs +186 -0
  26. package/lib/parity/capture.mjs +527 -0
  27. package/lib/parity/compare.mjs +813 -0
  28. package/lib/parity/png.mjs +166 -0
  29. package/lib/parity/run.mjs +145 -0
  30. package/lib/profile.mjs +281 -0
  31. package/lib/replay/cert.mjs +45 -0
  32. package/lib/replay/model.mjs +656 -0
  33. package/lib/replay/run.mjs +1140 -0
  34. package/lib/replay/server.mjs +499 -0
  35. package/lib/replay/shaper.mjs +158 -0
  36. package/lib/rolldown-support.mjs +161 -0
  37. package/lib/serve.mjs +142 -0
  38. package/lib/worker/attrib.mjs +125 -0
  39. package/lib/worker/config.mjs +116 -0
  40. package/lib/worker/launch.mjs +337 -0
  41. package/lib/worker/measure.mjs +296 -0
  42. package/package.json +38 -4
  43. 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
+ }