diff --git a/shepherd.js/src/components/shepherd-modal.ts b/shepherd.js/src/components/shepherd-modal.ts index b7cf55318..9f239d2ca 100644 --- a/shepherd.js/src/components/shepherd-modal.ts +++ b/shepherd.js/src/components/shepherd-modal.ts @@ -353,10 +353,21 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI { if (!el) return offset; + // The overlay is `position: fixed` in the document it is rendered into, so + // frame offsets only accumulate up to that document's window. Walking on + // to `window.top` would also add the offsets of the frames hosting + // Shepherd itself, pushing the opening off the target (#3478). + const modalWindow = container.ownerDocument.defaultView; let targetWindow: Window | null = el.ownerDocument.defaultView; try { - while (targetWindow && targetWindow !== window.top) { + while ( + targetWindow && + targetWindow !== modalWindow && + // The top window is its own parent; stop there if the target is not + // nested under the modal's window at all. + targetWindow !== targetWindow.parent + ) { const targetIframe = targetWindow?.frameElement; if (targetIframe) { diff --git a/shepherd.js/test/cypress/examples/iframes/content.html b/shepherd.js/test/cypress/examples/iframes/content.html new file mode 100644 index 000000000..2458f4934 --- /dev/null +++ b/shepherd.js/test/cypress/examples/iframes/content.html @@ -0,0 +1,21 @@ + + + + + + + +
Target
+ + diff --git a/shepherd.js/test/cypress/examples/iframes/host.html b/shepherd.js/test/cypress/examples/iframes/host.html new file mode 100644 index 000000000..b6ae24740 --- /dev/null +++ b/shepherd.js/test/cypress/examples/iframes/host.html @@ -0,0 +1,27 @@ + + + + + + + + + + + + + diff --git a/shepherd.js/test/cypress/examples/iframes/nested.html b/shepherd.js/test/cypress/examples/iframes/nested.html new file mode 100644 index 000000000..c4d1cd770 --- /dev/null +++ b/shepherd.js/test/cypress/examples/iframes/nested.html @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/shepherd.js/test/cypress/integration/iframes.cy.js b/shepherd.js/test/cypress/integration/iframes.cy.js new file mode 100644 index 000000000..7d6a45816 --- /dev/null +++ b/shepherd.js/test/cypress/integration/iframes.cy.js @@ -0,0 +1,83 @@ +import setupTour from '../utils/setup-tour'; +import overlayOpenings from '../utils/overlay-openings'; + +// End-to-end guard for #3478. The overlay opening for a target in a nested +// frame must be offset only by the frames between the target and the document +// Shepherd renders into, not by the frames hosting Shepherd itself. Unit tests +// can only mock the frame chain, so the arithmetic is checked here. +describe('modal overlay for targets inside iframes', () => { + let tour; + + afterEach(() => { + tour?.complete(); + }); + + /** Waits for a same-origin frame's window to satisfy `isReady`. */ + const frameWindow = ($frame, isReady) => + cy + .wrap($frame) + .its('0.contentWindow') + .should((win) => expect(isReady(win)).to.be.ok); + + const expectOpeningOnTarget = (shepherdWindow) => { + const doc = shepherdWindow.document; + const content = doc.getElementById('content'); + const frameRect = content.getBoundingClientRect(); + const targetRect = content.contentDocument + .querySelector('.target') + .getBoundingClientRect(); + const [opening] = overlayOpenings(doc); + + expect(opening.x).to.be.closeTo(frameRect.left + targetRect.left, 1); + expect(opening.y).to.be.closeTo(frameRect.top + targetRect.top, 1); + expect(opening.height).to.be.closeTo(40, 1); + }; + + const startTour = (shepherdWindow) => { + const target = shepherdWindow.document + .getElementById('content') + .contentDocument.querySelector('.target'); + + tour = setupTour( + shepherdWindow.Shepherd, + { scrollTo: false }, + () => [ + { + attachTo: { element: target, on: 'bottom' }, + id: 'iframe-target', + text: 'Inside an iframe' + } + ], + { useModalOverlay: true } + ); + tour.start(); + }; + + const contentReady = (win) => + win.Shepherd && + win.document + .getElementById('content') + ?.contentDocument?.querySelector('.target'); + + it('offsets the opening by the target frame when Shepherd is in the top document', () => { + cy.visit('/test/cypress/examples/iframes/host.html'); + + cy.window() + .should((win) => expect(contentReady(win)).to.be.ok) + .then((win) => { + startTour(win); + cy.wait(250).then(() => expectOpeningOnTarget(win)); + }); + }); + + it('does not add the offset of the frame hosting Shepherd', () => { + cy.visit('/test/cypress/examples/iframes/nested.html'); + + cy.get('#host').then(($host) => { + frameWindow($host, contentReady).then((win) => { + startTour(win); + cy.wait(250).then(() => expectOpeningOnTarget(win)); + }); + }); + }); +}); diff --git a/shepherd.js/test/cypress/utils/overlay-openings.js b/shepherd.js/test/cypress/utils/overlay-openings.js new file mode 100644 index 000000000..ad1a2b5b7 --- /dev/null +++ b/shepherd.js/test/cypress/utils/overlay-openings.js @@ -0,0 +1,26 @@ +/** + * Reads the cutouts of the modal overlay back out of its svg path. + * + * The path is the full-viewport rect followed by one sub-path per opening, + * each starting `M{x + radius},{y}` and reaching its bottom edge with the + * first `V{y + height}`. Only valid for openings without a corner radius. + * + * @param {Document} doc The document the overlay is rendered in + * @returns {{ x: number, y: number, height: number }[]} + */ +export default function overlayOpenings(doc) { + const d = doc + .querySelector('.shepherd-modal-overlay-container path') + .getAttribute('d'); + + return d + .split('Z') + .slice(1) + .filter(Boolean) + .map((subPath) => { + const [, x, y, bottom] = subPath.match( + /^M([-\d.e]+),([-\d.e]+).*?V([-\d.e]+)/ + ); + return { x: Number(x), y: Number(y), height: Number(bottom) - Number(y) }; + }); +} diff --git a/shepherd.js/test/unit/components/shepherd-modal.spec.js b/shepherd.js/test/unit/components/shepherd-modal.spec.js index e6c5dee88..fe9c7bbd1 100644 --- a/shepherd.js/test/unit/components/shepherd-modal.spec.js +++ b/shepherd.js/test/unit/components/shepherd-modal.spec.js @@ -1270,41 +1270,50 @@ describe('components/ShepherdModal', () => { }); describe('_getIframeOffset (via setupForStep)', function () { - it('accumulates offset when element is inside an iframe', () => { - const modal = createShepherdModal(container); - const rafSpy = vi + let rafSpy; + let restoreWindow = []; + + beforeEach(() => { + rafSpy = vi .spyOn(window, 'requestAnimationFrame') .mockImplementation(() => 1); + }); - const targetEl = document.createElement('div'); - container.appendChild(targetEl); + afterEach(() => { + rafSpy.mockRestore(); + restoreWindow.forEach((restore) => restore()); + restoreWindow = []; + }); - // Simulate the element being inside an iframe by mocking ownerDocument.defaultView - const fakeIframe = document.createElement('iframe'); - Object.defineProperty(fakeIframe, 'getBoundingClientRect', { - value: () => ({ - top: 10, - left: 20, - width: 100, - height: 100, - x: 20, - y: 10 - }) + function makeIframe({ top, left }, { scrollTop = 0, scrollLeft = 0 } = {}) { + const iframe = document.createElement('iframe'); + Object.defineProperty(iframe, 'getBoundingClientRect', { + value: () => ({ top, left, width: 100, height: 100, x: left, y: top }) }); - Object.defineProperty(fakeIframe, 'scrollTop', { value: 5 }); - Object.defineProperty(fakeIframe, 'scrollLeft', { value: 3 }); - - const fakeChildWindow = { - frameElement: fakeIframe, - parent: window - }; - - const origDescriptor = Object.getOwnPropertyDescriptor( - targetEl.ownerDocument, - 'defaultView' + Object.defineProperty(iframe, 'scrollTop', { value: scrollTop }); + Object.defineProperty(iframe, 'scrollLeft', { value: scrollLeft }); + return iframe; + } + + // Overrides a property of the real window for the duration of one test. + function stubWindow(key, value) { + const descriptor = Object.getOwnPropertyDescriptor(window, key); + Object.defineProperty(window, key, { value, configurable: true }); + restoreWindow.push(() => + descriptor + ? Object.defineProperty(window, key, descriptor) + : delete window[key] ); - Object.defineProperty(targetEl.ownerDocument, 'defaultView', { - value: fakeChildWindow, + } + + // A target living in the document of `frameWindow`. Only the target's + // ownerDocument is swapped: the modal container stays in the real + // document, which is what decides where the frame walk has to stop. + function startStepInFrame(modal, frameWindow) { + const targetEl = document.createElement('div'); + container.appendChild(targetEl); + Object.defineProperty(targetEl, 'ownerDocument', { + value: { defaultView: frameWindow }, configurable: true }); @@ -1315,46 +1324,88 @@ describe('components/ShepherdModal', () => { step._resolveAttachToOptions(); step.target = targetEl; - // This triggers _styleForStep -> _getIframeOffset, which should - // walk up through fakeChildWindow and accumulate the iframe offset modal.setupForStep(step); - // Restore defaultView before any assertions (jsdom needs it for instanceof checks) - if (origDescriptor) { - Object.defineProperty( - targetEl.ownerDocument, - 'defaultView', - origDescriptor - ); - } else { - Object.defineProperty(targetEl.ownerDocument, 'defaultView', { - value: window, - configurable: true - }); - } + return modal.getElement().querySelector('path').getAttribute('d'); + } + + it('offsets the opening by the frame holding the target', () => { + const modal = createShepherdModal(container); + const contentWindow = { + frameElement: makeIframe( + { top: 10, left: 20 }, + { scrollTop: 5, scrollLeft: 3 } + ), + parent: window + }; + + const d = startStepInFrame(modal, contentWindow); expect(modal.getElement()).toHaveClass('shepherd-modal-is-visible'); + // The target itself measures 0x0 at the origin in happy-dom, so the + // opening sits exactly at the accumulated frame offset. + expect(d).toContain('M23,15'); + }); - rafSpy.mockRestore(); + it('sums the offsets of every frame between the target and the modal', () => { + const modal = createShepherdModal(container); + const middleWindow = { + frameElement: makeIframe({ top: 100, left: 50 }), + parent: window + }; + const contentWindow = { + frameElement: makeIframe({ top: 60, left: 30 }), + parent: middleWindow + }; + + expect(startStepInFrame(modal, contentWindow)).toContain('M80,160'); + }); + + it('stops at the window the modal renders into when Shepherd is itself framed', () => { + // Regression test for https://github.com/shipshapecode/shepherd/issues/3478 + // top -> host frame (Shepherd, at 50,100) -> content frame (target, at + // 30,60 within the host). The overlay is fixed inside the host document, + // so only the content frame's offset applies. + const modal = createShepherdModal(container); + const topWindow = {}; + topWindow.parent = topWindow; + stubWindow('frameElement', makeIframe({ top: 100, left: 50 })); + stubWindow('parent', topWindow); + stubWindow('top', topWindow); + + const contentWindow = { + frameElement: makeIframe({ top: 60, left: 30 }), + parent: window + }; + + const d = startStepInFrame(modal, contentWindow); + + expect(d).toContain('M30,60'); + // Walking on to window.top adds the host frame's offset as well. + expect(d).not.toContain('M80,160'); + }); + + it('stops at the top window when the target is not below the modal window', () => { + const modal = createShepherdModal(container); + const topWindow = {}; + topWindow.parent = topWindow; + const siblingWindow = { + frameElement: makeIframe({ top: 60, left: 30 }), + parent: topWindow + }; + + expect(() => startStepInFrame(modal, siblingWindow)).not.toThrow(); }); it('handles cross-origin iframe SecurityError gracefully', () => { // Regression test for https://github.com/shipshapecode/shepherd/issues/3087 // When Shepherd is loaded in a nested cross-origin iframe, accessing // window.frameElement throws a SecurityError due to Same-Origin Policy. - // This test ensures the error is caught and handled gracefully. + // This test ensures the error is caught and handled gracefully, keeping + // the offset accumulated before the cross-origin boundary. const modal = createShepherdModal(container); - const rafSpy = vi - .spyOn(window, 'requestAnimationFrame') - .mockImplementation(() => 1); - - const targetEl = document.createElement('div'); - container.appendChild(targetEl); - - // Simulate a cross-origin iframe by making frameElement access throw SecurityError - const fakeChildWindow = { + const crossOriginWindow = { get frameElement() { - // Simulate browser's SecurityError when accessing cross-origin frameElement const error = new Error( 'Blocked a frame with origin "https://example.com" from accessing a cross-origin frame.' ); @@ -1363,45 +1414,18 @@ describe('components/ShepherdModal', () => { }, parent: window }; + const contentWindow = { + frameElement: makeIframe({ top: 60, left: 30 }), + parent: crossOriginWindow + }; - const origDescriptor = Object.getOwnPropertyDescriptor( - targetEl.ownerDocument, - 'defaultView' - ); - Object.defineProperty(targetEl.ownerDocument, 'defaultView', { - value: fakeChildWindow, - configurable: true - }); - - const tour = new Tour({ useModalOverlay: true }); - const step = new Step(tour, { - attachTo: { element: targetEl, on: 'bottom' } - }); - step._resolveAttachToOptions(); - step.target = targetEl; - - // This should NOT throw an error, even though frameElement access throws SecurityError + let d; expect(() => { - modal.setupForStep(step); + d = startStepInFrame(modal, contentWindow); }).not.toThrow(); - // Restore defaultView before any assertions - if (origDescriptor) { - Object.defineProperty( - targetEl.ownerDocument, - 'defaultView', - origDescriptor - ); - } else { - Object.defineProperty(targetEl.ownerDocument, 'defaultView', { - value: window, - configurable: true - }); - } - expect(modal.getElement()).toHaveClass('shepherd-modal-is-visible'); - - rafSpy.mockRestore(); + expect(d).toContain('M30,60'); }); }); });