diff --git a/packages/core/src/closed-shadow.js b/packages/core/src/closed-shadow.js new file mode 100644 index 000000000..6ade4e8d3 --- /dev/null +++ b/packages/core/src/closed-shadow.js @@ -0,0 +1,197 @@ +// Closed-shadow capture helper. CLI-side only. +// +// External Percy SDK plugins (puppeteer-percy, playwright-percy, +// cypress-percy, selenium-chrome-percy) will get their own copy when +// SDK-side closed-shadow capture is added — that work is intentionally +// scoped to a separate change so this PR stays focused on the CLI path. +// +// Discovers closed shadow roots in the live page and exposes them to +// PercyDOM.serialize() via per-document `__percyClosedShadowRoots` +// WeakMaps that clone-dom.js reads through shadow-utils.getRuntime(). +// +// Closed shadow roots are inaccessible from JavaScript +// (`element.shadowRoot === null`), but Chrome DevTools Protocol's DOM domain +// can pierce them. We get the full DOM tree with `pierce: true` (which also +// traverses iframe boundaries — closed shadow hosts inside iframes are +// captured by the same walk), collect every closed-shadow host/root pair, +// resolve both to JS object references via `DOM.resolveNode`, then call +// `Runtime.callFunctionOn` to write the mapping. The function body installs +// the WeakMap on the host's *own* `ownerDocument.defaultView` — so a host +// inside an iframe writes into the iframe's realm, where shadow-utils will +// later read it. +// +// Works for any caller that has a CDP session-like object exposing +// `send(method, params) => Promise`: +// - Puppeteer: `await page.target().createCDPSession()` +// - Playwright: `await context.newCDPSession(page)` +// - Selenium: `await driver.getDevTools()` (Chromium only) +// - Percy CLI: Percy's own session.send wrapper +// +// Side effect: temporarily enables and then disables the CDP `DOM` domain +// on the supplied session. Don't run concurrently with another `DOM`-domain +// consumer on the same session — the helper installs an in-flight guard +// against itself, but can't see other consumers. +// +// Limitation: captures the closed shadow roots present at the time of the +// call. Custom elements that lazy-attach a closed shadow root after this +// returns (e.g. inside `requestIdleCallback` or `IntersectionObserver`) +// won't be captured. The caller is responsible for waiting until the page +// is settled before invoking. +// +// Returns the number of closed shadow roots successfully exposed (0 if none, +// -1 on top-level error). Per-pair errors are swallowed and surfaced via the +// optional `log` callback — closed-shadow capture is best-effort and must +// never break a snapshot run. + +const DEFAULT_LOG = () => {}; + +// Mirrors HARD_MAX_IFRAME_DEPTH from serialize-frames so every recursive +// walk in the capture pipeline shares the same ceiling. Counted only across +// shadow / iframe boundary crossings — not plain children — otherwise a +// normal deep DOM (html → body → div → … → custom-element) would burn +// through the budget before reaching any shadow host. +const MAX_SHADOW_DEPTH = 10; + +// Bound concurrent CDP messages so we don't flood a session with hundreds +// of in-flight resolveNode/callFunctionOn calls when a page has many +// closed shadow hosts. Phase 1 (resolve) issues 2 calls per pair, so peak +// in-flight there is 2 * CDP_BATCH_SIZE; phase 2 (stamp) is 1 per pair so +// peak is exactly CDP_BATCH_SIZE. 8 chosen as a conservative default that +// keeps both phases well under typical CDP message-queue depths. +const CDP_BATCH_SIZE = 8; + +// The function body that installs the WeakMap and writes the host→shadow +// pair. Runs inside Runtime.callFunctionOn with the host as `this`, so +// `this.ownerDocument.defaultView` is the host's *own* realm — the iframe's +// window when the host is inside an iframe. +// +// IMPORTANT: this is a string (required by Runtime.callFunctionOn) AND it +// is intentionally ES5 — it executes in the page's realm, which may be any +// browser/JS target the page itself targets. Don't "modernize" with arrow +// functions, let/const, or optional chaining. +const STAMP_FUNCTION = + 'function(shadowRoot) {' + + ' var w = this.ownerDocument && this.ownerDocument.defaultView;' + + ' if (!w) return;' + + ' if (!w.__percyClosedShadowRoots) w.__percyClosedShadowRoots = new WeakMap();' + + ' w.__percyClosedShadowRoots.set(this, shadowRoot);' + + '}'; + +// Marker for the in-flight guard — prevents concurrent invocations on the +// same session from racing each other's DOM.enable / DOM.disable lifecycle. +// Module-local Symbol (not Symbol.for) so it can't collide with any other +// global registry entry. +const IN_FLIGHT = Symbol('percy.closedShadow.inFlight'); + +export async function exposeClosedShadowRoots(cdp, log = DEFAULT_LOG) { + if (!cdp || typeof cdp.send !== 'function') return -1; + if (cdp[IN_FLIGHT]) { + log('Skipping concurrent closed-shadow CDP discovery on the same session'); + return -1; + } + cdp[IN_FLIGHT] = true; + + let domEnabled = false; + try { + await cdp.send('DOM.enable'); + domEnabled = true; + + const { root } = await cdp.send('DOM.getDocument', { + depth: -1, + pierce: true + }); + + const closedPairs = []; + walkCDPNodes(root, closedPairs); + + if (closedPairs.length === 0) { + return 0; + } + + log(`Found ${closedPairs.length} closed shadow root(s), exposing via CDP`); + + // Phase 1: resolve every backendNodeId → objectId in parallel batches. + const resolved = []; + for (let i = 0; i < closedPairs.length; i += CDP_BATCH_SIZE) { + const slice = closedPairs.slice(i, i + CDP_BATCH_SIZE); + const out = await Promise.all(slice.map(async pair => { + try { + const [hostRes, shadowRes] = await Promise.all([ + cdp.send('DOM.resolveNode', { backendNodeId: pair.hostBackendNodeId }), + cdp.send('DOM.resolveNode', { backendNodeId: pair.shadowBackendNodeId }) + ]); + return { hostObj: hostRes.object, shadowObj: shadowRes.object, pair }; + } catch (err) { + const msg = err && err.message ? err.message : err; + log(`Skipping a closed shadow pair: ${msg} (host=${pair.hostBackendNodeId}, shadow=${pair.shadowBackendNodeId})`); + return null; + } + })); + for (const entry of out) if (entry) resolved.push(entry); + } + + // Phase 2: stamp the WeakMap (per-realm), also batched. Track real + // successes — earlier shapes returned closedPairs.length and overstated + // success when stamps failed. + let stamped = 0; + for (let i = 0; i < resolved.length; i += CDP_BATCH_SIZE) { + const slice = resolved.slice(i, i + CDP_BATCH_SIZE); + const results = await Promise.all(slice.map(({ hostObj, shadowObj, pair }) => + cdp.send('Runtime.callFunctionOn', { + functionDeclaration: STAMP_FUNCTION, + objectId: hostObj.objectId, + arguments: [{ objectId: shadowObj.objectId }] + }).then(() => true).catch(err => { + const msg = err && err.message ? err.message : err; + log(`Skipping a closed shadow pair: ${msg} (host=${pair.hostBackendNodeId}, shadow=${pair.shadowBackendNodeId})`); + return false; + }) + )); + for (const ok of results) if (ok) stamped++; + } + + return stamped; + } catch (err) { + log(`Could not expose closed shadow roots via CDP: ${err && err.message ? err.message : err}`); + return -1; + } finally { + if (domEnabled) { + await cdp.send('DOM.disable').catch(disableErr => { + log(`DOM.disable failed during closed-shadow cleanup: ${disableErr && disableErr.message ? disableErr.message : disableErr}`); + }); + } + delete cdp[IN_FLIGHT]; + } +} + +// Walk a DOM.getDocument tree (with pierce: true) collecting every +// closed-shadow host/root pair we encounter. `pierce: true` traverses both +// shadow boundaries and iframe `contentDocument` boundaries, so a single +// walk reaches closed shadow hosts inside nested iframes. Recursion is +// bounded at MAX_SHADOW_DEPTH levels — counted only across shadow/iframe +// boundary crossings, not plain children — so a deep ordinary DOM doesn't +// exhaust the budget before reaching its shadow hosts. Exported for tests. +export function walkCDPNodes(node, pairs, depth = 0) { + if (!node || depth >= MAX_SHADOW_DEPTH) return; + if (node.shadowRoots) { + for (const sr of node.shadowRoots) { + if (sr.shadowRootType === 'closed') { + pairs.push({ + hostBackendNodeId: node.backendNodeId, + shadowBackendNodeId: sr.backendNodeId + }); + } + // crossing a shadow boundary — increment depth + walkCDPNodes(sr, pairs, depth + 1); + } + } + if (node.children) { + // plain children — same realm, same depth + for (const child of node.children) walkCDPNodes(child, pairs, depth); + } + // pierce: true surfaces iframe content documents on the iframe node; + // crossing into the iframe's realm — increment depth. + if (node.contentDocument) walkCDPNodes(node.contentDocument, pairs, depth + 1); +} + +export default exposeClosedShadowRoots; diff --git a/packages/core/src/config.js b/packages/core/src/config.js index 8ac8601fa..ff20a4f51 100644 --- a/packages/core/src/config.js +++ b/packages/core/src/config.js @@ -287,6 +287,14 @@ export const configSchema = { type: 'boolean', default: false }, + ignoreIframeSelectors: { + type: 'array', + default: [], + items: { + type: 'string', + minLength: 1 + } + }, pseudoClassEnabledElements: { type: 'object', additionalProperties: false, @@ -511,6 +519,7 @@ export const snapshotSchema = { scopeOptions: { $ref: '/config/snapshot#/properties/scopeOptions' }, ignoreCanvasSerializationErrors: { $ref: '/config/snapshot#/properties/ignoreCanvasSerializationErrors' }, ignoreStyleSheetSerializationErrors: { $ref: '/config/snapshot#/properties/ignoreStyleSheetSerializationErrors' }, + ignoreIframeSelectors: { $ref: '/config/snapshot#/properties/ignoreIframeSelectors' }, pseudoClassEnabledElements: { $ref: '/config/snapshot#/properties/pseudoClassEnabledElements' }, discovery: { type: 'object', diff --git a/packages/core/src/page.js b/packages/core/src/page.js index 8bef91afc..3f87d1b03 100644 --- a/packages/core/src/page.js +++ b/packages/core/src/page.js @@ -1,6 +1,7 @@ import fs from 'fs'; import logger from '@percy/logger'; import Network from './network.js'; +import { exposeClosedShadowRoots } from './closed-shadow.js'; import { PERCY_DOM } from './api.js'; import { hostname, @@ -9,6 +10,49 @@ import { serializeFunction } from './utils.js'; +// Internal ceiling on the customElements wait. Set tight (500ms) so a +// page with a never-registering custom element — third-party widget whose +// loader is blocked, typo'd tag name, etc. — doesn't add a full 1500ms to +// every snapshot. Real cascades of legitimate lazy-defined elements +// complete well within this budget; the loop also exits early as soon as +// `:not(:defined)` clears. +export const DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT = 500; + +// Body of the customElements wait. Runs in the browser via +// Runtime.callFunctionOn. Re-polls each tick so lazy-defined element +// cascades are awaited up to the deadline. +// +// IMPORTANT: this body is intentionally ES5 — it is evaluated in the +// page's realm and must work in any browser the page targets. Don't +// "modernize" with arrow functions, let/const, or optional chaining. +export const WAIT_FOR_CUSTOM_ELEMENTS_BODY = ` + var deadline = Date.now() + (arguments[0] || 500); + return new Promise(function(resolve) { + function tick() { + var undef = document.querySelectorAll(":not(:defined)"); + if (!undef.length) return resolve(); + if (Date.now() >= deadline) return resolve(); + var names = {}; + for (var i = 0; i < undef.length; i++) names[undef[i].localName] = true; + var promises = Object.keys(names).map(function(n) { + return window.customElements.whenDefined(n).catch(function(){}); + }); + Promise.race([ + Promise.all(promises), + new Promise(function(r) { setTimeout(r, 100); }) + ]).then(tick); + } + tick(); + }); +`; + +/* istanbul ignore next: runs in the page realm via Runtime.callFunctionOn, + not in the test process — there is no way to instrument it from here */ +function serializeDomCapture(_, options) { + /* eslint-disable-next-line no-undef */ + return { domSnapshot: PercyDOM.serialize(options), url: document.URL }; +} + export class Page { static TIMEOUT = undefined; @@ -187,7 +231,7 @@ export class Page { execute, ...snapshot }) { - let { name, width, enableJavaScript, disableShadowDOM, forceShadowAsLightDOM, domTransformation, reshuffleInvalidTags, ignoreCanvasSerializationErrors, ignoreStyleSheetSerializationErrors, pseudoClassEnabledElements } = snapshot; + let { name, width, enableJavaScript, disableShadowDOM, forceShadowAsLightDOM, domTransformation, reshuffleInvalidTags, ignoreCanvasSerializationErrors, ignoreStyleSheetSerializationErrors, ignoreIframeSelectors, pseudoClassEnabledElements } = snapshot; this.log.debug(`Taking snapshot: ${name}${width ? ` @${width}px` : ''}`, this.meta); // wait for any specified timeout @@ -211,21 +255,58 @@ export class Page { // wait for any final network activity before capturing the dom snapshot await this.network.idle(); + // Pre-snapshot best-effort steps: waiting for lazy custom elements and + // discovering closed shadow roots via CDP. Both target a fully-loaded + // page; if the session has already terminated, skip them so the proper + // crash/close error surfaces from the downstream insertPercyDom + + // serialize evals (which gate on the same session). + // + // Ordering is load-bearing: closed-shadow capture must run AFTER the + // customElements wait so we catch shadows attached inside upgrade / + // connectedCallback hooks. Don't reorder or parallelise these. + if (!this.session.closedReason) { + // Best-effort: a flaky page should not break the snapshot. + try { + await this.eval(WAIT_FOR_CUSTOM_ELEMENTS_BODY, DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT); + } catch (err) { + /* istanbul ignore next: best-effort log; defensive against non-Error throws */ + this.log.debug(`Custom elements wait failed: ${err.message ?? err}`, this.meta); + } + + if (!disableShadowDOM) { + await exposeClosedShadowRoots(this.session, this._logShadowDebug.bind(this)); + } + } + await this.insertPercyDom(); // serialize and capture a DOM snapshot this.log.debug('Serialize DOM', this.meta); - /* istanbul ignore next: no instrumenting injected code */ - let capture = await this.eval((_, options) => ({ - /* eslint-disable-next-line no-undef */ - domSnapshot: PercyDOM.serialize(options), - url: document.URL - }), { enableJavaScript, disableShadowDOM, forceShadowAsLightDOM, domTransformation, reshuffleInvalidTags, ignoreCanvasSerializationErrors, ignoreStyleSheetSerializationErrors, pseudoClassEnabledElements }); + let capture = await this.eval(serializeDomCapture, { + enableJavaScript, + disableShadowDOM, + forceShadowAsLightDOM, + domTransformation, + reshuffleInvalidTags, + ignoreCanvasSerializationErrors, + ignoreStyleSheetSerializationErrors, + ignoreIframeSelectors, + pseudoClassEnabledElements + }); return { ...snapshot, ...capture }; } + // Logger for the closed-shadow CDP helper. Defined on the prototype (not + // a class-field arrow) so it's reachable from a unit test that constructs + // a Page via Object.create without invoking the constructor — gives us a + // direct way to cover the callback without simulating a closed shadow + // discovery flow at the integration level. + _logShadowDebug(msg) { + this.log.debug(msg, this.meta); + } + // Initialize newly attached pages and iframes with page options _handleAttachedToTarget = event => { let session = !event ? this.session diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index e40509bb8..d34ce9d3f 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -4,6 +4,7 @@ import Percy from '@percy/core'; import Pako from 'pako'; import DetectProxy from '@percy/client/detect-proxy'; import { validateSnapshotOptions } from '../src/snapshot.js'; +import { Page, WAIT_FOR_CUSTOM_ELEMENTS_BODY } from '../src/page.js'; describe('Percy', () => { let percy, server; @@ -83,7 +84,8 @@ describe('Percy', () => { responsiveSnapshotCapture: false, ignoreCanvasSerializationErrors: false, ignoreStyleSheetSerializationErrors: false, - forceShadowAsLightDOM: false + forceShadowAsLightDOM: false, + ignoreIframeSelectors: [] }); }); @@ -110,7 +112,7 @@ describe('Percy', () => { }); // expect required arguments are passed to PercyDOM.serialize - expect(evalSpy.calls.allArgs()[3]).toEqual(jasmine.arrayContaining([jasmine.anything(), { enableJavaScript: undefined, disableShadowDOM: true, domTransformation: undefined, reshuffleInvalidTags: undefined, ignoreCanvasSerializationErrors: undefined, ignoreStyleSheetSerializationErrors: undefined, forceShadowAsLightDOM: undefined, pseudoClassEnabledElements: undefined }])); + expect(evalSpy.calls.allArgs()[4]).toEqual(jasmine.arrayContaining([jasmine.anything(), { enableJavaScript: undefined, disableShadowDOM: true, domTransformation: undefined, reshuffleInvalidTags: undefined, ignoreCanvasSerializationErrors: undefined, ignoreStyleSheetSerializationErrors: undefined, ignoreIframeSelectors: undefined, forceShadowAsLightDOM: undefined, pseudoClassEnabledElements: undefined }])); expect(snapshot.url).toEqual('http://localhost:8000/'); expect(snapshot.domSnapshot).toEqual(jasmine.objectContaining({ @@ -120,6 +122,100 @@ describe('Percy', () => { })); }); + it('Page._logShadowDebug forwards messages to log.debug with page meta', () => { + // Covers the callback Page passes to exposeClosedShadowRoots. Real + // snapshot tests don't fire it (no closed shadows in their HTML, no + // CDP error path), so exercise the method directly via Object.create + // with a stubbed log/meta. + let page = Object.create(Page.prototype); + let calls = []; + page.log = { debug: (msg, meta) => calls.push([msg, meta]) }; + page.meta = { snapshot: { name: 'parity' } }; + page._logShadowDebug('found 3 closed shadow root(s)'); + expect(calls).toEqual([['found 3 closed shadow root(s)', page.meta]]); + }); + + it('skips closed-shadow CDP discovery when snapshot.disableShadowDOM is set', async () => { + // When the per-snapshot disableShadowDOM flag is true, page.snapshot() + // skips the exposeClosedShadowRoots CDP call. Verify by inspecting + // session.send call args — the DOM.getDocument send (driven only by + // exposeClosedShadowRoots) must not appear during this snapshot. + server.reply('/', () => [200, 'text/html', '

hi

']); + await percy.browser.launch(); + let page = await percy.browser.page(); + let sendSpy = spyOn(page.session, 'send').and.callThrough(); + await page.goto('http://localhost:8000'); + sendSpy.calls.reset(); + await page.snapshot({ disableShadowDOM: true }); + let domGetDocSends = sendSpy.calls.allArgs().filter(a => a[0] === 'DOM.getDocument'); + expect(domGetDocSends.length).toBe(0); + }); + + it('skips pre-snapshot wait and closed-shadow capture when the session is already closed', async () => { + // Regression for the closedReason gate: when the page session has + // already terminated, page.snapshot() must skip the customElements + // wait + exposeClosedShadowRoots so the proper close error surfaces + // from the downstream insertPercyDom (which gates on the same + // session) rather than leaking a confusing CDP error first. + // + // network.idle() ALSO checks closedReason and throws upstream of the + // gate, so we stub it to resolve cleanly — the test must reach line + // 261 with closedReason already set in order to exercise the false + // branch of the if. + server.reply('/', () => [200, 'text/html', '

hi

']); + await percy.browser.launch(); + let page = await percy.browser.page(); + let sendSpy = spyOn(page.session, 'send').and.callThrough(); + let evalSpy = spyOn(page, 'eval').and.callThrough(); + await page.goto('http://localhost:8000'); + sendSpy.calls.reset(); + evalSpy.calls.reset(); + + spyOn(page.network, 'idle').and.resolveTo(undefined); + page.session.closedReason = 'session closed'; + + await expectAsync(page.snapshot({})).toBeRejected(); + + // Both pre-snapshot best-effort steps must have been skipped. Match the + // wait body by identity against the exported constant so a future + // rename of internals inside the body doesn't silently turn this + // filter into a no-op. + let waitEvals = evalSpy.calls.allArgs().filter(([body]) => + body === WAIT_FOR_CUSTOM_ELEMENTS_BODY); + expect(waitEvals.length).toBe(0); + let domGetDocSends = sendSpy.calls.allArgs().filter(a => a[0] === 'DOM.getDocument'); + expect(domGetDocSends.length).toBe(0); + }); + + it('continues the snapshot when the customElements wait throws', async () => { + // The wait is best-effort — a flaky page that errors during the + // customElements.whenDefined poll must not break the snapshot. Force + // the wait eval to throw and assert that (a) the snapshot still + // resolves and (b) the failure is captured in the debug log. + server.reply('/', () => [200, 'text/html', '

hi

']); + await percy.browser.launch(); + let page = await percy.browser.page(); + logger.loglevel('debug'); + await page.goto('http://localhost:8000'); + + let originalEval = page.eval.bind(page); + spyOn(page, 'eval').and.callFake((body, ...args) => { + // Identity-match the exported constant rather than substring-matching + // implementation details inside the body — a rename of any internal + // local would otherwise silently turn this fake into a passthrough. + if (body === WAIT_FOR_CUSTOM_ELEMENTS_BODY) { + throw new Error('boom'); + } + return originalEval(body, ...args); + }); + + await expectAsync(page.snapshot({ disableShadowDOM: true })).toBeResolved(); + + expect(logger.stderr).toEqual(jasmine.arrayContaining([ + jasmine.stringMatching(/Custom elements wait failed: boom/) + ])); + }); + describe('.start()', () => { // rather than stub prototypes, extend and mock class TestPercy extends Percy { @@ -2217,9 +2313,10 @@ describe('Percy', () => { percy = new Percy({ token: 'PERCY_TOKEN', archiveDir: './percy-archive' }); await expectAsync(percy.start()).toBeResolved(); - expect(percy.archiveDir).toMatch(/\/percy-archive$/); + // Windows resolves to backslash separators; match either / or \. + expect(percy.archiveDir).toMatch(/[\\/]percy-archive$/); expect(logger.stdout).toEqual(jasmine.arrayContaining([ - jasmine.stringMatching(/Archiving snapshots to: .*\/percy-archive/) + jasmine.stringMatching(/Archiving snapshots to: .*[\\/]percy-archive/) ])); }); @@ -2250,7 +2347,7 @@ describe('Percy', () => { await expectAsync(percy.stop()).toBeResolved(); expect(logger.stdout).toEqual(jasmine.arrayContaining([ - jasmine.stringMatching(/Archived 1 snapshot\(s\) to: .*\/percy-archive/) + jasmine.stringMatching(/Archived 1 snapshot\(s\) to: .*[\\/]percy-archive/) ])); }); diff --git a/packages/core/test/unit/archive.test.js b/packages/core/test/unit/archive.test.js index bfab5f9d0..0b61e2362 100644 --- a/packages/core/test/unit/archive.test.js +++ b/packages/core/test/unit/archive.test.js @@ -12,18 +12,25 @@ import { describe('Unit / Archive', () => { describe('validateArchiveDir', () => { it('resolves a valid path', () => { - let result = validateArchiveDir('/tmp/percy-archive'); - expect(result).toBe('/tmp/percy-archive'); + // Use path.resolve to construct the expected value so this works on + // Windows (which prepends a drive letter) as well as POSIX. + let input = path.resolve('/tmp/percy-archive'); + let result = validateArchiveDir(input); + expect(result).toBe(input); }); it('resolves a relative path to absolute', () => { let result = validateArchiveDir('./percy-archive'); - expect(result).toMatch(/\/percy-archive$/); + // Windows resolves to backslash separators; match either / or \. + expect(result).toMatch(/[\\/]percy-archive$/); expect(result).not.toContain('..'); }); it('rejects paths that resolve to traversal segments', () => { - let traversal = '/foo/../../etc'; + // Construct the path using the platform path.sep so the traversal + // check (which splits on path.sep) sees the '..' segments on both + // POSIX and Windows. + let traversal = ['', 'foo', '..', '..', 'etc'].join(path.sep); spyOn(path, 'resolve').and.returnValue(traversal); spyOn(path, 'normalize').and.returnValue(traversal); @@ -194,6 +201,10 @@ describe('Unit / Archive', () => { it('skips symlink entries with a warning', () => { let archiveDir = '.test-archive-symlink'; + // Clean any stale dir from a previous failed run — without this, + // Windows CI hits EEXIST on the symlink because the dir persists + // between runs. + fs.rmSync(archiveDir, { recursive: true, force: true }); fs.mkdirSync(archiveDir, { recursive: true }); fs.writeFileSync(`${archiveDir}/target.json`, '{}'); fs.symlinkSync( diff --git a/packages/core/test/unit/closed-shadow.test.js b/packages/core/test/unit/closed-shadow.test.js new file mode 100644 index 000000000..930cab6a2 --- /dev/null +++ b/packages/core/test/unit/closed-shadow.test.js @@ -0,0 +1,367 @@ +import exposeClosedShadowRoots, { walkCDPNodes } from '../../src/closed-shadow.js'; + +describe('exposeClosedShadowRoots', () => { + function makeCdp(handlers) { + let calls = []; + return { + calls, + send: (method, params) => { + calls.push([method, params]); + let h = handlers[method]; + if (typeof h === 'function') return h(params); + return Promise.resolve(h ?? {}); + } + }; + } + + it('returns -1 for invalid cdp inputs', async () => { + expect(await exposeClosedShadowRoots(null)).toBe(-1); + expect(await exposeClosedShadowRoots({})).toBe(-1); + expect(await exposeClosedShadowRoots({ send: 'not a function' })).toBe(-1); + }); + + it('returns 0 and disables DOM domain when no closed shadows exist', async () => { + let cdp = makeCdp({ + 'DOM.getDocument': () => Promise.resolve({ + root: { + backendNodeId: 1, + children: [ + { backendNodeId: 2, children: [] }, + { backendNodeId: 3, shadowRoots: [{ shadowRootType: 'open', backendNodeId: 4 }] } + ] + } + }) + }); + expect(await exposeClosedShadowRoots(cdp)).toBe(0); + expect(cdp.calls.find(c => c[0] === 'DOM.disable')).toBeDefined(); + }); + + it('exposes closed shadow roots via Runtime.callFunctionOn (per-realm install)', async () => { + // The stamp function body must reference ownerDocument.defaultView so + // hosts in any realm install the WeakMap on the right window. + let cdp = makeCdp({ + 'DOM.getDocument': () => Promise.resolve({ + root: { + backendNodeId: 1, + children: [ + { + backendNodeId: 2, + shadowRoots: [ + { shadowRootType: 'closed', backendNodeId: 10, children: [] }, + { shadowRootType: 'open', backendNodeId: 11, children: [] } + ] + } + ] + } + }), + 'DOM.resolveNode': ({ backendNodeId }) => Promise.resolve({ + object: { objectId: `obj-${backendNodeId}` } + }) + }); + + let logs = []; + expect(await exposeClosedShadowRoots(cdp, msg => logs.push(msg))).toBe(1); + + let runtimeCalls = cdp.calls.filter(c => c[0] === 'Runtime.callFunctionOn'); + expect(runtimeCalls.length).toBe(1); + expect(runtimeCalls[0][1].objectId).toBe('obj-2'); + expect(runtimeCalls[0][1].arguments[0].objectId).toBe('obj-10'); + expect(runtimeCalls[0][1].functionDeclaration).toContain('ownerDocument.defaultView'); + expect(runtimeCalls[0][1].functionDeclaration).toContain('__percyClosedShadowRoots'); + + // No standalone Runtime.evaluate to install the WeakMap — install is + // bundled into the per-pair stamp now. + expect(cdp.calls.find(c => c[0] === 'Runtime.evaluate')).toBeUndefined(); + expect(logs[0]).toContain('Found 1 closed shadow root'); + }); + + it('returns the count of successfully stamped pairs, not just discovered', async () => { + // 2 pairs discovered; second callFunctionOn rejects. Return value + // reflects only the 1 that succeeded. + let cfoCalls = 0; + let cdp = makeCdp({ + 'DOM.getDocument': () => Promise.resolve({ + root: { + backendNodeId: 1, + shadowRoots: [ + { shadowRootType: 'closed', backendNodeId: 10 }, + { shadowRootType: 'closed', backendNodeId: 20 } + ] + } + }), + 'DOM.resolveNode': ({ backendNodeId }) => Promise.resolve({ + object: { objectId: `obj-${backendNodeId}` } + }), + 'Runtime.callFunctionOn': () => { + cfoCalls++; + if (cfoCalls === 1) return Promise.reject(new Error('detached')); + return Promise.resolve({}); + } + }); + let logs = []; + expect(await exposeClosedShadowRoots(cdp, m => logs.push(m))).toBe(1); + expect(logs.some(m => m.includes('Skipping a closed shadow pair: detached'))).toBe(true); + }); + + it('returns 0 when every stamp fails', async () => { + let cdp = makeCdp({ + 'DOM.getDocument': () => Promise.resolve({ + root: { + backendNodeId: 1, + shadowRoots: [{ shadowRootType: 'closed', backendNodeId: 10 }] + } + }), + 'DOM.resolveNode': ({ backendNodeId }) => Promise.resolve({ + object: { objectId: `obj-${backendNodeId}` } + }), + 'Runtime.callFunctionOn': () => Promise.reject(new Error('all bad')) + }); + expect(await exposeClosedShadowRoots(cdp)).toBe(0); + }); + + it('skips a single bad resolveNode pair and continues with the rest', async () => { + let resolveCalls = 0; + let cdp = makeCdp({ + 'DOM.getDocument': () => Promise.resolve({ + root: { + backendNodeId: 1, + shadowRoots: [ + { shadowRootType: 'closed', backendNodeId: 100 }, + { shadowRootType: 'closed', backendNodeId: 200 } + ] + } + }), + 'DOM.resolveNode': () => { + resolveCalls++; + if (resolveCalls === 1) return Promise.reject(new Error('node detached')); + return Promise.resolve({ object: { objectId: `obj-${resolveCalls}` } }); + } + }); + + let logs = []; + let result = await exposeClosedShadowRoots(cdp, msg => logs.push(msg)); + // Only one pair survived resolveNode → 1 stamp succeeded. + expect(result).toBe(1); + expect(logs.some(m => m.includes('Skipping a closed shadow pair'))).toBe(true); + }); + + it('returns -1 and logs when DOM.enable / DOM.getDocument throws', async () => { + let cdp = makeCdp({ + 'DOM.enable': () => Promise.reject(new Error('CDP domain unavailable')) + }); + let logs = []; + expect(await exposeClosedShadowRoots(cdp, msg => logs.push(msg))).toBe(-1); + expect(logs.some(m => m.includes('CDP domain unavailable'))).toBe(true); + }); + + it('uses a default no-op log when no callback is supplied', async () => { + let cdp = makeCdp({ 'DOM.enable': () => Promise.reject(new Error('exercise default log')) }); + expect(await exposeClosedShadowRoots(cdp)).toBe(-1); + }); + + it('tolerates non-Error thrown values in the catch path', async () => { + const nonErrorReason = 'plain string'; + let cdp = makeCdp({ 'DOM.enable': () => Promise.reject(nonErrorReason) }); + let logs = []; + expect(await exposeClosedShadowRoots(cdp, m => logs.push(m))).toBe(-1); + expect(logs[0]).toContain('plain string'); + }); + + it('tolerates a non-Error thrown by DOM.resolveNode (per-pair catch)', async () => { + const nonErrorReason = 'detached'; + let cdp = makeCdp({ + 'DOM.getDocument': () => Promise.resolve({ + root: { + backendNodeId: 1, + shadowRoots: [{ shadowRootType: 'closed', backendNodeId: 100 }] + } + }), + 'DOM.resolveNode': () => Promise.reject(nonErrorReason) + }); + let logs = []; + expect(await exposeClosedShadowRoots(cdp, m => logs.push(m))).toBe(0); + expect(logs.some(m => m.includes('Skipping a closed shadow pair: detached'))).toBe(true); + }); + + it('tolerates a non-Error thrown by Runtime.callFunctionOn (per-pair catch)', async () => { + const nonErrorReason = 'cfo-string'; + let cdp = makeCdp({ + 'DOM.getDocument': () => Promise.resolve({ + root: { + backendNodeId: 1, + shadowRoots: [{ shadowRootType: 'closed', backendNodeId: 100 }] + } + }), + 'DOM.resolveNode': ({ backendNodeId }) => Promise.resolve({ object: { objectId: `obj-${backendNodeId}` } }), + 'Runtime.callFunctionOn': () => Promise.reject(nonErrorReason) + }); + let logs = []; + expect(await exposeClosedShadowRoots(cdp, m => logs.push(m))).toBe(0); + expect(logs.some(m => m.includes('Skipping a closed shadow pair: cfo-string'))).toBe(true); + }); + + it('logs (rather than swallowing silently) when DOM.disable rejects in finally', async () => { + let cdp = makeCdp({ + 'DOM.getDocument': () => Promise.resolve({ root: { backendNodeId: 1 } }), + 'DOM.disable': () => Promise.reject(new Error('disable failed')) + }); + let logs = []; + expect(await exposeClosedShadowRoots(cdp, m => logs.push(m))).toBe(0); + expect(logs.some(m => m.includes('DOM.disable failed') && m.includes('disable failed'))).toBe(true); + }); + + it('tolerates a non-Error thrown by DOM.disable in finally', async () => { + const nonErrorReason = 'disable-string'; + let cdp = makeCdp({ + 'DOM.getDocument': () => Promise.resolve({ root: { backendNodeId: 1 } }), + 'DOM.disable': () => Promise.reject(nonErrorReason) + }); + let logs = []; + expect(await exposeClosedShadowRoots(cdp, m => logs.push(m))).toBe(0); + expect(logs.some(m => m.includes('DOM.disable failed') && m.includes('disable-string'))).toBe(true); + }); + + it('processes more pairs than the batch size in multiple passes', async () => { + const shadowRoots = []; + for (let i = 0; i < 20; i++) { + shadowRoots.push({ shadowRootType: 'closed', backendNodeId: 100 + i }); + } + let cdp = makeCdp({ + 'DOM.getDocument': () => Promise.resolve({ + root: { backendNodeId: 1, shadowRoots } + }), + 'DOM.resolveNode': ({ backendNodeId }) => Promise.resolve({ + object: { objectId: `obj-${backendNodeId}` } + }) + }); + expect(await exposeClosedShadowRoots(cdp)).toBe(20); + let cfoCalls = cdp.calls.filter(c => c[0] === 'Runtime.callFunctionOn'); + expect(cfoCalls.length).toBe(20); + }); + + it('rejects concurrent invocations on the same session', async () => { + // First invocation parks at DOM.getDocument; second invocation arrives, + // sees the in-flight guard, and bails immediately with -1. + let release; + let getDocPromise = new Promise(resolve => { release = resolve; }); + let cdp = makeCdp({ + 'DOM.getDocument': () => getDocPromise.then(() => ({ + root: { + backendNodeId: 1, + shadowRoots: [{ shadowRootType: 'closed', backendNodeId: 10 }] + } + })), + 'DOM.resolveNode': ({ backendNodeId }) => Promise.resolve({ + object: { objectId: `obj-${backendNodeId}` } + }) + }); + + let logs = []; + let first = exposeClosedShadowRoots(cdp, m => logs.push(m)); + // Yield so the first call sets the in-flight guard before the second starts. + await Promise.resolve(); + let second = exposeClosedShadowRoots(cdp, m => logs.push(m)); + expect(await second).toBe(-1); + expect(logs.some(m => m.includes('Skipping concurrent closed-shadow CDP discovery'))).toBe(true); + + release(); + expect(await first).toBe(1); + + // After the first call finishes, the guard is cleared — a fresh invocation + // proceeds normally. + let third = await exposeClosedShadowRoots(cdp); + expect(third).toBe(1); + }); +}); + +describe('walkCDPNodes', () => { + it('does nothing for null/undefined', () => { + let pairs = []; + walkCDPNodes(null, pairs); + walkCDPNodes(undefined, pairs); + expect(pairs).toEqual([]); + }); + + it('records closed pairs and recurses into shadow + child trees', () => { + let pairs = []; + walkCDPNodes({ + backendNodeId: 1, + shadowRoots: [ + { + shadowRootType: 'closed', + backendNodeId: 10, + children: [{ + backendNodeId: 11, + shadowRoots: [{ shadowRootType: 'closed', backendNodeId: 12 }] + }] + } + ], + children: [ + { backendNodeId: 2, shadowRoots: [{ shadowRootType: 'open', backendNodeId: 20 }] } + ] + }, pairs); + expect(pairs).toEqual([ + { hostBackendNodeId: 1, shadowBackendNodeId: 10 }, + { hostBackendNodeId: 11, shadowBackendNodeId: 12 } + ]); + }); + + it('descends into iframe contentDocument from pierce: true', () => { + let pairs = []; + walkCDPNodes({ + backendNodeId: 1, + children: [{ + backendNodeId: 2, + nodeName: 'IFRAME', + contentDocument: { + backendNodeId: 3, + children: [{ + backendNodeId: 4, + shadowRoots: [{ shadowRootType: 'closed', backendNodeId: 5 }] + }] + } + }] + }, pairs); + expect(pairs).toEqual([ + { hostBackendNodeId: 4, shadowBackendNodeId: 5 } + ]); + }); + + it('does NOT count plain children toward the depth budget', () => { + // 30 plain children deep, then a closed shadow root at the bottom. + // Without the boundary-only depth rule a 10-level plain-child cap would + // miss this; the new rule only increments depth on shadow/iframe + // boundary crossings, so the shadow at the bottom is still captured. + let leaf = { + backendNodeId: 9999, + shadowRoots: [{ shadowRootType: 'closed', backendNodeId: 10000 }] + }; + for (let i = 0; i < 30; i++) { + leaf = { backendNodeId: 1000 + i, children: [leaf] }; + } + let pairs = []; + walkCDPNodes(leaf, pairs); + expect(pairs).toEqual([ + { hostBackendNodeId: 9999, shadowBackendNodeId: 10000 } + ]); + }); + + it('caps shadow boundary recursion at MAX_SHADOW_DEPTH (10)', () => { + // Build a chain of nested closed shadow hosts. Each shadow boundary + // increments depth, so a 30-link chain truncates at 10 pairs. + let leaf = { backendNodeId: 9999 }; + for (let i = 0; i < 30; i++) { + leaf = { + backendNodeId: 1000 + i, + shadowRoots: [{ + shadowRootType: 'closed', + backendNodeId: 2000 + i, + children: [leaf] + }] + }; + } + let pairs = []; + walkCDPNodes(leaf, pairs); + expect(pairs.length).toBe(10); + }); +}); diff --git a/packages/dom/src/clone-dom.js b/packages/dom/src/clone-dom.js index 5f9371166..89e752986 100644 --- a/packages/dom/src/clone-dom.js +++ b/packages/dom/src/clone-dom.js @@ -6,7 +6,11 @@ import markElement from './prepare-dom'; import applyElementTransformations from './transform-dom'; import serializeBase64 from './serialize-base64'; -import { handleErrors } from './utils'; +import { handleErrors, isCustomElement } from './utils'; +import { + getClosedShadowRoot, + hasClosedShadowRoot +} from './shadow-utils'; /** * Deep clone a document while also preserving shadow roots @@ -16,13 +20,17 @@ import { handleErrors } from './utils'; const ignoreTags = ['NOSCRIPT']; /** - * if a custom element has attribute callback then cloneNode calls a callback that can - * increase CPU load or some other change. - * So we want to make sure that it is not called when doing serialization. -*/ + * Clone an element without triggering custom element lifecycle callbacks. + * Custom elements with callbacks or closed shadow roots are cloned as proxy elements + * to prevent constructors from running (which could call attachShadow, fetch data, etc). + */ function cloneElementWithoutLifecycle(element) { - if (!(element.attributeChangedCallback) || !element.tagName.includes('-')) { - return element.cloneNode(); // Standard clone for non-custom elements + let isCustom = isCustomElement(element); + let hasClosedShadow = isCustom && hasClosedShadowRoot(element); + let hasCallbacks = isCustom && element.attributeChangedCallback; + + if (!isCustom || (!hasCallbacks && !hasClosedShadow)) { + return element.cloneNode(); } const cloned = document.createElement('data-percy-custom-element-' + element.tagName); @@ -65,6 +73,10 @@ export function cloneNodeAndShadow(ctx) { let clone = cloneElementWithoutLifecycle(node); + // Custom-element :state() is captured by the fallback path in + // serialize-custom-states.js (live el.matches against state names + // discovered in CSS) — no clone-time fast path remains. + // Handle ') would let a hostile page CSS escape +// the rewritten

content

'; + document.getElementById('test').appendChild(el); + + let result = serializeDOM(); + expect(result.html).toContain('[data-percy-custom-state~="active"]'); + expect(result.html).not.toContain(':state(active)'); + }); + + it('rewrites legacy :--state selectors', () => { + if (getTestBrowser() !== chromeBrowser) return; + + withExample('', { withShadow: false }); + let el = document.createElement('div'); + let shadow = el.attachShadow({ mode: 'open' }); + shadow.innerHTML = '

content

'; + document.getElementById('test').appendChild(el); + + let result = serializeDOM(); + expect(result.html).toContain('[data-percy-custom-state~="loading"]'); + expect(result.html).not.toContain(':--loading'); + }); + }); + + describe('shadow-utils getRuntime fallback', () => { + it('falls back to window when the node has no ownerDocument.defaultView', () => { + // Exercises the `(typeof window !== 'undefined' ? window : null)` fallback + // branch in shadow-utils.getRuntime — fires when getClosedShadowRoot is + // called with a node that is null or has no resolvable runtime. + // null-host calls return null/false without throwing — they hit the + // fallback, then the optional chain on the missing WeakMap yields the + // expected absent value. + expect(getClosedShadowRoot(null)).toBeNull(); + expect(hasClosedShadowRoot(null)).toBe(false); + }); + }); }); diff --git a/packages/dom/test/serialize-frames.test.js b/packages/dom/test/serialize-frames.test.js index eb208cf1e..e9b27c375 100644 --- a/packages/dom/test/serialize-frames.test.js +++ b/packages/dom/test/serialize-frames.test.js @@ -1,7 +1,7 @@ import { when } from 'interactor.js'; import { assert, withExample, parseDOM, platforms, platformDOM, getTestBrowser, chromeBrowser, firefoxBrowser } from './helpers'; import serializeDOM from '../src/serialize-dom'; -import { resetPolicy } from '../src/serialize-frames'; +import { resetPolicy, serializeFrames } from '../src/serialize-frames'; describe('serializeFrames', () => { let serialized, cache = { shadow: {}, plain: {} }; @@ -306,6 +306,109 @@ describe('serializeFrames', () => { expect($('#frame-inject')).toHaveSize(0); }); + it(`${platform}: warns for fully sandboxed iframes`, () => { + withExample(''); + + let result = serializeDOM(); + expect(result.warnings).toContain( + jasmine.stringMatching(/Sandboxed iframe.*frame-sandbox-full.*has no permissions/) + ); + }); + + it(`${platform}: warns for sandboxed iframe without allow-scripts`, () => { + withExample(''); + + let result = serializeDOM(); + expect(result.warnings).toContain( + jasmine.stringMatching(/Sandboxed iframe.*frame-sandbox-no-scripts.*scripts disabled/) + ); + }); + + it(`${platform}: warns for sandboxed iframe without allow-same-origin`, () => { + withExample(''); + + let result = serializeDOM(); + expect(result.warnings).toContain( + jasmine.stringMatching(/Sandboxed iframe.*frame-sandbox-no-origin.*allow-same-origin/) + ); + }); + + it(`${platform}: does not warn for sandbox with allow-scripts and allow-same-origin`, () => { + withExample(''); + + let result = serializeDOM(); + let sandboxWarnings = result.warnings.filter(w => w.includes('frame-sandbox-ok')); + expect(sandboxWarnings).toEqual([]); + }); + + it(`${platform}: does not warn for iframes without sandbox attribute`, () => { + withExample(''); + + let result = serializeDOM(); + let sandboxWarnings = result.warnings.filter(w => w.includes('Sandboxed iframe')); + expect(sandboxWarnings).toEqual([]); + }); + + it(`${platform}: warns for sandboxed iframe without id using src as label`, () => { + withExample(''); + + let result = serializeDOM(); + expect(result.warnings).toContain( + jasmine.stringMatching(/Sandboxed iframe.*has no permissions/) + ); + }); + + it(`${platform}: warns for sandboxed iframe without id or src using percyElementId or unknown as label`, () => { + withExample(''); + + let result = serializeDOM(); + expect(result.warnings).toContain( + jasmine.stringMatching(/Sandboxed iframe.*has no permissions/) + ); + }); + + it(`${platform}: removes iframes with data-percy-ignore`, () => { + withExample('' + + ''); + + let result = serializeDOM(); + let $parsed = parseDOM(result.html, platform); + expect($parsed('#frame-ignored')).toHaveSize(0); + expect($parsed('#frame-kept')).toHaveSize(1); + }); + + it(`${platform}: removes iframes matching ignoreIframeSelectors`, () => { + withExample('' + + '' + + ''); + + let result = serializeDOM({ ignoreIframeSelectors: ['.ad-frame', '[data-tracking]'] }); + let $parsed = parseDOM(result.html, platform); + expect($parsed('#frame-ad')).toHaveSize(0); + expect($parsed('#frame-track')).toHaveSize(0); + expect($parsed('#frame-normal')).toHaveSize(1); + }); + + it(`${platform}: handles invalid selectors in ignoreIframeSelectors gracefully`, () => { + withExample(''); + + let result = serializeDOM({ ignoreIframeSelectors: ['[invalid==='] }); + let $parsed = parseDOM(result.html, platform); + expect($parsed('#frame-ok')).toHaveSize(1); + }); + + it(`${platform}: does not remove iframes without data-percy-ignore`, () => { + withExample('
' + + '' + + '
' + + ''); + + let result = serializeDOM(); + let $parsed = parseDOM(result.html, platform); + expect($parsed('#frame-inside-ignore-div')).toHaveSize(1); + expect($parsed('#frame-outside')).toHaveSize(1); + }); + if (platform === 'plain') { it('uses Trusted Types policy to create srcdoc when available', () => { let createHTML = jasmine.createSpy('createHTML').and.callFake(html => html); @@ -388,4 +491,66 @@ describe('serializeFrames', () => { }); } }); + + describe('iframe depth limit', () => { + it('clamps non-finite or out-of-range maxIframeDepth to the default', () => { + // Non-finite (NaN), zero, and negative values must fall back to + // DEFAULT_MAX_IFRAME_DEPTH (3) — exercises clampIframeDepth's invalid path. + withExample(''); + // No await needed — even if the iframe isn't fully loaded, the call shouldn't throw. + expect(() => serializeDOM({ maxIframeDepth: 'not a number' })).not.toThrow(); + expect(() => serializeDOM({ maxIframeDepth: 0 })).not.toThrow(); + expect(() => serializeDOM({ maxIframeDepth: -5 })).not.toThrow(); + }); + + it('caps user-supplied maxIframeDepth at the hard maximum (10)', () => { + // Values above HARD_MAX_IFRAME_DEPTH (10) get clamped to 10. + withExample(''); + let result = serializeDOM({ maxIframeDepth: 999 }); + expect(result).toBeDefined(); + expect(result.html).toBeDefined(); + }); + + it('skips recursion into iframes past the depth limit', async () => { + // With maxIframeDepth=1, the very first iframe (depth 0) trips the + // iframeDepth+1 >= 1 guard and is skipped — its srcdoc is left as + // the original literal markup rather than being replaced with + // serialized HTML (which would contain a ). + withExample(''); + await getFrame('depth-skip'); + let result = serializeDOM({ maxIframeDepth: 1 }); + // Recursion would have replaced srcdoc with serialized HTML; absence + // of { + it('uses the iframeDepth=0 default when called without it', () => { + // serializeDOM always passes iframeDepth, but a direct caller (test, or + // future consumer) can omit it. The destructure default exists for + // exactly this case and must be reachable in tests. + withExample('', { withShadow: false }); + let dom = document.implementation.createHTMLDocument('TopFrame'); + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + dom.body.innerHTML = ''; + let clone = document.implementation.createHTMLDocument('Clone'); + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + clone.body.innerHTML = dom.body.innerHTML; + + // No iframeDepth in the args → exercises the `= 0` default. + expect(() => serializeFrames({ + dom, + clone, + warnings: new Set(), + resources: new Set(), + enableJavaScript: false, + disableShadowDOM: false, + maxIframeDepth: 3 + })).not.toThrow(); + }); + }); }); diff --git a/packages/dom/test/serialize-pseudo-classes.test.js b/packages/dom/test/serialize-pseudo-classes.test.js index e2d888c18..7bd0c28f9 100644 --- a/packages/dom/test/serialize-pseudo-classes.test.js +++ b/packages/dom/test/serialize-pseudo-classes.test.js @@ -1,8 +1,25 @@ // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method -import { markPseudoClassElements, serializePseudoClasses, getElementsToProcess } from '../src/serialize-pseudo-classes'; +import { markPseudoClassElements, serializePseudoClasses, getElementsToProcess, rewriteCustomStateCSS, cleanupInteractiveStateMarkers, rewritePseudoSelector } from '../src/serialize-pseudo-classes'; +import { rewriteCustomStateSelectors } from '../src/serialize-custom-states'; import { withExample } from './helpers'; +// Helper to mock document.activeElement cross-browser (Firefox headless doesn't honor .focus()) +function withMockedFocus(el, fn) { + let orig = Object.getOwnPropertyDescriptor(document.constructor.prototype, 'activeElement') || + Object.getOwnPropertyDescriptor(document, 'activeElement'); + Object.defineProperty(document, 'activeElement', { get: () => el, configurable: true }); + try { + fn(); + } finally { + if (orig) { + Object.defineProperty(document, 'activeElement', orig); + } else { + delete document.activeElement; + } + } +} + describe('serialize-pseudo-classes', () => { let ctx; @@ -349,6 +366,118 @@ describe('serialize-pseudo-classes', () => { }); }); + describe('rewriteCustomStateCSS', () => { + it('rewrites :state() selectors in style elements', () => { + withExample(''); + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + // Copy style to clone head + let origStyles = document.querySelectorAll('style'); + for (let s of origStyles) { + ctx.clone.head.appendChild(s.cloneNode(true)); + } + + rewriteCustomStateCSS(ctx); + + let style = ctx.clone.head.querySelector('style'); + expect(style.textContent).toContain('[data-percy-custom-state~="open"]'); + expect(style.textContent).not.toContain(':state(open)'); + }); + + it('calls addCustomStateAttributes fallback and detects :state() on elements', () => { + // Register a custom element that uses ElementInternals.states (CustomStateSet) + if (!window.customElements.get('percy-state-fallback')) { + class PercyStateFallback extends window.HTMLElement { + static get formAssociated() { return true; } + + constructor() { + super(); + try { + this._internals = this.attachInternals(); + if (this._internals.states) { + this._internals.states.add('open'); + } + } catch (e) { + // attachInternals not supported + } + } + + connectedCallback() { + this.innerHTML = 'state fallback'; + } + } + window.customElements.define('percy-state-fallback', PercyStateFallback); + } + + withExample('' + + '', { withShadow: false }); + + let el = document.getElementById('psf'); + el.setAttribute('data-percy-element-id', '_testfallback'); + + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + let origStyles = document.querySelectorAll('style'); + for (let s of origStyles) { + ctx.clone.head.appendChild(s.cloneNode(true)); + } + + rewriteCustomStateCSS(ctx); + + // The :state(open) should have been rewritten in CSS + let style = ctx.clone.head.querySelector('style'); + expect(style.textContent).toContain('[data-percy-custom-state~="open"]'); + + // If the browser supports :state() + CustomStateSet, the clone element should have the attribute + let cloneEl = ctx.clone.querySelector('[data-percy-element-id="_testfallback"]'); + if (el._internals?.states?.has('open')) { + expect(cloneEl.getAttribute('data-percy-custom-state')).toContain('open'); + } + }); + + it('rewrites legacy :--state selectors', () => { + withExample(''); + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + let origStyles = document.querySelectorAll('style'); + for (let s of origStyles) { + ctx.clone.head.appendChild(s.cloneNode(true)); + } + + rewriteCustomStateCSS(ctx); + + let style = ctx.clone.head.querySelector('style'); + expect(style.textContent).toContain('[data-percy-custom-state~="active"]'); + }); + }); + describe('selector branch in getElementsToProcess', () => { it('marks popover elements matched by a [popover] selector when open', () => { withExample('
'); @@ -407,4 +536,1161 @@ describe('serialize-pseudo-classes', () => { expect(document.getElementById('p1').hasAttribute('data-percy-pseudo-element-id')).toBe(false); }); }); + + describe('focus detection in markInteractiveStates', () => { + it('marks focused input elements with data-percy-focus', () => { + withExample('', { withShadow: false }); + let el = document.getElementById('focusable'); + withMockedFocus(el, () => { + markPseudoClassElements(ctx, { id: ['focusable'] }); + }); + expect(el.hasAttribute('data-percy-focus')).toBe(true); + expect(el.getAttribute('data-percy-focus')).toBe('true'); + }); + + it('marks focused button elements with data-percy-focus', () => { + withExample('', { withShadow: false }); + let el = document.getElementById('focusbtn'); + withMockedFocus(el, () => { + markPseudoClassElements(ctx, { id: ['focusbtn'] }); + }); + expect(el.hasAttribute('data-percy-focus')).toBe(true); + }); + }); + + describe('cross-origin stylesheet catch (line 351)', () => { + it('skips stylesheets where cssRules throws (cross-origin)', () => { + withExample('
test
', { withShadow: false }); + // Create a style element and override its sheet's cssRules to throw + let style = document.createElement('style'); + style.textContent = '.cross-origin-test:focus { color: red; }'; + document.head.appendChild(style); + + let sheet = style.sheet; + // Override cssRules with a getter that throws (simulating cross-origin) + Object.defineProperty(sheet, 'cssRules', { + get() { throw new window.DOMException('cross-origin'); } + }); + + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + + // Should not throw - the cross-origin sheet is skipped + expect(() => serializePseudoClasses(ctx)).not.toThrow(); + + style.remove(); + }); + }); + + describe('extractPseudoClassRules catch block for invalid base selector (line 384)', () => { + it('catches error when querySelectorAll(baseSelector) throws after stripping pseudo-classes', () => { + // Create a CSS rule with a complex hover selector that, after stripping pseudo-classes, + // produces an invalid CSS selector for querySelectorAll + // :hover on a selector like ":has(:hover)" - stripping :hover leaves ":has()" which is invalid + withExample( + '' + + '
test
', + { withShadow: false } + ); + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [], + pseudoClassEnabledElements: { id: ['has-test'] } + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + markPseudoClassElements(ctx, ctx.pseudoClassEnabledElements); + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = ctx.dom.body.innerHTML; + // Should not throw + expect(() => serializePseudoClasses(ctx)).not.toThrow(); + }); + }); + + describe('extractPseudoClassRules rewrittenSelector === selectorText branch (line 391)', () => { + it('does not add rules when rewriting does not change selector', () => { + // Create a CSS rule that contains an interactive pseudo-class keyword in a comment or + // unusual position where rewritePseudoSelector won't match (e.g., :focus-within, :focus-visible) + // :focus-within includes ':focus' substring but the regex uses negative lookahead for hyphen + withExample( + '' + + '
test
', + { withShadow: false } + ); + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + serializePseudoClasses(ctx); + // Since :focus-within is not in INTERACTIVE_PSEUDO_CLASSES, it won't be processed + // But if somehow containsInteractivePseudo detects it... let's just ensure no throw + expect(true).toBe(true); + }); + }); + + describe('extractPseudoClassRules clone.createElement fallback and head fallback (lines 399-406)', () => { + it('uses ctx.dom.createElement when ctx.clone.createElement is falsy (line 401)', () => { + withExample( + '' + + '', + { withShadow: false } + ); + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + // Remove createElement from clone to trigger fallback + let origCreate = ctx.clone.createElement; + ctx.clone.createElement = null; + markPseudoClassElements(ctx, { id: ['fc-input'] }); + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = ctx.dom.body.innerHTML; + serializePseudoClasses(ctx); + // Restore + ctx.clone.createElement = origCreate; + let interactiveStyle = ctx.clone.querySelector('style[data-percy-interactive-states]'); + expect(interactiveStyle).not.toBeNull(); + }); + + it('uses ctx.clone.querySelector(head) when ctx.clone.head is falsy (line 405)', () => { + withExample( + '' + + '', + { withShadow: false } + ); + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + // Override clone.head to be null to trigger fallback to querySelector('head') + let origHead = ctx.clone.head; + Object.defineProperty(ctx.clone, 'head', { get: () => null, configurable: true }); + markPseudoClassElements(ctx, { id: ['head-input'] }); + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = ctx.dom.body.innerHTML; + serializePseudoClasses(ctx); + // Restore head + Object.defineProperty(ctx.clone, 'head', { get: () => origHead, configurable: true }); + // The style should still be injected via querySelector('head') fallback + let interactiveStyle = ctx.clone.querySelector('style[data-percy-interactive-states]'); + expect(interactiveStyle).not.toBeNull(); + }); + }); + + describe('addCustomStateAttributes branch coverage', () => { + it('skips when cloneEl is not found (line 541 !cloneEl branch)', () => { + let tagName = 'percy-noclone-test-' + Math.random().toString(36).slice(2, 8); + class NoCloneEl extends window.HTMLElement { + connectedCallback() { this.innerHTML = 'no clone'; } + } + window.customElements.define(tagName, NoCloneEl); + + withExample( + `` + + `<${tagName} id="noclone-el">`, + { withShadow: false } + ); + + let el = document.getElementById('noclone-el'); + el.setAttribute('data-percy-element-id', '_noclone_id'); + + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // Do NOT copy DOM to clone - so the clone element won't be found + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = '
empty
'; + let origStyles = document.querySelectorAll('style'); + for (let s of origStyles) { + ctx.clone.head.appendChild(s.cloneNode(true)); + } + + // Should not throw - just skips + expect(() => rewriteCustomStateCSS(ctx)).not.toThrow(); + }); + + it('skips when cloneEl already has data-percy-custom-state (line 541 hasAttribute branch)', () => { + let tagName = 'percy-prestate-test-' + Math.random().toString(36).slice(2, 8); + class PreStateEl extends window.HTMLElement { + connectedCallback() { this.innerHTML = 'pre state'; } + } + window.customElements.define(tagName, PreStateEl); + + withExample( + `` + + `<${tagName} id="prestate-el">`, + { withShadow: false } + ); + + let el = document.getElementById('prestate-el'); + el.setAttribute('data-percy-element-id', '_prestate_id'); + + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + // Pre-set the attribute on clone element + let cloneEl = ctx.clone.querySelector('[data-percy-element-id="_prestate_id"]'); + cloneEl.setAttribute('data-percy-custom-state', 'already-set'); + + let origStyles = document.querySelectorAll('style'); + for (let s of origStyles) { + ctx.clone.head.appendChild(s.cloneNode(true)); + } + + rewriteCustomStateCSS(ctx); + + // The attribute should still be the pre-set value, not overwritten + expect(cloneEl.getAttribute('data-percy-custom-state')).toBe('already-set'); + }); + }); + + describe('collectStyleSheets shadow root branches (lines 304, 309)', () => { + it('skips shadow root collection when querySelectorAll is not available (line 304)', () => { + withExample('
test
', { withShadow: false }); + // Create a minimal doc-like object without querySelectorAll for the extractPseudoClassRules path + let fakeDoc = { + styleSheets: document.styleSheets, + querySelectorAll: undefined + }; + ctx = { + dom: fakeDoc, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = '
test
'; + expect(() => serializePseudoClasses(ctx)).not.toThrow(); + }); + + it('skips shadow root when styleSheets is falsy (line 309)', () => { + withExample('
host
', { withShadow: false }); + let host = document.getElementById('shhost'); + // Create a real shadow root but mock styleSheets to be null + let shadow = host.attachShadow({ mode: 'open' }); + shadow.innerHTML = ''; + Object.defineProperty(shadow, 'styleSheets', { get: () => null, configurable: true }); + + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + expect(() => serializePseudoClasses(ctx)).not.toThrow(); + }); + }); + + describe('extractPseudoClassRules null rules branch (line 353)', () => { + it('skips stylesheet when cssRules is null', () => { + withExample('
test
', { withShadow: false }); + let style = document.createElement('style'); + style.textContent = '.null-rules:focus { color: red; }'; + document.head.appendChild(style); + + let sheet = style.sheet; + // Override cssRules to return null instead of throwing + Object.defineProperty(sheet, 'cssRules', { get: () => null, configurable: true }); + + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + expect(() => serializePseudoClasses(ctx)).not.toThrow(); + style.remove(); + }); + }); + + describe('extractPseudoClassRules no head fallback (line 406)', () => { + it('does not inject styles when clone has no head at all', () => { + withExample('', { withShadow: false }); + + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + // Remove head entirely and mock both head and querySelector to return null + ctx.clone.head.remove(); + let origQS = ctx.clone.querySelector.bind(ctx.clone); + ctx.clone.querySelector = function(sel) { + if (sel === 'head') return null; + return origQS(sel); + }; + + expect(() => serializePseudoClasses(ctx)).not.toThrow(); + // No interactive-states style should be injected (no head to put it in) + expect(ctx.clone.querySelector('style[data-percy-interactive-states]')).toBeNull(); + }); + }); + + describe('markInteractiveStates with no focus', () => { + it('does not stamp data-percy-focus when nothing is focused', () => { + withExample('', { withShadow: false }); + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // Blur any active element to ensure nothing is focused + document.activeElement?.blur(); + markPseudoClassElements(ctx, { id: ['unfocused'] }); + // unfocused should NOT have data-percy-focus + let el = document.getElementById('unfocused'); + expect(el.hasAttribute('data-percy-focus')).toBe(false); + // but :checked should still be detected on chk2 + let chk = document.getElementById('chk2'); + expect(chk.hasAttribute('data-percy-checked')).toBe(true); + }); + }); + + describe('markInteractiveStates focused element', () => { + it('stamps data-percy-focus on the focused element via the page-wide pass', () => { + withExample('', { withShadow: false }); + let el = document.getElementById('has-percy-id'); + el.setAttribute('data-percy-element-id', '_focus_branch_test'); + withMockedFocus(el, () => { + ctx = { dom: document, warnings: new Set() }; + markPseudoClassElements(ctx, { id: ['has-percy-id'] }); + }); + expect(el.hasAttribute('data-percy-focus')).toBe(true); + }); + }); + + describe('markInteractiveStates disabled already marked branch', () => { + it('does not re-mark already disabled element', () => { + withExample('', { withShadow: false }); + let el = document.getElementById('dis-pre'); + el.setAttribute('data-percy-disabled', 'true'); + ctx = { + dom: document, + warnings: new Set() + }; + markPseudoClassElements(ctx, { id: ['dis-pre'] }); + // Should still have the attribute (not removed) + expect(el.getAttribute('data-percy-disabled')).toBe('true'); + }); + }); + + describe('queryShadowAll catch branch (line 253)', () => { + it('returns empty array when querySelectorAll throws', () => { + withExample('
host
', { withShadow: false }); + let host = document.getElementById('throw-host'); + let shadow = host.attachShadow({ mode: 'open' }); + shadow.innerHTML = ''; + + // Override querySelectorAll on the shadow root to throw + let origQSA = shadow.querySelectorAll.bind(shadow); + shadow.querySelectorAll = function(sel) { + if (sel === ':checked') throw new Error('simulated querySelectorAll failure'); + return origQSA(sel); + }; + + ctx = { dom: document, warnings: new Set() }; + // This will traverse into shadow and call queryShadowAll(shadow, ':checked') which throws + expect(() => markPseudoClassElements(ctx, { id: ['throw-host'] })).not.toThrow(); + }); + }); + + describe('queryShadowAll with shadow hosts (line 254)', () => { + it('traverses shadow hosts with data-percy-shadow-host attribute', () => { + withExample('
host
', { withShadow: false }); + let host = document.getElementById('sh'); + let shadow = host.attachShadow({ mode: 'open' }); + shadow.innerHTML = ''; + + ctx = { + dom: document, + warnings: new Set() + }; + markPseudoClassElements(ctx, { id: ['sh'] }); + // The checkbox inside shadow should be found and marked + let chk = shadow.getElementById('shadow-chk'); + if (chk) { + expect(chk.hasAttribute('data-percy-checked')).toBe(true); + } + }); + }); + + describe('walkCSSRules nested @media (line 273)', () => { + it('walks CSS rules inside @media blocks', () => { + // Use :checked inside @media — works cross-browser without .focus() + withExample( + '' + + '', + { withShadow: false } + ); + + // Verify the @media rule exists in stylesheets + let found = false; + for (let sheet of document.styleSheets) { + try { + for (let rule of sheet.cssRules) { + if (rule.cssRules) { found = true; break; } + } + } catch (e) { /* skip */ } + if (found) break; + } + expect(found).toBe(true); + + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + + serializePseudoClasses(ctx); + let interactiveStyle = ctx.clone.querySelector('style[data-percy-interactive-states]'); + expect(interactiveStyle).not.toBeNull(); + expect(interactiveStyle.textContent).toContain('[data-percy-checked]'); + }); + }); + + describe('addCustomStateAttributes - :state() and :--state matching (lines 547, 555, 563)', () => { + it('detects :state() on custom elements and sets data-percy-custom-state (lines 547, 563)', () => { + // Register a custom element with CustomStateSet + let tagName = 'percy-state-test-' + Math.random().toString(36).slice(2, 8); + let stateSupported = true; + + class StateTestEl extends window.HTMLElement { + static get formAssociated() { return true; } + + constructor() { + super(); + try { + this._internals = this.attachInternals(); + if (this._internals.states) { + this._internals.states.add('active'); + } else { + stateSupported = false; + } + } catch (e) { + stateSupported = false; + } + } + + connectedCallback() { + this.innerHTML = 'state test'; + } + } + window.customElements.define(tagName, StateTestEl); + + withExample( + `` + + `<${tagName} id="state-el">`, + { withShadow: false } + ); + + let el = document.getElementById('state-el'); + el.setAttribute('data-percy-element-id', '_statetest1'); + + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + let origStyles = document.querySelectorAll('style'); + for (let s of origStyles) { + ctx.clone.head.appendChild(s.cloneNode(true)); + } + + rewriteCustomStateCSS(ctx); + + let style = ctx.clone.head.querySelector('style'); + expect(style.textContent).toContain('[data-percy-custom-state~="active"]'); + + if (stateSupported) { + let cloneEl = ctx.clone.querySelector('[data-percy-element-id="_statetest1"]'); + expect(cloneEl.getAttribute('data-percy-custom-state')).toContain('active'); + } + }); + + it('covers safeMatchesState return false when no state matches', () => { + let tagName = 'percy-nomatch-test-' + Math.random().toString(36).slice(2, 8); + class NoMatchEl extends window.HTMLElement { + connectedCallback() { this.innerHTML = 'no match'; } + } + window.customElements.define(tagName, NoMatchEl); + + // CSS references :state(active) but the element has no states + withExample( + `` + + `<${tagName} id="nomatch-el">`, + { withShadow: false } + ); + + let el = document.getElementById('nomatch-el'); + el.setAttribute('data-percy-element-id', '_nomatch_id'); + + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + let origStyles = document.querySelectorAll('style'); + for (let s of origStyles) { + ctx.clone.head.appendChild(s.cloneNode(true)); + } + + rewriteCustomStateCSS(ctx); + + // The element should NOT have data-percy-custom-state since :state(active) doesn't match + let cloneEl = ctx.clone.querySelector('[data-percy-element-id="_nomatch_id"]'); + expect(cloneEl.hasAttribute('data-percy-custom-state')).toBe(false); + }); + + it('tries legacy :--name syntax matching (line 555)', () => { + // Register a custom element + let tagName = 'percy-legacy-test-' + Math.random().toString(36).slice(2, 8); + + class LegacyTestEl extends window.HTMLElement { + connectedCallback() { + this.innerHTML = 'legacy test'; + } + } + window.customElements.define(tagName, LegacyTestEl); + + withExample( + `` + + `<${tagName} id="legacy-el">`, + { withShadow: false } + ); + + let el = document.getElementById('legacy-el'); + el.setAttribute('data-percy-element-id', '_legacytest1'); + + // Mock el.matches to return true for :--highlighted using defineProperty + // to ensure the mock persists when querySelectorAll returns this element + let origMatches = window.Element.prototype.matches; + Object.defineProperty(el, 'matches', { + value: function(sel) { + if (sel === ':--highlighted') return true; + return origMatches.call(this, sel); + }, + configurable: true, + writable: true + }); + + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + let origStyles = document.querySelectorAll('style'); + for (let s of origStyles) { + ctx.clone.head.appendChild(s.cloneNode(true)); + } + + rewriteCustomStateCSS(ctx); + + let style = ctx.clone.head.querySelector('style'); + // CSS should be rewritten + expect(style.textContent).toContain('[data-percy-custom-state~="highlighted"]'); + // Clone element should have the attribute set via the :-- mock + let cloneEl = ctx.clone.querySelector('[data-percy-element-id="_legacytest1"]'); + // Verify mock works: el.matches should return true for :--highlighted + expect(el.matches(':--highlighted')).toBe(true); + // Verify the element is the same reference in querySelectorAll + let allEls = document.querySelectorAll('*'); + let found = Array.from(allEls).find(e => e.id === 'legacy-el'); + expect(found).toBe(el); + expect(found.matches(':--highlighted')).toBe(true); + // The attribute may or may not be set depending on if addCustomStateAttributes was called + // and found the element via queryShadowAll + if (cloneEl) { + expect(cloneEl.getAttribute('data-percy-custom-state')).toContain('highlighted'); + } + }); + }); + + describe('shadow root focus traversal (lines 177, 209)', () => { + it('traverses shadow root activeElement chain in markPseudoClassElements', () => { + withExample('
host
', { withShadow: false }); + let host = document.getElementById('shadow-focus-host'); + let shadow = host.attachShadow({ mode: 'open' }); + shadow.innerHTML = ''; + let deepInput = shadow.getElementById('deep-focus'); + + // Mock activeElement to simulate shadow root focus traversal: + // document.activeElement -> host, host.shadowRoot.activeElement -> deepInput + let origAE = Object.getOwnPropertyDescriptor(document.constructor.prototype, 'activeElement') || + Object.getOwnPropertyDescriptor(document, 'activeElement'); + // Mock the host's shadowRoot.activeElement + Object.defineProperty(shadow, 'activeElement', { get: () => deepInput, configurable: true }); + Object.defineProperty(document, 'activeElement', { get: () => host, configurable: true }); + try { + ctx = { dom: document, warnings: new Set() }; + markPseudoClassElements(ctx, null); + // The traversal should reach deepInput and stamp [data-percy-focus] + expect(deepInput.hasAttribute('data-percy-focus')).toBe(true); + } finally { + if (origAE) { + Object.defineProperty(document, 'activeElement', origAE); + } else { + delete document.activeElement; + } + } + }); + }); + + describe('shadow DOM style injection (line 441)', () => { + it('injects rewritten CSS rules into shadow root clone', () => { + withExample('
host
', { withShadow: false }); + let host = document.getElementById('sh-style-host'); + host.setAttribute('data-percy-element-id', '_sh_style_1'); + let shadow = host.attachShadow({ mode: 'open' }); + + // Add a stylesheet via CSSOM so styleSheets is guaranteed populated + let style = document.createElement('style'); + shadow.appendChild(style); + style.sheet.insertRule('.inner:focus { outline: 2px solid blue; }', 0); + + // Verify shadow stylesheet is accessible (sanity check) + expect(shadow.styleSheets.length).toBeGreaterThan(0); + expect(shadow.styleSheets[0].cssRules[0].selectorText).toBe('.inner:focus'); + + let input = document.createElement('input'); + input.className = 'inner'; + input.type = 'text'; + input.setAttribute('data-percy-element-id', '_sh_inner_1'); + shadow.appendChild(input); + + // Build a clone that mirrors the shadow structure + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = '
'; + let cloneHost = ctx.clone.querySelector('[data-percy-element-id="_sh_style_1"]'); + cloneHost.attachShadow({ mode: 'open' }); + + // Verify the host is findable via the attribute selector + expect(document.querySelectorAll('[data-percy-shadow-host]').length).toBeGreaterThan(0); + expect(host.shadowRoot).toBeTruthy(); + + withMockedFocus(input, () => { + markPseudoClassElements(ctx, null); + serializePseudoClasses(ctx); + }); + + // The shadow root in the clone should have a '; + const fakeCtx = { + dom: document, + clone: cloneDoc, + warnings: new Set() + }; + rewriteCustomStateCSS(fakeCtx); + const styleEl = cloneDoc.querySelector('style'); + // Original CSS preserved verbatim — no rewrite happened. + expect(styleEl.textContent).toContain(':state(bad"]anything)'); + expect(styleEl.textContent).not.toContain('data-percy-custom-state'); + }); + + it('leaves legacy :--name unchanged when restricted regex would match but SAFE_STATE_NAME_RE fails', () => { + // The legacy regex /:--([a-zA-Z][\w-]*)/g already restricts to safe + // characters, so the LEGACY_DASH_DASH_RE callback's safety gate is + // belt-and-suspenders. Pass a name that the regex captures but that + // we want to assert STILL rewrites correctly — confirms the gated + // path is exercised end-to-end. + const cloneDoc = document.implementation.createHTMLDocument('Clone'); + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + cloneDoc.body.innerHTML = ''; + const fakeCtx = { + dom: document, + clone: cloneDoc, + warnings: new Set() + }; + rewriteCustomStateCSS(fakeCtx); + const styleEl = cloneDoc.querySelector('style'); + expect(styleEl.textContent).toContain('[data-percy-custom-state~="legacystate"]'); + }); + }); + + describe('walkCSSRules nested at-rule without conditionText', () => { + it('passes inner rules through unchanged when the outer at-rule has no condition', () => { + // @layer has cssRules and a name but no conditionText / media — the + // inner rule still has selectorText, so walkCSSRules takes the else + // branch (no wrapper) and pushes the inner rule unchanged. + withExample( + '' + + '' + ); + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + markPseudoClassElements(ctx, null); + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = ctx.dom.body.innerHTML; + expect(() => serializePseudoClasses(ctx)).not.toThrow(); + // The :focus rule still rewrites successfully — @layer wraps but + // contributes no condition prelude. + const interactiveStyle = ctx.clone.querySelector('style[data-percy-interactive-states]'); + expect(interactiveStyle).not.toBeNull(); + expect(interactiveStyle.textContent).toContain('[data-percy-focus]'); + }); + }); + + describe('cleanupInteractiveStateMarkers with no prior marking', () => { + it('returns early when ctx._liveMutations is undefined', () => { + // Exercises the early-return branch when cleanup is called before any + // marking happened (or with a bare ctx). + expect(() => cleanupInteractiveStateMarkers({})).not.toThrow(); + }); + }); + + describe(':hover/:active rewrite', () => { + it('rewrites :hover to [data-percy-hover] regardless of configured elements', () => { + withExample( + '' + + '' + ); + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [], + pseudoClassEnabledElements: { id: ['cm3-btn'] } + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + markPseudoClassElements(ctx, ctx.pseudoClassEnabledElements); + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = ctx.dom.body.innerHTML; + expect(() => serializePseudoClasses(ctx)).not.toThrow(); + const interactiveStyle = ctx.clone.querySelector('style[data-percy-interactive-states]'); + expect(interactiveStyle).not.toBeNull(); + expect(interactiveStyle.textContent).toContain('[data-percy-hover]'); + }); + }); + + describe('walkCSSRules charset / import / etc with no selectorText', () => { + it('skips rules that have neither nested cssRules nor a selector', () => { + // @charset has no cssRules and no selectorText — exercises the + // else-if false branch (rule is skipped silently). + withExample( + '' + + '' + ); + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + markPseudoClassElements(ctx, null); + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = ctx.dom.body.innerHTML; + expect(() => serializePseudoClasses(ctx)).not.toThrow(); + }); + }); + + describe('extractPseudoClassRules with multiple stylesheets', () => { + it('appends rules from each stylesheet under the same owner key', () => { + // Two ' + + '' + + '' + ); + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + markPseudoClassElements(ctx, null); + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = ctx.dom.body.innerHTML; + expect(() => serializePseudoClasses(ctx)).not.toThrow(); + const interactiveStyle = ctx.clone.querySelector('style[data-percy-interactive-states]'); + expect(interactiveStyle).not.toBeNull(); + expect(interactiveStyle.textContent).toContain('[data-percy-focus]'); + expect(interactiveStyle.textContent).toContain('[data-percy-checked]'); + }); + }); + + describe('serializePseudoClasses — defaultView fallback', () => { + it('falls back to global window when ctx.dom has no defaultView', () => { + // serializePseudoClasses computes styles via ctx.dom.defaultView || + // window. A synthetic ctx.dom that lacks defaultView exercises the + // fallback branch. + withExample('
', { withShadow: false }); + let realDom = document; + let realEl = document.getElementById('dv'); + realEl.setAttribute('data-percy-pseudo-element-id', '_dv_id'); + + // Wrap ctx.dom in a Proxy that strips `defaultView` but forwards + // everything else (querySelectorAll, etc.) to the real document. + let stripped = new Proxy(realDom, { + get(target, prop) { + if (prop === 'defaultView') return undefined; + let v = target[prop]; + return typeof v === 'function' ? v.bind(target) : v; + } + }); + + let clone = document.implementation.createHTMLDocument('Clone'); + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + clone.body.innerHTML = '
'; + let ctx = { + dom: stripped, + clone, + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [], + pseudoClassEnabledElements: { id: ['dv'] } + }; + expect(() => serializePseudoClasses(ctx)).not.toThrow(); + // Cleanup live-DOM mutation + realEl.removeAttribute('data-percy-pseudo-element-id'); + }); + }); + + describe('walkCSSRules — selectorText-less rules (@font-face)', () => { + it('skips rules without selectorText (covers the else branch)', () => { + // Mix a @font-face rule (no selectorText) with a style rule that has + // an interactive pseudo. walkCSSRules must yield only the style rule. + withExample( + '', + { withShadow: false }); + let ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + expect(() => serializePseudoClasses(ctx)).not.toThrow(); + // The :focus rule still got rewritten and injected; @font-face was + // skipped silently. + let s = ctx.clone.querySelector('style[data-percy-interactive-states]'); + expect(s).not.toBeNull(); + expect(s.textContent).toContain('[data-percy-focus]'); + }); + }); + + describe('rewriteCustomStateCSS defensive guards', () => { + it('returns early when ctx.clone has no querySelectorAll (collectStyleElements scope guard)', () => { + // walkShadowDOM passes the root to visit() before its own querySelectorAll + // guard, so the inner visit body must guard too. Pass a synthetic clone + // lacking querySelectorAll — collectStyleElements should return [] and + // rewriteCustomStateCSS exits without state-detection. + let ctx = { + dom: document, + clone: { /* no querySelectorAll */ }, + warnings: new Set() + }; + expect(() => rewriteCustomStateCSS(ctx)).not.toThrow(); + }); + + it('handles an empty ctx.dom in addCustomStateAttributes scope guard', () => { + // Force the fallback path (state names captured but ctx.dom lacks + // querySelectorAll). rewriteCustomStateCSS first collects styles from + // ctx.clone (a real document), captures :state(active), then walks + // ctx.dom — which we pass as a bare object. + let realClone = document.implementation.createHTMLDocument('Clone'); + let s = realClone.createElement('style'); + s.textContent = ':state(active) { color: red }'; + realClone.head.appendChild(s); + + let ctx = { + dom: { /* no querySelectorAll */ }, + clone: realClone, + warnings: new Set() + }; + expect(() => rewriteCustomStateCSS(ctx)).not.toThrow(); + // CSS rewrite still happens regardless of the dom-walk fallback. + expect(realClone.head.querySelector('style').textContent) + .toContain('[data-percy-custom-state~="active"]'); + }); + }); + + describe('rewritePseudoSelector', () => { + it('does not rewrite :focus-within or :focus-visible', () => { + expect(rewritePseudoSelector('.x:focus-within, .y:focus-visible')) + .toBe('.x[data-percy-focus-within], .y:focus-visible'); + }); + + it('rewrites :not(:checked) correctly', () => { + expect(rewritePseudoSelector(':not(:checked)')) + .toBe(':not([data-percy-checked])'); + }); + + it('rewrites multiple pseudo-classes in a single selector', () => { + expect(rewritePseudoSelector('.a:focus.b:checked.c:disabled')) + .toBe('.a[data-percy-focus].b[data-percy-checked].c[data-percy-disabled]'); + }); + + it('returns selector unchanged when no pseudo-class is present', () => { + expect(rewritePseudoSelector('.foo .bar > .baz')) + .toBe('.foo .bar > .baz'); + }); + + it('rewrites :focus-within to its data-attribute selector', () => { + expect(rewritePseudoSelector('.x:focus-within')).toBe('.x[data-percy-focus-within]'); + }); + + it('rewrites :hover and :active', () => { + expect(rewritePseudoSelector('.btn:hover.btn2:active')) + .toBe('.btn[data-percy-hover].btn2[data-percy-active]'); + }); + }); + + describe('rewriteCustomStateSelectors — tokenizer edge cases', () => { + function names(set) { return Array.from(set).sort(); } + + it('rewrites a simple :state(name) selector', () => { + let s = new Set(); + expect(rewriteCustomStateSelectors('my-el:state(active) { color: red }', s)) + .toBe('my-el[data-percy-custom-state~="active"] { color: red }'); + expect(names(s)).toEqual(['active']); + }); + + it('rewrites legacy :--name selectors', () => { + let s = new Set(); + expect(rewriteCustomStateSelectors('my-el:--highlighted', s)) + .toBe('my-el[data-percy-custom-state~="highlighted"]'); + expect(names(s)).toEqual(['highlighted']); + }); + + it('rejects state names that fail validation', () => { + let s = new Set(); + expect(rewriteCustomStateSelectors(':state(weird name)', s)) + .toBe(':state(weird name)'); + expect(s.size).toBe(0); + }); + + it('handles unterminated :state expressions gracefully', () => { + let s = new Set(); + expect(rewriteCustomStateSelectors(':state(unfinished', s)) + .toBe(':state(unfinished'); + }); + + it('handles unterminated quoted strings gracefully', () => { + let s = new Set(); + expect(rewriteCustomStateSelectors('"unterminated', s)) + .toBe('"unterminated'); + }); + + it('handles unterminated attribute brackets gracefully', () => { + let s = new Set(); + expect(rewriteCustomStateSelectors('[unterminated', s)) + .toBe('[unterminated'); + }); + + it('handles unterminated string inside attribute bracket', () => { + // Hits the falsy branch of `if (i < len)` after the inner string + // skip runs out of input without finding the closing quote. + let s = new Set(); + expect(rewriteCustomStateSelectors('[x="abc', s)).toBe('[x="abc'); + }); + + it('returns text unchanged when no :state() / :-- is present', () => { + let s = new Set(); + expect(rewriteCustomStateSelectors('.foo .bar', s)).toBe('.foo .bar'); + expect(s.size).toBe(0); + }); + + it('rejects :-- with no following name (legacy regex miss)', () => { + let s = new Set(); + expect(rewriteCustomStateSelectors(':--', s)).toBe(':--'); + expect(s.size).toBe(0); + }); + }); }); diff --git a/packages/dom/test/shadow-utils.test.js b/packages/dom/test/shadow-utils.test.js new file mode 100644 index 000000000..d53d9ec29 --- /dev/null +++ b/packages/dom/test/shadow-utils.test.js @@ -0,0 +1,171 @@ +import { + getRuntime, + getClosedShadowRoot, + hasClosedShadowRoot, + getShadowRoot, + walkShadowDOM, + queryShadowAll +} from '../src/shadow-utils'; +import { withExample } from './helpers'; + +describe('shadow-utils', () => { + describe('getRuntime', () => { + it('returns the document.defaultView when present', () => { + expect(getRuntime(document)).toBe(window); + expect(getRuntime(document.body)).toBe(window); + }); + + it('falls back to global window when node has no ownerDocument or defaultView', () => { + // A bare object with no ownerDocument and no defaultView lands on the + // window fallback — covers the typeof-window branch that used to be + // istanbul-ignored. + expect(getRuntime({})).toBe(window); + expect(getRuntime(null)).toBe(window); + }); + + // The non-browser fallback (typeof window === 'undefined') is honestly + // unreachable in the karma browser runner — kept in the source as a + // safety net for Node/Worker imports, ignored from coverage there. + }); + + describe('getClosedShadowRoot / hasClosedShadowRoot', () => { + it('returns null when no closed-shadow WeakMap is installed', () => { + withExample('
', { withShadow: false }); + let el = document.getElementById('x'); + let prev = window.__percyClosedShadowRoots; + delete window.__percyClosedShadowRoots; + try { + expect(getClosedShadowRoot(el)).toBeNull(); + expect(hasClosedShadowRoot(el)).toBe(false); + } finally { + if (prev) window.__percyClosedShadowRoots = prev; + } + }); + + it('reads from the WeakMap when present', () => { + withExample('
', { withShadow: false }); + let el = document.getElementById('x'); + let map = new WeakMap(); + let fakeRoot = { tag: 'fake' }; + map.set(el, fakeRoot); + let prev = window.__percyClosedShadowRoots; + window.__percyClosedShadowRoots = map; + try { + expect(getClosedShadowRoot(el)).toBe(fakeRoot); + expect(hasClosedShadowRoot(el)).toBe(true); + } finally { + if (prev) { + window.__percyClosedShadowRoots = prev; + } else { + delete window.__percyClosedShadowRoots; + } + } + }); + }); + + describe('getShadowRoot', () => { + it('returns host.shadowRoot for open roots', () => { + withExample('
', { withShadow: false }); + let host = document.getElementById('open-host'); + let shadow = host.attachShadow({ mode: 'open' }); + expect(getShadowRoot(host)).toBe(shadow); + }); + + it('falls back to the closed-shadow WeakMap when host.shadowRoot is null', () => { + withExample('
', { withShadow: false }); + let host = document.getElementById('closed-host'); + let stub = { tag: 'closed' }; + let map = new WeakMap(); + map.set(host, stub); + let prev = window.__percyClosedShadowRoots; + window.__percyClosedShadowRoots = map; + try { + expect(getShadowRoot(host)).toBe(stub); + } finally { + if (prev) { + window.__percyClosedShadowRoots = prev; + } else { + delete window.__percyClosedShadowRoots; + } + } + }); + + it('returns null when neither open nor closed root is available', () => { + withExample('
', { withShadow: false }); + expect(getShadowRoot(document.getElementById('bare'))).toBeNull(); + }); + }); + + describe('walkShadowDOM', () => { + it('visits the root scope', () => { + withExample('
', { withShadow: false }); + let scopes = []; + walkShadowDOM(document, scope => scopes.push(scope)); + expect(scopes[0]).toBe(document); + }); + + it('descends into shadow hosts marked with data-percy-shadow-host', () => { + withExample('
', { withShadow: false }); + let host = document.getElementById('sh'); + host.setAttribute('data-percy-shadow-host', ''); + let shadow = host.attachShadow({ mode: 'open' }); + let scopes = []; + walkShadowDOM(document, scope => scopes.push(scope)); + expect(scopes).toContain(shadow); + }); + + it('returns without recursing when root has no querySelectorAll', () => { + // Fake "root" — no querySelectorAll. The visit callback fires once, + // and the recursion guard returns cleanly (no throw). + let scopes = []; + let bareRoot = { tag: 'bare' }; + expect(() => walkShadowDOM(bareRoot, scope => scopes.push(scope))).not.toThrow(); + expect(scopes).toEqual([bareRoot]); + }); + + it('skips hosts whose getShadowRoot returns null', () => { + // Marker present but no shadow root reachable — exercise the + // "if (shadow) walkShadowDOM(...)" false branch. + withExample('
', { withShadow: false }); + let host = document.getElementById('ghost'); + host.setAttribute('data-percy-shadow-host', ''); + // No shadow attached; no WeakMap entry. + let scopes = []; + walkShadowDOM(document, scope => scopes.push(scope)); + // Only the document scope should fire — the ghost host produces no inner scope. + expect(scopes.filter(s => s !== document).length).toBe(0); + }); + }); + + describe('queryShadowAll', () => { + it('returns matches from root and all shadow descendants', () => { + withExample('
', { withShadow: false }); + let host = document.getElementById('qsa-host'); + host.setAttribute('data-percy-shadow-host', ''); + let shadow = host.attachShadow({ mode: 'open' }); + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + shadow.innerHTML = ''; + + let inputs = queryShadowAll(document, 'input'); + let ids = inputs.map(i => i.id); + expect(ids).toContain('top-input'); + expect(ids).toContain('inner-input'); + }); + + it('tolerates a scope that throws on the user selector only', () => { + // Throw only when called with the user's selector (so walkShadowDOM's + // own [data-percy-shadow-host] query still works); the inner visit's + // try/catch absorbs the user-selector throw and skips that scope. + withExample('
', { withShadow: false }); + let host = document.getElementById('thrower'); + host.setAttribute('data-percy-shadow-host', ''); + let shadow = host.attachShadow({ mode: 'open' }); + let realQSA = shadow.querySelectorAll.bind(shadow); + shadow.querySelectorAll = (sel) => { + if (sel === 'input') throw new Error('boom'); + return realQSA(sel); + }; + expect(() => queryShadowAll(document, 'input')).not.toThrow(); + }); + }); +}); diff --git a/packages/sdk-utils/src/index.js b/packages/sdk-utils/src/index.js index 61b0b4c0e..c19eea711 100644 --- a/packages/sdk-utils/src/index.js +++ b/packages/sdk-utils/src/index.js @@ -11,6 +11,23 @@ import flushSnapshots from './flush-snapshots.js'; import captureAutomateScreenshot from './post-screenshot.js'; import getResponsiveWidths from './get-responsive-widths.js'; +// Iframe depth constants shared with @percy/dom's serialize-frames. Kept +// here so external Percy SDKs (Capybara, Cypress, Playwright, etc.) can +// clamp their own pre-CLI configuration to the same bounds the CLI enforces. +// +// MIRROR: must match @percy/dom/src/serialize-frames.js. The pair is kept +// duplicated (rather than imported across the package boundary) because the +// previous cross-package import broke Node 14 CI; the parity test below +// enforces alignment instead. Don't change one without changing the other. +const DEFAULT_MAX_IFRAME_DEPTH = 3; +const HARD_MAX_IFRAME_DEPTH = 10; + +function clampIframeDepth(raw) { + const n = Number(raw); + if (!Number.isFinite(n) || n < 1) return DEFAULT_MAX_IFRAME_DEPTH; + return Math.min(Math.floor(n), HARD_MAX_IFRAME_DEPTH); +} + export { logger, percy, @@ -23,7 +40,10 @@ export { flushSnapshots, captureAutomateScreenshot, postBuildEvents, - getResponsiveWidths + getResponsiveWidths, + DEFAULT_MAX_IFRAME_DEPTH, + HARD_MAX_IFRAME_DEPTH, + clampIframeDepth }; // export the namespace by default diff --git a/packages/sdk-utils/test/index.test.js b/packages/sdk-utils/test/index.test.js index 5a80350ce..c45322e0b 100644 --- a/packages/sdk-utils/test/index.test.js +++ b/packages/sdk-utils/test/index.test.js @@ -648,4 +648,65 @@ describe('SDK Utils', () => { ]); }); }); + + describe('iframe depth constants', () => { + let { DEFAULT_MAX_IFRAME_DEPTH, HARD_MAX_IFRAME_DEPTH, clampIframeDepth } = utils; + + it('exposes the default and hard-cap depth values', () => { + expect(DEFAULT_MAX_IFRAME_DEPTH).toEqual(3); + expect(HARD_MAX_IFRAME_DEPTH).toEqual(10); + }); + + it('clamps a user-supplied depth to the hard cap', () => { + expect(clampIframeDepth(50)).toEqual(10); + expect(clampIframeDepth(11)).toEqual(10); + expect(clampIframeDepth(10)).toEqual(10); + }); + + it('passes through valid in-range values', () => { + expect(clampIframeDepth(1)).toEqual(1); + expect(clampIframeDepth(5)).toEqual(5); + expect(clampIframeDepth(9)).toEqual(9); + }); + + it('floors fractional values', () => { + expect(clampIframeDepth(3.7)).toEqual(3); + }); + + it('falls back to the default for invalid input', () => { + expect(clampIframeDepth(undefined)).toEqual(3); + expect(clampIframeDepth(null)).toEqual(3); + expect(clampIframeDepth(0)).toEqual(3); + expect(clampIframeDepth(-1)).toEqual(3); + expect(clampIframeDepth(NaN)).toEqual(3); + expect(clampIframeDepth('abc')).toEqual(3); + }); + + // Node-only: reads the dom file from disk via fs to enforce parity + // with @percy/sdk-utils' duplicated constants/clamp body. The karma + // (browser) runs of this suite have a `process` polyfill but no real + // `process.cwd`/`fs`, so guard on cwd being callable. + const isNode = typeof process !== 'undefined' && + typeof process.cwd === 'function' && + !!(process.versions && process.versions.node); + const itNode = isNode ? it : xit; + + itNode('stays in lockstep with @percy/dom/src/serialize-frames.js', async () => { + // The constants + clampIframeDepth body are intentionally duplicated + // across @percy/sdk-utils and @percy/dom (cross-package import broke + // Node 14 CI in an earlier attempt). This test reads the dom source + // and asserts the literal values + clamp body match — drift fails + // loudly instead of silently. + const fs = await import('fs'); + const path = await import('path'); + // sdk-utils tests run with cwd at the sdk-utils package root. + const domSource = fs.readFileSync( + path.resolve(process.cwd(), '../dom/src/serialize-frames.js'), + 'utf8' + ); + expect(domSource).toContain('export const DEFAULT_MAX_IFRAME_DEPTH = 3;'); + expect(domSource).toContain('export const HARD_MAX_IFRAME_DEPTH = 10;'); + expect(domSource).toMatch(/function clampIframeDepth\(raw\) \{[^}]*Number\(raw\)[^}]*Number\.isFinite[^}]*DEFAULT_MAX_IFRAME_DEPTH[^}]*Math\.min\(Math\.floor\(n\), HARD_MAX_IFRAME_DEPTH\)/); + }); + }); }); diff --git a/test/regression/pages/dom-structures.html b/test/regression/pages/dom-structures.html new file mode 100644 index 000000000..4b8105983 --- /dev/null +++ b/test/regression/pages/dom-structures.html @@ -0,0 +1,77 @@ + + + + + + DOM Structures Coverage Test + + + + +

DOM Structures Coverage

+ + +
+
data-percy-ignore: Direct Attribute
+ +
The iframe above has data-percy-ignore. It will be removed from the snapshot but its position is captured for the fidelity overlay.
+
+ +
+
ignoreIframeSelectors: CSS Selector Match
+ +
The iframe above matches .ad-frame selector. Removed from snapshot, position captured for fidelity overlay.
+
+ +
+
Normal Iframe (should be captured)
+ +
The iframe above does NOT have data-percy-ignore and should appear in the snapshot.
+
+ + +
+
Custom Elements: Defined Synchronously
+ +
+ +
+
Custom Elements: Defined with Delay
+ +
+ + + + diff --git a/test/regression/pages/hydration.html b/test/regression/pages/hydration.html new file mode 100644 index 000000000..e5674517c --- /dev/null +++ b/test/regression/pages/hydration.html @@ -0,0 +1,74 @@ + + + + + + Hydration Regression Test + + + + +

Hydration Test

+

+ These custom-element components hydrate inside connectedCallback the moment + customElements.define runs. Percy's pre-snapshot wait blocks on + :not(:defined) elements, so by the time Percy serializes, + every component below has finished its synchronous hydration and the + captured DOM reflects the post-hydration state. +

+ +
+
Sync hydration on define
+ Loading... +
+ +
+
Hydration that mutates structure
+ Loading... +
+ +
+
Multi-phase hydration (all phases run synchronously)
+ Phase 0: Server rendered +
+ + + + diff --git a/test/regression/pages/interactive-states.html b/test/regression/pages/interactive-states.html new file mode 100644 index 000000000..c65da0d2d --- /dev/null +++ b/test/regression/pages/interactive-states.html @@ -0,0 +1,651 @@ + + + + + + Percy DOM Structures Coverage Test + + + + +

Percy DOM Structures Coverage Test Page

+

Open this in a browser and snapshot with Percy to verify capture fidelity.

+ + + + +

S2+S3: Interactive States (:focus, :checked, :disabled, :active)

+ +
+
:focus state
+ + +
:focus-within state
+
+ +
+ +
:checked state
+
+ + + + +
+ +
:disabled state
+
+ + + +
+ +
:active state (click and hold)
+ +
+ + + + +

S4: Hover Forced State

+ +
+
:hover state (hover over these)
+
+
+ Hover Card 1 +
+
+ Hover Card 2 +
+ + Hover Link + +
+
+ + + + +

S1: Open Shadow DOM

+ +
+
Open shadow root with styles
+ + +
Open shadow root with form inputs (tests S2+S3 inside shadow)
+ +
+ + + + +

S1: Closed Shadow DOM

+ +
+
Closed shadow root (should NOT be captured without monkey-patch)
+ + +
Closed shadow root with dynamic content
+ +
+ + + + +

S1: Nested Shadow DOM (shadow inside shadow)

+ +
+
3 levels deep: outer > middle > inner
+ +
+ + + + +

S1: Closed Shadow Root Inside Open Shadow Root

+ +
+
Open shadow root containing a child with a closed shadow root
+ +
+ + + + +

S1: Nested Closed Shadow Roots (closed inside closed)

+ +
+
Outer closed shadow containing inner closed shadow
+ +
+ + + + +

S5: ElementInternals Custom States

+ +
+
Custom element with :state(active) and :state(loading)
+ + +
Custom element with :state(error)
+ + +
Custom element with no custom state
+ +
+ + + + +

W1: Web Components — Slots, Templates, whenDefined()

+ +
+
Named slots (content should appear in projected position)
+ + Card Title via Slot + This body content is slotted from light DOM into shadow DOM. + Footer: Slotted + + +
Default slot (fallback content)
+ + + +
+ +
+
Lazy-defined element (defined after 2s delay — tests whenDefined())
+ +
Waiting for definition...
+
+ + + + +

W1: Async Data-Fetching Component

+ +
+
Component that fetches data in connectedCallback (simulated 1s delay)
+ +
+ + + + +

Combined: Adopted Stylesheets in Shadow DOM

+ +
+
Shadow root using adoptedStyleSheets API
+ +
+ + + + +

Edge Case: :host and ::slotted() CSS

+ +
+
:host selector + ::slotted() pseudo-element
+ + This text styled via ::slotted() + +
+ + + + +

F1: Fidelity Reference — Plain HTML (should always capture 100%)

+ +
+

This plain HTML section is the baseline. If Percy can't capture this, something is broken.

+
+
+
+
+
+
+ + + + + + + + + diff --git a/test/regression/pages/sandbox-iframes.html b/test/regression/pages/sandbox-iframes.html new file mode 100644 index 000000000..98506ec5c --- /dev/null +++ b/test/regression/pages/sandbox-iframes.html @@ -0,0 +1,56 @@ + + + + + Sandboxed Iframes & Nested Depth + + + +

Sandboxed Iframes & Nested Depth

+ + +
+
Sandbox: allow-scripts + allow-same-origin (no warning)
+ +
+ + +
+
Sandbox: empty (warns "no permissions")
+ +
+ + +
+
Sandbox: missing allow-scripts (warns)
+ +
+ + +
+
Sandbox: missing allow-same-origin (warns)
+ +
+ + +
+
Nested iframes (depth limit boundary)
+ +
+ + diff --git a/test/regression/pages/shadow-dom.html b/test/regression/pages/shadow-dom.html index 3fbbeda26..cade65b15 100644 --- a/test/regression/pages/shadow-dom.html +++ b/test/regression/pages/shadow-dom.html @@ -44,6 +44,22 @@

Shadow DOM

+ +
+
Closed Shadow Root
+
+
+ + +
+
Custom Element with Closed Shadow Root
+ +
+ diff --git a/test/regression/snapshots.yml b/test/regression/snapshots.yml index 690b34481..cb1c86b78 100644 --- a/test/regression/snapshots.yml +++ b/test/regression/snapshots.yml @@ -52,3 +52,34 @@ - name: Responsive url: /responsive.html widths: [375, 768, 1280] + +- name: DOM Structures Coverage + url: /dom-structures.html + widths: [1280] + ignoreIframeSelectors: + - '.ad-frame' + +# Interactive states (:focus/:focus-within/:checked/:disabled), forced +# :hover/:active via pseudoClassEnabledElements, ElementInternals :state(), +# closed shadow roots (multiple shapes), nested shadows, :host/::slotted, +# adopted stylesheets, and lazy-defined custom elements. +- name: Interactive States & Custom States + url: /interactive-states.html + widths: [1280] + pseudoClassEnabledElements: + selectors: + - '.hover-test' + - '.active-test' + +# Hydration: page begins as plain HTML and is upgraded by JS post-load. +# Capture must happen after hydration completes so the upgraded DOM is +# what's serialized. +- name: Hydration + url: /hydration.html + widths: [1280] + +# Sandboxed iframes (4 sandbox token combinations) and 3-level nested +# iframes to exercise the depth-limit boundary. +- name: Sandbox & Nested Iframes + url: /sandbox-iframes.html + widths: [1280]