feat: Readiness gate in PercyDOM.serialize() — works for URL + SDK paths (PER-7348)#2184
feat: Readiness gate in PercyDOM.serialize() — works for URL + SDK paths (PER-7348)#2184Shivanshu-07 wants to merge 9 commits intomasterfrom
Conversation
Readiness checks now run INSIDE PercyDOM.serialize() before DOM
serialization. This is the architecturally correct location because
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. No special flags, no domSnapshot dropping,
no re-navigation — the DOM is captured in-place after stability.
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
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
- Tests: 905-line readiness.test.js + 163-line serialize-readiness.test.js
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| // Serializes a document and returns the resulting DOM string. | ||
| export function serializeDOM(options) { | ||
| // Internal sync serialization — called after readiness (if any) completes. | ||
| function serializeDOMSync(options) { |
There was a problem hiding this comment.
why is this required?, if readiness flag is there call waitForReadiness with a try catch
There was a problem hiding this comment.
Fixed. serializeDOMWithReadiness now wraps the waitForReady call in a try/catch and falls back to synchronous serialization on error (graceful degradation). See packages/dom/src/serialize-dom.js:102-121 on commit c879f34.
| if (result && typeof result.then === 'function') { | ||
| return result.then(r => ({ domSnapshot: r, url: document.URL })); | ||
| } | ||
| return { domSnapshot: result, url: document.URL }; | ||
| }, { enableJavaScript, disableShadowDOM, forceShadowAsLightDOM, domTransformation, reshuffleInvalidTags, ignoreCanvasSerializationErrors, ignoreStyleSheetSerializationErrors, pseudoClassEnabledElements, readiness }); |
There was a problem hiding this comment.
should not be required why output of serialize is changing?
There was a problem hiding this comment.
The page.js change IS required — here's why:
PercyDOM.serialize(options) now returns a Promise when readiness is configured (it calls waitForReady() which is async). When no readiness is configured, it returns a plain object (sync, same as before).
Inside page.eval(), the injected function runs in the browser. If serialize() returns a Promise, that Promise ends up as { domSnapshot: Promise, url: ... }. CDP's awaitPromise: true only awaits the top-level return value — it won't resolve a Promise nested inside an object.
So we need to check: if serialize returned a Promise, .then() it to get the final { domSnapshot, url } shape as the top-level return. If it returned a plain object, wrap it directly. This ensures CDP always receives a resolvable value.
Without this handling, readiness-enabled snapshots would return domSnapshot: [object Promise] instead of the actual HTML.
…plit (PER-7348) Address PR review: removed the serializeDOMSync split. Now serializeDOM checks readiness at the top with try-catch — if readiness is configured, calls waitForReady().then(serialize). If not, falls through to sync serialize directly. Graceful degradation on readiness failure. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…s (PER-7348) The catch block (line 104) only fires if waitForReady throws synchronously, which can't happen since it returns a Promise. Defensive code for graceful degradation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…PER-7348) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
page.js now passes readiness to PercyDOM.serialize() options. The test that asserts the exact options object needs to include readiness: undefined when no readiness config is provided. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| // before serializing. This works identically for both URL-based captures | ||
| // (CLI percy snapshot, Storybook) and SDK captures (Cypress, Selenium, | ||
| // Puppeteer) since serialize is the common entry point for both paths. | ||
| export function serializeDOM(options) { |
There was a problem hiding this comment.
function was sync till now now we are making it async for readiness flow, does all SDK handle that.
In current case if for any reason DOM serialization breaks user will not even get failure as there test suite will move forward
There was a problem hiding this comment.
And SDK publish DOM Snapshot as is and will end up sending a Promise
There was a problem hiding this comment.
Valid concern — fixed. Split serializeDOM into two functions:
serializeDOM()— remains synchronous and backward-compatible. All existing SDKs (@percy/cypress,@percy/puppeteer,@percy/selenium-webdriver) that doPercyDOM.serialize(options)withoutawaitcontinue to work unchanged.serializeDOMWithReadiness()— new async variant. Used only by the CLI URL-capture path (page.js uses CDPawaitPromise: trueto await it) and by new SDK versions that opt into readiness-gated capture.
If readiness fails internally, the function falls back to sync serialization (graceful degradation) rather than breaking the capture. See packages/dom/src/serialize-dom.js:93-121.
rishigupta1599
left a comment
There was a problem hiding this comment.
Review Summary
The architecture is solid -- moving readiness into PercyDOM.serialize() is the right call for URL + SDK parity. Good test coverage (1000+ lines) and clean abort/cleanup logic.
However, there are several issues that need addressing before merge, ranging from a config naming mismatch bug that will silently break user overrides, to missing abort propagation, and a coupling concern in the JS idle check. See inline comments.
|
|
||
| let preset = PRESETS[presetName] || PRESETS.balanced; | ||
| let config = { ...preset, ...options }; | ||
| let effectiveTimeout = config.max_timeout_ms ? Math.min(config.timeout_ms, config.max_timeout_ms) : config.timeout_ms; |
There was a problem hiding this comment.
Bug: camelCase / snake_case mismatch breaks user config overrides
The config schema in config.js defines properties in camelCase (stabilityWindowMs, networkIdleWindowMs, timeoutMs), but readiness.js presets and this spread use snake_case (stability_window_ms, etc.).
When a user sets .percy.yml:
snapshot:
readiness:
preset: strict
stabilityWindowMs: 500The spread { ...preset, ...options } sets stabilityWindowMs: 500 but the code reads config.stability_window_ms (from the preset default), silently ignoring the override.
You need either:
- A normalization step to convert camelCase options to snake_case before the spread, or
- Align the config schema to use snake_case (matching the existing
enable_javascript/dom_transformationpattern inserialize-dom.js), or - Support both in the destructuring like
serialize-dom.jsdoes:enableJavaScript = options?.enable_javascript
This is likely to bite every user who configures readiness via .percy.yml or per-snapshot SDK options.
There was a problem hiding this comment.
Fixed. Added normalizeOptions() helper in packages/dom/src/readiness.js that accepts either camelCase (what the schema defines) or snake_case, and maps both to the snake_case keys used internally.
The merge also now only overrides preset defaults for user keys whose value is not undefined, so a partial override no longer wipes other preset values:
let userOptions = normalizeOptions(options);
let config = { ...preset };
for (let key of Object.keys(userOptions)) {
if (userOptions[key] !== undefined) config[key] = userOptions[key];
}Added a direct unit test covering both naming conventions, precedence when both are passed, and falsy-value (0, false) preservation.
| if (config.font_ready !== false) { expected.push('font_ready'); checks.push(checkFontReady().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'); checks.push(checkJSIdle(config.stability_window_ms, 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; })); } |
There was a problem hiding this comment.
Coupling: checkJSIdle reuses stability_window_ms instead of its own config
checks.push(checkJSIdle(config.stability_window_ms, aborted)...)JS idle window and DOM stability window are conceptually different. With the strict preset (stability_window_ms: 1000), the JS idle check requires 1 full second of no long tasks before confirming idle -- that's very aggressive and will cause unnecessary timeouts on pages with normal JS activity.
Consider adding a dedicated js_idle_window_ms config (defaulting to something like 200ms) or at minimum document why these are intentionally coupled.
There was a problem hiding this comment.
Fixed. Added a dedicated js_idle_window_ms config (camelCase jsIdleWindowMs in the schema) that is independent of stability_window_ms.
Preset defaults now decouple them sensibly:
| Preset | stability_window_ms | js_idle_window_ms |
|---|---|---|
| fast | 100 | 100 |
| balanced | 300 | 300 |
| strict | 1000 | 500 |
With strict, DOM-stability still waits 1000ms but the JS idle check only needs 500ms of no long tasks — no more 1-full-second timeouts on pages with normal JS activity.
If a user passes a custom config that predates this option, runAllChecks falls back to stability_window_ms for backward compat. Added integration tests proving the decoupling works and the fallback still works.
| }); | ||
| } | ||
|
|
||
| function checkFontReady() { |
There was a problem hiding this comment.
Missing abort propagation in checkFontReady
Unlike every other check function, checkFontReady doesn't accept or honor the aborted signal. It has a hardcoded 5s internal timeout that runs independently of the orchestrator's effectiveTimeout.
If a user sets timeoutMs: 2000 and fonts take 3s, the overall timeout fires at 2s and aborts all other checks -- but the font check's internal Promise.race keeps the 5s timer alive. While it won't block (the orchestrator moves on), it leaves a dangling timer.
Accept aborted and clear the internal timeout on abort, consistent with the other checks.
There was a problem hiding this comment.
Fixed. checkFontReady now accepts an aborted handle and clears the 5s font timer on abort:
function checkFontReady(aborted) {
let start = performance.now();
if (!document.fonts?.ready) return Promise.resolve({ passed: true, duration_ms: 0, skipped: true });
let fontTimer;
let result = Promise.race([
document.fonts.ready.then(() => ({ passed: true, duration_ms: Math.round(performance.now() - start) })),
new Promise(r => { fontTimer = setTimeout(() => r({ passed: false, duration_ms: 5000, timed_out: true }), 5000); })
]);
if (aborted) aborted.onAbort(() => { if (fontTimer) clearTimeout(fontTimer); });
return result;
}Also updated runAllChecks to pass the abort handle through. Matches the abort pattern used by every other check.
| let result = PercyDOM.serialize(options); | ||
| // serialize may return a Promise when readiness is configured | ||
| if (result && typeof result.then === 'function') { | ||
| return result.then(r => ({ domSnapshot: r, url: document.URL })); |
There was a problem hiding this comment.
Simplification: the manual thenable check is redundant
Since eval() already uses awaitPromise: true (line 143), CDP will automatically await any Promise returned from the function. You can simplify this to:
let capture = await this.eval(async (_, options) => {
let result = await PercyDOM.serialize(options);
return { domSnapshot: result, url: document.URL };
}, { ...opts, readiness });await on a non-thenable (sync path) just returns the value directly, so backward compat is preserved. This avoids the manual duck-typing of .then.
There was a problem hiding this comment.
Fixed. Simplified to use native await — CDP awaitPromise: true auto-awaits the returned Promise, and await on the sync path is a no-op:
let capture = await this.eval(async (_, options) => {
let fn = (PercyDOM.serializeDOMWithReadiness || PercyDOM.serialize);
let domSnapshot = await fn(options);
return { domSnapshot, url: document.URL };
}, { ...opts, readiness });The PercyDOM.serializeDOMWithReadiness || PercyDOM.serialize fallback keeps page.js compatible with older injected dom bundles that don't yet export the new async variant.
| return new Promise(resolve => { | ||
| let startTime = performance.now(); | ||
| let lastCount = performance.getEntriesByType('resource').length; | ||
| let timer = null; |
There was a problem hiding this comment.
Performance: performance.getEntriesByType('resource') called every 50ms
This allocates a new array of all resource entries on every tick. On resource-heavy pages (hundreds of images, scripts, stylesheets), this is expensive -- especially since you only need the count.
Consider using PerformanceObserver for 'resource' entries (like you do for longtask in checkJSIdle) to incrementally track count changes, instead of polling the full entry list every 50ms.
There was a problem hiding this comment.
Fixed. Replaced the 50ms polling of performance.getEntriesByType('resource') with a PerformanceObserver that subscribes incrementally to 'resource' entries:
try {
observer = new PerformanceObserver(list => {
if (aborted.value) return;
if (list.getEntries().length > 0) resetIdleTimer();
});
observer.observe({ type: 'resource', buffered: false });
} catch (e) {
// Older browser — fall back to polling
...
}No more scanning the entire resource list every tick — the observer fires only when a new resource entry is added, matching the pattern already used in checkJSIdle for longtasks. The polling path is preserved as a fallback for very old browsers that lack PerformanceObserver.
| additionalProperties: false, | ||
| properties: { | ||
| preset: { type: 'string', enum: ['balanced', 'strict', 'fast', 'disabled'] }, | ||
| stabilityWindowMs: { type: 'integer', minimum: 50, maximum: 30000 }, |
There was a problem hiding this comment.
Naming convention inconsistency with the rest of the config schema
The readiness properties use camelCase (stabilityWindowMs, networkIdleWindowMs, timeoutMs) but the internal readiness implementation uses snake_case, and other Percy config options like enable_javascript, dom_transformation, reshuffle_invalid_tags use snake_case in the schema.
Consider aligning to snake_case (stability_window_ms, network_idle_window_ms, timeout_ms) for consistency with the rest of the Percy config, or add explicit camelCase to snake_case mapping in readiness.js.
There was a problem hiding this comment.
I verified this and I think the premise here might be inverted. Grepping the existing schema in packages/core/src/config.js:
enableJavaScript, cliEnableJavaScript, disableShadowDOM,
forceShadowAsLightDOM, enableLayout, domTransformation, reshuffleInvalidTags,
deferUploads, useSystemProxy, skipBaseBuild, browserName, browserVersion,
osVersion, deviceName, percyBrowserCustomName, diffSensitivity,
imageIgnoreThreshold, carouselsEnabled, bannersEnabled, adsEnabled,
minHeight, percyCSS, scopeOptions, ignoreRegionSelectors, freezeAnimation,
freezeAnimatedImage, responsiveSnapshotCapture, testCase, thTestCaseExecutionId,
fullPage ...
Every existing snapshot option in the schema is already camelCase — there are no enable_javascript / dom_transformation / reshuffle_invalid_tags entries in config.js. So the readiness schema (stabilityWindowMs, timeoutMs, etc.) is actually consistent with the rest of the file.
The snake_case you're seeing in readiness.js is the internal naming used after normalization (matches the diagnostic output keys like stability_window_ms, network_idle_window_ms, which are the payload contract with the backend). The normalizeOptions() helper bridges the two, and it accepts both forms so direct snake_case overrides still work if anyone is using them.
Happy to rename if you'd still prefer snake_case for the user-facing schema, but the current naming was chosen specifically to match the surrounding camelCase conventions.
|
|
||
| const LAYOUT_ATTRIBUTES = new Set([ | ||
| 'class', 'width', 'height', 'display', 'visibility', | ||
| 'position', 'src', 'href' |
There was a problem hiding this comment.
Questionable: href in LAYOUT_ATTRIBUTES
Changing href on an <a> tag doesn't affect layout. This is only layout-relevant for <link> stylesheet elements. As written, any JS that updates an anchor's href will be incorrectly classified as a layout mutation, resetting the stability timer.
Consider either removing href or scoping it: check mutation.target.tagName === 'LINK' before treating an href change as layout-affecting.
There was a problem hiding this comment.
Fixed. href removed from the generic LAYOUT_ATTRIBUTES set, replaced with a scoped check in isLayoutMutation:
// href is only layout-affecting on <link> elements (stylesheets).
// On <a> tags changing href is a no-op for layout.
if (attr === 'href') return mutation.target.tagName === 'LINK';href is still in the MutationObserver attributeFilter so the observer fires — the classifier just returns false for <a> tags now, so it doesn't reset the stability timer.
Added direct unit tests for both cases (<a href> returns false, <link href> returns true) in the new readiness-helpers test file.
|
|
||
| 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)/; | ||
|
|
||
| /* istanbul ignore next: branches constrained by MutationObserver attributeFilter config */ |
There was a problem hiding this comment.
Excessive istanbul ignore coverage suppression
There are ~15 /* istanbul ignore next */ annotations across this 392-line file, covering core logic paths (abort branches, network idle, image ready, JS idle, selector polling, style parsing, etc.).
This undermines the coverage story in the PR description. Many of these are testable -- e.g., abort branches can be triggered by setting very short timeouts, the style parsing can be tested with direct unit tests of hasLayoutStyleChange / parseStyleProps if exported.
Consider:
- Exporting the internal helpers for direct unit testing
- Using short timeouts to exercise abort/timeout paths
- Reserving
istanbul ignorefor truly untestable browser APIs only
There was a problem hiding this comment.
Fixed. Dropped from ~15 function-wide istanbul ignore annotations to narrow, justified ignores on genuinely untestable paths only. Concrete changes:
-
Exported internal helpers from
readiness.jsfor direct unit testing:isLayoutMutation,hasLayoutStyleChange,parseStyleProps,normalizeOptions,createAbortHandle
(These are not added to the public
index.jssurface — tests import from../src/readiness, matching the pattern used byserialize-frames,serialize-cssom,transform-dometc.) -
Added
packages/dom/test/readiness-helpers.test.js— 36 new unit tests covering every branch of the exported helpers (empty inputs, multi-decl parsing, prefix-matched layout props likemin-/max-/flex/z-index,data-/aria-filtering,<a>vs<link>href handling, camelCase precedence, falsy-value preservation, abort callback ordering, etc.). -
Removed function-wide ignores from six check functions (
checkDOMStability,checkNetworkIdle,checkImageReady,checkJSIdle,checkReadySelectors,checkNotPresentSelectors). These are now measured through integration tests. -
Narrow ignores remain only on genuinely untestable paths, each with a one-line reason:
- Browser-API availability (
document.fonts?.ready, Long Task APItry/catch,requestIdleCallbackelse-branch) — these are always available in Chrome/Firefox; fallbacks are for older browsers. - Font 5s timeout race — impractical to wait for in tests.
- Defensive
if (aborted.value)guards insidesetInterval/MutationObservercallbacks —abort()clears the interval/disconnects the observer synchronously, so the check is dead code in practice. - Empty-selector early returns — orchestrator only calls those checks when
selectors.length > 0. - Error-safety-net
catchblocks. /* istanbul ignore else */for therequestIdleCallbackavailability check (only the fallback branch is ignored, not the whole statement).
- Browser-API availability (
Result: readiness.js now shows 100% lines / 100% branches / 100% functions / 100% statements instead of having 6 functions wholly excluded from the report.
Addresses reviewer feedback from rishigupta1599:
1. Split serializeDOM into sync + async variants (backward compat):
- serializeDOM() stays SYNC — existing SDKs (@percy/cypress,
@percy/puppeteer, @percy/selenium-webdriver) call this without
await. Making it async would break them (they'd post a Promise
object as domSnapshot to CLI).
- serializeDOMWithReadiness() is the new async opt-in variant used
by the URL-capture path via page.eval + CDP awaitPromise:true.
- New export added in packages/dom/src/index.js.
2. page.js: simplified to use native await. Removed manual thenable
check — CDP's awaitPromise:true auto-awaits the returned Promise.
Added fallback to PercyDOM.serialize if older bundle is injected.
3. readiness.js: normalize camelCase config keys to snake_case.
Users configure via .percy.yml camelCase (stabilityWindowMs), but
internal checks use snake_case (stability_window_ms). Without
normalization, user overrides silently failed and presets always
won. Added normalizeOptions() helper that accepts either form and
merges only defined values so undefined keys don't overwrite
preset defaults.
4. readiness.js: scope href mutation-filtering to <link> elements.
Changing href on <a> tags is a navigation target change, not
layout-affecting, so it shouldn't count as a DOM mutation. Only
<link rel="stylesheet"> href changes are layout-affecting (they
load a new stylesheet). Removed href from LAYOUT_ATTRIBUTES set,
added conditional tagName === 'LINK' check in isLayoutMutation,
kept 'href' in attributeFilter so observer still sees link loads.
5. readiness.js: propagate abort signal to checkFontReady. The
hardcoded 5s font timer previously did not honor abort, so a
timed-out readiness race would leak the timer. Added aborted
parameter that clears fontTimer on abort, matching the other
checks' abort-cleanup pattern.
6. Tests: updated readiness.test.js to validate the corrected
href behavior (<a> href is NOT layout-affecting, <link> href IS).
Rewrote serialize-readiness.test.js to cover both the sync
serializeDOM and async serializeDOMWithReadiness paths, and
added a test for camelCase config normalization (SDK flow).
All 540 @percy/dom tests pass locally. Lint passes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…PER-7348) Addresses PR #2184 review comment #3086822527 (excessive istanbul ignores). Previously readiness.js had ~15 /* istanbul ignore next */ annotations wrapping ENTIRE functions — hiding core logic like abort branches, style parsing, and mutation filtering from the coverage story entirely. That undermined the coverage claim in the PR description. Changes: 1. Export internal helpers for direct unit testing: - isLayoutMutation — mutation-record classification logic - hasLayoutStyleChange — inline style diff detection - parseStyleProps — style declaration parser - normalizeOptions — camelCase -> snake_case config normalization - createAbortHandle — abort controller for browser context These are not added to the public `index.js` surface; tests import them directly from `../src/readiness`, matching the pattern already used by serialize-frames, serialize-cssom, etc. 2. Add packages/dom/test/readiness-helpers.test.js — 35 new direct unit tests that cover: - parseStyleProps: empty input, multi-decl, whitespace, case, missing colon, empty/whitespace-only keys, duplicate keys - hasLayoutStyleChange: identical, non-layout, layout changes, add/remove, prefix-matched props (min-, max-, flex, z-index) - isLayoutMutation: childList, data-/aria-, style layout vs non-layout, href on <a> vs <link>, known layout attrs, unknown attrs, null/undefined oldValue fallback - normalizeOptions: defaults, camelCase -> snake_case, snake_case pass-through, camelCase precedence, falsy-value preservation - createAbortHandle: initial state, callback registration, abort invokes all callbacks, idempotent abort, post-abort callback orphaning 3. Remove function-wide istanbul ignores from six check functions (checkDOMStability, checkNetworkIdle, checkImageReady, checkJSIdle, checkReadySelectors, checkNotPresentSelectors). These are now exercised by integration tests. 4. Replace them with NARROW ignores only on genuinely untestable paths, each with a specific reason: - Browser API availability (document.fonts, PerformanceObserver, requestIdleCallback) — these are always available in Chrome/ Firefox; the fallback branches are for older browsers. - Font 5s timeout — impractical to wait in tests. - Long Task API callback body — fires only on CPU-heavy >50ms tasks; not reliably triggered in test environment. - Defensive `if (aborted.value)` guards inside setInterval/ MutationObserver callbacks — abort clears the interval/ disconnects the observer synchronously, so these checks are dead in practice. - Defensive `if (timer)` guards — timer is always set before cleanup can fire. - Empty-selector early returns — orchestrator only calls these checks when selectors.length > 0. - Error safety-net catch block. 5. Use `/* istanbul ignore else */` (not `/* istanbul ignore next */`) for the requestIdleCallback availability check so only the fallback branch is ignored, keeping the common path covered. Result: readiness.js coverage: 100% lines, 100% branches, 100% functions, 100% statements. Before: function-wide ignores masked 6 entire check functions. After: all core logic is measured; ignores are narrow, justified, and each carries a one-line reason comment. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses two substantive review comments that were not fixed in the
prior commits:
1. Comment #3086822493 — checkJSIdle coupling with stability_window_ms
Added a dedicated `js_idle_window_ms` config (camelCase
`jsIdleWindowMs` in the schema, snake_case internally). JS idle
and DOM stability now use independent windows:
fast: stability 100 | js_idle 100
balanced: stability 300 | js_idle 300
strict: stability 1000 | js_idle 500
With the `strict` preset the main-thread idle check no longer needs
a full 1s of no long tasks — prevents unnecessary timeouts on pages
with normal JS activity while still getting 1s of DOM stability.
- Added `jsIdleWindowMs` to the readiness schema in config.js
- Added to normalizeOptions() and PRESETS
- runAllChecks falls back to stability_window_ms when
js_idle_window_ms is not set (backward compat for custom configs
that predate this option)
- Integration tests prove the decoupling works and fallback works
2. Comment #3086822510 — checkNetworkIdle polling performance
Replaced the 50ms polling of performance.getEntriesByType('resource')
with a PerformanceObserver subscribed to the 'resource' entry type.
The observer fires incrementally when a new resource entry is
added, so there's no per-tick allocation + scan of the full
resource list on resource-heavy pages (hundreds of images/scripts/
stylesheets).
Pattern matches the existing longtask observer in checkJSIdle.
Polling path is preserved as a catch-block fallback for very old
browsers without PerformanceObserver support.
Coverage: readiness.js stays at 100% lines/branches/functions/
statements. All @percy/dom tests pass locally.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The js_idle_window_ms decoupling test used `performance.now()` directly, which is not in eslint's global scope for test files. Simplified the test to use the returned `duration_ms` diagnostics instead. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
Integrates the readiness gate into
PercyDOM.serialize()itself — the only location that works for both URL-based and SDK-based snapshot paths.Why this matters
The previous approach (PR #2172) put readiness in
page.jsbefore thePercyDOM.serialize()call. This only worked for the URL-capture path (CLIpercy snapshot, Storybook). For the SDK path (Cypress/Selenium/Puppeteer),page.snapshot()is never called — SDKs callPercyDOM.serialize()directly in the test browser. Readiness never ran for SDK snapshots.How it works now
This works identically for:
percy snapshot: CLI callsPercyDOM.serialize(options)viapage.eval()— readiness runs in the CLI browsercy.percySnapshot()→ SDK callsPercyDOM.serialize()in test browser — readiness runs therepercySnapshot()→PercyDOM.serialize()in test browserKey design
serialize()returns a Promise when readiness is configured, stays sync when not (backward compatible)page.eval()usesawaitPromise: truewhich handles async automaticallyreadiness_diagnosticscy.percySnapshot('name', { readiness: { preset: 'disabled' } }).percy.ymlflows via the options parameter_fromSDKflag, no domSnapshot dropping, no re-navigationFiles changed
packages/dom/src/readiness.jspackages/dom/src/serialize-dom.jswaitForReady()before serializing when readiness configuredpackages/dom/src/index.jswaitForReadypackages/core/src/page.jsreadinessconfig to serialize optionspackages/core/src/config.js.percy.yml+ per-snapshotpackages/dom/test/readiness.test.jspackages/dom/test/serialize-readiness.test.jsTest coverage
serialize-readiness.test.js verifies:
preset: 'disabled'readiness_diagnosticsto resultRelated