diff --git a/packages/happy-dom/README.md b/packages/happy-dom/README.md index de847fa45..fa881a3fc 100644 --- a/packages/happy-dom/README.md +++ b/packages/happy-dom/README.md @@ -257,6 +257,7 @@ const window = new Window({ disableJavaScriptFileLoading: true, disableJavaScriptEvaluation: true, disableCSSFileLoading: true, + disableIframePageLoading: true, enableFileSystemHttpRequests: true } }); @@ -270,6 +271,7 @@ const window = new Window(); window.happyDOM.settings.disableJavaScriptFileLoading = true; window.happyDOM.settings.disableJavaScriptEvaluation = true; window.happyDOM.settings.disableCSSFileLoading = true; +window.happyDOM.settings.disableIframePageLoading = true; window.happyDOM.settings.enableFileSystemHttpRequests = true; ``` @@ -283,7 +285,11 @@ Set it to "true" to completely disable JavaScript evaluation. Defaults to "false **disableCSSFileLoading** -Set it to "true" to disable CSS file loading using the HTMLLinkElement. Defaults to "false". +Set it to "true" to disable CSS file loading in HTMLLinkElement. Defaults to "false". + +**disableIframePageLoading** + +Set it to "true" to disable page loading in HTMLIframeElement. Defaults to "false". **enableFileSystemHttpRequests** diff --git a/packages/happy-dom/src/config/ElementTag.ts b/packages/happy-dom/src/config/ElementTag.ts index 49f15720a..034c460da 100644 --- a/packages/happy-dom/src/config/ElementTag.ts +++ b/packages/happy-dom/src/config/ElementTag.ts @@ -21,6 +21,7 @@ import HTMLButtonElement from '../nodes/html-button-element/HTMLButtonElement'; import HTMLAudioElement from '../nodes/html-audio-element/HTMLAudioElement'; import HTMLVideoElement from '../nodes/html-video-element/HTMLVideoElement'; import HTMLAnchorElement from '../nodes/html-anchor-element/HTMLAnchorElement'; +import HTMLIframeElement from '../nodes/html-iframe-element/HTMLIframeElement'; export default { A: HTMLAnchorElement, @@ -94,7 +95,7 @@ export default { HR: HTMLElement, HTML: HTMLElement, I: HTMLElement, - IFRAME: HTMLElement, + IFRAME: HTMLIframeElement, INS: HTMLElement, KBD: HTMLElement, LEGEND: HTMLElement, diff --git a/packages/happy-dom/src/config/NonImplemenetedElementClasses.ts b/packages/happy-dom/src/config/NonImplemenetedElementClasses.ts index 3b712841f..6622795a7 100644 --- a/packages/happy-dom/src/config/NonImplemenetedElementClasses.ts +++ b/packages/happy-dom/src/config/NonImplemenetedElementClasses.ts @@ -45,7 +45,6 @@ export default [ 'HTMLTableSectionElement', 'HTMLFrameElement', 'HTMLFrameSetElement', - 'HTMLIFrameElement', 'HTMLEmbedElement', 'HTMLObjectElement', 'HTMLParamElement', diff --git a/packages/happy-dom/src/event/IMessagePort.ts b/packages/happy-dom/src/event/IMessagePort.ts new file mode 100644 index 000000000..1af798dc2 --- /dev/null +++ b/packages/happy-dom/src/event/IMessagePort.ts @@ -0,0 +1,26 @@ +import IEventTarget from './IEventTarget'; + +/** + * Message port. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/MessagePort + */ +export default interface IMessagePort extends IEventTarget { + /** + * Sends a message from the port, and optionally, transfers ownership of objects to other browsing contexts. + * + * @param type Event type. + * @param listener Listener. + */ + postMessage(message: unknown, transerList: unknown[]): void; + + /** + * Starts the sending of messages queued on the port. + */ + start(): void; + + /** + * Disconnects the port, so it is no longer active. This stops the flow of messages to that port. + */ + close(): void; +} diff --git a/packages/happy-dom/src/event/MessagePort.ts b/packages/happy-dom/src/event/MessagePort.ts new file mode 100644 index 000000000..cb7a67337 --- /dev/null +++ b/packages/happy-dom/src/event/MessagePort.ts @@ -0,0 +1,33 @@ +import EventTarget from './EventTarget'; +import IMessagePort from './IMessagePort'; + +/** + * Message port. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/MessagePort + */ +export default abstract class MessagePort extends EventTarget implements IMessagePort { + /** + * Sends a message from the port, and optionally, transfers ownership of objects to other browsing contexts. + * + * @param _message Message. + * @param _transerList Transfer list. + */ + public postMessage(_message: unknown, _transerList: unknown[]): void { + // TODO: Implement + } + + /** + * Starts the sending of messages queued on the port. + */ + public start(): void { + // TODO: Implement + } + + /** + * Disconnects the port, so it is no longer active. This stops the flow of messages to that port. + */ + public close(): void { + // TODO: Implement + } +} diff --git a/packages/happy-dom/src/event/events/IMessageEventInit.ts b/packages/happy-dom/src/event/events/IMessageEventInit.ts new file mode 100644 index 000000000..7d1e6339b --- /dev/null +++ b/packages/happy-dom/src/event/events/IMessageEventInit.ts @@ -0,0 +1,11 @@ +import IEventInit from '../IEventInit'; +import IWindow from '../../window/IWindow'; +import IMessagePort from '../IMessagePort'; + +export default interface IMessageEventInit extends IEventInit { + data?: unknown | null; + origin?: string; + lastEventId?: string; + source?: IWindow | null; + ports?: IMessagePort[]; +} diff --git a/packages/happy-dom/src/event/events/MessageEvent.ts b/packages/happy-dom/src/event/events/MessageEvent.ts new file mode 100644 index 000000000..fc95ecc3f --- /dev/null +++ b/packages/happy-dom/src/event/events/MessageEvent.ts @@ -0,0 +1,32 @@ +import IWindow from '../../window/IWindow'; +import Event from '../Event'; +import IMessagePort from '../IMessagePort'; +import IMessageEventInit from './IMessageEventInit'; + +/** + * Message event. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent + */ +export default class MessageEvent extends Event { + public data?: unknown | null = null; + public origin?: string = ''; + public lastEventId?: string = ''; + public source?: IWindow | null = null; + public ports?: IMessagePort[] = []; + + /** + * Constructor. + * + * @param type Event type. + * @param [eventInit] Event init. + */ + constructor(type: string, eventInit?: IMessageEventInit) { + super(type, eventInit); + this.data = eventInit?.data !== undefined ? eventInit.data : null; + this.origin = eventInit?.origin || ''; + this.lastEventId = eventInit?.lastEventId || ''; + this.source = eventInit?.source || null; + this.ports = eventInit?.ports || []; + } +} diff --git a/packages/happy-dom/src/nodes/html-iframe-element/HTMLIframeElement.ts b/packages/happy-dom/src/nodes/html-iframe-element/HTMLIframeElement.ts new file mode 100644 index 000000000..566a6a2b9 --- /dev/null +++ b/packages/happy-dom/src/nodes/html-iframe-element/HTMLIframeElement.ts @@ -0,0 +1,266 @@ +import { URL } from 'url'; +import Event from '../../event/Event'; +import ErrorEvent from '../../event/events/ErrorEvent'; +import IWindow from '../../window/IWindow'; +import Window from '../../window/Window'; +import IDocument from '../document/IDocument'; +import HTMLElement from '../html-element/HTMLElement'; +import INode from '../node/INode'; +import IframeCrossOriginWindow from './IframeCrossOriginWindow'; +import IHTMLIframeElement from './IHTMLIframeElement'; + +/** + * HTML Iframe Element. + * + * Reference: + * https://developer.mozilla.org/en-US/docs/Web/API/HTMLIframeElement. + */ +export default class HTMLIframeElement extends HTMLElement implements IHTMLIframeElement { + // Events + public onload: (event: Event) => void | null = null; + public onerror: (event: Event) => void | null = null; + + // Private + #contentWindow: IWindow | IframeCrossOriginWindow | null = null; + + /** + * Returns source. + * + * @returns Source. + */ + public get src(): string { + return this.getAttribute('src') || ''; + } + + /** + * Sets source. + * + * @param src Source. + */ + public set src(src: string) { + this.setAttribute('src', src); + } + + /** + * Returns allow. + * + * @returns Allow. + */ + public get allow(): string { + return this.getAttribute('allow') || ''; + } + + /** + * Sets allow. + * + * @param allow Allow. + */ + public set allow(allow: string) { + this.setAttribute('allow', allow); + } + + /** + * Returns height. + * + * @returns Height. + */ + public get height(): string { + return this.getAttribute('height') || ''; + } + + /** + * Sets height. + * + * @param height Height. + */ + public set height(height: string) { + this.setAttribute('height', height); + } + + /** + * Returns width. + * + * @returns Width. + */ + public get width(): string { + return this.getAttribute('width') || ''; + } + + /** + * Sets width. + * + * @param width Width. + */ + public set width(width: string) { + this.setAttribute('width', width); + } + + /** + * Returns name. + * + * @returns Name. + */ + public get name(): string { + return this.getAttribute('name') || ''; + } + + /** + * Sets name. + * + * @param name Name. + */ + public set name(name: string) { + this.setAttribute('name', name); + } + + /** + * Returns sandbox. + * + * @returns Sandbox. + */ + public get sandbox(): string { + return this.getAttribute('sandbox') || ''; + } + + /** + * Sets sandbox. + * + * @param sandbox Sandbox. + */ + public set sandbox(sandbox: string) { + this.setAttribute('sandbox', sandbox); + } + + /** + * Returns srcdoc. + * + * @returns Srcdoc. + */ + public get srcdoc(): string { + return this.getAttribute('srcdoc') || ''; + } + + /** + * Sets sandbox. + * + * @param srcdoc Srcdoc. + */ + public set srcdoc(srcdoc: string) { + this.setAttribute('srcdoc', srcdoc); + } + + /** + * Returns content document. + * + * @returns Content document. + */ + public get contentDocument(): IDocument | null { + return (this.#contentWindow)?.document || null; + } + + /** + * Returns content window. + * + * @returns Content window. + */ + public get contentWindow(): IWindow | IframeCrossOriginWindow | null { + return this.#contentWindow || null; + } + + /** + * @override + */ + public override _connectToNode(parentNode: INode = null): void { + const isConnected = this.isConnected; + const isParentConnected = parentNode ? parentNode.isConnected : false; + + super._connectToNode(parentNode); + + if ( + isParentConnected && + isConnected !== isParentConnected && + !this.ownerDocument.defaultView.happyDOM.settings.disableIframePageLoading + ) { + const src = this.src; + + if (src !== null) { + const contentWindow = new (this.ownerDocument.defaultView.constructor)({ + url: src, + settings: { + ...this.ownerDocument.defaultView.happyDOM.settings + } + }); + + (contentWindow.parent) = this.ownerDocument.defaultView; + (contentWindow.top) = this.ownerDocument.defaultView; + + if (src === 'about:blank') { + this.#contentWindow = contentWindow; + return; + } + + if (src.startsWith('javascript:')) { + this.#contentWindow = contentWindow; + this.#contentWindow.eval(src.replace('javascript:', '')); + return; + } + + const originURL = this.ownerDocument.defaultView.location; + const targetURL = new URL(src, originURL); + const isCORS = + (originURL.hostname !== targetURL.hostname && + !originURL.hostname.endsWith(targetURL.hostname)) || + originURL.protocol !== targetURL.protocol; + + const onError = (error): void => { + this.dispatchEvent( + new ErrorEvent('error', { + message: error.message, + error + }) + ); + this.ownerDocument.defaultView.dispatchEvent( + new ErrorEvent('error', { + message: error.message, + error + }) + ); + if ( + !this['_listeners']['error'] && + !this.ownerDocument.defaultView['_listeners']['error'] + ) { + this.ownerDocument.defaultView.console.error(error); + } + }; + + this.#contentWindow = null; + this.ownerDocument.defaultView + .fetch(src) + .then((response) => { + response + .text() + .then((text) => { + this.#contentWindow = isCORS + ? new IframeCrossOriginWindow(this.ownerDocument.defaultView, contentWindow) + : contentWindow; + contentWindow.document.write(text); + this.dispatchEvent(new Event('load')); + }) + .catch(onError); + }) + .catch(onError); + } + } + } + + /** + * Clones a node. + * + * @override + * @param [deep=false] "true" to clone deep. + * @returns Cloned node. + */ + public cloneNode(deep = false): IHTMLIframeElement { + return super.cloneNode(deep); + } +} diff --git a/packages/happy-dom/src/nodes/html-iframe-element/IHTMLIframeElement.ts b/packages/happy-dom/src/nodes/html-iframe-element/IHTMLIframeElement.ts new file mode 100644 index 000000000..1c326cd43 --- /dev/null +++ b/packages/happy-dom/src/nodes/html-iframe-element/IHTMLIframeElement.ts @@ -0,0 +1,27 @@ +import Event from '../../event/Event'; +import IWindow from '../../window/IWindow'; +import IDocument from '../document/IDocument'; +import IHTMLElement from '../html-element/IHTMLElement'; +import IframeCrossOriginWindow from './IframeCrossOriginWindow'; + +/** + * HTML Iframe Element. + * + * Reference: + * https://developer.mozilla.org/en-US/docs/Web/API/HTMLIframeElement. + */ +export default interface IHTMLIframeElement extends IHTMLElement { + src: string | null; + allow: string | null; + height: string | null; + width: string | null; + name: string | null; + sandbox: string | null; + srcdoc: string | null; + readonly contentDocument: IDocument | null; + readonly contentWindow: IWindow | IframeCrossOriginWindow | null; + + // Events + onload: (event: Event) => void | null; + onerror: (event: Event) => void | null; +} diff --git a/packages/happy-dom/src/nodes/html-iframe-element/IframeCrossOriginWindow.ts b/packages/happy-dom/src/nodes/html-iframe-element/IframeCrossOriginWindow.ts new file mode 100644 index 000000000..77821503f --- /dev/null +++ b/packages/happy-dom/src/nodes/html-iframe-element/IframeCrossOriginWindow.ts @@ -0,0 +1,49 @@ +import Location from '../../location/Location'; +import EventTarget from '../../event/EventTarget'; +import IWindow from '../../window/IWindow'; + +/** + * Browser window with limited access due to CORS restrictions in iframes. + */ +export default class IframeCrossOriginWindow extends EventTarget { + public readonly self = this; + public readonly window = this; + public readonly parent: IWindow; + public readonly top: IWindow; + + private _targetWindow: IWindow; + + /** + * Constructor. + * + * @param parent Parent window. + * @param target Target window. + */ + constructor(parent: IWindow, target: IWindow) { + super(); + + this.parent = parent; + this.top = parent; + this._targetWindow = target; + } + + /** + * Returns location. + * + * @returns Location. + */ + public get location(): Location { + return this._targetWindow.location; + } + + /** + * Safely enables cross-origin communication between Window objects; e.g., between a page and a pop-up that it spawned, or between a page and an iframe embedded within it. + * + * @param message Message. + * @param [targetOrigin=*] Target origin. + * @param transfer Transfer. Not implemented. + */ + public postMessage(message: unknown, targetOrigin = '*', transfer?: unknown[]): void { + this._targetWindow.postMessage(message, targetOrigin, transfer); + } +} diff --git a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts b/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts index b04dd4d17..6e3839edb 100644 --- a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts +++ b/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts @@ -250,13 +250,14 @@ export default class HTMLLinkElement extends HTMLElement implements IHTMLLinkEle /** * @override */ - public _connectToNode(parentNode: INode = null): void { + public override _connectToNode(parentNode: INode = null): void { const isConnected = this.isConnected; const isParentConnected = parentNode ? parentNode.isConnected : false; super._connectToNode(parentNode); if ( + isParentConnected && isConnected !== isParentConnected && this._evaluateCSS && !this.ownerDocument.defaultView.happyDOM.settings.disableCSSFileLoading diff --git a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts index 6affe075d..f14561f6d 100644 --- a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +++ b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts @@ -182,13 +182,13 @@ export default class HTMLScriptElement extends HTMLElement implements IHTMLScrip /** * @override */ - public _connectToNode(parentNode: INode = null): void { + public override _connectToNode(parentNode: INode = null): void { const isConnected = this.isConnected; const isParentConnected = parentNode ? parentNode.isConnected : false; super._connectToNode(parentNode); - if (isConnected !== isParentConnected && this._evaluateScript) { + if (isParentConnected && isConnected !== isParentConnected && this._evaluateScript) { const src = this.getAttributeNS(null, 'src'); if (src !== null) { diff --git a/packages/happy-dom/src/window/IHappyDOMOptions.ts b/packages/happy-dom/src/window/IHappyDOMOptions.ts new file mode 100644 index 000000000..5cf962447 --- /dev/null +++ b/packages/happy-dom/src/window/IHappyDOMOptions.ts @@ -0,0 +1,15 @@ +/** + * Happy DOM options. + */ +export default interface IHappyDOMOptions { + innerWidth?: number; + innerHeight?: number; + url?: string; + settings?: { + disableJavaScriptEvaluation?: boolean; + disableJavaScriptFileLoading?: boolean; + disableCSSFileLoading?: boolean; + disableIframePageLoading?: boolean; + enableFileSystemHttpRequests?: boolean; + }; +} diff --git a/packages/happy-dom/src/window/IHappyDOMSettings.ts b/packages/happy-dom/src/window/IHappyDOMSettings.ts index cb19d869f..9e9490c48 100644 --- a/packages/happy-dom/src/window/IHappyDOMSettings.ts +++ b/packages/happy-dom/src/window/IHappyDOMSettings.ts @@ -5,5 +5,6 @@ export default interface IHappyDOMSettings { disableJavaScriptEvaluation: boolean; disableJavaScriptFileLoading: boolean; disableCSSFileLoading: boolean; + disableIframePageLoading: boolean; enableFileSystemHttpRequests: boolean; } diff --git a/packages/happy-dom/src/window/IWindow.ts b/packages/happy-dom/src/window/IWindow.ts index 88e7db410..20820b2f9 100644 --- a/packages/happy-dom/src/window/IWindow.ts +++ b/packages/happy-dom/src/window/IWindow.ts @@ -24,6 +24,7 @@ import HTMLMediaElement from '../nodes/html-media-element/HTMLMediaElement'; import HTMLAudioElement from '../nodes/html-audio-element/HTMLAudioElement'; import HTMLVideoElement from '../nodes/html-video-element/HTMLVideoElement'; import HTMLBaseElement from '../nodes/html-base-element/HTMLBaseElement'; +import HTMLIframeElement from '../nodes/html-iframe-element/HTMLIframeElement'; import SVGSVGElement from '../nodes/svg-element/SVGSVGElement'; import SVGElement from '../nodes/svg-element/SVGElement'; import HTMLScriptElement from '../nodes/html-script-element/HTMLScriptElement'; @@ -73,6 +74,8 @@ import InputEvent from '../event/events/InputEvent'; import UIEvent from '../event/UIEvent'; import ErrorEvent from '../event/events/ErrorEvent'; import StorageEvent from '../event/events/StorageEvent'; +import MessageEvent from '../event/events/MessageEvent'; +import MessagePort from '../event/MessagePort'; import Screen from '../screen/Screen'; import AsyncTaskManager from '../async-task-manager/AsyncTaskManager'; import Storage from '../storage/Storage'; @@ -142,6 +145,7 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { readonly HTMLAudioElement: typeof HTMLAudioElement; readonly HTMLVideoElement: typeof HTMLVideoElement; readonly HTMLBaseElement: typeof HTMLBaseElement; + readonly HTMLIframeElement: typeof HTMLIframeElement; readonly HTMLDialogElement: typeof HTMLDialogElement; readonly Attr: typeof Attr; readonly NamedNodeMap: typeof NamedNodeMap; @@ -175,6 +179,8 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { readonly InputEvent: typeof InputEvent; readonly ErrorEvent: typeof ErrorEvent; readonly StorageEvent: typeof StorageEvent; + readonly MessageEvent: typeof MessageEvent; + readonly MessagePort: typeof MessagePort; readonly ProgressEvent: typeof ProgressEvent; readonly MediaQueryListEvent: typeof MediaQueryListEvent; readonly EventTarget: typeof EventTarget; @@ -374,4 +380,12 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { * @returns An ASCII string containing decoded data from encodedData. */ atob(data: unknown): string; + + /** + * Safely enables cross-origin communication between Window objects; e.g., between a page and a pop-up that it spawned, or between a page and an iframe embedded within it. + * + * @param message Message. + * @param listener Listener. + */ + postMessage(message: unknown, targetOrigin?: string, transfer?: unknown[]): void; } diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts index bcc72ec4b..5c7ce8f14 100644 --- a/packages/happy-dom/src/window/Window.ts +++ b/packages/happy-dom/src/window/Window.ts @@ -25,6 +25,7 @@ import HTMLMediaElement from '../nodes/html-media-element/HTMLMediaElement'; import HTMLAudioElement from '../nodes/html-audio-element/HTMLAudioElement'; import HTMLVideoElement from '../nodes/html-video-element/HTMLVideoElement'; import HTMLBaseElement from '../nodes/html-base-element/HTMLBaseElement'; +import HTMLIframeElement from '../nodes/html-iframe-element/HTMLIframeElement'; import HTMLDialogElement from '../nodes/html-dialog-element/HTMLDialogElement'; import SVGSVGElement from '../nodes/svg-element/SVGSVGElement'; import SVGElement from '../nodes/svg-element/SVGElement'; @@ -39,9 +40,11 @@ import Event from '../event/Event'; import CustomEvent from '../event/events/CustomEvent'; import AnimationEvent from '../event/events/AnimationEvent'; import KeyboardEvent from '../event/events/KeyboardEvent'; +import MessageEvent from '../event/events/MessageEvent'; import ProgressEvent from '../event/events/ProgressEvent'; import MediaQueryListEvent from '../event/events/MediaQueryListEvent'; import EventTarget from '../event/EventTarget'; +import MessagePort from '../event/MessagePort'; import { URL, URLSearchParams } from 'url'; import Location from '../location/Location'; import NonImplementedEventTypes from '../event/NonImplementedEventTypes'; @@ -115,9 +118,10 @@ import Attr from '../nodes/attr/Attr'; import NamedNodeMap from '../named-node-map/NamedNodeMap'; import IElement from '../nodes/element/IElement'; import ProcessingInstruction from '../nodes/processing-instruction/ProcessingInstruction'; -import IHappyDOMSettings from './IHappyDOMSettings'; import RequestInfo from '../fetch/RequestInfo'; import FileList from '../nodes/html-input-element/FileList'; +import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum'; +import IHappyDOMOptions from './IHappyDOMOptions'; const ORIGINAL_SET_TIMEOUT = setTimeout; const ORIGINAL_CLEAR_TIMEOUT = clearTimeout; @@ -159,6 +163,7 @@ export default class Window extends EventTarget implements IWindow { disableJavaScriptEvaluation: false, disableJavaScriptFileLoading: false, disableCSSFileLoading: false, + disableIframePageLoading: false, enableFileSystemHttpRequests: false } }; @@ -183,6 +188,7 @@ export default class Window extends EventTarget implements IWindow { public readonly HTMLAudioElement = HTMLAudioElement; public readonly HTMLVideoElement = HTMLVideoElement; public readonly HTMLBaseElement = HTMLBaseElement; + public readonly HTMLIframeElement = HTMLIframeElement; public readonly HTMLDialogElement = HTMLDialogElement; public readonly Attr = Attr; public readonly NamedNodeMap = NamedNodeMap; @@ -208,6 +214,7 @@ export default class Window extends EventTarget implements IWindow { public readonly CustomEvent = CustomEvent; public readonly AnimationEvent = AnimationEvent; public readonly KeyboardEvent = KeyboardEvent; + public readonly MessageEvent = MessageEvent; public readonly MouseEvent = MouseEvent; public readonly PointerEvent = PointerEvent; public readonly FocusEvent = FocusEvent; @@ -218,6 +225,7 @@ export default class Window extends EventTarget implements IWindow { public readonly ProgressEvent = ProgressEvent; public readonly MediaQueryListEvent = MediaQueryListEvent; public readonly EventTarget = EventTarget; + public readonly MessagePort = MessagePort; public readonly DataTransfer = DataTransfer; public readonly DataTransferItem = DataTransferItem; public readonly DataTransferItemList = DataTransferItemList; @@ -370,12 +378,7 @@ export default class Window extends EventTarget implements IWindow { * @param [options.url] URL. * @param [options.settings] Settings. */ - constructor(options?: { - innerWidth?: number; - innerHeight?: number; - url?: string; - settings?: IHappyDOMSettings; - }) { + constructor(options?: IHappyDOMOptions) { super(); this.customElements = new CustomElementRegistry(); @@ -732,6 +735,42 @@ export default class Window extends EventTarget implements IWindow { return Base64.atob(data); } + /** + * Safely enables cross-origin communication between Window objects; e.g., between a page and a pop-up that it spawned, or between a page and an iframe embedded within it. + * + * @param message Message. + * @param [targetOrigin=*] Target origin. + * @param _transfer Transfer. Not implemented. + */ + public postMessage(message: unknown, targetOrigin = '*', _transfer?: unknown[]): void { + // TODO: Implement transfer. + + if (targetOrigin && targetOrigin !== '*' && this.location.origin !== targetOrigin) { + throw new DOMException( + `Failed to execute 'postMessage' on 'Window': The target origin provided ('${targetOrigin}') does not match the recipient window\'s origin ('${this.location.origin}').`, + DOMExceptionNameEnum.securityError + ); + } + + try { + JSON.stringify(message); + } catch (error) { + throw new DOMException( + `Failed to execute 'postMessage' on 'Window': The provided message cannot be serialized.`, + DOMExceptionNameEnum.invalidStateError + ); + } + + this.dispatchEvent( + new MessageEvent('message', { + data: message, + origin: this.parent.location.origin, + source: this.parent, + lastEventId: '' + }) + ); + } + /** * Setup of VM context. */ 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 new file mode 100644 index 000000000..bae297f1a --- /dev/null +++ b/packages/happy-dom/test/nodes/html-iframe-element/HTMLIframeElement.test.ts @@ -0,0 +1,154 @@ +import Window from '../../../src/window/Window'; +import IWindow from '../../../src/window/IWindow'; +import IDocument from '../../../src/nodes/document/IDocument'; +import IHTMLIframeElement from '../../../src/nodes/html-iframe-element/IHTMLIframeElement'; +import IResponse from '../../../src/fetch/IResponse'; +import ErrorEvent from '../../../src/event/events/ErrorEvent'; +import IframeCrossOriginWindow from '../../../src/nodes/html-iframe-element/IframeCrossOriginWindow'; +import MessageEvent from '../../../src/event/events/MessageEvent'; + +describe('HTMLIframeElement', () => { + let window: IWindow; + let document: IDocument; + let element: IHTMLIframeElement; + + beforeEach(() => { + window = new Window(); + document = window.document; + element = document.createElement('iframe'); + }); + + describe('Object.prototype.toString', () => { + it('Returns `[object HTMLIframeElement]`', () => { + expect(Object.prototype.toString.call(element)).toBe('[object HTMLIframeElement]'); + }); + }); + + for (const property of ['src', 'allow', 'height', 'width', 'name', 'sandbox', 'srcdoc']) { + describe(`get ${property}()`, () => { + it(`Returns the "${property}" attribute.`, () => { + element.setAttribute(property, 'value'); + expect(element[property]).toBe('value'); + }); + }); + + describe(`set ${property}()`, () => { + it(`Sets the attribute "${property}".`, () => { + element[property] = 'value'; + expect(element.getAttribute(property)).toBe('value'); + }); + }); + } + + describe('get contentWindow()', () => { + it('Returns content window for "about:blank".', () => { + element.src = 'about:blank'; + expect(element.contentDocument).toBe(null); + document.body.appendChild(element); + expect(element.contentWindow === element.contentDocument.defaultView).toBe(true); + expect(element.contentDocument.documentElement.innerHTML).toBe(''); + }); + + it('Returns content window for "javascript:scroll(10, 20)".', () => { + element.src = 'javascript:scroll(10, 20)'; + document.body.appendChild(element); + expect(element.contentWindow === element.contentDocument.defaultView).toBe(true); + expect(element.contentDocument.documentElement.scrollLeft).toBe(10); + expect(element.contentDocument.documentElement.scrollTop).toBe(20); + }); + + it('Returns content window for URL with same origin.', (done) => { + const responseHTML = 'Test'; + let fetchedURL = null; + + jest.spyOn(window, 'fetch').mockImplementation((url: string) => { + fetchedURL = url; + return Promise.resolve({ + text: () => Promise.resolve(responseHTML), + ok: true + }); + }); + + window.happyDOM.setURL('https://localhost:8080'); + element.src = 'https://localhost:8080/iframe.html'; + element.addEventListener('load', () => { + expect(fetchedURL).toBe('https://localhost:8080/iframe.html'); + expect(element.contentWindow === element.contentDocument.defaultView).toBe(true); + expect(`${element.contentDocument.documentElement.innerHTML}`).toBe( + responseHTML + ); + done(); + }); + document.body.appendChild(element); + }); + + it('Returns instance of IframeCrossOriginWindow for URL with different origin.', (done) => { + const iframeOrigin = 'https://other.origin.com'; + const iframeSrc = iframeOrigin + '/iframe.html'; + const documentOrigin = 'https://localhost:8080'; + let fetchedURL = null; + + jest.spyOn(window, 'fetch').mockImplementation((url: string) => { + fetchedURL = url; + return Promise.resolve({ + text: () => Promise.resolve('Test'), + ok: true + }); + }); + + window.happyDOM.setURL(documentOrigin); + element.src = iframeSrc; + element.addEventListener('load', () => { + const message = 'test'; + let triggeredEvent: MessageEvent | null = null; + expect(fetchedURL).toBe(iframeSrc); + expect(element.contentWindow instanceof IframeCrossOriginWindow).toBe(true); + expect(element.contentWindow.location.href).toBe(iframeSrc); + expect(element.contentWindow.self === element.contentWindow).toBe(true); + expect(element.contentWindow.window === element.contentWindow).toBe(true); + expect(element.contentWindow.parent === window).toBe(true); + expect(element.contentWindow.top === window).toBe(true); + element.contentWindow['_targetWindow'].addEventListener( + 'message', + (event: MessageEvent) => (triggeredEvent = event) + ); + element.contentWindow.postMessage(message, iframeOrigin); + expect(element.contentDocument).toBe(null); + expect(triggeredEvent.data).toBe(message); + expect(triggeredEvent.origin).toBe(documentOrigin); + expect(triggeredEvent.source === window).toBe(true); + expect(triggeredEvent.lastEventId).toBe(''); + done(); + }); + document.body.appendChild(element); + }); + + it('Dispatches an error event when the page fails to load.', (done) => { + const error = new Error('Error'); + + jest.spyOn(window, 'fetch').mockImplementation(() => { + return Promise.resolve({ + text: () => Promise.reject(error), + ok: true + }); + }); + + element.src = 'https://localhost:8080/iframe.html'; + element.addEventListener('error', (event: ErrorEvent) => { + expect(event.message).toBe(error.message); + expect(event.error).toBe(error); + done(); + }); + document.body.appendChild(element); + }); + }); + + describe('get contentDocument()', () => { + it('Returns content document for "about:blank".', () => { + element.src = 'about:blank'; + expect(element.contentDocument).toBe(null); + document.body.appendChild(element); + expect(element.contentDocument.documentElement.innerHTML).toBe(''); + }); + }); +}); diff --git a/packages/happy-dom/test/window/Window.test.ts b/packages/happy-dom/test/window/Window.test.ts index c50543402..0cbfb8505 100644 --- a/packages/happy-dom/test/window/Window.test.ts +++ b/packages/happy-dom/test/window/Window.test.ts @@ -13,6 +13,7 @@ import DOMException from '../../src/exception/DOMException'; import DOMExceptionNameEnum from '../../src/exception/DOMExceptionNameEnum'; import CustomElement from '../../test/CustomElement'; import { URL } from 'url'; +import MessageEvent from '../../src/event/events/MessageEvent'; describe('Window', () => { let window: IWindow; @@ -973,4 +974,68 @@ describe('Window', () => { ); }); }); + + describe('postMessage()', () => { + it('Posts a message.', function () { + const message = 'test'; + const parentOrigin = 'https://localhost:8080'; + const parent = new Window({ + url: parentOrigin + }); + let triggeredEvent: MessageEvent | null = null; + + (window.parent) = parent; + + window.addEventListener('message', (event) => (triggeredEvent = event)); + window.postMessage(message); + + expect(triggeredEvent.data).toBe(message); + expect(triggeredEvent.origin).toBe(parentOrigin); + expect(triggeredEvent.source).toBe(parent); + expect(triggeredEvent.lastEventId).toBe(''); + + window.postMessage(message, '*'); + + expect(triggeredEvent.data).toBe(message); + expect(triggeredEvent.origin).toBe(parentOrigin); + expect(triggeredEvent.source).toBe(parent); + expect(triggeredEvent.lastEventId).toBe(''); + }); + + it('Posts a data object as message.', function () { + const message = { + test: 'test' + }; + let triggeredEvent: MessageEvent | null = null; + + window.addEventListener('message', (event) => (triggeredEvent = event)); + window.postMessage(message); + + expect(triggeredEvent.data).toBe(message); + }); + + it("Throws an exception if the provided object can't be serialized.", function () { + expect(() => window.postMessage(window)).toThrowError( + new DOMException( + `Failed to execute 'postMessage' on 'Window': The provided message cannot be serialized.`, + DOMExceptionNameEnum.invalidStateError + ) + ); + }); + + it('Throws an exception if the target origin differs from the document origin.', function () { + const message = 'test'; + const targetOrigin = 'https://localhost:8081'; + const documentOrigin = 'https://localhost:8080'; + + window.happyDOM.setURL(documentOrigin); + + expect(() => window.postMessage(message, targetOrigin)).toThrowError( + new DOMException( + `Failed to execute 'postMessage' on 'Window': The target origin provided ('${targetOrigin}') does not match the recipient window\'s origin ('${documentOrigin}').`, + DOMExceptionNameEnum.securityError + ) + ); + }); + }); });