From 9cc7af0cdb9d75d4e45a9335cb1c7876d76e9338 Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Tue, 12 May 2026 13:04:58 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20Readiness=20gate=20in=20PercyDOM.serial?= =?UTF-8?q?ize()=20=E2=80=94=20URL=20+=20SDK=20paths=20(PER-7348)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Readiness checks now run INSIDE PercyDOM.serialize() before DOM serialization. serialize() is the common entry point for both snapshot paths: - URL-based (CLI percy snapshot, Storybook): CLI calls PercyDOM.serialize(options) via page.eval() - SDK-based (Cypress, Selenium, Puppeteer): SDK calls PercyDOM.serialize(options) directly in the test browser When readiness config is provided, serialize() calls waitForReady() first, waits for the page to stabilize, then serializes the DOM. This means readiness works identically regardless of which path captures the snapshot. Key design decisions: - serialize() returns a Promise when readiness is configured, stays synchronous when not (backward compatible) - Readiness diagnostics are attached to the serialized result as readiness_diagnostics for smart debugging - page.eval() uses awaitPromise:true which handles the async return automatically - Per-snapshot override works: { readiness: { preset: 'disabled' } } - Global config from .percy.yml flows via options parameter - domStability kill-switch flag for emergency disable - Two-call pattern: waitForReadyScript helper for SDK integrations Changes: - @percy/dom: readiness.js (7 checks including JS idle), integrated into serialize-dom.js via waitForReady() call before serialization - @percy/dom: index.js exports waitForReady - @percy/core: page.js passes readiness config to serialize options - @percy/core: config.js adds readiness schema for .percy.yml - @percy/sdk-utils: serialize-dom.js helper for SDK readiness - Tests: comprehensive readiness + serialize-readiness coverage Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/config.js | 50 + packages/core/src/page.js | 30 +- packages/core/src/snapshot.js | 21 + packages/core/test/percy.test.js | 124 ++ packages/core/test/snapshot.test.js | 109 ++ packages/dom/src/index.js | 2 + packages/dom/src/readiness.js | 556 +++++++++ packages/dom/src/serialize-dom.js | 3 +- packages/dom/test/readiness-helpers.test.js | 414 +++++++ packages/dom/test/readiness.test.js | 1010 +++++++++++++++++ packages/dom/test/serialize-readiness.test.js | 168 +++ packages/sdk-utils/src/index.js | 6 +- packages/sdk-utils/src/serialize-dom.js | 69 ++ packages/sdk-utils/test/index.test.js | 96 ++ 14 files changed, 2654 insertions(+), 4 deletions(-) create mode 100644 packages/dom/src/readiness.js create mode 100644 packages/dom/test/readiness-helpers.test.js create mode 100644 packages/dom/test/readiness.test.js create mode 100644 packages/dom/test/serialize-readiness.test.js create mode 100644 packages/sdk-utils/src/serialize-dom.js diff --git a/packages/core/src/config.js b/packages/core/src/config.js index ff20a4f51..ad860e2fd 100644 --- a/packages/core/src/config.js +++ b/packages/core/src/config.js @@ -135,6 +135,48 @@ 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 }, + domStability: { type: 'boolean' }, + imageReady: { type: 'boolean' }, + fontReady: { type: 'boolean' }, + jsIdle: { type: 'boolean' }, + readySelectors: { + type: 'array', + items: { + oneOf: [ + { type: 'string' }, + { + type: 'object', + additionalProperties: false, + properties: { css: { type: 'string' }, xpath: { type: 'string' } } + } + ] + } + }, + notPresentSelectors: { + type: 'array', + items: { + oneOf: [ + { type: 'string' }, + { + type: 'object', + additionalProperties: false, + properties: { css: { type: 'string' }, xpath: { type: 'string' } } + } + ] + } + }, + maxTimeoutMs: { type: 'integer', minimum: 1000, maximum: 60000 } + } + }, responsiveSnapshotCapture: { type: 'boolean', default: false @@ -507,6 +549,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' }, @@ -701,6 +744,13 @@ export const snapshotSchema = { type: 'array', items: { type: 'string' } }, + readiness_diagnostics: { + type: 'object', + normalize: false, + description: 'Diagnostics from readiness checks run before serialization. ' + + 'normalize: false preserves the snake_case wire format the SDKs send (timed_out, ' + + 'total_duration_ms, etc.) instead of camelCasing inner keys at validate time.' + }, corsIframes: { type: 'array', items: { diff --git a/packages/core/src/page.js b/packages/core/src/page.js index 3f87d1b03..8920b3a4a 100644 --- a/packages/core/src/page.js +++ b/packages/core/src/page.js @@ -280,8 +280,27 @@ export class Page { await this.insertPercyDom(); - // serialize and capture a DOM snapshot - this.log.debug('Serialize DOM', this.meta); + // Run readiness checks before serializing — wait for page stability + let readiness = snapshot.readiness || this.browser?.percy?.config?.snapshot?.readiness; + let readinessDiagnostics; + + if (readiness && readiness.preset !== 'disabled') { + this.log.debug('Waiting for readiness', this.meta); + readinessDiagnostics = await this.eval( + /* istanbul ignore next: no instrumenting injected code */ + async (_, config) => { + // eslint-disable-next-line no-undef + if (typeof PercyDOM?.waitForReady === 'function') return PercyDOM.waitForReady(config); + }, + readiness + ).catch(e => { + this.log.debug(`Readiness check failed: ${e}`, this.meta); + }); + + if (readinessDiagnostics?.timed_out) { + this.log.debug('Readiness timed out, capturing anyway', this.meta); + } + } let capture = await this.eval(serializeDomCapture, { enableJavaScript, @@ -295,6 +314,13 @@ export class Page { pseudoClassEnabledElements }); + // Attach readiness diagnostics onto the captured DOM snapshot so the backend/UI can surface + // readiness metrics. Only valid when domSnapshot is the structured object form — the legacy + // string form (HTML only) has no place to embed diagnostics. + if (readinessDiagnostics && capture?.domSnapshot && typeof capture.domSnapshot === 'object') { + capture.domSnapshot.readiness_diagnostics = readinessDiagnostics; + } + return { ...snapshot, ...capture }; } diff --git a/packages/core/src/snapshot.js b/packages/core/src/snapshot.js index b93be4ddb..8e33750bf 100644 --- a/packages/core/src/snapshot.js +++ b/packages/core/src/snapshot.js @@ -220,6 +220,27 @@ export function validateSnapshotOptions(options) { log.warn('Encountered snapshot serialization warnings:'); for (let w of domWarnings) log.warn(`- ${w}`); } + + // log readiness diagnostics when present. + // domSnapshot is a union of `string` (legacy SDK payload — HTML only) and `object` + // ({ html, warnings, readiness_diagnostics, ... }). Diagnostics only exist on the object form; + // gate explicitly on typeof so the intent is obvious to readers. + // The schema marks readiness_diagnostics with normalize: false to preserve the snake_case wire + // format. The dual-read fallback below is defensive — it keeps the log working even if a future + // SDK sends camelCase keys, or if a path in PercyConfig.migrate skips the normalize: false hint. + let domSnapshotObj = (migrated.domSnapshot && typeof migrated.domSnapshot === 'object') ? migrated.domSnapshot : null; + let readinessDiag = domSnapshotObj?.readiness_diagnostics ?? domSnapshotObj?.readinessDiagnostics; + if (readinessDiag) { + let timedOut = readinessDiag.timed_out ?? readinessDiag.timedOut; + let durationMs = readinessDiag.total_duration_ms ?? readinessDiag.totalDurationMs; + let presetName = readinessDiag.preset || 'custom'; + if (timedOut) { + log.warn(`Readiness timed out after ${durationMs}ms (preset: ${presetName})`); + } else { + log.debug(`Readiness passed in ${durationMs}ms (preset: ${presetName})`); + } + } + // 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 d34ce9d3f..f9b331293 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -216,6 +216,130 @@ describe('Percy', () => { ])); }); + it('runs readiness check before serializing when readiness option is set', async () => { + server.reply('/', () => [200, 'text/html', '

Hello Percy!

']); + + await percy.browser.launch(); + let page = await percy.browser.page(); + await page.goto('http://localhost:8000'); + + percy.loglevel('debug'); + + let snapshot = await page.snapshot({ + readiness: { preset: 'fast', timeoutMs: 1000 } + }); + + expect(logger.stderr).toEqual(jasmine.arrayContaining([ + jasmine.stringMatching(/Waiting for readiness/) + ])); + expect(snapshot.url).toEqual('http://localhost:8000/'); + }); + + it('skips readiness check when readiness preset is disabled', async () => { + server.reply('/', () => [200, 'text/html', '

Hello Percy!

']); + + await percy.browser.launch(); + let page = await percy.browser.page(); + await page.goto('http://localhost:8000'); + + percy.loglevel('debug'); + + let snapshot = await page.snapshot({ + readiness: { preset: 'disabled' } + }); + + expect(logger.stderr).not.toEqual(jasmine.arrayContaining([ + jasmine.stringMatching(/Waiting for readiness/) + ])); + expect(snapshot.url).toEqual('http://localhost:8000/'); + }); + + it('logs when readiness times out and continues to capture', async () => { + server.reply('/', () => [200, 'text/html', '

Hello Percy!

']); + + await percy.browser.launch(); + let page = await percy.browser.page(); + await page.goto('http://localhost:8000'); + + percy.loglevel('debug'); + + // Force the readiness eval to resolve with a timed_out diagnostics + // payload while letting all other eval calls (PercyDOM injection, + // serialize, etc.) pass through untouched. The readiness eval is the + // only call whose first arg is a config object containing `preset`. + let originalEval = page.eval.bind(page); + spyOn(page, 'eval').and.callFake((fn, ...args) => { + if (args[0] && typeof args[0] === 'object' && 'preset' in args[0]) { + return Promise.resolve({ timed_out: true, total_duration_ms: 1234 }); + } + return originalEval(fn, ...args); + }); + + let snapshot = await page.snapshot({ + readiness: { preset: 'balanced' } + }); + + expect(logger.stderr).toEqual(jasmine.arrayContaining([ + jasmine.stringMatching(/Waiting for readiness/), + jasmine.stringMatching(/Readiness timed out, capturing anyway/) + ])); + expect(snapshot.url).toEqual('http://localhost:8000/'); + // diagnostics should be attached to the captured DOM snapshot so the + // backend/UI can surface readiness metrics + expect(snapshot.domSnapshot).toEqual(jasmine.objectContaining({ + readiness_diagnostics: { timed_out: true, total_duration_ms: 1234 } + })); + }); + + it('debug logs when the readiness eval rejects and continues to capture', async () => { + server.reply('/', () => [200, 'text/html', '

Hello Percy!

']); + + await percy.browser.launch(); + let page = await percy.browser.page(); + await page.goto('http://localhost:8000'); + + percy.loglevel('debug'); + + // Reject only the readiness eval (its first arg is the readiness config + // object containing `preset`); all other evals pass through. + let originalEval = page.eval.bind(page); + spyOn(page, 'eval').and.callFake((fn, ...args) => { + if (args[0] && typeof args[0] === 'object' && 'preset' in args[0]) { + return Promise.reject(new Error('boom')); + } + return originalEval(fn, ...args); + }); + + let snapshot = await page.snapshot({ + readiness: { preset: 'fast' } + }); + + expect(logger.stderr).toEqual(jasmine.arrayContaining([ + jasmine.stringMatching(/Readiness check failed: Error: boom/) + ])); + // serialize still runs after a readiness failure + expect(snapshot.url).toEqual('http://localhost:8000/'); + }); + + it('falls back to the percy config readiness when none is set per snapshot', async () => { + server.reply('/', () => [200, 'text/html', '

Hello Percy!

']); + + percy.config.snapshot.readiness = { preset: 'fast', timeoutMs: 1000 }; + + await percy.browser.launch(); + let page = await percy.browser.page(); + await page.goto('http://localhost:8000'); + + percy.loglevel('debug'); + + let snapshot = await page.snapshot({}); + + expect(logger.stderr).toEqual(jasmine.arrayContaining([ + jasmine.stringMatching(/Waiting for readiness/) + ])); + expect(snapshot.url).toEqual('http://localhost:8000/'); + }); + describe('.start()', () => { // rather than stub prototypes, extend and mock class TestPercy extends Percy { diff --git a/packages/core/test/snapshot.test.js b/packages/core/test/snapshot.test.js index b8dbebad2..45d7f2251 100644 --- a/packages/core/test/snapshot.test.js +++ b/packages/core/test/snapshot.test.js @@ -1036,6 +1036,115 @@ describe('Snapshot', () => { expect(uploads[2]).toEqual(Buffer.from(textResource.content).toString('base64')); }); + it('warns when readiness diagnostics indicate a timeout', async () => { + // domSnapshot is sent as a JSON string by SDKs, which preserves snake_case + // keys like readiness_diagnostics through option normalization. + await percy.snapshot({ + name: 'Readiness Timed Out', + url: 'http://localhost:8000/', + domSnapshot: JSON.stringify({ + html: testDOM, + readiness_diagnostics: { + timed_out: true, + total_duration_ms: 12345, + preset: 'balanced' + } + }) + }); + + expect(logger.stderr).toEqual(jasmine.arrayContaining([ + '[percy] Readiness timed out after 12345ms (preset: balanced)' + ])); + expect(logger.stdout).toEqual(jasmine.arrayContaining([ + '[percy] Snapshot taken: Readiness Timed Out' + ])); + }); + + it('falls back to "custom" preset label when readiness timeout omits the preset', async () => { + await percy.snapshot({ + name: 'Readiness Timed Out Custom', + url: 'http://localhost:8000/', + domSnapshot: JSON.stringify({ + html: testDOM, + readiness_diagnostics: { + timed_out: true, + total_duration_ms: 9999 + } + }) + }); + + expect(logger.stderr).toEqual(jasmine.arrayContaining([ + '[percy] Readiness timed out after 9999ms (preset: custom)' + ])); + }); + + it('debug logs readiness diagnostics when readiness passes', async () => { + percy.loglevel('debug'); + + await percy.snapshot({ + name: 'Readiness Passed', + url: 'http://localhost:8000/', + domSnapshot: JSON.stringify({ + html: testDOM, + readiness_diagnostics: { + timed_out: false, + total_duration_ms: 250 + } + }) + }); + + expect(logger.stderr).toEqual(jasmine.arrayContaining([ + '[percy:core:snapshot] Readiness passed in 250ms (preset: custom)' + ])); + }); + + // The previous three tests pass `domSnapshot` as a JSON-stringified string — + // a path that bypasses PercyConfig.migrate's recursive case-conversion of + // nested keys. Real SDKs (post-PR2184) submit `domSnapshot` as an OBJECT in + // the snapshot post body, so the normalize layer rewrites snake_case nested + // keys to camelCase. The schema marks readiness_diagnostics with + // `normalize: false` to preserve the wire shape; these regression tests + // pin both the schema flag and the snapshot.js dual-read fallback. + it('logs readiness timeout when domSnapshot is submitted as an object (real SDK wire shape)', async () => { + await percy.snapshot({ + name: 'Readiness Timeout Object', + url: 'http://localhost:8000/', + domSnapshot: { + html: testDOM, + readiness_diagnostics: { + timed_out: true, + total_duration_ms: 4321, + preset: 'balanced' + } + } + }); + + expect(logger.stderr).toEqual(jasmine.arrayContaining([ + '[percy] Readiness timed out after 4321ms (preset: balanced)' + ])); + }); + + it('logs readiness pass when domSnapshot is submitted as an object', async () => { + percy.loglevel('debug'); + + await percy.snapshot({ + name: 'Readiness Passed Object', + url: 'http://localhost:8000/', + domSnapshot: { + html: testDOM, + readiness_diagnostics: { + timed_out: false, + total_duration_ms: 175, + preset: 'fast' + } + } + }); + + expect(logger.stderr).toEqual(jasmine.arrayContaining([ + '[percy:core:snapshot] Readiness passed in 175ms (preset: fast)' + ])); + }); + it('handles duplicate snapshots when testCase is not passed', async () => { await percy.snapshot([{ url: 'http://localhost:8000/foobar', diff --git a/packages/dom/src/index.js b/packages/dom/src/index.js index a449d17f3..13578bab9 100644 --- a/packages/dom/src/index.js +++ b/packages/dom/src/index.js @@ -7,3 +7,5 @@ export { } 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..8f1a4dc51 --- /dev/null +++ b/packages/dom/src/readiness.js @@ -0,0 +1,556 @@ +/* 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, + dom_stability: true, + 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, + dom_stability: true, + 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, + dom_stability: true, + 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; +} + +// Resolve a single ready/notPresent selector to a DOM Element. Accepts: +// - CSS string: '.app-loaded' +// - XPath string: '//div[@id="root"]' (sniffed by leading /, //, ./, (/, (./) +// - Object form (explicit): { css: '.foo' } | { xpath: '//bar' } +// Returns the matched Element, or null when no element matches, the +// selector is malformed, or it resolves to a non-Element node. +// +// Exported for direct unit testing. +const XPATH_SNIFF = /^\(?\.?\//; +export function resolveSelector(selector) { + if (!selector) return null; + let xpath = null; + let css = null; + if (typeof selector === 'object') { + if (selector.xpath) xpath = selector.xpath; + else if (selector.css) css = selector.css; + else return null; + } else if (typeof selector === 'string') { + if (XPATH_SNIFF.test(selector)) xpath = selector; + else css = selector; + } else { + return null; + } + try { + let el = xpath + ? document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue + : document.querySelector(css); + return el instanceof Element ? el : null; + } catch (e) { + // Malformed XPath or invalid CSS — treat as no-match so the selector + // gate keeps polling rather than blowing up the entire readiness gate. + return null; + } +} + +// Subscribe to PerformanceObserver entries of a given type. Returns the +// observer (for the caller to disconnect) or null when PerformanceObserver +// (or the requested entry type) is unavailable, so callers can fall back. +// +// Used by checkNetworkIdle (`resource`) and checkJSIdle (`longtask`) to +// avoid duplicating the try/observe/disconnect boilerplate. +function observePerformance(type, onEntries) { + try { + let observer = new PerformanceObserver(list => onEntries(list.getEntries())); + observer.observe({ type, buffered: false }); + return observer; + } catch (e) /* istanbul ignore next: PerformanceObserver is available in Chrome/Firefox; catch is for old browsers */ { + return null; + } +} + +// --- 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) { + return new Promise(resolve => { + let startTime = performance.now(); + let timer = 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 before any resource entry arrives */ + if (timer) clearTimeout(timer); + timer = setTimeout(settle, networkIdleWindowMs); + } + + /* istanbul ignore next: observer callback body only runs if a network resource loads during the idle window */ + let observer = observePerformance('resource', entries => { + if (aborted.value) return; + if (entries.length > 0) resetIdleTimer(); + }); + + /* istanbul ignore next: PerformanceObserver fallback only triggers in older browsers */ + if (!observer) { + 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 resolveAbort; + // Resolve deterministically on abort so the race is settled by the orchestrator's timeout + // path and doesn't get retroactively flipped to { passed: true } when document.fonts.ready + // settles late. Important if we ever begin reading checks.font_ready post-timeout. + let abortPromise = new Promise(r => { resolveAbort = r; }); + 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); }), + abortPromise + ]); + /* istanbul ignore next: abort path not deterministically testable */ + if (aborted) { + aborted.onAbort(() => { + if (fontTimer) clearTimeout(fontTimer); + resolveAbort({ passed: false, duration_ms: Math.round(performance.now() - start), aborted: true }); + }); + } + 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. + // observePerformance returns null on older browsers; we degrade to the + // rIC/rAF-only path in that case. + /* istanbul ignore next: longtask callback fires only on CPU-heavy >50ms tasks, not reliable in tests */ + observer = observePerformance('longtask', entries => { + if (!observing || settled || aborted.value) return; + for (let entry of entries) { + if (entry.entryType === 'longtask') { + longTaskCount++; + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(confirmIdle, idleWindowMs); + } + } + }); + + 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 = resolveSelector(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 (resolveSelector(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 = []; + // dom_stability: false is an explicit kill switch for the MutationObserver + // check. Use it on heavy SPA pages where the observer itself can drive + // CPU/memory pressure. Other checks (js_idle, image/font ready, selectors) + // continue to run, so capture is still gated — just not on raw mutation rate. + if (config.dom_stability !== false && 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, + dom_stability: options.domStability ?? options.dom_stability, + 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 0e986998a..1d025b505 100644 --- a/packages/dom/src/serialize-dom.js +++ b/packages/dom/src/serialize-dom.js @@ -83,7 +83,8 @@ export function waitForResize() { window.resizeCount = 0; } -// Serializes a document and returns the resulting DOM string. +// Synchronous DOM serializer. For readiness gating, call `PercyDOM.waitForReady(config)` +// before this — see readiness.js. export function serializeDOM(options) { let { dom = document, diff --git a/packages/dom/test/readiness-helpers.test.js b/packages/dom/test/readiness-helpers.test.js new file mode 100644 index 000000000..d3175c6bc --- /dev/null +++ b/packages/dom/test/readiness-helpers.test.js @@ -0,0 +1,414 @@ +// 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, + resolveSelector +} 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, + domStability: true, + 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, + dom_stability: true, + 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 domStability to dom_stability', () => { + let n = normalizeOptions({ domStability: false }); + expect(n.dom_stability).toBe(false); + }); + + it('accepts dom_stability in snake_case', () => { + let n = normalizeOptions({ dom_stability: false }); + expect(n.dom_stability).toBe(false); + }); + + it('prefers camelCase domStability when both are provided', () => { + let n = normalizeOptions({ domStability: false, dom_stability: true }); + expect(n.dom_stability).toBe(false); + }); + + 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); + }); + }); + + describe('resolveSelector', () => { + let host; + beforeEach(() => { + host = document.createElement('div'); + host.id = 'rs-host'; + host.innerHTML = '

hi

'; + document.body.appendChild(host); + }); + afterEach(() => host.remove()); + + it('returns null for falsy / unsupported input', () => { + expect(resolveSelector(null)).toBe(null); + expect(resolveSelector(undefined)).toBe(null); + expect(resolveSelector('')).toBe(null); + expect(resolveSelector(42)).toBe(null); + expect(resolveSelector({})).toBe(null); + }); + + it('resolves a CSS string', () => { + let el = resolveSelector('#main .hello'); + expect(el).not.toBe(null); + expect(el.textContent).toBe('hi'); + }); + + it('returns null when CSS does not match', () => { + expect(resolveSelector('#nope')).toBe(null); + }); + + it('resolves an XPath string starting with //', () => { + let el = resolveSelector('//section[@id="main"]'); + expect(el).not.toBe(null); + expect(el.id).toBe('main'); + }); + + it('resolves an XPath string starting with /', () => { + let el = resolveSelector('/html/body//*[@id="rs-host"]'); + expect(el).not.toBe(null); + expect(el.id).toBe('rs-host'); + }); + + it('resolves an XPath string starting with ./', () => { + let el = resolveSelector('.//section[@id="main"]'); + expect(el).not.toBe(null); + expect(el.id).toBe('main'); + }); + + it('resolves an XPath string starting with (/', () => { + let el = resolveSelector('(//section)[1]'); + expect(el).not.toBe(null); + expect(el.id).toBe('main'); + }); + + it('returns null for malformed XPath', () => { + expect(resolveSelector('//section[unclosed')).toBe(null); + }); + + it('returns null when XPath resolves to a non-Element node', () => { + // Text node — has no offsetParent, must be filtered out + expect(resolveSelector('//p[@class="hello"]/text()')).toBe(null); + }); + + it('accepts explicit { css } object form', () => { + let el = resolveSelector({ css: '#main' }); + expect(el).not.toBe(null); + expect(el.id).toBe('main'); + }); + + it('accepts explicit { xpath } object form', () => { + let el = resolveSelector({ xpath: '//*[@data-role="main"]' }); + expect(el).not.toBe(null); + expect(el.id).toBe('main'); + }); + + it('prefers xpath over css when both keys are present', () => { + let el = resolveSelector({ xpath: '//section[@id="main"]', css: '#nope' }); + expect(el).not.toBe(null); + expect(el.id).toBe('main'); + }); + + it('returns null for object form with neither css nor xpath', () => { + expect(resolveSelector({ foo: 'bar' })).toBe(null); + }); + + it('returns null for invalid CSS in object form', () => { + expect(resolveSelector({ css: '!!not a selector' })).toBe(null); + }); + }); +}); diff --git a/packages/dom/test/readiness.test.js b/packages/dom/test/readiness.test.js new file mode 100644 index 000000000..e490f4063 --- /dev/null +++ b/packages/dom/test/readiness.test.js @@ -0,0 +1,1010 @@ +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('skips the dom_stability check when dom_stability is false (kill-switch)', async () => { + withExample('

kill-switch

', { withShadow: false }); + let result = await waitForReady({ stability_window_ms: 50, timeout_ms: 3000, image_ready: false, font_ready: false, network_idle_window_ms: 50, js_idle: false, dom_stability: false }); + expect(result.checks.dom_stability).toBeUndefined(); + expect(result.passed).toBe(true); + }); + + it('accepts the camelCase domStability flag', async () => { + withExample('

kill-switch-camel

', { withShadow: false }); + let result = await waitForReady({ stabilityWindowMs: 50, timeoutMs: 3000, imageReady: false, fontReady: false, networkIdleWindowMs: 50, jsIdle: false, domStability: false }); + expect(result.checks.dom_stability).toBeUndefined(); + expect(result.passed).toBe(true); + }); + + it('still runs dom_stability when dom_stability is true (default behavior)', async () => { + withExample('

default

', { withShadow: false }); + let result = await waitForReady({ stability_window_ms: 50, timeout_ms: 3000, image_ready: false, font_ready: false, network_idle_window_ms: 50, js_idle: false, dom_stability: true }); + expect(result.checks.dom_stability).toBeDefined(); + expect(result.checks.dom_stability.passed).toBe(true); + }); + + it('passes when ready_selectors are given as XPath', async () => { + withExample('
ok
', { 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: ['//section[@id="ready-xp" and contains(@class,"loaded")]'] }); + expect(result.checks.ready_selectors.passed).toBe(true); + }); + + it('passes when not_present_selectors are given as XPath', async () => { + withExample('

no spinner here

', { 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: ['//div[contains(@class,"spinner")]'] }); + expect(result.checks.not_present_selectors.passed).toBe(true); + }); + + it('accepts mixed CSS and XPath selectors via object form', async () => { + withExample('
ok
', { 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: [{ css: '.ready' }, { xpath: '//section[@id="mix"]' }] }); + expect(result.checks.ready_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..76f6758e4 --- /dev/null +++ b/packages/dom/test/serialize-readiness.test.js @@ -0,0 +1,168 @@ +import { serializeDOM, waitForReady } from '@percy/dom'; +import { withExample } from './helpers'; + +describe('serializeDOM — always sync', () => { + afterEach(() => { + let $test = document.getElementById('test'); + if ($test) $test.remove(); + }); + + it('returns synchronously (plain object, not a Promise)', () => { + withExample('

    Static content

    ', { withShadow: false }); + let result = serializeDOM(); + expect(result.html).toBeDefined(); + expect(typeof result.html).toBe('string'); + expect(result.html).toContain('Static content'); + // Must NOT be a Promise + expect(result.then).toBeUndefined(); + }); + + it('stays sync even when readiness config is present in options', () => { + // serializeDOM is ALWAYS sync. Readiness runs separately via waitForReady. + withExample('

    Backcompat

    ', { withShadow: false }); + let result = serializeDOM({ + readiness: { preset: 'fast', stability_window_ms: 50, timeout_ms: 2000 } + }); + expect(result.then).toBeUndefined(); + expect(result.html).toContain('Backcompat'); + }); +}); + +describe('waitForReady + serializeDOM — two-call pattern', () => { + afterEach(() => { + let $test = document.getElementById('test'); + if ($test) $test.remove(); + }); + + it('waitForReady returns a Promise', () => { + withExample('

    Async content

    ', { withShadow: false }); + let result = waitForReady({ + 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('readiness passes, then serialize captures stable DOM', async () => { + withExample('

    Ready content

    ', { withShadow: false }); + let diagnostics = await waitForReady({ + preset: 'fast', stability_window_ms: 50, timeout_ms: 3000, network_idle_window_ms: 50, image_ready: false, font_ready: false, js_idle: false + }); + let result = serializeDOM(); + expect(diagnostics.passed).toBe(true); + expect(result.html).toBeDefined(); + expect(result.html).toContain('Ready content'); + }); + + it('readiness returns diagnostics with timing info', async () => { + withExample('

    Diagnostics test

    ', { withShadow: false }); + let diagnostics = await waitForReady({ + preset: 'fast', stability_window_ms: 50, timeout_ms: 3000, network_idle_window_ms: 50, image_ready: false, font_ready: false, js_idle: false + }); + expect(diagnostics).toBeDefined(); + expect(diagnostics.passed).toBe(true); + expect(typeof 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 diagnostics = await waitForReady({ + 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'] + }); + + let result = serializeDOM(); + expect(result.html).toContain('Fully loaded data'); + expect(result.html).not.toContain('Loading...'); + expect(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 diagnostics = await waitForReady({ + 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]'] + }); + + let result = serializeDOM(); + expect(result.html).toContain('Data loaded'); + expect(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 diagnostics = await waitForReady({ + stability_window_ms: 200, + timeout_ms: 1000, + network_idle_window_ms: 50, + image_ready: false, + font_ready: false, + js_idle: false + }); + + clearInterval(interval); + let result = serializeDOM(); + expect(result.html).toBeDefined(); + expect(diagnostics.timed_out).toBe(true); + }); + + it('accepts camelCase config keys (SDK flow)', async () => { + withExample('

    CamelCase test

    ', { withShadow: false }); + let diagnostics = await waitForReady({ + preset: 'fast', + stabilityWindowMs: 50, + networkIdleWindowMs: 50, + timeoutMs: 2000, + imageReady: false, + fontReady: false, + jsIdle: false + }); + let result = serializeDOM(); + expect(result.html).toContain('CamelCase test'); + expect(diagnostics.passed).toBe(true); + }); + + it('readiness with disabled preset skips all checks', async () => { + withExample('

    Disabled

    ', { withShadow: false }); + let diagnostics = await waitForReady({ preset: 'disabled' }); + expect(diagnostics.skipped).toBe(true); + expect(diagnostics.passed).toBe(true); + let result = serializeDOM(); + expect(result.html).toContain('Disabled'); + }); +}); diff --git a/packages/sdk-utils/src/index.js b/packages/sdk-utils/src/index.js index c19eea711..371046c6b 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 { waitForReadyScript, getReadinessConfig, isReadinessDisabled } from './serialize-dom.js'; // Iframe depth constants shared with @percy/dom's serialize-frames. Kept // here so external Percy SDKs (Capybara, Cypress, Playwright, etc.) can @@ -43,7 +44,10 @@ export { getResponsiveWidths, DEFAULT_MAX_IFRAME_DEPTH, HARD_MAX_IFRAME_DEPTH, - clampIframeDepth + clampIframeDepth, + waitForReadyScript, + getReadinessConfig, + isReadinessDisabled }; // 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..0d5e8caef --- /dev/null +++ b/packages/sdk-utils/src/serialize-dom.js @@ -0,0 +1,69 @@ +import percy from './percy-info.js'; + +// Returns the readiness config for a snapshot. +// Priority: per-snapshot options > global percy.config > empty object (triggers balanced default). +// SDKs obtain percy.config via the healthcheck endpoint in isPercyEnabled(). +export function getReadinessConfig(snapshotOptions = {}) { + return snapshotOptions?.readiness || + percy.config?.snapshot?.readiness || + {}; +} + +// Returns true if readiness should be skipped for this snapshot. +export function isReadinessDisabled(snapshotOptions = {}) { + let config = getReadinessConfig(snapshotOptions); + return config?.preset === 'disabled'; +} + +// Returns a JavaScript code string that SDKs evaluate in the browser +// to run readiness checks BEFORE serialize. +// +// This is the READINESS-ONLY call. Serialize stays as a separate sync call. +// The two-call pattern: +// 1. await evaluate(waitForReadyScript(config)) — async, readiness +// 2. evaluate('return PercyDOM.serialize(options)') — sync, unchanged +// +// Usage: +// // Puppeteer/Playwright (page.evaluate auto-awaits): +// await page.evaluate(waitForReadyScript(config)); +// +// // Selenium (executeAsyncScript with callback): +// driver.execute_async_script(waitForReadyScript(config, { callback: true })); +// +// Graceful degradation: +// - If PercyDOM.waitForReady is not available (old CLI): resolves immediately +// - If waitForReady throws: resolves immediately (catch swallows the error) +// - If readiness times out: waitForReady resolves with { timed_out: true } +// +// IMPORTANT: The output is intended for CDP / executeScript / executeAsyncScript channels. +// Do NOT inline this string into HTML — `` sequences in user config would break out +// of a