diff --git a/packages/core/src/config.js b/packages/core/src/config.js
index c34e45427..8694f97b3 100644
--- a/packages/core/src/config.js
+++ b/packages/core/src/config.js
@@ -132,6 +132,23 @@ export const configSchema = {
sync: {
type: 'boolean'
},
+ readiness: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ preset: { type: 'string', enum: ['balanced', 'strict', 'fast', 'disabled'] },
+ stabilityWindowMs: { type: 'integer', minimum: 50, maximum: 30000 },
+ jsIdleWindowMs: { type: 'integer', minimum: 50, maximum: 30000 },
+ networkIdleWindowMs: { type: 'integer', minimum: 50, maximum: 10000 },
+ timeoutMs: { type: 'integer', minimum: 1000, maximum: 60000 },
+ imageReady: { type: 'boolean' },
+ fontReady: { type: 'boolean' },
+ jsIdle: { type: 'boolean' },
+ readySelectors: { type: 'array', items: { type: 'string' } },
+ notPresentSelectors: { type: 'array', items: { type: 'string' } },
+ maxTimeoutMs: { type: 'integer', minimum: 1000, maximum: 60000 }
+ }
+ },
responsiveSnapshotCapture: {
type: 'boolean',
default: false
@@ -489,6 +506,7 @@ export const snapshotSchema = {
domTransformation: { $ref: '/config/snapshot#/properties/domTransformation' },
enableLayout: { $ref: '/config/snapshot#/properties/enableLayout' },
sync: { $ref: '/config/snapshot#/properties/sync' },
+ readiness: { $ref: '/config/snapshot#/properties/readiness' },
responsiveSnapshotCapture: { $ref: '/config/snapshot#/properties/responsiveSnapshotCapture' },
testCase: { $ref: '/config/snapshot#/properties/testCase' },
labels: { $ref: '/config/snapshot#/properties/labels' },
@@ -682,6 +700,10 @@ export const snapshotSchema = {
type: 'array',
items: { type: 'string' }
},
+ readiness_diagnostics: {
+ type: 'object',
+ description: 'Diagnostics from readiness checks run before serialization'
+ },
corsIframes: {
type: 'array',
items: {
diff --git a/packages/core/src/page.js b/packages/core/src/page.js
index 8bef91afc..de9aedb24 100644
--- a/packages/core/src/page.js
+++ b/packages/core/src/page.js
@@ -214,14 +214,23 @@ export class Page {
await this.insertPercyDom();
// serialize and capture a DOM snapshot
+ // Readiness config is passed to PercyDOM.serialize() so that readiness
+ // checks run BEFORE serialization — in both URL-based and SDK paths.
+ // The readiness config comes from per-snapshot options or the global
+ // Percy config (injected as window.__PERCY__.config.snapshot.readiness).
+ let readiness = snapshot.readiness || this.browser?.percy?.config?.snapshot?.readiness;
this.log.debug('Serialize DOM', this.meta);
+ // Use serializeDOMWithReadiness so readiness runs BEFORE serialize in the
+ // URL-capture path. Existing SDKs continue calling the sync serializeDOM.
+ // page.eval uses CDP awaitPromise: true, which auto-awaits the returned Promise.
/* istanbul ignore next: no instrumenting injected code */
- let capture = await this.eval((_, options) => ({
+ let capture = await this.eval(async (_, options) => {
/* eslint-disable-next-line no-undef */
- domSnapshot: PercyDOM.serialize(options),
- url: document.URL
- }), { enableJavaScript, disableShadowDOM, forceShadowAsLightDOM, domTransformation, reshuffleInvalidTags, ignoreCanvasSerializationErrors, ignoreStyleSheetSerializationErrors, pseudoClassEnabledElements });
+ let fn = (PercyDOM.serializeDOMWithReadiness || PercyDOM.serialize);
+ let domSnapshot = await fn(options);
+ return { domSnapshot, url: document.URL };
+ }, { enableJavaScript, disableShadowDOM, forceShadowAsLightDOM, domTransformation, reshuffleInvalidTags, ignoreCanvasSerializationErrors, ignoreStyleSheetSerializationErrors, pseudoClassEnabledElements, readiness });
return { ...snapshot, ...capture };
}
diff --git a/packages/core/src/snapshot.js b/packages/core/src/snapshot.js
index b93be4ddb..4b1494d6b 100644
--- a/packages/core/src/snapshot.js
+++ b/packages/core/src/snapshot.js
@@ -220,6 +220,17 @@ export function validateSnapshotOptions(options) {
log.warn('Encountered snapshot serialization warnings:');
for (let w of domWarnings) log.warn(`- ${w}`);
}
+
+ // log readiness diagnostics when present (SDK-submitted snapshots with readiness enabled)
+ let readinessDiag = migrated.domSnapshot?.readiness_diagnostics;
+ if (readinessDiag) {
+ if (readinessDiag.timed_out) {
+ log.warn(`Readiness timed out after ${readinessDiag.total_duration_ms}ms (preset: ${readinessDiag.preset || 'custom'})`);
+ } else {
+ log.debug(`Readiness passed in ${readinessDiag.total_duration_ms}ms (preset: ${readinessDiag.preset || 'custom'})`);
+ }
+ }
+
// warn on validation errors
let errors = PercyConfig.validate(migrated, schema);
if (errors?.length > 0) {
diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js
index 7c5e0ac55..4524c87ca 100644
--- a/packages/core/test/percy.test.js
+++ b/packages/core/test/percy.test.js
@@ -110,7 +110,7 @@ describe('Percy', () => {
});
// expect required arguments are passed to PercyDOM.serialize
- expect(evalSpy.calls.allArgs()[3]).toEqual(jasmine.arrayContaining([jasmine.anything(), { enableJavaScript: undefined, disableShadowDOM: true, domTransformation: undefined, reshuffleInvalidTags: undefined, ignoreCanvasSerializationErrors: undefined, ignoreStyleSheetSerializationErrors: undefined, forceShadowAsLightDOM: undefined, pseudoClassEnabledElements: undefined }]));
+ expect(evalSpy.calls.allArgs()[3]).toEqual(jasmine.arrayContaining([jasmine.anything(), { enableJavaScript: undefined, disableShadowDOM: true, domTransformation: undefined, reshuffleInvalidTags: undefined, ignoreCanvasSerializationErrors: undefined, ignoreStyleSheetSerializationErrors: undefined, forceShadowAsLightDOM: undefined, pseudoClassEnabledElements: undefined, readiness: undefined }]));
expect(snapshot.url).toEqual('http://localhost:8000/');
expect(snapshot.domSnapshot).toEqual(jasmine.objectContaining({
diff --git a/packages/dom/src/index.js b/packages/dom/src/index.js
index a449d17f3..e0f1939a8 100644
--- a/packages/dom/src/index.js
+++ b/packages/dom/src/index.js
@@ -3,7 +3,10 @@ export {
serializeDOM,
// namespace alias
serializeDOM as serialize,
+ serializeDOMWithReadiness,
waitForResize
} from './serialize-dom';
export { loadAllSrcsetLinks } from './serialize-image-srcset';
+
+export { waitForReady } from './readiness';
diff --git a/packages/dom/src/readiness.js b/packages/dom/src/readiness.js
new file mode 100644
index 000000000..f546e2ea1
--- /dev/null
+++ b/packages/dom/src/readiness.js
@@ -0,0 +1,501 @@
+/* eslint-disable no-undef */
+// Browser globals (performance, MutationObserver, document, window, getComputedStyle)
+// are available in the browser execution context where this code runs.
+
+// Readiness check presets
+//
+// `js_idle_window_ms` is separate from `stability_window_ms` on purpose:
+// DOM stability and main-thread idleness measure different things. With
+// the `strict` preset we want a long DOM-stability window (1000ms) but
+// not necessarily 1000ms of no long tasks — that would cause unnecessary
+// timeouts on pages with normal JS activity. Both windows are
+// independently configurable but default to reasonable values per preset.
+const PRESETS = {
+ balanced: {
+ stability_window_ms: 300,
+ js_idle_window_ms: 300,
+ network_idle_window_ms: 200,
+ timeout_ms: 10000,
+ image_ready: true,
+ font_ready: true,
+ js_idle: true
+ },
+ strict: {
+ stability_window_ms: 1000,
+ js_idle_window_ms: 500,
+ network_idle_window_ms: 500,
+ timeout_ms: 30000,
+ image_ready: true,
+ font_ready: true,
+ js_idle: true
+ },
+ fast: {
+ stability_window_ms: 100,
+ js_idle_window_ms: 100,
+ network_idle_window_ms: 100,
+ timeout_ms: 5000,
+ image_ready: false,
+ font_ready: true,
+ js_idle: true
+ }
+};
+
+const LAYOUT_ATTRIBUTES = new Set([
+ 'class', 'width', 'height', 'display', 'visibility',
+ 'position', 'src'
+]);
+
+const LAYOUT_STYLE_PROPS = /^(width|height|top|left|right|bottom|margin|padding|display|position|visibility|flex|grid|min-|max-|inset|gap|order|float|clear|overflow|z-index|columns)/;
+
+// Exported for direct unit testing — logic is deterministic and does not
+// depend on browser timing, so it should not be covered only indirectly
+// through MutationObserver-driven integration tests.
+export function isLayoutMutation(mutation) {
+ if (mutation.type === 'childList') return true;
+ if (mutation.type === 'attributes') {
+ let attr = mutation.attributeName;
+ if (attr.startsWith('data-') || attr.startsWith('aria-')) return false;
+ if (attr === 'style') {
+ let oldStyle = mutation.oldValue || '';
+ let newStyle = mutation.target.getAttribute('style') || '';
+ return hasLayoutStyleChange(oldStyle, newStyle);
+ }
+ // href is only layout-affecting on elements (stylesheets).
+ // On tags changing href is a no-op for layout.
+ if (attr === 'href') return mutation.target.tagName === 'LINK';
+ if (LAYOUT_ATTRIBUTES.has(attr)) return true;
+ }
+ return false;
+}
+
+export function hasLayoutStyleChange(oldStyle, newStyle) {
+ if (oldStyle === newStyle) return false;
+ let oldProps = parseStyleProps(oldStyle);
+ let newProps = parseStyleProps(newStyle);
+ let allKeys = new Set([...Object.keys(oldProps), ...Object.keys(newProps)]);
+ for (let key of allKeys) {
+ if (LAYOUT_STYLE_PROPS.test(key) && oldProps[key] !== newProps[key]) return true;
+ }
+ return false;
+}
+
+export function parseStyleProps(styleStr) {
+ let props = {};
+ if (!styleStr) return props;
+ for (let part of styleStr.split(';')) {
+ let i = part.indexOf(':');
+ if (i > 0) {
+ let key = part.slice(0, i).trim().toLowerCase();
+ if (key) props[key] = part.slice(i + 1).trim();
+ }
+ }
+ return props;
+}
+
+// --- Individual Checks ---
+// Each check accepts an `aborted` object ({ value: boolean }) so the orchestrator
+// can signal cancellation on timeout. Checks must clean up timers/observers on abort.
+
+function checkDOMStability(stabilityWindowMs, aborted) {
+ return new Promise(resolve => {
+ let startTime = performance.now();
+ let timer = null;
+ let mutationCount = 0;
+ let lastMutationType = null;
+
+ let observer = new MutationObserver(mutations => {
+ /* istanbul ignore next: abort disconnects the observer synchronously, defensive dead code in tests */
+ if (aborted.value) return;
+ let hasLayout = false;
+ for (let m of mutations) {
+ if (isLayoutMutation(m)) { hasLayout = true; mutationCount++; lastMutationType = m.type; }
+ }
+ /* istanbul ignore next: timer is always set before observer fires */
+ if (hasLayout) { if (timer) clearTimeout(timer); timer = setTimeout(settle, stabilityWindowMs); }
+ });
+
+ function settle() {
+ observer.disconnect();
+ resolve({
+ passed: true,
+ duration_ms: Math.round(performance.now() - startTime),
+ mutations_observed: mutationCount,
+ last_mutation_type: lastMutationType
+ });
+ }
+
+ observer.observe(document.documentElement, {
+ childList: true,
+ attributes: true,
+ attributeOldValue: true,
+ subtree: true,
+ attributeFilter: [...LAYOUT_ATTRIBUTES, 'style', 'href']
+ });
+ timer = setTimeout(settle, stabilityWindowMs);
+
+ // Cleanup on abort
+ aborted.onAbort(() => {
+ /* istanbul ignore next: timer is always set at line 124 before abort can fire */
+ if (timer) clearTimeout(timer);
+ observer.disconnect();
+ });
+ });
+}
+
+function checkNetworkIdle(networkIdleWindowMs, aborted) {
+ // Use PerformanceObserver to listen for new 'resource' entries incrementally.
+ // Previously we polled `performance.getEntriesByType('resource')` every 50ms,
+ // which allocates and scans the full resource list on every tick — expensive
+ // on resource-heavy pages with hundreds of images/scripts/stylesheets.
+ //
+ // On browsers without PerformanceObserver (very old), fall back to polling
+ // so the check still works.
+ return new Promise(resolve => {
+ let startTime = performance.now();
+ let timer = null;
+ let observer = null;
+ let pollInterval = null;
+
+ function settle() {
+ /* istanbul ignore next: observer is only null on fallback path (itself ignored) */
+ if (observer) observer.disconnect();
+ /* istanbul ignore next: fallback polling path only used when PerformanceObserver is unavailable */
+ if (pollInterval) clearInterval(pollInterval);
+ resolve({ passed: true, duration_ms: Math.round(performance.now() - startTime) });
+ }
+
+ function resetIdleTimer() {
+ /* istanbul ignore next: timer is always set at line 191 before any resource entry arrives */
+ if (timer) clearTimeout(timer);
+ timer = setTimeout(settle, networkIdleWindowMs);
+ }
+
+ try {
+ /* istanbul ignore next: observer callback body only runs if a network resource loads during the idle window */
+ observer = new PerformanceObserver(list => {
+ if (aborted.value) return;
+ // Any resource entry means network activity — reset the idle window.
+ if (list.getEntries().length > 0) resetIdleTimer();
+ });
+ observer.observe({ type: 'resource', buffered: false });
+ } catch (e) /* istanbul ignore next: PerformanceObserver is available in Chrome/Firefox; catch is for old browsers */ {
+ // Older browser — fall back to polling
+ observer = null;
+ let lastCount = performance.getEntriesByType('resource').length;
+ pollInterval = setInterval(() => {
+ if (aborted.value) { clearInterval(pollInterval); return; }
+ let count = performance.getEntriesByType('resource').length;
+ if (count !== lastCount) { lastCount = count; resetIdleTimer(); }
+ }, 50);
+ }
+
+ // Start the initial idle window.
+ timer = setTimeout(settle, networkIdleWindowMs);
+
+ aborted.onAbort(() => {
+ /* istanbul ignore next: observer is only null on fallback path (itself ignored) */
+ if (observer) observer.disconnect();
+ /* istanbul ignore next: pollInterval is only set on the fallback path */
+ if (pollInterval) clearInterval(pollInterval);
+ /* istanbul ignore next: timer is always set before abort can fire */
+ if (timer) clearTimeout(timer);
+ });
+ });
+}
+
+function checkFontReady(aborted) {
+ let start = performance.now();
+ /* istanbul ignore next: cannot mock document.fonts API in browser tests */
+ if (!document.fonts?.ready) return Promise.resolve({ passed: true, duration_ms: 0, skipped: true });
+ let fontTimer;
+ let result = Promise.race([
+ document.fonts.ready.then(() => ({ passed: true, duration_ms: Math.round(performance.now() - start) })),
+ /* istanbul ignore next: font timeout requires 5s delay, impractical in tests */
+ new Promise(r => { fontTimer = setTimeout(() => r({ passed: false, duration_ms: 5000, timed_out: true }), 5000); })
+ ]);
+ /* istanbul ignore next: abort path not deterministically testable */
+ if (aborted) aborted.onAbort(() => { if (fontTimer) clearTimeout(fontTimer); });
+ return result;
+}
+
+function checkImageReady(aborted) {
+ return new Promise(resolve => {
+ let start = performance.now();
+ let vh = window.innerHeight;
+ function getIncomplete() {
+ let imgs = document.querySelectorAll('img');
+ let incomplete = [];
+ for (let img of imgs) {
+ let r = img.getBoundingClientRect();
+ /* istanbul ignore else: test images are always placed in the viewport with non-zero dimensions */
+ if (r.top < vh && r.bottom > 0 && r.width > 0 && r.height > 0) {
+ if (!img.complete || img.naturalWidth === 0) incomplete.push(img);
+ }
+ }
+ return incomplete;
+ }
+ let total = document.querySelectorAll('img').length;
+ let incStart = getIncomplete().length;
+ if (incStart === 0) { resolve({ passed: true, duration_ms: 0, images_checked: total, images_incomplete_at_start: 0 }); return; }
+ let interval = setInterval(() => {
+ /* istanbul ignore next: abort clears the interval synchronously, defensive dead code in tests */
+ if (aborted.value) { clearInterval(interval); return; }
+ /* istanbul ignore next: requires network latency — images load synchronously in tests with data: URLs */
+ if (getIncomplete().length === 0) {
+ clearInterval(interval);
+ resolve({ passed: true, duration_ms: Math.round(performance.now() - start), images_checked: total, images_incomplete_at_start: incStart });
+ }
+ }, 100);
+
+ /* istanbul ignore next: abort-on-timeout path; only fires when images never load in time */
+ aborted.onAbort(() => clearInterval(interval));
+ });
+}
+
+function checkJSIdle(idleWindowMs, aborted) {
+ // Three-tier JS idle detection — purely observational, no monkey-patching:
+ // Tier 1: Long Task API (PerformanceObserver) — detects main-thread tasks >50ms
+ // Tier 2: requestIdleCallback — confirms browser idle (fallback: setTimeout 200ms)
+ // Tier 3: Double-requestAnimationFrame — ensures render/paint cycle is complete
+ return new Promise(resolve => {
+ let start = performance.now();
+ let longTaskCount = 0;
+ let idleTimer = null;
+ let observer = null;
+ let settled = false;
+ let observing = false;
+
+ // Tier 1: Long Task API — reset idle timer on each observed long task
+ try {
+ /* istanbul ignore next: longtask callback fires only on CPU-heavy >50ms tasks, not reliable in tests */
+ observer = new PerformanceObserver(list => {
+ if (!observing || settled || aborted.value) return;
+ for (let entry of list.getEntries()) {
+ if (entry.entryType === 'longtask') {
+ longTaskCount++;
+ if (idleTimer) clearTimeout(idleTimer);
+ idleTimer = setTimeout(confirmIdle, idleWindowMs);
+ }
+ }
+ });
+ observer.observe({ type: 'longtask', buffered: false });
+ } catch (e) /* istanbul ignore next: Long Task API is available in Chrome/Firefox, catch is for older browsers */ {
+ // Long Task API not available — degrade to rIC/rAF-only path
+ observer = null;
+ }
+
+ function cleanup() {
+ settled = true;
+ /* istanbul ignore next: defensive — observer is always set except when Long Task API fails (itself ignored) */
+ if (observer) observer.disconnect();
+ /* istanbul ignore next: defensive — idleTimer may be null between cleanup calls from multiple abort paths */
+ if (idleTimer) clearTimeout(idleTimer);
+ }
+
+ function done(idleCallbackUsed) {
+ /* istanbul ignore next: defensive — re-entry guard for race between done/cleanup/abort */
+ if (settled || aborted.value) return;
+ cleanup();
+ resolve({
+ passed: true,
+ duration_ms: Math.round(performance.now() - start),
+ long_tasks_observed: longTaskCount,
+ idle_callback_used: idleCallbackUsed
+ });
+ }
+
+ // Tier 2: requestIdleCallback confirmation (or fallback)
+ function confirmIdle() {
+ /* istanbul ignore next: defensive re-entry guard — confirmIdle can be scheduled multiple times */
+ if (settled || aborted.value) return;
+ /* istanbul ignore else: rIC is available in modern Chrome/Firefox — fallback is for older browsers */
+ if (typeof requestIdleCallback === 'function') {
+ /* istanbul ignore next: rIC timeout only fires if requestIdleCallback takes longer than idleWindowMs * 2 — cleared by rIC callback in normal runs */
+ let ricTimer = setTimeout(() => doubleRAF(false), idleWindowMs * 2);
+ requestIdleCallback(() => {
+ clearTimeout(ricTimer);
+ doubleRAF(true);
+ });
+ aborted.onAbort(() => clearTimeout(ricTimer));
+ } else {
+ let fallbackTimer = setTimeout(() => doubleRAF(false), 200);
+ aborted.onAbort(() => clearTimeout(fallbackTimer));
+ }
+ }
+
+ // Tier 3: Double-rAF render gate
+ function doubleRAF(usedRIC) {
+ /* istanbul ignore next: defensive re-entry guard — doubleRAF can be scheduled from multiple paths */
+ if (settled || aborted.value) return;
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ done(usedRIC);
+ });
+ });
+ }
+
+ // Start: skip first frame to avoid detecting Percy's own insertPercyDom() setup,
+ // then begin idle window
+ requestAnimationFrame(() => {
+ /* istanbul ignore next: abort only fires during timeout race, not on first rAF in tests */
+ if (aborted.value) return;
+ observing = true;
+ idleTimer = setTimeout(confirmIdle, idleWindowMs);
+ });
+
+ aborted.onAbort(() => cleanup());
+ });
+}
+
+function checkReadySelectors(selectors, aborted) {
+ /* istanbul ignore next: orchestrator only calls this when selectors.length > 0; defensive for direct callers */
+ if (!selectors?.length) return Promise.resolve({ passed: true, duration_ms: 0, selectors: [] });
+ return new Promise(resolve => {
+ let start = performance.now();
+ function check() {
+ for (let s of selectors) {
+ let el = document.querySelector(s);
+ if (!el) return false;
+ if (el.offsetParent === null && getComputedStyle(el).position !== 'fixed' && getComputedStyle(el).position !== 'sticky') return false;
+ }
+ return true;
+ }
+ if (check()) { resolve({ passed: true, duration_ms: 0, selectors }); return; }
+ let interval = setInterval(() => {
+ /* istanbul ignore next: abort clears the interval synchronously, defensive dead code in tests */
+ if (aborted.value) { clearInterval(interval); return; }
+ if (check()) { clearInterval(interval); resolve({ passed: true, duration_ms: Math.round(performance.now() - start), selectors }); }
+ }, 100);
+
+ aborted.onAbort(() => clearInterval(interval));
+ });
+}
+
+function checkNotPresentSelectors(selectors, aborted) {
+ /* istanbul ignore next: orchestrator only calls this when selectors.length > 0; defensive for direct callers */
+ if (!selectors?.length) return Promise.resolve({ passed: true, duration_ms: 0, selectors: [] });
+ return new Promise(resolve => {
+ let start = performance.now();
+ function check() { for (let s of selectors) { if (document.querySelector(s)) return false; } return true; }
+ if (check()) { resolve({ passed: true, duration_ms: 0, selectors }); return; }
+ let interval = setInterval(() => {
+ /* istanbul ignore next: abort clears the interval synchronously, defensive dead code in tests */
+ if (aborted.value) { clearInterval(interval); return; }
+ if (check()) { clearInterval(interval); resolve({ passed: true, duration_ms: Math.round(performance.now() - start), selectors }); }
+ }, 100);
+
+ /* istanbul ignore next: abort-on-timeout path; only fires when the excluded selector never disappears */
+ aborted.onAbort(() => clearInterval(interval));
+ });
+}
+
+// --- Orchestrator ---
+
+// Simple abort controller for browser context (no AbortController dependency).
+// Exported for direct unit testing.
+export function createAbortHandle() {
+ let callbacks = [];
+ return {
+ value: false,
+ onAbort(fn) { callbacks.push(fn); },
+ abort() { this.value = true; callbacks.forEach(fn => fn()); callbacks = []; }
+ };
+}
+
+async function runAllChecks(config, result, aborted) {
+ let checks = [];
+ let expected = [];
+ if (config.stability_window_ms > 0) { expected.push('dom_stability'); checks.push(checkDOMStability(config.stability_window_ms, aborted).then(r => { result.checks.dom_stability = r; })); }
+ if (config.network_idle_window_ms > 0) { expected.push('network_idle'); checks.push(checkNetworkIdle(config.network_idle_window_ms, aborted).then(r => { result.checks.network_idle = r; })); }
+ if (config.font_ready !== false) { expected.push('font_ready'); checks.push(checkFontReady(aborted).then(r => { result.checks.font_ready = r; })); }
+ if (config.image_ready !== false) { expected.push('image_ready'); checks.push(checkImageReady(aborted).then(r => { result.checks.image_ready = r; })); }
+ if (config.js_idle !== false) {
+ expected.push('js_idle');
+ // Fall back to stability_window_ms if js_idle_window_ms is not set.
+ // All built-in presets set js_idle_window_ms, so this fallback only
+ // fires when a caller passes a custom config that predates the
+ // dedicated option — preserves backward compatibility.
+ /* istanbul ignore next: fallback only hit by pre-js_idle_window_ms configs; built-in presets always set it */
+ let jsIdleWindow = config.js_idle_window_ms ?? config.stability_window_ms;
+ checks.push(checkJSIdle(jsIdleWindow, aborted).then(r => { result.checks.js_idle = r; }));
+ }
+ if (config.ready_selectors?.length) { expected.push('ready_selectors'); checks.push(checkReadySelectors(config.ready_selectors, aborted).then(r => { result.checks.ready_selectors = r; })); }
+ if (config.not_present_selectors?.length) { expected.push('not_present_selectors'); checks.push(checkNotPresentSelectors(config.not_present_selectors, aborted).then(r => { result.checks.not_present_selectors = r; })); }
+ result._expectedChecks = expected;
+ await Promise.all(checks);
+}
+
+// Normalize camelCase config keys (from .percy.yml / SDK options) to the
+// snake_case keys used internally. Accepts either naming.
+// Exported for direct unit testing.
+export function normalizeOptions(options = {}) {
+ return {
+ preset: options.preset,
+ stability_window_ms: options.stabilityWindowMs ?? options.stability_window_ms,
+ js_idle_window_ms: options.jsIdleWindowMs ?? options.js_idle_window_ms,
+ network_idle_window_ms: options.networkIdleWindowMs ?? options.network_idle_window_ms,
+ timeout_ms: options.timeoutMs ?? options.timeout_ms,
+ image_ready: options.imageReady ?? options.image_ready,
+ font_ready: options.fontReady ?? options.font_ready,
+ js_idle: options.jsIdle ?? options.js_idle,
+ ready_selectors: options.readySelectors ?? options.ready_selectors,
+ not_present_selectors: options.notPresentSelectors ?? options.not_present_selectors,
+ max_timeout_ms: options.maxTimeoutMs ?? options.max_timeout_ms
+ };
+}
+
+export async function waitForReady(options = {}) {
+ let presetName = options.preset || 'balanced';
+ if (presetName === 'disabled') return { passed: true, timed_out: false, skipped: true, checks: {} };
+
+ let preset = PRESETS[presetName] || PRESETS.balanced;
+ // Normalize user options to snake_case, then merge. Only overrides
+ // where user explicitly provided a value (undefined keys don't overwrite).
+ let userOptions = normalizeOptions(options);
+ let config = { ...preset };
+ for (let key of Object.keys(userOptions)) {
+ if (userOptions[key] !== undefined) config[key] = userOptions[key];
+ }
+ let effectiveTimeout = config.max_timeout_ms ? Math.min(config.timeout_ms, config.max_timeout_ms) : config.timeout_ms;
+
+ let startTime = performance.now();
+ let result = { passed: false, timed_out: false, preset: presetName, checks: {} };
+ let settled = false;
+ let aborted = createAbortHandle();
+
+ try {
+ await Promise.race([
+ runAllChecks(config, result, aborted).then(() => { settled = true; }),
+ new Promise(resolve => setTimeout(() => {
+ if (!settled) {
+ result.timed_out = true;
+ // Abort all running checks — clears intervals, disconnects observers
+ aborted.abort();
+ }
+ resolve();
+ }, effectiveTimeout))
+ ]);
+ } catch (error) {
+ /* istanbul ignore next: safety net for unexpected errors in readiness checks */
+ result.error = error.message || String(error);
+ }
+
+ // Mark any checks that didn't complete before timeout as failed.
+ // `_expectedChecks` is always set by runAllChecks, but coverage here
+ // depends on whether any expected check was skipped due to timeout.
+ /* istanbul ignore next: only falsy when the catch block above fires before runAllChecks sets _expectedChecks */
+ if (result._expectedChecks) {
+ for (let name of result._expectedChecks) {
+ if (!result.checks[name]) {
+ result.checks[name] = { passed: false, timed_out: true };
+ }
+ }
+ delete result._expectedChecks;
+ }
+
+ result.total_duration_ms = Math.round(performance.now() - startTime);
+ result.passed = !result.timed_out && !result.error && Object.values(result.checks).every(c => c.passed);
+ return result;
+}
+
+export { PRESETS };
diff --git a/packages/dom/src/serialize-dom.js b/packages/dom/src/serialize-dom.js
index 6f16427b1..8ea0b54c7 100644
--- a/packages/dom/src/serialize-dom.js
+++ b/packages/dom/src/serialize-dom.js
@@ -5,6 +5,7 @@ import serializeCanvas from './serialize-canvas';
import serializeVideos from './serialize-video';
import { serializePseudoClasses, markPseudoClassElements } from './serialize-pseudo-classes';
import { cloneNodeAndShadow, getOuterHTML } from './clone-dom';
+import { waitForReady } from './readiness';
// Returns a copy or new doctype for a document.
function doctype(dom) {
@@ -80,7 +81,47 @@ export function waitForResize() {
}
// Serializes a document and returns the resulting DOM string.
+//
+// This function is SYNCHRONOUS and always returns a plain object — preserving
+// backward compatibility with existing SDKs (@percy/cypress, @percy/puppeteer,
+// @percy/selenium-webdriver, etc.) that call `PercyDOM.serialize(options)`
+// without awaiting the result.
+//
+// To enable readiness-gated serialization, callers must explicitly opt in by
+// calling `serializeDOMWithReadiness()` (which returns a Promise) or by
+// awaiting `serializeDOM()` after calling `waitForReady()` separately.
export function serializeDOM(options) {
+ return _serialize(options);
+}
+
+// Async variant that runs readiness checks before serializing. Returns a
+// Promise. Used by:
+// - CLI URL-capture path (page.js calls this via page.eval)
+// - New SDK versions that opt into readiness-gated capture
+// Existing SDKs are unaffected as they call serializeDOM() directly.
+export async function serializeDOMWithReadiness(options) {
+ let readiness = options?.readiness;
+
+ if (readiness && readiness.preset !== 'disabled') {
+ try {
+ let diagnostics = await waitForReady(readiness);
+ let result = _serialize(options);
+ /* istanbul ignore next: stringifyResponse with readiness is an unlikely combination */
+ if (typeof result === 'object' && diagnostics) {
+ result.readiness_diagnostics = diagnostics;
+ }
+ return result;
+ } catch (err) /* istanbul ignore next */ {
+ // If readiness fails, still serialize (graceful degradation)
+ console.error(`Readiness check failed: ${err.message}`);
+ }
+ }
+
+ return _serialize(options);
+}
+
+// Core serialization logic — always synchronous.
+function _serialize(options) {
let {
dom = document,
// allow snake_case or camelCase
diff --git a/packages/dom/test/readiness-helpers.test.js b/packages/dom/test/readiness-helpers.test.js
new file mode 100644
index 000000000..0aaa56ef6
--- /dev/null
+++ b/packages/dom/test/readiness-helpers.test.js
@@ -0,0 +1,308 @@
+// Direct unit tests for readiness.js internal helpers.
+//
+// These helpers were previously covered only indirectly through
+// MutationObserver-driven integration tests and marked with
+// `istanbul ignore next`. They are pure and deterministic, so testing
+// them directly gives real coverage and lets us drop the ignores.
+
+import {
+ isLayoutMutation,
+ hasLayoutStyleChange,
+ parseStyleProps,
+ normalizeOptions,
+ createAbortHandle
+} from '../src/readiness';
+
+describe('readiness helpers', () => {
+ describe('parseStyleProps', () => {
+ it('returns empty object for empty/undefined input', () => {
+ expect(parseStyleProps('')).toEqual({});
+ expect(parseStyleProps(undefined)).toEqual({});
+ expect(parseStyleProps(null)).toEqual({});
+ });
+
+ it('parses a single declaration', () => {
+ expect(parseStyleProps('color: red')).toEqual({ color: 'red' });
+ });
+
+ it('parses multiple declarations and trims whitespace', () => {
+ expect(parseStyleProps(' width: 100px ; height : 20px '))
+ .toEqual({ width: '100px', height: '20px' });
+ });
+
+ it('lowercases keys but preserves value case', () => {
+ expect(parseStyleProps('Color: Red'))
+ .toEqual({ color: 'Red' });
+ });
+
+ it('ignores declarations without a colon', () => {
+ expect(parseStyleProps('color red; width: 10px'))
+ .toEqual({ width: '10px' });
+ });
+
+ it('ignores empty keys', () => {
+ expect(parseStyleProps(':red; width: 10px'))
+ .toEqual({ width: '10px' });
+ });
+
+ it('ignores whitespace-only keys (covers the !key branch)', () => {
+ // ` : red` has i > 0 but trims to empty — exercises the
+ // `if (key)` falsy branch.
+ expect(parseStyleProps(' : red; width: 10px'))
+ .toEqual({ width: '10px' });
+ });
+
+ it('keeps the last value when a key is declared twice', () => {
+ // parseStyleProps is a simple last-wins parser — matches loose
+ // browser behavior for duplicate inline declarations.
+ expect(parseStyleProps('width: 10px; width: 20px'))
+ .toEqual({ width: '20px' });
+ });
+ });
+
+ describe('hasLayoutStyleChange', () => {
+ it('returns false when styles are identical', () => {
+ expect(hasLayoutStyleChange('color: red', 'color: red')).toBe(false);
+ });
+
+ it('returns false when only non-layout properties change', () => {
+ expect(hasLayoutStyleChange('color: red', 'color: blue')).toBe(false);
+ expect(hasLayoutStyleChange('background: red', 'background: blue')).toBe(false);
+ });
+
+ it('returns true when a layout property changes', () => {
+ expect(hasLayoutStyleChange('width: 10px', 'width: 20px')).toBe(true);
+ expect(hasLayoutStyleChange('display: block', 'display: none')).toBe(true);
+ expect(hasLayoutStyleChange('margin: 0', 'margin: 10px')).toBe(true);
+ });
+
+ it('returns true when a layout property is added or removed', () => {
+ expect(hasLayoutStyleChange('', 'width: 20px')).toBe(true);
+ expect(hasLayoutStyleChange('width: 20px', '')).toBe(true);
+ });
+
+ it('returns false when a non-layout property is added while layout props are stable', () => {
+ expect(hasLayoutStyleChange('width: 10px', 'width: 10px; color: red')).toBe(false);
+ });
+
+ it('detects prefix-matched layout props (min-, max-, margin, padding, flex, grid, z-index)', () => {
+ expect(hasLayoutStyleChange('min-width: 0', 'min-width: 100px')).toBe(true);
+ expect(hasLayoutStyleChange('max-height: none', 'max-height: 200px')).toBe(true);
+ expect(hasLayoutStyleChange('padding-left: 0', 'padding-left: 10px')).toBe(true);
+ expect(hasLayoutStyleChange('flex: 1', 'flex: 2')).toBe(true);
+ expect(hasLayoutStyleChange('z-index: 1', 'z-index: 2')).toBe(true);
+ });
+ });
+
+ describe('isLayoutMutation', () => {
+ // Build a minimal mutation-record-like object — the helper only reads
+ // these fields and never calls MutationObserver APIs directly.
+ function mutation({ type, attributeName, oldValue, targetAttr, tagName }) {
+ return {
+ type,
+ attributeName,
+ oldValue,
+ target: {
+ getAttribute: () => targetAttr ?? '',
+ tagName: tagName ?? 'DIV'
+ }
+ };
+ }
+
+ it('returns true for any childList mutation', () => {
+ expect(isLayoutMutation(mutation({ type: 'childList' }))).toBe(true);
+ });
+
+ it('returns false for data-* attribute changes', () => {
+ expect(isLayoutMutation(mutation({
+ type: 'attributes', attributeName: 'data-foo'
+ }))).toBe(false);
+ });
+
+ it('returns false for aria-* attribute changes', () => {
+ expect(isLayoutMutation(mutation({
+ type: 'attributes', attributeName: 'aria-hidden'
+ }))).toBe(false);
+ });
+
+ it('returns true for layout-affecting style changes', () => {
+ expect(isLayoutMutation(mutation({
+ type: 'attributes',
+ attributeName: 'style',
+ oldValue: 'width: 10px',
+ targetAttr: 'width: 20px'
+ }))).toBe(true);
+ });
+
+ it('handles null/undefined oldValue and missing target style', () => {
+ // Covers the `mutation.oldValue || ''` and `target.getAttribute(...) || ''`
+ // fallback branches when the browser reports no prior value.
+ expect(isLayoutMutation({
+ type: 'attributes',
+ attributeName: 'style',
+ oldValue: null,
+ target: { getAttribute: () => null, tagName: 'DIV' }
+ })).toBe(false);
+
+ expect(isLayoutMutation({
+ type: 'attributes',
+ attributeName: 'style',
+ oldValue: undefined,
+ target: { getAttribute: () => 'width: 20px', tagName: 'DIV' }
+ })).toBe(true);
+ });
+
+ it('returns false for non-layout style changes', () => {
+ expect(isLayoutMutation(mutation({
+ type: 'attributes',
+ attributeName: 'style',
+ oldValue: 'color: red',
+ targetAttr: 'color: blue'
+ }))).toBe(false);
+ });
+
+ it('treats href on as NOT layout-affecting', () => {
+ expect(isLayoutMutation(mutation({
+ type: 'attributes', attributeName: 'href', tagName: 'A'
+ }))).toBe(false);
+ });
+
+ it('treats href on as layout-affecting', () => {
+ expect(isLayoutMutation(mutation({
+ type: 'attributes', attributeName: 'href', tagName: 'LINK'
+ }))).toBe(true);
+ });
+
+ it('returns true for known layout attributes (class/width/height/src)', () => {
+ for (let attr of ['class', 'width', 'height', 'src', 'display', 'visibility', 'position']) {
+ expect(isLayoutMutation(mutation({
+ type: 'attributes', attributeName: attr
+ }))).toBe(true);
+ }
+ });
+
+ it('returns false for unknown attributes', () => {
+ expect(isLayoutMutation(mutation({
+ type: 'attributes', attributeName: 'title'
+ }))).toBe(false);
+ });
+
+ it('returns false for unsupported mutation types', () => {
+ expect(isLayoutMutation(mutation({ type: 'characterData' }))).toBe(false);
+ });
+ });
+
+ describe('normalizeOptions', () => {
+ it('returns an object with all keys undefined when given no options', () => {
+ let n = normalizeOptions();
+ expect(n.preset).toBeUndefined();
+ expect(n.stability_window_ms).toBeUndefined();
+ expect(n.timeout_ms).toBeUndefined();
+ });
+
+ it('prefers camelCase and maps to snake_case', () => {
+ let n = normalizeOptions({
+ stabilityWindowMs: 100,
+ jsIdleWindowMs: 150,
+ networkIdleWindowMs: 200,
+ timeoutMs: 3000,
+ imageReady: true,
+ fontReady: false,
+ jsIdle: true,
+ readySelectors: ['.a'],
+ notPresentSelectors: ['.b'],
+ maxTimeoutMs: 5000
+ });
+ expect(n).toEqual({
+ preset: undefined,
+ stability_window_ms: 100,
+ js_idle_window_ms: 150,
+ network_idle_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: true,
+ font_ready: false,
+ js_idle: true,
+ ready_selectors: ['.a'],
+ not_present_selectors: ['.b'],
+ max_timeout_ms: 5000
+ });
+ });
+
+ it('accepts snake_case directly', () => {
+ let n = normalizeOptions({
+ stability_window_ms: 100,
+ timeout_ms: 3000
+ });
+ expect(n.stability_window_ms).toBe(100);
+ expect(n.timeout_ms).toBe(3000);
+ });
+
+ it('prefers camelCase when both are provided', () => {
+ let n = normalizeOptions({
+ stabilityWindowMs: 100,
+ stability_window_ms: 999
+ });
+ expect(n.stability_window_ms).toBe(100);
+ });
+
+ it('does not coerce falsy user values (0, false) to undefined', () => {
+ let n = normalizeOptions({
+ stabilityWindowMs: 0,
+ fontReady: false,
+ imageReady: false
+ });
+ expect(n.stability_window_ms).toBe(0);
+ expect(n.font_ready).toBe(false);
+ expect(n.image_ready).toBe(false);
+ });
+
+ it('passes preset through', () => {
+ expect(normalizeOptions({ preset: 'strict' }).preset).toBe('strict');
+ });
+
+ it('normalizes jsIdleWindowMs to js_idle_window_ms', () => {
+ // Decoupling from stability_window_ms — see PRESETS comment and
+ // PR #2184 review comment #3086822493.
+ expect(normalizeOptions({ jsIdleWindowMs: 250 }).js_idle_window_ms).toBe(250);
+ expect(normalizeOptions({ js_idle_window_ms: 250 }).js_idle_window_ms).toBe(250);
+ expect(normalizeOptions({ jsIdleWindowMs: 100, js_idle_window_ms: 999 }).js_idle_window_ms).toBe(100);
+ });
+ });
+
+ describe('createAbortHandle', () => {
+ it('starts with value === false and no callbacks fired', () => {
+ let a = createAbortHandle();
+ expect(a.value).toBe(false);
+ });
+
+ it('flips value to true and invokes all registered callbacks on abort', () => {
+ let a = createAbortHandle();
+ let calls = [];
+ a.onAbort(() => calls.push('a'));
+ a.onAbort(() => calls.push('b'));
+ a.abort();
+ expect(a.value).toBe(true);
+ expect(calls).toEqual(['a', 'b']);
+ });
+
+ it('does not re-invoke callbacks on a second abort()', () => {
+ let a = createAbortHandle();
+ let count = 0;
+ a.onAbort(() => count++);
+ a.abort();
+ a.abort();
+ expect(count).toBe(1);
+ });
+
+ it('callbacks registered after abort() are not invoked by the initial abort', () => {
+ let a = createAbortHandle();
+ a.abort();
+ let late = 0;
+ a.onAbort(() => late++);
+ // Callback is stored but will only fire on a future abort() call —
+ // and the handle's internal callbacks list was reset, so the late
+ // callback is orphaned. This just asserts current behavior.
+ expect(late).toBe(0);
+ });
+ });
+});
diff --git a/packages/dom/test/readiness.test.js b/packages/dom/test/readiness.test.js
new file mode 100644
index 000000000..a41367299
--- /dev/null
+++ b/packages/dom/test/readiness.test.js
@@ -0,0 +1,971 @@
+import { waitForReady } from '@percy/dom';
+import { withExample } from './helpers';
+
+describe('waitForReady', () => {
+ afterEach(() => {
+ let $test = document.getElementById('test');
+ if ($test) $test.remove();
+ });
+
+ it('is exported as a function', () => {
+ expect(typeof waitForReady).toBe('function');
+ });
+
+ it('returns a promise', () => {
+ let result = waitForReady({ timeout_ms: 1000, stability_window_ms: 50 });
+ expect(result instanceof Promise).toBe(true);
+ });
+
+ it('works when called with no arguments (uses defaults)', async () => {
+ withExample('Default
', { withShadow: false });
+ let result = await waitForReady();
+ expect(result).toBeDefined();
+ expect(result.preset).toBe('balanced');
+ });
+
+ it('resolves with diagnostic result on stable page', async () => {
+ withExample('Stable
', { withShadow: false });
+ let result = await waitForReady({ stability_window_ms: 100, timeout_ms: 3000, image_ready: false, network_idle_window_ms: 50 });
+ expect(result.passed).toBe(true);
+ expect(result.timed_out).toBe(false);
+ expect(result.checks.dom_stability).toBeDefined();
+ expect(result.checks.dom_stability.passed).toBe(true);
+ });
+
+ it('returns immediately when preset is disabled', async () => {
+ let start = Date.now();
+ let result = await waitForReady({ preset: 'disabled' });
+ expect(result.passed).toBe(true);
+ expect(result.skipped).toBe(true);
+ expect(Date.now() - start).toBeLessThan(50);
+ });
+
+ it('uses balanced defaults when no preset specified', async () => {
+ withExample('Content
', { withShadow: false });
+ let result = await waitForReady({ timeout_ms: 2000, stability_window_ms: 50, network_idle_window_ms: 50, image_ready: false });
+ expect(result.preset).toBe('balanced');
+ });
+
+ it('detects stability when no mutations occur', async () => {
+ withExample('Static
', { withShadow: false });
+ let result = await waitForReady({ stability_window_ms: 100, timeout_ms: 3000, image_ready: false, font_ready: false, network_idle_window_ms: 50 });
+ expect(result.checks.dom_stability.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBe(0);
+ });
+
+ it('waits for DOM to stabilize after mutations', async () => {
+ withExample('', { withShadow: false });
+ let count = 0;
+ let interval = setInterval(() => {
+ if (count++ < 3) {
+ let el = document.createElement('p');
+ el.textContent = `Added ${count}`;
+ document.getElementById('mutating')?.appendChild(el);
+ } else clearInterval(interval);
+ }, 50);
+
+ let result = await waitForReady({ stability_window_ms: 200, timeout_ms: 5000, image_ready: false, font_ready: false, network_idle_window_ms: 50 });
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBeGreaterThan(0);
+ });
+
+ it('times out when DOM never stabilizes', async () => {
+ withExample('', { withShadow: false });
+ let interval = setInterval(() => {
+ let el = document.getElementById('forever');
+ if (el) { let s = document.createElement('span'); s.textContent = Date.now(); el.appendChild(s); if (el.children.length > 10) el.removeChild(el.firstChild); }
+ }, 30);
+
+ let result = await waitForReady({ stability_window_ms: 200, timeout_ms: 1000, image_ready: false, font_ready: false, network_idle_window_ms: 50 });
+ clearInterval(interval);
+ expect(result.passed).toBe(false);
+ expect(result.timed_out).toBe(true);
+ });
+
+ it('ignores data-* attribute mutations', async () => {
+ withExample('', { withShadow: false });
+ setTimeout(() => { document.getElementById('data-test')?.setAttribute('data-value', '2'); }, 50);
+ let result = await waitForReady({ stability_window_ms: 200, timeout_ms: 3000, image_ready: false, font_ready: false, network_idle_window_ms: 50 });
+ expect(result.checks.dom_stability.passed).toBe(true);
+ });
+
+ it('ignores aria-* attribute mutations', async () => {
+ withExample('', { withShadow: false });
+ setTimeout(() => { document.getElementById('aria-test')?.setAttribute('aria-pressed', 'true'); }, 50);
+ let result = await waitForReady({ stability_window_ms: 200, timeout_ms: 3000, image_ready: false, font_ready: false, network_idle_window_ms: 50 });
+ expect(result.checks.dom_stability.passed).toBe(true);
+ });
+
+ it('passes when ready_selectors exist', async () => {
+ withExample('Ready
', { withShadow: false });
+ let result = await waitForReady({ stability_window_ms: 50, timeout_ms: 3000, image_ready: false, font_ready: false, network_idle_window_ms: 50, ready_selectors: ['#content.loaded'] });
+ expect(result.checks.ready_selectors.passed).toBe(true);
+ });
+
+ it('waits for ready_selectors to appear', async () => {
+ withExample('', { withShadow: false });
+ setTimeout(() => { let el = document.createElement('div'); el.id = 'late'; el.className = 'loaded'; document.getElementById('container')?.appendChild(el); }, 200);
+ let result = await waitForReady({ stability_window_ms: 50, timeout_ms: 5000, image_ready: false, font_ready: false, network_idle_window_ms: 50, ready_selectors: ['#late.loaded'] });
+ expect(result.checks.ready_selectors.passed).toBe(true);
+ expect(result.checks.ready_selectors.duration_ms).toBeGreaterThan(0);
+ });
+
+ it('passes when not_present_selectors are absent', async () => {
+ withExample('No loader
', { withShadow: false });
+ let result = await waitForReady({ stability_window_ms: 50, timeout_ms: 3000, image_ready: false, font_ready: false, network_idle_window_ms: 50, not_present_selectors: ['.spinner'] });
+ expect(result.checks.not_present_selectors.passed).toBe(true);
+ });
+
+ it('waits for skeleton loader to disappear', async () => {
+ withExample('', { withShadow: false });
+ setTimeout(() => { document.querySelector('.skeleton-loader')?.remove(); }, 200);
+ let result = await waitForReady({ stability_window_ms: 50, timeout_ms: 5000, image_ready: false, font_ready: false, network_idle_window_ms: 50, not_present_selectors: ['.skeleton-loader'] });
+ expect(result.checks.not_present_selectors.passed).toBe(true);
+ });
+
+ it('checks fonts ready', async () => {
+ withExample('Text
', { withShadow: false });
+ let result = await waitForReady({ stability_window_ms: 50, timeout_ms: 3000, image_ready: false, font_ready: true, network_idle_window_ms: 50 });
+ expect(result.checks.font_ready).toBeDefined();
+ expect(result.checks.font_ready.passed).toBe(true);
+ });
+
+ it('passes image check when no images exist', async () => {
+ withExample('No images
', { withShadow: false });
+ let result = await waitForReady({ stability_window_ms: 50, timeout_ms: 3000, image_ready: true, font_ready: false, network_idle_window_ms: 50 });
+ expect(result.checks.image_ready.passed).toBe(true);
+ expect(result.checks.image_ready.images_incomplete_at_start).toBe(0);
+ });
+
+ it('skips image check when image_ready is false', async () => {
+ withExample('
', { withShadow: false });
+ let result = await waitForReady({ stability_window_ms: 50, timeout_ms: 2000, image_ready: false, font_ready: false, network_idle_window_ms: 50 });
+ expect(result.checks.image_ready).toBeUndefined();
+ });
+
+ it('runs all checks concurrently', async () => {
+ withExample('All checks
', { withShadow: false });
+ let result = await waitForReady({ stability_window_ms: 100, timeout_ms: 5000, image_ready: true, font_ready: true, network_idle_window_ms: 50 });
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability).toBeDefined();
+ expect(result.checks.network_idle).toBeDefined();
+ expect(result.checks.font_ready).toBeDefined();
+ expect(result.checks.image_ready).toBeDefined();
+ });
+
+ it('includes all expected fields in result', async () => {
+ withExample('Fields
', { withShadow: false });
+ let result = await waitForReady({ stability_window_ms: 50, timeout_ms: 2000, image_ready: false, font_ready: false, network_idle_window_ms: 50 });
+ expect(result.passed).toBeDefined();
+ expect(result.timed_out).toBeDefined();
+ expect(result.preset).toBeDefined();
+ expect(result.total_duration_ms).toBeDefined();
+ expect(result.checks).toBeDefined();
+ expect(typeof result.total_duration_ms).toBe('number');
+ });
+
+ it('detects layout-affecting attribute mutations (class change)', async () => {
+ withExample('', { withShadow: false });
+
+ // Change a layout-affecting attribute after a short delay
+ setTimeout(() => {
+ let el = document.getElementById('class-test');
+ if (el) el.setAttribute('class', 'wide');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBeGreaterThan(0);
+ });
+
+ it('ignores non-layout style mutations (opacity change)', async () => {
+ withExample('', { withShadow: false });
+
+ // Change a visual-only style property
+ setTimeout(() => {
+ let el = document.getElementById('opacity-test');
+ if (el) el.style.opacity = '0.5';
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ // opacity change should NOT count as a layout mutation
+ expect(result.checks.dom_stability.mutations_observed).toBe(0);
+ });
+
+ it('handles image loading in viewport', async () => {
+ // Create a visible image that is "loading"
+ withExample('
', { withShadow: false });
+ let img = document.getElementById('test-img');
+
+ // Set src after a delay to simulate loading
+ setTimeout(() => {
+ if (img) {
+ img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==';
+ }
+ }, 100);
+
+ let result = await waitForReady({
+ stability_window_ms: 50,
+ timeout_ms: 5000,
+ image_ready: true,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.checks.image_ready).toBeDefined();
+ expect(result.checks.image_ready.passed).toBe(true);
+ });
+
+ it('uses max_timeout_ms when provided (WebDriver buffer)', async () => {
+ withExample('', { withShadow: false });
+ let interval = setInterval(() => {
+ let el = document.getElementById('forever2');
+ if (el) { let s = document.createElement('span'); el.appendChild(s); if (el.children.length > 5) el.removeChild(el.firstChild); }
+ }, 30);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 10000,
+ max_timeout_ms: 800, // Should cap at 800ms, not 10000ms
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ clearInterval(interval);
+ expect(result.timed_out).toBe(true);
+ expect(result.total_duration_ms).toBeLessThan(2000); // Should be ~800ms, not 10s
+ });
+
+ it('detects layout-affecting style attribute change (width via setAttribute)', async () => {
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('style-attr-test');
+ if (el) el.setAttribute('style', 'width:200px');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBeGreaterThan(0);
+ });
+
+ it('ignores non-layout style attribute change (opacity via setAttribute)', async () => {
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('style-opacity-attr');
+ if (el) el.setAttribute('style', 'opacity:0.5');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBe(0);
+ });
+
+ it('detects when same style value is set (no layout change)', async () => {
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('style-same');
+ if (el) el.setAttribute('style', 'width:100px');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ // Same value = no layout change = 0 mutations
+ expect(result.checks.dom_stability.mutations_observed).toBe(0);
+ });
+
+ it('catches errors in readiness checks gracefully', async () => {
+ // Force an error by running on a page with no document element
+ // The waitForReady function should catch errors internally
+ let result = await waitForReady({
+ stability_window_ms: 50,
+ timeout_ms: 500,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50,
+ ready_selectors: ['#nonexistent-guaranteed']
+ });
+
+ // Should still resolve (not reject) — errors are caught
+ expect(result).toBeDefined();
+ expect(typeof result.passed).toBe('boolean');
+ });
+
+ it('uses unknown preset name and falls back to balanced', async () => {
+ withExample('Fallback
', { withShadow: false });
+ let result = await waitForReady({
+ preset: 'nonexistent',
+ timeout_ms: 2000,
+ stability_window_ms: 50,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ // Should still resolve (uses balanced defaults as fallback)
+ expect(result.passed).toBe(true);
+ });
+
+ // --- Page stability: DOM mutation filter edge cases ---
+
+ it('detects src attribute change on images as layout-affecting', async () => {
+ withExample('
', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('src-test');
+ if (el) el.setAttribute('src', 'data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBeGreaterThan(0);
+ });
+
+ it('ignores href attribute change on elements (not layout-affecting)', async () => {
+ // href on tags is a navigation target, not a layout property —
+ // changing it does not re-render the page, so it should NOT count
+ // as a layout mutation.
+ withExample('Link', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('href-test');
+ if (el) el.setAttribute('href', '/page2');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ // changes should NOT be counted as layout mutations
+ expect(result.checks.dom_stability.mutations_observed).toBe(0);
+ });
+
+ it('detects href attribute change on elements as layout-affecting', async () => {
+ // href on IS layout-affecting because it loads
+ // a new stylesheet that can restyle the page.
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('css-test');
+ if (el) el.setAttribute('href', 'data:text/css,.x{color:blue}');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBeGreaterThan(0);
+ });
+
+ it('detects width attribute change as layout-affecting', async () => {
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('width-attr-test');
+ if (el) el.setAttribute('width', '200');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBeGreaterThan(0);
+ });
+
+ it('detects height attribute change as layout-affecting', async () => {
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('height-attr-test');
+ if (el) el.setAttribute('height', '200');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBeGreaterThan(0);
+ });
+
+ it('detects display property change via style attribute as layout-affecting', async () => {
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('display-test');
+ if (el) el.setAttribute('style', 'display:none');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBeGreaterThan(0);
+ });
+
+ it('detects margin change via style attribute as layout-affecting', async () => {
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('margin-test');
+ if (el) el.setAttribute('style', 'margin:20px');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBeGreaterThan(0);
+ });
+
+ it('detects padding change via style attribute as layout-affecting', async () => {
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('padding-test');
+ if (el) el.setAttribute('style', 'padding:10px');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBeGreaterThan(0);
+ });
+
+ it('detects position change via style attribute as layout-affecting', async () => {
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('position-test');
+ if (el) el.setAttribute('style', 'position:absolute');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBeGreaterThan(0);
+ });
+
+ it('ignores transform style change as non-layout', async () => {
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('transform-test');
+ if (el) el.setAttribute('style', 'transform:translateX(10px)');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBe(0);
+ });
+
+ it('ignores background style change as non-layout', async () => {
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('bg-test');
+ if (el) el.setAttribute('style', 'background:red');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBe(0);
+ });
+
+ it('ignores box-shadow style change as non-layout', async () => {
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('shadow-test');
+ if (el) el.setAttribute('style', 'box-shadow:0 2px 4px rgba(0,0,0,0.5)');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBe(0);
+ });
+
+ it('detects layout property among mixed layout+non-layout style changes', async () => {
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('mixed-test');
+ if (el) el.setAttribute('style', 'width:200px;opacity:0.5');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ // width changed — should count as layout mutation even though opacity also changed
+ expect(result.checks.dom_stability.mutations_observed).toBeGreaterThan(0);
+ });
+
+ it('ignores title attribute mutations as non-layout', async () => {
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('title-test');
+ if (el) el.setAttribute('title', 'new tooltip');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ // title is not in LAYOUT_ATTRIBUTES — should not be counted
+ expect(result.checks.dom_stability.mutations_observed).toBe(0);
+ });
+
+ it('detects multiple rapid childList mutations then stabilizes', async () => {
+ withExample('', { withShadow: false });
+ let count = 0;
+ let interval = setInterval(() => {
+ let ul = document.getElementById('rapid-list');
+ if (ul && count++ < 5) {
+ let li = document.createElement('li');
+ li.textContent = `Item ${count}`;
+ ul.appendChild(li);
+ } else {
+ clearInterval(interval);
+ }
+ }, 40);
+
+ let result = await waitForReady({
+ stability_window_ms: 300,
+ timeout_ms: 5000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBeGreaterThanOrEqual(5);
+ });
+
+ it('detects flex property change via style attribute as layout-affecting', async () => {
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('flex-test');
+ if (el) el.setAttribute('style', 'flex:1');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBeGreaterThan(0);
+ });
+
+ it('detects overflow property change via style attribute as layout-affecting', async () => {
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('overflow-test');
+ if (el) el.setAttribute('style', 'overflow:hidden');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBeGreaterThan(0);
+ });
+
+ it('handles multiple not_present_selectors all disappearing', async () => {
+ withExample('', { withShadow: false });
+ setTimeout(() => { document.querySelector('.spinner')?.remove(); }, 100);
+ setTimeout(() => { document.querySelector('.skeleton')?.remove(); }, 200);
+
+ let result = await waitForReady({
+ stability_window_ms: 50,
+ timeout_ms: 5000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50,
+ not_present_selectors: ['.spinner', '.skeleton']
+ });
+
+ expect(result.checks.not_present_selectors.passed).toBe(true);
+ });
+
+ it('handles multiple ready_selectors all appearing', async () => {
+ withExample('', { withShadow: false });
+ setTimeout(() => {
+ let container = document.getElementById('multi-ready');
+ if (container) {
+ let a = document.createElement('div');
+ a.className = 'section-a';
+ container.appendChild(a);
+ let b = document.createElement('div');
+ b.className = 'section-b';
+ container.appendChild(b);
+ }
+ }, 100);
+
+ let result = await waitForReady({
+ stability_window_ms: 50,
+ timeout_ms: 5000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50,
+ ready_selectors: ['.section-a', '.section-b']
+ });
+
+ expect(result.checks.ready_selectors.passed).toBe(true);
+ });
+
+ it('visibility attribute change is detected as layout-affecting', async () => {
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('vis-test');
+ if (el) el.setAttribute('visibility', 'hidden');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBeGreaterThan(0);
+ });
+
+ it('detects visibility change via style as layout-affecting', async () => {
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.getElementById('vis-style-test');
+ if (el) el.setAttribute('style', 'visibility:hidden');
+ }, 50);
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability.mutations_observed).toBeGreaterThan(0);
+ });
+
+ // --- Branch coverage: runAllChecks config-gated checks ---
+
+ it('does not pass ready_selectors for hidden elements (offsetParent null)', async () => {
+ withExample('', { withShadow: false });
+
+ let result = await waitForReady({
+ stability_window_ms: 50,
+ timeout_ms: 1000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50,
+ ready_selectors: ['.hidden-content']
+ });
+
+ // Element exists but is hidden (display:none makes offsetParent null) — should time out
+ expect(result.timed_out).toBe(true);
+ });
+
+ it('passes ready_selectors for fixed-position elements (offsetParent null but visible)', async () => {
+ withExample('Fixed
', { withShadow: false });
+
+ let result = await waitForReady({
+ stability_window_ms: 50,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50,
+ ready_selectors: ['#fixed-el']
+ });
+
+ expect(result.checks.ready_selectors.passed).toBe(true);
+ });
+
+ it('skips dom_stability check when stability_window_ms is 0', async () => {
+ withExample('Skip stability
', { withShadow: false });
+
+ let result = await waitForReady({
+ stability_window_ms: 0,
+ timeout_ms: 2000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.dom_stability).toBeUndefined();
+ });
+
+ it('skips network_idle check when network_idle_window_ms is 0', async () => {
+ withExample('Skip network
', { withShadow: false });
+
+ let result = await waitForReady({
+ stability_window_ms: 50,
+ timeout_ms: 2000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 0
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.network_idle).toBeUndefined();
+ });
+
+ it('skips font check when font_ready is false', async () => {
+ withExample('Skip fonts
', { withShadow: false });
+
+ let result = await waitForReady({
+ stability_window_ms: 50,
+ timeout_ms: 2000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.font_ready).toBeUndefined();
+ });
+
+ it('passes ready_selectors for sticky-position elements', async () => {
+ withExample('Sticky
', { withShadow: false });
+
+ let result = await waitForReady({
+ stability_window_ms: 50,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50,
+ ready_selectors: ['#sticky-el']
+ });
+
+ expect(result.checks.ready_selectors.passed).toBe(true);
+ });
+
+ // --- JS idle check ---
+
+ it('includes js_idle check by default', async () => {
+ withExample('JS idle test
', { withShadow: false });
+
+ let result = await waitForReady({
+ stability_window_ms: 50,
+ timeout_ms: 5000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.checks.js_idle).toBeDefined();
+ expect(result.checks.js_idle.passed).toBe(true);
+ expect(typeof result.checks.js_idle.long_tasks_observed).toBe('number');
+ });
+
+ it('skips js_idle check when js_idle is false', async () => {
+ withExample('No JS idle
', { withShadow: false });
+
+ let result = await waitForReady({
+ stability_window_ms: 50,
+ timeout_ms: 2000,
+ image_ready: false,
+ font_ready: false,
+ js_idle: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.js_idle).toBeUndefined();
+ });
+
+ it('js_idle passes on a page with no long tasks', async () => {
+ withExample('Static content
', { withShadow: false });
+
+ let result = await waitForReady({
+ stability_window_ms: 50,
+ timeout_ms: 5000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50,
+ js_idle: true
+ });
+
+ expect(result.checks.js_idle.passed).toBe(true);
+ expect(result.checks.js_idle.duration_ms).toBeDefined();
+ expect(typeof result.checks.js_idle.idle_callback_used).toBe('boolean');
+ expect(result.checks.js_idle.long_tasks_observed).toBe(0);
+ });
+
+ it('uses dedicated js_idle_window_ms independently of stability_window_ms', async () => {
+ // Verifies the decoupling introduced for PR #2184 comment #3086822493.
+ // With a long stability window but short js_idle window, the js_idle
+ // check must finish quickly instead of blocking on the stability window.
+ withExample('decoupled
', { withShadow: false });
+
+ let result = await waitForReady({
+ stability_window_ms: 200,
+ js_idle_window_ms: 50,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.js_idle.passed).toBe(true);
+ // js_idle check's own duration should be driven by js_idle_window_ms (50ms),
+ // not by stability_window_ms (200ms). We give generous headroom for
+ // rAF cadence and scheduler jitter.
+ expect(result.checks.js_idle.duration_ms).toBeLessThan(500);
+ });
+
+ it('falls back to stability_window_ms when js_idle_window_ms is not provided', async () => {
+ // Backward compat: older configs that only set stability_window_ms
+ // should still drive the js_idle window.
+ withExample('fallback
', { withShadow: false });
+
+ let result = await waitForReady({
+ stability_window_ms: 100,
+ timeout_ms: 3000,
+ image_ready: false,
+ font_ready: false,
+ network_idle_window_ms: 50
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.checks.js_idle.passed).toBe(true);
+ });
+});
diff --git a/packages/dom/test/serialize-readiness.test.js b/packages/dom/test/serialize-readiness.test.js
new file mode 100644
index 000000000..3a7d66854
--- /dev/null
+++ b/packages/dom/test/serialize-readiness.test.js
@@ -0,0 +1,169 @@
+import { serializeDOM, serializeDOMWithReadiness } from '@percy/dom';
+import { withExample } from './helpers';
+
+describe('serializeDOM — sync, backward compatible', () => {
+ afterEach(() => {
+ let $test = document.getElementById('test');
+ if ($test) $test.remove();
+ });
+
+ it('always returns synchronously (no readiness)', () => {
+ withExample('Static content
', { withShadow: false });
+ let result = serializeDOM();
+ expect(result.html).toBeDefined();
+ expect(typeof result.html).toBe('string');
+ expect(result.html).toContain('Static content');
+ });
+
+ it('returns sync even when readiness config is present (backward compat)', () => {
+ // serializeDOM stays SYNC so existing SDKs don't break.
+ // Readiness is opt-in via serializeDOMWithReadiness.
+ withExample('Backcompat
', { withShadow: false });
+ let result = serializeDOM({
+ readiness: { preset: 'fast', stability_window_ms: 50, timeout_ms: 2000 }
+ });
+ // Must NOT be a Promise
+ expect(result.then).toBeUndefined();
+ expect(result.html).toContain('Backcompat');
+ });
+});
+
+describe('serializeDOMWithReadiness — async, readiness-gated', () => {
+ afterEach(() => {
+ let $test = document.getElementById('test');
+ if ($test) $test.remove();
+ });
+
+ it('serializes synchronously when readiness is disabled', async () => {
+ withExample('Disabled readiness
', { withShadow: false });
+ let result = await serializeDOMWithReadiness({ readiness: { preset: 'disabled' } });
+ expect(result.html).toContain('Disabled readiness');
+ });
+
+ it('returns a Promise when readiness config is provided', () => {
+ withExample('Async content
', { withShadow: false });
+ let result = serializeDOMWithReadiness({
+ readiness: { preset: 'fast', stability_window_ms: 50, timeout_ms: 2000, network_idle_window_ms: 50, image_ready: false }
+ });
+ expect(result).toBeDefined();
+ expect(typeof result.then).toBe('function');
+ });
+
+ it('resolves with serialized DOM after readiness passes', async () => {
+ withExample('Ready content
', { withShadow: false });
+ let result = await serializeDOMWithReadiness({
+ readiness: { preset: 'fast', stability_window_ms: 50, timeout_ms: 3000, network_idle_window_ms: 50, image_ready: false, font_ready: false, js_idle: false }
+ });
+ expect(result.html).toBeDefined();
+ expect(result.html).toContain('Ready content');
+ });
+
+ it('attaches readiness_diagnostics to the result', async () => {
+ withExample('Diagnostics test
', { withShadow: false });
+ let result = await serializeDOMWithReadiness({
+ readiness: { preset: 'fast', stability_window_ms: 50, timeout_ms: 3000, network_idle_window_ms: 50, image_ready: false, font_ready: false, js_idle: false }
+ });
+ expect(result.readiness_diagnostics).toBeDefined();
+ expect(result.readiness_diagnostics.passed).toBe(true);
+ expect(typeof result.readiness_diagnostics.total_duration_ms).toBe('number');
+ });
+
+ it('waits for DOM stability before serializing (skeleton removal)', async () => {
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let skeleton = document.querySelector('.skeleton');
+ if (skeleton) {
+ skeleton.parentNode.removeChild(skeleton);
+ let content = document.createElement('div');
+ content.className = 'real-content';
+ content.textContent = 'Fully loaded data';
+ document.getElementById('app').appendChild(content);
+ }
+ }, 200);
+
+ let result = await serializeDOMWithReadiness({
+ readiness: {
+ stability_window_ms: 300,
+ timeout_ms: 5000,
+ network_idle_window_ms: 50,
+ image_ready: false,
+ font_ready: false,
+ js_idle: false,
+ not_present_selectors: ['.skeleton']
+ }
+ });
+
+ expect(result.html).toContain('Fully loaded data');
+ expect(result.html).not.toContain('Loading...');
+ expect(result.readiness_diagnostics.passed).toBe(true);
+ });
+
+ it('waits for ready_selectors before serializing', async () => {
+ withExample('', { withShadow: false });
+
+ setTimeout(() => {
+ let el = document.createElement('div');
+ el.setAttribute('data-loaded', 'true');
+ el.textContent = 'Data loaded';
+ document.getElementById('container').appendChild(el);
+ }, 200);
+
+ let result = await serializeDOMWithReadiness({
+ readiness: {
+ stability_window_ms: 50,
+ timeout_ms: 5000,
+ network_idle_window_ms: 50,
+ image_ready: false,
+ font_ready: false,
+ js_idle: false,
+ ready_selectors: ['[data-loaded]']
+ }
+ });
+
+ expect(result.html).toContain('Data loaded');
+ expect(result.readiness_diagnostics.checks.ready_selectors.passed).toBe(true);
+ });
+
+ it('serializes even if readiness times out (graceful degradation)', async () => {
+ withExample('', { withShadow: false });
+ let interval = setInterval(() => {
+ let el = document.getElementById('forever');
+ if (el) { let s = document.createElement('span'); s.textContent = Date.now(); el.appendChild(s); if (el.children.length > 10) el.removeChild(el.firstChild); }
+ }, 30);
+
+ let result = await serializeDOMWithReadiness({
+ readiness: {
+ stability_window_ms: 200,
+ timeout_ms: 1000,
+ network_idle_window_ms: 50,
+ image_ready: false,
+ font_ready: false,
+ js_idle: false
+ }
+ });
+
+ clearInterval(interval);
+ expect(result.html).toBeDefined();
+ expect(result.readiness_diagnostics.timed_out).toBe(true);
+ });
+
+ it('accepts camelCase config keys (SDK flow)', async () => {
+ // Tests the camelCase -> snake_case normalization. Users typically
+ // configure in .percy.yml with camelCase; the override must work.
+ withExample('CamelCase test
', { withShadow: false });
+ let result = await serializeDOMWithReadiness({
+ readiness: {
+ preset: 'fast',
+ stabilityWindowMs: 50,
+ networkIdleWindowMs: 50,
+ timeoutMs: 2000,
+ imageReady: false,
+ fontReady: false,
+ jsIdle: false
+ }
+ });
+ expect(result.html).toContain('CamelCase test');
+ expect(result.readiness_diagnostics.passed).toBe(true);
+ });
+});
diff --git a/packages/sdk-utils/src/index.js b/packages/sdk-utils/src/index.js
index 61b0b4c0e..190028e30 100644
--- a/packages/sdk-utils/src/index.js
+++ b/packages/sdk-utils/src/index.js
@@ -10,6 +10,7 @@ import postBuildEvents from './post-build-event.js';
import flushSnapshots from './flush-snapshots.js';
import captureAutomateScreenshot from './post-screenshot.js';
import getResponsiveWidths from './get-responsive-widths.js';
+import { serializeScript, buildSerializeOptions } from './serialize-dom.js';
export {
logger,
@@ -23,7 +24,9 @@ export {
flushSnapshots,
captureAutomateScreenshot,
postBuildEvents,
- getResponsiveWidths
+ getResponsiveWidths,
+ serializeScript,
+ buildSerializeOptions
};
// export the namespace by default
diff --git a/packages/sdk-utils/src/serialize-dom.js b/packages/sdk-utils/src/serialize-dom.js
new file mode 100644
index 000000000..386ee61e0
--- /dev/null
+++ b/packages/sdk-utils/src/serialize-dom.js
@@ -0,0 +1,63 @@
+import percy from './percy-info.js';
+
+// Returns the readiness config from the Percy CLI config, if present.
+// SDKs obtain percy.config via the healthcheck endpoint in isPercyEnabled().
+function getReadinessConfig(snapshotOptions) {
+ return snapshotOptions?.readiness ||
+ percy.config?.snapshot?.readiness;
+}
+
+// Build the serialize options object that SDKs pass into the browser.
+// Merges per-snapshot readiness overrides with the global readiness config.
+export function buildSerializeOptions(snapshotOptions = {}) {
+ let readiness = getReadinessConfig(snapshotOptions);
+ let options = { ...snapshotOptions };
+ if (readiness) options.readiness = readiness;
+ return options;
+}
+
+// Returns a JavaScript code string that SDKs evaluate in the browser
+// to serialize the DOM with readiness support.
+//
+// Uses serializeDOMWithReadiness when available (new CLI) with a
+// fallback to serialize (old CLI) for backward compatibility.
+//
+// The result is always wrapped in Promise.resolve() so it works
+// uniformly with both async and sync execution APIs.
+//
+// Usage in SDKs:
+// // JS SDKs (Puppeteer, Playwright — auto-await):
+// let domSnapshot = await page.evaluate(serializeScript(options));
+//
+// // Selenium SDKs (Python, Java, Ruby, .NET — executeAsyncScript):
+// let dom = driver.execute_async_script(
+// serializeScript(options, { callback: true }),
+// options
+// );
+export function serializeScript(options = {}, { callback = false } = {}) {
+ let opts = JSON.stringify(buildSerializeOptions(options));
+
+ let core = `
+ var fn = (typeof PercyDOM.serializeDOMWithReadiness === 'function')
+ ? PercyDOM.serializeDOMWithReadiness
+ : PercyDOM.serialize;
+ var result = Promise.resolve(fn(${opts}));
+ `;
+
+ if (callback) {
+ // For executeAsyncScript — last argument is the callback
+ return `
+ ${core}
+ var done = arguments[arguments.length - 1];
+ result.then(done).catch(function() { done(PercyDOM.serialize(${opts})); });
+ `;
+ }
+
+ // For page.evaluate / executeScript with auto-await
+ return `
+ ${core}
+ return result;
+ `;
+}
+
+export default serializeScript;
diff --git a/packages/sdk-utils/test/index.test.js b/packages/sdk-utils/test/index.test.js
index 5a80350ce..62e14e46f 100644
--- a/packages/sdk-utils/test/index.test.js
+++ b/packages/sdk-utils/test/index.test.js
@@ -648,4 +648,71 @@ describe('SDK Utils', () => {
]);
});
});
+
+ describe('serializeScript(options[, flags])', () => {
+ let { serializeScript, percy } = utils;
+
+ it('returns a JS string that calls serializeDOMWithReadiness with fallback', () => {
+ let script = serializeScript({ enableJavaScript: true });
+ expect(script).toContain('PercyDOM.serializeDOMWithReadiness');
+ expect(script).toContain('PercyDOM.serialize');
+ expect(script).toContain('Promise.resolve');
+ expect(script).toContain('return result');
+ });
+
+ it('generates a callback variant for executeAsyncScript', () => {
+ let script = serializeScript({ enableJavaScript: true }, { callback: true });
+ expect(script).toContain('arguments[arguments.length - 1]');
+ expect(script).toContain('.then(done)');
+ expect(script).toContain('.catch(');
+ expect(script).not.toContain('return result');
+ });
+
+ it('includes serialize options in the generated script', () => {
+ let script = serializeScript({ enableJavaScript: true, disableShadowDOM: false });
+ expect(script).toContain('"enableJavaScript":true');
+ expect(script).toContain('"disableShadowDOM":false');
+ });
+
+ it('includes readiness config from percy.config when available', () => {
+ percy.config = { snapshot: { readiness: { preset: 'fast', timeoutMs: 5000 } } };
+ let script = serializeScript({});
+ expect(script).toContain('"readiness"');
+ expect(script).toContain('"preset":"fast"');
+ percy.config = undefined;
+ });
+
+ it('prefers per-snapshot readiness over global config', () => {
+ percy.config = { snapshot: { readiness: { preset: 'balanced' } } };
+ let script = serializeScript({ readiness: { preset: 'strict' } });
+ expect(script).toContain('"preset":"strict"');
+ expect(script).not.toContain('"preset":"balanced"');
+ percy.config = undefined;
+ });
+ });
+
+ describe('buildSerializeOptions(snapshotOptions)', () => {
+ let { buildSerializeOptions, percy } = utils;
+
+ it('returns options unchanged when no readiness config exists', () => {
+ percy.config = undefined;
+ let opts = buildSerializeOptions({ enableJavaScript: true });
+ expect(opts).toEqual({ enableJavaScript: true });
+ });
+
+ it('merges global readiness config into options', () => {
+ percy.config = { snapshot: { readiness: { preset: 'fast' } } };
+ let opts = buildSerializeOptions({ enableJavaScript: true });
+ expect(opts.readiness).toEqual({ preset: 'fast' });
+ expect(opts.enableJavaScript).toBe(true);
+ percy.config = undefined;
+ });
+
+ it('uses per-snapshot readiness when provided', () => {
+ percy.config = { snapshot: { readiness: { preset: 'balanced' } } };
+ let opts = buildSerializeOptions({ readiness: { preset: 'strict' } });
+ expect(opts.readiness).toEqual({ preset: 'strict' });
+ percy.config = undefined;
+ });
+ });
});