diff --git a/packages/happy-dom/src/browser/utilities/BrowserFrameNavigator.ts b/packages/happy-dom/src/browser/utilities/BrowserFrameNavigator.ts index 5f6b010a..cc5b1a33 100644 --- a/packages/happy-dom/src/browser/utilities/BrowserFrameNavigator.ts +++ b/packages/happy-dom/src/browser/utilities/BrowserFrameNavigator.ts @@ -93,6 +93,8 @@ export default class BrowserFrameNavigator { const width = frame.window.innerWidth; const height = frame.window.innerHeight; const devicePixelRatio = frame.window.devicePixelRatio; + const parentWindow = frame.window.parent !== frame.window ? frame.window.parent : null; + const topWindow = frame.window.top !== frame.window ? frame.window.top : null; for (const childFrame of frame.childFrames) { BrowserFrameFactory.destroyFrame(childFrame); @@ -104,6 +106,8 @@ export default class BrowserFrameNavigator { frame[PropertySymbol.asyncTaskManager] = new AsyncTaskManager(); (frame.window) = new windowClass(frame, { url: targetURL.href, width, height }); + (frame.window.parent) = parentWindow; + (frame.window.top) = topWindow; (frame.window.devicePixelRatio) = devicePixelRatio; if (referrer) { diff --git a/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementNamedNodeMap.ts index 28a8be90..ce55262a 100644 --- a/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementNamedNodeMap.ts +++ b/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementNamedNodeMap.ts @@ -46,8 +46,14 @@ export default class HTMLIFrameElementNamedNodeMap extends HTMLElementNamedNodeM public override setNamedItem(item: Attr): Attr | null { const replacedAttribute = super.setNamedItem(item); + if (item[PropertySymbol.name] === 'srcdoc') { + this.#pageLoader.loadPage(); + } + + // If the src attribute and the srcdoc attribute are both specified together, the srcdoc attribute takes priority. if ( item[PropertySymbol.name] === 'src' && + this[PropertySymbol.ownerElement][PropertySymbol.attributes]['srcdoc']?.value === undefined && item[PropertySymbol.value] && item[PropertySymbol.value] !== replacedAttribute?.[PropertySymbol.value] ) { @@ -70,6 +76,21 @@ export default class HTMLIFrameElementNamedNodeMap extends HTMLElementNamedNodeM return replacedAttribute || null; } + /** + * @override + */ + public override [PropertySymbol.removeNamedItem](name: string): Attr | null { + const removedItem = super[PropertySymbol.removeNamedItem](name); + if ( + removedItem && + (removedItem[PropertySymbol.name] === 'srcdoc' || removedItem[PropertySymbol.name] === 'src') + ) { + this.#pageLoader.loadPage(); + } + + return removedItem; + } + /** * * @param tokens diff --git a/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementPageLoader.ts b/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementPageLoader.ts index 6783a5f6..c690a8de 100644 --- a/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementPageLoader.ts +++ b/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementPageLoader.ts @@ -19,6 +19,7 @@ export default class HTMLIFrameElementPageLoader { #contentWindowContainer: { window: BrowserWindow | CrossOriginBrowserWindow | null }; #browserParentFrame: IBrowserFrame; #browserIFrame: IBrowserFrame; + #srcdoc: string | null = null; /** * Constructor. @@ -44,15 +45,43 @@ export default class HTMLIFrameElementPageLoader { */ public loadPage(): void { if (!this.#element[PropertySymbol.isConnected]) { - if (this.#browserIFrame) { - BrowserFrameFactory.destroyFrame(this.#browserIFrame); - this.#browserIFrame = null; - } - this.#contentWindowContainer.window = null; + this.unloadPage(); return; } + const srcdoc = this.#element.getAttribute('srcdoc'); const window = this.#element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow]; + + if (srcdoc !== null) { + if (this.#srcdoc === srcdoc) { + return; + } + + this.unloadPage(); + + this.#browserIFrame = BrowserFrameFactory.createChildFrame(this.#browserParentFrame); + this.#browserIFrame.url = 'about:srcdoc'; + + this.#contentWindowContainer.window = this.#browserIFrame.window; + + (this.#browserIFrame.window.top) = this.#browserParentFrame.window.top; + (this.#browserIFrame.window.parent) = this.#browserParentFrame.window; + + this.#browserIFrame.window.document.open(); + this.#browserIFrame.window.document.write(srcdoc); + + this.#srcdoc = srcdoc; + + this.#element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].requestAnimationFrame( + () => this.#element.dispatchEvent(new Event('load')) + ); + return; + } + + if (this.#srcdoc !== null) { + this.unloadPage(); + } + const originURL = this.#browserParentFrame.window.location; const targetURL = BrowserFrameURL.getRelativeURL(this.#browserParentFrame, this.#element.src); @@ -105,5 +134,6 @@ export default class HTMLIFrameElementPageLoader { this.#browserIFrame = null; } this.#contentWindowContainer.window = null; + this.#srcdoc = null; } } diff --git a/packages/happy-dom/test/nodes/html-iframe-element/HTMLIFrameElement.test.ts b/packages/happy-dom/test/nodes/html-iframe-element/HTMLIFrameElement.test.ts index 74d75024..79de967c 100644 --- a/packages/happy-dom/test/nodes/html-iframe-element/HTMLIFrameElement.test.ts +++ b/packages/happy-dom/test/nodes/html-iframe-element/HTMLIFrameElement.test.ts @@ -35,7 +35,7 @@ describe('HTMLIFrameElement', () => { }); }); - for (const property of ['src', 'allow', 'height', 'width', 'name', 'srcdoc']) { + for (const property of ['src', 'allow', 'height', 'width', 'name']) { describe(`get ${property}()`, () => { it(`Returns the "${property}" attribute.`, () => { element.setAttribute(property, 'value'); @@ -126,6 +126,105 @@ describe('HTMLIFrameElement', () => { }); }); + describe('get srcdoc()', () => { + it('Returns string', () => { + expect(element.srcdoc).toBe(''); + element.srcdoc = '
'; + expect(element.getAttribute('srcdoc')).toBe('
'); + }); + }); + + describe('set srcdoc()', () => { + it("Navigate the element's browsing context to a resource whose Content-Type is text/html", async () => { + const actualHTML = await new Promise((resolve) => { + element.srcdoc = '
TEST
'; + element.addEventListener('load', () => { + resolve(element.contentDocument?.documentElement.innerHTML); + }); + document.body.appendChild(element); + }); + expect(actualHTML).toBe('
TEST
'); + }); + + it('Takes priority, when the src attribute and the srcdoc attribute are both specified together', async () => { + const browser = new Browser(); + const page = browser.newPage(); + const window = page.mainFrame.window; + const document = window.document; + const element = document.createElement('iframe'); + const url = await new Promise((resolve) => { + element.srcdoc = 'TEST'; + element.src = 'https://localhost:8080/iframe.html'; + element.addEventListener('load', () => { + resolve(page.mainFrame.childFrames[0].url); + }); + document.appendChild(element); + }); + expect(url).toBe('about:srcdoc'); + }); + + it('Resolve the value of the src attribute when the srcdoc attribute has been removed', async () => { + const browser = new Browser(); + const page = browser.newPage(); + const window = page.mainFrame.window; + const document = window.document; + const element = document.createElement('iframe'); + page.mainFrame.url = 'https://localhost:8080'; + const responseHTML = 'Test'; + + vi.spyOn(BrowserWindow.prototype, 'fetch').mockImplementation((url: IRequestInfo) => { + return Promise.resolve(({ + text: () => Promise.resolve(responseHTML), + ok: true, + headers: new Headers() + })); + }); + const frameUrl = 'https://localhost:8080/iframe.html'; + const actualFrameUrl = await new Promise((resolve) => { + element.srcdoc = responseHTML; + element.src = frameUrl; + const firstLoad = (): void => { + expect(page.mainFrame.childFrames[0].url).toBe('about:srcdoc'); + element.removeEventListener('load', firstLoad); + element.addEventListener('load', () => { + resolve(page.mainFrame.childFrames[0].url); + }); + element.removeAttribute('srcdoc'); + }; + element.addEventListener('load', firstLoad); + document.body.appendChild(element); + }); + expect(actualFrameUrl).toBe(frameUrl); + }); + + it('Execute code in the script', async () => { + const message = await new Promise((resolve) => { + element.srcdoc = ` + + `; + element.addEventListener('load', () => { + element.contentWindow?.postMessage('MESSAGE', '*'); + }); + window.addEventListener( + 'message', + (e) => { + const data = (e).data; + resolve(data); + }, + false + ); + document.body.appendChild(element); + expect(element.contentWindow?.parent === window).toBe(true); + }); + expect(message).toMatchObject({ msg: 'loaded' }); + }); + }); + describe('get contentWindow()', () => { it('Returns content window for "about:blank".', () => { element.src = 'about:blank'; @@ -421,6 +520,36 @@ describe('HTMLIFrameElement', () => { document.body.appendChild(element); }); }); + + it('Remain at the initial about:blank page when none of the srcdoc/src attributes are set', async () => { + const browser = new Browser(); + const page = browser.newPage(); + const window = page.mainFrame.window; + const document = window.document; + const element = document.createElement('iframe'); + page.mainFrame.url = 'https://localhost:8080'; + + vi.spyOn(BrowserWindow.prototype, 'fetch').mockImplementation((url: IRequestInfo) => { + return Promise.resolve(({ + text: () => Promise.resolve('Test'), + ok: true, + headers: new Headers() + })); + }); + const actualFrameUrl = await new Promise((resolve) => { + element.src = 'https://localhost:8080/iframe.html'; + const firstLoad = (): void => { + element.removeEventListener('load', firstLoad); + element.addEventListener('load', () => { + resolve(page.mainFrame.childFrames[0].url); + }); + element.removeAttribute('src'); + }; + element.addEventListener('load', firstLoad); + document.body.appendChild(element); + }); + expect(actualFrameUrl).toBe('about:blank'); + }); }); describe('get contentDocument()', () => { @@ -428,6 +557,7 @@ describe('HTMLIFrameElement', () => { element.src = 'about:blank'; expect(element.contentDocument).toBe(null); document.body.appendChild(element); + expect(element.contentWindow?.parent === window).toBe(true); expect(element.contentDocument?.documentElement.innerHTML).toBe(''); }); });