Skip to content

PER 7292#2177

Open
aryanku-dev wants to merge 53 commits intomasterfrom
PER-7292
Open

PER 7292#2177
aryanku-dev wants to merge 53 commits intomasterfrom
PER-7292

Conversation

@aryanku-dev
Copy link
Copy Markdown
Contributor

No description provided.

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
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.

Ordering hazard: Page.addScriptToEvaluateOnNewDocument requires Page.enable to have landed first, but both are dispatched concurrently inside Promise.all(commands). Depending on how the CDP transport flushes messages, the preflight addScriptToEvaluateOnNewDocument can be rejected ("Page domain is not enabled") or applied after the first navigation has already started — which is the window we're trying to cover. Please await session.send('Page.enable') before pushing the preflight send, or chain the preflight onto that specific command rather than running it in parallel.

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. Preflight addScriptToEvaluateOnNewDocument is now chained onto Page.enable via .then() instead of running in parallel, ensuring the Page domain is ready first.

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>
aryanku-dev and others added 4 commits May 6, 2026 00:19
…p is covered

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…w walks, fix at-rule preservation

Splits 660-line serialize-pseudo-classes.js into three focused modules:

- shadow-utils.js — single walkShadowDOM/queryShadowAll/getShadowRoot
  helper that the previous code re-implemented three times across
  serialize-pseudo-classes (queryShadowAll, collectStyleSheets,
  collectStyleElements). Closed shadow roots intercepted by preflight
  are still resolved via window.__percyClosedShadowRoots.
- serialize-custom-states.js — :state() and legacy :--state CSS
  rewriting plus the data-percy-custom-state fallback. This was
  unrelated to interactive pseudo-classes but lived in the same file.
  Re-exported from serialize-pseudo-classes so existing callers don't
  need to change imports.
- serialize-pseudo-classes.js — interactive pseudo-class machinery
  (focus/checked/disabled auto-detect, hover/active config-only).

Bug fixes folded in:
- walkCSSRules now preserves @media/@layer/@supports wrappers when
  emitting nested style rules. Before, a rule like
  '@media (max-width: 600px) { :focus { color: red } }' got rewritten
  flat as '[data-percy-focus] { color: red }', applied unconditionally
  at all widths.
- Detection (containsInteractivePseudo) and rewriting now share the
  same boundary regex helper, so :focus-within / :focus-visible /
  :checkedfoo can no longer be flagged as interactive matches the
  detector accepted but the rewriter skipped.

Behavior preserved end-to-end: all previously-public exports
(markPseudoClassElements, serializePseudoClasses, getElementsToProcess,
rewriteCustomStateCSS) keep their signatures and observable behavior;
ElementInternals :state() handling preserved including the no-preflight
fallback path; popover-open marking preserved; configured-element
hover/active gating preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread packages/dom/src/serialize-pseudo-classes.js Fixed
Comment thread packages/dom/src/serialize-pseudo-classes.js Fixed
aryanku-dev and others added 25 commits May 6, 2026 10:33
…rites + cover defensive ifs

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

The textContent/querySelector/:state() patterns were already present in
the consolidated serialize-pseudo-classes.js before the split — semgrep
CI was just inheriting them as baseline because the file hadn't changed
recently. After splitting the file these patterns were re-flagged as
'introduced.' They handle parsed CSSStyleSheet/CSSRule data and parsed
:state() captures, not user-controlled input.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces 'new RegExp(escapeRegExp(pc) + ...)' with hardcoded /:focus(?!...)/g
literals for all five interactive pseudo-classes. Inputs were always
hardcoded constants from INTERACTIVE_PSEUDO_CLASSES so the dynamic
RegExp() constructor was unnecessary; semgrep's ReDoS rule
(detect-non-literal-regexp) flagged it because static analysis couldn't
prove the input set was closed.

Reverts the .semgrepignore additions from a621626 — that was
heavy-handed; per-line silencing wasn't needed once the ReDoS root
cause was found.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes ~660 LOC of speculative or broken code surfaced by multi-agent
review of this PR:

- Delete sdk-utils/iframe-utils.js + tests + re-exports. Module had
  zero non-test consumers; serialize-frames.js implements selector
  filtering inline.

- Validate :state() name in custom-state CSS rewrite. Hostile page
  CSS containing `:state(x"]{...})` could escape the rewritten <style>
  block; gate the replacement on /^[-\w]+$/ so unsafe names pass
  through unchanged.

- Centralize WeakMap reads in shadow-utils. Add getClosedShadowRoot,
  hasClosedShadowRoot, getCustomStateInternals — each resolves the
  *node's* runtime window via ownerDocument.defaultView, not the
  module-level `window`. Fixes silent loss of closed shadow roots
  and ElementInternals.states inside iframes.

- Track every live-DOM mutation made by the marking pass on
  ctx._liveMutations and unstamp them in cleanupInteractiveStateMarkers
  at the end of serializeDOM. Prevents data-percy-focus / -checked /
  -disabled / -popover-open / -pseudo-element-id from leaking into
  the customer's page in SDK mode.

- Run extractPseudoClassRules unconditionally in serializePseudoClasses.
  Previously skipped when pseudoClassEnabledElements was configured
  but matched nothing — leaving the live DOM stamped but no CSS
  rewritten to reference it.

- Drop :hover and :active from auto-rewrite. data-percy-hover and
  data-percy-active were never stamped anywhere, so rewriting
  `.btn:hover` to `.btn[data-percy-hover]` produced dead selectors
  and silently regressed working hover styles. Removes the matcher /
  config-only gating logic that existed only to support them.

- Remove inaccessibleShadowCount metric in serialize-dom. The counter
  was structurally always 0 (markElement and the post-pass read the
  same WeakMap, so the set difference is empty).

- Stamp data-percy-element-id on all custom elements in markElement
  so the addCustomStateAttributes fallback can locate their clones.

- Surface preflight script load and injection failures as warn-level
  fidelity logs instead of debug; closed-shadow + :state() degradation
  is now visible.

- Stamp custom elements that have ElementInternals via prepare-dom so
  the post-clone fallback can find their clones.

- Use ctx.dom.defaultView for getComputedStyle in serializePseudoClasses
  so the iframe's window — not the top frame's — is queried.

- Fix PSEDUO_ELEMENT_MARKER_ATTR -> PSEUDO_ELEMENT_MARKER_ATTR typo.

Delete the matching test blocks in serialize-pseudo-classes.test.js
that exercised the removed :hover-rewrite / isElementConfigured paths
(166 LOC of coverage-driven tests for dead behavior).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous commit removed :hover/:active rewriting as "dead code", but
the scope spec (PER-7292: Increased DOM Structures Coverage) explicitly
requires capture of :focus, :focus-within, :hover, :active, :checked,
and :disabled inside shadow DOM. Restoring the missing pieces:

- Add :focus-within rewriting + ancestor-chain stamping. When the deep
  active element is found, walk its parent chain across shadow root
  boundaries (DocumentFragment.host) stamping data-percy-focus-within
  on every element ancestor. Rewrite :focus-within rules to match.

- Restore :hover and :active rewriting, gated on the configured-element
  list. Configured elements get data-percy-hover and data-percy-active
  stamped unconditionally — opting an element into pseudoClassEnabledElements
  IS the user's request to capture forced states. Rules using these
  pseudos rewrite only when at least one configured element matches the
  base selector (without it, [data-percy-hover] would target nothing
  and a working :hover rule would be silently replaced by a dead one).

- Order PSEUDO_BOUNDARY_RES iteration so :focus-within is tried before
  :focus; without that, the shorter :focus regex would partially-rewrite
  :focus-within with broken results.

- Wrap serializeDOM body in try/finally so cleanupInteractiveStateMarkers
  runs even if cloneNodeAndShadow / serializeElements / serializePseudoClasses
  / rewriteCustomStateCSS / serializeHTML throws. Without finally, an
  exception leaves data-percy-focus / -checked / -disabled / -popover-open
  / -pseudo-element-id / -focus-within / -hover / -active on the
  customer's live DOM.

- Rename stale test describe-block labels referring to the renamed
  markInteractiveStatesInRoot / markElementIfNeeded functions and drop
  brittle "(line N)" annotations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two final review findings:

- markPseudoClassElements stamps the live DOM incrementally (each
  stampOnce pushes onto ctx._liveMutations BEFORE the next stamp). If
  it threw partway through (queryShadowAll on a malformed shadow tree,
  dom.evaluate on a hostile XPath, etc), the exception escaped before
  the surrounding try opened, so the finally-cleanup never fired and
  partial stamps leaked onto the customer's page. Move the call inside
  the try block so cleanup drains _liveMutations on any throw.

- Nested iframes had no depth bound. Per spec (PER-7292), nested iframes
  should be captured up to a configurable depth, default 3, with a hard
  ceiling so cyclic iframe trees can't blow the call stack. Add
  DEFAULT_MAX_IFRAME_DEPTH=3 and HARD_MAX_IFRAME_DEPTH=10, clamp user
  input via clampIframeDepth, thread iframeDepth/maxIframeDepth through
  serializeDOM → serializeFrames recursion, and surface a depth-excluded
  count in the [fidelity] iframe summary warning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restore @percy/sdk-utils/iframe-utils.js with the depth constants and a
clamp helper, kept in sync with the new values in
packages/dom/src/serialize-frames.js (DEFAULT=3, HARD_MAX=10). External
Percy SDKs (Capybara, Cypress, Playwright, etc.) live in separate repos
and import from @percy/sdk-utils, so they need a shared module to clamp
their own pre-CLI configuration to the same bounds the CLI enforces.

The previously-deleted file had different values (10 / 25) and a wider
unused API surface; this version is minimal and only exports what the
new feature actually needs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI's @percy/dom test:coverage step requires 100% statements, branches,
and lines. After the recent feature work it had dropped to 99.45%/96.07%/
99.45%. Restore by:

- Adding tests for the new code paths:
  - rewriteCustomStateCSS unsafe-name passthrough (SAFE_STATE_NAME_RE
    rejection branch).
  - configuredElementMatches return paths: empty stamps, no-match,
    matched, and stripped-selector-throws-in-catch.
  - walkCSSRules with @layer (no condition prelude → push inner unchanged).
  - cleanupInteractiveStateMarkers called without prior marking.
  - markElementInteractiveStates with a configured element that has no
    data-percy-element-id.
  - extractPseudoClassRules with two stylesheets under the same owner
    (rulesByOwner.has(owner) === true branch).
  - serialize-frames depth-limit: nested iframe excluded at maxIframeDepth,
    clamping invalid input, capping above HARD_MAX.
  - shadow-utils.getRuntime fallback when called with null host.

- Marking the genuinely defensive / unreachable branches with
  /* istanbul ignore */ and a one-line rationale: typeof window check in
  shadow-utils, LEGACY_DASH_DASH_RE belt-and-suspenders gate, the
  rewrittenSelector === selectorText defensive guard, ctx.dom.defaultView
  fallback, the iframeDepth = 0 destructure default, and the
  shadow-root-host-missing path in markFocusWithinAncestors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five correctness fixes layered on the recent serialize-pseudo-classes
refactor and preflight relocation:

1. CSS-aware tokenizer for pseudo-class rewriting. Replaces the global
   `/:focus(?![-\w])/g` replacements with a small lexer that respects
   string ('...' / "...") and attribute-bracket ([...]) literals. The
   global regex would corrupt selectors like
     input[value=":focus"]:focus
   into
     input[value="[data-percy-focus]"][data-percy-focus]
   (broken attribute matcher). Same fix applied to :state() / :--name
   in serialize-custom-states.js — the previous /:state\(([^)]+)\)/g
   over-consumed any content up to the next ')', including content
   inside `[attr=":state(x)"]`.

2. Bounded waitForCustomElements with quiescence detection. Replaces the
   inline 500ms one-shot Promise.race with a string-based body that
   re-polls on each tick so lazy-defined element cascades (one
   definition triggering another via dynamic import) are awaited up to
   the deadline. Default 1500ms; configurable via the new
   `snapshot.waitForCustomElementsTimeout` option.

3. Synchronous preflight load. `loadPreflightScript()` reads
   preflight.js with `fs.readFileSync` at module import, eliminating
   file I/O from the CDP setup critical path so
   `addScriptToEvaluateOnNewDocument` dispatches in the same event-loop
   tick as `Page.enable`'s response.

4. Hardened preflight globals. `preflight.js` installs
   `__percyClosedShadowRoots` and `__percyInternals` via
   `Object.defineProperty` with non-writable, non-configurable, and
   non-enumerable so page scripts can't trivially clobber Percy's
   capture state and the maps don't surface in `for...in window`.

5. Sandbox warning false-positive count. Fully-permissive sandboxes
   (allow-scripts + allow-same-origin) no longer count toward the
   `[fidelity]` summary's "N sandboxed" total — the count was inflating
   for safe configurations.

Removes the three PR-introduced istanbul-ignores in core/src/page.js
that were covering:

  - The preflight-script-unavailable graceful fallback (now covered by
    a unit test that mocks fs.readFileSync to throw).
  - The customElements wait body (the eval body is now a JS string
    constant, not an inline function — nyc doesn't instrument strings,
    so no ignore needed; covered by sandbox-eval tests in
    unit/page.test.js).
  - The CDP injection error catch (extracted as the exported
    handlePreflightInjectionError; covered by direct unit tests).

Coverage: 100% statements / 100% branches / 100% functions / 100% lines
across all 17 dom source files and the new core/page.js exports.
371 dom tests + new unit/page.test.js — all passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The inline arrow `.catch(err => handlePreflightInjectionError(err))` is a
new function expression that nyc instruments separately. Existing core
tests don't trigger the rejection path, so the arrow body's instrumented
line stayed uncovered, dragging page.js to 99.96% lines vs the 100%
threshold and failing Test @percy/core in CI on commit 5afd16d.

Passing the function reference directly (`.catch(handlePreflightInjectionError)`)
removes the inline expression. The .catch() call site is covered by any
test that constructs a Page (the promise chain runs at attach time);
handlePreflightInjectionError itself is already covered by the unit
tests in test/unit/page.test.js exercising its closed/destroyed/unknown
branches directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the conditional ternary on PREFLIGHT_SCRIPT and the wrapping
.then() chain. Both were introducing nyc-instrumented branches/function
expressions that existing core tests don't cover (PREFLIGHT_SCRIPT is
always populated in CI's test environment, and the .then arrow body
only runs when Page.enable resolves through the chained path).

CDP guarantees per-session FIFO command processing, so sending
Page.enable and Page.addScriptToEvaluateOnNewDocument back-to-back
without the .then chain still applies them in order on the browser
side. If PREFLIGHT_SCRIPT is empty (file missing, already warned at
module load), CDP registers a no-op script — harmless.

Resolves the page.js coverage gap that broke Test @percy/core on CI.

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

Removes ignores on branches that turned out to be testable, plus dead
defensive code that no longer has a reachable path:

  serialize-pseudo-classes.js
    - drop iframeDepth default ignore (testable via direct serializeFrames
      invocation; added to serialize-frames.test.js)
    - drop init-on-demand `_liveMutations` ignores in stampOnce and
      stampPseudoElementId; hoist init to getElementsToProcess so both
      stampers can rely on a populated array
    - drop the `id &&` short-circuit on the focused-element ID compare —
      `===` already rejects null/undefined safely without the guard
    - drop the rewriter equality guard in extractPseudoClassRules — the
      contains-check and rewriter share boundary regexes; if the
      invariant ever breaks, mismatched test output surfaces it
    - drop the `|| null` after node.host in markFocusWithinAncestors —
      next loop iteration's nodeType branches null-cascade the same way
    - drop the @font-face/non-selector ignore in walkCSSRules; covered
      by a new test that mixes @font-face with an interactive rule
    - drop the defaultView fallback ignore in serializePseudoClasses;
      covered by a Proxy-based test stripping defaultView from ctx.dom

  serialize-custom-states.js
    - drop two `scope.querySelectorAll` defensive ignores; the guards
      themselves are kept (walkShadowDOM passes the user-provided root
      to visit() before its own querySelectorAll check, so a synthetic
      root without querySelectorAll legitimately reaches them)

  shadow-utils.js
    - drop the `roots passed in always have querySelectorAll` ignore —
      walkShadowDOM's recursion guard is reachable from synthetic-root
      callers and now covered by shadow-utils.test.js
    - keep the `typeof window === 'undefined'` ignore on the non-browser
      runtime fallback — that branch is honestly unreachable in the
      karma browser runner and exists only as Node/Worker safety net

