Skip to content

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

Open
Shivanshu-07 wants to merge 20 commits into
masterfrom
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 20 commits into
masterfrom
feat/PER-7348-readiness-in-serialize

Conversation

@Shivanshu-07
Copy link
Copy Markdown
Contributor

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

Summary

Implements readiness-gated snapshot capture for Percy (PER-7348). Readiness checks ensure pages are stable before DOM serialization — no skeleton screens, loaded fonts/images, idle network, settled JavaScript.

Architecture: Two-Call Pattern

Readiness and serialization are two separate calls, not one combined async function:

1. PercyDOM.waitForReady(config)  — async, waits for page stability
2. PercyDOM.serialize(options)    — sync, captures the stable DOM

This keeps serialize() always synchronous (zero SDK breakage) while isolating the async readiness into its own independent step. Both the CLI URL-capture path and SDKs use the same pattern.

What's included in this PR (CLI-side)

@percy/dom — readiness checks (packages/dom/src/readiness.js)

  • 7 parallel checks: DOM stability (MutationObserver), network idle (PerformanceObserver), font ready, image ready, JS idle (Long Task API + rIC + double-rAF), ready selectors, not-present selectors
  • 3 presets: balanced (default), strict, fast, disabled
  • Dedicated js_idle_window_ms decoupled from stability_window_ms
  • normalizeOptions() for camelCase/snake_case config compatibility
  • Abort propagation to all checks on timeout
  • 100% test coverage (lines/branches/functions/statements)

@percy/dom — serialization (packages/dom/src/serialize-dom.js)

  • serializeDOM() — single sync function. No async variant needed.
  • waitForReady() exported separately for the readiness call

@percy/core — CLI URL-capture path (packages/core/src/page.js)

  • Two-call pattern: waitForReady(config) then serialize(options) as separate eval calls
  • Readiness config from per-snapshot options or global .percy.yml
  • Graceful degradation: if readiness fails or times out, serialize still runs

@percy/core — config schema (packages/core/src/config.js)

  • readiness schema: preset, stabilityWindowMs, jsIdleWindowMs, networkIdleWindowMs, timeoutMs, maxTimeoutMs, domStability, imageReady, fontReady, jsIdle, readySelectors, notPresentSelectors
  • readySelectors / notPresentSelectors accept either a CSS string or an explicit { css } / { xpath } object form. Bare strings beginning with /, //, ./, (/, (./ are auto-detected as XPath.
  • readiness_diagnostics allowed in domSnapshot schema (no more validation warnings)
  • CLI logs readiness diagnostics at debug/warn level

@percy/sdk-utils — helpers for SDK adoption (packages/sdk-utils/src/serialize-dom.js)

  • waitForReadyScript(config, { callback }) — JS code string for the readiness call
    • Default variant: for page.evaluate() (Puppeteer/Playwright auto-await)
    • Callback variant: for executeAsyncScript (Selenium/Nightwatch/WebdriverIO)
  • getReadinessConfig(options) — extracts readiness config from per-snapshot or global percy.config
  • isReadinessDisabled(options) — quick check for preset: disabled

SDK Rollout (separate PRs per SDK)

After this CLI PR merges and a new CLI version is published, each SDK adds a waitForReady() call before its existing serialize() call. The serialize() call itself stays unchanged.

How SDKs adopt readiness

The two-call pattern for SDKs:

Step 1: Call PercyDOM.waitForReady(config)   — async, page stabilizes
Step 2: Call PercyDOM.serialize(options)      — sync, UNCHANGED from today

Pattern A: Puppeteer / Playwright SDKs (page.evaluate auto-awaits)

// NEW — readiness (async, auto-awaited by page.evaluate)
let readinessConfig = utils.getReadinessConfig(options);
if (!utils.isReadinessDisabled(options)) {
  await page.evaluate(async (config) => {
    if (typeof PercyDOM?.waitForReady === 'function') {
      return PercyDOM.waitForReady(config);
    }
  }, readinessConfig).catch(() => {});
}

// UNCHANGED — serialize (sync)
let domSnapshot = await page.evaluate((opts) => PercyDOM.serialize(opts), serializeOptions);

Applies to: @percy/puppeteer, @percy/playwright, percy-playwright-python, percy-playwright-java, percy-playwright-dotnet

Pattern B: Selenium SDKs (executeAsyncScript for readiness, executeScript for serialize)

# NEW — readiness (async, uses executeAsyncScript with callback)
readiness_config = percy.config.get('snapshot', {}).get('readiness', {})
if readiness_config.get('preset') != 'disabled':
    try:
        driver.execute_async_script('''
            var config = arguments[0];
            var done = arguments[arguments.length - 1];
            if (typeof PercyDOM !== 'undefined' && typeof PercyDOM.waitForReady === 'function') {
                PercyDOM.waitForReady(config).then(function() { done(); }).catch(function() { done(); });
            } else { done(); }
        ''', readiness_config or {})
    except Exception:
        pass

# UNCHANGED — serialize (sync, exactly the same as today)
dom = driver.execute_script('return PercyDOM.serialize({})'.format(json.dumps(opts)))

Applies to: percy-selenium-python, percy-selenium-java, percy-selenium-ruby, percy-selenium-dotnet, @percy/selenium-js, percy-capybara, @percy/webdriverio

Pattern C: In-browser SDKs (Cypress, Ember)

// NEW — readiness (await the Promise directly)
if (typeof PercyDOM?.waitForReady === 'function') {
  await PercyDOM.waitForReady(readinessConfig).catch(() => {});
}

// UNCHANGED — serialize (sync)
let domSnapshot = PercyDOM.serialize(options);

Applies to: @percy/cypress, @percy/ember

Pattern D: Other JS SDKs

  • @percy/nightwatch: browser.executeAsync() for readiness, browser.execute() unchanged
  • @percy/testcafe: await t.eval(async () => PercyDOM.waitForReady(config)) before serialize
  • @percy/storybook: Uses CLI page.eval() — already covered by this PR

Backward compatibility

Scenario Behavior
Old SDK + new CLI SDK never calls waitForReady. serialize() works as today. No readiness.
New SDK + old CLI SDK checks typeof PercyDOM.waitForReady === 'function' — undefined on old CLI. Skips readiness. serialize() works.
New SDK + new CLI waitForReady() runs then page stabilizes then serialize() captures stable DOM.
waitForReady throws SDK catches error, proceeds to serialize(). Snapshot captured without readiness.
waitForReady times out Resolves with { timed_out: true }. SDK proceeds to serialize().

SDK change matrix

SDK Repo Change needed
@percy/puppeteer percy/percy-puppeteer Pattern A
@percy/playwright percy/percy-playwright Pattern A
percy-playwright-python percy/percy-playwright-python Pattern A
percy-playwright-java percy/percy-playwright-java Pattern A
percy-playwright-dotnet percy/percy-playwright-dotnet Pattern A
@percy/cypress percy/percy-cypress Pattern C
@percy/ember percy/percy-ember Pattern C
@percy/nightwatch percy/percy-nightwatch Pattern D
@percy/testcafe percy/percy-testcafe Pattern D
@percy/selenium-js percy/percy-selenium-js Pattern B
@percy/webdriverio percy/percy-webdriverio Pattern B
percy-selenium-python percy/percy-selenium-python Pattern B
percy-selenium-java percy/percy-selenium-java Pattern B
percy-selenium-ruby percy/percy-selenium-ruby Pattern B
percy-selenium-dotnet percy/percy-selenium-dotnet Pattern B
percy-capybara percy/percy-capybara Pattern B
@percy/storybook percy/percy-storybook No change (uses CLI page.eval)

Test plan

  • @percy/dom tests: 616 pass, 100% coverage
  • @percy/core tests: 684 specs, no new failures (27 pre-existing env failures: Chromium install mocks, runDoctor, AggregateError)
  • @percy/config tests: 82 pass
  • Lint passes
  • serializeDOM() is always sync (backward compat test)
  • waitForReady() with disabled preset skips all checks
  • Readiness waits for DOM stability (skeleton removal test)
  • Readiness waits for ready selectors
  • Readiness times out gracefully (serialize still happens)
  • camelCase config normalization (SDK flow)
  • Direct unit tests for helpers: isLayoutMutation, hasLayoutStyleChange, parseStyleProps, normalizeOptions, createAbortHandle

Configuration

# .percy.yml
snapshot:
  readiness:
    preset: balanced  # balanced | strict | fast | disabled
    stabilityWindowMs: 300
    jsIdleWindowMs: 300
    networkIdleWindowMs: 200
    timeoutMs: 10000
    maxTimeoutMs: 25000  # cap for Selenium async timeout compat
    domStability: true   # set false to fully disable the MutationObserver check
    imageReady: true
    fontReady: true
    jsIdle: true
    readySelectors:
      - '.content-loaded'                    # CSS string
      - '//section[@id="ready" and contains(@class,"loaded")]'  # XPath (auto-detected)
      - { xpath: '//div[@id="root"]' }       # explicit XPath object form
      - { css: '.weird /selector' }          # explicit CSS object form
    notPresentSelectors: ['.skeleton']

Kill switches

Scenario Setting
Disable everything (revert to pre-readiness behavior) preset: disabled
Keep gating on JS idle / images / fonts / selectors but skip the MutationObserver (use this on heavy-DOM pages where the observer drives CPU/memory pressure) domStability: false
Skip individual checks imageReady: false, fontReady: false, jsIdle: false

Generated with Claude Code

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
Comment thread packages/core/src/page.js Outdated
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>
Comment thread packages/dom/src/serialize-dom.js
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.

Comment thread packages/dom/src/readiness.js
Comment thread packages/dom/src/readiness.js
Comment thread packages/dom/src/readiness.js Outdated
Comment thread packages/core/src/page.js Outdated
Comment thread packages/dom/src/readiness.js
Comment thread packages/core/src/config.js
Comment thread packages/dom/src/readiness.js Outdated
Comment thread packages/dom/src/readiness.js Outdated
Shivanshu-07 and others added 7 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>
…er (PER-7348)

Addresses gaps identified during the readiness SDK rollout planning
that would block SDK adoption of readiness-gated capture:

1. Add `readiness_diagnostics` to domSnapshot schema (config.js)

   The domSnapshot object schema uses `unevaluatedProperties: false`,
   which strips unknown properties and logs a warning. When an SDK
   submits a snapshot with readiness enabled, `readiness_diagnostics`
   (attached by serializeDOMWithReadiness) would be silently removed.
   Now it's an allowed `type: 'object'` property on the schema.

2. Add `maxTimeoutMs` to readiness config schema (config.js)

   Already supported at runtime in readiness.js but not exposed in
   the .percy.yml validation schema. Selenium's `executeAsyncScript`
   has a default 30s timeout that collides with the `strict` preset's
   30s readiness timeout. `maxTimeoutMs` lets users cap readiness
   below the WebDriver async script timeout to prevent hard failures.

3. Add CLI-side logging for readiness diagnostics (snapshot.js)

   When an SDK-submitted snapshot includes `readiness_diagnostics`,
   the CLI now logs:
   - Debug: "Readiness passed in Xms (preset: Y)"
   - Warn: "Readiness timed out after Xms (preset: Y)"

   Essential for debugging readiness behavior during SDK rollout.

4. Add `serializeScript` and `buildSerializeOptions` to @percy/sdk-utils

   New helpers that JS SDKs can use instead of hand-building serialize
   script strings:

   - `buildSerializeOptions(opts)` — merges per-snapshot readiness
     overrides with global readiness config from percy.config
   - `serializeScript(opts)` — returns a JS code string that calls
     serializeDOMWithReadiness with fallback to serialize for backward
     compat. Supports `{ callback: true }` for executeAsyncScript.

   The fallback pattern `(PercyDOM.serializeDOMWithReadiness ||
   PercyDOM.serialize)` ensures new SDKs work with old CLIs that
   don't export the async variant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ER-7348)

Adds sdk-utils helpers for the separate-call readiness pattern where
SDKs call waitForReady() BEFORE serialize() as two independent steps.
This keeps serialize() sync (zero SDK breakage) while isolating the
async readiness into its own call.

New exports from @percy/sdk-utils:

- waitForReadyScript(config, { callback }) — Returns JS code string
  that calls PercyDOM.waitForReady() with graceful fallback when
  unavailable (old CLI) or on error. Supports { callback: true }
  for Selenium executeAsyncScript pattern.

- getReadinessConfig(snapshotOptions) — Returns readiness config from
  per-snapshot options > global percy.config > empty object. Empty
  object triggers the balanced preset default in waitForReady().

- isReadinessDisabled(snapshotOptions) — Returns true when readiness
  preset is 'disabled'. SDKs use this to skip the waitForReady call.

The two-call SDK pattern:
  1. await evaluate(waitForReadyScript(config))  — async, readiness
  2. evaluate('return PercyDOM.serialize(opts)')  — sync, unchanged

Readiness is ON by default: getReadinessConfig returns {} when no
config exists, and waitForReady({}) defaults to the balanced preset.
Users opt out with readiness: { preset: disabled }.

The prior serializeScript() (combined readiness + serialize) is kept
for backward compatibility but the new pattern is preferred.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…-7348)

Simplifies the readiness architecture by removing the combined
serializeDOMWithReadiness function in favor of the two-call pattern:
  1. waitForReady(config)  — async, page stabilizes
  2. serialize(options)    — sync, captures stable DOM

This is cleaner because:
- serialize() is always sync — zero breakage for any SDK
- The async part (waitForReady) is isolated as a separate call
- Both functions already exist and are exported
- No need for a combined async wrapper

Changes:

1. packages/dom/src/serialize-dom.js:
   - Removed serializeDOMWithReadiness() — no longer needed
   - Removed unused waitForReady import
   - serializeDOM() stays as the single serialize function

2. packages/dom/src/index.js:
   - Removed serializeDOMWithReadiness export

3. packages/core/src/page.js:
   - Split into two separate eval calls matching the SDK pattern:
     * First eval: waitForReady(readiness) — async, with graceful catch
     * Second eval: PercyDOM.serialize(options) — sync, unchanged
   - Removed readiness from serialize options (it's handled separately)
   - Same pattern as what SDKs will use, ensuring consistency

4. packages/sdk-utils/src/serialize-dom.js:
   - Removed legacy serializeScript() that referenced the removed function
   - Removed buildSerializeOptions() — no longer needed
   - Kept: waitForReadyScript(), getReadinessConfig(), isReadinessDisabled()

5. Tests updated to use waitForReady + serializeDOM two-call pattern
   instead of the removed serializeDOMWithReadiness

Net result: -129 lines, single serialize function, consistent two-call
pattern across CLI and SDKs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Shivanshu-07 added a commit to percy/percy-cypress that referenced this pull request Apr 17, 2026
Adds the readiness gate from percy/cli#2184 to percySnapshot. The SDK
now calls PercyDOM.waitForReady() with readiness config (from per-snapshot
options or utils.percy.config.snapshot.readiness) before the existing
PercyDOM.serialize() call. The serialize call itself is unchanged.

Backward compat: typeof guard on PercyDOM.waitForReady means older CLI
versions that lack the method skip the readiness step entirely.

Graceful degradation: a rejected/thrown waitForReady is logged at debug
level and serialize still runs.

Disabled preset: { readiness: { preset: 'disabled' } } in snapshot options
or config skips the readiness call.

Tests cover happy path, backward compat (no waitForReady), disabled preset,
and waitForReady rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shivanshu-07 added a commit to percy/percy-playwright that referenced this pull request Apr 17, 2026
Adds the readiness gate from percy/cli#2184 to captureSerializedDOM. Each
time the SDK is about to serialize the DOM, it first calls
PercyDOM.waitForReady(config) via page.evaluate, which auto-awaits the
returned Promise. The serialize call itself is unchanged.

Readiness config precedence: per-snapshot options.readiness →
utils.percy.config.snapshot.readiness → {} (CLI falls back to balanced
preset default).

Backward compat: the page.evaluate wrapper checks
typeof PercyDOM.waitForReady === 'function' in-browser, so older CLI
versions without the method skip readiness entirely.

Graceful degradation: a rejected waitForReady eval is logged at debug and
serialize still runs (the .catch handler swallows the error).

Disabled preset: { readiness: { preset: 'disabled' } } on the snapshot
options or global config skips the readiness page.evaluate call entirely.

Tests cover happy path (call order), config pass-through, disabled preset,
and waitForReady rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shivanshu-07 added a commit to percy/percy-selenium-python that referenced this pull request Apr 17, 2026
Adds the readiness gate from percy/cli#2184 to percy_snapshot. A new
helper _wait_for_ready() is called from get_serialized_dom immediately
before the existing PercyDOM.serialize execute_script call. serialize
itself is unchanged.

Pattern B (Selenium): readiness is sent via driver.execute_async_script
with a callback-style script (arguments[arguments.length - 1]). The
embedded JS checks typeof PercyDOM.waitForReady === 'function' so older
CLI versions without the method skip readiness silently.

Readiness config precedence: kwargs['readiness'] > cached
percy.config.snapshot.readiness from healthcheck > {} (CLI applies
balanced default).

Disabled preset: readiness={'preset': 'disabled'} skips the
execute_async_script call entirely.

Graceful degradation: any exception from execute_async_script is caught
and logged at debug; serialize still runs. The embedded JS catches
PercyDOM-side errors via .catch.

Tests (in TestPercySnapshot) cover happy path (readiness runs before
serialize), config pass-through, disabled preset (no readiness call),
and readiness raising (serialize + snapshot POST still happen).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shivanshu-07 added a commit to percy/percy-puppeteer that referenced this pull request Apr 20, 2026
Adds the readiness gate from percy/cli#2184. Before the existing
PercyDOM.serialize page.evaluate call, percySnapshot now runs
PercyDOM.waitForReady via page.evaluate (auto-await). The return value
(readiness diagnostics) is attached to the domSnapshot as
readiness_diagnostics so the CLI can log timing and pass/fail info. The
serialize call itself is unchanged.

Readiness config precedence: options.readiness →
utils.percy.config.snapshot.readiness → {} (CLI applies balanced default).
Backward compat: typeof PercyDOM.waitForReady === 'function' guard in the
page.evaluate body. Disabled preset skips the evaluate call entirely.
Graceful: rejected readiness is logged at debug and serialize still runs.

Tests cover happy path, config pass-through, disabled preset, and
waitForReady rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shivanshu-07 added a commit to percy/percy-playwright-python that referenced this pull request Apr 20, 2026
Adds the readiness gate from percy/cli#2184. A new _wait_for_ready()
helper uses page.evaluate (sync Playwright auto-awaits Promises) with a
typeof guard so older CLI versions that lack waitForReady are no-ops.
The return value is attached to dom_snapshot['readiness_diagnostics'] so
the CLI can log timing and pass/fail. serialize itself is unchanged.

Config precedence: kwargs['readiness'] > healthcheck-cached
percy.config.snapshot.readiness > {} (CLI applies balanced default).
Disabled preset skips the evaluate call. Graceful on exception.

Tests cover happy path (call order), config pass-through, disabled
preset, and readiness raising.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shivanshu-07 added a commit to percy/percy-playwright-java that referenced this pull request Apr 20, 2026
Adds the readiness gate from percy/cli#2184. New waitForReady() helper
runs PercyDOM.waitForReady before the existing PercyDOM.serialize
page.evaluate inside getSerializedDOM. Playwright auto-awaits the
returned Promise. Diagnostics are attached to the mutable domSnapshot as
readiness_diagnostics so the CLI can log timing and pass/fail.

Config precedence: options['readiness'] > cliConfig.snapshot.readiness >
empty (CLI applies balanced default). Backward compat via in-browser
typeof PercyDOM.waitForReady === 'function' guard. Disabled preset
short-circuits. Any exception is swallowed at debug level.

Tests (Mockito): diagnostics attached + readiness JS sent, disabled
preset skips the evaluate, and readiness throw leaves the serialize path
intact. Local: mvn test → 3 passed, 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shivanshu-07 added a commit to percy/percy-playwright-dotnet that referenced this pull request Apr 20, 2026
Adds the readiness gate from percy/cli#2184. New WaitForReady() helper
runs PercyDOM.waitForReady via EvaluateSync before the existing serialize
EvaluateSync in GetSerializedDom. Playwright auto-awaits the returned
Promise. Diagnostics are attached to the domSnapshot dictionary as
readiness_diagnostics. The serialize call is unchanged.

Config precedence: options['readiness'] > cliConfig.snapshot.readiness >
empty (CLI applies balanced default). Backward compat via in-browser
typeof PercyDOM.waitForReady === 'function' guard. Disabled preset
short-circuits. Any exception is swallowed at debug level.

Tests: two Fact tests exercise readiness-enabled + readiness-disabled
paths via the public Snapshot API, asserting the snapshot posts to the
mock CLI. Local: dotnet build → 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shivanshu-07 added a commit to percy/percy-selenium-js that referenced this pull request Apr 20, 2026
Adds the readiness gate from percy/cli#2184. captureSerializedDOM now
runs PercyDOM.waitForReady via driver.executeAsyncScript (callback-style
for robustness across selenium-webdriver versions) immediately before
the existing serialize driver.executeScript. The return value is
attached to domSnapshot.readiness_diagnostics so the CLI can log timing
and pass/fail. serialize itself is unchanged.

Config precedence: options.readiness >
utils.percy.config.snapshot.readiness > {} (CLI applies balanced
default). Backward compat via in-browser typeof PercyDOM.waitForReady
=== 'function' guard. Disabled preset skips the executeAsyncScript
entirely. Graceful on any exception.

Tests (jasmine + spyOn): happy path (executeAsyncScript called with
waitForReady script), per-snapshot config pass-through, disabled preset
(no executeAsyncScript), and readiness rejection (serialize still
succeeds).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shivanshu-07 added a commit to percy/percy-selenium-java that referenced this pull request Apr 20, 2026
Adds the readiness gate from percy/cli#2184. New waitForReady() helper
runs PercyDOM.waitForReady via executeAsyncScript (callback signal)
before the existing PercyDOM.serialize executeScript inside
getSerializedDOM. Diagnostics are attached to the mutable snapshot as
readiness_diagnostics. serialize is unchanged.

Config precedence: options['readiness'] > cliConfig.snapshot.readiness >
empty. Backward compat via in-browser typeof guard. Disabled preset
short-circuits. Graceful on exception.

Visibility: getSerializedDOM is now package-private so tests can call it
directly; it was previously private.

Tests (Mockito): diagnostics attached + readiness script contains
waitForReady, disabled preset skips executeAsyncScript, readiness throw
leaves serialize intact. Local: mvn test → 3 passed, 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shivanshu-07 added a commit to percy/percy-selenium-dotnet that referenced this pull request Apr 20, 2026
Adds the readiness gate from percy/cli#2184. New WaitForReady() internal
helper runs PercyDOM.waitForReady via ExecuteAsyncScript (callback
signal) BEFORE the existing PercyDOM.serialize ExecuteScript inside
getSerializedDom. Diagnostics are attached to domSnapshot as
readiness_diagnostics. Serialize is unchanged.

Config precedence: options["readiness"] > cliConfig.snapshot.readiness >
empty (CLI applies balanced default). Backward compat via in-browser
typeof guard. Disabled preset short-circuits. Graceful on any exception.

Tests: two integration-style Facts exercise readiness-enabled and
readiness-disabled paths via the public Snapshot API.
dotnet build Percy/Percy.csproj → 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shivanshu-07 added a commit to percy/percy-selenium-ruby that referenced this pull request Apr 20, 2026
Adds the readiness gate from percy/cli#2184. New wait_for_ready() helper
runs PercyDOM.waitForReady via driver.execute_async_script (callback
signal) before the existing PercyDOM.serialize execute_script inside
get_serialized_dom. The return value is attached to the domSnapshot
hash as 'readiness_diagnostics'. serialize is unchanged.

Config precedence: options[:readiness] (or 'readiness') >
@cli_config.dig('snapshot','readiness') > {} (CLI applies balanced
default). Backward compat via in-browser typeof guard. Disabled preset
skips the execute_async_script. Graceful on any StandardError.

Tests (RSpec): happy path (execute_async_script called with
waitForReady + typeof guard, diagnostics on snapshot), per-snapshot
config embedded, disabled preset skips execute_async_script, and
execute_async_script raising leaves serialize intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shivanshu-07 added a commit to percy/percy-capybara that referenced this pull request Apr 20, 2026
Adds the readiness gate from percy/cli#2184. New wait_for_ready(page,
options) helper runs PercyDOM.waitForReady via evaluate_async_script
(callback signal) before the existing PercyDOM.serialize evaluate_script
inside percy_snapshot. The result is attached to the dom_snapshot as
'readiness_diagnostics'. serialize is unchanged.

