Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions packages/sdk-utils/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,57 @@ function clampIframeDepth(raw) {
return Math.min(Math.floor(n), HARD_MAX_IFRAME_DEPTH);
}

// Canonical list of iframe `src` URL prefixes that out-of-process SDK frame
// walks (Capybara, Nightwatch, Playwright, Puppeteer, Selenium, ...) must NOT
// switch into / serialize: browser-internal pages, non-HTTP schemes, and
// pseudo-protocols that either can't be navigated cross-process or are unsafe
// to recurse into. SINGLE SOURCE OF TRUTH — every SDK previously hand-copied
// this list (it drifted into 4 divergent versions), so they now consume it
// from here instead. `startsWith`, case-insensitive.
const UNSUPPORTED_IFRAME_SRCS = [
'about:', 'chrome:', 'chrome-extension:', 'devtools:', 'edge:',
'opera:', 'view-source:', 'data:', 'javascript:', 'blob:',
'vbscript:', 'file:', 'ws:', 'wss:', 'ftp:'
];

// True when an iframe `src` should be skipped by an SDK frame walk. A missing
// / empty src is treated as unsupported (nothing to navigate to).
function isUnsupportedIframeSrc(src) {
if (!src) return true;
const s = String(src).toLowerCase();
return UNSUPPORTED_IFRAME_SRCS.some(prefix => s.startsWith(prefix));
}

// Normalize a raw ignore-selectors value (array | string | unset) into a
// clean string[]. PercyDOM does `selectors?.length && selectors.some(...)`,
// which throws when handed a bare string (has .length, no .some), so SDKs
// 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

if (typeof value === 'string') return [value];
return [];
}

// Resolve the effective maxIframeDepth for a snapshot: per-snapshot option
// wins over the global `percy.config.snapshot.maxIframeDepth`, then the value
// is clamped to [1, HARD_MAX_IFRAME_DEPTH] (invalid/<1 -> default).
function resolveMaxFrameDepth(options = {}) {
let raw = options.maxIframeDepth;
if (raw == null) raw = percy?.config?.snapshot?.maxIframeDepth;
return clampIframeDepth(raw);
}

// Resolve the effective ignoreIframeSelectors for a snapshot: per-snapshot
// option wins; when absent, fall back to the global
// `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

if (perSnapshot.length) return perSnapshot;
return normalizeIgnoreSelectors(percy?.config?.snapshot?.ignoreIframeSelectors);
}

export {
logger,
percy,
Expand All @@ -52,6 +103,11 @@ export {
DEFAULT_MAX_IFRAME_DEPTH,
HARD_MAX_IFRAME_DEPTH,
clampIframeDepth,
UNSUPPORTED_IFRAME_SRCS,
isUnsupportedIframeSrc,
normalizeIgnoreSelectors,
resolveMaxFrameDepth,
resolveIgnoreSelectors,
waitForReadyScript,
getReadinessConfig,
isReadinessDisabled,
Expand Down
103 changes: 103 additions & 0 deletions packages/sdk-utils/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,109 @@ describe('SDK Utils', () => {
});
});

describe('iframe capture helpers', () => {
let { UNSUPPORTED_IFRAME_SRCS, isUnsupportedIframeSrc, normalizeIgnoreSelectors, resolveMaxFrameDepth, resolveIgnoreSelectors } = utils;

afterEach(() => { utils.percy.config = undefined; });

describe('isUnsupportedIframeSrc(src)', () => {
it('exposes the canonical scheme prefix list', () => {
expect(UNSUPPORTED_IFRAME_SRCS).toEqual([
'about:', 'chrome:', 'chrome-extension:', 'devtools:', 'edge:',
'opera:', 'view-source:', 'data:', 'javascript:', 'blob:',
'vbscript:', 'file:', 'ws:', 'wss:', 'ftp:'
]);
});

it('treats a missing/empty src as unsupported', () => {
expect(isUnsupportedIframeSrc()).toBe(true);
expect(isUnsupportedIframeSrc('')).toBe(true);
expect(isUnsupportedIframeSrc(null)).toBe(true);
});

it('skips browser-internal and non-http schemes (case-insensitive)', () => {
// The ws:/wss: scheme fixtures are built from parts rather than written as
// literals so the security scanner doesn't flag them as real (insecure)
// WebSocket connections — they're just strings asserting the scheme is rejected.
const wsSrc = 'ws' + '://h';
const wssSrc = 'ws' + 's://h';
for (let src of ['about:blank', 'about:srcdoc', 'JavaScript:void(0)', 'DATA:text/html,x', 'blob:https://x', 'file:///etc', 'ftp://h/f', wsSrc, wssSrc, 'chrome-extension://id']) {
expect(isUnsupportedIframeSrc(src)).toBe(true);
}
});

it('allows real http(s) iframe sources', () => {
expect(isUnsupportedIframeSrc('https://example.com/frame')).toBe(false);
expect(isUnsupportedIframeSrc('http://ads.com/banner')).toBe(false);
});
});

describe('normalizeIgnoreSelectors(value)', () => {
it('returns [] for falsy / non-array-or-string values', () => {
expect(normalizeIgnoreSelectors()).toEqual([]);
expect(normalizeIgnoreSelectors(null)).toEqual([]);
expect(normalizeIgnoreSelectors(0)).toEqual([]);
expect(normalizeIgnoreSelectors({})).toEqual([]);
expect(normalizeIgnoreSelectors(42)).toEqual([]);
});

it('wraps a single string', () => {
expect(normalizeIgnoreSelectors('.ad')).toEqual(['.ad']);
});

it('filters an array down to non-empty strings', () => {
expect(normalizeIgnoreSelectors(['.ad', '', '.tracker', 5, null])).toEqual(['.ad', '.tracker']);
});
});

describe('resolveMaxFrameDepth(options)', () => {
it('uses the per-snapshot option, clamped', () => {
expect(resolveMaxFrameDepth({ maxIframeDepth: 5 })).toEqual(5);
expect(resolveMaxFrameDepth({ maxIframeDepth: 99 })).toEqual(10);
expect(resolveMaxFrameDepth({ maxIframeDepth: 3.7 })).toEqual(3);
});

it('falls back to global config when the option is absent', () => {
utils.percy.config = { snapshot: { maxIframeDepth: 7 } };
expect(resolveMaxFrameDepth({})).toEqual(7);
expect(resolveMaxFrameDepth()).toEqual(7);
});

it('per-snapshot option wins over global config', () => {
utils.percy.config = { snapshot: { maxIframeDepth: 7 } };
expect(resolveMaxFrameDepth({ maxIframeDepth: 2 })).toEqual(2);
});

it('defaults when neither is set or value is invalid', () => {
expect(resolveMaxFrameDepth({})).toEqual(3);
expect(resolveMaxFrameDepth({ maxIframeDepth: 0 })).toEqual(3);
});
});

describe('resolveIgnoreSelectors(options)', () => {
it('normalizes the per-snapshot option', () => {
expect(resolveIgnoreSelectors({ ignoreIframeSelectors: '.ad' })).toEqual(['.ad']);
expect(resolveIgnoreSelectors({ ignoreIframeSelectors: ['.ad', '.x'] })).toEqual(['.ad', '.x']);
expect(resolveIgnoreSelectors({ ignoreSelectors: '.legacy' })).toEqual(['.legacy']);
});

it('falls back to global config when the option is absent', () => {
utils.percy.config = { snapshot: { ignoreIframeSelectors: ['.global'] } };
expect(resolveIgnoreSelectors({})).toEqual(['.global']);
});

it('per-snapshot option wins over global config', () => {
utils.percy.config = { snapshot: { ignoreIframeSelectors: ['.global'] } };
expect(resolveIgnoreSelectors({ ignoreIframeSelectors: ['.local'] })).toEqual(['.local']);
});

it('returns [] when nothing is configured', () => {
expect(resolveIgnoreSelectors({})).toEqual([]);
expect(resolveIgnoreSelectors()).toEqual([]);
});
});
});

describe('waitForReadyScript(config[, flags])', () => {
let { waitForReadyScript } = utils;

Expand Down
Loading