feat: PER-7348 add waitForReady() call before serialize()#218
feat: PER-7348 add waitForReady() call before serialize()#218Shivanshu-07 wants to merge 2 commits intomasterfrom
Conversation
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>
rishigupta1599
left a comment
There was a problem hiding this comment.
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:
-
readinessleaks intoPercyDOM.serialize()and into the snapshot POST body.get_serialized_domnever popsreadinessout ofkwargs, so it gets JSON-serialized into thePercyDOM.serialize(kwargs)argument and also spread into the/percy/snapshotPOST body as a top-level field.@percy/dom.serializeignores unknown keys today, but it's a silent coupling, and the CLI snapshot endpoint receiving a top-levelreadinessalongside snapshot options is likely not what the CLI expects (readiness config is supposed to flow via healthcheck per the PR description). Popreadinessfrom kwargs once consumed. -
Readiness runs N times for responsive capture.
capture_responsive_domcallsget_serialized_domonce 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 insideget_serialized_domwhen responsive is active. -
No explicit script timeout.
execute_async_scriptrelies on the driver's global script timeout (Selenium default ~30s, but BrowserStack hubs and some remotes use lower). If a user setsreadiness.timeoutMshigher than the driver's script timeout, WebDriver fires ScriptTimeoutException before the in-page Promise resolves — so the user-facing timeout is silently capped. Eitherdriver.set_script_timeout(readiness.timeoutMs + buffer)around the call (and restore the previous value) or document the interaction. -
Minor: the
is_percy_enabled()call inside_wait_for_readyis redundant —percy_snapshothas already called it and has thedatadict in scope. Plumbdata['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.
| """ | ||
| readiness_config = kwargs.get('readiness') | ||
| if readiness_config is None: | ||
| data = is_percy_enabled() |
There was a problem hiding this comment.
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).
| if readiness_config is None: | ||
| data = is_percy_enabled() | ||
| if isinstance(data, dict): | ||
| readiness_config = (data.get('config') or {}).get('snapshot', {}).get('readiness', {}) or {} |
There was a problem hiding this comment.
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.
| return None | ||
| try: | ||
| diagnostics = driver.execute_async_script( | ||
| 'var config = ' + json.dumps(readiness_config) + ';' |
There was a problem hiding this comment.
Two concerns on this execute_async_script call:
-
No script timeout is set on the driver.
execute_async_scriptuses the session's global script timeout (Selenium default ~30s, but BrowserStack hubs and some remotes run lower). If a user configuresreadiness.timeoutMshigher than the driver's script timeout, WebDriver fires ScriptTimeoutException before the in-page Promise resolves — so the user-facing timeout config is silently capped. Eitherdriver.set_script_timeout(readiness.timeoutMs / 1000 + buffer)around this call (and restore the previous value) or document the interaction prominently. -
Readiness runs once per width for responsive snapshots.
capture_responsive_domcallsget_serialized_dominside its per-width loop, so every responsive snapshot pays the readiness cost N times. With a 10stimeoutMsacross 3 widths that's up to 30s of sequential waits per snapshot. Consider running readiness once before the loop and short-circuiting insideget_serialized_domfor responsive calls (e.g. askip_readiness=Truekwarg).
|
|
||
| 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) |
There was a problem hiding this comment.
readiness stays in kwargs after this call and then gets:
- JSON-serialized into the
PercyDOM.serialize(kwargs)argument on the next line — harmless today because@percy/domignores unknown keys, but it's an implicit coupling. - Spread into the
requests.post(..., json={**kwargs, ...})body inpercy_snapshotat the end of the function, so the CLI receives a top-levelreadinessalongside 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.
| 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 |
There was a problem hiding this comment.
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):.
| percy_snapshot(self.driver, 'readiness-boom') | ||
|
|
||
| # Snapshot endpoint was hit | ||
| paths = [req.path for req in httpretty.latest_requests()] |
There was a problem hiding this comment.
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.
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
serialize()stays synchronous — zero breakagewaitForReady()are captured and attached todomSnapshot.readiness_diagnosticsso the CLI can log timing and pass/failBackward compatibility
typeof PercyDOM.waitForReady === 'function'is false — skips readiness.serialize()works as today.waitForReady()runs, page stabilizes, diagnostics attached,serialize()captures stable DOM.serialize()still runs.{ timed_out: true },serialize()still runs.Readiness config
Readiness config flows from
.percy.ymlthrough the CLI healthcheck to the SDK:Per-snapshot overrides:
percySnapshot('name', { readiness: { preset: 'strict' } })Disable globally:
readiness: { preset: disabled }Test plan
preset: disabledPercyDOM.waitForReadyis unavailable (old CLI)Depends on
@percy/dom)