Skip to content

PER 7292#2177

Merged
aryanku-dev merged 78 commits into
masterfrom
PER-7292
May 11, 2026
Merged

PER 7292#2177
aryanku-dev merged 78 commits into
masterfrom
PER-7292

Conversation

@aryanku-dev
Copy link
Copy Markdown
Contributor

@aryanku-dev aryanku-dev commented Apr 11, 2026

Summary

Adds CLI-side capture for DOM features Percy's serializer previously missed:

  • Closed shadow DOM — discovered via CDP (DOM.getDocument({ pierce: true })) and exposed to PercyDOM.serialize() through a per-realm window.__percyClosedShadowRoots WeakMap. Works for shadow hosts nested inside iframes; respects snapshot.disableShadowDOM.
  • Custom-element :state() selectors — captures ElementInternals.states; rewrites modern :state(X) and legacy :--X to attribute selectors that survive serialization.
  • Interactive pseudo-class states:focus (captured before clone), :checked, :disabled, :hover, :active. Differential CSS rules extracted via CSSOM walk so the serialized DOM renders the state without JS.
  • Iframe sandbox / cross-origin warnings — fidelity warnings when sandbox attributes prevent capture. Adds snapshot.ignoreIframeSelectors and a per-iframe depth cap.
  • Custom-elements upgrade wait — pre-snapshot best-effort wait on :not(:defined) so lazy-defined components hydrate before serialization. 1500 ms internal cap (no user-facing knob — waitForCustomElementsTimeout was removed after PR review).

Files changed

Area Files
Closed shadow capture (CLI) packages/core/src/closed-shadow.js, page.js, config.js, tests
DOM serializer packages/dom/src/{clone-dom,prepare-dom,serialize-dom,serialize-frames,serialize-pseudo-classes,serialize-custom-states,shadow-utils,utils}.js, tests
Regression fixtures test/regression/pages/{dom-structures,hydration,interactive-states,sandbox-iframes,shadow-dom}.html, snapshots.yml

Test plan

  • @percy/core unit suite passes locally and in CI
  • @percy/dom Karma suite passes in Chrome
  • Windows CI green (archive flow regexes/path.resolve fixes)
  • Coverage report checked in CI

Notes for reviewers

  • MAX_SHADOW_DEPTH parity: packages/core/src/closed-shadow.js's MAX_SHADOW_DEPTH = 10 must stay in lockstep with HARD_MAX_IFRAME_DEPTH in @percy/dom's serialize-frames. Comment in source flags this.
  • waitForCustomElementsTimeout was initially exposed as a snapshot option and removed after PR review — there was no compelling case for per-snapshot tuning. Existing user configs that set it will now fail schema validation; surface this in the changelog.
  • ignoreIframeSelectors.items.minLength: 1 rejects empty-string selector entries. Same changelog note applies.
  • Archive snapshots + --replay command are bundled in this branch via commit 543eb805 (Add archive snapshots option and separate replay command #2216). Functionally independent from the closed-shadow/serializer work — flagging in case you want to mentally split scope during review.
  • Local coverage gotcha: nyc only collects coverage on Node 14 here because the codebase's ESM transformSource loader hook was removed in Node 18+. CI uses Node 14, so this only matters for local verification.

aryanku-dev and others added 10 commits April 11, 2026 16:56
Detect sandbox attributes on iframes and emit warnings when sandbox
restrictions may affect rendering fidelity in Percy. Warns for fully
sandboxed iframes, missing allow-scripts, and missing allow-same-origin.

[PER-7292]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…) CSS, interactive states, fidelity warnings

- Add preflight.js monkey-patch for attachShadow/attachInternals interception
- Inject preflight via CDP addScriptToEvaluateOnNewDocument in CLI browser
- Support closed shadow roots in prepare-dom.js, clone-dom.js, serialize-dom.js via WeakMap
- Rewrite :state() and legacy :--state CSS selectors to attribute selectors for ElementInternals
- Add fallback state detection via element.matches(':state(X)') for SDK path
- Capture :focus (before clone), :checked, :disabled states automatically on all elements
- Extract differential CSS rules for :focus/:checked/:disabled/:hover/:active via CSSOM
- Add shadow DOM traversal for interactive state detection
- Add iframe and shadow root fidelity warnings
- Guard all custom elements with closed shadow roots during cloning to prevent constructor conflicts
- Add regression test fixture for DOM structures coverage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… wait, and coverage fixes

- Add data-percy-ignore attribute to exclude specific iframes from capture
- Add ignoreIframeSelectors config option for CSS selector-based iframe exclusion
- Wait for customElements.whenDefined() with 5s timeout before snapshot capture
- Fix eslint quotes violations in sandbox iframe tests
- Add 63 new tests covering iframe ignore, custom element wait, interactive
  state CSS extraction, custom state detection, and edge cases
- Refactor serialize-pseudo-classes for testability: generator to array fn,
  extracted safeMatchesState helper, simplified guards
- Remove dead 'unknown' fallback in iframe label
- Add dom-structures.html regression test page
- 315 tests passing, 100% coverage (statements, branches, functions, lines)

[PER-7292]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prefix Element and HTMLElement with window. to satisfy eslint no-undef
rule in browser-injected script context.

[PER-7292]

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

- Replace querySelectorAll(':focus') with document.activeElement for focus
  detection in markInteractiveStatesInRoot — more reliable in headless browsers
- Refactor focus tests to mock document.activeElement instead of calling .focus()
  which doesn't work in Firefox headless
- Use :checked instead of :focus in CSS extraction tests for cross-browser compat
- Add ignoreIframeSelectors to @percy/core percy.test.js default config and
  eval spy expectations

[PER-7292]

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

- Capture bounding rect (getBoundingClientRect) of excluded iframes BEFORE
  removing them from the clone DOM, storing as fidelityRegions[] in domSnapshot
- Detect custom elements with potentially inaccessible shadow roots and capture
  their bounding rects
- Update shadow root fidelity warning to include totals:
  "N shadow root(s): X captured, Y potentially inaccessible"
- Return fidelityRegions array in serializeDOM result alongside html, warnings,
  resources, hints
- Log [fidelity] warnings in CLI verbose output via discovery.js
- Screenshot remains identical to user's page (iframes still removed)
- Fidelity regions will be used by API + Web to render overlay indicators
  at the original positions of uncaptured content

[PER-7292]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add fidelityRegions to client.js createSnapshot POST payload as
  'fidelity-regions' attribute
- Extract fidelityRegions from domSnapshot in discovery.js and pass
  through to snapshot upload
- Update client test payload assertions to include fidelity-regions

[PER-7292]

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

[PER-7292]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
[PER-7292]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Shivanshu-07 added a commit that referenced this pull request Apr 13, 2026
…granularity

The funcVariableTime assertions used a 10 ms tolerance on a 10 ms sleep,
which is below the Windows default timer tick (~15.6 ms). Observed CI
failures showed drifts of 11, 15, and 16 ms — exactly the range you'd
expect from a Windows tick miss.

The same test file's funcReturns assertions already use a 15 ms tolerance
with a 'adding buffer for win test' comment; the variable-time block was
overlooked. Bump to 20 ms (one full tick of headroom over the existing
15 ms pattern) and document why.

Eliminates the ~11 retry events observed across PRs #2138, #2147, #2172,
#2177 on Test @percy/webdriver-utils Windows jobs.
Shivanshu-07 added a commit that referenced this pull request Apr 13, 2026
The 'stops waiting on process termination' test used a fixed 100 ms
setTimeout before emitting SIGTERM, racing the first poll's log emit on
slow Windows runners. Two coupled failure modes were observed:

  A. The 'Processing 18 snapshots - 0 of 72 comparisons finished...' log
     hadn't landed yet, so logger.stdout was empty and the assertion got
     `Expected $.length = 0 to equal 1`.
  B. The same log arrived a few ms later and bled into the *next* test's
     logger snapshot ('failure messages > logs an error when there are
     no snapshots'), tripping `Expected $.length = 1 to equal 0` there.

Replace the fixed wait with a poll-until-logged loop bounded by a 5s
deadline. Once the expected log line is observed, SIGTERM and assertion
proceed deterministically — no race, no bleed.

Eliminates the ~6 retry events observed across PRs #2147, #2172, #2177
on Test @percy/cli-build Windows jobs.
Shivanshu-07 added a commit that referenced this pull request Apr 13, 2026
The 'stops waiting on process termination' test used a fixed 100 ms
setTimeout before emitting SIGTERM, racing the first poll's log emit on
slow Windows runners. Two coupled failure modes were observed:

  A. The 'Processing 18 snapshots - 0 of 72 comparisons finished...' log
     hadn't landed yet, so logger.stdout was empty and the assertion got
     `Expected $.length = 0 to equal 1`.
  B. The same log arrived a few ms later and bled into the *next* test's
     logger snapshot ('failure messages > logs an error when there are
     no snapshots'), tripping `Expected $.length = 1 to equal 0` there.

Replace the fixed wait with a poll-until-logged loop bounded by a 5s
deadline. Once the expected log line is observed, SIGTERM and assertion
proceed deterministically — no race, no bleed.

Eliminates the ~6 retry events observed across PRs #2147, #2172, #2177
on Test @percy/cli-build Windows jobs.
Shivanshu-07 added a commit that referenced this pull request Apr 14, 2026
* fix(dom): close srcdoc-iframe load race in serializeFrames test setup

`getFrame` used `performance.timing.loadEventEnd > 0` as the iframe-ready
signal. In Firefox, srcdoc iframes fire `load` for the placeholder
about:blank before the srcdoc content commits, so the helper could return
a frame whose `contentDocument.querySelector('input')` was still null.

Add an optional `ready(contentDocument)` predicate and pass it at the
three call sites whose next line dereferences specific content. Fixes the
intermittent `serializeFrames > plain: does not serialize iframes
without document elements` failure observed on Firefox 148/Linux in
PR #2154 CI run 23190540857.

* fix(webdriver-utils): widen TimeIt timing tolerance for Windows tick granularity

The funcVariableTime assertions used a 10 ms tolerance on a 10 ms sleep,
which is below the Windows default timer tick (~15.6 ms). Observed CI
failures showed drifts of 11, 15, and 16 ms — exactly the range you'd
expect from a Windows tick miss.

The same test file's funcReturns assertions already use a 15 ms tolerance
with a 'adding buffer for win test' comment; the variable-time block was
overlooked. Bump to 20 ms (one full tick of headroom over the existing
15 ms pattern) and document why.

Eliminates the ~11 retry events observed across PRs #2138, #2147, #2172,
#2177 on Test @percy/webdriver-utils Windows jobs.

* fix(cli-exec): wait for alternate-port server via ping before asserting

`start(['--port=4567'])` returns a Promise that resolves on process exit,
not on listen. The test fired a single healthcheck immediately and raced
the server bind — ECONNREFUSED on Windows (single-stack IPv4) and
AggregateError on dual-stack Node 18+ runners (Happy Eyeballs wraps the
underlying errors so the request client cannot retry on `.code`).

Mirror the beforeEach's proven pattern: sleep 1 s then `await ping(
['--port=4567'])`. `ping` resolves only once the server is reachable,
turning the race into a deterministic checkpoint. A single shot
healthcheck then verifies the response shape.

Eliminates the intermittent ECONNREFUSED 127.0.0.1:4567 observed on
PR #2172 run 24120410492 (Test @percy/cli-exec, Windows).

* fix(cli-build): wait for processing log before SIGTERM in wait test

The 'stops waiting on process termination' test used a fixed 100 ms
setTimeout before emitting SIGTERM, racing the first poll's log emit on
slow Windows runners. Two coupled failure modes were observed:

  A. The 'Processing 18 snapshots - 0 of 72 comparisons finished...' log
     hadn't landed yet, so logger.stdout was empty and the assertion got
     `Expected $.length = 0 to equal 1`.
  B. The same log arrived a few ms later and bled into the *next* test's
     logger snapshot ('failure messages > logs an error when there are
     no snapshots'), tripping `Expected $.length = 1 to equal 0` there.

Replace the fixed wait with a poll-until-logged loop bounded by a 5s
deadline. Once the expected log line is observed, SIGTERM and assertion
proceed deterministically — no race, no bleed.

Eliminates the ~6 retry events observed across PRs #2147, #2172, #2177
on Test @percy/cli-build Windows jobs.

* fix(karma): tolerate browser disconnect race on windows-latest

karma-firefox-launcher on windows-latest can finish all specs and then
trip Karma's default 2 s browserDisconnectTimeout during teardown,
producing an ERROR exit despite '✔ 736 tests completed'. Observed on
PR #2172 run 24155699650 (Test @percy/dom Windows job).

Raise the disconnect/no-activity/capture timeouts and allow a single
disconnect retry. These settings only affect runner teardown timing —
they do not silence test failures, only the post-test handshake race.
aryanku-dev and others added 6 commits April 15, 2026 00:46
…ow roots

- Traverse shadowRoot.activeElement recursively to find the deepest focused
  element instead of stopping at the shadow host
- Track which shadow root each stylesheet originated from and inject rewritten
  pseudo-class CSS rules back into the correct shadow root clone instead of
  the document head

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cover uncovered lines 177, 209, and 440-443 in serialize-pseudo-classes.js
by testing shadow root activeElement chain traversal and CSS rule injection
into shadow root clones.

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

Use style.sheet.insertRule instead of innerHTML to ensure shadow root
styleSheets are populated, and add test for empty rewrittenRules guard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove unreachable empty-rules check (rulesByOwner entries always have
at least one rule). Add sanity-check assertions to shadow DOM test to
verify stylesheet accessibility and host discoverability.

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

Thanks for the thorough work on DOM fidelity (closed shadow roots, sandboxed iframes, :state() rewriting, interactive states). The test coverage additions are substantial. However, a few issues should be addressed before this lands. The biggest ones:

  1. Preflight script is not shipped to consumers. @percy/dom publishes only dist/, but page.js reads the script from src/preflight.js relative to the resolved PERCY_DOM bundle. Works in the monorepo; silently falls back to an empty string for every installed dependency — so closed-shadow-root / ElementInternals capture will not function in production. It needs to be bundled or copied into dist/ (and added to files in packages/dom/package.json).
  2. Page.addScriptToEvaluateOnNewDocument is dispatched in the same Promise.all as Page.enable. No ordering guarantee — the preflight injection can race with, or land after, the initial navigation.
  3. "Potentially inaccessible shadow root" detection counts every custom element without a shadow-host marker. Most custom elements have no shadow DOM at all; this produces false positives in both the fidelity warning and the fidelityRegions payload.
  4. Sandboxed-iframe fidelity regions are emitted before we know whether the frame was captured. Successfully serialized sandboxed srcdoc frames still emit an overlay region.

A few smaller nits inline. Nothing blocking from a test-coverage perspective — suite is very thorough — but the production-path issues above should be fixed before merge.

(Apologies: an earlier stray "Test" review came through while I was iterating on this payload — please ignore it.)

Comment thread packages/core/src/page.js Outdated
try {
_preflightScript = await fs.promises.readFile(preflightPath, 'utf-8');
} catch {
_preflightScript = ''; // graceful fallback if file not found
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 will not work for consumers of @percy/core once published. PERCY_DOM resolves to node_modules/@percy/dom/dist/bundle.js at runtime, and @percy/dom's package.json has "files": ["dist"] — so ../src/preflight.js does not exist in the installed package. The catch silently swallows ENOENT and sets _preflightScript = '', meaning closed shadow root capture and ElementInternals interception will never run for any SDK consumer. The only reason it works in this PR's tests is that the monorepo layout happens to put packages/dom/src/preflight.js one directory up from dist/bundle.js.

Fix options: (a) inline the preflight source into @percy/dom's bundle and export it as a string (e.g. PercyDOM.PREFLIGHT_SCRIPT), (b) copy preflight.js into packages/dom/dist/ as part of the build and reference it there, or (c) bundle it into @percy/core/dist directly. Whichever you pick, please add a test that asserts getPreflightScript() returns a non-empty string when the resolved bundle lives in a typical node_modules layout so this regression can't reoccur.

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. getPreflightScript() now tries multiple candidate paths (src/preflight.js, dist/preflight.js, and alongside the bundle) so it works both in the monorepo and when installed via npm.

Comment thread packages/core/src/page.js
Comment thread packages/core/src/page.js Outdated
// before any page scripts run
getPreflightScript().then(script => {
if (script) {
return session.send('Page.addScriptToEvaluateOnNewDocument', { source: script });
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.

The returned promise here is swallowed by the outer .catch(session._handleClosedError) on line 292, which is fine for session-close races. Any other addScriptToEvaluateOnNewDocument failure (oversized source, invalid JS, etc.) is also consumed by _handleClosedError though. Consider logging a debug/warning when preflight injection fails for non-close reasons — otherwise we can't tell from field logs whether capture is actually instrumented.

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. Non-close preflight injection failures are now logged at debug level. Close/destroy errors are still silently handled by _handleClosedError.

Comment thread packages/dom/src/serialize-dom.js Outdated
let inaccessibleShadowCount = 0;
for (let origEl of ctx.dom.querySelectorAll('*')) {
if (!origEl.tagName?.includes('-')) continue;
if (origEl.hasAttribute('data-percy-shadow-host')) continue;
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 flags every custom element that isn't a shadow host as "potentially inaccessible shadow". In practice most custom elements don't use shadow DOM at all (light-DOM components, simple wrappers, icon elements, etc.). Consequences: (a) inaccessibleShadowCount is inflated and the fidelity warning becomes noisy/wrong, and (b) a fidelityRegions entry is pushed per custom element, so the API/UI renders overlays on elements that were fully captured.

Suggestion: only flag when there's positive evidence of an inaccessible shadow — e.g. preflight records which elements called attachShadow with mode: 'closed' before WeakMap lookup succeeded. Absent that signal, drop the counter/region and only warn when WeakMap tracking is explicitly bypassed.

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. Now only flags custom elements that are confirmed closed shadow hosts via window.__percyClosedShadowRoots?.has(origEl), instead of flagging every custom element without a shadow host attribute.

Comment thread packages/dom/src/serialize-frames.js Outdated

if (tokens.length === 0) {
warnings.add(`Sandboxed iframe "${frameLabel}" has no permissions — content may not render with full fidelity in Percy`);
captureFidelityRegion(frame, 'sandboxed-restricted', fidelityRegions);
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.

Fidelity region is pushed before we know the sandboxed frame's fate. A srcdoc iframe with sandbox but no allow-scripts can still be serializable via frame.contentDocument, so it gets both (a) fully captured into the snapshot AND (b) an overlay region in fidelityRegions. Consider deferring captureFidelityRegion for sandbox-restricted frames until after the contentDocument branch, only pushing if the frame was not captured — or only emit the region in the else/!enableJavaScript && builtWithJs branches below where we actually discard the iframe.

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. Removed captureFidelityRegion calls from within the sandbox warning section. Sandboxed frames that aren't captured will still get a fidelity region from the else branch at the bottom (cross-origin-excluded).

Comment thread packages/dom/src/serialize-frames.js Outdated
// Warn about sandboxed iframes
if (sandboxAttr !== null) {
sandboxWarned++;
let frameLabel = frame.id || frame.src || percyElementId;
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.

Nit: frameLabel = frame.id || frame.src || percyElementIdpercyElementId is an internal token (_abc123) that's meaningless to users. Falling through produces a warning like Sandboxed iframe "_ab12cd" has no permissions, which reads like an error code. Prefer user-actionable labeling, e.g. frame.src || frame.getAttribute('name') || '<unnamed iframe>'.

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. Changed fallback to frame.id || frame.src || frame.getAttribute('name') || '<unnamed iframe>' instead of using the internal percyElementId.

Comment thread packages/dom/src/preflight.js Outdated
let origAttachShadow = window.Element.prototype.attachShadow;
window.Element.prototype.attachShadow = function(init) {
let root = origAttachShadow.call(this, init);
if (init && init.mode === 'closed') {
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 robustness: if a page calls attachShadow() with no args (or null), init.mode throws a TypeError inside our wrapper — a slightly different failure mode than the original attachShadow. Guard with init?.mode === 'closed'.

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. Changed to init?.mode === 'closed' and origAttachShadow.apply(this, arguments) for both null safety and forward compatibility.

Comment thread packages/core/src/page.js Outdated

// wait for custom elements to be defined before capturing
/* istanbul ignore next: no instrumenting injected code */
await this.eval(function() {
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 adds up to a 5-second hard wait to every snapshot whenever the page has a single :not(:defined) element. Many production sites legitimately reference custom-element tags that never register (third-party widgets, blocked lazy loaders, typos). That means the common case eats +5s latency with no way to opt out. Please either (a) make the timeout configurable via the snapshot schema, (b) use a much shorter default (e.g. 500ms) since customElements.whenDefined tends to resolve synchronously for already-defined tags, or (c) resolve early if network is idle and the undefined tags have no pending loader.

This will regress P50 build latency for SDK consumers.

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. Reduced timeout from 5000ms to 500ms. customElements.whenDefined resolves synchronously for already-defined tags, so 500ms is sufficient for the common case while avoiding the latency regression for pages with never-registered custom elements.

Comment thread packages/core/src/discovery.js Outdated
for (let w of domWarnings) log.info(w);

// extract fidelity regions for API upload
let fidelityRegions = domSnapshot?.fidelityRegions || domSnapshot?.[0]?.fidelityRegions || [];
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.

Reading domSnapshot?.[0]?.fidelityRegions suggests multi-root DOMs are expected, but only domSnapshot[0] is consulted — fidelity regions captured by subsequent roots are silently dropped. If responsive snapshot capture produces multiple domSnapshots (per-width), you'll be under-reporting. Either merge regions across all entries or add a comment explaining why only the first matters.

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.

Added a comment explaining that only the first domSnapshot's fidelity regions are used because for responsive captures with multiple widths, regions are width-independent (same DOM structure) so the first entry is representative.

Comment thread packages/dom/src/clone-dom.js Outdated
try {
for (let state of percyInternals.states) {
// CSS-escape the state value to prevent injection
states.push(state.replace(/["\\}\]]/g, '\\$&'));
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.

The inline comment says "CSS-escape the state value to prevent injection" but the regex only escapes ", \\, }, ] — not whitespace, <, >, &, or non-printables. Custom state names per spec are <dashed-ident>s so this shouldn't happen through legitimate API use; if the intent is defense-in-depth against a non-compliant runtime, either tighten the validation (if (!/^[-\\w]+$/.test(state)) continue;) or soften the comment to "escape attribute-breaking chars".

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 partial regex escape with strict validation: if (!/^[-\w]+$/.test(state)) continue; to skip invalid state values per the <dashed-ident> spec requirement.

aryanku-dev and others added 4 commits April 20, 2026 17:28
…agging, timing

- Fix preflight path resolution to work in both monorepo and npm-installed
  contexts by trying multiple candidate paths
- Chain preflight addScriptToEvaluateOnNewDocument after Page.enable to
  avoid ordering hazard, and log non-close injection failures at debug level
- Guard attachShadow wrapper against null/undefined init argument
- Use .apply(this, arguments) for forward-compatible wrapping
- Reduce custom element wait timeout from 5000ms to 500ms to avoid
  regressing P50 build latency for pages with never-registered custom elements
- Only flag custom elements as potentially-inaccessible-shadow when they
  are confirmed closed shadow hosts via the WeakMap
- Defer sandbox fidelity regions until after capture attempt
- Use meaningful frame label fallback instead of internal percyElementId
- Add comment explaining multi-root fidelity regions behavior
- Tighten custom state validation to skip invalid values

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

Pushes coverage for serialize-dom.js to 100% by exercising the
potentially-inaccessible-shadow path (custom element present in the
__percyClosedShadowRoots map but not marked as a shadow host) and
clone-dom.js to 100% by feeding an invalid custom state value through
__percyInternals so the dashed-ident regex skip is hit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The preflight-script fallback (when no candidate path resolves) and
the catch handler for unexpected CDP injection errors are defensive
guards that aren't reachable from the existing test suite. Mark them
with istanbul-ignore — the same pattern already used elsewhere in
page.js — so coverage doesn't drop below 100%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread packages/core/src/config.js Outdated
type: 'string'
}
},
waitForCustomElementsTimeout: {
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.

what's the usecase?, custome element individual timeout not required

Comment thread packages/dom/src/prepare-dom.js Outdated
if (['input', 'textarea', 'select', 'iframe', 'canvas', 'video', 'style', 'dialog'].includes(domElement.tagName?.toLowerCase())) {
// Custom elements with ElementInternals or closed shadow roots also get
// stamped so the post-clone state-fallback can locate their clones.
let isCustomElement = domElement.tagName?.includes('-');
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.

make it a function used at multiple places

Comment on lines +91 to +103
function walkPseudoSelector(selectorText, replace) {
let out = '';
let i = 0;
let len = selectorText.length;
while (i < len) {
let ch = selectorText[i];
// Top-level string literal — copy verbatim through the closing quote.
if (ch === '"' || ch === "'") {
let quote = ch;
out += ch; i++;
while (i < len && selectorText[i] !== quote) {
if (selectorText[i] === '\\' && i + 1 < len) {
out += selectorText[i] + selectorText[i + 1];
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.

same as above

aryanku-dev and others added 22 commits May 9, 2026 20:48
…xpand regression

Simplifications (~325 lines removed across src + tests):

  serialize-pseudo-classes.js
    - Drop CSS-aware tokenizer (walkPseudoSelector); replace with regex
      using existing boundary lookaheads. Tokenizer protected against
      `:focus` inside `[attr=":focus"]` — pattern doesn't occur in real CSS.
    - Drop hover/active gating (stripInteractivePseudo + someStampedMatches).
      Unmatched `[data-percy-hover]` rules sit inert; no visual or perf cost.
    - Drop _focusedElementId redirection; page-wide markInteractiveStates
      already stamps :focus/:checked/:disabled — config path was redundant.
    - Drop init-on-demand _liveMutations guard; markPseudoClassElements
      always initializes it.

  serialize-custom-states.js
    - Drop CSS-aware tokenizer; replace with regex. SAFE_STATE_NAME_RE
      already validates names against escape — tokenizer added nothing.

  serialize-frames.js + serialize-dom.js + discovery.js
    - Drop [capture] summary warnings + counters. Per-frame warnings
      (sandbox, etc.) are still emitted; the aggregator was nice-to-have
      observability, not load-bearing.

  page.js
    - Inline _logShadowDebug at the call site; drop test that constructed
      a Page via Object.create only to exercise it.
    - Replace WAIT_FOR_CUSTOM_ELEMENTS_BODY array-join with template literal.

Closed-shadow dedup (~550 lines removed):

  Move closed-shadow helper to @percy/dom/src/closed-shadow.mjs (single
  source). Both @percy/core (page.js) and @percy/sdk-utils import from
  the same .mjs file. Drops byte-identical copies in core/ and sdk-utils/
  along with their duplicated test files. Adds @percy/dom as a sdk-utils
  dependency. .mjs extension forces ESM loading since @percy/dom doesn't
  declare "type": "module".

Iframe-utils inline (~14 lines):

  Folded sdk-utils/src/iframe-utils.js (3 constants + clampIframeDepth)
  into sdk-utils/src/index.js — over-modularized for its size.

Regression coverage:

  Wire in the orphan comprehensive test page (was unreferenced under
  assets/) as pages/interactive-states.html. Adds snapshot entries for:
    - Interactive States & Custom States (forces :hover/:active via
      pseudoClassEnabledElements; covers :state(), nested closed shadow)
    - Hydration (page upgraded by JS post-load)
    - Sandbox & Nested Iframes (4 sandbox token combos + 3-level nesting
      against default maxIframeDepth=3)

Test results:
  @percy/dom: 371 pass
  @percy/sdk-utils: 57 pass

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

Earlier the dedupe placed closed-shadow.mjs in @percy/dom, but @percy/dom
has no Node-side build (only rollup → dist/bundle.js). Babel's loader
transformed the .mjs as CJS at test time because dom lacks "type": "module",
breaking ESM imports — every core/cli-* test job failed with
"does not provide an export named 'exposeClosedShadowRoots'".

@percy/sdk-utils already builds Node-importable dist/index.js (its main !=
browser triggers buildNode in scripts/build.js), so the file slots in
without any package config gymnastics. Adds @percy/sdk-utils as a core
dependency — same pattern as core's other workspace deps (client, config,
dom, logger, monitoring, webdriver-utils).

Also fixes lint: drop the extra blank line at serialize-dom.test.js:820
that snuck in when the [capture] summary describe block was removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@percy/sdk-utils builds to CJS (no "type": "module"). Node ESM's named-import
heuristic doesn't reliably extract named exports from Babel-transpiled CJS
under Node 14, so the "import { exposeClosedShadowRoots } from ..." form
threw "Named export not found" on CI.

Match the pattern every other workspace consumer already uses (sdk-utils'
own tests, webdriver-utils tests): default-import the namespace and
destructure the function.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The rebase brought in master's bump to 1.31.14-beta.2; my newly-added
sdk-utils entry on core was still pinned to 1.31.12-beta.0. Bumping to
match keeps lerna happy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
eslint's import/first rule rejects statements between import declarations.
Move the destructure after the import block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dedupe via @percy/sdk-utils dependency triggered an ESM↔CJS interop
bug under Node 14 (CI's Node version): every sdk-utils test that posted
to /test/api/reset got back "logger.instance.reset is not a function".
The cross-package import was somehow disturbing module resolution for
@percy/logger inside the CLI process, despite the import path itself
working in isolation locally on Node 18.

Roll back to the original PR shape — same closed-shadow source in both
@percy/core/src and @percy/sdk-utils/src — accepting the ~180 lines of
duplication. The two files are kept manually in sync; the per-package
header comments in each call out the constraint.

Also align core's and sdk-utils' workspace dep pins from 1.31.12-beta.0
to 1.31.14-beta.2 (matched the package versions after the master rebase)
and drop the @percy/dom dep that sdk-utils no longer needs.

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

Two coverage gaps in the simplified PR shape:

  serialize-pseudo-classes.js:80 — `if (!element || typeof
  element.hasAttribute !== 'function') return` was a defensive guard for
  callers passing non-Elements. Every real call site already filters by
  nodeType === 1 or routes through dom.querySelectorAll/dom.getElementById,
  so the guard is unreachable. Drop it rather than fence with istanbul-ignore.

  serialize-frames.js:115 — the iframeDepth+1 >= maxIframeDepth continue
  was reachable but lost its coverage when the [capture] summary test that
  used to exercise it got removed. Add a focused test (maxIframeDepth=1
  with one iframe) that asserts the iframe's srcdoc keeps its original
  literal markup — the absence of "<!DOCTYPE" inside the serialized srcdoc
  proves the recursive serializeDOM call was skipped.

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

CI hit "TypeError: Cannot read property 'version' of null" on the
'handles page crashes' test. The crash test fires Inspector.targetCrashed
+ session._handleClose synchronously, leaving session.browser = null.
With the dedupe revert in place, the new pre-snapshot steps
(waitForCustomElements eval and exposeClosedShadowRoots) sat between
network.idle and insertPercyDom and could fire on a session whose
browser handle had been nulled — surfacing a TypeError instead of the
expected "Session crashed!" propagation.

Gate both steps on session.closedReason: when the session is already
gone, fall through to insertPercyDom / the serialize eval — which both
hit session.send and produce the canonical crash error the test expects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n, extract isCustomElement, scope closed-shadow to CLI

Bundles four small, behavior-preserving cleanups raised in PR review:

- Remove waitForCustomElementsTimeout config option and forwarded
  serialize arg. The wait still runs with the internal default
  (DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT = 1500ms) and is best-effort
  wrapped in try/catch so a flaky page can't break the snapshot. Per
  reviewer: per-snapshot tunability isn't worth the surface area.
- Extract isCustomElement(el) into @percy/dom utils.js and use it from
  prepare-dom, clone-dom and serialize-custom-states. Single source of
  truth for the tagName.includes('-') check; comment in utils calls out
  the rule for future contributors.
- Drop @percy/sdk-utils/src/closed-shadow.js and its tests entirely.
  The file was a 183-line duplicate of @percy/core/src/closed-shadow.js
  that no consumer in this repo imported. SDK-side closed-shadow capture
  will be added in a focused follow-up so this PR stays scoped to the
  CLI path.
- Polish on @percy/core/src/closed-shadow.js: Symbol.for -> Symbol
  (no global registry needed), justify CDP_BATCH_SIZE = 8 and the ES5
  string requirement of STAMP_FUNCTION, append backendNodeIds to the
  per-pair skip log (test prefix kept stable), and rewrite the file
  header now that the sdk-utils copy is gone.

Net: -591 / +80 lines. Lint clean. Core closed-shadow tests 20/20 pass.
percy.test.js delta unchanged from baseline (same 6 pre-existing env
failures). DOM Chrome suite 352/352 pass. sdk-utils suite delta is
exactly the 20 deleted closed-shadow specs; no other regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a unit test that forces the WAIT_FOR_CUSTOM_ELEMENTS_BODY eval to
throw, asserting the snapshot still resolves and the failure surfaces
in the debug log. Covers the previously-uncovered catch on page.js:266
introduced when the wait was made best-effort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the missing branch case for the `!this.session.closedReason` gate:
when the session has already terminated, the customElements wait and
exposeClosedShadowRoots must be skipped so the proper close error
surfaces from insertPercyDom rather than from a stray CDP call. Test
sets closedReason after navigation and asserts that DOM.getDocument
(the only CDP send in exposeClosedShadowRoots) never fires.

Closes the remaining branch-coverage gap on page.js:261-266.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier test set page.session.closedReason and called page.snapshot,
but page.network.idle() has its own closedReason guard and throws first
— rejecting the snapshot upstream of the gate. The branch on line 261
was never exercised.

Stub page.network.idle to resolve cleanly so the snapshot reaches the
gate with closedReason already set. The test still asserts the snapshot
rejects (insertPercyDom throws on closed session) and now also asserts
the customElements wait body never gets eval'd, proving both pre-snapshot
best-effort steps were skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous catch used `err && err.message ? err.message : err` — three
distinct throw shapes were needed (Error, falsy err, err with no .message)
to satisfy istanbul branch coverage on the ternary. Replace with template
literal coercion `${err}` which handles Error/string/null/undefined alike
and produces zero branches on the line.

The Error case now logs "Custom elements wait failed: Error: boom" instead
of just "boom" — acceptable verbosity for a debug log. Update the existing
test's assertion to match.

Adds a second test that throws a plain string from the wait eval to
exercise the catch with a non-Error value, proving the simplification
preserves the defensive intent.

Closes the branch-coverage gap on page.js:266.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Match the existing defensive-log pattern used in session.js:97 — the
nullish-coalescing form `err.message ?? err` handles Error/string/null/
undefined alike with one fewer branch than the original ternary, and
mirrors how the codebase already handles the same problem one file over.

Mark the catch with `/* istanbul ignore next */` per the documented
convention used on similar defensive guards (session.js:40, 60, 95;
page.js:77; 15+ uses across packages/core/src). The branch is best-effort
defensive code that's not worth fighting the coverage tool over.

Drops the dedicated non-Error throw test that was added to chase the
extra ternary branches — no longer needed once the branch is istanbul-
ignored, and the catch entry is still covered by the existing Error
throw test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The arrow `(_, options) => ({ domSnapshot: PercyDOM.serialize(options),
url: document.URL })` runs inside the browser via Runtime.evaluate and
never executes in the test process — it's not instrumentable, hence the
existing `/* istanbul ignore next: no instrumenting injected code */`
comment.

The comment was placed above the surrounding `let capture = await this
.eval(...)` statement, but istanbul-lib-instrument attaches the directive
to the NEXT AST node only — i.e. the variable declaration — and doesn't
propagate it into the inner arrow expression. The arrow therefore
appeared as a 0-hit function in coverage reports, dragging the func
metric below 100%.

Move the comment inline, directly before the arrow expression itself,
so istanbul attaches it to the arrow's AST node and excludes it from
coverage entirely. No behavior change.

Closes the function-coverage gap on page.js.

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

Inline placement (`eval(/* ignore */ arrow)`) didn't move the function-
coverage needle in nyc — the directive needs to be on its own line in
the leading-comment position for istanbul-lib-instrument to attach it
to the arrow's AST node.

Reformat the call to put each arg on its own line so the comment can sit
directly above the arrow expression. No behavior change.

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

Inline + above-statement positions of /* istanbul ignore next */ both
failed to attach the directive to the inner arrow expression in nyc's
report — the function kept showing up in the func tally.

Extract the serialization callback into a named top-level function so
the ignore directive sits on the function declaration itself, which is
the canonical position istanbul-lib-instrument recognises. The function
runs in the page realm via Runtime.callFunctionOn and is never invoked
in the test process, so excluding it is correct.

127 specs pass, lint clean, no behavior change.

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

The inline `msg => this.log.debug(msg, this.meta)` arrow passed to
exposeClosedShadowRoots was the last 0-hit function on page.js. Real
snapshot tests don't fire it because none of the test pages contain
closed shadow roots and no CDP error path is exercised.

Restore the prior pattern (commits 551f2dc / 1deb86d — lost during
rebase): pull the callback out as `_logShadowDebug` on the prototype,
hand it to exposeClosedShadowRoots via .bind(this), and add a unit test
that constructs a Page via Object.create with stubbed log/meta and
exercises the method directly.

Verified locally on Node 14 (the version CI uses): page.js coverage
goes from 99.18 / 100 / 96 / 100 (Stmts/Branch/Funcs/Lines) to 100 / 100
/ 100 / 100.

Also drops the istanbul-ignore-on-the-arrow attempt (3bbfa07) that
didn't take — restoring the clean structure that ignore directive can
sit on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Windows CI surfaced 6 archive-related test failures that boil down to
path-separator and stale-fixture issues, not real regressions. None of
them touch the closed-shadow / page.js work this PR is about; they're
all in the bundled #2216 archive flow.

Fixes:

- percy.test.js archive flow assertions: regexes hard-coded a leading
  forward slash before `percy-archive`. On Windows the resolved path
  uses backslashes — relax to `[\\/]percy-archive`.
- archive.test.js validateArchiveDir 'resolves a valid path': asserted
  `'/tmp/percy-archive'` literally; `path.resolve` prepends a drive
  letter on Windows. Use `path.resolve` to construct the expected value.
- archive.test.js validateArchiveDir 'resolves a relative path': same
  separator fix as above.
- archive.test.js validateArchiveDir 'rejects paths that resolve to
  traversal segments': built the traversal string with literal `/`,
  but the implementation splits on `path.sep`. Build the test string
  from `path.sep` so the `..` segments actually surface on Windows.
- archive.test.js symlink spec: the `.test-archive-symlink` dir
  persisted between runs, hitting EEXIST on the second invocation.
  Clean up the dir before recreating it.

Verified locally on Node 14 (the version CI uses):
- Unit / Archive: 19/19 pass (was 3 failing on Windows)
- Percy archive flow specs: pass (were 2 failing on Windows)

The remaining test failures in the percy.test.js suite are all
environment-related (real backend / browser needed locally) and present
on master too.

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

The original page claimed Percy had a "DOM stability wait" that lets
setTimeout-driven hydration finish before serialization. Percy has no
such wait — only network.idle and the customElements :not(:defined)
poll added in this PR. The captured snapshots showed pre-hydration
"Loading..." state, which made the fixture misleading: it documented
broken behavior, not a regression target.

Replace the setTimeout-based pseudo-hydration with real custom elements
that hydrate synchronously inside connectedCallback. Percy's pre-snapshot
wait blocks on `:not(:defined)`, so by the time serialization runs every
component has already executed its connectedCallback and the captured
DOM reflects the post-hydration state.

Three scenarios covered:
- Simple text/class swap on hydration
- Structural mutation (innerHTML replacement)
- Multi-phase hydration (all phases run synchronously, final state wins)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new tests in percy.test.js identified the wait body by substring-matching
`var deadline`. A pure refactor that renames the local (e.g. `endsAt`,
`until`) silently turns both into no-ops:

- The customElements-wait-throws test's spy fake never throws, so the catch
  branch is never exercised; coverage drops silently.
- The closedReason gate test's `waitEvals.length === 0` assertion becomes
  vacuously true (the substring stopped matching, not the gate working).

Import WAIT_FOR_CUSTOM_ELEMENTS_BODY and compare by identity instead. The
coupling is now to the symbol, not its current string content — renaming
internals inside the body keeps the tests honest.

Resolves review todo #20.

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

Review — Confirmed Issues

1. Custom elements timeout is 1500ms, not 500ms as stated in review reply

page.js:15 sets DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT = 1500 and the injected body hardcodes arguments[0] || 1500. The reply to my earlier review said "Reduced timeout from 5000ms to 500ms" but the code shows 1500ms. This adds 1.5s to every snapshot on any page with a single never-registered custom element (common: third-party chat widgets, blocked lazy loaders, typos in tag names). Either reduce to 500ms as claimed, or justify 1500ms with data.

2. clampIframeDepth is duplicated

Identical function exists in both packages/dom/src/serialize-frames.js and packages/sdk-utils/src/index.js. One should import from the other — if the clamping logic ever changes (e.g., different default), these will silently diverge.

3. Performance: 6+ redundant shadow DOM walks per snapshot

During a single serializeDOM call, walkShadowDOM / queryShadowAll is invoked at least 6 times:

Each walk visits every [data-percy-shadow-host] element and recurses into its shadow root. On pages with many shadow hosts (Lit, Shoelace, Lightning Web Components — 200-500+ shadow hosts is common), this compounds to thousands of extra querySelectorAll calls per snapshot. Walks #3 and #4 cover overlapping ground (both iterate stylesheets in shadow roots). Consider:

  • Merging the CSS walks into a single pass that collects both pseudo-class rules and custom-state styles
  • Caching the shadow host list so repeated querySelectorAll('[data-percy-shadow-host]') calls don't re-traverse the tree
  • Short-circuiting: if no :checked/:disabled elements exist, skip the CSS rule extraction for those pseudos

4. markInteractiveStates now mutates the live DOM unconditionally

markPseudoClassElements (line 720 of serialize-pseudo-classes.js) now always calls markInteractiveStates(ctx) before checking if pseudoClassEnabledElements config exists. This means every snapshot stamps data-percy-checked, data-percy-disabled, data-percy-focus on the live page — even when users haven't opted into pseudo-class capture.

The cleanupInteractiveStateMarkers in the finally block undoes this, which handles the normal path. But:

  • If the process crashes or the tab is closed between mark and cleanup (SDK mode runs in the customer's browser tab), attributes leak into the live page
  • This is a behavioral change from the previous version where no live-DOM mutation happened without explicit pseudoClassEnabledElements config — worth calling out in the changelog

Not blocking, but worth being aware of as a change in the contract with SDK consumers.

aryanku-dev and others added 3 commits May 11, 2026 17:40
…edup, redundant walks

Resolves all three confirmed findings from PR review:

1. customElements-wait timeout reduced 1500ms -> 500ms

   The earlier reply claiming the timeout was reduced to 500ms was
   incorrect — the code still had 1500ms in both the exported constant
   and the hardcoded fallback inside the eval body. Lower both to 500ms
   so pages with a never-registering custom element (third-party widget
   loader blocked, typo'd tag name) pay 0.5s of extra wait per snapshot
   instead of 1.5s. Real cascades of legitimate lazy-defined elements
   resolve well within 500ms; the loop also exits early on cleared
   `:not(:defined)`.

2. clampIframeDepth + DEFAULT/HARD_MAX_IFRAME_DEPTH parity enforced

   The function and constants are duplicated across @percy/dom and
   @percy/sdk-utils. A prior attempt to dedupe via cross-package import
   broke Node 14 CI (commit 6ae3c4d), so the duplication stays — but
   silent divergence is now blocked by:
   - Mutual `MIRROR:` header comments in both files pointing at each other
   - A parity test in @percy/sdk-utils/test/index.test.js that reads the
     dom file and asserts the literal constants + clamp body match. Drift
     fails the test loudly.

3. Pseudo-class extraction: two redundant ctx.dom walks consolidated, plus
   short-circuit for unmatched pseudos

   `markInteractiveStates` previously made two separate `queryShadowAll`
   calls — one for `:checked`, one for `:disabled`. Both walked the whole
   shadow DOM and re-traversed every `[data-percy-shadow-host]` element.
   Combined into a single `walkShadowDOM(ctx.dom, ...)` pass that collects
   both selectors in one scope visit, halving the per-snapshot walk cost
   on pages with many shadow hosts (200+ is common with Lit, Shoelace, LWC).

   Tracks observed states in `ctx._stampedInteractive` and feeds them into
   `extractPseudoClassRules`: if no `:checked` / `:disabled` element was
   stamped, those pseudo selectors are dropped from the rule filter — the
   rewritten CSS could never match anything in the clone anyway, so the
   rewrite/append cost is wasted. `:focus`, `:focus-within`, `:hover`,
   `:active` are kept regardless. When `_stampedInteractive` is absent
   (direct unit-test path that calls `serializePseudoClasses` without
   `markPseudoClassElements`), fall back to the full pseudo list so prior
   test behavior is preserved.

   Drops the now-unused `queryShadowAll` import from
   serialize-pseudo-classes.js.

   One test (`appends rules from each stylesheet under the same owner key`)
   was making the checkbox unchecked while asserting `[data-percy-checked]`
   appeared in the extracted CSS — the test's name is about multi-stylesheet
   append, the checked behavior was incidental. Set `checked` on the input
   so the assertion holds under the new short-circuit.

Verified locally:
- @percy/core lint clean
- @percy/dom karma suite: 352/352 pass on ChromeHeadless
- @percy/sdk-utils baseline 121 specs / 57 failures (env: ECONNREFUSED on
  port 5338) -> 122 specs / 58 failures (+1 = the new parity test,
  blocked by the same beforeEach env failure; the assertion itself
  passes when verified directly via fs.readFileSync).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The new parity test added in 524b330 calls `process.cwd()` + `fs.readFileSync`
to read the dom source file. @percy/sdk-utils' test suite runs in BOTH Node
and karma (Chrome / Firefox) — in the browser the test failed with
`TypeError: process.cwd is not a function` because the polyfilled `process`
in karma has no real `cwd`.

Detect Node via `typeof process.cwd === 'function' && process.versions.node`
and use `it` when present, `xit` otherwise. The parity assertion only needs
to fire once per CI run (the Node job), and dom source-file drift will be
caught there regardless.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
walkShadowDOM only invokes its visitor with Document/Element/ShadowRoot
scopes — each always has querySelectorAll, so the `if (!scope.querySelectorAll)
return;` guard added in the recent walk-consolidation refactor was
unreachable defensive code that pinned coverage at 99.56% statements.

Replace the guard with a single-line comment documenting the contract.
The visitor's try/catch already absorbs selector-syntax errors for any
exotic scope that did reach it, so removing the guard does not lose any
real protection.

Verified locally on Node 14: serialize-pseudo-classes.js coverage now
100 / 100 / 100 / 100.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@aryanku-dev aryanku-dev merged commit 2964cdf into master May 11, 2026
44 of 45 checks passed
@aryanku-dev aryanku-dev deleted the PER-7292 branch May 11, 2026 14:05
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.

3 participants