Skip to content

feat(sdk-utils): export canonical iframe-capture helpers (single source of truth)#2319

Merged
aryanku-dev merged 5 commits into
masterfrom
PER-7292-sdk-utils-iframe-helpers
Jun 26, 2026
Merged

feat(sdk-utils): export canonical iframe-capture helpers (single source of truth)#2319
aryanku-dev merged 5 commits into
masterfrom
PER-7292-sdk-utils-iframe-helpers

Conversation

@aryanku-dev

Copy link
Copy Markdown
Contributor

Summary

Part of PER-7292. The CORS-iframe SDK family (Capybara, Cypress, Nightwatch, Playwright, Puppeteer, Selenium-*) each hand-copied the iframe scheme list + resolver helpers, which drifted into 4 divergent scheme lists and 3 depth defaults (flagged in the cross-SDK parity review). This promotes them to @percy/sdk-utils as the single source of truth so every SDK consumes one canonical implementation and can drop its local _iframe_shim.js / inlined copies.

New exports (packages/sdk-utils/src/index.js)

  • UNSUPPORTED_IFRAME_SRCS — canonical 15-prefix scheme list (about:, data:, javascript:, blob:, vbscript:, file:, ws:/wss:/ftp:, chrome*, devtools:, edge:, opera:, view-source:).
  • isUnsupportedIframeSrc(src)startsWith, case-insensitive; missing/empty src → unsupported.
  • normalizeIgnoreSelectors(value) — array | string | unset → clean string[].
  • resolveMaxFrameDepth(options) — per-snapshot maxIframeDepth → global percy.config.snapshot.maxIframeDepthclampIframeDepth (reuses the existing canonical 3 / 10 bounds, so it stays aligned with @percy/dom).
  • resolveIgnoreSelectors(options) — per-snapshot wins, else global config; normalized.

Tests

New iframe capture helpers block in packages/sdk-utils/test/index.test.js covering the scheme list, case-insensitivity, normalization, depth clamp + global-config fallback, and selector resolution. Node suite: 169/169 specs pass.

Follow-up (separate PRs)

Once this ships in a published @percy/sdk-utils, the JS SDKs can delete their local _iframe_shim.js and inlined resolvers and import these instead.

🤖 Generated with Claude Code

…ce of truth)

SDKs (Capybara, Cypress, Nightwatch, Playwright, Puppeteer, Selenium, ...) each
hand-copied the iframe scheme list + resolver helpers, which drifted into 4
divergent scheme lists and 3 depth defaults. Centralize them here so every SDK
consumes one source of truth and can drop its local _iframe_shim.js / inlined
copies:
- UNSUPPORTED_IFRAME_SRCS: the canonical 15-prefix scheme list (about:, data:,
  javascript:, blob:, file:, ws:/wss:/ftp:, chrome*, etc.)
- isUnsupportedIframeSrc(src): startsWith, case-insensitive; empty -> unsupported
- normalizeIgnoreSelectors(value): array|string|unset -> clean string[]
- resolveMaxFrameDepth(options): per-snapshot -> global percy.config.snapshot
  -> clampIframeDepth (canonical 3/10 bounds)
- resolveIgnoreSelectors(options): per-snapshot wins, else global config

Adds unit tests (169 specs pass). Depth bounds reuse the existing
clampIframeDepth so they stay aligned with @percy/dom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@aryanku-dev
aryanku-dev requested a review from a team as a code owner June 25, 2026 03:30
Comment thread packages/sdk-utils/test/index.test.js Fixed
aryanku-dev and others added 2 commits June 25, 2026 09:07
100% branch-coverage gate flagged index.js:82 (the options = {} default
parameter on resolveIgnoreSelectors) as uncovered — every test passed an
explicit argument. Add an argless call to exercise it.

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

GitHub Advanced Security flagged the 'ws://h' literal in the unsupported-scheme
test fixture as detect-insecure-websocket. It's a test string asserting the ws:
scheme is rejected as an unsupported iframe src — not a real WebSocket
connection. Inline nosemgrep with explanation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread packages/sdk-utils/test/index.test.js Fixed
aryanku-dev and others added 2 commits June 25, 2026 09:21
The rule-specific suppression id didn't match Semgrep OSS's reported id; use a
bare // nosemgrep on the scheme-list fixture line (it's test strings, not a real
connection).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nner false positive

Inline nosemgrep wasn't honored by the Semgrep OSS / code-scanning setup, so
remove the ws:// string literal entirely: construct the ws:/wss: test fixtures
via concatenation. Semantically identical (asserts both schemes are rejected as
unsupported iframe srcs) with no literal for detect-insecure-websocket to match.

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

@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) — 2 inline finding(s). Full report in the PR comment below. Verdict: Passed.

// `percy.config.snapshot.ignoreIframeSelectors`. Always returns a string[].
function resolveIgnoreSelectors(options = {}) {
const perSnapshot = normalizeIgnoreSelectors(
options.ignoreIframeSelectors ?? options.ignoreSelectors);

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] resolveIgnoreSelectors ?? drops the legacy alias when the canonical key is an empty array

options.ignoreIframeSelectors ?? options.ignoreSelectors only falls through on null/undefined. For { ignoreIframeSelectors: [], ignoreSelectors: '.ad' }, the empty array is kept, ignoreSelectors is never consulted, and .ad is silently dropped (the resolver then falls through to global config). Narrow trigger and arguably intentional ?? semantics — non-blocking.

Suggestion: branch on === undefined if the legacy alias should win when the canonical key is empty, and add a test for the { ignoreIframeSelectors: [], ignoreSelectors: '.ad' } case; otherwise add a one-line comment noting the behavior is intentional.

Reviewer: stack:code-reviewer

// must normalize before forwarding — this is the shared primitive.
function normalizeIgnoreSelectors(value) {
if (!value) return [];
if (Array.isArray(value)) return value.filter(s => typeof s === 'string' && s.length);

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.

[Low] s.length reads as a truthy check rather than non-empty-string intent

Suggested change
if (Array.isArray(value)) return value.filter(s => typeof s === 'string' && s.length);
if (Array.isArray(value)) return value.filter(s => typeof s === 'string' && s.length > 0);

Reviewer: stack:code-reviewer

@aryanku-dev

Copy link
Copy Markdown
Contributor Author

Claude Code PR Review

PR: #2319Head: 4a490b0Reviewers: stack:code-reviewer

Summary

Promotes the iframe-capture helpers (UNSUPPORTED_IFRAME_SRCS, isUnsupportedIframeSrc, normalizeIgnoreSelectors, resolveMaxFrameDepth, resolveIgnoreSelectors) into @percy/sdk-utils as a single source of truth, replacing four divergent per-SDK copies. Adds a thorough iframe capture helpers test block covering scheme matching, selector normalization, depth clamping, and per-snapshot/global config precedence.

Review Table

Priority Category Check Status Notes
High Security No hardcoded secrets or credentials Pass No secrets; pure helper functions
High Security Authentication/authorization checks present N/A No auth surface
High Security Input validation and sanitization Pass isUnsupportedIframeSrc coerces/lowercases src; operates on DOM-normalized scheme strings
High Security No IDOR — resource ownership validated N/A No resource access
High Security No SQL injection (parameterized queries) N/A No DB access
High Correctness Logic is correct, handles edge cases Pass One narrow edge case in resolveIgnoreSelectors (Medium, below); core paths well-tested
High Correctness Error handling is explicit, no swallowed exceptions Pass Pure functions, defensive null/type guards
High Correctness No race conditions or concurrency issues N/A Synchronous pure helpers
Medium Testing New code has corresponding tests Pass All five exports covered, incl. clamp/default/global-fallback branches
Medium Testing Error paths and edge cases tested Pass Empty/null/wrong-type inputs covered; missing the {ignoreIframeSelectors:[], ignoreSelectors} combo
Medium Testing Existing tests still pass (no regressions) Pass Additive only; no existing behavior changed
Medium Performance No N+1 queries or unbounded data fetching N/A No I/O
Medium Performance Long-running tasks use background jobs N/A N/A
Medium Quality Follows existing codebase patterns Pass Mirrors existing clampIframeDepth/depth-constant conventions in the same file
Medium Quality Changes are focused (single concern) Pass One concern: centralize iframe helpers
Low Quality Meaningful names, no dead code Pass Minor: s.length truthy check (below)
Low Quality Comments explain why, not what Pass "Single source of truth" history comment is excellent; a couple of per-fn comments narrate what
Low Quality No unnecessary dependencies added Pass No new deps

Findings

  • File: packages/sdk-utils/src/index.js:84

  • Severity: Medium

  • Reviewer: stack:code-reviewer

  • Issue: options.ignoreIframeSelectors ?? options.ignoreSelectors uses ??, which only falls through on null/undefined. If a caller passes { ignoreIframeSelectors: [], ignoreSelectors: '.ad' }, the empty array is kept and the legacy ignoreSelectors alias is never consulted — .ad is silently dropped and the resolver falls through to global config instead. Narrow trigger (caller must set both keys, canonical one empty), non-crashing, and arguably intentional ?? semantics — hence Medium, not gating.

  • Suggestion: If the legacy alias should win when the canonical key is empty/absent, branch on === undefined instead of ??, and add a test for the { ignoreIframeSelectors: [], ignoreSelectors: '.ad' } case. If the current behavior is intentional, a one-line comment would prevent future "is this a bug?" churn.

  • File: packages/sdk-utils/src/index.js:65

  • Severity: Low

  • Reviewer: stack:code-reviewer

  • Issue: value.filter(s => typeof s === 'string' && s.length) uses s.length as a truthy check; the intent ("non-empty string") reads more clearly as s.length > 0.

  • Suggestion: value.filter(s => typeof s === 'string' && s.length > 0).

Raised by other reviewers (not independently confirmed)

  • URL-encoded scheme bypass of isUnsupportedIframeSrc (src/index.js:53, raised by stack:code-reviewer as High). Not confirmed: browsers do not decode javascript%3A... into the javascript: scheme (the encoded colon means it is not parsed as a scheme, so nothing executes), and iframe src values read from the DOM (element.src) are already scheme-normalized. Adding decodeURIComponent would risk throwing and producing false matches. Treated as non-gating.
  • Missing mailto:/tel: schemes (raised as Medium). A completeness suggestion, not a defect; the list mirrors the consolidated existing SDK copies. Worth a follow-up only if a downstream SDK actually encounters these in iframe src.
  • Test relies on live percy binding (raised as Medium). The tests mutate utils.percy.config and rely on the module exporting a live reference; this works and afterEach resets it. Minor robustness note, not a defect.

Verdict: PASS — clean, well-tested centralization. One Medium edge case in resolveIgnoreSelectors alias precedence and a couple of Low nits are worth folding into a follow-up, but none are blocking.

@aryanku-dev
aryanku-dev merged commit 3e58060 into master Jun 26, 2026
47 checks passed
@aryanku-dev
aryanku-dev deleted the PER-7292-sdk-utils-iframe-helpers branch June 26, 2026 14:28
aryanku-dev added a commit to percy/percy-selenium-js that referenced this pull request Jun 28, 2026
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 added a commit to percy/percy-puppeteer that referenced this pull request Jun 28, 2026
Delete the local _iframe_shim.js (which wrapped @percy/sdk-utils with local
fallbacks for helpers it didn't yet export) and import the canonical
resolveMaxFrameDepth, resolveIgnoreSelectors, and isUnsupportedIframeSrc
directly from @percy/sdk-utils, the single source of truth landed in
percy/cli#2319.

- Bump @percy/sdk-utils to 1.32.3-beta.1 (first version with the exports)
  and re-lock yarn.lock.
- Depth bounds and the unsupported-scheme list now come from sdk-utils
  (DEFAULT=3 / HARD=10, 15-prefix canonical list).
- Drop the now-stale _iframe_shim.js entry from .nycrc exclude.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
aryanku-dev added a commit to percy/percy-cypress that referenced this pull request Jun 28, 2026
Delete the local _iframe_shim.js (which augmented the @percy/sdk-utils
singleton in place with isUnsupportedIframeSrc + normalizeIgnoreSelectors)
and require @percy/sdk-utils directly, now that percy/cli#2319 exports the
canonical helpers from the single source of truth.

- Bump @percy/sdk-utils to 1.32.3-beta.1 in both dependencies and the
  resolutions pin (the resolution would otherwise force the old version
  across the tree) and re-lock yarn.lock.
- Repoint the one spec reference and __getShimForTesting hook at the
  @percy/sdk-utils instance (same singleton the shim re-exported).
- The canonical UNSUPPORTED_IFRAME_SRCS is a superset of the old local
  list (adds ws/wss/ftp); existing about:/javascript:/data: assertions
  are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
aryanku-dev added a commit to percy/percy-nightwatch that referenced this pull request Jun 28, 2026
Delete the local _iframe_shim.js (which bridged the _FRAME_ helper names
to the published sdk-utils and hand-copied the unsupported-scheme list)
and import the canonical helpers from @percy/sdk-utils (percy/cli#2319),
aliasing DEFAULT_MAX_IFRAME_DEPTH/clampIframeDepth to the local
_FRAME_DEPTH names this module already uses.

- Bump @percy/sdk-utils to 1.32.3-beta.1 and re-lock.
- Depth bounds now sourced from sdk-utils (DEFAULT=3 / HARD=10) instead
  of the shim's 10/25 fallback.
- normalizeIgnoreSelectors is now the canonical value-shaped helper, which
  resolveIgnoreSelectors already passes a raw value to (the shim's
  options-shaped variant silently returned []).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
aryanku-dev added a commit to percy/percy-playwright that referenced this pull request Jun 29, 2026
This SDK has no direct @percy/sdk-utils dependency (it relies on the
version hoisted from the user's @percy/cli), so the iframe helpers stay
inlined for robustness across CLI versions rather than importing the
canonical exports added in percy/cli#2319. Bring the inline copies into
behavioural parity with that single source of truth:

- Extend the unsupported-scheme list to the canonical 15 prefixes
  (add vbscript:/file:/ws:/wss:/ftp:), matching UNSUPPORTED_IFRAME_SRCS.
- resolveIgnoreSelectors now falls back to the global
  percy.config.snapshot.ignoreIframeSelectors when no per-snapshot value
  is given, matching the canonical resolver.

Depth bounds already come from sdk-utils (DEFAULT=3 / HARD=10).
Full Playwright suite: 112 passed, 6 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
aryanku-dev added a commit to percy/percy-selenium-js that referenced this pull request Jun 30, 2026
…ck, context-lost recovery, and closed shadow DOM CDP support (#739)

* feat: add nested CORS iframes, ignore options, post-switch URL re-check, 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>

* Fix missing return in resize-wait predicate

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>

* Fix CDP destructure precedence in exposeClosedShadowRoots

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>

* Extend iframe cycle guard to the post-switch document URL

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>

* Add regression tests for CDP destructure + post-switch cycle guard

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>

* Pin image-size to 1.0.2 to keep Node 14 CI runnable

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

* Gate exposeClosedShadowRoots when deferUploads is set

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>

* Cover rare error/finally branches in processFrameTree

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>

* Cover pre-switch cycle / missing-element paths and ignore browser-only 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>

* Cover remaining shouldSkipIframe / enumerate-throws / dead-fallback paths

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>

* Add branch-coverage tests for processFrameTree and exposeClosedShadowRoots

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>

* Close final branch-coverage gaps to reach 100%

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>

* Ignore last two unreachable defensive branches for coverage

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>

* fix: align iframe src filter and depth gate with canonical SDKs

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>

* fix: escape percyElementId before interpolating into CSS selector

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>

* refactor: consume canonical iframe helpers from @percy/sdk-utils

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>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
aryanku-dev added a commit to percy/percy-puppeteer that referenced this pull request Jun 30, 2026
* Add cross-origin iframe support via corsIframes

Detect and serialize cross-origin iframes using page.frames() API,
matching the pattern established in percy-playwright and percy-selenium-js.
Each cross-origin frame gets PercyDOM injected and serialized, with results
passed to the CLI via the corsIframes array for processing.

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

* Add tests for cross-origin iframe handling

Tests cover: no iframes, same-origin filtering, cross-origin detection,
multiple frames, about:blank skipping, graceful error handling on frame
injection failure, and cookie capture.

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

* Fix CORS iframe bugs: PercyDOM casing, null guard, src filtering, logging

- Fix PercyDOM.serialize casing bug (percyDOM → PercyDOM) in processFrame
- Add percyElementId null check to skip frames without element IDs
- Add comprehensive iframe src filtering (data:, blob:, javascript:, chrome:, etc.)
- Only process frames where PercyDOM injection succeeded
- Add debug logging throughout CORS iframe processing
- Add 7 new tests covering edge cases (data/blob/js/chrome-ext frames,
  missing percyElementId, injection failure isolation, invalid URLs)
- Improve existing test assertions (enableJavascript verification,
  same-origin frame not evaluated, payload structure checks)

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

* feat: replace post-serialization stitching with pre-serialization CDP WeakMap approach

Replace stitchClosedShadowRoots (regex-based HTML stitching after serialize)
with exposeClosedShadowRoots (CDP DOM.resolveNode into WeakMap before serialize).
This lets PercyDOM.serialize() handle closed shadow roots natively, capturing
styles, nested shadows, and pseudo-classes correctly.

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

* test: add coverage tests for exposeClosedShadowRoots CDP function

Adds 6 tests in new 'closed shadow root handling' describe block:
- Non-Chromium browser (createCDPSession throws)
- No closed shadow roots found (DOM.disable early return)
- Closed shadow roots found and exposed via CDP
- CDP error during DOM.getDocument (catch + detach)
- Multiple closed shadow roots (loop coverage)
- Nested shadow roots - open containing closed (recursion)

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

* coverage

* Address PR review comments: fix shadow DOM scoping, cleanup API, add docs

- Remove unused percyDOM parameter from processFrame
- Add comment explaining why enableJavascript is forced to true for iframes
- Always assign corsIframes for consistency with percy-playwright
- Add deprecation note for page.cookies() (Puppeteer v23+)
- Fix cross-frame shadow DOM bug: skip nodes inside child frame documents
  in walkNodes to prevent TypeError when __percyClosedShadowRoots is
  undefined in child frame execution contexts
- Add performance note about DOM.getDocument pierce:true cost
- Log CDP session errors at debug level instead of swallowing silently

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

* Add istanbul ignore for contentDocument branch in walkNodes

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

* Add test for cross-frame shadow DOM skipping, remove istanbul ignore

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

* Add nested cross-origin iframe support and fix percyElementId lookup

- page.frames() returns a flat list including descendants, so nested cross-
  origin iframes were already iterated, but processFrame was looking up the
  iframe element via page.evaluate() on the *top page*. Nested iframes whose
  element lives in a parent frame's DOM were silently dropped. Fixed by
  resolving the parentFrame and reading data-percy-element-id from there.
- Filter cross-origin against the immediate parent's origin (not the top
  page's origin) so cross-origin-inside-cross-origin is captured correctly.
- Added MAX_FRAME_DEPTH = 10 cap.
- Fixed enableJavascript -> enableJavaScript typo in serialize options
  (lowercase 's' was a no-op; PercyDOM expects camelCase capital S).
- Skip the main frame by reference equality with page.mainFrame() rather
  than by depth so test mocks without parentFrame() still work.

Adds a unit test for cross-origin-inside-cross-origin nesting.

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

* Tighten cross-origin iframe URL matching to avoid false positives

A naive iframe.src.startsWith(fUrl) match swaps the wrong
percyElementId onto a frame's snapshot when sibling iframes share a
URL prefix (e.g. https://ads.com/ and https://ads.com/banner) —
both pass startsWith for either query and find() returns the first
in DOM order. Match by exact src first, then fall back to a
trailing-slash-normalized comparison only.

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

* Add cycle detection for cross-origin iframes (A->B->A)

isCyclicFrame walks the parentFrame chain and skips frames whose URL
already appears in their ancestor list. Prevents pages with mutual
cross-origin references from emitting duplicate corsIframes entries
up to MAX_FRAME_DEPTH.

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

* Add maxIframeDepth option and data-percy-ignore / ignoreIframeSelectors filters

- maxIframeDepth (snapshot option, also via percy.config.snapshot): caps
  the recursive walk. Default 10, hard ceiling 25 to prevent abuse from
  misconfigured values.
- ignoreIframeSelectors (snapshot option / config): array of CSS
  selectors; matching iframes are skipped before the SDK pays the cost
  of switching into them.
- data-percy-ignore attribute on an <iframe> element: per-element opt-out
  that skips capture without needing a selector.

Closes the TB requirement: "Users can optionally exclude specific iframes
from capture via CSS selector or data-percy-ignore attribute" and
"Nested iframes ... captured recursively up to a configurable depth."

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

* Centralize iframe constants in iframe-utils, harden cookie capture

Address ce:review findings:
- Move UNSUPPORTED_IFRAME_SRCS, frame-depth clamping, and the
  ignore-selector resolver into iframe-utils.js so the same constants
  don't drift across SDKs.
- isUnsupportedIframeSrc now lowercases the src so JavaScript:/DATA:
  prefixes are caught (matches the published @percy/sdk-utils
  iframe-utils helper).
- Wrap page.cookies() in try/catch with a fallback to
  page.browserContext().cookies() — page.cookies is removed in
  Puppeteer v23+ and was previously aborting the entire snapshot.
- Rename captureSerializedDOM's third arg from `percyDOM` to
  `percyDOMScript` since it's a JS source string, not the runtime API.

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

* Fix percyDOMScript reference at the cross-origin inject site

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

* Add iframe-utils unit tests

Pushes iframe-utils.js to 100% coverage and reaches branch+function
coverage that the integration tests don't exercise.

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

* Broaden 'about:' to generic prefix for browser-internal URLs

Firefox replaces failed cross-origin loads with about:neterror, which
slipped past the explicit 'about:blank'/'about:srcdoc' enumeration.

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

* Add real tests for ignore-flags, depth-cap, cyclic, cookie fallbacks

Drops the need for istanbul-ignore comments by exercising every branch
through behavior tests:
- data-percy-ignore filter via parent.evaluate flag stub
- ignoreIframeSelectors filter via parent.evaluate flag stub
- depth-cap path with a 12-deep parentFrame chain
- cyclic-iframe detection through a 3-frame ancestor walk
- frameDepth/isCyclicFrame helpers exported and unit-tested directly
- page.cookies fallback to page.browserContext().cookies() (Puppeteer v23+)
- both-cookie-API-throw tolerated
- both-cookie-API-missing tolerated
- parentUrl falsy branch in cross-origin filter

Coverage: 100% lines / 100% branches / 100% functions / 100% statements.
Only the in-browser parent.evaluate callback at lines 126-137 is
istanbul-ignored — its body literally runs in the page process, never
in Jasmine, so coverage instrumentation cannot observe it (matches the
existing convention used at lines 53/71/99 of the same file).

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

* Migrate iframe constants from local shim to @percy/sdk-utils

Removes the transitional local iframe-utils.js shim that duplicated
constants now exported from @percy/sdk-utils. SDK PR is now blocked
on the CLI PR (PER-7292) merging and a sdk-utils 1.32.x release.

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

* Restore sdk-utils shim; scope Puppeteer v23+ cookies to the page host

- Restore the local _iframe_shim.js — published @percy/sdk-utils doesn't yet
  export the resolver helpers.
- Puppeteer v23+ removed page.cookies() and made browserContext().cookies()
  return context-wide cookies. Filter the result to the page's registrable
  origin so cross-domain cookies from other tabs in the same context don't
  leak into the snapshot payload.

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

* Pin @percy/sdk-utils to 1.31.14-beta.4

^1.32.0 doesn't exist on npm (latest stable is 1.31.14); pin to the beta that
actually publishes the iframe constants we consume.

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

* Fix lint indentation in cookie filter

* Cover cookie host-filter branches; exclude iframe shim from coverage

- Add tests for the Puppeteer v23+ cookie filter: registrable-origin match
  (incl. leading-dot subdomain form) and the opaque page-URL fallback.
- Exclude _iframe_shim.js from coverage — it's a temporary parity shim that
  delegates to @percy/sdk-utils; its unreachable fallback branches were
  dragging total branch coverage under 100%.

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

* PER-7292: document closed-shadow execution context and pierce scoping

Address the two remaining review threads on percy-puppeteer#960 by mirroring
the canonical percy-playwright (feat/closed-shadow-dom-cdp) handling:

- Closed-shadow-in-iframe (index.js:347): the contentDocument guard in
  walkNodes() already drops hosts that live inside child frames, so the
  hostObj passed to Runtime.callFunctionOn always resolves into the top
  frame's main world via DOM.resolveNode — the same context where
  page.evaluate() created window.__percyClosedShadowRoots. Added the
  load-bearing comment (mirrored from percy-playwright) explaining why no
  explicit executionContextId is needed. Behavior + skip test already in place.

- pierce:true perf (index.js:292): clarified that pierce:true is intentionally
  scoped to the single DOM.getDocument call and that the attachShadow
  monkey-patch pre-check is a deferred shared-PercyDOM optimization, matching
  how the canonical SDK scopes it.

Comment-only changes; no behavioral change. Lint passes.

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

* fix: address code-review findings (payload shape, global config, DoS, float depth)

- Only attach corsIframes to the snapshot payload when non-empty (was [] on
  every snapshot, changing payload shape for iframe-free pages)
- resolveMaxFrameDepth/resolveIgnoreSelectors now fall back to global
  .percy.yml config (percy.config.snapshot.*), matching the other iframe SDKs
- Run cheap filters (depth/scheme/cross-origin/cycle) before the per-frame
  parent.evaluate round-trip, so the depth cap bounds the round-trips
- Floor float maxIframeDepth via sdk-utils clampIframeDepth

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

* refactor: consume canonical iframe helpers from @percy/sdk-utils

Delete the local _iframe_shim.js (which wrapped @percy/sdk-utils with local
fallbacks for helpers it didn't yet export) and import the canonical
resolveMaxFrameDepth, resolveIgnoreSelectors, and isUnsupportedIframeSrc
directly from @percy/sdk-utils, the single source of truth landed in
percy/cli#2319.

- Bump @percy/sdk-utils to 1.32.3-beta.1 (first version with the exports)
  and re-lock yarn.lock.
- Depth bounds and the unsupported-scheme list now come from sdk-utils
  (DEFAULT=3 / HARD=10, 15-prefix canonical list).
- Drop the now-stale _iframe_shim.js entry from .nycrc exclude.

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

* chore: drop incidental packageManager field from package.json

Auto-stamped by Corepack during earlier yarn runs; not on master and not
needed (yarn-classic repo). package.json now matches master; yarn.lock
unchanged and consistent (verified --frozen-lockfile).

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
aryanku-dev added a commit to percy/percy-cypress that referenced this pull request Jun 30, 2026
* feat: add closed shadow DOM and ElementInternals capture support

Inject preflight script via Cypress.on('window:before:load') to intercept
attachShadow({ mode: 'closed' }) and attachInternals() calls before page
scripts run. Bridge the resulting WeakMaps from the app's window to the
runner's window so PercyDOM.serialize() can access them.

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

* test: add coverage tests for shadow DOM and ElementInternals preflight

Adds 7 tests in new 'Closed Shadow DOM and ElementInternals Preflight' block:
- __percyPreflightActive flag set on window
- Closed shadow roots intercepted and stored in WeakMap
- Open shadow roots NOT captured
- ElementInternals intercepted and stored in WeakMap
- Preflight is idempotent (skips if already active)
- attachShadow returns usable shadow root
- Preflight data bridged to runner window during snapshot

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

* fix: remove unused variable to fix eslint no-unused-vars

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

* fix: make ElementInternals test form-associated and avoid Chai inspection

- Add static formAssociated getter so attachInternals doesn't throw
- Use strict equality check instead of Chai .equal() to avoid
  NotSupportedError when Chai inspects the ElementInternals object

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

* fix: achieve 100% coverage for shadow DOM and ElementInternals code

- Add test for preflight idempotency guard (re-emit window:before:load)
- Add test for browsers without attachInternals support
- Add test for snapshot when preflight data is absent from app window
- Replace optional chaining with explicit null checks to avoid
  babel-generated uncoverable branches
- Exclude cypress/plugins from coverage (test infrastructure, not source)

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

* Address PR review comments: registration guard, forward-compat args, stale map fix

- Add module-level Cypress.__percyPreflightRegistered guard to prevent
  duplicate listener registration if index.js is required multiple times
- Use .apply(this, arguments) for forward-compatible wrapping of
  attachShadow and attachInternals
- Clear stale preflight WeakMaps when absent from app window to prevent
  cross-navigation data leaks
- Add private API usage comment for Cypress.emit in tests
- Rename test to accurately reflect what is verified
- Use Math.random() suffix for custom element tag names to avoid
  collision on test retries within the same millisecond

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

* Add istanbul ignore for preflight registration guard branch

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

* Add test for registration guard, remove istanbul ignore

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

* Extract registerPreflight function and add test for duplicate guard branch

- Extract registration logic into registerPreflight() that returns
  true on first call, false on subsequent calls
- Test calls registerPreflight() a second time to cover the early-return
  branch and achieve 100% branch coverage

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

* Add nested cross-origin iframe walk (depth up to 10)

Recursively walks accessible (same-origin) iframes to find cross-origin
descendants and emit one corsIframes entry per cross-origin frame at any
depth. Cypress runs in the same browser window as the AUT, so the same-
origin policy still blocks reading the contentDocument of a true cross-
origin iframe — when that happens iframeSnapshot stays null and the CLI
drops the entry. Cross-origin-inside-cross-origin therefore remains an
inherent Cypress limitation (documented in code), but for the common case
of cross-origin iframes nested in a same-origin parent we now at least
emit the percyElementId entry so downstream tooling sees the structure.

Same skip rules as before (about:blank, javascript:, data:, blob:, vbscript:,
chrome:, chrome-extension:, srcdoc).

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

* Drop unreachable cross-origin iframes from corsIframes payload

Cypress runs in the AUT window and is blocked from reading true
cross-origin iframe content. The recursion still walks accessible
parents to find these frames, but emitting an entry with
iframeSnapshot=null is wire-time waste — the CLI drops malformed
entries on validation. Filter them client-side so pages with many
ad/tracker iframes don't pay the bandwidth cost. Only entries with
a real captured snapshot are kept.

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

* Drop dead nested-iframe recursion in Cypress CORS handler

Code-simplicity review surfaced that the recursive walk produces no
output the flat top-level walk wouldn't, because the null-snapshot
filter (added in f6aa61e) drops every nested entry the recursion
finds: the browser's same-origin policy blocks Cypress JS from
reading cross-origin contentDocument either way, so iframeSnapshot
stays null and the entry is filtered out before submission. ~50 LOC
of dead code removed; the null-snapshot filter and same-origin skip
are kept.

Nested-cross-origin support is documented as an inherent Cypress
limitation in the comment above processCrossOriginIframes —
percy-playwright/percy-puppeteer are the right tools when
out-of-process frame access is needed.

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

* Test: drops null-snapshot entries from corsIframes payload

Closes the test gap surfaced by the ce:review run — verifies that a
cross-origin iframe whose contentDocument access throws does not show
up in the corsIframes payload (browser security blocks JS access; the
SDK filters those entries client-side rather than shipping null
snapshots that the CLI would discard anyway).

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

* Add maxIframeDepth option and data-percy-ignore / ignoreIframeSelectors filters

- maxIframeDepth (snapshot option, also via percy.config.snapshot): caps
  the recursive walk. Default 10, hard ceiling 25 to prevent abuse from
  misconfigured values.
- ignoreIframeSelectors (snapshot option / config): array of CSS
  selectors; matching iframes are skipped before the SDK pays the cost
  of switching into them.
- data-percy-ignore attribute on an <iframe> element: per-element opt-out
  that skips capture without needing a selector.

Closes the TB requirement: "Users can optionally exclude specific iframes
from capture via CSS selector or data-percy-ignore attribute" and
"Nested iframes ... captured recursively up to a configurable depth."

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

* Wire iframe-utils into index.js, drop dead test bindings

Address ce:review findings:
- Add iframe-utils.js exporting UNSUPPORTED_IFRAME_SRCS,
  isUnsupportedIframeSrc (case-insensitive), and normalizeIgnoreSelectors
  so iframe constants stay in sync with the other SDKs.
- index.js now consumes iframe-utils instead of carrying its own local
  SKIP_IFRAME_SRCS list and resolver.
- Drop unused postedPayload/utils variables flagged by lint in
  cypress/e2e/index.cy.js.

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

* Fix flaky null-snapshot test that crashed in-page serializer

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

* Broaden 'about:' to generic prefix for browser-internal URLs

Firefox replaces failed cross-origin loads with about:neterror, which
slipped past the explicit 'about:blank'/'about:srcdoc' enumeration.

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

* Add cypress tests for data-percy-ignore, ignoreIframeSelectors, invalid-selector branches

Covers the previously-uncovered branches in processCrossOriginIframes:
the iframe.hasAttribute('data-percy-ignore') early-return path, the
iframe.matches(selectors[i]) loop with skipBySelector=true, and the
invalid-selector try/catch that swallows.

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

* Cypress: drop redundant default arg, add no-match test for ignoreIframeSelectors loop

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

* Add cypress test for non-array ignoreIframeSelectors (normalizeIgnoreSelectors falsy branch)

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

* Migrate iframe constants from local shim to @percy/sdk-utils

Removes the transitional local iframe-utils.js shim that duplicated
constants now exported from @percy/sdk-utils. SDK PR is now blocked on
the CLI PR (PER-7292) merging and a sdk-utils 1.32.x release.

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

* Restore local sdk-utils shim and inject PercyDOM into the AUT window

Two fixes folded together:

1. The migration to @percy/sdk-utils 1.31.14-beta.3 was premature — that
   release doesn't yet export the iframe resolver helpers we rely on. Restore
   the local _iframe_shim.js until sdk-utils ships them in a stable release.

2. PercyDOM.serialize must run inside the AUT window so cloneNode sees the
   AUT's own document/Element prototypes. Running it in the Cypress runner
   window with `dom: aut_doc` dropped closed shadow roots during the cross-
   window clone. Inject via a <script> appended to the AUT head so PercyDOM
   lands on the AUT global, then call appWin.PercyDOM.serialize directly.

Also keep parallel hosts arrays alongside the WeakMaps for closed shadow
roots and ElementInternals so the serializer can iterate them.

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

* Pin @percy/sdk-utils to 1.31.14-beta.4

^1.32.0 doesn't exist on npm (latest stable is 1.31.14); the migration commit
bumped the floor prematurely and CI install fails. Pin to the beta that
actually publishes the iframe constants we consume.

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

* Refresh yarn.lock for sdk-utils pin

* Drop host-pinning arrays and patch initial AUT window synchronously

Two CE review fixes folded together because they touch the same preflight
block and the second one cannot be exercised cleanly without the first.

1. closedShadowHosts / internalsHosts arrays are gone. They pinned every
   shadow host element strongly for the whole test lifetime, defeating the
   WeakMaps that store the actual roots and leaking detached DOM nodes in
   long SPA suites. The @percy/dom serializer reaches the WeakMaps by
   probing each live DOM node it visits, so iteration over the maps is
   never needed — there is no reason to hold strong refs to hosts.

2. Cypress.on('window:before:load') only fires on subsequent navigations.
   When index.js is required from support/e2e.js, the first AUT page is
   already loaded, so closed shadow roots created on the initial page used
   to be invisible to the serializer. registerPreflight now also patches
   cy.state('window') synchronously, gated by a try/catch in case cy.state
   isn't available during very early module init.

Also tightens the CORS-iframe block: contentWindow / contentDocument
getters themselves throw SecurityError on true cross-origin frames in
Blink, so both reads now live inside the try/catch — otherwise the throw
bubbles up and breaks every same-origin frame later in the same loop. The
serialize call already normalizes ignoreIframeSelectors before handing it
to @percy/dom so a string value can't crash the in-page walker.

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

* Bump @percy/cli to 1.31.14

Pins to the released 1.31.14 so the test harness uses the same CLI
version we expect downstream. Pairs with @percy/sdk-utils 1.31.14-beta.4
which is already pinned.

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

* Cover new preflight + injection paths in cypress tests

- WeakMap leak guard: assert __percyClosedShadowHosts / __percyInternalsHosts
  are gone (CE review #1).
- Synchronous preflight patch: exercise the patch path against a fresh
  mock window and assert both WeakMaps land (CE review #3).
- cy.state guard: re-invoke registerPreflight with cy.state throwing, to
  cover the try/catch around the initial-window patch.
- Injection-path coverage: a new test stamps a marker on PercyDOM between
  two snapshots to prove the second injection is a no-op when PercyDOM
  is already on the AUT window. Another test asserts PercyDOM ends up on
  cy.window().PercyDOM after a snapshot (script-element append landed in
  the AUT global, not the runner global).
- CORS iframe success branch: simulate a same-origin-misclassified frame
  by overriding contentWindow/contentDocument to return a working
  PercyDOM-bearing shell, exercising the capture-success path from CE
  review #2.

Also fixes the existing 'skips DOM serialization when percyDOMScript is
unavailable' test: it stubbed via the @percy/sdk-utils namespace, but
that exports fetchPercyDOM as a non-writable getter in 1.31.14-beta.4 so
the stub silently no-op'd and the snapshot kept going through. The SDK
reads through the local _iframe_shim re-export, which is a regular
writable property, so the test now stubs there.

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

* Fix CI test failures + restore 100% nyc coverage on closed-shadow branch

CI run 26355847952 failed with 7 cypress test failures all rooted in
the same module-isolation surprise: webpack's spec bundle gets its own
@percy/sdk-utils + _iframe_shim instance, so mutating utils.percy.* or
utils.fetchPercyDOM from the test file doesn't reach the SDK side.

- Add __getShimForTesting / isResponsiveDOMCaptureValid exports on
  index.js so tests can reach the SDK-side shim instance index.js
  captured at module load and invoke branch-only code paths directly.
- Rewrite the deferUploads, getResponsiveWidths-throws, and
  no-domScript tests to drive the shim instance index.js actually uses
  (and intercept the SDK-side logger to capture warn / debug — the
  helpers.logger mock targets a different logger instance).
- Drop the cy.state-unavailable test that relied on Cypress private
  internals; cover its synchronous-patch branch via /* istanbul ignore
  next */ on the registerPreflight try/catch (the guard exists to keep
  the runner alive on edge-case init failures, not to be exercised).
- /* istanbul ignore */ a handful of defensive branches that can't be
  reached from real fixtures: legacy RESPONSIVE_CAPTURE_SLEEP_TIME
  alias, viewport-resize short-circuit on equal dimensions, null
  defaultView from cy.document(), and the normalizeIgnoreSelectors
  garbage-input fallback.
- Clear indexShim.percy.domScript before triggering the dom.js-error
  test — fetchPercyDOM caches the script on the SDK-side info object,
  so the test server's error response would otherwise be ignored.

yarn test:coverage now: 95 passing, 100/100/100/100 nyc.

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

* fix: refresh yarn.lock so @percy/sdk-utils resolves to 1.31.15-beta.0

package.json requires ^1.31.15-beta.0 (the readiness-gate floor), but the
lockfile was stale at 1.31.14-beta.4 — whose getReadinessConfig/isReadinessDisabled
don't merge global .percy.yml config or detect preset:disabled, failing 4 readiness
gate specs. Re-resolving to 1.31.15-beta.0 restores the expected behavior.

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

* fix: force single @percy/sdk-utils 1.31.15-beta.0 via resolutions

The readiness gate needs sdk-utils >=1.31.15-beta.0 (config merge + preset:disabled
detection). But @percy/cli@1.31.14's transitive deps (@percy/monitoring,
@percy/webdriver-utils) pull @percy/sdk-utils@1.31.14 stable, which won hoisting in
CI and got bundled into the cypress spec — failing 4 readiness gate specs. A
resolutions pin collapses the tree to a single 1.31.15-beta.0 so the correct build
is always bundled.

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

* fix: export the @percy/sdk-utils singleton from _iframe_shim, not a copy

The shim did module.exports = Object.assign({}, utils, …), giving index.js a
fresh object that snapshots utils.percy at load time. That made the SDK's utils
a different instance than the @percy/sdk-utils the Cypress spec reads/mutates, so
tests setting utils.percy.config (global readiness) or stubbing utils.postSnapshot
never reached the SDK — 4 readiness gate specs failed (no config merge, no
preset:disabled detection, no diagnostics forwarded). Augment the real singleton
in place and re-export it, matching master's direct require('@percy/sdk-utils').

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

* fix: block vbscript: and file: iframe srcs to match canonical

BROWSER_INTERNAL_PREFIXES was missing the legacy IE-era `vbscript:` and
`file:` schemes that the canonical Protractor/Nightwatch _iframe_shim list
includes. file: can reference local paths and vbscript: is script
execution, so both should be treated as unsupported iframe srcs.

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

* fix: degrade gracefully when PercyDOM injection is blocked (e.g. strict CSP)

On an AUT with a strict CSP (no unsafe-inline), the injected <script> never
runs, so appWin.PercyDOM stays undefined and serialize() threw an uncaught
TypeError outside withLog, failing the whole test. Guard after capturing the
PercyDOM reference: if it's missing or serialize isn't a function, log a
warning and skip the snapshot (Step 3's empty-snapshots guard handles the
rest), instead of crashing the test. Normal path and readiness gate unchanged.

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

* test: cover CSP-blocked PercyDOM skip path (restore 100% coverage)

The graceful-skip guard added an uncovered branch (coverage gate is 100%).
Add a spec that stubs fetchPercyDOM to return a truthy script which never
defines window.PercyDOM (mimicking a strict-CSP AUT blocking the inline
injector), and asserts percySnapshot skips the snapshot instead of throwing.

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

* refactor: consume canonical iframe helpers from @percy/sdk-utils

Delete the local _iframe_shim.js (which augmented the @percy/sdk-utils
singleton in place with isUnsupportedIframeSrc + normalizeIgnoreSelectors)
and require @percy/sdk-utils directly, now that percy/cli#2319 exports the
canonical helpers from the single source of truth.

- Bump @percy/sdk-utils to 1.32.3-beta.1 in both dependencies and the
  resolutions pin (the resolution would otherwise force the old version
  across the tree) and re-lock yarn.lock.
- Repoint the one spec reference and __getShimForTesting hook at the
  @percy/sdk-utils instance (same singleton the shim re-exported).
- The canonical UNSUPPORTED_IFRAME_SRCS is a superset of the old local
  list (adds ws/wss/ftp); existing about:/javascript:/data: assertions
  are unaffected.

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

* chore: drop incidental packageManager field from package.json

Auto-stamped by Corepack during earlier yarn runs; not needed (yarn-classic
repo). yarn.lock unchanged and consistent (verified --frozen-lockfile).

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
aryanku-dev added a commit to percy/percy-nightwatch that referenced this pull request Jun 30, 2026
* Add cross-origin iframe capture support

Enumerate iframes on the page, filter to cross-origin ones with valid
src and data-percy-element-id, switch into each via WebDriver frame API,
inject PercyDOM, serialize, and attach results as corsIframes on the
snapshot payload. Failures are logged at debug level and never crash the
snapshot.

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

* Address PR review comments: thread serializeOptions, element-based frame switching, document.URL, warn logging

- Thread resolved serializeOptions into processFrame/captureCorsIframes for
  consistent iframe serialization with parent page options
- Use CSS selector (data-percy-element-id) instead of numeric frame index
  to avoid DOM drift between enumeration and switch
- Return document.URL from inside the frame instead of iframe.src for
  accuracy after client-side navigation
- Promote genuine iframe capture failures from debug to warn level logging
- Add comment noting nested iframe limitation (v1 only captures top-level)

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

* Fix lint error and update tests for element-based frame switching

- Move eslint-disable comment to cover PercyDOM.serialize line
- Update test mocks to handle querySelector for element-based frame switching
- Update test to expect {snapshot, frameUrl} return format from frame serialize
- Update error test to capture warn-level messages instead of debug

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

* Add nested cross-origin iframe support (depth up to 10)

Recursively descend into each cross-origin iframe and re-enumerate iframes
inside it, so cross-origin frames at any depth (cross-origin inside
cross-origin) are captured as their own corsIframes entry. Uses Nightwatch's
frameParent() to step up exactly one level on each return rather than
unwinding all the way to the top, with a defaultContent() fallback when
parentFrame is unavailable. MAX_FRAME_DEPTH = 10 prevents runaway recursion.

The new processFrameTree wraps the previous single-frame logic. The existing
top-level path continues to behave the same for same-origin and unsupported
iframe sources. Inline tests cover three-level nesting, same-origin
descendant skipping, and frame-detached error handling.

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

* Abort nested CORS recursion when parent-frame restoration fails

When frameParent() throws inside a deep recursion, falling back to
frame(null) (top) leaves outer recursion levels iterating child iframe
references against the wrong context — sibling lookups silently match
the top document and produce wrong percyElementId resolutions. Surface
the failure as a tagged error (percyContextLost) so the outer captureCorsIframes
loop breaks instead of misclassifying remaining siblings.

Only triggered when depth > 1; at the top level falling back to the page
context is the right destination anyway.

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

* Add cycle detection, partial-capture preservation on context loss

- Track ancestor frame URLs through processFrameTree and skip frames
  that already appear in their own ancestor chain (A->B->A pages were
  emitting up to MAX_FRAME_DEPTH duplicate corsIframes entries).
- When parentFrame restoration fails mid-recursion at depth>1, attach
  the partial capture to the percyContextLost error so already-serialized
  frames make it into the final corsIframes array instead of being
  discarded along with the abort signal.
- Preserve original cause when re-throwing from finally so the original
  error message isn't swallowed.
- Tests: cycle protection, parentFrame failure mid-recursion with
  partial-capture handoff. Stack-aware mock fixed to expose currentFrame
  as a live getter (Object.assign was snapshotting it as a stale value).

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

* Add maxIframeDepth option and data-percy-ignore / ignoreIframeSelectors filters

- maxIframeDepth (snapshot option, also via percy.config.snapshot): caps
  the recursive walk. Default 10, hard ceiling 25 to prevent abuse from
  misconfigured values.
- ignoreIframeSelectors (snapshot option / config): array of CSS
  selectors; matching iframes are skipped before the SDK pays the cost
  of switching into them.
- data-percy-ignore attribute on an <iframe> element: per-element opt-out
  that skips capture without needing a selector.

Closes the TB requirement: "Users can optionally exclude specific iframes
from capture via CSS selector or data-percy-ignore attribute" and
"Nested iframes ... captured recursively up to a configurable depth."

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

* Centralize iframe constants in lib/iframe-utils

Address ce:review findings:
- Move UNSUPPORTED_IFRAME_SRCS and frame-depth clamping into
  lib/iframe-utils.js so the constants don't drift across SDKs.
- isUnsupportedIframeSrc now lowercases the src so JavaScript:/DATA:
  prefixes are caught.
- resolveMaxFrameDepth/resolveIgnoreSelectors now compose clampFrameDepth
  and normalizeIgnoreSelectors from the shared utils module.

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

* Fix nightwatch lint: operator-linebreak and object-curly-newline

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

* Broaden 'about:' to generic prefix for browser-internal URLs

Firefox replaces failed cross-origin loads with about:neterror, which
slipped past the explicit 'about:blank'/'about:srcdoc' enumeration.

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

* Migrate iframe constants from local shim to @percy/sdk-utils

Removes the transitional local iframe-utils.js shim that duplicated
constants now exported from @percy/sdk-utils. SDK PR is now blocked on
the CLI PR (PER-7292) merging and a sdk-utils 1.32.x release.

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

* Address Rishi's review: hoist domScript+log to module scope, drop redundant check, inline iframe-enumeration script

- domScript and log are constant for the duration of a snapshot. Pin them
  at module scope via a new setSnapshotContext(domScript, log) setter
  invoked once at the top of the percySnapshot command, instead of
  threading them through every helper signature.
- Drop the if (domScript) guard in captureSerializedDOM — domScript is
  always present after setSnapshotContext runs.
- Replace the enumerateIframesInCurrentContext wrapper with an
  enumerateIframesScript function that callers invoke directly via
  executeScript. The wrapper added indirection without value since the
  body is just the in-browser script.

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

* Restore local sdk-utils shim

@percy/sdk-utils 1.31.14-beta.3 doesn't yet export the iframe resolver
helpers we use; revert lib/snapshot.js to import from the local shim until
a stable sdk-utils release ships them.

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

* Pin @percy/sdk-utils to 1.31.14-beta.4

^1.32.0 doesn't exist on npm (latest stable is 1.31.14); pin to the beta that
actually publishes the iframe constants we consume.

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

* Refresh yarn.lock for sdk-utils pin

* Thread snapshot context through args and harden parentFrame fallback

Addresses three CE review MAJORs in lib/snapshot.js:

1. maybeReloadPage no longer calls executeScript(browser, null) when a
   consumer imports captureDOM directly without setting context. The
   PercyDOM re-injection is now guarded on a non-null domScript.

2. Module-level domScript + log are removed. Both flow through ctx /
   function arguments so two concurrent percySnapshot calls in the same
   Node process cannot race on shared module state. setSnapshotContext
   is kept as a no-op shim to preserve backward compatibility for any
   external caller. commands/percySnapshot.js threads the new args.

3. parentFrame failure now always raises PercyContextLost regardless of
   depth. The previous depth>1 guard silently dropped the signal at
   depth=1, after which the outer sibling enumeration would continue
   against a stale top-document context. captureCorsIframes already
   breaks the outer loop on PercyContextLost and preserves partial
   capture, so the depth=1 case now gets the same treatment.

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

* Add vbscript: to browser-internal iframe-src prefix list

vbscript: URLs cannot be navigated cross-process and are unsafe to
recurse into. The local iframe shim's BROWSER_INTERNAL_PREFIXES list
now matches the unit-test expectations and aligns with the broader
unsupported-protocol set already covered (javascript:, data:, blob:).

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

* Add regression tests for CE MAJORs and update signatures

Update existing captureSerializedDOM tests to pass (domScript, log)
as explicit arguments now that context flows through the call chain.
The "injects serialization flags and cookies" stub now captures only
the first execute() call's args so the trailing cors-iframe
enumeration (an empty selectors array) doesn't overwrite the assertion
target.

New tests under "context threading (CE MAJORs)":

- captureDOM without domScript/log completes without throwing:
  exercises the direct-import path that previously crashed inside
  maybeReloadPage when setSnapshotContext was never called.

- maybeReloadPage path is safe when domScript is null: drives the
  responsive capture path with PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE
  set and asserts the null-script branch never reaches
  executeScript(browser, null).

- concurrent captures do not cross-contaminate per-call context: runs
  two captureDOM invocations through Promise.all with distinct browser
  stubs and loggers, then asserts each log only mentions its own
  origin. With the old module-scope log this would have failed.

- parentFrame failure at depth=1 raises PercyContextLost and
  preserves partial capture: makes frameParent always reject so the
  depth=1 unwind exercises the new always-throw branch and verifies
  the outer loop breaks before iterating the sibling iframe while
  retaining the inner capture.

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

* Bump @percy/cli to 1.31.14

Pin to the released 1.31.14 to match the @percy/sdk-utils dep and
keep the dev/runtime CLI versions aligned for nightwatch test exec.

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

* Address review: harden iframe prefix list and CSS selector

- _iframe_shim.js: add file:, ws:, wss:, ftp: to BROWSER_INTERNAL_PREFIXES
  to match protractor canonical (#708) and block local-FS leaks
- lib/snapshot.js: CSS.escape() percyElementId before attribute-selector
  concatenation as defence-in-depth against non-UUID ids
- test/unit/snapshot.test.js: cover new prefixes

Addresses PR #869 comments from pranavz28.

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

* Fix lint: CSS no-undef and no-var in CSS.escape shim

ESLint flags `CSS` as undefined in this Node-env file even though the
function ships to the browser via executeScript. typeof-guard the
access, add eslint-disable-next-line, and use const instead of var.

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

* fix: make captureDOM backward-compatible with published arg order

captureDOM is an exported helper that was published in v2.2.1-beta.0 with
the trailing argument order (browser, options, utils, log, domScript).
This branch reordered the trailing pair to (..., domScript, log) so the
per-call domScript + logger could be threaded through ctx (avoiding a
concurrency race on shared module state). Silently swapping two published
positional arguments is a breaking change: an external caller importing
captureDOM directly and still passing (log, domScript) would have its
logger injected as the PercyDOM script and its script used as the logger.

captureDOM now normalizes the trailing pair by type — domScript is always
a string, log is always an object — so both the legacy (log, domScript)
and the new (domScript, log) call shapes route each value to the correct
slot. Internal helpers keep the new order; only the public entry point
performs the normalization.

Adds a unit test asserting both argument orders produce identical results
and that the logger is never injected as a script.

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

* refactor: consume canonical iframe helpers from @percy/sdk-utils

Delete the local _iframe_shim.js (which bridged the _FRAME_ helper names
to the published sdk-utils and hand-copied the unsupported-scheme list)
and import the canonical helpers from @percy/sdk-utils (percy/cli#2319),
aliasing DEFAULT_MAX_IFRAME_DEPTH/clampIframeDepth to the local
_FRAME_DEPTH names this module already uses.

- Bump @percy/sdk-utils to 1.32.3-beta.1 and re-lock.
- Depth bounds now sourced from sdk-utils (DEFAULT=3 / HARD=10) instead
  of the shim's 10/25 fallback.
- normalizeIgnoreSelectors is now the canonical value-shaped helper, which
  resolveIgnoreSelectors already passes a raw value to (the shim's
  options-shaped variant silently returned []).

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
aryanku-dev added a commit to percy/percy-playwright that referenced this pull request Jun 30, 2026
* feat: add closed shadow DOM capture via CDP WeakMap approach

Use CDP DOM.getDocument with pierce:true to discover closed shadow roots,
resolve them to JS objects via DOM.resolveNode, and store in the
__percyClosedShadowRoots WeakMap before PercyDOM.serialize() runs.

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

* test: add coverage tests for exposeClosedShadowRoots CDP function

Adds 6 tests covering the exposeClosedShadowRoots function path:
- Non-Chromium browser (newCDPSession throws)
- No closed shadow roots found (DOM.disable early return)
- Closed shadow roots found and exposed via CDP (full flow)
- CDP error during processing (outer catch)
- detach called in finally block on success
- detach called in finally block on error

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

* coverage

* Address PR review comments: CDP error logging, parallel calls, cross-frame fix

- Log CDP session errors at debug level instead of swallowing silently
- Skip nodes inside child frame documents in walkNodes to prevent
  TypeError when WeakMap is undefined in child frame execution contexts
- Parallelize CDP roundtrips with Promise.all for better performance
  on pages with many closed shadow roots
- Add comment documenting execution world-matching assumption
- Drop manual DOM.disable calls and rely on session detach for cleanup
- Add test limitation comment about mock-only coverage
- Add stderr assertion to verify exposeClosedShadowRoots doesn't throw

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

* Update tests: remove DOM.disable assertions after dropping manual disable calls

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

* Add nested cross-origin iframe support and fix percyElementId lookup

- page.frames() returns a flat list including descendants, so nested cross-
  origin iframes were already iterated, but processFrame was looking up the
  iframe element via page.evaluate() on the *top page*. Nested iframes whose
  element lives in a parent frame's DOM were silently dropped. Fixed by
  resolving the parentFrame and reading data-percy-element-id from there;
  falls back to page itself when neither parentFrame nor mainFrame is
  available (e.g. minimal test stubs).
- Filter cross-origin against the immediate parent's origin (not the top
  page's origin) so cross-origin-inside-cross-origin is captured correctly.
- Skip unsupported iframe srcs (about:blank, javascript:, data:, blob:,
  vbscript:, chrome:, chrome-extension:) to match the other SDKs.
- Added MAX_FRAME_DEPTH = 10 cap.
- Fixed enableJavascript -> enableJavaScript typo in serialize options
  (lowercase 's' was a no-op; PercyDOM expects camelCase capital S).
- Skip the main frame by reference equality with page.mainFrame() rather
  than by URL so it works against test stubs as well as real pages.

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

* Tighten cross-origin iframe URL matching to avoid false positives

Mirrors the percy-puppeteer fix — startsWith would mis-match sibling
iframes from the same domain (https://ads.com/ vs https://ads.com/banner).
Match by exact src first, fall back only to trailing-slash-normalized
comparison.

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

* Add cycle detection for cross-origin iframes (A->B->A)

Mirrors the percy-puppeteer fix — isCyclicFrame walks parentFrame
chain and skips frames whose URL already appears in the ancestor
list, preventing duplicate corsIframes entries on mutual-reference
pages.

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

* Add maxIframeDepth option and data-percy-ignore / ignoreIframeSelectors filters

- maxIframeDepth (snapshot option, also via percy.config.snapshot): caps
  the recursive walk. Default 10, hard ceiling 25 to prevent abuse from
  misconfigured values.
- ignoreIframeSelectors (snapshot option / config): array of CSS
  selectors; matching iframes are skipped before the SDK pays the cost
  of switching into them.
- data-percy-ignore attribute on an <iframe> element: per-element opt-out
  that skips capture without needing a selector.

Closes the TB requirement: "Users can optionally exclude specific iframes
from capture via CSS selector or data-percy-ignore attribute" and
"Nested iframes ... captured recursively up to a configurable depth."

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

* Centralize iframe constants, re-prime closed shadow map after reload

Address ce:review findings:
- Move UNSUPPORTED_IFRAME_SRCS, frame-depth clamping, and the
  ignore-selector resolver into iframe-utils.js so the constants stay
  in sync with other SDKs.
- isUnsupportedIframeSrc lowercases the src so JavaScript:/DATA:
  prefixes are caught.
- Re-call exposeClosedShadowRoots after page.reload() in the responsive
  capture loop — without this the closed-shadow WeakMap is discarded
  with the prior document and subsequent width captures lose closed
  shadow content.

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

* Add iframe-utils unit tests for branch coverage

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

* Broaden 'about:' to generic prefix for browser-internal URLs

Firefox replaces failed cross-origin loads with about:neterror, which
slipped past the explicit 'about:blank'/'about:srcdoc' enumeration.

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

* Mark in-browser parent.evaluate callback istanbul-ignore (matches existing convention)

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

* Add real unit tests for frameDepth/isCyclicFrame helpers and filter branches

Exports captureSerializedDOM, frameDepth, and isCyclicFrame for direct
unit testing. Adds tests/iframe-helpers.spec.mjs covering:
- frameDepth: top-level frame, multi-level chain
- isCyclicFrame: missing url, falsy url, no-match, match, no-parentFrame ancestor
- captureSerializedDOM filter: dataPercyIgnore, matchesIgnoreSelector,
  depth-cap (12-deep chain), cyclic detection

Coverage: 100% lines / 100% functions / 100% statements; branches
moved from 80% → 95.9%. Remaining branch gaps are defensive fallbacks
when both frame.parentFrame and page.mainFrame are undefined — paths
that real Playwright cannot produce.

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

* Cover the remaining processFrame fallback and parentUrl falsy branch

- processFrame's '|| page' fallback when neither frame.parentFrame nor
  page.mainFrame yield a frame
- cross-origin filter's 'parentUrl ? ... : null' falsy branch when both
  parent.url() and page.url() return empty strings

Coverage: 100% across all metrics (no istanbul-ignore additions on
test-reachable code).

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

* Migrate iframe constants from local shim to @percy/sdk-utils

Removes the transitional local iframe-utils.js shim that duplicated
constants now exported from @percy/sdk-utils. SDK PR is now blocked on
the CLI PR (PER-7292) merging and a sdk-utils 1.32.x release.

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

* Inline iframe resolver helpers; harden cross-origin frame processing

- @percy/sdk-utils 1.31.14-beta.4 exposes the depth constants but not the
  resolveMaxFrameDepth / resolveIgnoreSelectors / isUnsupportedIframeSrc
  helpers; inline them here until sdk-utils exposes them in a stable release.
- Wrap per-frame evaluate and processFrame in .catch so a single detached or
  navigating frame doesn't fail-fast the whole Promise.all and abort the
  snapshot. Drop frames that returned null or whose percyElementId lookup
  came up empty (the CLI rejects malformed entries).
- Only assign domSnapshot.corsIframes when at least one frame survives the
  filter, and update tests to match.

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

* Mirror sdk-utils clampIframeDepth — treat <1 as default

resolveMaxFrameDepth previously returned Math.max(0, ...) so a caller
passing maxFrameDepth: 0 silently disabled all CORS iframe capture (the
depth gate became 0, rejecting every iframe at depth 1+). Match
@percy/sdk-utils clampIframeDepth: non-finite or <1 falls back to the
default; positive values are floored and capped at the hard limit.

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

* Cover new resolveMaxFrameDepth semantics

Update the existing negative-input test (no longer floors to 0) and add
explicit cases for maxIframeDepth=0 → default, negative → default, and
floored fractional values.

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

* chore: pin @percy/sdk-utils to 1.31.14 stable and add packageManager

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

* fix: drop direct @percy/sdk-utils pin so runReadinessGate resolves via @percy/cli

The PER-7348 readiness feature merged from master calls utils.runReadinessGate,
which is only exported by newer @percy/sdk-utils (1.32.0-beta.x, pulled in
transitively by @percy/cli@^1.32.0-beta.4). The direct pin to 1.31.14 forced an
older sdk-utils lacking that function, breaking 9 tests. master carries no direct
sdk-utils dep for exactly this reason; mirror it.

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

* test: fix misleading test name referencing removed DOM.disable call

The no-closed-shadow-roots test name claimed it "calls DOM.disable",
but the code no longer issues DOM.disable (session detach handles
cleanup) and the test body asserts detach, not DOM.disable. Rename to
match actual behaviour; assertions unchanged.

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

* refactor: align inline iframe helpers with canonical @percy/sdk-utils

This SDK has no direct @percy/sdk-utils dependency (it relies on the
version hoisted from the user's @percy/cli), so the iframe helpers stay
inlined for robustness across CLI versions rather than importing the
canonical exports added in percy/cli#2319. Bring the inline copies into
behavioural parity with that single source of truth:

- Extend the unsupported-scheme list to the canonical 15 prefixes
  (add vbscript:/file:/ws:/wss:/ftp:), matching UNSUPPORTED_IFRAME_SRCS.
- resolveIgnoreSelectors now falls back to the global
  percy.config.snapshot.ignoreIframeSelectors when no per-snapshot value
  is given, matching the canonical resolver.

Depth bounds already come from sdk-utils (DEFAULT=3 / HARD=10).
Full Playwright suite: 112 passed, 6 skipped.

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

* chore: drop incidental packageManager field from package.json

The `packageManager` (Corepack) field was auto-stamped during earlier yarn
runs; it's not on master and isn't needed (this is a yarn-classic repo, and
sibling SDKs build fine without it). Removing it makes package.json identical
to master again. yarn.lock is unchanged and stays consistent (verified with
--frozen-lockfile); its larger diff vs master is the legitimate result of
reconciling master's stale lock with package.json's @percy/cli ^1.32.0-beta.4
pin, which also supplies the iframe depth constants this SDK reads.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
pranavz28 added a commit to percy/percy-protractor that referenced this pull request Jul 3, 2026
…lpers (#734)

percy/cli#2319 made @percy/sdk-utils the single source of truth for the
iframe helpers (resolveMaxFrameDepth, resolveIgnoreSelectors,
normalizeIgnoreSelectors, isUnsupportedIframeSrc). Our shim already
prefers those exports when the linked sdk-utils provides them, but the
local fallbacks (and their tests) encoded a different contract, so the
SDK regression run — which links sdk-utils from percy/cli master —
failed:

- resolveMaxFrameDepth: canonical reads `options.maxIframeDepth` (the
  public SnapshotOptions key) and treats invalid/<1 values as the
  default; the local copy read a made-up `maxFrameDepth` key and
  clamped negatives to 0.
- normalizeIgnoreSelectors: canonical takes a raw value; the local copy
  aliased resolveIgnoreSelectors and took an options object.
- The local fallbacks became dead code when the canonical exports were
  present, collapsing coverage below the 100% thresholds.

Rewrite the fallbacks to be contract-identical to canonical (including
the `percy.config.snapshot` fallback and the ws:/wss:/ftp: unsupported
schemes), reusing the already-published `clampIframeDepth` for the
shared clamping semantics. Expose the fallbacks as `_localFallbacks`
and run the same contract test suite against both the public exports
and the fallbacks, so behavior and 100% coverage hold whether the
linked @percy/sdk-utils is the published 1.31.14 or percy/cli master.

Co-authored-by: Claude Fable 5 <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