PER 7292#2177
Conversation
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>
…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.
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.
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(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.
…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>
rishigupta1599
left a comment
There was a problem hiding this comment.
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:
- Preflight script is not shipped to consumers.
@percy/dompublishes onlydist/, butpage.jsreads the script fromsrc/preflight.jsrelative to the resolvedPERCY_DOMbundle. 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 intodist/(and added tofilesinpackages/dom/package.json). Page.addScriptToEvaluateOnNewDocumentis dispatched in the samePromise.allasPage.enable. No ordering guarantee — the preflight injection can race with, or land after, the initial navigation.- "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
fidelityRegionspayload. - 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.)
| try { | ||
| _preflightScript = await fs.promises.readFile(preflightPath, 'utf-8'); | ||
| } catch { | ||
| _preflightScript = ''; // graceful fallback if file not found |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| if (script) { | ||
| return session.send('Page.addScriptToEvaluateOnNewDocument', { source: script }); | ||
| } | ||
| })); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed. Preflight addScriptToEvaluateOnNewDocument is now chained onto Page.enable via .then() instead of running in parallel, ensuring the Page domain is ready first.
| // before any page scripts run | ||
| getPreflightScript().then(script => { | ||
| if (script) { | ||
| return session.send('Page.addScriptToEvaluateOnNewDocument', { source: script }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed. Non-close preflight injection failures are now logged at debug level. Close/destroy errors are still silently handled by _handleClosedError.
| let inaccessibleShadowCount = 0; | ||
| for (let origEl of ctx.dom.querySelectorAll('*')) { | ||
| if (!origEl.tagName?.includes('-')) continue; | ||
| if (origEl.hasAttribute('data-percy-shadow-host')) continue; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
|
||
| 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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
| // Warn about sandboxed iframes | ||
| if (sandboxAttr !== null) { | ||
| sandboxWarned++; | ||
| let frameLabel = frame.id || frame.src || percyElementId; |
There was a problem hiding this comment.
Nit: frameLabel = frame.id || frame.src || percyElementId — percyElementId 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>'.
There was a problem hiding this comment.
Fixed. Changed fallback to frame.id || frame.src || frame.getAttribute('name') || '<unnamed iframe>' instead of using the internal percyElementId.
| let origAttachShadow = window.Element.prototype.attachShadow; | ||
| window.Element.prototype.attachShadow = function(init) { | ||
| let root = origAttachShadow.call(this, init); | ||
| if (init && init.mode === 'closed') { |
There was a problem hiding this comment.
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'.
There was a problem hiding this comment.
Fixed. Changed to init?.mode === 'closed' and origAttachShadow.apply(this, arguments) for both null safety and forward compatibility.
|
|
||
| // wait for custom elements to be defined before capturing | ||
| /* istanbul ignore next: no instrumenting injected code */ | ||
| await this.eval(function() { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| for (let w of domWarnings) log.info(w); | ||
|
|
||
| // extract fidelity regions for API upload | ||
| let fidelityRegions = domSnapshot?.fidelityRegions || domSnapshot?.[0]?.fidelityRegions || []; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| try { | ||
| for (let state of percyInternals.states) { | ||
| // CSS-escape the state value to prevent injection | ||
| states.push(state.replace(/["\\}\]]/g, '\\$&')); |
There was a problem hiding this comment.
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".
There was a problem hiding this comment.
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.
…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>
…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>
…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>
No description provided.