Skip to content

feat: Readiness gate in PercyDOM.serialize() — works for URL + SDK paths (PER-7348)#2184

Open
Shivanshu-07 wants to merge 9 commits intomasterfrom
feat/PER-7348-readiness-in-serialize
Open

feat: Readiness gate in PercyDOM.serialize() — works for URL + SDK paths (PER-7348)#2184
Shivanshu-07 wants to merge 9 commits intomasterfrom
feat/PER-7348-readiness-in-serialize

Conversation

@Shivanshu-07
Copy link
Copy Markdown
Contributor

@Shivanshu-07 Shivanshu-07 commented Apr 14, 2026

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.js before the PercyDOM.serialize() call. This only worked for the URL-capture path (CLI percy snapshot, Storybook). For the SDK path (Cypress/Selenium/Puppeteer), page.snapshot() is never called — SDKs call PercyDOM.serialize() directly in the test browser. Readiness never ran for SDK snapshots.

How it works now

PercyDOM.serialize({ readiness: { preset: 'strict', notPresentSelectors: ['.skeleton'] } })
  │
  ├── waitForReady(readiness)  ← runs readiness checks FIRST
  │     ├── DOM stability (MutationObserver)
  │     ├── Network idle (PerformanceObserver)
  │     ├── Font ready (document.fonts.ready)
  │     ├── Image ready (above-fold img.complete)
  │     ├── JS idle (Long Task API + requestIdleCallback)
  │     ├── Ready selectors (poll for elements)
  │     └── Not-present selectors (wait for loaders to disappear)
  │
  └── serializeDOMSync(options)  ← THEN serializes the stable DOM

This works identically for:

  • CLI percy snapshot: CLI calls PercyDOM.serialize(options) via page.eval() — readiness runs in the CLI browser
  • Cypress: cy.percySnapshot() → SDK calls PercyDOM.serialize() in test browser — readiness runs there
  • Selenium/Puppeteer: Same — percySnapshot()PercyDOM.serialize() in test browser
  • Storybook: CLI navigates stories and calls serialize — readiness runs before capture

Key design

  • serialize() returns a Promise when readiness is configured, stays sync when not (backward compatible)
  • page.eval() uses awaitPromise: true which handles async automatically
  • Readiness diagnostics attached to result as readiness_diagnostics
  • Per-snapshot override: cy.percySnapshot('name', { readiness: { preset: 'disabled' } })
  • Global config from .percy.yml flows via the options parameter
  • No changes to discovery.js, percy.js, or api.js — serialize handles everything
  • No _fromSDK flag, no domSnapshot dropping, no re-navigation

Files changed

File Change
packages/dom/src/readiness.js NEW — 7 readiness checks with abort mechanism
packages/dom/src/serialize-dom.js Calls waitForReady() before serializing when readiness configured
packages/dom/src/index.js Exports waitForReady
packages/core/src/page.js Passes readiness config to serialize options
packages/core/src/config.js Readiness schema for .percy.yml + per-snapshot
packages/dom/test/readiness.test.js 905 lines — unit tests for all 7 checks
packages/dom/test/serialize-readiness.test.js 163 lines — integration tests for readiness-in-serialize

Test coverage

serialize-readiness.test.js verifies:

  • Sync behavior preserved when no readiness config
  • Sync behavior when preset: 'disabled'
  • Returns Promise when readiness configured
  • Resolves with serialized DOM after readiness passes
  • Attaches readiness_diagnostics to result
  • Waits for skeleton removal before serializing (key E2E scenario)
  • Waits for ready_selectors before serializing
  • Graceful degradation on timeout (still serializes)
  • Per-snapshot override (SDK flow simulation)
  • Backward compatibility for non-readiness options

Related

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>
@Shivanshu-07 Shivanshu-07 requested a review from a team as a code owner April 14, 2026 12:12
Comment thread packages/dom/src/serialize-dom.js Outdated
// 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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this required?, if readiness flag is there call waitForReadiness with a try catch

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/core/src/page.js Outdated
Comment on lines +229 to +233
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 });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should not be required why output of serialize is changing?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Shivanshu-07 and others added 4 commits April 14, 2026 22:39
…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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And SDK publish DOM Snapshot as is and will end up sending a Promise

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 do PercyDOM.serialize(options) without await continue to work unchanged.
  • serializeDOMWithReadiness() — new async variant. Used only by the CLI URL-capture path (page.js uses CDP awaitPromise: true to 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.

Copy link
Copy Markdown
Contributor

@rishigupta1599 rishigupta1599 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: 500

The 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:

  1. A normalization step to convert camelCase options to snake_case before the spread, or
  2. Align the config schema to use snake_case (matching the existing enable_javascript / dom_transformation pattern in serialize-dom.js), or
  3. Support both in the destructuring like serialize-dom.js does: enableJavaScript = options?.enable_javascript

This is likely to bite every user who configures readiness via .percy.yml or per-snapshot SDK options.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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; })); }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/dom/src/readiness.js Outdated
});
}

function checkFontReady() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/core/src/page.js Outdated
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 }));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/dom/src/readiness.js Outdated

const LAYOUT_ATTRIBUTES = new Set([
'class', 'width', 'height', 'display', 'visibility',
'position', 'src', 'href'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/dom/src/readiness.js Outdated

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 */
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Exporting the internal helpers for direct unit testing
  2. Using short timeouts to exercise abort/timeout paths
  3. Reserving istanbul ignore for truly untestable browser APIs only

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Dropped from ~15 function-wide istanbul ignore annotations to narrow, justified ignores on genuinely untestable paths only. Concrete changes:

  1. Exported internal helpers from readiness.js for direct unit testing:

    • isLayoutMutation, hasLayoutStyleChange, parseStyleProps, normalizeOptions, createAbortHandle

    (These are not added to the public index.js surface — tests import from ../src/readiness, matching the pattern used by serialize-frames, serialize-cssom, transform-dom etc.)

  2. 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 like min-/max-/flex/z-index, data-/aria- filtering, <a> vs <link> href handling, camelCase precedence, falsy-value preservation, abort callback ordering, etc.).

  3. Removed function-wide ignores from six check functions (checkDOMStability, checkNetworkIdle, checkImageReady, checkJSIdle, checkReadySelectors, checkNotPresentSelectors). These are now measured through integration tests.

  4. Narrow ignores remain only on genuinely untestable paths, each with a one-line reason:

    • Browser-API availability (document.fonts?.ready, Long Task API try/catch, requestIdleCallback else-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 inside setInterval/MutationObserver callbacks — 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 catch blocks.
    • /* istanbul ignore else */ for the requestIdleCallback availability check (only the fallback branch is ignored, not the whole statement).

Result: readiness.js now shows 100% lines / 100% branches / 100% functions / 100% statements instead of having 6 functions wholly excluded from the report.

Shivanshu-07 and others added 4 commits April 15, 2026 20:51
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants