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('
Loading...
', { 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('
Loading...
', { 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; + }); + }); });