diff --git a/packages/sdk-utils/src/index.js b/packages/sdk-utils/src/index.js index 7fba38706..a4d5aec40 100644 --- a/packages/sdk-utils/src/index.js +++ b/packages/sdk-utils/src/index.js @@ -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); + 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); + if (perSnapshot.length) return perSnapshot; + return normalizeIgnoreSelectors(percy?.config?.snapshot?.ignoreIframeSelectors); +} + export { logger, percy, @@ -52,6 +103,11 @@ export { DEFAULT_MAX_IFRAME_DEPTH, HARD_MAX_IFRAME_DEPTH, clampIframeDepth, + UNSUPPORTED_IFRAME_SRCS, + isUnsupportedIframeSrc, + normalizeIgnoreSelectors, + resolveMaxFrameDepth, + resolveIgnoreSelectors, waitForReadyScript, getReadinessConfig, isReadinessDisabled, diff --git a/packages/sdk-utils/test/index.test.js b/packages/sdk-utils/test/index.test.js index 5362f2592..e3dbf6a13 100644 --- a/packages/sdk-utils/test/index.test.js +++ b/packages/sdk-utils/test/index.test.js @@ -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;