Skip to content

feat: PER-7348 add waitForReady() call before serialize()#218

Open
Shivanshu-07 wants to merge 2 commits intomasterfrom
PER-7348-readiness-in-serialize
Open

feat: PER-7348 add waitForReady() call before serialize()#218
Shivanshu-07 wants to merge 2 commits intomasterfrom
PER-7348-readiness-in-serialize

Conversation

@Shivanshu-07
Copy link
Copy Markdown

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

Summary

Adds readiness-gated snapshot capture to this SDK (PER-7348). Before serializing the DOM, the SDK now calls PercyDOM.waitForReady(config) to ensure the page is stable — no skeleton screens, fonts loaded, network idle, JavaScript settled.

Architecture: Two-Call Pattern

Step 1: PercyDOM.waitForReady(config)  — async, waits for page stability
Step 2: PercyDOM.serialize(options)    — sync, captures the stable DOM (unchanged)
  • serialize() stays synchronous — zero breakage
  • Readiness is a separate async call that runs before serialize
  • Diagnostics from waitForReady() are captured and attached to domSnapshot.readiness_diagnostics so the CLI can log timing and pass/fail

Backward compatibility

Scenario Behavior
This SDK + old CLI typeof PercyDOM.waitForReady === 'function' is false — skips readiness. serialize() works as today.
This SDK + new CLI waitForReady() runs, page stabilizes, diagnostics attached, serialize() captures stable DOM.
waitForReady throws Caught, logged at debug level, serialize() still runs.
waitForReady times out Resolves with { timed_out: true }, serialize() still runs.

Readiness config

Readiness config flows from .percy.yml through the CLI healthcheck to the SDK:

# .percy.yml
snapshot:
  readiness:
    preset: balanced  # balanced | strict | fast | disabled
    stabilityWindowMs: 300
    networkIdleWindowMs: 200
    timeoutMs: 10000

Per-snapshot overrides: percySnapshot('name', { readiness: { preset: 'strict' } })

Disable globally: readiness: { preset: disabled }

Test plan

  • Readiness runs before serialize when enabled
  • Readiness config from snapshot options is passed through
  • Readiness skipped when preset: disabled
  • Serialize still runs when waitForReady throws
  • Backward compat: works when PercyDOM.waitForReady is unavailable (old CLI)

Depends on

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>
_wait_for_ready() now returns the diagnostics dict from
execute_async_script. get_serialized_dom() captures them and attaches
as dom_snapshot['readiness_diagnostics'] before the snapshot is POSTed.

Without this, the CLI's logging at snapshot.js:224-232 never fires —
users have zero visibility into whether readiness ran or timed out.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@Shivanshu-07 Shivanshu-07 marked this pull request as ready for review April 20, 2026 05:00
@Shivanshu-07 Shivanshu-07 requested a review from a team as a code owner April 20, 2026 05:00
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.

Nice backward-compatible design — the in-browser typeof PercyDOM.waitForReady guard plus graceful exception handling is the right shape for a staged CLI rollout. A few substantive concerns worth addressing before merge:

  1. readiness leaks into PercyDOM.serialize() and into the snapshot POST body. get_serialized_dom never pops readiness out of kwargs, so it gets JSON-serialized into the PercyDOM.serialize(kwargs) argument and also spread into the /percy/snapshot POST body as a top-level field. @percy/dom.serialize ignores unknown keys today, but it's a silent coupling, and the CLI snapshot endpoint receiving a top-level readiness alongside snapshot options is likely not what the CLI expects (readiness config is supposed to flow via healthcheck per the PR description). Pop readiness from kwargs once consumed.

  2. Readiness runs N times for responsive capture. capture_responsive_dom calls get_serialized_dom once per width, so with 3 widths and a 10s timeout you can add up to 30s of sequential readiness waits per snapshot name. Consider running readiness once before the width loop and short-circuiting inside get_serialized_dom when responsive is active.

  3. No explicit script timeout. execute_async_script relies on the driver's global script timeout (Selenium default ~30s, but BrowserStack hubs and some remotes use lower). If a user sets readiness.timeoutMs higher than the driver's script timeout, WebDriver fires ScriptTimeoutException before the in-page Promise resolves — so the user-facing timeout is silently capped. Either driver.set_script_timeout(readiness.timeoutMs + buffer) around the call (and restore the previous value) or document the interaction.

  4. Minor: the is_percy_enabled() call inside _wait_for_ready is redundantpercy_snapshot has already called it and has the data dict in scope. Plumb data['config'] through instead of re-reading the lru_cache.