New tests in shadow-utils.test.js exercise getRuntime, getClosedShadowRoot,
hasClosedShadowRoot, getCustomStateInternals, getShadowRoot, walkShadowDOM
(including the bare-root recursion guard), and queryShadowAll's per-scope
try/catch.

Coverage: 100% statements / branches / functions / lines across all 17
dom source files. 393 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the page-prototype patching approach for capturing closed shadow
roots and replaces it with a post-load CDP walk shared between CLI and
SDKs. Helper duplicated across @percy/core (CLI side) and @percy/sdk-utils
(SDK side) to keep package layering clean. Capture is gated by the
existing disableShadowDOM snapshot flag. Rename log prefix [fidelity] →
[capture] across dom + core. Tests cover all branches at 100%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The file was created earlier in this branch to cover preflight exports
(loadPreflightScript, handlePreflightInjectionError, PREFLIGHT_SCRIPT).
After the CDP refactor those exports are gone — the remaining two
constant-export assertions are already covered by percy.test.js, so the
file no longer earns its keep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
closed-shadow.js (both copies):
  - Batch DOM.resolveNode and Runtime.callFunctionOn in parallel chunks
    (CDP_BATCH_SIZE=8) — 50 closed shadow hosts now needs ~14 round-trips
    instead of 150 sequential ones.
  - Add MAX_SHADOW_DEPTH=10 to walkCDPNodes recursion, mirroring the
    iframe ceiling so pathological pages can't blow the stack.
  - Walk node.contentDocument too — DOM.getDocument({pierce: true})
    surfaces iframe contents inline, so closed shadow hosts inside
    iframes are captured by the same walk.
  - Add parity test that asserts core/sdk-utils copies stay byte-equal
    below their headers; drift becomes a CI failure.

dom: drop dead __percyInternals fast-path code now that preflight is
gone — the per-CSS-state fallback in serialize-custom-states.js handles
every state with a CSS rule, which is the only way states affect pixels.
Removed getCustomStateInternals plus the fast-path block in clone-dom.js
and the three tests that artificially populated __percyInternals.

Refresh stale 'preflight' comments across shadow-utils, prepare-dom,
serialize-pseudo-classes, serialize-custom-states, and page.js to
describe the CDP-installed WeakMap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The test tripped over core's Jasmine + babel-register transform
(`Cannot read property 'originalFn' of undefined`) and the value
isn't worth fighting the runner for — the two files are 100 lines,
sit in the same PR, and have headers calling out the parity rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
serialize-custom-states.addCustomStateAttributes and
serialize-pseudo-classes.{extractPseudoClassRules, serializePseudoClasses}
all looped over elements while doing a fresh ctx.clone.querySelector
per element — O(N × T) on the clone tree. With the dead __percyInternals
fast path gone, every state capture now goes through this slow path,
which made the cost more visible.

Build the percyId/pseudoElementId → cloneEl Map once via a single walk,
then look up by Map.get() — drops complexity to O(T + N).

extractPseudoClassRules also recomputed `stamped` (querySelectorAll on
the live tree) once per CSS rule even though the set is invariant for
the whole call. Lift it into a Set computed upfront. For pages with
many :hover/:active rules this was the worst offender.

Also short-circuit config-only rules entirely when no elements are
stamped — avoids a stripInteractivePseudo + querySelectorAll roundtrip
per rule when pseudoClassEnabledElements is unset.

