Skip to content

feat(sdk-utils): own readiness — shallow-merge precedence + move waitForReady from @percy/dom (PER-7348)#2236

Merged
Shivanshu-07 merged 5 commits into
masterfrom
fix/PER-7348-sdk-utils-shallow-merge
May 22, 2026
Merged

feat(sdk-utils): own readiness — shallow-merge precedence + move waitForReady from @percy/dom (PER-7348)#2236
Shivanshu-07 merged 5 commits into
masterfrom
fix/PER-7348-sdk-utils-shallow-merge

Conversation

@Shivanshu-07

@Shivanshu-07 Shivanshu-07 commented May 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Two related changes that consolidate readiness ownership in @percy/sdk-utils:

1. getReadinessConfig shallow-merge fix (4ece9cc6)

Used options.readiness || percy.config?.snapshot?.readiness || {} — surfaced during SDK PR review (percy-cypress #1087, percy-ember #1149) as having two latent failure modes:

  1. Empty per-snapshot wipes global. options = { readiness: {} } short-circuits the fallback and discards the entire .percy.yml config.
  2. Partial override loses kill switch. With global snapshot.readiness.preset: disabled and percySnapshot('x', { readiness: { stabilityWindowMs: 500 } }), the disabled state is silently lost.

Switched to shallow-merge — per-snapshot keys win, unspecified keys inherited.

2. Move readiness implementation to @percy/sdk-utils (d43c9bae)

The 556-line readiness implementation lived in @percy/dom. The SDK-facing helpers (getReadinessConfig, waitForReadyScript, isReadinessDisabled) already lived in @percy/sdk-utils. Splitting "implementation" from "orchestration" across two packages was confusing.

Moved:

  • packages/dom/src/readiness.jspackages/sdk-utils/src/readiness-browser.js
  • packages/dom/test/readiness-helpers.test.jspackages/sdk-utils/test/

packages/dom/src/index.js now re-exports waitForReady from @percy/sdk-utils/readiness-browser. @percy/sdk-utils/package.json adds the file to files and the exports map.

Why the source lives in sdk-utils but is consumed via @percy/dom: the code uses browser globals (MutationObserver, document, performance, etc.) so it can't run in Node. @percy/dom's rollup build inlines it into the browser bundle that ships via fetchPercyDOM(). End users of @percy/dom still get a self-contained bundle — no runtime dep on sdk-utils — because the bundle is already inlined.

Verified: yarn build in packages/dom produces a dist/bundle.js that still contains function waitForReady and the full readiness implementation. PercyDOM.waitForReady global remains identical for every SDK caller — no SDK changes required.

Test plan

  • 8 new tests in packages/sdk-utils/test/index.test.js cover the shallow-merge precedence: empty per-snapshot, partial override inheritance, global preset: disabled inheritance, and the existing per-snapshot-wins case.
  • readiness-helpers.test.js moved alongside the source it tests; imports updated to ../src/readiness-browser.js.
  • yarn build in packages/dom succeeds; readiness implementation inlined into dist/bundle.js.
  • Public API surface unchanged: import { waitForReady } from '@percy/dom' still works.

Net effect

Single source of truth per concern in @percy/sdk-utils:

Concern Export
Browser readiness implementation @percy/sdk-utils/readiness-browser (named waitForReady)
In-browser invoker script waitForReadyScript(cfg, { callback? })
Config precedence getReadinessConfig(options)
Kill switch isReadinessDisabled(options)

Future readiness changes (new checks, preset additions, log shape) land once and flow everywhere via the existing dependency chain.

🤖 Generated with Claude Code

…(PER-7348)

`getReadinessConfig` used `options.readiness || percy.config?.snapshot?.readiness || {}`
which had two failure modes that surfaced during SDK review:

1. `options.readiness = {}` short-circuited the fallback and wiped the
   global `.percy.yml` config — a thin wrapper that always forwards a
   `readiness` key would silently lose every global setting.
2. A partial per-snapshot override like `{ stabilityWindowMs: 500 }`
   dropped the global `preset: disabled` kill switch, silently
   re-enabling the gate for a snapshot the user opted out of.

Switching to shallow-merge fixes both: per-snapshot keys win, unspecified
keys are inherited.

Locked by tests covering: empty per-snapshot, partial override
inheritance, global `preset: disabled` inheritance, and the existing
per-snapshot-wins case.

This is the single source of truth for the precedence rule — every JS
SDK that imports `getReadinessConfig` from `@percy/sdk-utils` now gets
the fix automatically, instead of each SDK duplicating the logic with
its own variations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Shivanshu-07
Shivanshu-07 requested a review from a team as a code owner May 22, 2026 04:45
Comment thread packages/sdk-utils/src/serialize-dom.js
The 556-line readiness implementation (MutationObserver, PerformanceObservers,
font/image checks, presets, abort handling) lived in @percy/dom because the
code physically runs in the browser via the @percy/dom bundle. But the
SDK-facing concerns — config precedence (getReadinessConfig), in-browser
invoker script emission (waitForReadyScript), kill-switch (isReadinessDisabled)
— already lived in @percy/sdk-utils since CLI #2184. Splitting "the readiness
implementation" from "the readiness orchestration" across two packages is
confusing for maintainers.

This commit consolidates the source of truth in @percy/sdk-utils:

- Moved packages/dom/src/readiness.js → packages/sdk-utils/src/readiness-browser.js
- Moved packages/dom/test/readiness-helpers.test.js → packages/sdk-utils/test/
  (the helpers tested are internal to the moved file)
- packages/dom/src/index.js now re-exports waitForReady from sdk-utils
- packages/sdk-utils/package.json adds the file to `files` and adds an
  exports map entry: `./readiness-browser`

Why the file lives as a source file in sdk-utils but is consumed via
@percy/dom: the code uses browser globals (MutationObserver, document,
performance, etc.) so it cannot run in Node. @percy/dom's rollup build
inlines it into the browser bundle that ships via fetchPercyDOM(). End
users of @percy/dom get a self-contained bundle — no runtime dep on
sdk-utils — because the bundle is already inlined. SDKs that already
depend on sdk-utils for the Node-side helpers can now also access the
browser-side source directly via `@percy/sdk-utils/readiness-browser`
if they ever need to inject it themselves.

Behavior verified: @percy/dom's rebuilt dist/bundle.js still contains
`function waitForReady` and the readiness implementation inlined; the
public `PercyDOM.waitForReady` global remains identical for every SDK
caller (no SDK changes required).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Shivanshu-07 Shivanshu-07 changed the title fix(sdk-utils): shallow-merge readiness config precedence (PER-7348) feat(sdk-utils): own readiness — shallow-merge precedence + move waitForReady from @percy/dom (PER-7348) May 22, 2026
Shivanshu-07 and others added 3 commits May 22, 2026 15:30
Folds the ~8-line readiness gate boilerplate that was duplicated across
every driver-based JS SDK into a single call. The SDK's only
responsibility is to provide an `evalScript` callback that ships the
generated script string to the browser via its driver's evaluator.

Centralised:
  - isReadinessDisabled kill-switch check
  - getReadinessConfig shallow-merge of global + per-snapshot config
  - waitForReadyScript generation (callback or promise mode)
  - try/catch with debug logging — serialize is never blocked

Before, every SDK had:

    let readinessDiagnostics;
    const readinessDisabled = typeof utils.isReadinessDisabled === 'function'
      ? utils.isReadinessDisabled(options)
      : ((options?.readiness || utils.percy?.config?.snapshot?.readiness)?.preset === 'disabled');
    if (!readinessDisabled && typeof utils.waitForReadyScript === 'function') {
      const readinessConfig = typeof utils.getReadinessConfig === 'function'
        ? utils.getReadinessConfig(options)
        : { ...(utils.percy?.config?.snapshot?.readiness || {}), ...(options?.readiness || {}) };
      readinessDiagnostics = await page.evaluate(
        utils.waitForReadyScript(readinessConfig)
      ).catch(err => log.debug(`waitForReady failed: ${err?.message || err}`));
    }

After:

    const readinessDiagnostics = await utils.runReadinessGate(
      (script) => page.evaluate(script),
      options,
      { log }
    );

For callback-mode drivers (selenium-js, wdio, nightwatch):

    const readinessDiagnostics = await utils.runReadinessGate(
      (script) => driver.executeAsyncScript(script),
      options,
      { callback: true, log }
    );

The function returns the diagnostics object (or null when disabled /
unavailable / failed). Callers attach the non-null result to
`domSnapshot.readiness_diagnostics`.

10 tests added covering: per-snapshot disabled, global-disabled
inheritance through partial overrides, shallow-merged config inlining,
callback vs promise mode, Error/non-Error/sync-throw rejection
handling, and absent-log tolerance. 7 behaviours additionally
verified end-to-end via direct node execution (all pass).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous `eslint-disable-next-line` was followed by two comment
lines, so the disable applied to the comment instead of the actual
`Promise.reject('plain-string-rejection')` call further down — and
lint still failed. Moved the directive immediately above the offending
line.
@Shivanshu-07
Shivanshu-07 merged commit 6991b65 into master May 22, 2026
44 of 45 checks passed
@Shivanshu-07
Shivanshu-07 deleted the fix/PER-7348-sdk-utils-shallow-merge branch May 22, 2026 12:04
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