Tests look solid for the happy paths. Consider adding one for the responsive + readiness interaction once (2) is resolved.

Comment thread percy/snapshot.py
"""
readiness_config = kwargs.get('readiness')
if readiness_config is None:
data = is_percy_enabled()
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.

Redundant call. percy_snapshot (the only production caller path) has already called is_percy_enabled() and has data['config'] in scope. Plumb the config through explicitly rather than relying on the lru_cache re-lookup — it makes the data flow clearer and avoids a surprise dependency on the cache for anyone who calls get_serialized_dom directly (module-level, no leading underscore).

Comment thread percy/snapshot.py
if readiness_config is None:
data = is_percy_enabled()
if isinstance(data, dict):
readiness_config = (data.get('config') or {}).get('snapshot', {}).get('readiness', {}) or {}
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.

If config.snapshot is ever returned from the CLI as a non-dict (e.g. null deserialized to None), .get('readiness', {}) raises AttributeError and blows up the snapshot call. Consider ((data.get('config') or {}).get('snapshot') or {}).get('readiness') or {} for symmetry with the other guards.

Comment thread percy/snapshot.py
return None
try:
diagnostics = driver.execute_async_script(
'var config = ' + json.dumps(readiness_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.

Two concerns on this execute_async_script call:

  1. No script timeout is set on the driver. execute_async_script uses the session's global script timeout (Selenium default ~30s, but BrowserStack hubs and some remotes run lower). If a user configures readiness.timeoutMs higher than the driver's script timeout, WebDriver fires ScriptTimeoutException before the in-page Promise resolves — so the user-facing timeout config is silently capped. Either driver.set_script_timeout(readiness.timeoutMs / 1000 + buffer) around this call (and restore the previous value) or document the interaction prominently.

  2. Readiness runs once per width for responsive snapshots. capture_responsive_dom calls get_serialized_dom inside its per-width loop, so every responsive snapshot pays the readiness cost N times. With a 10s timeoutMs across 3 widths that's up to 30s of sequential waits per snapshot. Consider running readiness once before the loop and short-circuiting inside get_serialized_dom for responsive calls (e.g. a skip_readiness=True kwarg).

Comment thread percy/snapshot.py

def get_serialized_dom(driver, cookies, percy_dom_script=None, **kwargs):
# 0. Readiness gate before serialize (PER-7348). Graceful on old CLI.
readiness_diagnostics = _wait_for_ready(driver, kwargs)
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.

readiness stays in kwargs after this call and then gets:

  1. JSON-serialized into the PercyDOM.serialize(kwargs) argument on the next line — harmless today because @percy/dom ignores unknown keys, but it's an implicit coupling.
  2. Spread into the requests.post(..., json={**kwargs, ...}) body in percy_snapshot at the end of the function, so the CLI receives a top-level readiness alongside snapshot options. That likely isn't what the CLI expects (readiness config is supposed to flow via healthcheck per the PR description), and it's easy for a future CLI-side validator to reject unknown top-level fields.

Safer: kwargs.pop('readiness', None) inside _wait_for_ready once you've consumed it (or pop at this call site) so neither serialize() nor the snapshot POST sees it.

Comment thread percy/snapshot.py
dom_snapshot = driver.execute_script(f'return PercyDOM.serialize({json.dumps(kwargs)})')
# Attach readiness diagnostics so the CLI can log timing and pass/fail
if readiness_diagnostics and isinstance(dom_snapshot, dict):
dom_snapshot['readiness_diagnostics'] = readiness_diagnostics
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.

Minor: the readiness_diagnostics truthiness check drops falsy-but-present results. The PR description specifically calls out { timed_out: true } as a signal the CLI needs for logging — that one's fine because it's truthy — but if waitForReady ever resolves with an empty dict {} (e.g. disabled preset short-circuit inside the JS), the diagnostics are silently dropped. Prefer if readiness_diagnostics is not None and isinstance(dom_snapshot, dict):.

Comment thread tests/test_snapshot.py
percy_snapshot(self.driver, 'readiness-boom')

# Snapshot endpoint was hit
paths = [req.path for req in httpretty.latest_requests()]
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.

This test validates the POST happens when execute_async_script raises, but it doesn't assert that serialize() actually ran — which is the whole point of the graceful-degradation path. Consider also spying on execute_script and asserting PercyDOM.serialize was called after the readiness exception. Otherwise this passes trivially even if the serialize call were accidentally short-circuited alongside readiness.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants