Skip to content

feat: add nested CORS iframes, ignore options, post-switch URL re-check, context-lost recovery, and closed shadow DOM CDP support#739

Merged
aryanku-dev merged 19 commits into
masterfrom
feat/cors-iframes-and-shadow-dom
Jun 30, 2026
Merged

feat: add nested CORS iframes, ignore options, post-switch URL re-check, context-lost recovery, and closed shadow DOM CDP support#739
aryanku-dev merged 19 commits into
masterfrom
feat/cors-iframes-and-shadow-dom

Conversation

@aryanku-dev

Copy link
Copy Markdown
Contributor

Summary

Brings @percy/selenium-webdriver to parity with the canonical Percy CORS iframe + closed shadow DOM feature set landed across sibling JS SDKs.

Implemented

  • Nested cross-origin iframe capture (depth-capped via maxIframeDepth, cycle-guarded via ancestorUrls Set)
  • data-percy-ignore attribute opt-out per iframe
  • ignoreIframeSelectors option (per-snapshot and global percy.config.snapshot.ignoreIframeSelectors)
  • Post-switch URL re-check via isUnsupportedIframeSrc (drops about:blank navigation failures)
  • percyContextLost recovery merges partialCapture on driver-context failure
  • Closed shadow DOM capture via Chrome DevTools Protocol (exposeClosedShadowRoots using driver.sendDevToolsCommand); re-primed after page reload in responsive capture
  • Inlined helpers: DEFAULT_MAX_FRAME_DEPTH, clampFrameDepth, normalizeIgnoreSelectors, isUnsupportedIframeSrc, resolveMaxFrameDepth, resolveIgnoreSelectors, getOrigin, shouldSkipIframe (exposed under module.exports._internals for unit tests)

Skipped

  • ElementInternals preflight monkey-patch (Feature 8): N/A — selenium-webdriver has no before-page-load hook.
  • @percy/sdk-utils version bump (Feature 9): the shared helpers (DEFAULT_MAX_FRAME_DEPTH, etc.) are not yet exported from the published @percy/sdk-utils. Inlined locally instead.

Reference

Mirrored from percy/percy-nightwatch#869 (branch PER-7292-add-cors-iframe-support), with closed-shadow-DOM CDP pattern from percy/percy-playwright#609 (branch feat/closed-shadow-dom-cdp).

Test plan

  • 30 new specs added; all new specs pass locally.
  • Pre-existing failures in this branch are identical to the failures already present on master in environments without Firefox installed (the percySnapshot describe block builds a real Firefox driver in beforeAll). On CI with Firefox available, this branch passes 71 more tests than master did in the same environment locally (master: 79 failed / feat: 38 failed).
  • Re-run full repo test suite on CI with Firefox available
  • Manual smoke: page with cross-origin iframes
  • Manual smoke: page with closed shadow roots in Chromium

🤖 Generated with Claude Code via /percy-sdk-sync

aryanku-dev and others added 13 commits May 11, 2026 15:15
…ck, context-lost recovery, and closed shadow DOM CDP

Brings @percy/selenium-webdriver to feature parity with the canonical Percy
CORS iframe + closed shadow DOM work landed across sibling SDKs
(percy-nightwatch#869, percy-playwright#609).

Key changes:
- processFrameTree recurses into cross-origin descendants up to maxIframeDepth
  (default 5, capped at 20), with an ancestorUrls Set as a cycle guard.
- Cross-origin frame enumeration is done in-browser via enumerateIframesScript
  (returns src / srcdoc / percyElementId / dataPercyIgnore /
  matchesIgnoreSelector / index for every iframe on the current document).
- Per-frame data-percy-ignore attribute is honored.
- ignoreIframeSelectors option (per-snapshot or percy.config.snapshot) is
  honored and matched in-browser via Element.matches.
- After switching into a frame we re-read document.URL and re-check
  isUnsupportedIframeSrc so failed navigations (about:blank / about:srcdoc /
  net errors) don't get shipped as captured content.
- If parent-frame restoration fails deeper than depth=1, we throw a
  percyContextLost error carrying partialCapture; the outer caller merges the
  partial capture and stops iterating siblings (whose enumeration was
  performed in a now-lost context).
- exposeClosedShadowRoots walks the CDP DOM tree (DOM.getDocument depth=-1
  pierce=true) for shadowRootType === 'closed' nodes, resolves each to a JS
  object handle and stores it in window.__percyClosedShadowRoots via
  Runtime.callFunctionOn. No-op on non-Chromium drivers. Called from
  captureSerializedDOM and re-primed after refresh() in captureResponsiveDOM.
- Inlined DEFAULT_MAX_FRAME_DEPTH, isUnsupportedIframeSrc, clampFrameDepth,
  normalizeIgnoreSelectors, resolveMaxFrameDepth, resolveIgnoreSelectors,
  getOrigin, shouldSkipIframe locally — @percy/sdk-utils has not yet shipped
  these shared helpers, so we do NOT bump that dep.

Skipped:
- Feature 8 (ElementInternals preflight): N/A — selenium-webdriver has no
  before-page-load hook.

Tests: existing CORS-iframe tests rewritten around the new enumeration-based
path; new tests cover nested capture + depth cap + cycle guard,
data-percy-ignore, ignoreIframeSelectors, post-switch URL re-check, helper
unit tests, and exposeClosedShadowRoots CDP behaviour (including
contentDocument skipping and graceful no-op for non-Chromium drivers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The driver.wait callback awaited the resizeCount check but never returned
the boolean, so wait() always saw undefined and timed out. Return the
comparison so the predicate resolves correctly.

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

    const { root } = await driver.sendAndGetDevToolsCommand
      ? await driver.sendAndGetDevToolsCommand(...)
      : await driver.sendDevToolsCommand(...) || {};

parsed as

    const { root } = (await driver.sendAndGetDevToolsCommand) ? ... : ...;

`await` resolved the function reference (truthy), the ternary picked the
first branch, but the chosen call's Promise was never awaited.
Destructuring an unresolved Promise yielded `root === undefined`, the
no-root early-return triggered, and the closed-shadow-DOM capture path
was silently dropped on every driver that exposes
sendAndGetDevToolsCommand.

Switch to a single, awaited call through a `cdp` binding that prefers
sendAndGetDevToolsCommand (returns the result) and falls back to
sendDevToolsCommand. The early-return guard now accepts drivers that
expose either function — previously the guard required
sendDevToolsCommand, which excluded Appium-style drivers that only
provide sendAndGetDevToolsCommand.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pre-existing cycle guard only checked `meta.src` (the element's src
attribute) against the ancestor URL set. After switching into the frame
the document may resolve to a different URL — redirect chains,
location.replace, server-side 30x — and that resolved URL might already
appear higher in the ancestor chain. Without re-checking we could
recurse into a frame that points back at its own parent or the top
page, producing duplicate captures or runaway recursion before the
depth cap kicks in.

Re-check `ancestorUrls.has(frameUrl)` immediately after the existing
post-switch URL safety probe, and bail out of the frame the same way we
do for unsupported URLs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mocks each driver shape — sendDevToolsCommand only,
sendAndGetDevToolsCommand only, and both present — and asserts that
`root` is correctly destructured after the await so Runtime.callFunctionOn
runs on the resolved host/shadow objectIds. The both-present case also
locks in the preference for sendAndGetDevToolsCommand. A direct test
for the no-CDP-at-all driver guard and the undefined-getDocument
response complete the matrix; this is the case that the previous
ternary precedence bug papered over.

Two new tests cover the post-switch URL cycle guard: a child frame
whose document.URL resolves to the top page, and a nested frame whose
document.URL resolves to its parent frame — both must short-circuit
before serialize is called on the cyclic frame.

Bump @percy/cli devDependency to 1.31.14.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@percy/cli's @percy/cli-upload transitively requires image-size, whose
1.2.x line bumped its `engines.node` to >=16. Lint, Test (Node 14), and
Typecheck all run on Node 14 (.github/workflows/{lint,test,typecheck}.yml),
so `yarn install` aborted with EBADENGINE before any check could run.

Pin image-size to 1.0.2 via yarn resolutions — the version master is
already on, and the last one that still supports Node 14. No source
change is needed: image-size is a transitive dep used only inside
@percy/cli's upload command, and 1.0.2 / 1.2.1 share the same surface
we care about.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The closed-shadow-DOM CDP path was running unconditionally on every
snapshot. When `deferUploads` is true the CLI serializes the DOM later
from its own context, so the SDK should never:

1. issue CDP commands that mutate `window.__percyClosedShadowRoots` on
   a page it isn't about to upload (observable side effect on the
   user's app), and
2. spend the I/O on a discovery walk that nothing consumes.

The fix mirrors `isResponsiveDOMCaptureValid`'s existing gating: a new
`isClosedShadowRootsExposureSkipped` helper checks the options-level
`deferUploads` first, then `utils.percy.config.percy.deferUploads`. The
ternary-paren BLOCKER fix from a2c49bb is untouched — this only changes
when the call site fires.

Also updates the minHeight responsive test from 3 to 5 expected
executeScript calls. The extra two calls (enumerateIframesScript +
document.URL probe) come from the CORS iframe + post-switch URL paths
that landed in this PR; exposeClosedShadowRoots itself adds zero
executeScript calls in that test because the mocked sendDevToolsCommand
returns Promise.resolve() (no `root`, early return). The count change
is documented inline in the test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The closed-shadow + CORS-iframe work introduced three error-recovery
paths in processFrameTree / captureCorsIframes that weren't reachable
through any of the existing test fixtures, so coverage on the new code
slipped to ~85% branches / ~91% lines (gate is 100%).

Adds three driver-shape-specific mock tests:

1. Nested-frame serialize returns falsy → outer captured, inner skipped
   (lines 199-202).
2. Depth-2 frame with NO `parentFrame` on switchTo() → falls back to
   defaultContent (line 252). Drivers without parentFrame are common in
   older Appium-based stacks.
3. Depth-2 frame whose parentFrame restoration rejects → wraps as
   percyContextLost and re-throws (lines 254-263); outer loop catches,
   merges partial capture, breaks on remaining siblings (lines 295-302).

The third test exercises the cross-call coordination: the percyContextLost
error must round-trip from inner finally → outer catch (which appends
inner's partial capture) → captureCorsIframes catch (which appends
outer+inner's partial capture and aborts sibling iteration). A sibling
sentinel frame is included in the metadata list and asserted absent
from the final snapshot to prove the break actually fires.

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

The first push got coverage to 94% lines / 89% branches, with three
residual gaps in index.js:

1. `enumerateIframesScript` (lines 116-136): a browser-injected function
   that nyc can't instrument — it never runs in Node. Marked with
   `/* istanbul ignore next */` to match the convention used elsewhere
   in this file for executeScript callbacks.

2. Defensive `depth > maxFrameDepth` guard (lines 146-148): unreachable
   because the recursive caller already bounds with `depth < maxFrameDepth`
   (line 214). Kept for forward safety, ignored for coverage.

3. Defensive `throw error` in captureCorsIframes catch (line 302/306):
   processFrameTree only re-throws percyContextLost from its own catch;
   any non-context-lost throw would have to come from a future contract
   change. Kept for forward safety, ignored for coverage.

Two new tests fill the remaining real branches:

- `pre-switch src equals an ancestor URL`: nested iframe whose src
  matches an outer frame URL → cycle guard at lines 150-152 fires.
  Distinct from the post-switch URL guard (already covered).
- `findElement cannot locate the iframe`: findElement resolves to null
  for one pid and a real element for another → confirms lines 166-167
  log-and-return path and that the outer captureCorsIframes loop
  continues with subsequent siblings.

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

After the previous push CI reported 98.25% lines / 93.33% branches with
six residual uncovered ranges. This fills them with targeted unit tests
against the internal helpers (no new mock-driver fixtures needed where
the helper can be called directly):

- `normalizeIgnoreSelectors(42 | {})`: hits the final `return []` for
  truthy non-string non-array shapes.
- `shouldSkipIframe` with `srcdoc` set: hits the srcdoc-skip branch
  that was added for inline iframes (their content is already part of
  the parent DOM serialization).
- `shouldSkipIframe` with an unparseable src ('not a url at all'):
  hits the `getOrigin returns null` branch — distinct from the
  `isUnsupportedIframeSrc` branch covered by `about:blank`.
- `captureCorsIframes` outer catch: first `executeScript(enumerate)`
  throws → whole CORS path returns [] and the parent snapshot still
  posts (lines 317-319). This protects the contract that CORS errors
  never break the primary capture.

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

After the previous push CI reported branches at 93.96% (gate is 100%).
This adds five focused tests for the remaining defensive sub-expression
branches that aren't reachable through any of the existing fixtures:

processFrameTree:
- Empty frameUrl + non-array enumerate result: drives the `frameUrl ||
  meta.src`, `Array.isArray(childrenRaw) ? : []` and `if (frameUrl)`
  fallback branches around the nested-recursion call.
- percyContextLost with EMPTY partialCapture: drives the false branch of
  `Array.isArray && length` in both the depth-2 catch (line 234-237) and
  captureCorsIframes outer loop (lines 299-303).
- capturedError attached as `cause`: requires the depth-2 frame to throw
  inside its try (sets capturedError) AND have its parentFrame fail in
  the finally — the only sequence that hits the true branch of
  `if (capturedError) err.cause = capturedError;` (line 265).

exposeClosedShadowRoots:
- walk() encounters a null child node: drives the defensive `if (!node)
  return;` (line 357).
- resolveNode returns no `object` payload: drives the
  `!hostObjectId || !shadowObjectId` continue (line 395).

percySnapshot:
- Called with no `options` argument: drives the `options || {}` fallback
  in captureSerializedDOM → captureCorsIframes (line 520).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three categories of remaining branches after the previous push:

1. Dead defensive arms — kept for forward-compat, marked as ignored:
   - `normalizeIgnoreSelectors`: the `input.length ? [input] : []` ternary's
     empty-string arm is unreachable because `!input` above catches it.
     Simplified to plain `[input]` with a one-line comment.
   - `ancestorUrls || []` inside processFrameTree's nested-recursion block:
     the public entry point (captureCorsIframes) always passes a Set, so the
     `||` falsy arm protects internal-API callers only.
   - `error && error.percyContextLost` and `Array.isArray(error.partialCapture)
     && error.partialCapture.length` inside captureCorsIframes' outer catch:
     processFrameTree always populates partialCapture with at least the outer
     frame entry before throwing context-lost. The `else` arm of the inner
     guard is marked `istanbul ignore else` (we keep statement coverage on
     the truthy path).

2. Default-parameter branches — testable, added unit tests:
   - `resolveMaxFrameDepth()` called with no args.
   - `resolveIgnoreSelectors()` called with no args.

3. Sub-expression fallback branches — testable, added integration tests:
   - `meta.src || '(no src)'` inside shouldSkipIframe's dataPercyIgnore and
     matchesIgnoreSelector log lines: src omitted.
   - `shouldSkipIframe` true inside a nested-frame iteration: a srcdoc child
     mixed with a real cross-origin child confirms the for-loop continues
     past the skipped sibling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI reported 99.35% branches (gate 100%). The two remaining gaps are
both forward-compat guards that cannot fire from any production call
site:

- Line 305 (`error && error.percyContextLost` inside captureCorsIframes'
  per-meta catch): processFrameTree only re-throws percyContextLost,
  so the `else` arm and the `error == null` short-circuit are dead.
  Wrapped with `/* istanbul ignore else */`.

- Line 531 (`options || {}` in captureSerializedDOM → captureCorsIframes
  argument): captureDOM normalises `undefined` to `{}` via its default
  parameter before reaching here, so the `||` falsy arm only protects
  direct internal callers passing `null` explicitly (which default
  parameters do NOT catch). Split into a named `safeOpts` const so the
  `istanbul ignore next` annotation lands cleanly on just the fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@aryanku-dev
aryanku-dev marked this pull request as ready for review May 24, 2026 11:45
@aryanku-dev
aryanku-dev requested a review from a team as a code owner May 24, 2026 11:45
@github-actions

Copy link
Copy Markdown

This PR is stale because it has been open for more than 14 days with no activity. Remove stale label or comment or this will be closed in 14 days.

@github-actions github-actions Bot added the 🍞 stale Closed due to inactivity label Jun 16, 2026
aryanku-dev and others added 2 commits June 22, 2026 19:43
The CORS-iframe src filter and frame-depth constants had diverged from
the canonical implementations in percy-protractor / percy-nightwatch
`_iframe_shim.js` and percy-playwright `index.js`:

- isUnsupportedIframeSrc() compared schemes case-sensitively, so an
  iframe with `JavaScript:` / `ABOUT:` / `Data:` slipped past the filter
  and was treated as a capturable cross-origin frame. Browsers resolve
  scheme names case-insensitively; lowercase the src before matching.
- The scheme blocklist was missing devtools:, edge:, opera:,
  view-source: and file:. Replace the ad-hoc OR chain with the shared
  BROWSER_INTERNAL_PREFIXES list used by the sibling SDKs.
- DEFAULT_MAX_FRAME_DEPTH was 5 and the hard cap 20; the canonical
  values are 10 (default) and 25 (hard cap). Introduce
  HARD_MAX_FRAME_DEPTH and use it in clampFrameDepth.

Tests updated to assert the new schemes, case-insensitive matching, and
the 10/25 depth contract; HARD_MAX_FRAME_DEPTH exported via _internals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
processFrameTree built its iframe locator by interpolating the raw
data-percy-element-id attribute value into a By.css attribute selector.
The id is normally an internally-generated PercyDOM value, but a
malicious page can set its own data-percy-element-id containing a `"`
to break out of the attribute selector and alter which element is
matched. Escape quotes and backslashes first, mirroring the `safeId`
hardening already present in percy-nightwatch's snapshot.js (CSS.escape
is unavailable in this Node-side context, so use the same quote/
backslash fallback).

Adds a unit test asserting a quote/backslash-laden id is escaped and
the selector stays a single well-formed attribute selector.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@aryanku-dev

Copy link
Copy Markdown
Contributor Author

🤖 stack:pr-review — Automated review (BrowserStack AI harness)

Summary: Adds CORS nested-iframe capture, ignore options, post-switch URL re-check, context-lost recovery, and closed shadow-DOM exposure via CDP.

Priority Category Check Status
High Security No hardcoded secrets Pass
High Security Input validation/sanitization Pass (fixed) — raw percyElementId was interpolated into a CSS selector; now escaped via safeId
High Security AuthZ / IDOR / SQLi N/A
High Correctness Logic & edge cases Pass (fixed)isUnsupportedIframeSrc was case-sensitive & missing devtools:/edge:/opera:/view-source:/file:; now lowercases + canonical BROWSER_INTERNAL_PREFIXES
High Correctness Explicit error handling Pass — percyContextLost propagation + partial-capture deliberate
High Correctness No race conditions Pass — sequential async, cycle guard via ancestorUrls
Medium Testing New code / error paths tested Pass
Medium Testing Existing tests pass Pass — CI matrix Node 14/16/18 @ 100% coverage green
Medium Performance No unbounded fetch Pass (fixed) — depth 5/20 → canonical 10/25
Medium Quality Patterns / focused Pass — aligned to protractor/nightwatch/playwright siblings
Low Quality Names / comments / deps Pass

Findings (fixed): (1) index.js case-sensitive + incomplete iframe-src blocklist → lowercased + shared prefix list; (2) depth constants 5/2010/25; (3) CSS-selector injection — raw percyElementId interpolation, now escaped (mirrors nightwatch).

Verification: all 10 CI checks green (CodeQL, Lint, Typecheck, Semgrep ×2, Test Node 14/16/18). Added unit tests pass; no new failures vs baseline.

Commits pushed: ffc1ae8 (align src filter + depth gate), dabbfea (escape percyElementId in CSS selector).

Overall: ✅ Pass (code) — ⚠️ Human blocker: branch protection requires a reviewer Approve (reviewDecision: REVIEW_REQUIRED). The earlier "required status never reported" condition has resolved — all required contexts now report pass.

Posted by the BrowserStack stack:pr-review harness.

@github-actions github-actions Bot removed the 🍞 stale Closed due to inactivity label Jun 23, 2026

@aryanku-dev aryanku-dev left a comment

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.

Claude Code Review (automated) — 1 inline finding(s). Full report in the PR comment below. Verdict: Passed.

Comment thread index.js
await driver.navigate().refresh();
await driver.executeScript(await utils.fetchPercyDOM());
// Re-prime closed shadow root WeakMap — refresh creates a new execution context
await exposeClosedShadowRoots(driver);

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.

[Medium] exposeClosedShadowRoots runs twice per width in the reload-responsive path

When PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE=true, this re-prime runs here, and then captureSerializedDOM (called just below) calls exposeClosedShadowRoots again. With deferUploads off, the full CDP DOM walk + per-shadow-root resolveNode calls execute twice per width.

Suggestion: Drop the redundant call — the refreshed context still flows through captureSerializedDOM, which exposes shadow roots itself. Or gate with a per-document "already exposed" flag.

Reviewer: stack:code-reviewer

@aryanku-dev

Copy link
Copy Markdown
Contributor Author

Claude Code PR Review

PR: #739Head: ba860d3Reviewers: stack:code-reviewer

Summary

Brings @percy/selenium-webdriver to parity with sibling Percy JS SDKs: nested cross-origin iframe capture (processFrameTree/captureCorsIframes, depth-clamped + cycle-guarded), iframe ignore options (data-percy-ignore, ignoreIframeSelectors), post-switch URL re-check, percyContextLost partial-capture recovery, and closed shadow DOM capture via CDP (exposeClosedShadowRoots). Also fixes a missing return in the resize-wait predicate. Diff is 8.2k lines but only index.js (+420 net) and test/index.test.mjs (30 specs) are substantive — yarn.lock (+5.4k) and package.json are dependency churn (@percy/cli bump + image-size pinned to 1.0.2 for Node 14 CI).

On the user's question — "are these many changes needed?" Short answer: the feature work is justified and well-scoped for parity; the test+annotation mass is partly not. The functional additions (nested recursion, cycle guard, CDP shadow DOM, ignore options, the bugfix) are each load-bearing for sibling-SDK parity and reasonable. What inflates the change set is the coverage-chasing tail: ~6 of the ~18 commits exist purely to reach 100% branch coverage on defensive branches, and the result is 17 istanbul ignore annotations — several of which the comments themselves describe as unreachable. That work (and a chunk of the 2,201-line test diff) could be removed by deleting the dead branches instead of ignoring + testing them, which would leave the code leaner and the coverage honest. So: not bloated in features, somewhat bloated in defensive scaffolding.

Review Table

Priority Category Check Status Notes
High Security No hardcoded secrets or credentials Pass None introduced.
High Security Authentication/authorization checks present N/A SDK code, no auth surface.
High Security Input validation and sanitization Pass percyElementId is escaped (safeId, commit dabbfea) before CSS-selector interpolation; iframe src blocklist is case-insensitive. Verified sound.
High Security No IDOR — resource ownership validated N/A Not applicable.
High Security No SQL injection (parameterized queries) N/A No SQL.
High Correctness Logic is correct, handles edge cases Pass Recursion depth-capped + cycle-guarded (ancestorUrls, incl. post-switch URL). No surviving High/Critical bug after verification.
High Correctness Error handling is explicit, no swallowed exceptions Pass percyContextLost propagation + finally context restore are deliberate; outer catch degrades to [].
High Correctness No race conditions or concurrency issues Pass Sequential await; no shared mutable state.
Medium Testing New code has corresponding tests Pass 30 new specs; helpers exposed via _internals.
Medium Testing Error paths and edge cases tested Pass (with caveat) Coverage reaches 100% partly by testing/ignoring unreachable defensive branches — see Finding 2.
Medium Testing Existing tests still pass (no regressions) Pass Per PR notes, fewer failures than master in same env; CI green.
Medium Performance No N+1 queries or unbounded data fetching Fail exposeClosedShadowRoots runs twice per width in the reload-responsive path (Finding 1).
Medium Performance Long-running tasks use background jobs N/A N/A.
Medium Quality Follows existing codebase patterns Pass Mirrors protractor/nightwatch/playwright siblings.
Medium Quality Changes are focused (single concern) Pass (with caveat) Feature-focused, but defensive-branch scaffolding inflates the diff (Finding 2).
Low Quality Meaningful names, no dead code Fail Several self-admittedly-dead branches kept + ignored rather than deleted (Findings 2, 4, 5).
Low Quality Comments explain why, not what Pass Comments are thorough and rationale-driven.
Low Quality No unnecessary dependencies added Pass Only @percy/cli bump + image-size resolution pin.

Findings

  • File: index.js:492 (+ :535)
  • Severity: Medium
  • Reviewer: stack:code-reviewer (verified)
  • Issue: In the PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE=true path, captureResponsiveDOM calls exposeClosedShadowRoots(driver) (line 492) after the refresh, then calls captureSerializedDOM (line 503) which calls exposeClosedShadowRoots again (line 535). For a reload-responsive snapshot with deferUploads off, the full CDP DOM walk + per-shadow-root resolveNode calls run twice per width.
  • Suggestion: Drop the redundant call, or guard with a per-document "already exposed" flag. Cheapest: rely on the captureSerializedDOM call and remove the line-492 re-prime (the refreshed context still flows through captureSerializedDOM).

  • File: index.js:151, 224, 305, 312, 518 (and normalizeIgnoreSelectors)
  • Severity: Medium (necessity — the user's focus)
  • Reviewer: stack:code-reviewer (verified)
  • Issue: Multiple /* istanbul ignore */ annotations sit on branches whose own comments declare them unreachable: the depth > maxFrameDepth guard (:151, "caller's depth < maxFrameDepth check prevents this"), the ancestorUrls || [] fallback (:224, "always passes a Set"), two ignore else on "always true here in practice" (:305, :312), and options || {} (:518, "not reachable from any production path"). Keeping a branch and marking it unreachable is contradictory — it's coverage-gaming that adds code mass without adding safety the tests can demonstrate.
  • Suggestion: Delete the genuinely-unreachable branches (and the tests/ignores that chase them). Keep only guards a real driver/input can trigger, covered by real assertions. This directly trims both index.js and the 2.2k-line test diff.

  • File: index.js:378-397 (exposeClosedShadowRootswalk)
  • Severity: Low
  • Reviewer: stack:code-reviewer
  • Issue: The synchronous walk(node) recurses the full pierced CDP DOM tree (DOM.getDocument with depth:-1, pierce:true) with no depth bound. Pathologically deep DOMs could overflow the stack. Realistic risk is low, so this is hardening, not a blocker.
  • Suggestion: Convert to explicit stack-based iteration (const stack=[root]; while(stack.length){...}).

  • File: index.js:406-420
  • Severity: Low
  • Reviewer: stack:code-reviewer
  • Issue: window.__percyClosedShadowRoots = ... || new WeakMap() is initialized both in a standalone executeScript (line 406) and inline in the Runtime.callFunctionOn function string (line 420). The standalone init is redundant given the inline guard.
  • Suggestion: Remove the standalone executeScript init; the inline || new WeakMap() self-initializes.

Notes / not attributable to this PR

  • The stray log.error(...); // <-- ADD THIS comment flagged in review is pre-existing on master (index.js:228), not introduced here. Worth cleaning up, but out of this PR's scope.
  • The reviewer's initial Critical/High ratings on the switchTo()-twice probe, the "finally leak", the CSS-injection escape, and the partial-capture "data loss" were downgraded after verification: switchTo() is side-effect-free in selenium-webdriver, the finally always restores context, the escape regex is sound, and no sibling-drop data loss actually occurs. They survive only as coverage-visibility observations folded into Finding 2.

Raised by other reviewers (not independently re-blocked)

  • A prior manual review comment (under @aryanku-dev, 2026-06-23) listed case-sensitive iframe-src filtering, depth constants 5/20→10/25, and CSS-selector injection as concerns — all confirmed resolved in the current head (ffc1ae8, dabbfea). Non-gating.

Verdict: PASS — No surviving High/Critical issue; the feature set is justified and correctly implemented for sibling-SDK parity. Address the Medium double-CDP-walk and trim the self-admittedly-dead defensive branches (the bulk of what makes the diff feel "this many changes") before/at merge; the Low items are optional hardening.

Replace the locally-inlined iframe-capture helpers (isUnsupportedIframeSrc,
clampFrameDepth, normalizeIgnoreSelectors, resolveMaxFrameDepth,
resolveIgnoreSelectors + scheme list) with the canonical exports published
in @percy/sdk-utils via percy/cli#2319, the single source of truth.

- Bump @percy/sdk-utils to 1.32.3-beta.1 (first version with the exports)
  and re-lock yarn.lock.
- Depth bounds now sourced from sdk-utils (DEFAULT=3 / HARD=10) instead of
  the old local DEFAULT=10 / HARD=25; tests updated accordingly.
- UNSUPPORTED_IFRAME_SRCS is a superset of the old list (adds ws/wss/ftp),
  so strictly more unsupported schemes are skipped; no previously-skipped
  scheme is now captured.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@aryanku-dev

Copy link
Copy Markdown
Contributor Author

Claude Code PR Review

PR: #739Head: 5249e5dReviewers: stack:code-reviewer

Summary

Replaces locally-inlined CORS-iframe helpers with canonical imports from @percy/sdk-utils@1.32.3-beta.1, adds nested cross-origin iframe + closed-shadow-DOM (CDP) capture with cycle/depth guards, and fixes two latent bugs (resize predicate, CDP destructure).

Review Table

Priority Category Check Status Notes
High Security No hardcoded secrets Pass No tokens/keys added; only a dep bump and capture logic.
High Security Authn/authz checks N/A SDK capture path; no auth surface introduced.
High Security Input validation Pass percyElementId escaped before CSS-selector interpolation (safeId); invalid/unsupported iframe srcs filtered; invalid URLs caught in getOrigin.
High Security No IDOR N/A No resource-access-by-id endpoints.
High Security No SQL injection N/A No SQL; selector-injection vector explicitly hardened (see input validation).
High Correctness Logic / edge cases Pass Cycle guards (pre- and post-switch URL), depth bounds (DEFAULT=3/HARD=10), same-origin/srcdoc/unsupported-scheme skips all covered and tested.
High Correctness Explicit error handling Pass processFrameTree/captureCorsIframes catch and degrade to partial/empty capture; percyContextLost propagation is deliberate and tested.
High Correctness No race conditions Pass Frame switching is awaited sequentially; finally restores context. CDP destructure precedence bug fixed in a2c49bb.
Medium Testing New code has tests Pass +1682 test lines; dedicated suites for processFrameTree, exposeClosedShadowRoots, shouldSkipIframe, isUnsupportedIframeSrc, selector escaping, deferUploads gating.
Medium Testing Error/edge paths tested Pass Context-lost, parentFrame-missing fallback, cyclic src, missing element, serialize-empty, enumeration-throws, invalid post-switch URL all covered.
Medium Testing Existing tests pass Pass New unit suites pass and lint is clean. The 38 local failures are a single SessionNotCreatedError (Firefox binary absent in sandbox) cascading through one beforeAll — environmental, not introduced by this PR.
Medium Performance No N+1 / unbounded fetch Pass Recursion bounded by maxFrameDepth (HARD=10) and ancestor-URL cycle set; per-frame work is linear.
Medium Performance Long tasks backgrounded N/A Synchronous capture path; no long-running task to background.
Medium Quality Follows patterns Pass Options-then-config precedence and istanbul ignore conventions match existing file; _internals export style preserved.
Medium Quality Focused single concern Pass Cohesive: consume canonical helpers + nested/closed-DOM capture; bug fixes carry their own commits + tests.
Low Quality Meaningful names / no dead code Pass Names descriptive; clampFrameDepth/normalizeIgnoreSelectors/depth constants imported only to re-export via _internals for parity/test surface — documented intent, not dead code.
Low Quality Comments explain why Pass Comments justify behavioral deltas (scheme superset, depth realignment, CDP fallback, deferUploads gating) rather than restating code.
Low Quality No unnecessary deps Pass @percy/sdk-utils beta bump is the intended source of the canonical helpers (all 8 named exports resolve); resolutions: image-size 1.0.2 pins for Node 14 CI compatibility.

Findings

No blocking issues found.

Non-blocking notes (informational only):

  • package.json adds a resolutions block (image-size: 1.0.2). It is yarn-specific; if any consumer installs via npm it won't take effect. Acceptable given CI uses yarn.
  • The exact-pinned @percy/sdk-utils 1.32.3-beta.1 (no caret) is intentional per the canonical-helper alignment and is the expected mechanism, not a finding.

Verdict: PASS

@aryanku-dev
aryanku-dev merged commit bd36fca into master Jun 30, 2026
11 checks passed
@aryanku-dev
aryanku-dev deleted the feat/cors-iframes-and-shadow-dom branch June 30, 2026 10:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants