diff --git a/.nycrc b/.nycrc
index 958167a..a2bb348 100644
--- a/.nycrc
+++ b/.nycrc
@@ -6,6 +6,7 @@
"exclude": [
"cypress/e2e/**",
"cypress/fixtures/**",
- "cypress/support/**"
+ "cypress/support/**",
+ "cypress/plugins/**"
]
}
diff --git a/cypress/e2e/index.cy.js b/cypress/e2e/index.cy.js
index 4c84f96..852ede5 100644
--- a/cypress/e2e/index.cy.js
+++ b/cypress/e2e/index.cy.js
@@ -765,24 +765,67 @@ describe('percySnapshot', () => {
});
it('falls back to normal capture when deferUploads is enabled', () => {
- const utils = require('@percy/sdk-utils');
- const originalConfig = utils.percy?.config;
+ // We can't reach the deferUploads warn branch through cy.percySnapshot
+ // alone: webpack bundles the spec separately from the SDK, so the
+ // test's `@percy/sdk-utils` is a different module instance than the
+ // SDK uses, and direct mutation of utils.percy.config doesn't carry
+ // over (the SDK's healthcheck would overwrite it anyway). Call the
+ // shim's `utils.percy` (same instance the SDK reads) by going
+ // through index.js's exports — index.js is loaded by support/e2e.js
+ // and its `require('@percy/sdk-utils')` resolves to the SDK-side
+ // instance. We bridge by calling isResponsiveDOMCaptureValid
+ // directly and asserting the warn fires + return value is false.
+ const indexExports = require('../../');
+ const shim = require('@percy/sdk-utils');
+
+ const originalConfig = shim.percy.config;
cy.then(() => {
- utils.percy = utils.percy || {};
- utils.percy.config = { percy: { deferUploads: true }, snapshot: {} };
+ // Reach the shim instance that index.js *actually* uses by going
+ // through index.js's exports — webpack treats each separately
+ // required path as a unique module, so `require('@percy/sdk-utils')`
+ // from this file does NOT alias the instance that index.js
+ // captured at module-load time. The index.js module IS the same
+ // instance the SDK uses (since support/e2e.js imports it), so we
+ // route through it.
+ const indexShim = indexExports.__getShimForTesting();
+ indexShim.percy.config = { percy: { deferUploads: true }, snapshot: {} };
+
+ // Capture warn messages from the SDK's own logger instance, since
+ // that's what isResponsiveDOMCaptureValid uses (helpers.logger
+ // mocks the spec-bundle's logger, which is a different instance).
+ const warnMessages = [];
+ const origLog = indexShim.logger.log;
+ indexShim.logger.log = (ns, lvl, msg) => {
+ if (lvl === 'warn') warnMessages.push(`[percy] ${msg}`);
+ };
+ try {
+ const result = indexExports.isResponsiveDOMCaptureValid({
+ responsiveSnapshotCapture: true
+ });
+ expect(result).to.equal(false);
+ } finally {
+ indexShim.logger.log = origLog;
+ }
+ expect(warnMessages.join('\n'))
+ .to.include('Responsive capture disabled: deferUploads is enabled');
});
- cy.percySnapshot('DeferUploads Test', { responsiveSnapshotCapture: true });
-
+ // Restore percy.config so subsequent tests aren't affected.
cy.then(() => {
- if (originalConfig) {
- utils.percy.config = originalConfig;
+ if (originalConfig === undefined) {
+ delete shim.percy.config;
} else {
- delete utils.percy.config;
+ shim.percy.config = originalConfig;
}
});
+ // Also exercise the cy.percySnapshot fall-through path (responsive
+ // disabled → captures non-responsive). This is the same code path
+ // existing tests already cover, but anchoring it here demonstrates
+ // the user-visible behaviour: deferUploads turns responsive off and
+ // we still post the snapshot.
+ cy.percySnapshot('DeferUploads Test');
cy.then(() => helpers.get('logs'))
.should('include', 'Snapshot found: DeferUploads Test');
});
@@ -836,12 +879,20 @@ describe('percySnapshot', () => {
});
it('skips DOM serialization when percyDOMScript is unavailable', () => {
- const utils = require('@percy/sdk-utils');
- const originalFetch = utils.fetchPercyDOM;
+ // Make /percy/dom.js return an error from the testing server AND
+ // clear the SDK-side cached domScript so fetchPercyDOM actually
+ // re-hits the endpoint. Webpack bundles the spec separately from
+ // the SDK, so deleting utils.percy.domScript on the spec-side
+ // sdk-utils doesn't clear the SDK's cached value — we reach the
+ // SDK's percy info via index.js's __getShimForTesting hook. The
+ // testing-mode server, on the other hand, is a single process both
+ // sides talk to via HTTP, so /test/api/error reaches the SDK.
+ const indexExports = require('../../');
+ const indexShim = indexExports.__getShimForTesting();
- // Make fetchPercyDOM return null to exercise the _percyDOMScript guard
- cy.then(() => {
- utils.fetchPercyDOM = async () => null;
+ cy.then(async () => {
+ delete indexShim.percy.domScript;
+ await helpers.test('error', '/percy/dom.js');
});
cy.percySnapshot('Responsive No DOM Script', {
@@ -852,10 +903,7 @@ describe('percySnapshot', () => {
cy.then(() => helpers.get('logs'))
.should('not.include', 'Snapshot found: Responsive No DOM Script');
- // Restore
- cy.then(() => {
- utils.fetchPercyDOM = originalFetch;
- });
+ cy.then(() => helpers.test('reset'));
});
it('uses getResponsiveWidths when available for width/height pairs', () => {
@@ -886,6 +934,56 @@ describe('percySnapshot', () => {
.should('include', 'Snapshot found: Responsive With CLI Heights');
});
+ it('logs and falls back when getResponsiveWidths throws', () => {
+ // Cover the catch in Step 1 of percySnapshot: getResponsiveWidths can
+ // reject if the CLI is older than 1.31.10. We reach the same
+ // instance index.js captured (webpack spec/SDK isolation means
+ // require('@percy/sdk-utils') from this file is a separate instance),
+ // and we intercept the SDK-side logger to capture the debug log —
+ // helpers.logger mocks a different logger module instance.
+ const indexExports = require('../../');
+ const indexShim = indexExports.__getShimForTesting();
+ const originalGRW = indexShim.getResponsiveWidths;
+ const debugMessages = [];
+ let originalLog;
+
+ cy.then(() => {
+ indexShim.getResponsiveWidths = async () => {
+ throw new Error('getResponsiveWidths failed for test');
+ };
+ originalLog = indexShim.logger.log;
+ // Force debug level so log.debug actually pushes through, then
+ // capture every debug into a local array we can assert against.
+ const origLevel = indexShim.logger.loglevel();
+ indexShim.logger.loglevel('debug');
+ indexShim.logger.log = (ns, lvl, msg) => {
+ if (lvl === 'debug') debugMessages.push(msg);
+ };
+ // Stash level on the function so restoration uses the right value.
+ indexShim.logger.log.__origLevel = origLevel;
+ });
+
+ cy.percySnapshot('Responsive Throws Test', {
+ responsiveSnapshotCapture: true,
+ widths: [1024]
+ });
+
+ cy.then(() => {
+ expect(debugMessages.join('\n'))
+ .to.include('getResponsiveWidths not available');
+ });
+
+ cy.then(() => {
+ indexShim.getResponsiveWidths = originalGRW;
+ const origLevel = indexShim.logger.log.__origLevel;
+ indexShim.logger.log = originalLog;
+ indexShim.logger.loglevel(origLevel || 'info');
+ });
+
+ cy.then(() => helpers.get('logs'))
+ .should('include', 'Snapshot found: Responsive Throws Test');
+ });
+
it('uses fallback widths when getResponsiveWidths is not available', () => {
const utils = require('@percy/sdk-utils');
const originalGetResponsiveWidths = utils.getResponsiveWidths;
@@ -910,11 +1008,21 @@ describe('percySnapshot', () => {
});
it('uses viewport width as fallback when no widths specified and getResponsiveWidths unavailable', () => {
- const utils = require('@percy/sdk-utils');
- const originalGetResponsiveWidths = utils.getResponsiveWidths;
+ // Reach the same shim instance index.js captured at module-load —
+ // webpack treats the spec bundle's sdk-utils as a separate instance,
+ // so deleting utils.getResponsiveWidths there doesn't reach the SDK.
+ const indexExports = require('../../');
+ const indexShim = indexExports.__getShimForTesting();
+ const original = indexShim.getResponsiveWidths;
cy.then(() => {
- delete utils.getResponsiveWidths;
+ // Throw so percySnapshot's catch swallows it and _widthHeights stays
+ // undefined; that's the only way the `_widthHeights || (...).map`
+ // OR branch trips the `[originalWidth]` short-circuit when widths
+ // is also unset.
+ indexShim.getResponsiveWidths = async () => {
+ throw new Error('CLI too old for this test');
+ };
});
// No widths specified -- should fall back to [originalWidth]
@@ -923,7 +1031,7 @@ describe('percySnapshot', () => {
});
cy.then(() => {
- utils.getResponsiveWidths = originalGetResponsiveWidths;
+ indexShim.getResponsiveWidths = original;
});
cy.then(() => helpers.get('logs'))
@@ -1229,6 +1337,86 @@ describe('percySnapshot', () => {
.should('include', 'Snapshot found: Mixed Iframes Test');
});
+ it('skips iframes marked with data-percy-ignore', () => {
+ cy.document().then(doc => {
+ const iframe = doc.createElement('iframe');
+ iframe.setAttribute('src', 'https://other.com/frame');
+ iframe.setAttribute('data-percy-element-id', 'ignored-iframe');
+ iframe.setAttribute('data-percy-ignore', '');
+ doc.body.appendChild(iframe);
+ });
+
+ cy.percySnapshot('Iframe Data Percy Ignore');
+
+ cy.then(() => helpers.get('logs'))
+ .should('include', 'Snapshot found: Iframe Data Percy Ignore');
+ });
+
+ it('skips iframes matching ignoreIframeSelectors', () => {
+ cy.document().then(doc => {
+ const iframe = doc.createElement('iframe');
+ iframe.setAttribute('src', 'https://ad-network.com/frame');
+ iframe.setAttribute('data-percy-element-id', 'ad-iframe');
+ iframe.className = 'ad-frame';
+ doc.body.appendChild(iframe);
+ });
+
+ cy.percySnapshot('Iframe Selector Ignore', { ignoreIframeSelectors: ['.ad-frame'] });
+
+ cy.then(() => helpers.get('logs'))
+ .should('include', 'Snapshot found: Iframe Selector Ignore');
+ });
+
+ it('tolerates non-array ignoreIframeSelectors (normalizeIgnoreSelectors falsy branch)', () => {
+ cy.document().then(doc => {
+ const iframe = doc.createElement('iframe');
+ iframe.setAttribute('src', 'https://other.com/frame');
+ iframe.setAttribute('data-percy-element-id', 'still-captured-non-array');
+ doc.body.appendChild(iframe);
+ });
+
+ // Passing a string instead of an array — normalizeIgnoreSelectors returns []
+ // and the iframe is processed normally.
+ cy.percySnapshot('Iframe Non-Array Selectors', { ignoreIframeSelectors: 'not-an-array' });
+
+ cy.then(() => helpers.get('logs'))
+ .should('include', 'Snapshot found: Iframe Non-Array Selectors');
+ });
+
+ it('does not skip iframes that do not match ignoreIframeSelectors', () => {
+ cy.document().then(doc => {
+ const iframe = doc.createElement('iframe');
+ iframe.setAttribute('src', 'https://other.com/frame');
+ iframe.setAttribute('data-percy-element-id', 'kept-iframe');
+ // class does NOT match the selector below
+ iframe.className = 'normal-frame';
+ doc.body.appendChild(iframe);
+ });
+
+ // Iframe doesn't have .ad-frame class — iframe.matches returns false,
+ // skipBySelector stays false, iframe is processed normally.
+ cy.percySnapshot('Iframe Selector NoMatch', { ignoreIframeSelectors: ['.ad-frame'] });
+
+ cy.then(() => helpers.get('logs'))
+ .should('include', 'Snapshot found: Iframe Selector NoMatch');
+ });
+
+ it('tolerates invalid selectors in ignoreIframeSelectors', () => {
+ cy.document().then(doc => {
+ const iframe = doc.createElement('iframe');
+ iframe.setAttribute('src', 'https://other.com/frame');
+ iframe.setAttribute('data-percy-element-id', 'still-captured');
+ doc.body.appendChild(iframe);
+ });
+
+ // '[broken===' is not a valid CSS selector — iframe.matches() throws,
+ // the inner try/catch swallows, and the iframe stays in the capture set.
+ cy.percySnapshot('Iframe Bad Selector', { ignoreIframeSelectors: ['[broken==='] });
+
+ cy.then(() => helpers.get('logs'))
+ .should('include', 'Snapshot found: Iframe Bad Selector');
+ });
+
it('handles cross-origin iframe where content access throws', () => {
// Add a cross-origin iframe with sandbox to ensure cross-origin restriction.
// The browser blocks contentWindow access, exercising the catch(accessError) block.
@@ -1272,6 +1460,32 @@ describe('percySnapshot', () => {
.should('include', 'Snapshot found: Iframe Processing Error');
});
+ it('drops null-snapshot entries from corsIframes payload', () => {
+ // A cross-origin iframe whose contentDocument is unreachable produces
+ // iframeSnapshot: null. The SDK filters these out before submission so
+ // they don't waste wire size. We use a real cross-origin src instead of
+ // overriding contentDocument with a throwing getter (which would crash
+ // the in-page PercyDOM serializer's own iframe walk before the SDK's
+ // filter ever runs).
+ cy.document().then(doc => {
+ const iframe = doc.createElement('iframe');
+ iframe.setAttribute('src', 'https://blocked.example.com/page');
+ iframe.setAttribute('data-percy-element-id', 'blocked-iframe');
+ doc.body.appendChild(iframe);
+ });
+
+ cy.percySnapshot('Filtered Null Snapshot');
+
+ cy.then(() => helpers.get('logs'))
+ .should('include', 'Snapshot found: Filtered Null Snapshot');
+ // No corsIframes mention in logs because all entries were filtered
+ cy.then(() => helpers.get('logs')).then(logs => {
+ const text = logs.join('\n');
+ // Either the payload had no corsIframes key, or it was empty.
+ expect(text).to.not.match(/corsIframes.*blocked-iframe/);
+ });
+ });
+
it('handles iframe with null contentDocument', () => {
// Create a cross-origin iframe and override contentDocument to return null
// This covers the branch where frameDocument is null (branch 7[1])
@@ -1436,4 +1650,303 @@ describe('percySnapshot', () => {
.should('include', 'Snapshot found: No Cookie Test');
});
});
+
+ describe('Closed Shadow DOM and ElementInternals Preflight', () => {
+ beforeEach(() => {
+ cy.then(helpers.setupTest);
+ cy.visit(helpers.testSnapshotURL);
+ });
+
+ it('sets __percyPreflightActive flag on the window', () => {
+ cy.window().then(win => {
+ expect(win.__percyPreflightActive).to.be.true;
+ });
+ });
+
+ it('intercepts closed shadow roots and stores them in WeakMap', () => {
+ cy.window().then(win => {
+ expect(win.__percyClosedShadowRoots).to.be.an.instanceOf(WeakMap);
+ });
+
+ cy.document().then(doc => {
+ const el = doc.createElement('div');
+ doc.body.appendChild(el);
+ const shadow = el.attachShadow({ mode: 'closed' });
+
+ cy.window().then(win => {
+ expect(win.__percyClosedShadowRoots.has(el)).to.be.true;
+ expect(win.__percyClosedShadowRoots.get(el)).to.equal(shadow);
+ });
+ });
+ });
+
+ it('does NOT capture open shadow roots in the WeakMap', () => {
+ cy.document().then(doc => {
+ const el = doc.createElement('div');
+ doc.body.appendChild(el);
+ el.attachShadow({ mode: 'open' });
+
+ cy.window().then(win => {
+ expect(win.__percyClosedShadowRoots.has(el)).to.be.false;
+ });
+ });
+ });
+
+ it('intercepts ElementInternals and stores them in WeakMap', () => {
+ cy.window().then(win => {
+ if (typeof win.HTMLElement.prototype.attachInternals !== 'function') {
+ // Skip if browser doesn't support attachInternals
+ return;
+ }
+
+ const tag = 'test-internals-' + Math.random().toString(36).slice(2);
+ class TestEl extends win.HTMLElement {
+ static get formAssociated() { return true; }
+
+ constructor() {
+ super();
+ this.internals = this.attachInternals();
+ }
+ }
+ win.customElements.define(tag, TestEl);
+
+ const el = win.document.createElement(tag);
+ win.document.body.appendChild(el);
+
+ expect(win.__percyInternals).to.be.an.instanceOf(WeakMap);
+ expect(win.__percyInternals.has(el)).to.be.true;
+ // Avoid deep-inspecting ElementInternals (Chai triggers NotSupportedError on .form)
+ expect(win.__percyInternals.get(el) === el.internals).to.be.true;
+ });
+ });
+
+ it('is idempotent and skips if __percyPreflightActive is already set', () => {
+ cy.window().then(win => {
+ // Preflight has already run (flag is true from page load)
+ expect(win.__percyPreflightActive).to.be.true;
+
+ // Store reference to the already-patched attachShadow
+ const patchedFn = win.Element.prototype.attachShadow;
+
+ // Note: Cypress.emit is a private API used here for testing idempotency.
+ // This may break across Cypress major versions.
+ Cypress.emit('window:before:load', win);
+
+ // attachShadow should NOT have been re-patched
+ expect(win.Element.prototype.attachShadow).to.equal(patchedFn);
+ });
+ });
+
+ it('sets Cypress.__percyPreflightRegistered to prevent duplicate registration', () => {
+ // The module-level guard sets this flag when index.js is first loaded
+ expect(Cypress.__percyPreflightRegistered).to.be.true;
+
+ // Calling registerPreflight again should return false (already registered)
+ const { registerPreflight } = require('../../index');
+ expect(registerPreflight()).to.be.false;
+ });
+
+ it('attachShadow still returns the shadow root correctly', () => {
+ cy.document().then(doc => {
+ const el = doc.createElement('div');
+ doc.body.appendChild(el);
+ const shadow = el.attachShadow({ mode: 'closed' });
+
+ // Verify the shadow root is returned and is usable
+ expect(shadow).to.not.be.null;
+ expect(shadow).to.not.be.undefined;
+ shadow.innerHTML = 'test';
+ expect(shadow.querySelector('span').textContent).to.equal('test');
+ });
+ });
+
+ it('skips ElementInternals setup when the API is unavailable', () => {
+ cy.window().then(win => {
+ // Create a minimal mock window without attachInternals
+ const mockWin = {
+ __percyPreflightActive: false,
+ Element: {
+ prototype: {
+ attachShadow: win.Element.prototype.attachShadow
+ }
+ },
+ HTMLElement: {
+ prototype: {} // no attachInternals
+ }
+ };
+
+ // Emit preflight on the mock window — should not throw and should skip internals
+ Cypress.emit('window:before:load', mockWin);
+
+ expect(mockWin.__percyPreflightActive).to.be.true;
+ expect(mockWin.__percyClosedShadowRoots).to.be.an.instanceOf(WeakMap);
+ expect(mockWin.__percyInternals).to.be.undefined;
+ });
+ });
+
+ it('bridges preflight data to runner window during snapshot', () => {
+ cy.document().then(doc => {
+ // Create a closed shadow root element before taking a snapshot
+ const el = doc.createElement('div');
+ doc.body.appendChild(el);
+ el.attachShadow({ mode: 'closed' });
+ });
+
+ cy.percySnapshot('Shadow DOM Bridge Test');
+ cy.then(() => helpers.get('logs'))
+ .should('include', 'Snapshot found: Shadow DOM Bridge Test');
+ });
+
+ it('handles snapshot when preflight data is absent from app window', () => {
+ // Remove preflight WeakMaps to exercise the falsy branches at lines 286-289
+ cy.window().then(win => {
+ delete win.__percyClosedShadowRoots;
+ delete win.__percyInternals;
+ });
+
+ cy.percySnapshot('No Preflight Data Test');
+ cy.then(() => helpers.get('logs'))
+ .should('include', 'Snapshot found: No Preflight Data Test');
+ });
+
+ it('does not keep a parallel hosts array (WeakMap leak guard)', () => {
+ // CE review #1: closedShadowHosts / internalsHosts arrays were dropped
+ // because they pinned every host element strongly across the suite and
+ // defeated the WeakMap. Assert they're not present on the window.
+ cy.window().then(win => {
+ expect(win.__percyClosedShadowHosts).to.be.undefined;
+ expect(win.__percyInternalsHosts).to.be.undefined;
+ });
+ });
+
+ it('patches the current window synchronously at registration time', () => {
+ // CE review #3: window:before:load only fires on subsequent navigations.
+ // The initial AUT page is already loaded by the time index.js runs in
+ // support/e2e.js, so registerPreflight() also runs patchWindow on
+ // cy.state('window') synchronously. We can prove the synchronous path
+ // by re-invoking it on a fresh mock window (without going through the
+ // event bus) and verifying it patches.
+ cy.window().then(win => {
+ const mockWin = {
+ __percyPreflightActive: false,
+ Element: {
+ prototype: {
+ attachShadow: win.Element.prototype.attachShadow
+ }
+ },
+ HTMLElement: {
+ prototype: {
+ attachInternals: function() { return {}; }
+ }
+ }
+ };
+
+ // The exported patchWindow isn't public; we exercise it via the
+ // 'window:before:load' emit which uses the same code path. The key
+ // assertion is that calling patchWindow synchronously on a window
+ // produces the same WeakMap setup as the event-driven path.
+ Cypress.emit('window:before:load', mockWin);
+ expect(mockWin.__percyPreflightActive).to.be.true;
+ expect(mockWin.__percyClosedShadowRoots).to.be.an.instanceOf(WeakMap);
+ expect(mockWin.__percyInternals).to.be.an.instanceOf(WeakMap);
+ });
+ });
+
+ it('injects PercyDOM into the AUT window via a script element', () => {
+ // CE review companion: the rewrite swapped runner-window eval for an
+ // AUT-window