Config: options[:readiness] / options['readiness'] > {} (CLI applies
balanced default). Backward compat via in-browser typeof
PercyDOM.waitForReady === 'function' guard. preset='disabled' skips
evaluate_async_script. Graceful on any StandardError.

Tests (RSpec): happy path verifies readiness_diagnostics propagates
into the POST body via a mock PercyDOM.waitForReady; disabled preset
verifies evaluate_async_script is NOT called.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

test

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.

Re-review summary

Thanks for the turnaround. The pivot to a two-call pattern (waitForReady then sync serialize) cleanly resolves the biggest concern from the last round (SDK breakage from a Promise-returning serializeDOM). Specifically:

  • camelCase / snake_case config mismatch — fixed via normalizeOptions + tests.
  • JS-idle / stability-window coupling — decoupled via dedicated js_idle_window_ms.
  • checkFontReady now honors aborted and clears the 5s timer.
  • href on <a> no longer resets stability.
  • Network-idle polling replaced with PerformanceObserver.
  • Redundant thenable check in page.js removed.
  • Coverage suppressions trimmed to narrow, justified ignores.

All prior blockers addressed. A few new/remaining items from this round's pushes — non-blocking but worth addressing before merge. See inline.

(Please ignore the stray "test" review I accidentally posted just before this one — API retry artifact. This is the real one.)

Comment thread packages/core/src/page.js
Comment thread packages/core/src/page.js
Comment thread packages/core/src/snapshot.js Outdated
Comment thread packages/sdk-utils/src/serialize-dom.js Outdated
Comment thread packages/dom/src/readiness.js
Shivanshu-07 added a commit to percy/percy-webdriverio that referenced this pull request Apr 20, 2026
Adds the readiness gate from percy/cli#2184. percySnapshot now runs
PercyDOM.waitForReady via b.executeAsync (callback signal) before the
existing PercyDOM.serialize b.execute call. The return value is attached
to domSnapshot.readiness_diagnostics. serialize is unchanged.

Config precedence: options.readiness >
utils.percy.config.snapshot.readiness > {} (CLI balanced default).
Backward compat via in-browser typeof guard. Disabled preset skips
executeAsync. Graceful on any rejection.

Tests (jasmine + spyOn): happy path, config pass-through, disabled
preset, and readiness rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shivanshu-07 and others added 4 commits April 29, 2026 18:49
…ons (PER-7348)

Hits the two readiness branches that were missed by existing specs and pushed
@percy/core test:coverage below the 100% threshold:

- packages/core/src/page.js (lines 220-230): readiness preset gating, the
  diagnostics?.timed_out branch, and the percy.config fallback path