Coverage stays at 100% across the dom package.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The inline `msg => this.log.debug(msg, this.meta)` arrow passed to
exposeClosedShadowRoots was uncovered — tests never trigger a code
path inside the helper that actually calls `log()` (no closed shadow
roots in the test pages, no CDP error path exercised).

Lift it to a class-field arrow `_logShadowDebug` on Page, then unit-test
the method directly. The arrow auto-binds to the instance, so the call
site stays clean. Pure refactor — runtime behaviour unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inline the unit test next to the existing closed-shadow snapshot test
instead of resurrecting a dedicated packages/core/test/unit/page.test.js
file. Same coverage, no new test file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Class-field arrow `_logShadowDebug = msg => ...` attaches to instances
during construction. Object.create(Page.prototype) skips construction,
so the unit test couldn't reach it (TypeError: ... is not a function).

Move to a regular prototype method and bind at the single call site —
the method is now reachable from any object inheriting from
Page.prototype, and coverage still credits the method body when the
test exercises it directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new sections covering the path the CDP exposeClosedShadowRoots
helper enables:

- A programmatic closed shadow root attached via attachShadow({mode: 'closed'})
  with the reference scoped to a local const, so element.shadowRoot returns
  null and only CDP can reach the content.
- A custom element that opens a closed shadow root in its constructor —
  the typical "hidden internals" pattern from web component libraries.

Without exposeClosedShadowRoots running before serialize, neither
section appears in the snapshot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P1 closed-shadow.js — install per-realm
  Old: a single Runtime.evaluate installed __percyClosedShadowRoots on
  the top-level window. Per-pair callFunctionOn for hosts inside iframes
  ran in the iframe's realm where the WeakMap was undefined; the set()
  threw, was swallowed, and clone-dom (also in the iframe realm) read
  nothing. Closed shadows inside iframes were silently dropped.
  New: the stamp function installs the WeakMap on this.ownerDocument
  .defaultView before set() — auto-creates the per-realm WeakMap on
  whichever realm the host actually lives in. The standalone
  Runtime.evaluate is gone.

P1 closed-shadow.js — depth counter only on boundary crossings
  walkCDPNodes was bumping depth on plain children, so a normal deep
  page (html → body → … → custom-element) burned the 10-step budget
  before reaching any shadow host. Now depth only increments when
  crossing shadow boundaries (shadowRoots) or iframe boundaries
  (contentDocument). Plain children stay at the same depth.

P1 serialize-dom.js cleanup — verified
  cleanupInteractiveStateMarkers is already in a finally that wraps
  mark/clone/serialize/transform/html. No change needed.

P2 closed-shadow.js — return real success count
  Was returning closedPairs.length even when half the per-pair stamps
  failed. Now tracks per-pair callFunctionOn outcomes and returns the
  count of pairs that actually landed in a WeakMap.

P2 closed-shadow.js — concurrency guard
  Adds an in-flight guard via Symbol.for so a second call on the same
  CDP session can't race the first call's DOM.enable/DOM.disable
  lifecycle. SDK consumers (puppeteer/playwright) sharing a session are
  the realistic risk surface.

P2 closed-shadow.js — log DOM.disable failures
  finally previously did `.catch(() => {})` which silently swallowed
  domain-leak telltales. Now routes the error message through the same
  log callback used elsewhere.

P2 serialize-pseudo-classes.js — invert someStampedMatches
  Was running ctx.dom.querySelectorAll(baseSelector) per :hover/:active
  rule. Inverted to iterate stampedSet (typically <10 elements) and call
  el.matches(baseSelector). On a design system shipping 200 hover rules
  this drops a per-rule full-tree scan.

P2 serialize-pseudo-classes.js — early bail on selectors without `:`
  Adds the cheapest possible filter before the regex bank: a selector
  with no `:` can't contain any interactive pseudo. Skips most rules on
  most stylesheets without touching the per-pseudo regex sequence.

P2 serialize-pseudo-classes.test.js — drop dead __percyInternals refs
  The test was saving/clearing/restoring window.__percyInternals "so the
  fallback path runs", but production code no longer reads or writes
  __percyInternals anywhere. Dead code.

Coverage stays at 100% across @percy/dom and @percy/sdk-utils.

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.

3 participants