- packages/core/src/snapshot.js (lines 227-230): warn vs debug log paths for
  SDK-submitted readiness_diagnostics. domSnapshot is sent as a JSON string
  so that snake_case keys survive option normalization.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…7348)

The timed_out warn log path on snapshot.js:228 has a `preset || 'custom'`
fallback whose falsy branch wasn't exercised by the previous spec (which
sets preset: 'balanced'). Adds a parallel spec without a preset so the
'custom' branch is hit, bringing snapshot.js back to 100% branch coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…grate (PER-7348)

PercyConfig.migrate recursively rewrites snake_case keys to camelCase
during validation. The readiness_diagnostics object the SDK posts as
domSnapshot.readiness_diagnostics — with snake_case inner keys
(timed_out, total_duration_ms, etc.) — was therefore being rewritten to
domSnapshot.readinessDiagnostics with camelCase contents before reaching
snapshot.js:225-232.

Effect: even when the SDK correctly posted diagnostics, the CLI's
"Readiness passed in ..." / "Readiness timed out after ..." log lines
never fired, because:
  - migrated.domSnapshot.readiness_diagnostics was undefined
    (renamed to readinessDiagnostics)
  - inside the diag object, .timed_out and .total_duration_ms were
    undefined too (renamed to .timedOut, .totalDurationMs)

Two-part fix:
  1. config.js: mark readiness_diagnostics with `normalize: false` so the
     PercyConfig normalize pass skips it. The wire shape stays as the SDK
     sent it. Same pattern used by 4 other paths in this schema.
  2. snapshot.js: defensive dual-read on both top-level key and inner
     keys, so a future SDK sending camelCase still works, and a future
     migration path that misses the normalize: false hint doesn't
     silently drop the log.

Adds two regression tests that pass domSnapshot as an object (the real
SDK wire shape) — the previous tests used JSON.stringify, which
sidesteps the normalize layer and missed this bug entirely.

Verified end-to-end against percy-puppeteer SDK in --testing mode.
… (PER-7348)

Follow-up to review feedback on PR #2184. Five focused changes:

1. **page.js** — Attach readiness diagnostics onto the captured DOM snapshot
   (when domSnapshot is the structured object form) so the snapshot.js reader
   is no longer dead code on the CLI URL path. Backend/UI can now surface
   readiness metrics for snapshots taken via the CLI.

2. **page.js** — Tighten `/* istanbul ignore next */` to cover only the
   injected arrow function (which is the genuinely uninstrumented browser
   code). The outer `await this.eval(...).catch(...)` is now testable, and a
   new spec exercises the "Readiness check failed" debug log path by rejecting
   the readiness eval.

3. **snapshot.js** — Read `readiness_diagnostics` only after an explicit
   `typeof migrated.domSnapshot === 'object'` gate so the union with the
   legacy string form is obvious to readers; behaviour is unchanged.

4. **sdk-utils** — Escape `U+2028` / `U+2029` in the JSON-stringified config
   that is interpolated into the readiness script source. These code points
   are valid JSON but were illegal in JS source string literals before ES2019
   and could cause SyntaxError on older eval hosts. Adds a JSDoc warning that
   the helper output is not safe to inline into HTML without escaping.

5. **readiness.js** — Make the abort path of `checkFontReady` deterministic by
   resolving its inner race with `{ aborted: true }` when abort fires, so the
   `document.fonts.ready` promise can no longer settle the result with
   `{ passed: true }` after the orchestrator's timeout has already declared.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Shivanshu-07
Copy link
Copy Markdown
Contributor Author

Review feedback addressed in a1046bf2

# Reviewer concern Resolution
1 diagnostics fetched in page.js but never attached → reader in snapshot.js is dead code Diagnostics now attached onto capture.domSnapshot (object form) before returning, so backend/UI can surface readiness metrics on the CLI URL path
2 /* istanbul ignore next */ swallows the .catch handler in page.js Ignore moved inside the eval(...) call, scoped only to the injected arrow. Added spec that rejects readiness eval and asserts Readiness check failed: ... debug log fires
3 migrated.domSnapshot?.readiness_diagnostics reads cleanly off a string but is unclear Explicit typeof migrated.domSnapshot === 'object' gate via domSnapshotObj. Reader is no longer dead code — populated by the CLI URL path today and by SDKs once their PRs land
4 JSON.stringify(readinessConfig) interpolates without escaping U+2028/U+2029 Escape both with .replace(/ /g, '\\u2028').replace(/ /g, '\\u2029'). Added JSDoc warning that the output must not be inlined into HTML. New sdk-utils spec asserts the escape sequences are present
5 checkFontReady abort: document.fonts.ready can flip the race to { passed: true } after timeout Race now includes a third abortPromise that resolves with { passed: false, aborted: true } on abort, settling the result deterministically

CI is green on Linux (Linux Test @percy/core passed at 11m22s, all other checks pass). Windows variant still in progress.

Comment thread packages/dom/src/serialize-dom.js Outdated
Comment thread packages/dom/src/readiness.js
Comment thread packages/dom/src/readiness.js
Comment thread packages/dom/src/readiness.js Outdated
Shivanshu-07 and others added 4 commits May 6, 2026 09:37
…348)

Addresses three open review comments on #2184:

- Extract `observePerformance(type, onEntries)` helper in readiness.js;
  checkNetworkIdle and checkJSIdle now share the try/observe/disconnect
  boilerplate. Returns null on older browsers so each caller can choose
  its own fallback (network idle polls; JS idle degrades to rIC/rAF).

- Add `resolveSelector` in readiness.js. Sniffs XPath via leading
  `/`, `//`, `./`, `(/`, `(./`; falls back to `document.querySelector`
  for CSS. Also accepts explicit `{ css }` / `{ xpath }` object form
  for ambiguous cases, and returns null on malformed XPath / non-Element
  results so the readiness gate keeps polling instead of blowing up.
  checkReadySelectors and checkNotPresentSelectors route through it.
  Schema in config.js now allows array items to be string or
  `{ css?, xpath? }` object.

- Trim the 9-line two-call-pattern docstring in serialize-dom.js to
  one line referencing readiness.js — the long block duplicated
  guidance already in readiness.js / sdk-utils.

Tests: 12 new resolveSelector unit tests (CSS, XPath in 4 leading
forms, malformed XPath, non-Element result, object form precedence)
and 3 new integration tests covering ready_selectors / not_present_selectors
with XPath and mixed CSS+XPath via object form. @percy/dom 650 specs
pass; @percy/core readiness specs all pass; the 28 core failures are
the documented pre-existing env failures (Chromium install / runDoctor
/ AggregateError) and are unchanged by this commit. yarn lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The PerformanceObserver dedupe in 87b6556 lost the
`/* istanbul ignore next */` annotations on the two `if (observer)`
guards in checkNetworkIdle (settle and the abort handler). Both branches
only fall through on the older-browser fallback path, which is itself
ignored — restoring the annotations brings readiness.js branch coverage
back to 100%.

CI Test @percy/dom (Linux, the failing run) reported 99.55% branches
with uncovered lines 203-235 in readiness.js. Local `yarn test:coverage`
now reports 100/100/100/100.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets users explicitly disable the MutationObserver-driven dom_stability
check on heavy-DOM pages where the observer itself can drive CPU/memory
pressure. When `domStability: false` (camelCase) or `dom_stability: false`
(snake_case) is set, the orchestrator skips that check entirely while
the other checks (js_idle, image_ready, font_ready, ready_selectors,
not_present_selectors, network_idle) continue to gate capture.

Defaults to true across all presets (balanced, strict, fast) so existing
configs are unchanged. Schema updated in packages/core/src/config.js;
normalizeOptions accepts both casings; PRESETS carry the default.

Tests: 3 new normalizeOptions cases (camelCase, snake_case, precedence)
and 3 new integration tests (kill-switch skips check, camelCase variant,
default behavior unchanged). 662 dom specs pass; readiness.js holds
100/100/100/100 coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses review comment on #2184 (r3186168268). The `_serialize`
helper was introduced when `serializeDOMWithReadiness` shared the
serialization logic with `serializeDOM`. After commit cd001ab removed
`serializeDOMWithReadiness` in favor of the two-call pattern
(`waitForReady` + `serialize`), the wrapper became a thin pass-through
with no purpose. Inlining keeps the surface as one function and matches
the file's pre-readiness shape.

No behavior change. @percy/dom: 662 specs pass, 100/100/100/100 coverage.

Co-Authored-By: Claude Opus 4.7 (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