diff --git a/.eslintignore b/.eslintignore index debffe068..367c10e45 100755 --- a/.eslintignore +++ b/.eslintignore @@ -2,4 +2,5 @@ **/tmp **/lib **/cjs -**/tmp \ No newline at end of file +**/tmp +**/test/**/modules-with-compilation-error \ No newline at end of file diff --git a/packages/happy-dom/src/PropertySymbol.ts b/packages/happy-dom/src/PropertySymbol.ts index be9bee7b5..6d490ce22 100644 --- a/packages/happy-dom/src/PropertySymbol.ts +++ b/packages/happy-dom/src/PropertySymbol.ts @@ -379,3 +379,15 @@ export const root = Symbol('root'); export const filterNode = Symbol('filterNode'); export const customElementReactionStack = Symbol('customElementReactionStack'); export const dispatching = Symbol('dispatching'); +export const modules = Symbol('modules'); +export const preloads = Symbol('preloads'); +export const body = Symbol('body'); +export const redirect = Symbol('redirect'); +export const referrerPolicy = Symbol('referrerPolicy'); +export const signal = Symbol('signal'); +export const bodyUsed = Symbol('bodyUsed'); +export const credentials = Symbol('credentials'); +export const blocking = Symbol('blocking'); +export const moduleImportMap = Symbol('moduleImportMap'); +export const dispatchError = Symbol('dispatchError'); +export const supports = Symbol('supports'); diff --git a/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts b/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts index 76918c7e9..3f434d7ed 100644 --- a/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts +++ b/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts @@ -16,11 +16,16 @@ export default class AsyncTaskManager { private runningTaskCount = 0; private runningTimers: NodeJS.Timeout[] = []; private runningImmediates: NodeJS.Immediate[] = []; + private debugTrace: Map = new Map(); private waitUntilCompleteTimer: NodeJS.Timeout | null = null; - private waitUntilCompleteResolvers: Array<() => void> = []; + private waitUntilCompleteResolvers: Array<{ + resolve: () => void; + reject: (error: Error) => void; + }> = []; private aborted = false; private destroyed = false; #browserFrame: IBrowserFrame; + #debugTimeout: NodeJS.Timeout | null; /** * Constructor. @@ -37,8 +42,8 @@ export default class AsyncTaskManager { * @returns Promise. */ public waitUntilComplete(): Promise { - return new Promise((resolve) => { - this.waitUntilCompleteResolvers.push(resolve); + return new Promise((resolve, reject) => { + this.waitUntilCompleteResolvers.push({ resolve, reject }); this.resolveWhenComplete(); }); } @@ -48,8 +53,8 @@ export default class AsyncTaskManager { */ public abort(): Promise { if (this.aborted) { - return new Promise((resolve) => { - this.waitUntilCompleteResolvers.push(resolve); + return new Promise((resolve, reject) => { + this.waitUntilCompleteResolvers.push({ resolve, reject }); this.resolveWhenComplete(); }); } @@ -61,8 +66,8 @@ export default class AsyncTaskManager { */ public destroy(): Promise { if (this.aborted) { - return new Promise((resolve) => { - this.waitUntilCompleteResolvers.push(resolve); + return new Promise((resolve, reject) => { + this.waitUntilCompleteResolvers.push({ resolve, reject }); this.resolveWhenComplete(); }); } @@ -84,6 +89,9 @@ export default class AsyncTaskManager { this.waitUntilCompleteTimer = null; } this.runningTimers.push(timerID); + if (this.#browserFrame.page?.context?.browser?.settings?.debug?.traceWaitUntilComplete > 0) { + this.debugTrace.set(timerID, new Error().stack); + } } /** @@ -99,9 +107,10 @@ export default class AsyncTaskManager { const index = this.runningTimers.indexOf(timerID); if (index !== -1) { this.runningTimers.splice(index, 1); - if (!this.runningTaskCount && !this.runningTimers.length && !this.runningImmediates.length) { - this.resolveWhenComplete(); - } + this.resolveWhenComplete(); + } + if (this.#browserFrame.page?.context?.browser?.settings?.debug?.traceWaitUntilComplete > 0) { + this.debugTrace.delete(timerID); } } @@ -120,6 +129,9 @@ export default class AsyncTaskManager { this.waitUntilCompleteTimer = null; } this.runningImmediates.push(immediateID); + if (this.#browserFrame.page?.context?.browser?.settings?.debug?.traceWaitUntilComplete > 0) { + this.debugTrace.set(immediateID, new Error().stack); + } } /** @@ -135,9 +147,10 @@ export default class AsyncTaskManager { const index = this.runningImmediates.indexOf(immediateID); if (index !== -1) { this.runningImmediates.splice(index, 1); - if (!this.runningTaskCount && !this.runningTimers.length && !this.runningImmediates.length) { - this.resolveWhenComplete(); - } + this.resolveWhenComplete(); + } + if (this.#browserFrame.page?.context?.browser?.settings?.debug?.traceWaitUntilComplete > 0) { + this.debugTrace.delete(immediateID); } } @@ -163,6 +176,9 @@ export default class AsyncTaskManager { const taskID = this.newTaskID(); this.runningTasks[taskID] = abortHandler ? abortHandler : () => {}; this.runningTaskCount++; + if (this.#browserFrame.page?.context?.browser?.settings?.debug?.traceWaitUntilComplete > 0) { + this.debugTrace.set(taskID, new Error().stack); + } return taskID; } @@ -178,9 +194,10 @@ export default class AsyncTaskManager { if (this.runningTasks[taskID]) { delete this.runningTasks[taskID]; this.runningTaskCount--; - if (!this.runningTaskCount && !this.runningTimers.length && !this.runningImmediates.length) { - this.resolveWhenComplete(); - } + this.resolveWhenComplete(); + } + if (this.#browserFrame.page?.context?.browser?.settings?.debug?.traceWaitUntilComplete > 0) { + this.debugTrace.delete(taskID); } } @@ -207,6 +224,8 @@ export default class AsyncTaskManager { * Resolves when complete. */ private resolveWhenComplete(): void { + this.applyDebugging(); + if (this.runningTaskCount || this.runningTimers.length || this.runningImmediates.length) { return; } @@ -222,16 +241,58 @@ export default class AsyncTaskManager { this.waitUntilCompleteTimer = TIMER.setTimeout(() => { this.waitUntilCompleteTimer = null; if (!this.runningTaskCount && !this.runningTimers.length && !this.runningImmediates.length) { + if (this.#debugTimeout) { + TIMER.clearTimeout(this.#debugTimeout); + } const resolvers = this.waitUntilCompleteResolvers; this.waitUntilCompleteResolvers = []; for (const resolver of resolvers) { - resolver(); + resolver.resolve(); } this.aborted = false; + } else { + this.applyDebugging(); } }, 1); } + /** + * Applies debugging. + */ + private applyDebugging(): void { + const debug = this.#browserFrame.page?.context?.browser?.settings?.debug; + if (!debug?.traceWaitUntilComplete || debug.traceWaitUntilComplete < 1) { + return; + } + if (this.#debugTimeout) { + return; + } + this.#debugTimeout = TIMER.setTimeout(() => { + this.#debugTimeout = null; + + let errorMessage = `The maximum time was reached for "waitUntilComplete()".\n\n${ + this.debugTrace.size + } task${ + this.debugTrace.size === 1 ? '' : 's' + } did not end in time.\n\nThe following traces were recorded:\n\n`; + + for (const [key, value] of this.debugTrace.entries()) { + const type = typeof key === 'number' ? 'Task' : 'Timer'; + errorMessage += `${type} #${key}\n‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾${value + .replace(/Error:/, '') + .replace(/\s+at /gm, '\n> ')}\n\n`; + } + + const error = new Error(errorMessage); + + for (const resolver of this.waitUntilCompleteResolvers) { + resolver.reject(error); + } + + this.abortAll(true); + }, debug.traceWaitUntilComplete); + } + /** * Aborts all tasks. * @@ -248,6 +309,7 @@ export default class AsyncTaskManager { this.runningTaskCount = 0; this.runningImmediates = []; this.runningTimers = []; + this.debugTrace = new Map(); if (this.waitUntilCompleteTimer) { TIMER.clearTimeout(this.waitUntilCompleteTimer); @@ -267,8 +329,8 @@ export default class AsyncTaskManager { } // We need to wait for microtasks to complete before resolving. - return new Promise((resolve) => { - this.waitUntilCompleteResolvers.push(resolve); + return new Promise((resolve, reject) => { + this.waitUntilCompleteResolvers.push({ resolve, reject }); this.resolveWhenComplete(); }); } diff --git a/packages/happy-dom/src/browser/BrowserSettingsFactory.ts b/packages/happy-dom/src/browser/BrowserSettingsFactory.ts index 438de64e3..d06d9c4d2 100644 --- a/packages/happy-dom/src/browser/BrowserSettingsFactory.ts +++ b/packages/happy-dom/src/browser/BrowserSettingsFactory.ts @@ -36,6 +36,10 @@ export default class BrowserSettingsFactory { device: { ...DefaultBrowserSettings.device, ...settings?.device + }, + debug: { + ...DefaultBrowserSettings.debug, + ...settings?.debug } }; } diff --git a/packages/happy-dom/src/browser/DefaultBrowserSettings.ts b/packages/happy-dom/src/browser/DefaultBrowserSettings.ts index e351c2c3f..4a336ee4b 100644 --- a/packages/happy-dom/src/browser/DefaultBrowserSettings.ts +++ b/packages/happy-dom/src/browser/DefaultBrowserSettings.ts @@ -39,5 +39,8 @@ export default { device: { prefersColorScheme: 'light', mediaType: 'screen' + }, + debug: { + traceWaitUntilComplete: -1 } }; diff --git a/packages/happy-dom/src/browser/types/IBrowserSettings.ts b/packages/happy-dom/src/browser/types/IBrowserSettings.ts index 18c40113a..6e55c0941 100644 --- a/packages/happy-dom/src/browser/types/IBrowserSettings.ts +++ b/packages/happy-dom/src/browser/types/IBrowserSettings.ts @@ -111,4 +111,11 @@ export default interface IBrowserSettings { prefersColorScheme: string; mediaType: string; }; + + /** + * Debug settings. + */ + debug: { + traceWaitUntilComplete: number; + }; } diff --git a/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts b/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts index 933ff2113..42946e1be 100644 --- a/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts +++ b/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts @@ -105,4 +105,11 @@ export default interface IOptionalBrowserSettings { prefersColorScheme?: string; mediaType?: string; }; + + /** + * Debug settings. + */ + debug?: { + traceWaitUntilComplete?: number; + }; } diff --git a/packages/happy-dom/src/browser/utilities/BrowserFrameNavigator.ts b/packages/happy-dom/src/browser/utilities/BrowserFrameNavigator.ts index 97e20272a..5a2d15998 100644 --- a/packages/happy-dom/src/browser/utilities/BrowserFrameNavigator.ts +++ b/packages/happy-dom/src/browser/utilities/BrowserFrameNavigator.ts @@ -2,7 +2,6 @@ import IBrowserFrame from '../types/IBrowserFrame.js'; import * as PropertySymbol from '../../PropertySymbol.js'; import IGoToOptions from '../types/IGoToOptions.js'; import Response from '../../fetch/Response.js'; -import DocumentReadyStateManager from '../../nodes/document/DocumentReadyStateManager.js'; import BrowserWindow from '../../window/BrowserWindow.js'; import BrowserFrameFactory from './BrowserFrameFactory.js'; import BrowserFrameURL from './BrowserFrameURL.js'; @@ -61,9 +60,7 @@ export default class BrowserFrameNavigator { // Javascript protocol if (targetURL.protocol === 'javascript:') { if (frame && !frame.page.context.browser.settings.disableJavaScriptEvaluation) { - const readyStateManager = (< - { [PropertySymbol.readyStateManager]: DocumentReadyStateManager } - >(frame.window))[PropertySymbol.readyStateManager]; + const readyStateManager = frame.window[PropertySymbol.readyStateManager]; readyStateManager.startTask(); const code = @@ -178,9 +175,7 @@ export default class BrowserFrameNavigator { } // Start navigation - const readyStateManager = (<{ [PropertySymbol.readyStateManager]: DocumentReadyStateManager }>( - (frame.window) - ))[PropertySymbol.readyStateManager]; + const readyStateManager = frame.window[PropertySymbol.readyStateManager]; const abortController = new frame.window.AbortController(); const timeout = frame.window.setTimeout( () => abortController.abort(new Error('Request timed out.')), diff --git a/packages/happy-dom/src/dom/DOMTokenList.ts b/packages/happy-dom/src/dom/DOMTokenList.ts index 12ff8add7..ee9921bc2 100644 --- a/packages/happy-dom/src/dom/DOMTokenList.ts +++ b/packages/happy-dom/src/dom/DOMTokenList.ts @@ -19,6 +19,7 @@ export default class DOMTokenList { items: [], attributeValue: '' }; + private [PropertySymbol.supports]: string[]; /** * Constructor. @@ -26,14 +27,21 @@ export default class DOMTokenList { * @param illegalConstructorSymbol Illegal constructor symbol. * @param ownerElement Owner element. * @param attributeName Attribute name. + * @param [supports] Supports. */ - constructor(illegalConstructorSymbol: symbol, ownerElement: Element, attributeName: string) { + constructor( + illegalConstructorSymbol: symbol, + ownerElement: Element, + attributeName: string, + supports?: string[] + ) { if (illegalConstructorSymbol !== PropertySymbol.illegalConstructor) { throw new TypeError('Illegal constructor'); } this[PropertySymbol.ownerElement] = ownerElement; this[PropertySymbol.attributeName] = attributeName; + this[PropertySymbol.supports] = supports || []; const methodBinder = new ClassMethodBinder(this, [DOMTokenList]); @@ -189,10 +197,10 @@ export default class DOMTokenList { /** * Supports. * - * @param _token Token. + * @param token Token. */ - public supports(_token: string): boolean { - return false; + public supports(token: string): boolean { + return this[PropertySymbol.supports].includes(token); } /** diff --git a/packages/happy-dom/src/event/EventTarget.ts b/packages/happy-dom/src/event/EventTarget.ts index f93341c20..c051d4d6c 100644 --- a/packages/happy-dom/src/event/EventTarget.ts +++ b/packages/happy-dom/src/event/EventTarget.ts @@ -2,7 +2,6 @@ import * as PropertySymbol from '../PropertySymbol.js'; import Event from './Event.js'; import IEventListenerOptions from './IEventListenerOptions.js'; import EventPhaseEnum from './EventPhaseEnum.js'; -import WindowErrorUtility from '../window/WindowErrorUtility.js'; import WindowBrowserContext from '../window/WindowBrowserContext.js'; import BrowserErrorCaptureEnum from '../browser/enums/BrowserErrorCaptureEnum.js'; import TEventListener from './TEventListener.js'; @@ -239,7 +238,16 @@ export default class EventTarget { !browserSettings?.disableErrorCapturing && browserSettings?.errorCapture === BrowserErrorCaptureEnum.tryAndCatch ) { - WindowErrorUtility.captureError(window, this[onEventName].bind(this, event)); + let result: any; + try { + result = this[onEventName].call(this, event); + } catch (error) { + window[PropertySymbol.dispatchError](error); + } + + if (result instanceof Promise) { + result.catch((error) => window[PropertySymbol.dispatchError](error)); + } } else { this[onEventName].call(this, event); } @@ -273,19 +281,31 @@ export default class EventTarget { browserSettings?.errorCapture === BrowserErrorCaptureEnum.tryAndCatch ) { if ((listener).handleEvent) { - WindowErrorUtility.captureError( - window, - (listener).handleEvent.bind(listener, event) - ); + let result: any; + try { + result = (listener).handleEvent.call(listener, event); + } catch (error) { + window[PropertySymbol.dispatchError](error); + } + + if (result instanceof Promise) { + result.catch((error) => window[PropertySymbol.dispatchError](error)); + } } else { - WindowErrorUtility.captureError( - window, - (listener).bind(this, event) - ); + let result: any; + try { + result = (listener).call(this, event); + } catch (error) { + window[PropertySymbol.dispatchError](error); + } + + if (result instanceof Promise) { + result.catch((error) => window[PropertySymbol.dispatchError](error)); + } } } else { if ((listener).handleEvent) { - (listener).handleEvent(event); + (listener).handleEvent.call(this, event); } else { (listener).call(this, event); } diff --git a/packages/happy-dom/src/fetch/Fetch.ts b/packages/happy-dom/src/fetch/Fetch.ts index c1e34eea8..ab8e0797b 100644 --- a/packages/happy-dom/src/fetch/Fetch.ts +++ b/packages/happy-dom/src/fetch/Fetch.ts @@ -7,7 +7,7 @@ import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js'; import HTTP, { IncomingMessage } from 'http'; import HTTPS from 'https'; import Zlib from 'zlib'; -import URL from '../url/URL.js'; +import { URL } from 'url'; import FS from 'fs'; import Path from 'path'; import { Socket } from 'net'; @@ -30,6 +30,7 @@ import { Buffer } from 'buffer'; import FetchBodyUtility from './utilities/FetchBodyUtility.js'; import IFetchInterceptor from './types/IFetchInterceptor.js'; import VirtualServerUtility from './utilities/VirtualServerUtility.js'; +import PreloadUtility from './preload/PreloadUtility.js'; const LAST_CHUNK = Buffer.from('0\r\n\r\n'); @@ -59,6 +60,7 @@ export default class Fetch { private redirectCount = 0; private disableCache: boolean; private disableSameOriginPolicy: boolean; + private disablePreload: boolean; #browserFrame: IBrowserFrame; #window: BrowserWindow; #unfilteredHeaders: Headers | null = null; @@ -76,6 +78,7 @@ export default class Fetch { * @param [options.disableCache] Disables the use of cached responses. It will still store the response in the cache. * @param [options.disableSameOriginPolicy] Disables the Same-Origin policy. * @param [options.unfilteredHeaders] Unfiltered headers - necessary for preflight requests. + * @param [options.disablePreload] Disables the use of preloaded responses. */ constructor(options: { browserFrame: IBrowserFrame; @@ -87,6 +90,7 @@ export default class Fetch { disableCache?: boolean; disableSameOriginPolicy?: boolean; unfilteredHeaders?: Headers; + disablePreload?: boolean; }) { this.#browserFrame = options.browserFrame; this.#window = options.window; @@ -105,6 +109,7 @@ export default class Fetch { this.#browserFrame.page.context.browser.settings.fetch.disableSameOriginPolicy ?? false; this.interceptor = this.#browserFrame.page.context.browser.settings.fetch.interceptor; + this.disablePreload = options.disablePreload ?? false; } /** @@ -129,12 +134,6 @@ export default class Fetch { FetchRequestValidationUtility.validateSchema(this.request); - const virtualServerResponse = await this.getVirtualServerResponse(); - - if (virtualServerResponse) { - return virtualServerResponse; - } - if (this.request.signal.aborted) { throw new this.#window.DOMException( 'The operation was aborted.', @@ -180,6 +179,38 @@ export default class Fetch { } } + if (!this.disablePreload) { + const preloadKey = PreloadUtility.getKey({ + url: this.request.url, + destination: 'fetch', + mode: this.request.mode, + credentialsMode: this.request.credentials + }); + + const preloadEntry = this.#window.document[PropertySymbol.preloads].get(preloadKey); + + if (preloadEntry) { + this.#window.document[PropertySymbol.preloads].delete(preloadKey); + + if (preloadEntry.response) { + return preloadEntry.response; + } + + const taskID = this.#browserFrame[PropertySymbol.asyncTaskManager].startTask(); + const response = await preloadEntry.onResponseAvailable(); + + this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); + + return response; + } + } + + const virtualServerResponse = await this.getVirtualServerResponse(); + + if (virtualServerResponse) { + return virtualServerResponse; + } + if (!this.disableSameOriginPolicy) { const compliesWithCrossOriginPolicy = await this.compliesWithCrossOriginPolicy(); @@ -295,14 +326,23 @@ export default class Fetch { return null; } + const taskID = this.#browserFrame[PropertySymbol.asyncTaskManager].startTask(); + if (this.request.method !== 'GET') { this.#browserFrame?.page?.console.error( `${this.request.method} ${this.request.url} 404 (Not Found)` ); - return VirtualServerUtility.getNotFoundResponse(this.#window); + const response = VirtualServerUtility.getNotFoundResponse(this.#window); + const interceptedResponse = this.interceptor?.afterAsyncResponse + ? await this.interceptor.afterAsyncResponse({ + window: this.#window, + response: await response, + request: this.request + }) + : undefined; + this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); + return interceptedResponse instanceof Response ? interceptedResponse : response; } - - const taskID = this.#browserFrame[PropertySymbol.asyncTaskManager].startTask(); let buffer: Buffer; try { @@ -315,13 +355,18 @@ export default class Fetch { `${this.request.method} ${this.request.url} 404 (Not Found)` ); + const response = VirtualServerUtility.getNotFoundResponse(this.#window); + const interceptedResponse = this.interceptor?.afterAsyncResponse + ? await this.interceptor.afterAsyncResponse({ + window: this.#window, + response: await response, + request: this.request + }) + : undefined; this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); - - return VirtualServerUtility.getNotFoundResponse(this.#window); + return interceptedResponse instanceof Response ? interceptedResponse : response; } - this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); - const body = new this.#window.ReadableStream({ start(controller) { setTimeout(() => { @@ -332,11 +377,32 @@ export default class Fetch { }); const response = new this.#window.Response(body); - response[PropertySymbol.buffer] = buffer; (response.url) = this.request.url; - return response; + const interceptedResponse = this.interceptor?.afterAsyncResponse + ? await this.interceptor.afterAsyncResponse({ + window: this.#window, + response: await response, + request: this.request + }) + : undefined; + + this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); + + const returnResponse = interceptedResponse instanceof Response ? interceptedResponse : response; + const cachedResponse = { + ...returnResponse, + body: buffer, + waitingForBody: false + }; + + response[PropertySymbol.cachedResponse] = this.#browserFrame.page?.context?.responseCache.add( + this.request, + cachedResponse + ); + + return returnResponse; } /** diff --git a/packages/happy-dom/src/fetch/Request.ts b/packages/happy-dom/src/fetch/Request.ts index 87bc40010..7810e8d02 100644 --- a/packages/happy-dom/src/fetch/Request.ts +++ b/packages/happy-dom/src/fetch/Request.ts @@ -1,6 +1,6 @@ import * as PropertySymbol from '../PropertySymbol.js'; import IRequestInit from './types/IRequestInit.js'; -import URL from '../url/URL.js'; +import { URL } from 'url'; import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js'; import IRequestInfo from './types/IRequestInfo.js'; import Headers from './Headers.js'; @@ -18,6 +18,7 @@ import FormData from '../form-data/FormData.js'; import MultipartFormDataParser from './multipart/MultipartFormDataParser.js'; import BrowserWindow from '../window/BrowserWindow.js'; import WindowBrowserContext from '../window/WindowBrowserContext.js'; +import IRequestMode from './types/IRequestMode.js'; /** * Fetch request. @@ -32,14 +33,15 @@ export default class Request implements Request { protected declare [PropertySymbol.window]: BrowserWindow; // Public properties - public readonly method: string; - public readonly body: ReadableStream | null; - public readonly headers: Headers; - public readonly redirect: IRequestRedirect; - public readonly referrerPolicy: IRequestReferrerPolicy; - public readonly signal: AbortSignal; - public readonly bodyUsed: boolean = false; - public readonly credentials: IRequestCredentials; + public [PropertySymbol.method]: string; + public [PropertySymbol.body]: ReadableStream | null; + public [PropertySymbol.mode]: IRequestMode = 'cors'; + public [PropertySymbol.headers]: Headers; + public [PropertySymbol.redirect]: IRequestRedirect; + public [PropertySymbol.referrerPolicy]: IRequestReferrerPolicy; + public [PropertySymbol.signal]: AbortSignal; + public [PropertySymbol.bodyUsed]: boolean = false; + public [PropertySymbol.credentials]: IRequestCredentials; // Internal properties public [PropertySymbol.aborted]: boolean = false; @@ -70,7 +72,30 @@ export default class Request implements Request { ); } - this.method = (init?.method || (input).method || 'GET').toUpperCase(); + this[PropertySymbol.method] = (init?.method || (input).method || 'GET').toUpperCase(); + + if (init?.mode) { + switch (init.mode) { + case 'navigate': + case 'websocket': + throw new window.DOMException( + `Failed to construct 'Request': Cannot construct a Request with a RequestInit whose mode member is set as '${init.mode}'.`, + DOMExceptionNameEnum.securityError + ); + case 'same-origin': + case 'no-cors': + case 'cors': + this[PropertySymbol.mode] = init.mode; + break; + default: + throw new window.DOMException( + `Failed to construct 'Request': The provided value '${init.mode}' is not a valid enum value of type RequestMode.`, + DOMExceptionNameEnum.syntaxError + ); + } + } else if (input instanceof Request) { + this[PropertySymbol.mode] = input.mode; + } const { stream, buffer, contentType, contentLength } = FetchBodyUtility.getBodyStream( input instanceof Request && (input[PropertySymbol.bodyBuffer] || input.body) @@ -79,9 +104,10 @@ export default class Request implements Request { ); this[PropertySymbol.bodyBuffer] = buffer; - this.body = stream; - this.credentials = init?.credentials || (input).credentials || 'same-origin'; - this.headers = new Headers(init?.headers || (input).headers || {}); + this[PropertySymbol.body] = stream; + this[PropertySymbol.credentials] = + init?.credentials || (input).credentials || 'same-origin'; + this[PropertySymbol.headers] = new Headers(init?.headers || (input).headers || {}); FetchRequestHeaderUtility.removeForbiddenHeaders(this.headers); @@ -100,11 +126,12 @@ export default class Request implements Request { this[PropertySymbol.contentType] = input[PropertySymbol.contentType]; } - this.redirect = init?.redirect || (input).redirect || 'follow'; - this.referrerPolicy = ( + this[PropertySymbol.redirect] = init?.redirect || (input).redirect || 'follow'; + this[PropertySymbol.referrerPolicy] = ( (init?.referrerPolicy || (input).referrerPolicy || '').toLowerCase() ); - this.signal = init?.signal || (input).signal || new window.AbortSignal(); + this[PropertySymbol.signal] = + init?.signal || (input).signal || new window.AbortSignal(); this[PropertySymbol.referrer] = FetchRequestReferrerUtility.getInitialReferrer( window, init?.referrer !== null && init?.referrer !== undefined @@ -142,6 +169,87 @@ export default class Request implements Request { FetchRequestValidationUtility.validateRedirect(this.redirect); } + /** + * Returns method. + * + * @returns Method. + */ + public get method(): string { + return this[PropertySymbol.method]; + } + + /** + * Returns body. + * + * @returns Body. + */ + public get body(): ReadableStream | null { + return this[PropertySymbol.body]; + } + + /** + * Returns mode. + * + * @returns Mode. + */ + public get mode(): IRequestMode { + return this[PropertySymbol.mode]; + } + + /** + * Returns headers. + * + * @returns Headers. + */ + public get headers(): Headers { + return this[PropertySymbol.headers]; + } + + /** + * Returns redirect. + * + * @returns Redirect. + */ + public get redirect(): IRequestRedirect { + return this[PropertySymbol.redirect]; + } + + /** + * Returns referrer policy. + * + * @returns Referrer policy. + */ + public get referrerPolicy(): IRequestReferrerPolicy { + return this[PropertySymbol.referrerPolicy]; + } + + /** + * Returns signal. + * + * @returns Signal. + */ + public get signal(): AbortSignal { + return this[PropertySymbol.signal]; + } + + /** + * Returns body used. + * + * @returns Body used. + */ + public get bodyUsed(): boolean { + return this[PropertySymbol.bodyUsed]; + } + + /** + * Returns credentials. + * + * @returns Credentials. + */ + public get credentials(): IRequestCredentials { + return this[PropertySymbol.credentials]; + } + /** * Returns referrer. * @@ -185,7 +293,7 @@ export default class Request implements Request { public async arrayBuffer(): Promise { const window = this[PropertySymbol.window]; - if (this.bodyUsed) { + if (this[PropertySymbol.bodyUsed]) { throw new window.DOMException( `Body has already been used for "${this.url}".`, DOMExceptionNameEnum.invalidStateError @@ -194,7 +302,7 @@ export default class Request implements Request { const asyncTaskManager = new WindowBrowserContext(window).getAsyncTaskManager(); - (this.bodyUsed) = true; + this[PropertySymbol.bodyUsed] = true; const taskID = asyncTaskManager.startTask(() => { if (this.body) { @@ -236,7 +344,7 @@ export default class Request implements Request { public async buffer(): Promise { const window = this[PropertySymbol.window]; - if (this.bodyUsed) { + if (this[PropertySymbol.bodyUsed]) { throw new window.DOMException( `Body has already been used for "${this.url}".`, DOMExceptionNameEnum.invalidStateError @@ -245,7 +353,7 @@ export default class Request implements Request { const asyncTaskManager = new WindowBrowserContext(window).getAsyncTaskManager(); - (this.bodyUsed) = true; + this[PropertySymbol.bodyUsed] = true; const taskID = asyncTaskManager.startTask(() => { if (this.body) { @@ -275,7 +383,7 @@ export default class Request implements Request { public async text(): Promise { const window = this[PropertySymbol.window]; - if (this.bodyUsed) { + if (this[PropertySymbol.bodyUsed]) { throw new window.DOMException( `Body has already been used for "${this.url}".`, DOMExceptionNameEnum.invalidStateError @@ -284,7 +392,7 @@ export default class Request implements Request { const asyncTaskManager = new WindowBrowserContext(window).getAsyncTaskManager(); - (this.bodyUsed) = true; + this[PropertySymbol.bodyUsed] = true; const taskID = asyncTaskManager.startTask(() => { if (this.body) { @@ -327,14 +435,14 @@ export default class Request implements Request { const contentType = this[PropertySymbol.contentType]; if (/multipart/i.test(contentType)) { - if (this.bodyUsed) { + if (this[PropertySymbol.bodyUsed]) { throw new window.DOMException( `Body has already been used for "${this.url}".`, DOMExceptionNameEnum.invalidStateError ); } - (this.bodyUsed) = true; + this[PropertySymbol.bodyUsed] = true; const taskID = asyncTaskManager.startTask(() => { if (this.body) { diff --git a/packages/happy-dom/src/fetch/ResourceFetch.ts b/packages/happy-dom/src/fetch/ResourceFetch.ts index 2e63cef74..f7ebc202d 100644 --- a/packages/happy-dom/src/fetch/ResourceFetch.ts +++ b/packages/happy-dom/src/fetch/ResourceFetch.ts @@ -1,41 +1,84 @@ import BrowserWindow from '../window/BrowserWindow.js'; import URL from '../url/URL.js'; -import IBrowserFrame from '../browser/types/IBrowserFrame.js'; import Fetch from './Fetch.js'; import SyncFetch from './SyncFetch.js'; +import IRequestCredentials from './types/IRequestCredentials.js'; +import WindowBrowserContext from '../window/WindowBrowserContext.js'; +import PreloadUtility from './preload/PreloadUtility.js'; +import * as PropertySymbol from '../PropertySymbol.js'; +import IRequestReferrerPolicy from './types/IRequestReferrerPolicy.js'; /** * Helper class for performing fetch of resources. */ export default class ResourceFetch { private window: BrowserWindow; - #browserFrame: IBrowserFrame; /** * Constructor. * - * @param options Options. - * @param options.browserFrame Browser frame. - * @param options.window Window. + * @param window Window. */ - constructor(options: { browserFrame: IBrowserFrame; window: BrowserWindow }) { - this.#browserFrame = options.browserFrame; - this.window = options.window; + constructor(window: BrowserWindow) { + this.window = window; } /** * Returns resource data asynchronously. * * @param url URL. + * @param destination Destination. + * @param [options] + * @param [options.credentials] Credentials. + * @param options.referrerPolicy * @returns Response. */ - public async fetch(url: string): Promise { + public async fetch( + url: string | URL, + destination: 'script' | 'style' | 'module', + options?: { credentials?: IRequestCredentials; referrerPolicy?: IRequestReferrerPolicy } + ): Promise { + const browserFrame = new WindowBrowserContext(this.window).getBrowserFrame(); + + // Preloaded resource + if (destination === 'script' || destination === 'style') { + const preloadKey = PreloadUtility.getKey({ + url: String(url), + destination, + mode: 'cors', + credentialsMode: options.credentials || 'same-origin' + }); + const preloadEntry = this.window.document[PropertySymbol.preloads].get(preloadKey); + + if (preloadEntry) { + this.window.document[PropertySymbol.preloads].delete(preloadKey); + + const response = preloadEntry.response || (await preloadEntry.onResponseAvailable()); + + if (!response.ok) { + throw new this.window.DOMException( + `Failed to perform request to "${ + new URL(url, this.window.location.href).href + }". Status ${preloadEntry.response.status} ${preloadEntry.response.statusText}.` + ); + } + + return preloadEntry.response[PropertySymbol.buffer].toString(); + } + } + const fetch = new Fetch({ - browserFrame: this.#browserFrame, + browserFrame, window: this.window, url, - disableSameOriginPolicy: true + disableSameOriginPolicy: destination === 'script' || destination === 'style', + disablePreload: true, + init: { + credentials: options?.credentials, + referrerPolicy: options?.referrerPolicy + } }); + const response = await fetch.send(); if (!response.ok) { @@ -53,14 +96,56 @@ export default class ResourceFetch { * Returns resource data synchronously. * * @param url URL. + * @param destination Destination. + * @param [options] Options. + * @param [options.credentials] Credentials. + * @param [options.referrerPolicy] Referrer policy. * @returns Response. */ - public fetchSync(url: string): string { + public fetchSync( + url: string, + destination: 'script' | 'style' | 'module', + options?: { credentials?: IRequestCredentials; referrerPolicy?: IRequestReferrerPolicy } + ): string { + const browserFrame = new WindowBrowserContext(this.window).getBrowserFrame(); + + // Preloaded resource + if (destination === 'script' || destination === 'style') { + const preloadKey = PreloadUtility.getKey({ + url: String(url), + destination, + mode: 'cors', + credentialsMode: options.credentials || 'same-origin' + }); + const preloadEntry = this.window.document[PropertySymbol.preloads].get(preloadKey); + + // We will only use this if the fetch for the resource is complete as it is async and this request is sync. + if (preloadEntry && preloadEntry.response) { + this.window.document[PropertySymbol.preloads].delete(preloadKey); + + const response = preloadEntry.response; + + if (!response.ok) { + throw new this.window.DOMException( + `Failed to perform request to "${ + new URL(url, this.window.location.href).href + }". Status ${preloadEntry.response.status} ${preloadEntry.response.statusText}.` + ); + } + + return preloadEntry.response[PropertySymbol.buffer].toString(); + } + } + const fetch = new SyncFetch({ - browserFrame: this.#browserFrame, + browserFrame, window: this.window, url, - disableSameOriginPolicy: true + disableSameOriginPolicy: true, + init: { + credentials: options?.credentials, + referrerPolicy: options?.referrerPolicy + } }); const response = fetch.send(); diff --git a/packages/happy-dom/src/fetch/SyncFetch.ts b/packages/happy-dom/src/fetch/SyncFetch.ts index 8338a910c..70114c42d 100644 --- a/packages/happy-dom/src/fetch/SyncFetch.ts +++ b/packages/happy-dom/src/fetch/SyncFetch.ts @@ -106,18 +106,13 @@ export default class SyncFetch { window: this.#window }) : undefined; + if (typeof beforeRequestResponse === 'object') { return beforeRequestResponse; } FetchRequestValidationUtility.validateSchema(this.request); - const virtualServerResponse = this.getVirtualServerResponse(); - - if (virtualServerResponse) { - return virtualServerResponse; - } - if (this.request.signal.aborted) { throw new this.#window.DOMException( 'The operation was aborted.', @@ -167,6 +162,12 @@ export default class SyncFetch { return cachedResponse; } + const virtualServerResponse = this.getVirtualServerResponse(); + + if (virtualServerResponse) { + return virtualServerResponse; + } + if (!this.compliesWithCrossOriginPolicy()) { this.#window.console.warn( `Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at "${this.request.url}".` @@ -283,7 +284,15 @@ export default class SyncFetch { this.#browserFrame?.page?.console.error( `${this.request.method} ${this.request.url} 404 (Not Found)` ); - return VirtualServerUtility.getNotFoundSyncResponse(this.#window); + const response = VirtualServerUtility.getNotFoundSyncResponse(this.#window); + const interceptedResponse = this.interceptor?.afterSyncResponse + ? this.interceptor.afterSyncResponse({ + window: this.#window, + response, + request: this.request + }) + : undefined; + return typeof interceptedResponse === 'object' ? interceptedResponse : response; } let buffer: Buffer; @@ -294,10 +303,18 @@ export default class SyncFetch { this.#browserFrame?.page?.console.error( `${this.request.method} ${this.request.url} 404 (Not Found)` ); - return VirtualServerUtility.getNotFoundSyncResponse(this.#window); + const response = VirtualServerUtility.getNotFoundSyncResponse(this.#window); + const interceptedResponse = this.interceptor?.afterSyncResponse + ? this.interceptor.afterSyncResponse({ + window: this.#window, + response, + request: this.request + }) + : undefined; + return typeof interceptedResponse === 'object' ? interceptedResponse : response; } - return { + const response = { status: 200, statusText: '', ok: true, @@ -306,6 +323,21 @@ export default class SyncFetch { headers: new this.#window.Headers(), body: buffer }; + const interceptedResponse = this.interceptor?.afterSyncResponse + ? this.interceptor.afterSyncResponse({ + window: this.#window, + response, + request: this.request + }) + : undefined; + const returnResponse = typeof interceptedResponse === 'object' ? interceptedResponse : response; + + this.#browserFrame.page.context.responseCache.add(this.request, { + ...returnResponse, + waitingForBody: false + }); + + return returnResponse; } /** diff --git a/packages/happy-dom/src/fetch/preload/PreloadEntry.ts b/packages/happy-dom/src/fetch/preload/PreloadEntry.ts new file mode 100644 index 000000000..8a09f0249 --- /dev/null +++ b/packages/happy-dom/src/fetch/preload/PreloadEntry.ts @@ -0,0 +1,46 @@ +import Response from '../Response.js'; + +/** + * Preload entry. + * + * @see https://html.spec.whatwg.org/multipage/links.html#preload-entry + */ +export default class PreloadEntry { + public integrityMetadata: string | null = null; + public response: Response | null = null; + public error: Error | null = null; + #callback: { resolve: (response: Response) => void; reject: (error: Error) => void } | null = + null; + + /** + * On response available. + * + * @returns Response. + */ + public onResponseAvailable(): Promise { + return new Promise((resolve, reject) => { + this.#callback = { resolve, reject }; + }); + } + + /** + * Response available. + * + * @param error + * @param response + */ + public responseAvailable(error: Error | null, response: Response): void { + this.response = response; + this.error = error; + + if (!this.#callback) { + return; + } + + if (error) { + this.#callback.reject(error); + } else { + this.#callback.resolve(response); + } + } +} diff --git a/packages/happy-dom/src/fetch/preload/PreloadUtility.ts b/packages/happy-dom/src/fetch/preload/PreloadUtility.ts new file mode 100644 index 000000000..05e7ac3dc --- /dev/null +++ b/packages/happy-dom/src/fetch/preload/PreloadUtility.ts @@ -0,0 +1,33 @@ +import IRequestCredentials from '../types/IRequestCredentials.js'; +import IRequestMode from '../types/IRequestMode.js'; + +/** + * Utility for preloading resources. + * + * @see https://html.spec.whatwg.org/multipage/links.html#link-type-preload + */ +export default class PreloadUtility { + /** + * Returns a key for a preload entry. + * + * @param options Options. + * @param options.url URL. + * @param options.destination Destination. + * @param options.mode Mode. + * @param options.credentialsMode Credentials mode. + * @returns Key. + */ + public static getKey(options: { + url: string; + destination: string; + mode: IRequestMode; + credentialsMode: IRequestCredentials; + }): string { + return JSON.stringify({ + url: options.url, + destination: options.destination, + mode: options.mode, + credentialsMode: options.credentialsMode + }); + } +} diff --git a/packages/happy-dom/src/fetch/types/IRequestInit.ts b/packages/happy-dom/src/fetch/types/IRequestInit.ts index 4c6e1a8b1..a1c7c5290 100644 --- a/packages/happy-dom/src/fetch/types/IRequestInit.ts +++ b/packages/happy-dom/src/fetch/types/IRequestInit.ts @@ -5,6 +5,7 @@ import IRequestReferrerPolicy from './IRequestReferrerPolicy.js'; import IRequestRedirect from './IRequestRedirect.js'; import IRequestBody from './IRequestBody.js'; import IRequestCredentials from './IRequestCredentials.js'; +import IRequestMode from './IRequestMode.js'; /** * Fetch request init. @@ -13,6 +14,7 @@ export default interface IRequestInit { body?: IRequestBody; headers?: IHeadersInit; method?: string; + mode?: IRequestMode; redirect?: IRequestRedirect; signal?: AbortSignal | null; referrer?: '' | 'no-referrer' | 'client' | string | URL; diff --git a/packages/happy-dom/src/fetch/types/IRequestMode.ts b/packages/happy-dom/src/fetch/types/IRequestMode.ts new file mode 100644 index 000000000..90503b16c --- /dev/null +++ b/packages/happy-dom/src/fetch/types/IRequestMode.ts @@ -0,0 +1,2 @@ +type IRequestMode = 'same-origin' | 'cors' | 'no-cors' | 'navigate' | 'websocket'; +export default IRequestMode; diff --git a/packages/happy-dom/src/fetch/utilities/FetchRequestReferrerUtility.ts b/packages/happy-dom/src/fetch/utilities/FetchRequestReferrerUtility.ts index f4ecc2cbb..4a6ad4b00 100644 --- a/packages/happy-dom/src/fetch/utilities/FetchRequestReferrerUtility.ts +++ b/packages/happy-dom/src/fetch/utilities/FetchRequestReferrerUtility.ts @@ -31,7 +31,7 @@ export default class FetchRequestReferrerUtility { */ public static prepareRequest(originURL: URL, request: Request): void { if (!request.referrerPolicy) { - (request.referrerPolicy) = 'strict-origin-when-cross-origin'; + request[PropertySymbol.referrerPolicy] = 'strict-origin-when-cross-origin'; } if (request.referrer && request.referrer !== 'no-referrer') { diff --git a/packages/happy-dom/src/module/CSSModule.ts b/packages/happy-dom/src/module/CSSModule.ts new file mode 100644 index 000000000..3bc5aa164 --- /dev/null +++ b/packages/happy-dom/src/module/CSSModule.ts @@ -0,0 +1,54 @@ +import BrowserWindow from '../window/BrowserWindow.js'; +import { URL } from 'url'; +import IModule from './IModule.js'; +import CSSStyleSheet from '../css/CSSStyleSheet.js'; + +/** + * CSS module. + */ +export default class CSSModule implements IModule { + public readonly url: URL; + readonly #window: BrowserWindow; + readonly #source: string; + #exports: { default: CSSStyleSheet } | null = null; + + /** + * Constructor. + * + * @param window Window. + * @param url Module URL. + * @param source Source code. + */ + constructor(window: BrowserWindow, url: URL, source: string) { + this.#window = window; + this.url = url; + this.#source = source; + } + + /** + * Compiles and evaluates the module. + * + * @returns Module exports. + */ + public async evaluate(): Promise<{ default: CSSStyleSheet }> { + if (this.#exports) { + return this.#exports; + } + + const styleSheet = new this.#window.CSSStyleSheet(); + styleSheet.replaceSync(this.#source); + + this.#exports = { default: styleSheet }; + + return this.#exports; + } + + /** + * Compiles and preloads the module and its imports. + * + * @returns Promise. + */ + public async preload(): Promise { + await this.evaluate(); + } +} diff --git a/packages/happy-dom/src/module/ECMAScriptModule.ts b/packages/happy-dom/src/module/ECMAScriptModule.ts new file mode 100644 index 000000000..77449dafa --- /dev/null +++ b/packages/happy-dom/src/module/ECMAScriptModule.ts @@ -0,0 +1,171 @@ +import BrowserWindow from '../window/BrowserWindow.js'; +import { URL } from 'url'; +import IModule from './IModule.js'; +import ECMAScriptModuleCompiler from './ECMAScriptModuleCompiler.js'; +import * as PropertySymbol from '../PropertySymbol.js'; +import Location from '../location/Location.js'; +import WindowBrowserContext from '../window/WindowBrowserContext.js'; +import IECMAScriptModuleCompiledResult from './IECMAScriptModuleCompiledResult.js'; +import ModuleFactory from './ModuleFactory.js'; + +const EMPTY_COMPILED_RESULT = { imports: [], execute: () => {} }; + +/** + * ECMAScript module. + */ +export default class ECMAScriptModule implements IModule { + public readonly url: URL; + public readonly [PropertySymbol.window]: BrowserWindow; + readonly #source: string; + #preloaded: boolean = false; + #compiled: IECMAScriptModuleCompiledResult | null = null; + #exports: { [k: string]: any } | null = null; + + /** + * Constructor. + * + * @param window Window. + * @param url Module URL. + * @param source Source code. + */ + constructor(window: BrowserWindow, url: URL | Location, source: string) { + this[PropertySymbol.window] = window; + this.url = url; + this.#source = source; + } + + /** + * Compiles and evaluates the module. + * + * @returns Module exports. + */ + public async evaluate(): Promise<{ [key: string]: any } | null> { + if (this.#exports) { + return this.#exports; + } + + const compiled = this.#compile(); + const modulePromises: Promise[] = []; + const window = this[PropertySymbol.window]; + const browserFrame = new WindowBrowserContext(window).getBrowserFrame(); + + if (!browserFrame) { + return {}; + } + + for (const moduleImport of compiled.imports) { + modulePromises.push( + ModuleFactory.getModule(window, this.url, moduleImport.url, { + with: { type: moduleImport.type } + }) + ); + } + + const modules = await Promise.all(modulePromises); + + const imports = new Map(); + + for (const module of modules) { + imports.set(module.url.href, await module.evaluate()); + } + + const exports = {}; + + this.#exports = exports; + + compiled.execute({ + dispatchError: window[PropertySymbol.dispatchError].bind(window), + dynamicImport: this.#import.bind(this), + imports, + exports + }); + + return exports; + } + + /** + * Compiles and preloads the module and its imports. + * + * @returns Promise. + */ + public async preload(): Promise { + if (this.#preloaded) { + return; + } + + this.#preloaded = true; + + const compiled = this.#compile(); + const modulePromises: Promise[] = []; + const window = this[PropertySymbol.window]; + const browserFrame = new WindowBrowserContext(window).getBrowserFrame(); + + if (!browserFrame) { + return; + } + + for (const moduleImport of compiled.imports) { + modulePromises.push( + ModuleFactory.getModule(window, this.url, moduleImport.url, { + with: { type: moduleImport.type } + }) + ); + } + + const modules = await Promise.all(modulePromises); + + const promises: Promise[] = []; + for (const module of modules) { + promises.push(module.preload()); + } + + await Promise.all(promises); + } + + /** + * Compiles the module. + */ + #compile(): IECMAScriptModuleCompiledResult { + if (this.#compiled) { + return this.#compiled; + } + + // In case of an error, the compiled module will be empty. + this.#compiled = EMPTY_COMPILED_RESULT; + + const compiler = new ECMAScriptModuleCompiler(this[PropertySymbol.window]); + + this.#compiled = compiler.compile(this.url.href, this.#source); + + return this.#compiled; + } + + /** + * Imports a module. + * + * @param url URL. + * @param [options] Options. + * @param [options.with] With. + * @param [options.with.type] Type. + */ + async #import( + url: string, + options?: { with?: { type?: string } } + ): Promise<{ [key: string]: any }> { + const window = this[PropertySymbol.window]; + const browserFrame = new WindowBrowserContext(window).getBrowserFrame(); + + if (!browserFrame) { + return; + } + + const asyncTaskManager = browserFrame[PropertySymbol.asyncTaskManager]; + const taskID = asyncTaskManager?.startTask(); + const module = await ModuleFactory.getModule(window, this.url, url, options); + const exports = await module.evaluate(); + + asyncTaskManager.endTask(taskID); + + return exports; + } +} diff --git a/packages/happy-dom/src/module/ECMAScriptModuleCompiler.ts b/packages/happy-dom/src/module/ECMAScriptModuleCompiler.ts new file mode 100644 index 000000000..19a03983a --- /dev/null +++ b/packages/happy-dom/src/module/ECMAScriptModuleCompiler.ts @@ -0,0 +1,567 @@ +import BrowserErrorCaptureEnum from '../browser/enums/BrowserErrorCaptureEnum.js'; +import BrowserWindow from '../window/BrowserWindow.js'; +import WindowBrowserContext from '../window/WindowBrowserContext.js'; +import IECMAScriptModuleCompiledResult from './IECMAScriptModuleCompiledResult.js'; +import IECMAScriptModuleImport from './IECMAScriptModuleImport.js'; +import ModuleURLUtility from './ModuleURLUtility.js'; +import * as PropertySymbol from '../PropertySymbol.js'; + +/** + * Code regexp. + * + * Group 1: Import without exported properties. + * Group 2: Dynamic import function call. + * Group 3: Import exported variables. + * Group 4: Import exported url. + * Group 5: Import with group. + * Group 6: Import with type. + * Group 7: Modules in export from module statement. + * Group 8: Import in export from module statement. + * Group 9: Export default statement. + * Group 10: Export function or class type. + * Group 11: Export function or class name. + * Group 12: Export object. + * Group 13: Export variable type (var, let or const). + * Group 14: Export variable name. + * Group 15: Export variable name end character (= or ;). + * Group 16: Slash (RegExp or comment). + * Group 17: Parentheses. + * Group 18: Curly braces. + * Group 19: Square brackets. + * Group 20: Escape template string (${). + * Group 21: Template string apostrophe (`). + * Group 22: String apostrophe ('). + * Group 23: String apostrophe ("). + * Group 24: Line feed character. + */ +const CODE_REGEXP = + /import\s*["']([^"']+)["'];{0,1}|import\s*\(([^)]+)\)|(import[\s{])|[\s}]from\s*["']([^"']+)["'](\s+with\s*{\s*type\s*:\s*["']([^"']+)["']\s*}){0,1}|export\s([a-zA-Z0-9-_$]+|\*|\*\s+as\s+["'a-zA-Z0-9-_$]+|{[^}]+})\s*from\s["']([^"']+)["']|(export\s*default\s*)|export\s*(function\*{0,1}|class)\s*([^({\s]+)|export\s*{([^}]+)}|export\s+(var|let|const)\s+([^=;]+)(=|;)|(\/)|(\(|\))|({|})|(\[|\])|(\${)|(`)|(')|(")|(\n)/gm; + +/** + * Import regexp. + * + * Group 1: Import braces. + * Group 2: Import all as. + * Group 3: Import default. + */ +const IMPORT_REGEXP = /{([^}]+)}|\*\s+as\s+([a-zA-Z0-9-_$]+)|([a-zA-Z0-9-_$]+)/gm; + +/** + * Valid preceding token before a statement. + */ +const PRECEDING_STATEMENT_TOKEN_REGEXP = /['"`(){}\s;=>]/; + +/** + * Valid preceding token before a regexp. + */ +const PRECEDING_REGEXP_TOKEN_REGEXP = /[([=\{\},;"'+-]/; + +/** + * Multiline comment regexp. + */ +const MULTILINE_COMMENT_REGEXP = /\/\*|\*\//gm; + +/** + * ECMAScript module compiler. + */ +export default class ECMAScriptModuleCompiler { + public readonly window: BrowserWindow; + + /** + * Constructor. + * + * @param window Window. + * @param url Module URL. + */ + constructor(window: BrowserWindow) { + this.window = window; + } + + /** + * Compiles code and returns imports and compiled code. + * + * @param moduleURL Module URL. + * @param code Code. + * @returns Result. + */ + public compile(moduleURL: string, code: string): IECMAScriptModuleCompiledResult { + const browserSettings = new WindowBrowserContext(this.window).getSettings(); + const regExp = new RegExp(CODE_REGEXP); + const imports: IECMAScriptModuleImport[] = []; + const count = { + comment: 0, + singleLineComment: 0, + parantheses: 0, + curlyBraces: 0, + squareBrackets: 0, + regExp: 0, + regExpSquareBrackets: 0, + escapeTemplateString: 0, + simpleString: 0, + doubleString: 0 + }; + const stack: { templateString: { index: number | null; code: string[] } } = { + templateString: { index: null, code: [] } + }; + const templateString: number[] = []; + const exportSpreadVariables: Array> = []; + let newCode = `(async function anonymous($happy_dom) {\n//# sourceURL=${moduleURL}\n`; + let match: RegExpExecArray; + let precedingToken: string; + let isEscaped: boolean; + let lastIndex = 0; + let importStartIndex = -1; + let skipMatchedCode = false; + + if ( + !browserSettings.disableErrorCapturing && + browserSettings.errorCapture === BrowserErrorCaptureEnum.tryAndCatch + ) { + newCode += 'try {\n'; + } + + while ((match = regExp.exec(code))) { + if (importStartIndex === -1) { + newCode += code.substring(lastIndex, match.index); + } + precedingToken = code[match.index - 1] || ' '; + isEscaped = precedingToken === '\\' && code[match.index - 2] !== '\\'; + + // Imports and exports are only valid outside any statement, string or comment at the top level + if ( + count.comment === 0 && + count.singleLineComment === 0 && + count.parantheses === 0 && + count.curlyBraces === 0 && + count.squareBrackets === 0 && + count.regExp === 0 && + count.simpleString === 0 && + count.doubleString === 0 && + templateString.length === 0 + ) { + if (match[1] && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken)) { + // Import without exported properties + imports.push({ + url: ModuleURLUtility.getURL(this.window, moduleURL, match[1]).href, + type: 'esm' + }); + skipMatchedCode = true; + } else if (match[3] && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken)) { + // Import statement start + if (importStartIndex !== -1) { + throw new this.window.TypeError( + `Failed to parse module: Unexpected import statement in "${moduleURL}"` + ); + } + importStartIndex = match.index + match[0].length - 1; + skipMatchedCode = true; + } else if (match[4]) { + // Import statement end + if (importStartIndex !== -1) { + const url = ModuleURLUtility.getURL(this.window, moduleURL, match[4]).href; + const variables = code.substring(importStartIndex, match.index + 1); + const importRegExp = new RegExp(IMPORT_REGEXP); + const importCode: string[] = []; + let importMatch: RegExpExecArray; + while ((importMatch = importRegExp.exec(variables))) { + if (importMatch[1]) { + // Import braces + importCode.push( + `const {${importMatch[1].replace( + /\s+as\s+/gm, + ': ' + )}} = $happy_dom.imports.get('${url}')` + ); + } else if (importMatch[2]) { + // Import all as + importCode.push(`const ${importMatch[2]} = $happy_dom.imports.get('${url}')`); + } else if (importMatch[3]) { + // Import default + importCode.push( + `const ${importMatch[3]} = $happy_dom.imports.get('${url}').default` + ); + } + } + newCode += importCode.join(';\n'); + importStartIndex = -1; + imports.push({ url, type: match[6] || 'esm' }); + skipMatchedCode = true; + } + } else if (match[7] && match[8] && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken)) { + // Export from module statement + + const url = ModuleURLUtility.getURL(this.window, moduleURL, match[8]).href; + const imported = match[7]; + + if (imported === '*') { + newCode += `Object.assign($happy_dom.exports, $happy_dom.imports.get('${url}'))`; + imports.push({ url, type: 'esm' }); + } else if (imported[0] === '*') { + const parts = imported.split(/\s+as\s+/); + if (parts.length === 2) { + const exportName = parts[1].replace(/["']/g, ''); + newCode += `$happy_dom.exports['${exportName}'] = $happy_dom.imports.get('${url}')`; + imports.push({ url, type: 'esm' }); + } + } else if (imported[0] === '{') { + const parts = this.removeMultilineComments(imported) + .slice(1, -1) + .split(/\s*,\s*/); + const exportCode: string[] = []; + for (const part of parts) { + const nameParts = part.trim().split(/\s+as\s+/); + const exportName = (nameParts[1] || nameParts[0]).replace(/["']/g, ''); + const importName = nameParts[0].replace(/["']/g, ''); + if (exportName && importName) { + exportCode.push( + `$happy_dom.exports['${exportName}'] = $happy_dom.imports.get('${url}')['${importName}']` + ); + } + } + newCode += exportCode.join(';\n'); + imports.push({ url, type: 'esm' }); + } + skipMatchedCode = true; + } else if (match[9] && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken)) { + // Export default statement + newCode += '$happy_dom.exports.default = '; + skipMatchedCode = true; + } else if ( + match[10] && + match[11] && + PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken) + ) { + // Export function or class type + newCode += `$happy_dom.exports['${match[11]}'] = ${match[10]} ${match[11]}`; + skipMatchedCode = true; + } else if (match[12] && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken)) { + // Export object + const parts = this.removeMultilineComments(match[12]).split(/\s*,\s*/); + const exportCode: string[] = []; + for (const part of parts) { + const nameParts = part.trim().split(/\s+as\s+/); + const exportName = (nameParts[1] || nameParts[0]).replace(/["']/g, ''); + const importName = nameParts[0].replace(/["']/g, ''); + if (exportName && importName) { + exportCode.push(`$happy_dom.exports['${exportName}'] = ${importName}`); + } + } + newCode += exportCode.join(';\n'); + skipMatchedCode = true; + } else if (match[13] && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken)) { + // Export variable + if (match[15] === '=') { + const exportName = this.removeMultilineComments(match[14]).trim(); + if ( + (exportName[0] === '{' && exportName[exportName.length - 1] === '}') || + (exportName[0] === '[' && exportName[exportName.length - 1] === ']') + ) { + const parts = exportName.slice(1, -1).split(/\s*,\s*/); + const variableObject: Map = new Map(); + + for (const part of parts) { + const nameParts = part.trim().split(/\s*:\s*/); + const exportName = (nameParts[1] || nameParts[0]).replace(/["']/g, ''); + const importName = nameParts[0].replace(/["']/g, ''); + if (exportName && importName) { + variableObject.set(exportName, importName); + } + } + + newCode += `const $happy_dom_export_${exportSpreadVariables.length} =`; + exportSpreadVariables.push(variableObject); + } else { + newCode += `$happy_dom.exports['${exportName}'] =`; + } + } else { + // TODO: If there is no =, we should ignore until we know what is is useful for + // Example: export let name1, name2, name3; + newCode += `/*Unknown export: ${match[0]}*/`; + this.window.console.warn(`Unknown export in "${moduleURL}": ${match[0]}`); + } + skipMatchedCode = true; + } + } + + if (match[2]) { + // Dynamic import function call + if ( + count.simpleString === 0 && + count.doubleString === 0 && + count.comment === 0 && + count.singleLineComment === 0 && + count.regExp === 0 && + (templateString.length === 0 || templateString[0] > 0) && + PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken) + ) { + newCode += `$happy_dom.dynamicImport(${match[2]})`; + skipMatchedCode = true; + } + } else if (match[16]) { + // Slash (RegExp or Comment) + if ( + count.simpleString === 0 && + count.doubleString === 0 && + count.singleLineComment === 0 && + count.regExpSquareBrackets === 0 && + (templateString.length === 0 || templateString[0] > 0) + ) { + if (count.comment === 1) { + if (precedingToken === '*') { + count.comment = 0; + } + } else { + if (count.regExp === 0) { + if (code[match.index + 1] === '*') { + count.comment = 1; + } else if (code[match.index + 1] === '/') { + count.singleLineComment = 1; + } else { + if (!isEscaped) { + let index = match.index - 1; + let nonSpacePrecedingToken = code[index]; + + while (nonSpacePrecedingToken === ' ' || nonSpacePrecedingToken === '\n') { + index--; + nonSpacePrecedingToken = code[index]; + } + + if (PRECEDING_REGEXP_TOKEN_REGEXP.test(nonSpacePrecedingToken)) { + count.regExp = 1; + } + } + } + } else if (!isEscaped) { + count.regExp = 0; + } + } + } + } else if (match[17]) { + // Parentheses + if ( + count.simpleString === 0 && + count.doubleString === 0 && + count.regExp === 0 && + count.comment === 0 && + count.singleLineComment === 0 && + (templateString.length === 0 || templateString[0] > 0) + ) { + if (match[17] === '(') { + count.parantheses++; + } else if (match[17] === ')' && count.parantheses > 0) { + count.parantheses--; + } + } + } else if (match[18]) { + // Curly braces + if ( + count.simpleString === 0 && + count.doubleString === 0 && + count.regExp === 0 && + count.comment === 0 && + count.singleLineComment === 0 && + (templateString.length === 0 || templateString[0] > 0) + ) { + if (match[18] === '{') { + if (templateString.length) { + templateString[0]++; + } + count.curlyBraces++; + } else if (match[18] === '}') { + if (templateString.length && templateString[0] > 0) { + templateString[0]--; + } + if (count.curlyBraces > 0) { + count.curlyBraces--; + } + } + } + } else if (match[19]) { + // Square brackets + if ( + count.simpleString === 0 && + count.doubleString === 0 && + count.comment === 0 && + count.singleLineComment === 0 && + (templateString.length === 0 || templateString[0] > 0) + ) { + // We need to check for square brackets in RegExp as well to know when the RegExp ends + if (count.regExp === 1) { + if (!isEscaped) { + if (match[19] === '[' && count.regExpSquareBrackets === 0) { + count.regExpSquareBrackets = 1; + } else if (match[19] === ']' && count.regExpSquareBrackets === 1) { + count.regExpSquareBrackets = 0; + } + } + } else { + if (match[19] === '[') { + count.squareBrackets++; + } else if (match[19] === ']' && count.squareBrackets > 0) { + count.squareBrackets--; + } + } + } + } else if (match[20]) { + // Escape template string (${) + if ( + count.simpleString === 0 && + count.doubleString === 0 && + count.comment === 0 && + count.singleLineComment === 0 && + count.regExp === 0 && + !isEscaped + ) { + if (templateString.length > 0) { + templateString[0]++; + } + count.curlyBraces++; + } + } else if (match[21]) { + // Template string + if ( + count.simpleString === 0 && + count.doubleString === 0 && + count.comment === 0 && + count.singleLineComment === 0 && + count.regExp === 0 && + !isEscaped + ) { + if (templateString?.[0] == 0) { + templateString.shift(); + stack.templateString.code.push( + code.substring(stack.templateString.index, match.index + 1) + ); + } else { + templateString.unshift(0); + stack.templateString.index = match.index; + } + } + } else if (match[22]) { + // String apostrophe (') + if ( + count.doubleString === 0 && + count.comment === 0 && + count.singleLineComment === 0 && + count.regExp === 0 && + !isEscaped && + (templateString.length === 0 || templateString[0] > 0) + ) { + if (count.simpleString === 0) { + count.simpleString = 1; + } else { + count.simpleString = 0; + } + } + } else if (match[23]) { + // String apostrophe (") + if ( + count.simpleString === 0 && + count.comment === 0 && + count.singleLineComment === 0 && + count.regExp === 0 && + !isEscaped && + (templateString.length === 0 || templateString[0] > 0) + ) { + if (count.doubleString === 0) { + count.doubleString = 1; + } else { + count.doubleString = 0; + } + } + } else if (match[24]) { + // Line feed character + count.singleLineComment = 0; + } + + // Unless the code has been handled by transforming imports or exports, we add it to the new code + if (!skipMatchedCode && importStartIndex === -1) { + newCode += match[0]; + } + + skipMatchedCode = false; + lastIndex = regExp.lastIndex; + } + + if (importStartIndex !== -1) { + // We will end up here if there is an import statement without a valid "from" part + // E.g. "import defaultExport from invalid;" or just "import defaultExport;" + throw new this.window.TypeError( + `Failed to parse module: Unexpected import statement in "${moduleURL}"` + ); + } + + newCode += code.substring(lastIndex); + + if (exportSpreadVariables.length > 0) { + newCode += '\n\n'; + + for (let i = 0; i < exportSpreadVariables.length; i++) { + for (const [exportName, importName] of exportSpreadVariables[i]) { + newCode += `$happy_dom.exports['${exportName}'] = $happy_dom_export_${i}['${importName}'];\n`; + } + } + } + + if ( + !browserSettings.disableErrorCapturing && + browserSettings.errorCapture === BrowserErrorCaptureEnum.tryAndCatch + ) { + newCode += `\n} catch(e) {\n $happy_dom.dispatchError(e);\n}`; + } + + newCode += '\n})'; + + try { + return { imports, execute: this.window.eval(newCode) }; + } catch (e) { + const error = new this.window.SyntaxError( + `Failed to parse module '${moduleURL}': ${e.message}` + ); + if ( + browserSettings.disableErrorCapturing || + browserSettings.errorCapture !== BrowserErrorCaptureEnum.tryAndCatch + ) { + throw error; + } else { + this.window[PropertySymbol.dispatchError](error); + return { + imports, + execute: () => {} + }; + } + } + } + + /** + * Remove multiline comments. + * + * @param code Code. + * @returns Code without multiline comments. + */ + private removeMultilineComments(code: string): string { + const regexp = new RegExp(MULTILINE_COMMENT_REGEXP); + let match: RegExpExecArray; + let count = 0; + let lastIndex = 0; + let newCode = ''; + + while ((match = regexp.exec(code))) { + if (count === 0) { + newCode += code.substring(lastIndex, match.index); + } + + if (match[0] === '/*') { + count++; + } else if (match[0] === '*/' && count > 0) { + count--; + } + + lastIndex = regexp.lastIndex; + } + + newCode += code.substring(lastIndex); + + return newCode; + } +} diff --git a/packages/happy-dom/src/module/IECMAScriptModuleCompiledResult.ts b/packages/happy-dom/src/module/IECMAScriptModuleCompiledResult.ts new file mode 100644 index 000000000..a5ebba76b --- /dev/null +++ b/packages/happy-dom/src/module/IECMAScriptModuleCompiledResult.ts @@ -0,0 +1,14 @@ +import IECMAScriptModuleImport from './IECMAScriptModuleImport.js'; + +export default interface IECMAScriptModuleCompiledResult { + imports: IECMAScriptModuleImport[]; + execute: (options: { + dispatchError: (error: Error) => void; + dynamicImport: ( + url: string, + options?: { with?: { type?: string } } + ) => Promise<{ [key: string]: any }>; + imports: Map; + exports: { [key: string]: any }; + }) => void; +} diff --git a/packages/happy-dom/src/module/IECMAScriptModuleImport.ts b/packages/happy-dom/src/module/IECMAScriptModuleImport.ts new file mode 100644 index 000000000..2cb35c06c --- /dev/null +++ b/packages/happy-dom/src/module/IECMAScriptModuleImport.ts @@ -0,0 +1,4 @@ +export default interface IECMAScriptModuleImport { + url: string; + type: string; +} diff --git a/packages/happy-dom/src/module/IModule.ts b/packages/happy-dom/src/module/IModule.ts new file mode 100644 index 000000000..cca08126a --- /dev/null +++ b/packages/happy-dom/src/module/IModule.ts @@ -0,0 +1,20 @@ +/** + * Module interface. + */ +export default interface IModule { + url: URL; + + /** + * Compiles and evaluates the module. + * + * @returns Module exports. + */ + evaluate(): Promise<{ [key: string]: any }>; + + /** + * Compiles and preloads the module and its imports. + * + * @returns Promise. + */ + preload(): Promise; +} diff --git a/packages/happy-dom/src/module/IModuleImportMap.ts b/packages/happy-dom/src/module/IModuleImportMap.ts new file mode 100644 index 000000000..498398c8a --- /dev/null +++ b/packages/happy-dom/src/module/IModuleImportMap.ts @@ -0,0 +1,10 @@ +import IModuleImportMapRule from './IModuleImportMapRule.js'; +import IModuleImportMapScope from './IModuleImportMapScope.js'; + +/** + * @see https://html.spec.whatwg.org/multipage/webappapis.html#import-map + */ +export default interface IModuleImportMap { + imports: IModuleImportMapRule[]; + scopes: IModuleImportMapScope[]; +} diff --git a/packages/happy-dom/src/module/IModuleImportMapRule.ts b/packages/happy-dom/src/module/IModuleImportMapRule.ts new file mode 100644 index 000000000..c96936c91 --- /dev/null +++ b/packages/happy-dom/src/module/IModuleImportMapRule.ts @@ -0,0 +1,4 @@ +export default interface IModuleImportMapRule { + from: string; + to: string; +} diff --git a/packages/happy-dom/src/module/IModuleImportMapScope.ts b/packages/happy-dom/src/module/IModuleImportMapScope.ts new file mode 100644 index 000000000..5a7ef7547 --- /dev/null +++ b/packages/happy-dom/src/module/IModuleImportMapScope.ts @@ -0,0 +1,6 @@ +import IModuleImportMapRule from './IModuleImportMapRule.js'; + +export default interface IModuleImportMapScope { + scope: string; + rules: IModuleImportMapRule[]; +} diff --git a/packages/happy-dom/src/module/JSONModule.ts b/packages/happy-dom/src/module/JSONModule.ts new file mode 100644 index 000000000..338174974 --- /dev/null +++ b/packages/happy-dom/src/module/JSONModule.ts @@ -0,0 +1,59 @@ +import BrowserWindow from '../window/BrowserWindow.js'; +import { URL } from 'url'; +import IModule from './IModule.js'; + +/** + * JSON module. + */ +export default class JSONModule implements IModule { + public readonly url: URL; + readonly #window: BrowserWindow; + readonly #source: string; + #exports: { default: object } | null = null; + + /** + * Constructor. + * + * @param window Window. + * @param url Module URL. + * @param source Source code. + */ + constructor(window: BrowserWindow, url: URL, source: string) { + this.#window = window; + this.url = url; + this.#source = source; + } + + /** + * Compiles and evaluates the module. + * + * @returns Module exports. + */ + public async evaluate(): Promise<{ default: object }> { + if (this.#exports) { + return this.#exports; + } + + let result: object; + try { + result = JSON.parse(this.#source); + } catch (error) { + throw new this.#window.TypeError( + `Failed to parse module "${this.url.href}": ${error.message}` + ); + } + + this.#exports = { default: result }; + + return this.#exports; + } + + /** + * Compiles and preloads the module and its imports. + * + * @returns Promise. + */ + public async preload(): Promise { + await this.evaluate(); + } +} diff --git a/packages/happy-dom/src/module/ModuleFactory.ts b/packages/happy-dom/src/module/ModuleFactory.ts new file mode 100644 index 000000000..e0499713d --- /dev/null +++ b/packages/happy-dom/src/module/ModuleFactory.ts @@ -0,0 +1,134 @@ +import { URL } from 'url'; +import IModule from './IModule.js'; +import * as PropertySymbol from '../PropertySymbol.js'; +import CSSModule from './CSSModule.js'; +import JSONModule from './JSONModule.js'; +import UnresolvedModule from './UnresolvedModule.js'; +import WindowBrowserContext from '../window/WindowBrowserContext.js'; +import ResourceFetch from '../fetch/ResourceFetch.js'; +import ECMAScriptModule from './ECMAScriptModule.js'; +import BrowserWindow from '../window/BrowserWindow.js'; +import Location from '../location/Location.js'; + +/** + * Module factory. + */ +export default class ModuleFactory { + /** + * Fetches the source code of the module from the given URL and creates a new module instance. + * + * @param window Window. + * @param parentURL Parent URL. + * @param url Module URL. + * @param [options] Options. + * @param [options.with] Options. + * @param [options.with.type] Module type. + */ + public static async getModule( + window: BrowserWindow, + parentURL: URL | Location, + url: string, + options?: { with?: { type?: string } } + ): Promise { + const absoluteURL = this.getURL(window, parentURL, url); + const absoluteURLString = absoluteURL.href; + const type = options?.with?.type || 'esm'; + + if (type !== 'esm' && type !== 'css' && type !== 'json') { + throw new window.TypeError( + `Failed to import module "${absoluteURL}" from "${parentURL}": Unkown type "${type}"` + ); + } + + const cached = window[PropertySymbol.modules][type].get(absoluteURLString); + + if (cached) { + if (cached instanceof UnresolvedModule) { + await new Promise((resolve, reject) => { + cached.addResolveListener(resolve, reject); + }); + return window[PropertySymbol.modules][type].get(absoluteURLString); + } + return cached; + } + const browserFrame = new WindowBrowserContext(window).getBrowserFrame(); + + if (!browserFrame) { + throw new window.TypeError( + `Failed to import module "${absoluteURL}" from "${parentURL}": Window is closed and is no longer attached to a frame` + ); + } + + const unresolvedModule = new UnresolvedModule(window, absoluteURL); + + window[PropertySymbol.modules][type].set(absoluteURLString, unresolvedModule); + + const resourceFetch = new ResourceFetch(window); + let source: string; + try { + source = await resourceFetch.fetch(absoluteURL, 'module'); + } catch (error) { + unresolvedModule.resolve(error); + throw error; + } + let module: IModule; + + switch (type) { + case 'json': + module = new JSONModule(window, absoluteURL, source); + break; + case 'css': + module = new CSSModule(window, absoluteURL, source); + break; + case 'esm': + module = new ECMAScriptModule(window, absoluteURL, source); + break; + } + + window[PropertySymbol.modules][type].set(absoluteURLString, module); + + unresolvedModule.resolve(); + + return module; + } + + /** + * Returns module URL based on parent URL and the import map. + * + * @param window Window. + * @param parentURL Parent URL. + * @param url Module URL. + */ + private static getURL(window: BrowserWindow, parentURL: URL | Location, url: string): URL { + const parentURLString = parentURL.href; + const absoluteURL = new URL(url, parentURLString); + const absoluteURLString = absoluteURL.href; + const importMap = window[PropertySymbol.moduleImportMap]; + + if (!importMap) { + return absoluteURL; + } + + if (importMap.scopes) { + for (const scope of importMap.scopes) { + if (parentURLString.includes(scope.scope)) { + for (const rule of scope.rules) { + if (absoluteURLString.startsWith(rule.from)) { + return new URL(rule.to + absoluteURLString.replace(rule.from, '')); + } + } + } + } + } + + if (importMap.imports) { + for (const rule of importMap.imports) { + if (absoluteURLString.startsWith(rule.from)) { + return new URL(rule.to + absoluteURLString.replace(rule.from, '')); + } + } + } + + return absoluteURL; + } +} diff --git a/packages/happy-dom/src/module/ModuleURLUtility.ts b/packages/happy-dom/src/module/ModuleURLUtility.ts new file mode 100644 index 000000000..a6018418a --- /dev/null +++ b/packages/happy-dom/src/module/ModuleURLUtility.ts @@ -0,0 +1,51 @@ +import { URL } from 'url'; +import * as PropertySymbol from '../PropertySymbol.js'; +import BrowserWindow from '../window/BrowserWindow.js'; +import Location from '../location/Location.js'; + +/** + * Module URL utility. + */ +export default class ModuleURLUtility { + /** + * Returns module URL based on parent URL and the import map. + * + * @param window Window. + * @param parentURL Parent URL. + * @param url Module URL. + */ + public static getURL( + window: BrowserWindow, + parentURL: URL | Location | string, + url: string + ): URL { + const parentURLString = typeof parentURL === 'string' ? parentURL : parentURL.href; + const importMap = window[PropertySymbol.moduleImportMap]; + + if (!importMap) { + return new URL(url, parentURLString); + } + + if (importMap.scopes.length) { + for (const scope of importMap.scopes) { + if (parentURLString.includes(scope.scope)) { + for (const rule of scope.rules) { + if (url.startsWith(rule.from)) { + return new URL(rule.to + url.replace(rule.from, ''), parentURLString); + } + } + } + } + } + + if (importMap.imports.length) { + for (const rule of importMap.imports) { + if (url.startsWith(rule.from)) { + return new URL(rule.to + url.replace(rule.from, ''), parentURLString); + } + } + } + + return new URL(url, parentURLString); + } +} diff --git a/packages/happy-dom/src/module/UnresolvedModule.ts b/packages/happy-dom/src/module/UnresolvedModule.ts new file mode 100644 index 000000000..c4ee1b932 --- /dev/null +++ b/packages/happy-dom/src/module/UnresolvedModule.ts @@ -0,0 +1,77 @@ +import BrowserWindow from '../window/BrowserWindow.js'; +import { URL } from 'url'; +import IModule from './IModule.js'; + +/** + * CSS module. + */ +export default class UnresolvedModule implements IModule { + public readonly url: URL; + readonly #window: BrowserWindow; + #hooks: { resolve: (value: unknown) => void; reject: (error: Error) => void }[] = []; + #error: Error | null = null; + + /** + * Constructor. + * + * @param window Window. + * @param url Module URL. + */ + constructor(window: BrowserWindow, url: URL) { + this.#window = window; + this.url = url; + } + + /** + * Compiles and evaluates the module. + * + * @returns Module exports. + */ + public async evaluate(): Promise { + throw new this.#window.TypeError('Unresolved module. We should never end up here.'); + } + + /** + * Compiles and preloads the module and its imports. + * + * @returns Promise. + */ + public async preload(): Promise { + throw new this.#window.TypeError('Unresolved module. We should never end up here.'); + } + + /** + * Add a hook to be called when the module is resolved. + * + * @param resolve Resolve. + * @param reject Reject. + */ + public addResolveListener( + resolve: (value: unknown) => void, + reject: (error: Error) => void + ): void { + if (this.#error) { + reject(this.#error); + return; + } + this.#hooks.push({ resolve, reject }); + } + + /** + * Resolves the module. + * + * @param [error] Error. + */ + public resolve(error?: Error): void { + if (error) { + this.#error = error; + } + for (const hook of this.#hooks) { + if (error) { + hook.reject(error); + } else { + hook.resolve(null); + } + } + } +} diff --git a/packages/happy-dom/src/nodes/document/Document.ts b/packages/happy-dom/src/nodes/document/Document.ts index 7092f9bba..9601b1cac 100644 --- a/packages/happy-dom/src/nodes/document/Document.ts +++ b/packages/happy-dom/src/nodes/document/Document.ts @@ -49,6 +49,7 @@ import NodeFactory from '../NodeFactory.js'; import SVGElementConfig from '../../config/SVGElementConfig.js'; import StringUtility from '../../utilities/StringUtility.js'; import HTMLParser from '../../html-parser/HTMLParser.js'; +import PreloadEntry from '../../fetch/preload/PreloadEntry.js'; const PROCESSING_INSTRUCTION_TARGET_REGEXP = /^[a-z][a-z0-9-]+$/; @@ -80,6 +81,7 @@ export default class Document extends Node { > = new Map(); public [PropertySymbol.contentType]: string = 'text/html'; public [PropertySymbol.xmlProcessingInstruction]: ProcessingInstruction | null = null; + public [PropertySymbol.preloads]: Map = new Map(); public declare cloneNode: (deep?: boolean) => Document; // Private properties diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts index 1c8990bbb..5eedf802d 100644 --- a/packages/happy-dom/src/nodes/element/Element.ts +++ b/packages/happy-dom/src/nodes/element/Element.ts @@ -14,7 +14,6 @@ import Attr from '../attr/Attr.js'; import NamedNodeMap from './NamedNodeMap.js'; import Event from '../../event/Event.js'; import EventPhaseEnum from '../../event/EventPhaseEnum.js'; -import WindowErrorUtility from '../../window/WindowErrorUtility.js'; import WindowBrowserContext from '../../window/WindowBrowserContext.js'; import BrowserErrorCaptureEnum from '../../browser/enums/BrowserErrorCaptureEnum.js'; import NodeTypeEnum from '../node/NodeTypeEnum.js'; @@ -259,6 +258,15 @@ export default class Element return this[PropertySymbol.classList]; } + /** + * Sets class list. + * + * @param value Class list. + */ + public set classList(value: string) { + this.setAttribute('class', value); + } + /** * Returns ID. * @@ -1258,7 +1266,8 @@ export default class Element */ public override dispatchEvent(event: Event): boolean { const returnValue = super.dispatchEvent(event); - const browserSettings = new WindowBrowserContext(this[PropertySymbol.window]).getSettings(); + const window = this[PropertySymbol.window]; + const browserSettings = new WindowBrowserContext(window).getSettings(); if ( browserSettings && @@ -1269,17 +1278,19 @@ export default class Element const attribute = this.getAttribute('on' + event.type); if (attribute && !event[PropertySymbol.immediatePropagationStopped]) { - const code = `//# sourceURL=${this[PropertySymbol.window].location.href}\n${attribute}`; + const code = `//# sourceURL=${window.location.href}\n${attribute}`; if ( browserSettings.disableErrorCapturing || browserSettings.errorCapture !== BrowserErrorCaptureEnum.tryAndCatch ) { - this[PropertySymbol.window].eval(code); + window.eval(code); } else { - WindowErrorUtility.captureError(this[PropertySymbol.window], () => - this[PropertySymbol.window].eval(code) - ); + try { + window.eval(code); + } catch (error) { + window[PropertySymbol.dispatchError](error); + } } } } diff --git a/packages/happy-dom/src/nodes/html-anchor-element/HTMLAnchorElement.ts b/packages/happy-dom/src/nodes/html-anchor-element/HTMLAnchorElement.ts index ad1175ea1..5f924cd4d 100644 --- a/packages/happy-dom/src/nodes/html-anchor-element/HTMLAnchorElement.ts +++ b/packages/happy-dom/src/nodes/html-anchor-element/HTMLAnchorElement.ts @@ -294,6 +294,15 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLHyper return this[PropertySymbol.relList]; } + /** + * Sets rel list. + * + * @param value Value. + */ + public set relList(value: string) { + this.setAttribute('rel', value); + } + /** * Returns search. * diff --git a/packages/happy-dom/src/nodes/html-area-element/HTMLAreaElement.ts b/packages/happy-dom/src/nodes/html-area-element/HTMLAreaElement.ts index 4a96ec94d..7b51f9f5c 100644 --- a/packages/happy-dom/src/nodes/html-area-element/HTMLAreaElement.ts +++ b/packages/happy-dom/src/nodes/html-area-element/HTMLAreaElement.ts @@ -158,6 +158,15 @@ export default class HTMLAreaElement extends HTMLElement implements IHTMLHyperli return this[PropertySymbol.relList]; } + /** + * Sets rel list. + * + * @param value Value. + */ + public set relList(value: string) { + this.setAttribute('rel', value); + } + /** * Returns target. * diff --git a/packages/happy-dom/src/nodes/html-element/HTMLElement.ts b/packages/happy-dom/src/nodes/html-element/HTMLElement.ts index 15134e854..c5f74c1cd 100644 --- a/packages/happy-dom/src/nodes/html-element/HTMLElement.ts +++ b/packages/happy-dom/src/nodes/html-element/HTMLElement.ts @@ -746,6 +746,9 @@ export default class HTMLElement extends Element { } if (newElement[PropertySymbol.isConnected]) { + if (newElement[PropertySymbol.shadowRoot]) { + newElement[PropertySymbol.shadowRoot][PropertySymbol.isConnected] = true; + } newElement[PropertySymbol.connectedToDocument](); } } diff --git a/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElement.ts b/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElement.ts index 034876391..6ea4f8488 100644 --- a/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElement.ts +++ b/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElement.ts @@ -9,7 +9,6 @@ import DOMTokenList from '../../dom/DOMTokenList.js'; import Attr from '../attr/Attr.js'; import BrowserFrameFactory from '../../browser/utilities/BrowserFrameFactory.js'; import BrowserFrameURL from '../../browser/utilities/BrowserFrameURL.js'; -import WindowErrorUtility from '../../window/WindowErrorUtility.js'; import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; import IRequestReferrerPolicy from '../../fetch/types/IRequestReferrerPolicy.js'; import WindowBrowserContext from '../../window/WindowBrowserContext.js'; @@ -375,13 +374,12 @@ export default class HTMLIFrameElement extends HTMLElement { } if (browserFrame.page.context.browser.settings.disableIframePageLoading) { - WindowErrorUtility.dispatchError( - this, - new window.DOMException( - `Failed to load iframe page "${targetURL.href}". Iframe page loading is disabled.`, - DOMExceptionNameEnum.notSupportedError - ) + const error = new window.DOMException( + `Failed to load iframe page "${targetURL.href}". Iframe page loading is disabled.`, + DOMExceptionNameEnum.notSupportedError ); + browserFrame.page?.console.error(error); + this.dispatchEvent(new Event('error')); return; } @@ -400,7 +398,10 @@ export default class HTMLIFrameElement extends HTMLElement { referrerPolicy: this.referrerPolicy }) .then(() => this.dispatchEvent(new Event('load'))) - .catch((error) => WindowErrorUtility.dispatchError(this, error)); + .catch((error) => { + browserFrame.page?.console.error(error); + this.dispatchEvent(new Event('error')); + }); this.#contentWindowContainer.window = isSameOrigin ? this.#iframe.window 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 35d4e2201..dd69866c8 100644 --- a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts +++ b/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts @@ -5,11 +5,14 @@ import Event from '../../event/Event.js'; import ErrorEvent from '../../event/events/ErrorEvent.js'; import DOMTokenList from '../../dom/DOMTokenList.js'; import Attr from '../attr/Attr.js'; -import WindowErrorUtility from '../../window/WindowErrorUtility.js'; import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; import ResourceFetch from '../../fetch/ResourceFetch.js'; -import DocumentReadyStateManager from '../document/DocumentReadyStateManager.js'; import WindowBrowserContext from '../../window/WindowBrowserContext.js'; +import Fetch from '../../fetch/Fetch.js'; +import BrowserErrorCaptureEnum from '../../browser/enums/BrowserErrorCaptureEnum.js'; +import ModuleFactory from '../../module/ModuleFactory.js'; +import PreloadUtility from '../../fetch/preload/PreloadUtility.js'; +import PreloadEntry from '../../fetch/preload/PreloadEntry.js'; /** * HTML Link Element. @@ -45,12 +48,22 @@ export default class HTMLLinkElement extends HTMLElement { this[PropertySymbol.relList] = new DOMTokenList( PropertySymbol.illegalConstructor, this, - 'rel' + 'rel', + ['stylesheet', 'modulepreload', 'preload'] ); } return this[PropertySymbol.relList]; } + /** + * Sets rel list. + * + * @param value Value. + */ + public set relList(value: string) { + this.setAttribute('rel', value); + } + /** * Returns as. * @@ -61,9 +74,9 @@ export default class HTMLLinkElement extends HTMLElement { } /** - * Sets crossOrigin. + * Sets as. * - * @param crossOrigin CrossOrigin. + * @param as As. */ public set as(as: string) { this.setAttribute('as', as); @@ -209,8 +222,22 @@ export default class HTMLLinkElement extends HTMLElement { */ public override [PropertySymbol.connectedToDocument](): void { super[PropertySymbol.connectedToDocument](); - if (this[PropertySymbol.evaluateCSS]) { - this.#loadStyleSheet(this.getAttribute('href'), this.getAttribute('rel')); + + const rel = this.getAttribute('rel'); + const href = this.getAttribute('href'); + + if (rel && href) { + switch (rel) { + case 'stylesheet': + this.#loadStyleSheet(href); + break; + case 'modulepreload': + this.#preloadModule(href); + break; + case 'preload': + this.#preloadResource(href); + break; + } } } @@ -223,10 +250,138 @@ export default class HTMLLinkElement extends HTMLElement { ): void { super[PropertySymbol.onSetAttribute](attribute, replacedAttribute); - if (attribute[PropertySymbol.name] === 'rel') { - this.#loadStyleSheet(this.getAttribute('href'), attribute[PropertySymbol.value]); - } else if (attribute[PropertySymbol.name] === 'href') { - this.#loadStyleSheet(attribute[PropertySymbol.value], this.getAttribute('rel')); + if (attribute[PropertySymbol.name] === 'rel' || attribute[PropertySymbol.name] === 'href') { + const rel = this.getAttribute('rel'); + const href = this.getAttribute('href'); + + if (rel && href) { + switch (rel) { + case 'stylesheet': + this.#loadStyleSheet(href); + break; + case 'modulepreload': + this.#preloadModule(href); + break; + case 'preload': + this.#preloadResource(href); + break; + } + } + } + } + + /** + * Preloads a module. + * + * @param url URL. + */ + async #preloadModule(url: string): Promise { + const absoluteURL = new URL(url, this[PropertySymbol.ownerDocument].location.href); + const window = this[PropertySymbol.window]; + const browserFrame = new WindowBrowserContext(window).getBrowserFrame(); + const browserSettings = new WindowBrowserContext(window).getSettings(); + + if ( + !browserSettings || + !this[PropertySymbol.isConnected] || + browserSettings.disableJavaScriptFileLoading || + browserSettings.disableJavaScriptEvaluation + ) { + return; + } + + if ( + browserSettings.disableErrorCapturing || + browserSettings.errorCapture !== BrowserErrorCaptureEnum.tryAndCatch + ) { + const module = await ModuleFactory.getModule(window, absoluteURL, url); + await module.preload(); + } else { + try { + const module = await ModuleFactory.getModule(window, absoluteURL, url); + await module.preload(); + } catch (error) { + browserFrame.page?.console.error(error); + window[PropertySymbol.dispatchError](error); + return; + } + } + } + + /** + * Preloads a resource. + * + * @param url URL. + */ + async #preloadResource(url: string): Promise { + const window = this[PropertySymbol.window]; + const browserFrame = new WindowBrowserContext(window).getBrowserFrame(); + const browserSettings = browserFrame.page?.context?.browser?.settings; + const as = this.as; + + // Only "script", "style" and "fetch" are supported for now. + if ( + !browserFrame || + !this[PropertySymbol.isConnected] || + (as !== 'script' && as !== 'style' && as !== 'fetch') + ) { + return; + } + + if ( + as === 'script' && + (browserSettings.disableJavaScriptFileLoading || browserSettings.disableJavaScriptEvaluation) + ) { + return; + } + + if (as === 'style' && browserSettings.disableCSSFileLoading) { + return; + } + + const absoluteURL = new URL(url, window.location.href).href; + + const preloadKey = PreloadUtility.getKey({ + url: absoluteURL, + destination: as, + mode: 'cors', + credentialsMode: this.crossOrigin === 'use-credentials' ? 'include' : 'same-origin' + }); + + if (window.document[PropertySymbol.preloads].has(preloadKey)) { + return; + } + + const preloadEntry = new PreloadEntry(); + + window.document[PropertySymbol.preloads].set(preloadKey, preloadEntry); + + const fetch = new Fetch({ + browserFrame, + window, + url: absoluteURL, + disableSameOriginPolicy: as === 'script' || as === 'style', + disablePreload: true, + init: { + credentials: this.crossOrigin === 'use-credentials' ? 'include' : 'same-origin' + } + }); + + try { + const response = await fetch.send(); + + if (!response[PropertySymbol.buffer]) { + await response.buffer(); + } + + preloadEntry.responseAvailable(null, response); + } catch (error) { + preloadEntry.responseAvailable(error, null); + window.document[PropertySymbol.preloads].delete(preloadKey); + + browserFrame.page?.console?.error( + `Failed to preload resource "${absoluteURL}": ${error.message}` + ); } } @@ -236,7 +391,7 @@ export default class HTMLLinkElement extends HTMLElement { * @param url URL. * @param rel Rel. */ - async #loadStyleSheet(url: string | null, rel: string | null): Promise { + async #loadStyleSheet(url: string | null): Promise { const window = this[PropertySymbol.window]; const browserFrame = new WindowBrowserContext(window).getBrowserFrame(); @@ -246,7 +401,7 @@ export default class HTMLLinkElement extends HTMLElement { const browserSettings = browserFrame.page?.context?.browser?.settings; - if (!url || !rel || rel.toLowerCase() !== 'stylesheet' || !this[PropertySymbol.isConnected]) { + if (!this[PropertySymbol.evaluateCSS] || !this[PropertySymbol.isConnected]) { return; } @@ -265,24 +420,19 @@ export default class HTMLLinkElement extends HTMLElement { if (browserSettings.handleDisabledFileLoadingAsSuccess) { this.dispatchEvent(new Event('load')); } else { - WindowErrorUtility.dispatchError( - this, - new window.DOMException( - `Failed to load external stylesheet "${absoluteURL}". CSS file loading is disabled.`, - DOMExceptionNameEnum.notSupportedError - ) + const error = new window.DOMException( + `Failed to load external stylesheet "${absoluteURL}". CSS file loading is disabled.`, + DOMExceptionNameEnum.notSupportedError ); + + browserFrame.page?.console.error(error); + this.dispatchEvent(new Event('error')); } return; } - const resourceFetch = new ResourceFetch({ - browserFrame, - window: window - }); - const readyStateManager = (<{ [PropertySymbol.readyStateManager]: DocumentReadyStateManager }>( - (window) - ))[PropertySymbol.readyStateManager]; + const resourceFetch = new ResourceFetch(window); + const readyStateManager = window[PropertySymbol.readyStateManager]; this.#loadedStyleSheetURL = absoluteURL; @@ -292,7 +442,9 @@ export default class HTMLLinkElement extends HTMLElement { let error: Error | null = null; try { - code = await resourceFetch.fetch(absoluteURL); + code = await resourceFetch.fetch(absoluteURL, 'style', { + credentials: this.crossOrigin === 'use-credentials' ? 'include' : 'same-origin' + }); } catch (e) { error = e; } @@ -300,7 +452,8 @@ export default class HTMLLinkElement extends HTMLElement { readyStateManager.endTask(); if (error) { - WindowErrorUtility.dispatchError(this, error); + browserFrame.page?.console.error(error); + this.dispatchEvent(new Event('error')); } else { const styleSheet = new CSSStyleSheet(); styleSheet.replaceSync(code); diff --git a/packages/happy-dom/src/nodes/html-media-element/HTMLMediaElement.ts b/packages/happy-dom/src/nodes/html-media-element/HTMLMediaElement.ts index d15b108a5..80b58848c 100644 --- a/packages/happy-dom/src/nodes/html-media-element/HTMLMediaElement.ts +++ b/packages/happy-dom/src/nodes/html-media-element/HTMLMediaElement.ts @@ -309,6 +309,15 @@ export default class HTMLMediaElement extends HTMLElement { return this[PropertySymbol.controlsList]; } + /** + * Sets controlsList. + * + * @param value Value. + */ + public set controlsList(value: string) { + this.setAttribute('controlslist', value); + } + /** * Returns mediaKeys. * 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 c42ac982c..f33791a6c 100644 --- a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +++ b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts @@ -2,13 +2,16 @@ import HTMLElement from '../html-element/HTMLElement.js'; import * as PropertySymbol from '../../PropertySymbol.js'; import Event from '../../event/Event.js'; import ErrorEvent from '../../event/events/ErrorEvent.js'; -import WindowErrorUtility from '../../window/WindowErrorUtility.js'; import WindowBrowserContext from '../../window/WindowBrowserContext.js'; import BrowserErrorCaptureEnum from '../../browser/enums/BrowserErrorCaptureEnum.js'; import Attr from '../attr/Attr.js'; import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; import ResourceFetch from '../../fetch/ResourceFetch.js'; -import DocumentReadyStateManager from '../document/DocumentReadyStateManager.js'; +import ECMAScriptModule from '../../module/ECMAScriptModule.js'; +import ModuleFactory from '../../module/ModuleFactory.js'; +import DOMTokenList from '../../dom/DOMTokenList.js'; +import IModuleImportMap from '../../module/IModuleImportMap.js'; +import IRequestReferrerPolicy from '../../fetch/types/IRequestReferrerPolicy.js'; /** * HTML Script Element. @@ -26,9 +29,11 @@ export default class HTMLScriptElement extends HTMLElement { // Internal properties public [PropertySymbol.evaluateScript] = true; + public [PropertySymbol.blocking]: DOMTokenList | null = null; // Private properties #loadedScriptURL: string | null = null; + /** * Returns type. * @@ -47,6 +52,144 @@ export default class HTMLScriptElement extends HTMLElement { this.setAttribute('type', type); } + /** + * Returns blocking. + */ + public get blocking(): DOMTokenList { + if (!this[PropertySymbol.blocking]) { + this[PropertySymbol.blocking] = new DOMTokenList( + PropertySymbol.illegalConstructor, + this, + 'blocking' + ); + } + return this[PropertySymbol.blocking]; + } + + /** + * Sets blocking. + * + * @param value Value. + */ + public set blocking(value: string) { + this.setAttribute('blocking', value); + } + + /** + * Returns crossOrigin. + * + * @returns CrossOrigin. + */ + public get crossOrigin(): string { + return this.getAttribute('crossorigin') || ''; + } + + /** + * Sets crossOrigin. + * + * @param crossOrigin CrossOrigin. + */ + public set crossOrigin(crossOrigin: string) { + this.setAttribute('crossorigin', crossOrigin); + } + + /** + * Returns fetch priority. + * + * @returns Fetch priority. + */ + public get fetchPriority(): 'auto' | 'high' | 'low' | 'normal' { + const fetchPriority = this.getAttribute('fetchpriority'); + switch (fetchPriority) { + case 'high': + case 'low': + case 'normal': + return fetchPriority; + default: + return 'auto'; + } + } + + /** + * Sets fetch priority. + * + * @param fetchPriority Fetch priority. + */ + public set fetchPriority(fetchPriority: 'auto' | 'high' | 'low' | 'normal') { + this.setAttribute('fetchpriority', fetchPriority); + } + + /** + * Returns noModule. + * + * @returns NoModule. + */ + public get noModule(): boolean { + return this.getAttribute('nomodule') !== null; + } + + /** + * Sets noModule. + * + * @param noModule NoModule. + */ + public set noModule(noModule: boolean) { + if (noModule) { + this.setAttribute('nomodule', ''); + } else { + this.removeAttribute('nomodule'); + } + } + + /** + * Returns integrity. + * + * @returns Integrity. + */ + public get integrity(): string { + return this.getAttribute('integrity') || ''; + } + + /** + * Sets integrity. + * + * @param integrity Integrity. + */ + public set integrity(integrity: string) { + this.setAttribute('integrity', integrity); + } + + /** + * Returns referrerPolicy. + * + * @returns ReferrerPolicy. + */ + public get referrerPolicy(): IRequestReferrerPolicy { + const referrerPolicy = this.getAttribute('referrerpolicy'); + switch (referrerPolicy) { + case 'no-referrer': + case 'no-referrer-when-downgrade': + case 'same-origin': + case 'origin': + case 'strict-origin': + case 'origin-when-cross-origin': + case 'strict-origin-when-cross-origin': + case 'unsafe-url': + return referrerPolicy; + default: + return ''; + } + } + + /** + * Sets referrerPolicy. + * + * @param referrerPolicy ReferrerPolicy. + */ + public set referrerPolicy(referrerPolicy: string) { + this.setAttribute('referrerpolicy', referrerPolicy); + } + /** * Returns source. * @@ -191,33 +334,28 @@ export default class HTMLScriptElement extends HTMLElement { const src = this.getAttribute('src'); if (src !== null) { - this.#loadScript(src); + if (this.getAttribute('type') === 'module') { + this.#loadModule(src); + } else { + this.#loadScript(src); + } } else if (browserSettings && !browserSettings.disableJavaScriptEvaluation) { - const textContent = this.textContent; + const source = this.textContent; const type = this.getAttribute('type'); - if ( - textContent && - (type === null || + + if (source) { + if (type === 'module') { + this.#evaluateModule(source); + } else if (type === 'importmap') { + this.#evaluateImportMap(source); + } else if ( + type === null || type === 'application/x-ecmascript' || type === 'application/x-javascript' || - type.startsWith('text/javascript')) - ) { - this[PropertySymbol.ownerDocument][PropertySymbol.currentScript] = this; - - const code = `//# sourceURL=${this[PropertySymbol.window].location.href}\n` + textContent; - - if ( - browserSettings.disableErrorCapturing || - browserSettings.errorCapture !== BrowserErrorCaptureEnum.tryAndCatch + type.startsWith('text/javascript') ) { - this[PropertySymbol.window].eval(code); - } else { - WindowErrorUtility.captureError(this[PropertySymbol.window], () => - this[PropertySymbol.window].eval(code) - ); + this.#evaluateScript(source); } - - this[PropertySymbol.ownerDocument][PropertySymbol.currentScript] = null; } } } @@ -237,8 +375,213 @@ export default class HTMLScriptElement extends HTMLElement { attribute[PropertySymbol.value] !== null && this[PropertySymbol.isConnected] ) { - this.#loadScript(attribute[PropertySymbol.value]); + if (this.getAttribute('type') === 'module') { + this.#loadModule(attribute[PropertySymbol.value]); + } else { + this.#loadScript(attribute[PropertySymbol.value]); + } + } + } + + /** + * Evaluates a module. + * + * @param source Source. + */ + async #evaluateModule(source: string): Promise { + const url = this[PropertySymbol.ownerDocument].location; + const window = this[PropertySymbol.window]; + const browserSettings = new WindowBrowserContext(window).getSettings(); + const browserFrame = new WindowBrowserContext(window).getBrowserFrame(); + + if (!browserFrame) { + return; + } + + const module = new ECMAScriptModule(window, url, source); + const readyStateManager = window[PropertySymbol.readyStateManager]; + + readyStateManager.startTask(); + + if ( + browserSettings.disableErrorCapturing || + browserSettings.errorCapture !== BrowserErrorCaptureEnum.tryAndCatch + ) { + await module.evaluate(); + } else { + try { + await module.evaluate(); + } catch (error) { + window[PropertySymbol.dispatchError](error); + return; + } + } + + readyStateManager.endTask(); + + this.dispatchEvent(new Event('load')); + } + + /** + * Evaluates an import map. + * + * @param source Source. + */ + async #evaluateImportMap(source: string): Promise { + const window = this[PropertySymbol.window]; + const browserSettings = new WindowBrowserContext(window).getSettings(); + const browserFrame = new WindowBrowserContext(window).getBrowserFrame(); + + if (!browserFrame || window[PropertySymbol.moduleImportMap]) { + return; + } + + let json: any; + if ( + browserSettings.disableErrorCapturing || + browserSettings.errorCapture !== BrowserErrorCaptureEnum.tryAndCatch + ) { + json = JSON.parse(source); + } else { + try { + json = JSON.parse(source); + } catch (error) { + window[PropertySymbol.dispatchError](error); + return; + } + } + + if (json.imports || json.scopes) { + const importMap: IModuleImportMap = { + imports: [], + scopes: [] + }; + + if (json.imports) { + for (const key of Object.keys(json.imports)) { + importMap.imports.push({ + from: key, + to: json.imports[key] + }); + } + } + + if (json.scopes) { + for (const scopeKey of Object.keys(json.scopes)) { + const scope = { + scope: scopeKey, + rules: [] + }; + for (const importKey of Object.keys(json.scopes[scopeKey])) { + const value = json.scopes[scopeKey][importKey]; + scope.rules.push({ + from: importKey, + to: value + }); + } + importMap.scopes.push(scope); + } + } + + window[PropertySymbol.moduleImportMap] = importMap; + } + } + + /** + * Evaluates a script. + * + * @param source Source. + */ + #evaluateScript(source: string): Promise { + const window = this[PropertySymbol.window]; + const browserSettings = new WindowBrowserContext(window).getSettings(); + + if (!browserSettings) { + return; + } + + this[PropertySymbol.ownerDocument][PropertySymbol.currentScript] = this; + + const code = `//# sourceURL=${this[PropertySymbol.ownerDocument].location.href}\n` + source; + + if ( + browserSettings.disableErrorCapturing || + browserSettings.errorCapture !== BrowserErrorCaptureEnum.tryAndCatch + ) { + window.eval(code); + } else { + try { + window.eval(code); + } catch (error) { + window[PropertySymbol.dispatchError](error); + } + } + + this[PropertySymbol.ownerDocument][PropertySymbol.currentScript] = null; + } + + /** + * Loads a module. + * + * @param url URL. + */ + async #loadModule(url: string): Promise { + const window = this[PropertySymbol.window]; + const browserFrame = new WindowBrowserContext(window).getBrowserFrame(); + const browserSettings = new WindowBrowserContext(window).getSettings(); + + if (!browserSettings) { + return; + } + + if (!url || !this[PropertySymbol.isConnected] || this.getAttribute('type') !== 'module') { + return; + } + + if ( + browserSettings && + (browserSettings.disableJavaScriptFileLoading || browserSettings.disableJavaScriptEvaluation) + ) { + if (browserSettings.handleDisabledFileLoadingAsSuccess) { + this.dispatchEvent(new Event('load')); + } else { + const error = new window.DOMException( + `Failed to load module "${url}". JavaScript file loading is disabled.`, + DOMExceptionNameEnum.notSupportedError + ); + browserFrame.page?.console.error(error); + this.dispatchEvent(new Event('error')); + } + return; } + + const readyStateManager = window[PropertySymbol.readyStateManager]; + + readyStateManager.startTask(); + + // TODO: What to do with "referrerPolicy" and "crossOrigin" for modules? + // @see https://github.com/w3c/webappsec-referrer-policy/issues/111 + + if ( + browserSettings.disableErrorCapturing || + browserSettings.errorCapture !== BrowserErrorCaptureEnum.tryAndCatch + ) { + const module = await ModuleFactory.getModule(window, window.location, url); + await module.evaluate(); + } else { + try { + const module = await ModuleFactory.getModule(window, window.location, url); + await module.evaluate(); + } catch (error) { + browserFrame.page?.console.error(error); + this.dispatchEvent(new Event('error')); + readyStateManager.endTask(); + return; + } + } + + readyStateManager.endTask(); + this.dispatchEvent(new Event('load')); } /** @@ -249,29 +592,34 @@ export default class HTMLScriptElement extends HTMLElement { async #loadScript(url: string): Promise { const window = this[PropertySymbol.window]; const browserFrame = new WindowBrowserContext(window).getBrowserFrame(); - const async = - this.getAttribute('async') !== null || - this.getAttribute('defer') !== null || - this.getAttribute('type') === 'module'; if (!browserFrame) { return; } const browserSettings = browserFrame.page?.context?.browser?.settings; + const type = this.getAttribute('type'); - if (!url || !this[PropertySymbol.isConnected]) { + if ( + !url || + !this[PropertySymbol.isConnected] || + (type !== null && + type !== 'application/x-ecmascript' && + type !== 'application/x-javascript' && + !type.startsWith('text/javascript')) + ) { return; } - let absoluteURL: string; + let absoluteURL: URL; try { - absoluteURL = new URL(url, this[PropertySymbol.window].location.href).href; + absoluteURL = new URL(url, window.location.href); } catch (error) { return; } - if (this.#loadedScriptURL === absoluteURL) { + const absoluteURLString = absoluteURL.toString(); + if (this.#loadedScriptURL === absoluteURLString) { return; } @@ -282,66 +630,90 @@ export default class HTMLScriptElement extends HTMLElement { if (browserSettings.handleDisabledFileLoadingAsSuccess) { this.dispatchEvent(new Event('load')); } else { - WindowErrorUtility.dispatchError( - this, - new window.DOMException( - `Failed to load external script "${absoluteURL}". JavaScript file loading is disabled.`, - DOMExceptionNameEnum.notSupportedError - ) + const error = new window.DOMException( + `Failed to load script "${absoluteURL}". JavaScript file loading is disabled.`, + DOMExceptionNameEnum.notSupportedError ); + browserFrame.page?.console.error(error); + this.dispatchEvent(new Event('error')); } return; } - const resourceFetch = new ResourceFetch({ - browserFrame, - window: this[PropertySymbol.window] - }); - let code: string | null = null; - let error: Error | null = null; + this.#loadedScriptURL = absoluteURLString; - this.#loadedScriptURL = absoluteURL; + const resourceFetch = new ResourceFetch(window); + const async = this.getAttribute('async') !== null || this.getAttribute('defer') !== null; + let code: string | null = null; if (async) { - const readyStateManager = (< - { [PropertySymbol.readyStateManager]: DocumentReadyStateManager } - >(this[PropertySymbol.window]))[PropertySymbol.readyStateManager]; + const readyStateManager = window[PropertySymbol.readyStateManager]; readyStateManager.startTask(); try { - code = await resourceFetch.fetch(absoluteURL); - } catch (e) { - error = e; + code = await resourceFetch.fetch(absoluteURLString, 'script', { + credentials: this.crossOrigin === 'use-credentials' ? 'include' : 'same-origin', + referrerPolicy: this.referrerPolicy + }); + } catch (error) { + browserFrame.page?.console.error(error); + this.dispatchEvent(new Event('error')); + return; } readyStateManager.endTask(); } else { try { - code = resourceFetch.fetchSync(absoluteURL); - } catch (e) { - error = e; + code = resourceFetch.fetchSync(absoluteURLString, 'script', { + credentials: this.crossOrigin === 'use-credentials' ? 'include' : 'same-origin', + referrerPolicy: this.referrerPolicy + }); + } catch (error) { + browserFrame.page?.console.error(error); + this.dispatchEvent(new Event('error')); + return; } } - if (error) { - WindowErrorUtility.dispatchError(this, error); - } else { - this[PropertySymbol.ownerDocument][PropertySymbol.currentScript] = this; - code = '//# sourceURL=' + absoluteURL + '\n' + code; + this[PropertySymbol.ownerDocument][PropertySymbol.currentScript] = this; - if ( - browserSettings.disableErrorCapturing || - browserSettings.errorCapture !== BrowserErrorCaptureEnum.tryAndCatch - ) { + code = '//# sourceURL=' + absoluteURL + '\n' + code; + + if ( + browserSettings.disableErrorCapturing || + browserSettings.errorCapture !== BrowserErrorCaptureEnum.tryAndCatch + ) { + this[PropertySymbol.window].eval(code); + } else { + try { this[PropertySymbol.window].eval(code); - } else { - WindowErrorUtility.captureError(this[PropertySymbol.window], () => - this[PropertySymbol.window].eval(code) - ); + } catch (error) { + this[PropertySymbol.ownerDocument][PropertySymbol.currentScript] = null; + window[PropertySymbol.dispatchError](error); + return; } - this[PropertySymbol.ownerDocument][PropertySymbol.currentScript] = null; - this.dispatchEvent(new Event('load')); + } + + this[PropertySymbol.ownerDocument][PropertySymbol.currentScript] = null; + this.dispatchEvent(new Event('load')); + } + + /** + * Returns true if the given type is supported. + * + * @param type Type. + * @returns True if the given type is supported. + */ + public static supports(type: string): boolean { + switch (type) { + case 'classic': + case 'module': + case 'importmap': + return true; + case 'speculationrules': + default: + return false; } } } diff --git a/packages/happy-dom/src/nodes/html-table-cell-element/HTMLTableCellElement.ts b/packages/happy-dom/src/nodes/html-table-cell-element/HTMLTableCellElement.ts index a8f860736..78ba057d4 100644 --- a/packages/happy-dom/src/nodes/html-table-cell-element/HTMLTableCellElement.ts +++ b/packages/happy-dom/src/nodes/html-table-cell-element/HTMLTableCellElement.ts @@ -1,7 +1,6 @@ import QuerySelector from '../../query-selector/QuerySelector.js'; import HTMLElement from '../html-element/HTMLElement.js'; import * as PropertySymbol from '../../PropertySymbol.js'; -import DOMTokenList from '../../dom/DOMTokenList.js'; /** * HTMLTableCellElement @@ -9,8 +8,6 @@ import DOMTokenList from '../../dom/DOMTokenList.js'; * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLTableCellElement */ export default class HTMLTableCellElement extends HTMLElement { - public [PropertySymbol.headers]: DOMTokenList | null = null; - /** * Returns abbr. * diff --git a/packages/happy-dom/src/window/BrowserWindow.ts b/packages/happy-dom/src/window/BrowserWindow.ts index e0722fd22..963da10bb 100644 --- a/packages/happy-dom/src/window/BrowserWindow.ts +++ b/packages/happy-dom/src/window/BrowserWindow.ts @@ -196,7 +196,6 @@ import XMLSerializer from '../xml-serializer/XMLSerializer.js'; import CrossOriginBrowserWindow from './CrossOriginBrowserWindow.js'; import INodeJSGlobal from './INodeJSGlobal.js'; import VMGlobalPropertyScript from './VMGlobalPropertyScript.js'; -import WindowErrorUtility from './WindowErrorUtility.js'; import WindowPageOpenUtility from './WindowPageOpenUtility.js'; import { PerformanceObserver, @@ -307,6 +306,8 @@ import DOMPoint from '../dom/DOMPoint.js'; import SVGAnimatedLengthList from '../svg/SVGAnimatedLengthList.js'; import CustomElementReactionStack from '../custom-element/CustomElementReactionStack.js'; import IScrollToOptions from './IScrollToOptions.js'; +import IModule from '../module/IModule.js'; +import IModuleImportMap from '../module/IModuleImportMap.js'; const TIMER = { setTimeout: globalThis.setTimeout.bind(globalThis), @@ -795,6 +796,16 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal public [PropertySymbol.window]: BrowserWindow = this; public [PropertySymbol.internalId]: number = -1; public [PropertySymbol.customElementReactionStack] = new CustomElementReactionStack(this); + public [PropertySymbol.modules]: { + json: Map; + css: Map; + esm: Map; + } = { + json: new Map(), + css: new Map(), + esm: new Map() + }; + public [PropertySymbol.moduleImportMap]: IModuleImportMap | null = null; // Private properties #browserFrame: IBrowserFrame; @@ -1311,7 +1322,15 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal zeroDelayTimeout.timeouts = null; for (const timeout of timeouts) { if (useTryCatch) { - WindowErrorUtility.captureError(this, () => timeout.callback()); + let result: any; + try { + result = timeout.callback(); + } catch (error) { + this[PropertySymbol.dispatchError](error); + } + if (result instanceof Promise) { + result.catch((error: Error) => this[PropertySymbol.dispatchError](error)); + } } else { timeout.callback(); } @@ -1339,7 +1358,15 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal // We need to call endTimer() before the callback as the callback might throw an error. this.#browserFrame[PropertySymbol.asyncTaskManager].endTimer(id); if (useTryCatch) { - WindowErrorUtility.captureError(this, () => callback(...args)); + let result: any; + try { + result = callback(...args); + } catch (error) { + this[PropertySymbol.dispatchError](error); + } + if (result instanceof Promise) { + result.catch((error: Error) => this[PropertySymbol.dispatchError](error)); + } } else { callback(...args); } @@ -1399,11 +1426,19 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal const id = TIMER.setInterval( () => { if (useTryCatch) { - WindowErrorUtility.captureError( - this, - () => callback(...args), - () => this.clearInterval(id) - ); + let result: any; + try { + result = callback(...args); + } catch (error) { + this.clearInterval(id); + this[PropertySymbol.dispatchError](error); + } + if (result instanceof Promise) { + result.catch((error: Error) => { + this.clearInterval(id); + this[PropertySymbol.dispatchError](error); + }); + } } else { callback(...args); } @@ -1466,7 +1501,15 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal // We need to call endImmediate() before the callback as the callback might throw an error. this.#browserFrame[PropertySymbol.asyncTaskManager].endImmediate(id); if (useTryCatch) { - WindowErrorUtility.captureError(this, () => callback(this.performance.now())); + let result: any; + try { + result = callback(this.performance.now()); + } catch (error) { + this[PropertySymbol.dispatchError](error); + } + if (result instanceof Promise) { + result.catch((error: Error) => this[PropertySymbol.dispatchError](error)); + } } else { callback(this.performance.now()); } @@ -1513,7 +1556,15 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal // We need to call endTask() before the callback as the callback might throw an error. this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskId); if (useTryCatch) { - WindowErrorUtility.captureError(this, <() => unknown>callback); + let result: any; + try { + result = callback(); + } catch (error) { + this[PropertySymbol.dispatchError](error); + } + if (result instanceof Promise) { + result.catch((error: Error) => this[PropertySymbol.dispatchError](error)); + } } else { callback(); } @@ -1665,6 +1716,16 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal } } + /** + * Dispatches an error event and outputs the error to the console. + * + * @param error Error. + */ + public [PropertySymbol.dispatchError](error: Error): void { + this.#browserFrame?.page?.console.error(error); + this.dispatchEvent(new ErrorEvent('error', { message: error.message, error })); + } + /** * Setup of VM context. */ @@ -1731,6 +1792,12 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal this.history[PropertySymbol.destroy](); } + this[PropertySymbol.modules].json.clear(); + this[PropertySymbol.modules].css.clear(); + this[PropertySymbol.modules].esm.clear(); + + this.document[PropertySymbol.preloads].clear(); + this.document[PropertySymbol.activeElement] = null; this.document[PropertySymbol.nextActiveElement] = null; this.document[PropertySymbol.currentScript] = null; diff --git a/packages/happy-dom/src/window/WindowErrorUtility.ts b/packages/happy-dom/src/window/WindowErrorUtility.ts deleted file mode 100644 index 6af1974d7..000000000 --- a/packages/happy-dom/src/window/WindowErrorUtility.ts +++ /dev/null @@ -1,68 +0,0 @@ -import BrowserWindow from './BrowserWindow.js'; -import * as PropertySymbol from '../PropertySymbol.js'; -import ErrorEvent from '../event/events/ErrorEvent.js'; -import Element from '../nodes/element/Element.js'; - -/** - * Error utility. - */ -export default class WindowErrorUtility { - /** - * Calls a function synchronously wrapped in a try/catch block to capture errors and dispatch error events. - * If the callback returns a Promise, it will catch errors from the promise. - * - * It will also output the errors to the console. - * - * @param elementOrWindow Element or Window. - * @param callback Callback. - * @param [cleanup] Cleanup callback on error. - * @returns Result. - */ - public static captureError( - elementOrWindow: BrowserWindow | Element, - callback: () => T, - cleanup?: () => void - ): T | null { - let result = null; - - try { - result = callback(); - } catch (error) { - this.dispatchError(elementOrWindow, error); - if (cleanup) { - cleanup(); - } - } - - if (result && result instanceof Promise) { - result.catch((error) => { - this.dispatchError(elementOrWindow, error); - if (cleanup) { - cleanup(); - } - }); - } - - return result; - } - - /** - * Dispatches an error event and outputs it to the console. - * - * @param elementOrWindow Element or Window. - * @param error Error. - */ - public static dispatchError(elementOrWindow: BrowserWindow | Element, error: Error): void { - if ((elementOrWindow).console) { - (elementOrWindow).console.error(error); - elementOrWindow.dispatchEvent(new ErrorEvent('error', { message: error.message, error })); - } else { - (elementOrWindow)[PropertySymbol.ownerDocument][ - PropertySymbol.defaultView - ]?.console.error(error); - (elementOrWindow).dispatchEvent( - new ErrorEvent('error', { message: error.message, error }) - ); - } - } -} diff --git a/packages/happy-dom/test/browser/BrowserFrame.test.ts b/packages/happy-dom/test/browser/BrowserFrame.test.ts index 77949fd05..aed2c2f33 100644 --- a/packages/happy-dom/test/browser/BrowserFrame.test.ts +++ b/packages/happy-dom/test/browser/BrowserFrame.test.ts @@ -14,6 +14,8 @@ import BrowserErrorCaptureEnum from '../../src/browser/enums/BrowserErrorCapture import Headers from '../../src/fetch/Headers'; import * as PropertySymbol from '../../src/PropertySymbol'; +const STACK_TRACE_REGEXP = />.+$\s*/gm; + describe('BrowserFrame', () => { afterEach(() => { vi.restoreAllMocks(); @@ -139,6 +141,78 @@ describe('BrowserFrame', () => { expect(frame1.window['test']).toBe(2); expect(frame2.window['test']).toBe(3); }); + + it('Traces never ending timeout when calling waitUntilComplete() with the setting "debug.traceWaitUntilComplete" set to a time in ms.', async () => { + const browser = new Browser({ + settings: { + debug: { + traceWaitUntilComplete: 100 + } + } + }); + const page = browser.newPage(); + const frame1 = BrowserFrameFactory.createChildFrame(page.mainFrame); + const frame2 = BrowserFrameFactory.createChildFrame(page.mainFrame); + page.mainFrame.evaluate('setTimeout(() => { globalThis.test = 1; }, 10);'); + frame1.evaluate('setTimeout(() => { globalThis.test = 2; }, 10);'); + frame2.evaluate( + 'function neverEndingTimeout() { setTimeout(neverEndingTimeout, 10); } neverEndingTimeout();' + ); + let error: Error | null = null; + try { + await page.waitUntilComplete(); + } catch (e) { + error = e; + } + expect( + error + ?.toString() + .replace(STACK_TRACE_REGEXP, '') + .replace(/Timer #[0-9]+/, 'Timer #1000') + '> testFunction (test.js:1:1)\n' + ).toBe(`Error: The maximum time was reached for "waitUntilComplete()". + +1 task did not end in time. + +The following traces were recorded: + +Timer #1000 +‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +> testFunction (test.js:1:1) +`); + }); + + it('Traces never ending task when calling waitUntilComplete() with the setting "debug.traceWaitUntilComplete" set to a time in ms.', async () => { + const browser = new Browser({ + settings: { + debug: { + traceWaitUntilComplete: 100 + } + } + }); + const page = browser.newPage(); + const frame1 = BrowserFrameFactory.createChildFrame(page.mainFrame); + const frame2 = BrowserFrameFactory.createChildFrame(page.mainFrame); + page.mainFrame.evaluate('setTimeout(() => { globalThis.test = 1; }, 10);'); + frame1.evaluate('setTimeout(() => { globalThis.test = 2; }, 10);'); + frame2[PropertySymbol.asyncTaskManager].startTask(); + let error: Error | null = null; + try { + await page.waitUntilComplete(); + } catch (e) { + error = e; + } + expect(error?.toString().replace(STACK_TRACE_REGEXP, '') + '> testFunction (test.js:1:1)\n') + .toBe(`Error: The maximum time was reached for "waitUntilComplete()". + +1 task did not end in time. + +The following traces were recorded: + +Task #1 +‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +> testFunction (test.js:1:1) +`); + }); }); describe('waitForNavigation()', () => { diff --git a/packages/happy-dom/test/browser/BrowserPage.test.ts b/packages/happy-dom/test/browser/BrowserPage.test.ts index cc0b51393..2faedf2e0 100644 --- a/packages/happy-dom/test/browser/BrowserPage.test.ts +++ b/packages/happy-dom/test/browser/BrowserPage.test.ts @@ -9,6 +9,7 @@ import IGoToOptions from '../../src/browser/types/IGoToOptions'; import BrowserFrameFactory from '../../src/browser/utilities/BrowserFrameFactory'; import Event from '../../src/event/Event'; import DefaultBrowserPageViewport from '../../src/browser/DefaultBrowserPageViewport'; +import * as PropertySymbol from '../../src/PropertySymbol'; describe('BrowserPage', () => { afterEach(() => { @@ -141,6 +142,61 @@ describe('BrowserPage', () => { expect(frame1.window).toBe(null); expect(frame2.window).toBe(null); }); + + it('Clears modules when closing.', async () => { + const browser = new Browser({ + settings: { + fetch: { + virtualServers: [ + { + url: 'https://localhost:8080/base/js/', + directory: './test/nodes/html-script-element/modules/' + } + ] + } + }, + console + }); + const page = browser.defaultContext.newPage(); + const mainFrame = BrowserFrameFactory.createChildFrame(page.mainFrame); + const frame1 = BrowserFrameFactory.createChildFrame(page.mainFrame); + const frame2 = BrowserFrameFactory.createChildFrame(page.mainFrame); + + mainFrame.url = 'https://localhost:8080/'; + + const mainFrameWindow = mainFrame.window; + const script = mainFrame.document.createElement('script'); + + script.src = 'https://localhost:8080/base/js/TestModuleElement.js'; + script.type = 'module'; + script.onload = () => { + mainFrame.document.body.appendChild(mainFrame.document.createElement('test-module')); + }; + + mainFrame.document.body.appendChild(script); + + await page.waitUntilComplete(); + + expect(mainFrameWindow[PropertySymbol.modules].esm.size).toBe(5); + expect(mainFrameWindow[PropertySymbol.modules].css.size).toBe(1); + expect(mainFrameWindow[PropertySymbol.modules].json.size).toBe(1); + + await page.close(); + + expect(browser.defaultContext.pages.length).toBe(0); + + expect(page.virtualConsolePrinter).toBe(null); + expect(page.context).toBe(null); + expect(page.mainFrame).toBe(null); + expect(mainFrame.window).toBe(null); + expect(frame1.window).toBe(null); + expect(frame2.window).toBe(null); + + expect(mainFrameWindow.closed).toBe(true); + expect(mainFrameWindow[PropertySymbol.modules].esm.size).toBe(0); + expect(mainFrameWindow[PropertySymbol.modules].css.size).toBe(0); + expect(mainFrameWindow[PropertySymbol.modules].json.size).toBe(0); + }); }); describe('waitUntilComplete()', () => { diff --git a/packages/happy-dom/test/dom/DOMTokenList.test.ts b/packages/happy-dom/test/dom/DOMTokenList.test.ts index 5d0d10ce5..1868d12f4 100644 --- a/packages/happy-dom/test/dom/DOMTokenList.test.ts +++ b/packages/happy-dom/test/dom/DOMTokenList.test.ts @@ -3,6 +3,7 @@ import Document from '../../src/nodes/document/Document.js'; import Element from '../../src/nodes/element/Element.js'; import { beforeEach, describe, it, expect } from 'vitest'; import DOMTokenList from '../../src/dom/DOMTokenList.js'; +import * as PropertySymbol from '../../src/PropertySymbol.js'; describe('DOMTokenList', () => { let window: Window; @@ -221,6 +222,18 @@ describe('DOMTokenList', () => { }); }); + describe('supports()', () => { + it('Returns true if the token is in the list', () => { + const domTokenList = new DOMTokenList(PropertySymbol.illegalConstructor, element, 'rel', [ + 'stylesheet', + 'modulepreload' + ]); + expect(domTokenList.supports('stylesheet')).toBe(true); + expect(domTokenList.supports('modulepreload')).toBe(true); + expect(domTokenList.supports('unsupported')).toBe(false); + }); + }); + describe('whitespace handling', () => { it('Normalizes whitespace to a single space', () => { element.className = ' class1 class2\nclass3 '; diff --git a/packages/happy-dom/test/fetch/Fetch.test.ts b/packages/happy-dom/test/fetch/Fetch.test.ts index 1ff5334f6..182c6deb8 100644 --- a/packages/happy-dom/test/fetch/Fetch.test.ts +++ b/packages/happy-dom/test/fetch/Fetch.test.ts @@ -3590,7 +3590,7 @@ describe('Fetch', () => { 'content-length', String(responseText.length), 'cache-control', - 'max-age=0.01', + 'max-age=0.001', 'last-modified', 'Mon, 11 Dec 2023 01:00:00 GMT' ]; @@ -3635,7 +3635,7 @@ describe('Fetch', () => { expect(headers1).toEqual({ 'content-type': 'text/html', 'content-length': String(responseText.length), - 'cache-control': `max-age=0.01`, + 'cache-control': `max-age=0.001`, 'last-modified': 'Mon, 11 Dec 2023 01:00:00 GMT' }); @@ -3749,7 +3749,7 @@ describe('Fetch', () => { 'content-length', String(responseText1.length), 'cache-control', - 'max-age=0.01', + 'max-age=0.001', 'last-modified', 'Mon, 11 Dec 2023 01:00:00 GMT' ]; @@ -3802,7 +3802,7 @@ describe('Fetch', () => { expect(headers1).toEqual({ 'content-type': 'text/html', 'content-length': String(responseText1.length), - 'cache-control': `max-age=0.01`, + 'cache-control': `max-age=0.001`, 'last-modified': 'Mon, 11 Dec 2023 01:00:00 GMT' }); @@ -3917,7 +3917,7 @@ describe('Fetch', () => { 'content-length', String(responseText.length), 'cache-control', - 'max-age=0.01', + 'max-age=0.001', 'last-modified', 'Mon, 11 Dec 2023 01:00:00 GMT', 'etag', @@ -3967,7 +3967,7 @@ describe('Fetch', () => { expect(headers1).toEqual({ 'content-type': 'text/html', 'content-length': String(responseText.length), - 'cache-control': `max-age=0.01`, + 'cache-control': `max-age=0.001`, 'last-modified': 'Mon, 11 Dec 2023 01:00:00 GMT', etag: etag1 }); @@ -3981,7 +3981,7 @@ describe('Fetch', () => { expect(headers2).toEqual({ 'content-type': 'text/html', 'content-length': String(responseText.length), - 'cache-control': `max-age=0.01`, + 'cache-control': `max-age=0.001`, 'Last-Modified': 'Mon, 11 Dec 2023 02:00:00 GMT', ETag: etag2 }); @@ -4087,7 +4087,7 @@ describe('Fetch', () => { 'content-length', String(responseText1.length), 'cache-control', - 'max-age=0.01', + 'max-age=0.001', 'last-modified', 'Mon, 11 Dec 2023 01:00:00 GMT', 'etag', @@ -4134,7 +4134,7 @@ describe('Fetch', () => { expect(headers1).toEqual({ 'content-type': 'text/html', 'content-length': String(responseText1.length), - 'cache-control': `max-age=0.01`, + 'cache-control': `max-age=0.001`, 'last-modified': 'Mon, 11 Dec 2023 01:00:00 GMT', etag: etag1 }); diff --git a/packages/happy-dom/test/fetch/Request.test.ts b/packages/happy-dom/test/fetch/Request.test.ts index f20c87bfe..a6649c5ed 100644 --- a/packages/happy-dom/test/fetch/Request.test.ts +++ b/packages/happy-dom/test/fetch/Request.test.ts @@ -53,6 +53,7 @@ describe('Request', () => { expect(request.referrerPolicy).toBe(''); expect(request.credentials).toBe('same-origin'); expect(request.referrer).toBe('about:client'); + expect(request.mode).toBe('cors'); }); it('Supports URL as string from Request object.', () => { @@ -112,6 +113,40 @@ describe('Request', () => { expect(request.method).toBe('POST'); }); + it('Supports mode from init object.', () => { + const request = new window.Request(TEST_URL, { mode: 'same-origin' }); + expect(request.mode).toBe('same-origin'); + }); + + it('Throws for invalid mode.', () => { + expect(() => { + new window.Request(TEST_URL, { mode: <'cors'>'invalid' }); + }).toThrow( + new window.DOMException( + `Failed to construct 'Request': The provided value 'invalid' is not a valid enum value of type RequestMode.`, + DOMExceptionNameEnum.syntaxError + ) + ); + + expect(() => { + new window.Request(TEST_URL, { mode: 'navigate' }); + }).toThrow( + new window.DOMException( + `Failed to construct 'Request': Cannot construct a Request with a RequestInit whose mode member is set as 'navigate'.`, + DOMExceptionNameEnum.securityError + ) + ); + + expect(() => { + new window.Request(TEST_URL, { mode: 'websocket' }); + }).toThrow( + new window.DOMException( + `Failed to construct 'Request': Cannot construct a Request with a RequestInit whose mode member is set as 'websocket'.`, + DOMExceptionNameEnum.securityError + ) + ); + }); + it('Supports body from Request object.', async () => { const otherRequest = new window.Request(TEST_URL, { method: 'POST', body: 'Hello World' }); const request = new window.Request(otherRequest); diff --git a/packages/happy-dom/test/fetch/ResourceFetch.test.ts b/packages/happy-dom/test/fetch/ResourceFetch.test.ts index fcae9ea97..36365f3e0 100644 --- a/packages/happy-dom/test/fetch/ResourceFetch.test.ts +++ b/packages/happy-dom/test/fetch/ResourceFetch.test.ts @@ -19,10 +19,7 @@ describe('ResourceFetch', () => { const page = browser.newPage(); window = page.mainFrame.window; page.mainFrame.url = URL; - resourceFetch = new ResourceFetch({ - browserFrame: page.mainFrame, - window - }); + resourceFetch = new ResourceFetch(window); }); afterEach(() => { diff --git a/packages/happy-dom/test/fetch/SyncFetch.test.ts b/packages/happy-dom/test/fetch/SyncFetch.test.ts index eda92c3c8..14107f7fb 100644 --- a/packages/happy-dom/test/fetch/SyncFetch.test.ts +++ b/packages/happy-dom/test/fetch/SyncFetch.test.ts @@ -2777,7 +2777,7 @@ describe('SyncFetch', () => { 'content-length', String(responseText.length), 'cache-control', - 'max-age=0.01', + 'max-age=0.001', 'last-modified', 'Mon, 11 Dec 2023 01:00:00 GMT' ], @@ -2823,7 +2823,7 @@ describe('SyncFetch', () => { expect(headers1).toEqual({ 'content-type': 'text/html', 'content-length': String(responseText.length), - 'cache-control': `max-age=0.01`, + 'cache-control': `max-age=0.001`, 'last-modified': 'Mon, 11 Dec 2023 01:00:00 GMT' }); @@ -2914,7 +2914,7 @@ describe('SyncFetch', () => { 'content-length', String(responseText1.length), 'cache-control', - 'max-age=0.01', + 'max-age=0.001', 'last-modified', 'Mon, 11 Dec 2023 01:00:00 GMT' ], @@ -2968,7 +2968,7 @@ describe('SyncFetch', () => { expect(headers1).toEqual({ 'content-type': 'text/html', 'content-length': String(responseText1.length), - 'cache-control': `max-age=0.01`, + 'cache-control': `max-age=0.001`, 'last-modified': 'Mon, 11 Dec 2023 01:00:00 GMT' }); @@ -3059,7 +3059,7 @@ describe('SyncFetch', () => { 'content-length', String(responseText.length), 'cache-control', - 'max-age=0.01', + 'max-age=0.001', 'last-modified', 'Mon, 11 Dec 2023 01:00:00 GMT', 'etag', @@ -3115,7 +3115,7 @@ describe('SyncFetch', () => { expect(headers1).toEqual({ 'content-type': 'text/html', 'content-length': String(responseText.length), - 'cache-control': `max-age=0.01`, + 'cache-control': `max-age=0.001`, 'last-modified': 'Mon, 11 Dec 2023 01:00:00 GMT', etag: etag1 }); @@ -3129,7 +3129,7 @@ describe('SyncFetch', () => { expect(headers2).toEqual({ 'content-type': 'text/html', 'content-length': String(responseText.length), - 'cache-control': `max-age=0.01`, + 'cache-control': `max-age=0.001`, 'Last-Modified': 'Mon, 11 Dec 2023 02:00:00 GMT', ETag: etag2 }); @@ -3212,7 +3212,7 @@ describe('SyncFetch', () => { 'content-length', String(responseText1.length), 'cache-control', - 'max-age=0.01', + 'max-age=0.001', 'last-modified', 'Mon, 11 Dec 2023 01:00:00 GMT', 'etag', @@ -3260,7 +3260,7 @@ describe('SyncFetch', () => { expect(headers1).toEqual({ 'content-type': 'text/html', 'content-length': String(responseText1.length), - 'cache-control': `max-age=0.01`, + 'cache-control': `max-age=0.001`, 'last-modified': 'Mon, 11 Dec 2023 01:00:00 GMT', etag: etag1 }); diff --git a/packages/happy-dom/test/module/ECMAScriptModuleCompiler.test.ts b/packages/happy-dom/test/module/ECMAScriptModuleCompiler.test.ts new file mode 100644 index 000000000..5e7f4fb15 --- /dev/null +++ b/packages/happy-dom/test/module/ECMAScriptModuleCompiler.test.ts @@ -0,0 +1,1130 @@ +import { beforeEach, describe, it, expect } from 'vitest'; +import ECMAScriptModuleCompiler from '../../src/module/ECMAScriptModuleCompiler.js'; +import BrowserWindow from '../../src/window/BrowserWindow.js'; +import Window from '../../src/window/Window.js'; +import BrowserErrorCaptureEnum from '../../src/browser/enums/BrowserErrorCaptureEnum.js'; + +describe('ECMAScriptModuleCompiler', () => { + let window: BrowserWindow; + + beforeEach(() => { + window = new Window({ + settings: { + errorCapture: BrowserErrorCaptureEnum.disabled + } + }); + }); + + describe('compile()', () => { + it('Handles imports and exports of a basic module.', () => { + const code = ` + import StringUtility from "../utilities/StringUtility.js"; + import { default as DefaultImageUtility } from "../utilities/ImageUtility.js"; + import * as NumberUtility from "../utilities/NumberUtility.js"; + + const result = await import('http://localhost:8080/js/utilities/StringUtility.js'); + + export const variable = 'hello'; + + export default class TestClass { + constructor() { + console.log('Hello World'); + } + } + `; + const compiler = new ECMAScriptModuleCompiler(window); + const result = compiler.compile('http://localhost:8080/js/app/main.js', code); + + expect(result.imports).toEqual([ + { url: 'http://localhost:8080/js/utilities/StringUtility.js', type: 'esm' }, + { url: 'http://localhost:8080/js/utilities/ImageUtility.js', type: 'esm' }, + { url: 'http://localhost:8080/js/utilities/NumberUtility.js', type: 'esm' } + ]); + expect(result.execute.toString()).toBe(`async function anonymous($happy_dom) { +//# sourceURL=http://localhost:8080/js/app/main.js + + const StringUtility = $happy_dom.imports.get('http://localhost:8080/js/utilities/StringUtility.js').default; + const { default: DefaultImageUtility } = $happy_dom.imports.get('http://localhost:8080/js/utilities/ImageUtility.js'); + const NumberUtility = $happy_dom.imports.get('http://localhost:8080/js/utilities/NumberUtility.js'); + + const result = await $happy_dom.dynamicImport('http://localhost:8080/js/utilities/StringUtility.js'); + + $happy_dom.exports['variable'] = 'hello'; + + $happy_dom.exports.default = class TestClass { + constructor() { + console.log('Hello World'); + } + } + +}`); + }); + + it('Ignores function suffixed with import().', () => { + const code = ` + async function test_import(url) { + return '"' + url + '"'; + } + + const result = await test_import('http://localhost:8080/js/utilities/StringUtility.js'); + `; + const compiler = new ECMAScriptModuleCompiler(window); + const result = compiler.compile('http://localhost:8080/js/app/main.js', code); + + expect(result.imports).toEqual([]); + + expect(result.execute.toString()).toBe(`async function anonymous($happy_dom) { +//# sourceURL=http://localhost:8080/js/app/main.js + + async function test_import(url) { + return '"' + url + '"'; + } + + const result = await test_import('http://localhost:8080/js/utilities/StringUtility.js'); + +}`); + }); + + it('Handles import and export with a various combinations.', () => { + const code = ` + import defaultExport1 from "stuff/defaultExport.js"; + import * as name from "stuff/name.js"; + import { export1 } from "stuff/export1.js"; + import { export2 as alias1 } from "stuff/export2.js"; + import { default as alias2 } from "stuff/default.js"; + import { export3, export4 } from "stuff/export3.js"; + import { export5, export6 as alias3, /* … */ } from "stuff/export4.js"; + import { "string name" as alias } from "stuff/stringName.js"; + import defaultExport2, { export7, /* … */ } from "stuff/defaultExport2.js"; + import defaultExport3, * as name2 from "stuff/defaultExport3.js"; + import JSON from 'json/data.json' with { type: "json" }; + import CSS from '../css/data.css' with { type: "css" }; + import "../run.js"; + import { export8, + export9 } from "stuff/export5.js"; + + // Comment + /* Comment */ + /** + *Comment import data from 'data' + ' + { + [ + / + */ + const variable = \`"'\\\`{[/\`; + const regexp = /import \\/data from 'data'/gm; + export default class TestClass { + constructor() { + console.log('export const variable = "\\'";'); + } + + async print() { + const data = await import('data/data.json', { with: { type: 'json' } }); + console.log(data); + + const data2 = await import('data/data.js'); + console.log(data2); + } + } + + if(test === 'export default class') { + const test = new TestClass(); + test.print("import data from 'data'"); + } + + export const variable = 'hello'; + export const variable2 = "he\\"ll\\"o"; + export const variable3 = \`export const variable = 'hello';\`; + export const arr = ['hello', "he\\"ll\\"o", \`hello\`]; + + // Exporting declarations + export let name1, name2; // also var + export const name3 = 1, name4 = 2; // also var, let + export function functionName() { /* … */ } + export class ClassName { /* … */ } + export function* generatorFunctionName() { /* … */ } + export const { name5, name6: bar } = o; + export const [ name7, name8 ] = array; + + // Export list + export { name9, name10 /* , !*/ }; + export { variable1 as name11, variable2 as name12, nameN }; + export { variable1 as "string name" }; + export { name1 as default }; + + // Aggregating modules + export * from "../aggregated1.js"; + export * as name1 from "../aggregated2.js"; + export { name1, /* …, */ nameN } from "../aggregated3.js"; + export { import1 as name1, import2 as name2, /* …, */ nameN } from "../aggregated4.js"; + export { default, /* …, */ } from "../aggregated5.js"; + export { default as name1 } from "../aggregated6.js"; + `; + + const compiler = new ECMAScriptModuleCompiler(window); + const result = compiler.compile('http://localhost:8080/js/app/main.js', code); + + expect(result.imports).toEqual([ + { url: 'http://localhost:8080/js/app/stuff/defaultExport.js', type: 'esm' }, + { url: 'http://localhost:8080/js/app/stuff/name.js', type: 'esm' }, + { url: 'http://localhost:8080/js/app/stuff/export1.js', type: 'esm' }, + { url: 'http://localhost:8080/js/app/stuff/export2.js', type: 'esm' }, + { url: 'http://localhost:8080/js/app/stuff/default.js', type: 'esm' }, + { url: 'http://localhost:8080/js/app/stuff/export3.js', type: 'esm' }, + { url: 'http://localhost:8080/js/app/stuff/export4.js', type: 'esm' }, + { url: 'http://localhost:8080/js/app/stuff/stringName.js', type: 'esm' }, + { url: 'http://localhost:8080/js/app/stuff/defaultExport2.js', type: 'esm' }, + { url: 'http://localhost:8080/js/app/stuff/defaultExport3.js', type: 'esm' }, + { url: 'http://localhost:8080/js/app/json/data.json', type: 'json' }, + { url: 'http://localhost:8080/js/css/data.css', type: 'css' }, + { url: 'http://localhost:8080/js/run.js', type: 'esm' }, + { url: 'http://localhost:8080/js/app/stuff/export5.js', type: 'esm' }, + { url: 'http://localhost:8080/js/aggregated1.js', type: 'esm' }, + { url: 'http://localhost:8080/js/aggregated2.js', type: 'esm' }, + { url: 'http://localhost:8080/js/aggregated3.js', type: 'esm' }, + { url: 'http://localhost:8080/js/aggregated4.js', type: 'esm' }, + { url: 'http://localhost:8080/js/aggregated5.js', type: 'esm' }, + { url: 'http://localhost:8080/js/aggregated6.js', type: 'esm' } + ]); + + expect(result.execute.toString()).toBe(`async function anonymous($happy_dom) { +//# sourceURL=http://localhost:8080/js/app/main.js + + const defaultExport1 = $happy_dom.imports.get('http://localhost:8080/js/app/stuff/defaultExport.js').default; + const name = $happy_dom.imports.get('http://localhost:8080/js/app/stuff/name.js'); + const { export1 } = $happy_dom.imports.get('http://localhost:8080/js/app/stuff/export1.js'); + const { export2: alias1 } = $happy_dom.imports.get('http://localhost:8080/js/app/stuff/export2.js'); + const { default: alias2 } = $happy_dom.imports.get('http://localhost:8080/js/app/stuff/default.js'); + const { export3, export4 } = $happy_dom.imports.get('http://localhost:8080/js/app/stuff/export3.js'); + const { export5, export6: alias3, /* … */ } = $happy_dom.imports.get('http://localhost:8080/js/app/stuff/export4.js'); + const { "string name": alias } = $happy_dom.imports.get('http://localhost:8080/js/app/stuff/stringName.js'); + const defaultExport2 = $happy_dom.imports.get('http://localhost:8080/js/app/stuff/defaultExport2.js').default; +const { export7, /* … */ } = $happy_dom.imports.get('http://localhost:8080/js/app/stuff/defaultExport2.js'); + const defaultExport3 = $happy_dom.imports.get('http://localhost:8080/js/app/stuff/defaultExport3.js').default; +const name2 = $happy_dom.imports.get('http://localhost:8080/js/app/stuff/defaultExport3.js'); + const JSON = $happy_dom.imports.get('http://localhost:8080/js/app/json/data.json').default; + const CSS = $happy_dom.imports.get('http://localhost:8080/js/css/data.css').default; + + const { export8, + export9 } = $happy_dom.imports.get('http://localhost:8080/js/app/stuff/export5.js'); + + // Comment + /* Comment */ + /** + *Comment import data from 'data' + ' + { + [ + / + */ + const variable = \`"'\\\`{[/\`; + const regexp = /import \\/data from 'data'/gm; + $happy_dom.exports.default = class TestClass { + constructor() { + console.log('export const variable = "\\'";'); + } + + async print() { + const data = await $happy_dom.dynamicImport('data/data.json', { with: { type: 'json' } }); + console.log(data); + + const data2 = await $happy_dom.dynamicImport('data/data.js'); + console.log(data2); + } + } + + if(test === 'export default class') { + const test = new TestClass(); + test.print("import data from 'data'"); + } + + $happy_dom.exports['variable'] = 'hello'; + $happy_dom.exports['variable2'] = "he\\"ll\\"o"; + $happy_dom.exports['variable3'] = \`export const variable = 'hello';\`; + $happy_dom.exports['arr'] = ['hello', "he\\"ll\\"o", \`hello\`]; + + // Exporting declarations + /*Unknown export: export let name1, name2;*/ // also var + $happy_dom.exports['name3'] = 1, name4 = 2; // also var, let + $happy_dom.exports['functionName'] = function functionName() { /* … */ } + $happy_dom.exports['ClassName'] = class ClassName { /* … */ } + $happy_dom.exports['generatorFunctionName'] = function* generatorFunctionName() { /* … */ } + const $happy_dom_export_0 = o; + const $happy_dom_export_1 = array; + + // Export list + $happy_dom.exports['name9'] = name9; +$happy_dom.exports['name10'] = name10; + $happy_dom.exports['name11'] = variable1; +$happy_dom.exports['name12'] = variable2; +$happy_dom.exports['nameN'] = nameN; + $happy_dom.exports['string name'] = variable1; + $happy_dom.exports['default'] = name1; + + // Aggregating modules + Object.assign($happy_dom.exports, $happy_dom.imports.get('http://localhost:8080/js/aggregated1.js')); + $happy_dom.exports['name1'] = $happy_dom.imports.get('http://localhost:8080/js/aggregated2.js'); + $happy_dom.exports['name1'] = $happy_dom.imports.get('http://localhost:8080/js/aggregated3.js')['name1']; +$happy_dom.exports['nameN'] = $happy_dom.imports.get('http://localhost:8080/js/aggregated3.js')['nameN']; + $happy_dom.exports['name1'] = $happy_dom.imports.get('http://localhost:8080/js/aggregated4.js')['import1']; +$happy_dom.exports['name2'] = $happy_dom.imports.get('http://localhost:8080/js/aggregated4.js')['import2']; +$happy_dom.exports['nameN'] = $happy_dom.imports.get('http://localhost:8080/js/aggregated4.js')['nameN']; + $happy_dom.exports['default'] = $happy_dom.imports.get('http://localhost:8080/js/aggregated5.js')['default']; + $happy_dom.exports['name1'] = $happy_dom.imports.get('http://localhost:8080/js/aggregated6.js')['default']; + + +$happy_dom.exports['name5'] = $happy_dom_export_0['name5']; +$happy_dom.exports['bar'] = $happy_dom_export_0['name6']; +$happy_dom.exports['name7'] = $happy_dom_export_1['name7']; +$happy_dom.exports['name8'] = $happy_dom_export_1['name8']; + +}`); + }); + + it('Handles export default function.', () => { + const code = ` + export const variable = /my-regexp/; + export default function () { + console.log('Hello World'); + } + `; + + const compiler = new ECMAScriptModuleCompiler(window); + const result = compiler.compile('http://localhost:8080/js/app/main.js', code); + + expect(result.imports).toEqual([]); + + expect(result.execute.toString()).toBe(`async function anonymous($happy_dom) { +//# sourceURL=http://localhost:8080/js/app/main.js + + $happy_dom.exports['variable'] = /my-regexp/; + $happy_dom.exports.default = function () { + console.log('Hello World'); + } + +}`); + }); + + it('Handles export default class.', () => { + const code = ` + export default class TestClass { + constructor() { + console.log('Hello World'); + } + } + `; + + const compiler = new ECMAScriptModuleCompiler(window); + const result = compiler.compile('http://localhost:8080/js/app/main.js', code); + + expect(result.imports).toEqual([]); + + expect(result.execute.toString()).toBe(`async function anonymous($happy_dom) { +//# sourceURL=http://localhost:8080/js/app/main.js + + $happy_dom.exports.default = class TestClass { + constructor() { + console.log('Hello World'); + } + } + +}`); + }); + + it('Handles export default generator function.', () => { + const code = ` + export default function* () { + yield i; + yield i + 10; + } + `; + + const compiler = new ECMAScriptModuleCompiler(window); + const result = compiler.compile('http://localhost:8080/js/app/main.js', code); + + expect(result.imports).toEqual([]); + + expect(result.execute.toString()).toBe(`async function anonymous($happy_dom) { +//# sourceURL=http://localhost:8080/js/app/main.js + + $happy_dom.exports.default = function* () { + yield i; + yield i + 10; + } + +}`); + }); + + it('Handles export default object.', () => { + const code = ` + export default { + test: 'test' + }; + `; + + const compiler = new ECMAScriptModuleCompiler(window); + const result = compiler.compile('http://localhost:8080/js/app/main.js', code); + + expect(result.imports).toEqual([]); + + expect(result.execute.toString()).toBe(`async function anonymous($happy_dom) { +//# sourceURL=http://localhost:8080/js/app/main.js + + $happy_dom.exports.default = { + test: 'test' + }; + +}`); + }); + + it('Handles export default expression.', () => { + const code = ` + export default (function () { + return { + test: 'test' + } + })(); + `; + + const compiler = new ECMAScriptModuleCompiler(window); + const result = compiler.compile('http://localhost:8080/js/app/main.js', code); + + expect(result.imports).toEqual([]); + + expect(result.execute.toString()).toBe(`async function anonymous($happy_dom) { +//# sourceURL=http://localhost:8080/js/app/main.js + + $happy_dom.exports.default = (function () { + return { + test: 'test' + } + })(); + +}`); + }); + + it('Adds try and catch statement if settings.errorCapture is set to "tryAndCatch".', () => { + const window = new Window(); + const code = ` + export default { + test: 'test' + }; + `; + + const compiler = new ECMAScriptModuleCompiler(window); + const result = compiler.compile('http://localhost:8080/js/app/main.js', code); + + expect(result.imports).toEqual([]); + + expect(result.execute.toString()).toBe(`async function anonymous($happy_dom) { +//# sourceURL=http://localhost:8080/js/app/main.js +try { + + $happy_dom.exports.default = { + test: 'test' + }; + +} catch(e) { + $happy_dom.dispatchError(e); +} +}`); + }); + + it('Handles special cases of RegExp.', () => { + const code = ` + const match = 'replace'.match(/replace/); + /test/.test('test') && (() => {})(); + const regexpes = [/test/, /test/]; + const templateString = \`\${'replace'.match(/replace/)[0]}\`; + const string = "/"; + const string2 = '/'; + const string3 = \`/\`; + const regexp1 = /[[[[[[[[[[]]/; + const regexp2 = /\\//i; + const regexp3 = /.*/i; + const regexp4 = /[/][/]/; + + export { regexp1, match, regexpes, templateString }; + `; + + const compiler = new ECMAScriptModuleCompiler(window); + const result = compiler.compile('http://localhost:8080/js/app/main.js', code); + + expect(result.imports).toEqual([]); + + expect(result.execute.toString()).toBe(`async function anonymous($happy_dom) { +//# sourceURL=http://localhost:8080/js/app/main.js + + const match = 'replace'.match(/replace/); + /test/.test('test') && (() => {})(); + const regexpes = [/test/, /test/]; + const templateString = \`\${'replace'.match(/replace/)[0]}\`; + const string = "/"; + const string2 = '/'; + const string3 = \`/\`; + const regexp1 = /[[[[[[[[[[]]/; + const regexp2 = /\\//i; + const regexp3 = /.*/i; + const regexp4 = /[/][/]/; + + $happy_dom.exports['regexp1'] = regexp1; +$happy_dom.exports['match'] = match; +$happy_dom.exports['regexpes'] = regexpes; +$happy_dom.exports['templateString'] = templateString; + +}`); + }); + + it('Handles string with escape character.', () => { + const code = ` + const string = "\\""; + const string2 = "\\\\"; + const string3 = '\\''; + const string4 = '\\\\'; + const string5 = \`\\\`\`; + const string6 = \`\\\\\`; + export { string, string2, string3, string4, string5, string6 }; + `; + + const compiler = new ECMAScriptModuleCompiler(window); + const result = compiler.compile('http://localhost:8080/js/app/main.js', code); + + expect(result.imports).toEqual([]); + + expect(result.execute.toString()).toBe(`async function anonymous($happy_dom) { +//# sourceURL=http://localhost:8080/js/app/main.js + + const string = "\\""; + const string2 = "\\\\"; + const string3 = '\\''; + const string4 = '\\\\'; + const string5 = \`\\\`\`; + const string6 = \`\\\\\`; + $happy_dom.exports['string'] = string; +$happy_dom.exports['string2'] = string2; +$happy_dom.exports['string3'] = string3; +$happy_dom.exports['string4'] = string4; +$happy_dom.exports['string5'] = string5; +$happy_dom.exports['string6'] = string6; + +}`); + }); + + it('Handles dynamic import inside template string.', () => { + const code = ` + export const func = async () => { + return \`test = \${({ test: \`\${ (await import('./test.js')) }\` })}\`; + }; + `; + + const compiler = new ECMAScriptModuleCompiler(window); + const result = compiler.compile('http://localhost:8080/js/app/main.js', code); + + expect(result.imports).toEqual([]); + + expect(result.execute.toString()).toBe(`async function anonymous($happy_dom) { +//# sourceURL=http://localhost:8080/js/app/main.js + + $happy_dom.exports['func'] = async () => { + return \`test = \${({ test: \`\${ (await $happy_dom.dynamicImport('./test.js')) }\` })}\`; + }; + +}`); + }); + + it('Handles vite preload library with minimzed import.', () => { + const code = `const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["static/js/Home-CsPrQa7_.js","static/js/preload-helper-BMSd6Up6.js","static/js/Router-yzXgKzu7.js","static/js/_commonjsHelpers-BosuxZz1.js","static/js/sizes-1ww1H62B.js","static/js/Choice-Bixrh5CR.js","static/js/index-OjqIgG3h.js","static/js/arrow-left-DVwQ9ese.js"])))=>i.map(i=>d[i]); +import{_ as c}from"./preload-helper-BMSd6Up6.js";class r{static connect(){const n=location.hash.match(/S{0,1}([0-9]{7,})$/);if(n){const a=new URLSearchParams(location.search);a.set("id",n[1]),location.href=new URL('example/?a=b',location.href).href;return}const t=location.hash.match(/\\/([a-zA-Z0-9-]{10,})$/);if(t){const a=new URLSearchParams(location.search);a.set("code",t[1]),location.href=new URL('example/?a=b',location.href).href;return}const o=location.hash.match(/\\/([a-zA-Z0-9]{4,6})$/);if(o){const a=new URLSearchParams(location.search);a.set("code",o[1]),location.href=new URL('example/?a=b',location.href).href;return}}}r.connect();c(()=>import("./Home-CsPrQa7_.js").then(e=>e.a),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13]));`; + + const compiler = new ECMAScriptModuleCompiler(window); + const result = compiler.compile('http://localhost:8080/js/app/main.js', code); + + expect(result.imports).toEqual([ + { url: 'http://localhost:8080/js/app/preload-helper-BMSd6Up6.js', type: 'esm' } + ]); + + expect(result.execute.toString()).toBe(`async function anonymous($happy_dom) { +//# sourceURL=http://localhost:8080/js/app/main.js +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["static/js/Home-CsPrQa7_.js","static/js/preload-helper-BMSd6Up6.js","static/js/Router-yzXgKzu7.js","static/js/_commonjsHelpers-BosuxZz1.js","static/js/sizes-1ww1H62B.js","static/js/Choice-Bixrh5CR.js","static/js/index-OjqIgG3h.js","static/js/arrow-left-DVwQ9ese.js"])))=>i.map(i=>d[i]); +const {_: c} = $happy_dom.imports.get('http://localhost:8080/js/app/preload-helper-BMSd6Up6.js');class r{static connect(){const n=location.hash.match(/S{0,1}([0-9]{7,})$/);if(n){const a=new URLSearchParams(location.search);a.set("id",n[1]),location.href=new URL('example/?a=b',location.href).href;return}const t=location.hash.match(/\\/([a-zA-Z0-9-]{10,})$/);if(t){const a=new URLSearchParams(location.search);a.set("code",t[1]),location.href=new URL('example/?a=b',location.href).href;return}const o=location.hash.match(/\\/([a-zA-Z0-9]{4,6})$/);if(o){const a=new URLSearchParams(location.search);a.set("code",o[1]),location.href=new URL('example/?a=b',location.href).href;return}}}r.connect();c(()=>$happy_dom.dynamicImport("./Home-CsPrQa7_.js").then(e=>e.a),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13])); +}`); + }); + + it('Handles vite with minimized export', () => { + const code = `(function(){const i=document.createElement("link").relList;if(i&&i.supports&&i.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))a(e);new MutationObserver(e=>{for(const r of e)if(r.type==="childList")for(const t of r.addedNodes)t.tagName==="LINK"&&t.rel==="modulepreload"&&a(t)}).observe(document,{childList:!0,subtree:!0});function c(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?r.credentials="include":e.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function a(e){if(e.ep)return;e.ep=!0;const r=c(e);fetch(e.href,r)}})();const h="modulepreload",y=function(u){return"/test/path/"+u},d={},g=function(i,c,a){let e=Promise.resolve();if(c&&c.length>0){document.getElementsByTagName("link");const t=document.querySelector("meta[property=csp-nonce]"),o=(t==null?void 0:t.nonce)||(t==null?void 0:t.getAttribute("nonce"));e=Promise.allSettled(c.map(n=>{if(n=y(n),n in d)return;d[n]=!0;const l=n.endsWith(".css"),f=l?'[rel="stylemodal"]':"";if(document.querySelector('link[href="'+n+'"]'+f+''))return;const s=document.createElement("link");if(s.rel=l?"stylemodal":h,l||(s.as="script"),s.crossOrigin="",s.href=n,o&&s.setAttribute("nonce",o),document.head.appendChild(s),l)return new Promise((m,p)=>{s.addEventListener("load",m),s.addEventListener("error",()=>p(new Error('Unable to preload CSS for ' + n)))})}))}function r(t){const o=new Event("vite:preloadError",{cancelable:!0});if(o.payload=t,window.dispatchEvent(o),!o.defaultPrevented)throw t}return e.then(t=>{for(const o of t||[])o.status==="rejected"&&r(o.reason);return i().catch(r)})};export{g as _};`; + + const compiler = new ECMAScriptModuleCompiler(window); + const result = compiler.compile('http://localhost:8080/js/app/main.js', code); + + expect(result.imports).toEqual([]); + + expect(result.execute.toString()).toBe( + `async function anonymous($happy_dom) { +//# sourceURL=http://localhost:8080/js/app/main.js +(function(){const i=document.createElement("link").relList;if(i&&i.supports&&i.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))a(e);new MutationObserver(e=>{for(const r of e)if(r.type==="childList")for(const t of r.addedNodes)t.tagName==="LINK"&&t.rel==="modulepreload"&&a(t)}).observe(document,{childList:!0,subtree:!0});function c(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?r.credentials="include":e.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function a(e){if(e.ep)return;e.ep=!0;const r=c(e);fetch(e.href,r)}})();const h="modulepreload",y=function(u){return"/test/path/"+u},d={},g=function(i,c,a){let e=Promise.resolve();if(c&&c.length>0){document.getElementsByTagName("link");const t=document.querySelector("meta[property=csp-nonce]"),o=(t==null?void 0:t.nonce)||(t==null?void 0:t.getAttribute("nonce"));e=Promise.allSettled(c.map(n=>{if(n=y(n),n in d)return;d[n]=!0;const l=n.endsWith(".css"),f=l?'[rel="stylemodal"]':"";if(document.querySelector('link[href="'+n+'"]'+f+''))return;const s=document.createElement("link");if(s.rel=l?"stylemodal":h,l||(s.as="script"),s.crossOrigin="",s.href=n,o&&s.setAttribute("nonce",o),document.head.appendChild(s),l)return new Promise((m,p)=>{s.addEventListener("load",m),s.addEventListener("error",()=>p(new Error('Unable to preload CSS for ' + n)))})}))}function r(t){const o=new Event("vite:preloadError",{cancelable:!0});if(o.payload=t,window.dispatchEvent(o),!o.defaultPrevented)throw t}return e.then(t=>{for(const o of t||[])o.status==="rejected"&&r(o.reason);return i().catch(r)})};$happy_dom.exports['_'] = g; +}` + ); + }); + + it('Handles real world example.', () => { + const code = `const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["static/js/preload-helper-BMSd6Up6.js","static/js/Router-yzXgKzu7.js","static/js/_commonjsHelpers-BosuxZz1.js","static/js/Choice-Bixrh5CR.js","static/js/index-OjqIgG3h.js","static/js/arrow-left-DVwQ9ese.js","static/js/Image-CMZuFGwN.js","static/js/IdGenerator-BXAguRov.js","static/js/resizeListener-BpJMTz31.js","static/js/menu-BQ9iRMnL.js","static/js/menu-DGNLEQ8L.js"])))=>i.map(i=>d[i]); +import{_ as a}from"./preload-helper-BMSd6Up6.js";import{k as g,s as A,r as C,t as P,h as o,R as c,m as O,F as V,u as B,n as M,A as f,P as $,v as G,w as _,q as W}from"./Router-yzXgKzu7.js";import{s as b}from"./sizes-1ww1H62B.js";import{C as F,S as d,r as U,b as I,I as H,s as N,g as j,i as K,d as q,T as E}from"./Choice-Bixrh5CR.js";import{K as R,W as Z}from"./index-OjqIgG3h.js";import"./arrow-left-DVwQ9ese.js";import{I as te}from"./Image-CMZuFGwN.js";const ie=[["&","&"],["<","<"],[">",">"],[\'"\',"""],["\'","'"],["/","/"]];class ae{static encodeForAttribute(e){for(const t of ie)e=e.replace(new RegExp(t[0],"gm"),t[1]+";");return e}}class ne{static templateToString(e,...t){let i="";for(let n=0,s=e.length;n{clearTimeout(this.resizeTimeout),this.resizeTimeout=setTimeout(()=>this.render())}).bind(this),this.attachShadow({mode:"open"})}async connectedCallback(){window.addEventListener("resize",this.resizeListener),this.render(),await Promise.all([(async()=>{this.settings=await A(),this.render()})(),(async()=>{this.translations=await C(),this.render()})(),a(()=>import("./AvatarButton-DpAonoMJ.js"),__vite__mapDeps([0,1,2,3,4,5,6,7,8]))])}disconnectedCallback(){clearTimeout(this.resizeTimeout),window.removeEventListener("resize",this.resizeListener)}attributeChangedCallback(){this.render()}render(){P(this.getTemplate(),this.shadowRoot)}getTemplate(){return o\` + + \${this.getLogoButton()} \${this.getBackButton()} +
+ \${this.getChoiceSelector()} \${this.getAvatarButton()} + \${this.getOCButton()} \${this.getTestExitButton()} +
+ \`}getLogoButton(){return this.hasAttribute("showLogo")?o\` + + EXAMPLE + + \`:null}getBackButton(){var e;return this.hasAttribute("showBackButton")?o\` + + \`:null}getChoiceSelector(){return g()?o\` + + \`:null}getAvatarButton(){return g()||!O(this.settings,V.loginEnabled)?null:o\` + + \`}getOCButton(){var t;const e=this.isMobile();return e?o\` + \${((t=this.translations)==null?void 0:t.test.open)??""} + \`:null}getTestExitButton(){var i;const e=new R({platform:"test"}),t=this.isMobile();return B()?o\` + \${((i=this.translations)==null?void 0:i.test.exitApplication)??""} + \`:null}onChoiceChange(){}isRTL(){var e;return((e=this.settings)==null?void 0:e.localisation.writeDirection)===Z.rtl}onManualClick(){}onStartClick(){}onImagesClick(){}isMobile(){return window.innerWidth<=b.mobile.max}};x.observedAttributes=["showLogo","showBackButton","backButtonTarget","visible"];let v=x;window.customElements.define("test-header",v);class le extends Y{}window.customElements.define("test-mo-language-selector",le);const ge=":host{cursor:pointer;display:block}*{box-sizing:border-box}mo-aspect-ratio{display:flex}.image>*{min-height:100%}a{display:flex;flex-direction:column;height:100%;text-decoration:none}a:not(.with-image){border-top:1px solid rgb(var(--colour-neutral-3,223,223,223))}a:not(.with-image) .image{display:none}.info{display:flex;flex-direction:column;padding:1.5rem;padding-inline-start:0}:focus-visible [name=title]::slotted(*),:host(:hover) [name=title]::slotted(*){text-decoration:underline}a:focus:not(:focus-visible){outline:none}a:focus-visible{box-shadow:0 0 0 4px var(--mo-focus-ring-internal,rgb(var(--colour-neutral-1,255,255,255)));outline:2px solid var(--mo-focus-ring-external,rgb(var(--colour-neutral-7,17,17,17)));outline-offset:4px;transition:outline .2s ease-out,box-shadow .2s ease-out}[name=image]::slotted(*){max-height:100%;max-width:100%;min-height:100%;object-fit:cover;width:100%}[name=title]{display:block;margin-top:.5rem}[name=title]::slotted(*){color:rgb(var(--colour-text-and-icon-1,17,17,17));font-size:1.125rem;letter-spacing:-.0042em;line-height:1.444}p{color:rgb(var(--colour-text-and-icon-2,72,72,72));margin:1rem 0 0;padding:0}[name=cta]{display:inline-block;margin-top:1.5rem}[name=label]::slotted(*){color:rgb(var(--colour-text-and-icon-2,72,72,72));font-size:.75rem;line-height:1.5}:host(:not([emphasised])) #btn{display:none}:host(:not([emphasised])) #btn+*{color:rgb(var(--colour-text-and-icon-1,17,17,17));margin-top:-.5rem}:host([emphasised]){background-color:rgb(var(--colour-neutral-2,245,245,245))}:host([emphasised]) a{border-top:0}:host([emphasised]) .info{flex-grow:1;padding-inline-start:1.5rem}:host([emphasised]) #btn{margin-top:1.5rem}:host([emphasised]) #btn+[icon]{display:none}:host([emphasised]) .text-wrapper{flex-grow:1}:host([large]){font-size:1rem;line-height:1.625}:host([large]) [name=title]::slotted(*){font-size:1.5rem;line-height:1.45}:host([expand]) .image{flex-shrink:0}:host([expand]) .info{flex-grow:1}:host([expand]) .text-wrapper{max-width:30rem}@media (min-width:37.5em){:host(:not([expand])) .info{padding-inline-end:10rem}:host(:not([expand])):host([emphasised]) .info{padding:2.5rem;padding-inline-end:7.5rem}:host(:not([expand])):host([emphasised]) [name=cta]{margin-top:1.5rem}:host(:not([expand])) [name=title]::slotted(*){font-size:1.5rem;line-height:1.45}:host(:not([expand])):host([large]) [name=title]::slotted(*){font-size:2.25rem;line-height:1.333}:host(:not([expand])):host([emphasised]) #btn{margin-top:2.5rem}}@media (min-width:75em){:host(:not([expand])) a{flex-direction:row}:host(:not([expand])) .image{flex-basis:60%;max-width:60%}:host(:not([expand])) .info{padding-bottom:0;padding-top:0}:host(:not([expand])) a.with-image .info{flex-basis:40%;max-width:40%;padding-inline-end:4rem;padding-inline-start:3rem}:host(:not([expand])) .text-wrapper{max-width:37.5rem}:host(:not([expand])):host([trailing-image]) .image{order:1}:host(:not([expand])):host([emphasised]) .info{padding:3rem;padding-inline-end:4rem}:host(:not([expand])):host([emphasised]) [name=cta]{margin-top:1.5rem}}:host([inverse]) [name=label]::slotted(*),:host([inverse]) [name=title]::slotted(*),:host([inverse]) p{color:inherit}:host([inverse]) .info{color:#fff}",ce="mo-card[inverse]>*{color:inherit}";function de({html:r,prefixReplacer:e}){const t=document.createElement("template");t.innerHTML=r\` + + +
+ + + + + +
+
+
+ + +

+
+ + + + +
+
+ + \`;const i=document.createElement("style");return i.slot="light-styles",i.innerHTML=e(ce.toString()),{main:t,lightStyleTemplate:i}}class h extends N{constructor(){super(),this.handleImageMode=()=>{this.anchor.classList.toggle("with-image",!!this.imageSlot.assignedNodes({flatten:!0}).length)},this.syncInverseLightStyle=()=>{this.inverse?this.append(this.__lightStyleNode):this.__lightStyleNode.remove()},j(["url","emphasised","large","expand","target","inverse","trailingImage","imageRatio"],this);const e=this.shadow=this.attachShadow({mode:"open",delegatesFocus:!0}),t=this.getTemplates(de);e.append(t.main.content.cloneNode(!0)),this.imageSlot=e.querySelector("[name=image]"),this.imageSlot.addEventListener("slotchange",this.handleImageMode),this.aspectRatio=e.getElementById("aspectratio"),this.__lightStyleNode=t.lightStyleTemplate.cloneNode(!0)}connectedCallback(){K(this),this.syncInverseLightStyle()}get anchor(){return this.shadow.querySelector("a")}get emphasised(){return this.hasAttribute("emphasised")}set emphasised(e){this.toggleAttribute("emphasised",!!e)}get large(){return this.hasAttribute("large")}set large(e){this.toggleAttribute("large",!!e)}get expand(){return this.hasAttribute("expand")}set expand(e){this.toggleAttribute("expand",!!e)}get trailingImage(){return this.hasAttribute("trailing-image")}set trailingImage(e){this.toggleAttribute("trailing-image",!!e)}set url(e){e?this.setAttribute("url",e):this.removeAttribute("url")}get url(){return this.getAttribute("url")}set target(e){e?this.setAttribute("target",e):this.removeAttribute("target")}get target(){return this.anchor.getAttribute("target")}get inverse(){return this.hasAttribute("inverse")}set inverse(e){this.toggleAttribute("inverse",!!e)}set imageRatio(e){e?this.aspectRatio.ratio=e:this.aspectRatio.ratio="wide"}get imageRatio(){return this.aspectRatio.getAttribute("ratio")||"wide"}attributeChangedCallback(e,t,i){e==="url"?i?this.anchor.setAttribute("href",i):this.anchor.removeAttribute("href"):e==="target"?i?this.anchor.setAttribute("target",i):this.anchor.removeAttribute("target"):e==="image-ratio"?i?this.aspectRatio.ratio=i:this.aspectRatio.ratio="wide":e==="inverse"&&this.syncInverseLightStyle()}}h.moTagName="card";h.moDependencies=[Q,H,ee,te];h.moIconDependencies=[I];h.observedAttributes=["url","inverse","target","image-ratio"];q(h);class he extends h{}window.customElements.define("test-mo-card",he);class me{static async getConfig(e){switch(e){case"test":return(await a(async()=>{const{default:t}=await import("./test-BY1dCHQi.js");return{default:t}},__vite__mapDeps([9,10]))).default;case"test":return(await a(async()=>{const{default:t}=await import("./test-Cd0OuqPp.js");return{default:t}},__vite__mapDeps([11,10]))).default;case"test":return(await a(async()=>{const{default:t}=await import("./test-D25rLxjc.js");return{default:t}},__vite__mapDeps([12,10]))).default;case"test":return(await a(async()=>{const{default:t}=await import("./test-DnBXNr7O.js");return{default:t}},__vite__mapDeps([13,10]))).default;case"test":return(await a(async()=>{const{default:t}=await import("./test-inuuqIOP.js");return{default:t}},__vite__mapDeps([14,10]))).default;case"test":return(await a(async()=>{const{default:t}=await import("./test-BOxWudPX.js");return{default:t}},__vite__mapDeps([15,10]))).default;case"test":return(await a(async()=>{const{default:t}=await import("./test-BWME_xib.js");return{default:t}},__vite__mapDeps([16,10]))).default;case"test":return(await a(async()=>{const{default:t}=await import("./test-CsDigEG0.js");return{default:t}},[])).default;case"test":return(await a(async()=>{const{default:t}=await import("./test-BkT8Eqai.js");return{default:t}},__vite__mapDeps([17,10]))).default;case"test":return(await a(async()=>{const{default:t}=await import("./test-8ntVW3Sr.js");return{default:t}},__vite__mapDeps([18,10]))).default;case"test":return(await a(async()=>{const{default:t}=await import("./test-CzkC7444.js");return{default:t}},__vite__mapDeps([19,10]))).default;case"test":return(await a(async()=>{const{default:t}=await import("./test-BdWyzcmt.js");return{default:t}},__vite__mapDeps([20,10]))).default;default:throw new Error(\`Failed to load landing page config for list "\${e}": No landing page config found.\`)}}}class z extends HTMLElement{constructor(){super(),this.translations=null,this.settings=null,this.resizeTimeout=null,this.resizeListener=(()=>{clearTimeout(this.resizeTimeout),this.resizeTimeout=setTimeout(()=>this.render())}).bind(this),this.homeConfig=null,this.serviceVisible=!1,this.attachShadow({mode:"open"})}async connectedCallback(){window.addEventListener("resize",this.resizeListener),c.connect(),g()&&S.connect();const e=new URLSearchParams(location.search),t=e.get("state");e.has("code")&&t==="service"&&(e.delete("state"),c.replace(\`\${location.href.split("?")[0]}?\${e}\`),this.serviceVisible=!0);const i=M(c.getHistory()[0]);f.sendNavigation({toURL:window.location.href,fromURL:c.getHistory()[0]||"",userLeftFromBackButton:i===$.planner||i===$.example}),this.render(),await Promise.all([a(()=>import("./Grid-BgS5-T1x.js"),__vite__mapDeps([21,2,3,22])),a(()=>import("./ContentMargin-1jDUK1kE.js"),__vite__mapDeps([23,1,2,3,22,5,24,4,25,26,7,27,28,29])),a(()=>import("./BannerMessage-1AfPgQtA.js"),__vite__mapDeps([30,2,3,31,5,4,32,25,33,1,22,24,26,7,27,28,29])),a(()=>import("./HomeGrid-D4PHTTN7.js"),__vite__mapDeps([34,2,3,22,1,5,24,4,25,26,7,27,28,29])),a(()=>import("./HomeImages-m6SThIqd.js").then(n=>n.L),__vite__mapDeps([35,2,3,5,22,36,37,38,39,40,4,41,42,43,44,45,46,47,48,26,7,49,50,51,52,1])),a(()=>import("./ServiceModal-B38FtOmo.js"),__vite__mapDeps([53,2,3,54,41,5,6,4,31,42,45,55,39,56,57,32,58,50,51,52,59,33,28])),(async()=>{this.settings=await A(),g()&&(new X({settings:this.settings}).clearZipCode(),new J({settings:this.settings}).setStoreId(G()||"")),this.render()})(),(async()=>{this.translations=await C(),this.render()})(),(async()=>{this.homeConfig=await me.getConfig(_()),this.render()})()]),a(()=>import("./Test-uBcfBNpa.js"),__vite__mapDeps([60,1,2,3,5,61,62,38,39,40,4,41,42,48,33,7,63,64,25,22]))}async disconnectedCallback(){clearTimeout(this.resizeTimeout),window.removeEventListener("resize",this.resizeListener),g()&&S.disconnect()}render(){P(this.getTemplate(),this.shadowRoot)}getTemplate(){var s,l;const e=((s=this.settings)==null?void 0:s.images.test)??"",t=this.isMobile(),i=W().get("testImages")==="true"&&!!e,n=!i;return o\` + + +
+ +
+ + +
+ \${t?"":o\` + + \`} +
\${this.getTitle()}
+
+ + +
+ \${this.getHomeImages()} +
+
+
+ \`}getHomeImages(){var n,s,l,m,p,u,y,T,L;const e=_(),t=this.isMobile(),i=((s=(n=this.settings)==null?void 0:n.general)==null?void 0:s.bookServiceEnabled)&&!g();return o\` + + + \${e==="test"&&t?o\` + + +

+ \${((m=this.translations)==null?void 0:m.test.homeManualTitle)??""} +

+ + \${(p=this.translations)==null?void 0:p.test.homeManualDescription} + +
+
+ \`:""} + \${i&&o\` + + +

\${((T=this.translations)==null?void 0:T.test.serviceSession)??""}

+ + \${(L=this.translations)==null?void 0:L.test.serviceSessionDescription} + +
+
+ \`||""} +
+ + \`}getTitle(){var i,n,s,l,m,p,u;const e=_().toUpperCase();if(this.isMobile())return o\` +

+ + \${(i=this.translations)==null?void 0:i.test.a} + \${e} + \${e==="TEST"?(n=this.translations)==null?void 0:n.test.b:(s=this.translations)==null?void 0:s.test.c} + +

+ \`;const t=((l=this.homeConfig)==null?void 0:l.listItems.titleTextColor)||"#111";return o\` +

+ + \${(m=this.translations)==null?void 0:m.test.d} + \${e} + \${e==="TEST"?(p=this.translations)==null?void 0:p.test.e:(u=this.translations)==null?void 0:u.test.f} + +

+ \`}onManualClick(e){f.sendHomeSelectStart("example"),e.preventDefault(),c.goto(\`example/\${location.search}\`)}onServiceClick(){f.sendBookService(w.home)}onServiceClose(){var t,i;this.serviceVisible=!1,this.render();const e=((i=(t=this.settings)==null?void 0:t.general)==null?void 0:i.enableHome)??!1;f.sendToggleModal({modal:e?"ServiceModal":"LoadModal",visible:!1,source:w.home})}onServiceClick(){var t,i;const e=((i=(t=this.settings)==null?void 0:t.general)==null?void 0:i.enableHome)??!1;f.sendToggleModal({modal:e?"ServiceModal":"LoadModal",visible:!0,source:w.home}),this.serviceVisible=!0,this.render()}onBackButtonClick(){var i;const e=new R({platform:"test"}),t=((i=this.settings)==null?void 0:i.test.test)??"";t.startsWith("/")?window.history.pushState(null,t):t.startsWith("http")&&window.location.replace(t),B()&&e.backClicked()}isMobile(){return window.innerWidth<=b.mobile.max}}window.customElements.define("test-home-page",z);const Ee=Object.freeze(Object.defineProperty({__proto__:null,default:z},Symbol.toStringTag,{value:"Module"}));export{me as L,Ee as a,D as c,$e as h}; +`; + + const compiler = new ECMAScriptModuleCompiler(window); + const result = compiler.compile('http://localhost:8080/js/app/main.js', code); + + expect(result.imports).toEqual([ + { + type: 'esm', + url: 'http://localhost:8080/js/app/preload-helper-BMSd6Up6.js' + }, + { + type: 'esm', + url: 'http://localhost:8080/js/app/Router-yzXgKzu7.js' + }, + { + type: 'esm', + url: 'http://localhost:8080/js/app/sizes-1ww1H62B.js' + }, + { + type: 'esm', + url: 'http://localhost:8080/js/app/Choice-Bixrh5CR.js' + }, + { + type: 'esm', + url: 'http://localhost:8080/js/app/index-OjqIgG3h.js' + }, + { + type: 'esm', + url: 'http://localhost:8080/js/app/arrow-left-DVwQ9ese.js' + }, + { + type: 'esm', + url: 'http://localhost:8080/js/app/Image-CMZuFGwN.js' + } + ]); + + expect(result.execute.toString()).toBe(`async function anonymous($happy_dom) { +//# sourceURL=http://localhost:8080/js/app/main.js +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["static/js/preload-helper-BMSd6Up6.js","static/js/Router-yzXgKzu7.js","static/js/_commonjsHelpers-BosuxZz1.js","static/js/Choice-Bixrh5CR.js","static/js/index-OjqIgG3h.js","static/js/arrow-left-DVwQ9ese.js","static/js/Image-CMZuFGwN.js","static/js/IdGenerator-BXAguRov.js","static/js/resizeListener-BpJMTz31.js","static/js/menu-BQ9iRMnL.js","static/js/menu-DGNLEQ8L.js"])))=>i.map(i=>d[i]); +const {_: a} = $happy_dom.imports.get('http://localhost:8080/js/app/preload-helper-BMSd6Up6.js');const {k: g,s: A,r: C,t: P,h: o,R: c,m: O,F: V,u: B,n: M,A: f,P: $,v: G,w: _,q: W} = $happy_dom.imports.get('http://localhost:8080/js/app/Router-yzXgKzu7.js');const {s: b} = $happy_dom.imports.get('http://localhost:8080/js/app/sizes-1ww1H62B.js');const {C: F,S: d,r: U,b: I,I: H,s: N,g: j,i: K,d: q,T: E} = $happy_dom.imports.get('http://localhost:8080/js/app/Choice-Bixrh5CR.js');const {K: R,W: Z} = $happy_dom.imports.get('http://localhost:8080/js/app/index-OjqIgG3h.js');const {I: te} = $happy_dom.imports.get('http://localhost:8080/js/app/Image-CMZuFGwN.js');const ie=[["&","&"],["<","<"],[">",">"],['"',"""],["'","'"],["/","/"]];class ae{static encodeForAttribute(e){for(const t of ie)e=e.replace(new RegExp(t[0],"gm"),t[1]+";");return e}}class ne{static templateToString(e,...t){let i="";for(let n=0,s=e.length;n{clearTimeout(this.resizeTimeout),this.resizeTimeout=setTimeout(()=>this.render())}).bind(this),this.attachShadow({mode:"open"})}async connectedCallback(){window.addEventListener("resize",this.resizeListener),this.render(),await Promise.all([(async()=>{this.settings=await A(),this.render()})(),(async()=>{this.translations=await C(),this.render()})(),a(()=>$happy_dom.dynamicImport("./AvatarButton-DpAonoMJ.js"),__vite__mapDeps([0,1,2,3,4,5,6,7,8]))])}disconnectedCallback(){clearTimeout(this.resizeTimeout),window.removeEventListener("resize",this.resizeListener)}attributeChangedCallback(){this.render()}render(){P(this.getTemplate(),this.shadowRoot)}getTemplate(){return o\` + + \${this.getLogoButton()} \${this.getBackButton()} +
+ \${this.getChoiceSelector()} \${this.getAvatarButton()} + \${this.getOCButton()} \${this.getTestExitButton()} +
+ \`}getLogoButton(){return this.hasAttribute("showLogo")?o\` + + EXAMPLE + + \`:null}getBackButton(){var e;return this.hasAttribute("showBackButton")?o\` + + \`:null}getChoiceSelector(){return g()?o\` + + \`:null}getAvatarButton(){return g()||!O(this.settings,V.loginEnabled)?null:o\` + + \`}getOCButton(){var t;const e=this.isMobile();return e?o\` + \${((t=this.translations)==null?void 0:t.test.open)??""} + \`:null}getTestExitButton(){var i;const e=new R({platform:"test"}),t=this.isMobile();return B()?o\` + \${((i=this.translations)==null?void 0:i.test.exitApplication)??""} + \`:null}onChoiceChange(){}isRTL(){var e;return((e=this.settings)==null?void 0:e.localisation.writeDirection)===Z.rtl}onManualClick(){}onStartClick(){}onImagesClick(){}isMobile(){return window.innerWidth<=b.mobile.max}};x.observedAttributes=["showLogo","showBackButton","backButtonTarget","visible"];let v=x;window.customElements.define("test-header",v);class le extends Y{}window.customElements.define("test-mo-language-selector",le);const ge=":host{cursor:pointer;display:block}*{box-sizing:border-box}mo-aspect-ratio{display:flex}.image>*{min-height:100%}a{display:flex;flex-direction:column;height:100%;text-decoration:none}a:not(.with-image){border-top:1px solid rgb(var(--colour-neutral-3,223,223,223))}a:not(.with-image) .image{display:none}.info{display:flex;flex-direction:column;padding:1.5rem;padding-inline-start:0}:focus-visible [name=title]::slotted(*),:host(:hover) [name=title]::slotted(*){text-decoration:underline}a:focus:not(:focus-visible){outline:none}a:focus-visible{box-shadow:0 0 0 4px var(--mo-focus-ring-internal,rgb(var(--colour-neutral-1,255,255,255)));outline:2px solid var(--mo-focus-ring-external,rgb(var(--colour-neutral-7,17,17,17)));outline-offset:4px;transition:outline .2s ease-out,box-shadow .2s ease-out}[name=image]::slotted(*){max-height:100%;max-width:100%;min-height:100%;object-fit:cover;width:100%}[name=title]{display:block;margin-top:.5rem}[name=title]::slotted(*){color:rgb(var(--colour-text-and-icon-1,17,17,17));font-size:1.125rem;letter-spacing:-.0042em;line-height:1.444}p{color:rgb(var(--colour-text-and-icon-2,72,72,72));margin:1rem 0 0;padding:0}[name=cta]{display:inline-block;margin-top:1.5rem}[name=label]::slotted(*){color:rgb(var(--colour-text-and-icon-2,72,72,72));font-size:.75rem;line-height:1.5}:host(:not([emphasised])) #btn{display:none}:host(:not([emphasised])) #btn+*{color:rgb(var(--colour-text-and-icon-1,17,17,17));margin-top:-.5rem}:host([emphasised]){background-color:rgb(var(--colour-neutral-2,245,245,245))}:host([emphasised]) a{border-top:0}:host([emphasised]) .info{flex-grow:1;padding-inline-start:1.5rem}:host([emphasised]) #btn{margin-top:1.5rem}:host([emphasised]) #btn+[icon]{display:none}:host([emphasised]) .text-wrapper{flex-grow:1}:host([large]){font-size:1rem;line-height:1.625}:host([large]) [name=title]::slotted(*){font-size:1.5rem;line-height:1.45}:host([expand]) .image{flex-shrink:0}:host([expand]) .info{flex-grow:1}:host([expand]) .text-wrapper{max-width:30rem}@media (min-width:37.5em){:host(:not([expand])) .info{padding-inline-end:10rem}:host(:not([expand])):host([emphasised]) .info{padding:2.5rem;padding-inline-end:7.5rem}:host(:not([expand])):host([emphasised]) [name=cta]{margin-top:1.5rem}:host(:not([expand])) [name=title]::slotted(*){font-size:1.5rem;line-height:1.45}:host(:not([expand])):host([large]) [name=title]::slotted(*){font-size:2.25rem;line-height:1.333}:host(:not([expand])):host([emphasised]) #btn{margin-top:2.5rem}}@media (min-width:75em){:host(:not([expand])) a{flex-direction:row}:host(:not([expand])) .image{flex-basis:60%;max-width:60%}:host(:not([expand])) .info{padding-bottom:0;padding-top:0}:host(:not([expand])) a.with-image .info{flex-basis:40%;max-width:40%;padding-inline-end:4rem;padding-inline-start:3rem}:host(:not([expand])) .text-wrapper{max-width:37.5rem}:host(:not([expand])):host([trailing-image]) .image{order:1}:host(:not([expand])):host([emphasised]) .info{padding:3rem;padding-inline-end:4rem}:host(:not([expand])):host([emphasised]) [name=cta]{margin-top:1.5rem}}:host([inverse]) [name=label]::slotted(*),:host([inverse]) [name=title]::slotted(*),:host([inverse]) p{color:inherit}:host([inverse]) .info{color:#fff}",ce="mo-card[inverse]>*{color:inherit}";function de({html:r,prefixReplacer:e}){const t=document.createElement("template");t.innerHTML=r\` + + +
+ + + + + +
+
+
+ + +

+
+ + + + +
+
+ + \`;const i=document.createElement("style");return i.slot="light-styles",i.innerHTML=e(ce.toString()),{main:t,lightStyleTemplate:i}}class h extends N{constructor(){super(),this.handleImageMode=()=>{this.anchor.classList.toggle("with-image",!!this.imageSlot.assignedNodes({flatten:!0}).length)},this.syncInverseLightStyle=()=>{this.inverse?this.append(this.__lightStyleNode):this.__lightStyleNode.remove()},j(["url","emphasised","large","expand","target","inverse","trailingImage","imageRatio"],this);const e=this.shadow=this.attachShadow({mode:"open",delegatesFocus:!0}),t=this.getTemplates(de);e.append(t.main.content.cloneNode(!0)),this.imageSlot=e.querySelector("[name=image]"),this.imageSlot.addEventListener("slotchange",this.handleImageMode),this.aspectRatio=e.getElementById("aspectratio"),this.__lightStyleNode=t.lightStyleTemplate.cloneNode(!0)}connectedCallback(){K(this),this.syncInverseLightStyle()}get anchor(){return this.shadow.querySelector("a")}get emphasised(){return this.hasAttribute("emphasised")}set emphasised(e){this.toggleAttribute("emphasised",!!e)}get large(){return this.hasAttribute("large")}set large(e){this.toggleAttribute("large",!!e)}get expand(){return this.hasAttribute("expand")}set expand(e){this.toggleAttribute("expand",!!e)}get trailingImage(){return this.hasAttribute("trailing-image")}set trailingImage(e){this.toggleAttribute("trailing-image",!!e)}set url(e){e?this.setAttribute("url",e):this.removeAttribute("url")}get url(){return this.getAttribute("url")}set target(e){e?this.setAttribute("target",e):this.removeAttribute("target")}get target(){return this.anchor.getAttribute("target")}get inverse(){return this.hasAttribute("inverse")}set inverse(e){this.toggleAttribute("inverse",!!e)}set imageRatio(e){e?this.aspectRatio.ratio=e:this.aspectRatio.ratio="wide"}get imageRatio(){return this.aspectRatio.getAttribute("ratio")||"wide"}attributeChangedCallback(e,t,i){e==="url"?i?this.anchor.setAttribute("href",i):this.anchor.removeAttribute("href"):e==="target"?i?this.anchor.setAttribute("target",i):this.anchor.removeAttribute("target"):e==="image-ratio"?i?this.aspectRatio.ratio=i:this.aspectRatio.ratio="wide":e==="inverse"&&this.syncInverseLightStyle()}}h.moTagName="card";h.moDependencies=[Q,H,ee,te];h.moIconDependencies=[I];h.observedAttributes=["url","inverse","target","image-ratio"];q(h);class he extends h{}window.customElements.define("test-mo-card",he);class me{static async getConfig(e){switch(e){case"test":return(await a(async()=>{const{default:t}=await $happy_dom.dynamicImport("./test-BY1dCHQi.js");return{default:t}},__vite__mapDeps([9,10]))).default;case"test":return(await a(async()=>{const{default:t}=await $happy_dom.dynamicImport("./test-Cd0OuqPp.js");return{default:t}},__vite__mapDeps([11,10]))).default;case"test":return(await a(async()=>{const{default:t}=await $happy_dom.dynamicImport("./test-D25rLxjc.js");return{default:t}},__vite__mapDeps([12,10]))).default;case"test":return(await a(async()=>{const{default:t}=await $happy_dom.dynamicImport("./test-DnBXNr7O.js");return{default:t}},__vite__mapDeps([13,10]))).default;case"test":return(await a(async()=>{const{default:t}=await $happy_dom.dynamicImport("./test-inuuqIOP.js");return{default:t}},__vite__mapDeps([14,10]))).default;case"test":return(await a(async()=>{const{default:t}=await $happy_dom.dynamicImport("./test-BOxWudPX.js");return{default:t}},__vite__mapDeps([15,10]))).default;case"test":return(await a(async()=>{const{default:t}=await $happy_dom.dynamicImport("./test-BWME_xib.js");return{default:t}},__vite__mapDeps([16,10]))).default;case"test":return(await a(async()=>{const{default:t}=await $happy_dom.dynamicImport("./test-CsDigEG0.js");return{default:t}},[])).default;case"test":return(await a(async()=>{const{default:t}=await $happy_dom.dynamicImport("./test-BkT8Eqai.js");return{default:t}},__vite__mapDeps([17,10]))).default;case"test":return(await a(async()=>{const{default:t}=await $happy_dom.dynamicImport("./test-8ntVW3Sr.js");return{default:t}},__vite__mapDeps([18,10]))).default;case"test":return(await a(async()=>{const{default:t}=await $happy_dom.dynamicImport("./test-CzkC7444.js");return{default:t}},__vite__mapDeps([19,10]))).default;case"test":return(await a(async()=>{const{default:t}=await $happy_dom.dynamicImport("./test-BdWyzcmt.js");return{default:t}},__vite__mapDeps([20,10]))).default;default:throw new Error(\`Failed to load landing page config for list "\${e}": No landing page config found.\`)}}}class z extends HTMLElement{constructor(){super(),this.translations=null,this.settings=null,this.resizeTimeout=null,this.resizeListener=(()=>{clearTimeout(this.resizeTimeout),this.resizeTimeout=setTimeout(()=>this.render())}).bind(this),this.homeConfig=null,this.serviceVisible=!1,this.attachShadow({mode:"open"})}async connectedCallback(){window.addEventListener("resize",this.resizeListener),c.connect(),g()&&S.connect();const e=new URLSearchParams(location.search),t=e.get("state");e.has("code")&&t==="service"&&(e.delete("state"),c.replace(\`\${location.href.split("?")[0]}?\${e}\`),this.serviceVisible=!0);const i=M(c.getHistory()[0]);f.sendNavigation({toURL:window.location.href,fromURL:c.getHistory()[0]||"",userLeftFromBackButton:i===$.planner||i===$.example}),this.render(),await Promise.all([a(()=>$happy_dom.dynamicImport("./Grid-BgS5-T1x.js"),__vite__mapDeps([21,2,3,22])),a(()=>$happy_dom.dynamicImport("./ContentMargin-1jDUK1kE.js"),__vite__mapDeps([23,1,2,3,22,5,24,4,25,26,7,27,28,29])),a(()=>$happy_dom.dynamicImport("./BannerMessage-1AfPgQtA.js"),__vite__mapDeps([30,2,3,31,5,4,32,25,33,1,22,24,26,7,27,28,29])),a(()=>$happy_dom.dynamicImport("./HomeGrid-D4PHTTN7.js"),__vite__mapDeps([34,2,3,22,1,5,24,4,25,26,7,27,28,29])),a(()=>$happy_dom.dynamicImport("./HomeImages-m6SThIqd.js").then(n=>n.L),__vite__mapDeps([35,2,3,5,22,36,37,38,39,40,4,41,42,43,44,45,46,47,48,26,7,49,50,51,52,1])),a(()=>$happy_dom.dynamicImport("./ServiceModal-B38FtOmo.js"),__vite__mapDeps([53,2,3,54,41,5,6,4,31,42,45,55,39,56,57,32,58,50,51,52,59,33,28])),(async()=>{this.settings=await A(),g()&&(new X({settings:this.settings}).clearZipCode(),new J({settings:this.settings}).setStoreId(G()||"")),this.render()})(),(async()=>{this.translations=await C(),this.render()})(),(async()=>{this.homeConfig=await me.getConfig(_()),this.render()})()]),a(()=>$happy_dom.dynamicImport("./Test-uBcfBNpa.js"),__vite__mapDeps([60,1,2,3,5,61,62,38,39,40,4,41,42,48,33,7,63,64,25,22]))}async disconnectedCallback(){clearTimeout(this.resizeTimeout),window.removeEventListener("resize",this.resizeListener),g()&&S.disconnect()}render(){P(this.getTemplate(),this.shadowRoot)}getTemplate(){var s,l;const e=((s=this.settings)==null?void 0:s.images.test)??"",t=this.isMobile(),i=W().get("testImages")==="true"&&!!e,n=!i;return o\` + + +
+ +
+ + +
+ \${t?"":o\` + + \`} +
\${this.getTitle()}
+
+ + +
+ \${this.getHomeImages()} +
+
+
+ \`}getHomeImages(){var n,s,l,m,p,u,y,T,L;const e=_(),t=this.isMobile(),i=((s=(n=this.settings)==null?void 0:n.general)==null?void 0:s.bookServiceEnabled)&&!g();return o\` + + + \${e==="test"&&t?o\` + + +

+ \${((m=this.translations)==null?void 0:m.test.homeManualTitle)??""} +

+ + \${(p=this.translations)==null?void 0:p.test.homeManualDescription} + +
+
+ \`:""} + \${i&&o\` + + +

\${((T=this.translations)==null?void 0:T.test.serviceSession)??""}

+ + \${(L=this.translations)==null?void 0:L.test.serviceSessionDescription} + +
+
+ \`||""} +
+ + \`}getTitle(){var i,n,s,l,m,p,u;const e=_().toUpperCase();if(this.isMobile())return o\` +

+ + \${(i=this.translations)==null?void 0:i.test.a} + \${e} + \${e==="TEST"?(n=this.translations)==null?void 0:n.test.b:(s=this.translations)==null?void 0:s.test.c} + +

+ \`;const t=((l=this.homeConfig)==null?void 0:l.listItems.titleTextColor)||"#111";return o\` +

+ + \${(m=this.translations)==null?void 0:m.test.d} + \${e} + \${e==="TEST"?(p=this.translations)==null?void 0:p.test.e:(u=this.translations)==null?void 0:u.test.f} + +

+ \`}onManualClick(e){f.sendHomeSelectStart("example"),e.preventDefault(),c.goto(\`example/\${location.search}\`)}onServiceClick(){f.sendBookService(w.home)}onServiceClose(){var t,i;this.serviceVisible=!1,this.render();const e=((i=(t=this.settings)==null?void 0:t.general)==null?void 0:i.enableHome)??!1;f.sendToggleModal({modal:e?"ServiceModal":"LoadModal",visible:!1,source:w.home})}onServiceClick(){var t,i;const e=((i=(t=this.settings)==null?void 0:t.general)==null?void 0:i.enableHome)??!1;f.sendToggleModal({modal:e?"ServiceModal":"LoadModal",visible:!0,source:w.home}),this.serviceVisible=!0,this.render()}onBackButtonClick(){var i;const e=new R({platform:"test"}),t=((i=this.settings)==null?void 0:i.test.test)??"";t.startsWith("/")?window.history.pushState(null,t):t.startsWith("http")&&window.location.replace(t),B()&&e.backClicked()}isMobile(){return window.innerWidth<=b.mobile.max}}window.customElements.define("test-home-page",z);const Ee=Object.freeze(Object.defineProperty({__proto__:null,default:z},Symbol.toStringTag,{value:"Module"}));$happy_dom.exports['L'] = me; +$happy_dom.exports['a'] = Ee; +$happy_dom.exports['c'] = D; +$happy_dom.exports['h'] = $e; + +}`); + }); + }); +}); diff --git a/packages/happy-dom/test/nodes/document/Document.test.ts b/packages/happy-dom/test/nodes/document/Document.test.ts index b59c27e35..88e5b5bce 100644 --- a/packages/happy-dom/test/nodes/document/Document.test.ts +++ b/packages/happy-dom/test/nodes/document/Document.test.ts @@ -1420,15 +1420,17 @@ describe('Document', () => { let target: EventTarget | null = null; let currentTarget: EventTarget | null = null; - vi.spyOn(ResourceFetch.prototype, 'fetch').mockImplementation(async function (url: string) { - if (url.endsWith('.css')) { + vi.spyOn(ResourceFetch.prototype, 'fetch').mockImplementation(async function ( + url: string | URL + ) { + if ((url).endsWith('.css')) { resourceFetchCSSWindow = this.window; - resourceFetchCSSURL = url; + resourceFetchCSSURL = url; return cssResponse; } resourceFetchJSWindow = this.window; - resourceFetchJSURL = url; + resourceFetchJSURL = url; return jsResponse; }); diff --git a/packages/happy-dom/test/nodes/element/Element.test.ts b/packages/happy-dom/test/nodes/element/Element.test.ts index 0aa927b93..a3f1c443d 100644 --- a/packages/happy-dom/test/nodes/element/Element.test.ts +++ b/packages/happy-dom/test/nodes/element/Element.test.ts @@ -19,6 +19,7 @@ import NodeList from '../../../src/nodes/node/NodeList.js'; import Event from '../../../src/event/Event.js'; import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; import * as PropertySymbol from '../../../src/PropertySymbol.js'; +import DOMTokenList from '../../../src/dom/DOMTokenList.js'; const NAMESPACE_URI = 'https://test.test'; @@ -173,8 +174,19 @@ describe('Element', () => { describe('get classList()', () => { it('Returns a DOMTokenList object.', () => { - element.setAttribute('class', 'class1'); - expect(element.classList.value).toBe('class1'); + element.setAttribute('class', 'value1 value2'); + expect(element.classList).toBeInstanceOf(DOMTokenList); + expect(element.classList.value).toBe('value1 value2'); + expect(element.classList.length).toBe(2); + expect(element.classList[0]).toBe('value1'); + expect(element.classList[1]).toBe('value2'); + }); + }); + + describe('set classList()', () => { + it('Sets the attribute "class".', () => { + element.classList = 'value1 value2'; + expect(element.getAttribute('class')).toBe('value1 value2'); }); }); diff --git a/packages/happy-dom/test/nodes/html-anchor-element/HTMLAnchorElement.test.ts b/packages/happy-dom/test/nodes/html-anchor-element/HTMLAnchorElement.test.ts index 9143ce5be..7c6b03698 100644 --- a/packages/happy-dom/test/nodes/html-anchor-element/HTMLAnchorElement.test.ts +++ b/packages/happy-dom/test/nodes/html-anchor-element/HTMLAnchorElement.test.ts @@ -9,6 +9,7 @@ import Fetch from '../../../src/fetch/Fetch.js'; import Browser from '../../../src/browser/Browser.js'; import MouseEvent from '../../../src/event/events/MouseEvent.js'; import BrowserWindow from '../../../src/window/BrowserWindow.js'; +import DOMTokenList from '../../../src/dom/DOMTokenList.js'; describe('HTMLAnchorElement', () => { let window: Window; @@ -83,6 +84,26 @@ describe('HTMLAnchorElement', () => { }); }); + describe('get relList()', () => { + it('Returns a DOMTokenList object.', () => { + const element = document.createElement('a'); + element.setAttribute('rel', 'value1 value2'); + expect(element.relList).toBeInstanceOf(DOMTokenList); + expect(element.relList.value).toBe('value1 value2'); + expect(element.relList.length).toBe(2); + expect(element.relList[0]).toBe('value1'); + expect(element.relList[1]).toBe('value2'); + }); + }); + + describe('set relList()', () => { + it('Sets the attribute "rel".', () => { + const element = document.createElement('a'); + element.relList = 'value1 value2'; + expect(element.getAttribute('rel')).toBe('value1 value2'); + }); + }); + describe('toString()', () => { it('Returns the "href" attribute.', () => { const element = document.createElement('a'); diff --git a/packages/happy-dom/test/nodes/html-area-element/HTMLAreaElement.test.ts b/packages/happy-dom/test/nodes/html-area-element/HTMLAreaElement.test.ts index 4d5fccb41..84b3afe2a 100644 --- a/packages/happy-dom/test/nodes/html-area-element/HTMLAreaElement.test.ts +++ b/packages/happy-dom/test/nodes/html-area-element/HTMLAreaElement.test.ts @@ -7,6 +7,7 @@ import Request from '../../../src/fetch/Request.js'; import Response from '../../../src/fetch/Response.js'; import Browser from '../../../src/browser/Browser.js'; import Fetch from '../../../src/fetch/Fetch.js'; +import DOMTokenList from '../../../src/dom/DOMTokenList.js'; describe('HTMLAreaElement', () => { let window: Window; @@ -58,6 +59,26 @@ describe('HTMLAreaElement', () => { }); } + describe('get relList()', () => { + it('Returns a DOMTokenList object.', () => { + const element = document.createElement('area'); + element.setAttribute('rel', 'value1 value2'); + expect(element.relList).toBeInstanceOf(DOMTokenList); + expect(element.relList.value).toBe('value1 value2'); + expect(element.relList.length).toBe(2); + expect(element.relList[0]).toBe('value1'); + expect(element.relList[1]).toBe('value2'); + }); + }); + + describe('set relList()', () => { + it('Sets the attribute "rel".', () => { + const element = document.createElement('area'); + element.relList = 'value1 value2'; + expect(element.getAttribute('rel')).toBe('value1 value2'); + }); + }); + describe('get href()', () => { it('Returns the "href" attribute.', () => { const element = document.createElement('area'); diff --git a/packages/happy-dom/test/nodes/html-element/HTMLElement.test.ts b/packages/happy-dom/test/nodes/html-element/HTMLElement.test.ts index 9c05718ef..64813bf4b 100644 --- a/packages/happy-dom/test/nodes/html-element/HTMLElement.test.ts +++ b/packages/happy-dom/test/nodes/html-element/HTMLElement.test.ts @@ -587,6 +587,7 @@ describe('HTMLElement', () => { expect(customElement instanceof CustomElement).toBe(true); expect(customElement.isConnected).toBe(true); + expect(customElement.shadowRoot?.isConnected).toBe(true); expect(customElement.shadowRoot?.children.length).toBe(2); expect(customElement.childNodes.length).toBe(2); @@ -609,6 +610,39 @@ describe('HTMLElement', () => { expect(customElement.attributes[0] === attribute1).toBe(true); }); + it('Renders child component inside the new instance of the custom element.', () => { + const element = document.createElement('parent-element'); + + document.body.appendChild(element); + + /* eslint-disable jsdoc/require-jsdoc */ + class ParentElement extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + public connectedCallback(): void { + (this.shadowRoot).innerHTML = + '
'; + } + } + + /* eslint-enable jsdoc/require-jsdoc */ + + window.customElements.define('custom-element', CustomElement); + window.customElements.define('parent-element', ParentElement); + + const parentElement = document.body.children[0]; + + expect( + parentElement.shadowRoot?.children[0].children[0].shadowRoot?.querySelector('.propKey') + ?.textContent + ).toBe(` + key1 is "value1" and key2 is "value2". + `); + }); + it('Does nothing if the property "_callback" doesn\'t exist on Window.customElements.', () => { (window.customElements) = ({ get: () => undefined 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 57304053b..9606a0ad6 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 @@ -13,6 +13,7 @@ import IRequestInfo from '../../../src/fetch/types/IRequestInfo.js'; import Headers from '../../../src/fetch/Headers.js'; import Browser from '../../../src/browser/Browser.js'; import DOMTokenList from '../../../src/dom/DOMTokenList.js'; +import Event from '../../../src/event/Event.js'; describe('HTMLIFrameElement', () => { let window: Window; @@ -256,17 +257,18 @@ describe('HTMLIFrameElement', () => { }); it('Returns content window for "javascript:scroll(10, 20)".', async () => { - await new Promise((resolve) => { - element.src = 'javascript:scroll(10, 20)'; - document.body.appendChild(element); - expect(element.contentWindow === element.contentDocument?.defaultView).toBe(true); + element.src = 'javascript:scroll(10, 20)'; + document.body.appendChild(element); + expect(element.contentWindow === element.contentDocument?.defaultView).toBe(true); - element.addEventListener('load', () => { - expect(element.contentDocument?.documentElement.scrollLeft).toBe(10); - expect(element.contentDocument?.documentElement.scrollTop).toBe(20); - resolve(null); + await new Promise((resolve) => { + element.addEventListener('load', (event) => { + resolve(event); }); }); + + expect(element.contentDocument?.documentElement.scrollLeft).toBe(10); + expect(element.contentDocument?.documentElement.scrollTop).toBe(20); }); it(`Doesn't load anything if the Happy DOM setting "disableIframePageLoading" is set to true.`, () => { @@ -283,175 +285,201 @@ describe('HTMLIFrameElement', () => { }); it(`Dispatches an error event if the response of the iframe page has an "x-frame-options" header set to "deny".`, async () => { - await new Promise((resolve) => { - const responseHTML = 'Test'; - - vi.spyOn(BrowserWindow.prototype, 'fetch').mockImplementation((url: IRequestInfo) => { - return new Promise((resolve) => { - setTimeout(() => { - resolve(({ - text: () => Promise.resolve(responseHTML), - ok: true, - headers: new Headers({ 'x-frame-options': 'deny' }) - })); - }, 1); - }); + const responseHTML = 'Test'; + + vi.spyOn(BrowserWindow.prototype, 'fetch').mockImplementation((url: IRequestInfo) => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(({ + text: () => Promise.resolve(responseHTML), + ok: true, + headers: new Headers({ 'x-frame-options': 'deny' }) + })); + }, 1); }); + }); - window.happyDOM?.setURL('https://localhost:8080'); - element.src = 'https://localhost:8080/iframe.html'; + window.happyDOM?.setURL('https://localhost:8080'); + element.src = 'https://localhost:8080/iframe.html'; + + document.body.appendChild(element); + + const event: Event = await new Promise((resolve) => { element.addEventListener('error', (event) => { - expect((event).message).toBe( - `Refused to display 'https://localhost:8080/iframe.html' in a frame because it set 'X-Frame-Options' to 'deny'.` - ); - expect((event).message === (event).error?.message).toBe(true); - resolve(null); + resolve(event); }); - document.body.appendChild(element); }); + + expect(event.type).toBe('error'); + + expect( + window.happyDOM.virtualConsolePrinter + .readAsString() + .startsWith( + `Error: Refused to display 'https://localhost:8080/iframe.html' in a frame because it set 'X-Frame-Options' to 'deny'.` + ) + ).toBe(true); }); it(`Dispatches an error event if the response of the iframe page has an "x-frame-options" header set to "sameorigin" when the origin is different.`, async () => { - await new Promise((resolve) => { - const responseHTML = 'Test'; - - vi.spyOn(BrowserWindow.prototype, 'fetch').mockImplementation((url: IRequestInfo) => { - return new Promise((resolve) => { - setTimeout(() => { - resolve(({ - text: () => Promise.resolve(responseHTML), - ok: true, - headers: new Headers({ 'x-frame-options': 'sameorigin' }) - })); - }, 1); - }); + const responseHTML = 'Test'; + + vi.spyOn(BrowserWindow.prototype, 'fetch').mockImplementation((url: IRequestInfo) => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(({ + text: () => Promise.resolve(responseHTML), + ok: true, + headers: new Headers({ 'x-frame-options': 'sameorigin' }) + })); + }, 1); }); + }); - window.happyDOM?.setURL('https://localhost:3000'); - element.src = 'https://localhost:8080/iframe.html'; + window.happyDOM?.setURL('https://localhost:3000'); + element.src = 'https://localhost:8080/iframe.html'; + + document.body.appendChild(element); + + const event: Event = await new Promise((resolve) => { element.addEventListener('error', (event) => { - expect((event).message).toBe( - `Refused to display 'https://localhost:8080/iframe.html' in a frame because it set 'X-Frame-Options' to 'sameorigin'.` - ); - expect((event).message === (event).error?.message).toBe(true); - resolve(null); + resolve(event); }); - document.body.appendChild(element); }); + + expect(event.type).toBe('error'); + + expect( + window.happyDOM.virtualConsolePrinter + .readAsString() + .startsWith( + `Error: Refused to display 'https://localhost:8080/iframe.html' in a frame because it set 'X-Frame-Options' to 'sameorigin'.` + ) + ).toBe(true); }); it('Returns content window for URL with same origin when the response has an "x-frame-options" set to "sameorigin".', async () => { - await new Promise((resolve) => { - const responseHTML = 'Test'; - let fetchedURL: string | null = null; + const responseHTML = 'Test'; + let fetchedURL: string | null = null; - vi.spyOn(BrowserWindow.prototype, 'fetch').mockImplementation((url: IRequestInfo) => { - fetchedURL = url; - return new Promise((resolve) => { - setTimeout(() => { - resolve(({ - text: () => Promise.resolve(responseHTML), - ok: true, - headers: new Headers({ 'x-frame-options': 'sameorigin' }) - })); - }, 1); - }); + vi.spyOn(BrowserWindow.prototype, 'fetch').mockImplementation((url: IRequestInfo) => { + fetchedURL = url; + return new Promise((resolve) => { + setTimeout(() => { + resolve(({ + text: () => Promise.resolve(responseHTML), + ok: true, + headers: new Headers({ 'x-frame-options': 'sameorigin' }) + })); + }, 1); }); + }); - window.happyDOM?.setURL('https://localhost:8080'); - element.src = 'https://localhost:8080/iframe.html'; + window.happyDOM?.setURL('https://localhost:8080'); + element.src = 'https://localhost:8080/iframe.html'; + + document.body.appendChild(element); + + await new Promise((resolve) => { element.addEventListener('load', () => { - expect(element.contentDocument?.location.href).toBe('https://localhost:8080/iframe.html'); - expect(fetchedURL).toBe('https://localhost:8080/iframe.html'); - expect(element.contentWindow === element.contentDocument?.defaultView).toBe(true); - expect(`${element.contentDocument?.documentElement.innerHTML}`).toBe( - responseHTML - ); resolve(null); }); - - document.body.appendChild(element); }); + + expect(element.contentDocument?.location.href).toBe('https://localhost:8080/iframe.html'); + expect(fetchedURL).toBe('https://localhost:8080/iframe.html'); + expect(element.contentWindow === element.contentDocument?.defaultView).toBe(true); + expect(`${element.contentDocument?.documentElement.innerHTML}`).toBe( + responseHTML + ); }); it('Returns content window for URL with same origin.', async () => { - await new Promise((resolve) => { - const responseHTML = 'Test'; - let fetchedURL: string | null = null; + const responseHTML = 'Test'; + let fetchedURL: string | null = null; - vi.spyOn(BrowserWindow.prototype, 'fetch').mockImplementation((url: IRequestInfo) => { - fetchedURL = url; - return Promise.resolve(({ - text: () => Promise.resolve(responseHTML), - ok: true, - headers: new Headers() - })); - }); + vi.spyOn(BrowserWindow.prototype, 'fetch').mockImplementation((url: IRequestInfo) => { + fetchedURL = url; + return Promise.resolve(({ + text: () => Promise.resolve(responseHTML), + ok: true, + headers: new Headers() + })); + }); - window.happyDOM?.setURL('https://localhost:8080'); - element.src = 'https://localhost:8080/iframe.html'; + window.happyDOM?.setURL('https://localhost:8080'); + element.src = 'https://localhost:8080/iframe.html'; + + document.body.appendChild(element); + + await new Promise((resolve) => { element.addEventListener('load', () => { - expect(element.contentDocument?.location.href).toBe('https://localhost:8080/iframe.html'); - expect(fetchedURL).toBe('https://localhost:8080/iframe.html'); - expect(element.contentWindow === element.contentDocument?.defaultView).toBe(true); - expect(`${element.contentDocument?.documentElement.innerHTML}`).toBe( - responseHTML - ); resolve(null); }); - document.body.appendChild(element); }); + + expect(element.contentDocument?.location.href).toBe('https://localhost:8080/iframe.html'); + expect(fetchedURL).toBe('https://localhost:8080/iframe.html'); + expect(element.contentWindow === element.contentDocument?.defaultView).toBe(true); + expect(`${element.contentDocument?.documentElement.innerHTML}`).toBe( + responseHTML + ); }); it('Returns content window for relative URL.', async () => { - await new Promise((resolve) => { - const responseHTML = 'Test'; - - vi.spyOn(BrowserWindow.prototype, 'fetch').mockImplementation((url: IRequestInfo) => { - return Promise.resolve(({ - text: () => Promise.resolve(responseHTML), - ok: true, - headers: new Headers() - })); - }); + const responseHTML = 'Test'; + + vi.spyOn(BrowserWindow.prototype, 'fetch').mockImplementation((url: IRequestInfo) => { + return Promise.resolve(({ + text: () => Promise.resolve(responseHTML), + ok: true, + headers: new Headers() + })); + }); + + window.happyDOM?.setURL('https://localhost:8080'); + element.src = '/iframe.html'; - window.happyDOM?.setURL('https://localhost:8080'); - element.src = '/iframe.html'; + document.body.appendChild(element); + + await new Promise((resolve) => { element.addEventListener('load', () => { - expect(element.contentDocument?.location.href).toBe('https://localhost:8080/iframe.html'); resolve(null); }); - document.body.appendChild(element); }); + + expect(element.contentDocument?.location.href).toBe('https://localhost:8080/iframe.html'); }); it('Returns content window for URL without protocol.', async () => { - await new Promise((resolve) => { - const browser = new Browser(); - const page = browser.newPage(); - const window = page.mainFrame.window; - const document = window.document; - const element = document.createElement('iframe'); - const responseHTML = 'Test'; + const browser = new Browser(); + const page = browser.newPage(); + const window = page.mainFrame.window; + const document = window.document; + const element = document.createElement('iframe'); + const responseHTML = 'Test'; - page.mainFrame.url = 'https://localhost:8080'; + page.mainFrame.url = 'https://localhost:8080'; - vi.spyOn(BrowserWindow.prototype, 'fetch').mockImplementation((url: IRequestInfo) => { - return Promise.resolve(({ - text: () => Promise.resolve(responseHTML), - ok: true, - headers: new Headers() - })); - }); + vi.spyOn(BrowserWindow.prototype, 'fetch').mockImplementation((url: IRequestInfo) => { + return Promise.resolve(({ + text: () => Promise.resolve(responseHTML), + ok: true, + headers: new Headers() + })); + }); + + element.src = '//www.github.com/iframe.html'; - element.src = '//www.github.com/iframe.html'; + document.body.appendChild(element); + + await new Promise((resolve) => { element.addEventListener('load', () => { - expect(page.mainFrame.childFrames[0].url).toBe('https://www.github.com/iframe.html'); resolve(null); }); - - document.body.appendChild(element); }); + + expect(page.mainFrame.childFrames[0].url).toBe('https://www.github.com/iframe.html'); }); it('Returns instance of CrossOriginBrowserWindow for URL with different origin.', async () => { @@ -520,25 +548,30 @@ describe('HTMLIFrameElement', () => { }); it('Dispatches an error event when the page fails to load.', async () => { - await new Promise((resolve) => { - const error = new Error('Error'); - - vi.spyOn(BrowserWindow.prototype, 'fetch').mockImplementation(() => { - return Promise.resolve(({ - text: () => Promise.reject(error), - ok: true, - headers: new Headers() - })); - }); + const error = new Error('error'); - element.src = 'https://localhost:8080/iframe.html'; + vi.spyOn(BrowserWindow.prototype, 'fetch').mockImplementation(() => { + return Promise.resolve(({ + text: () => Promise.reject(error), + ok: true, + headers: new Headers() + })); + }); + + element.src = 'https://localhost:8080/iframe.html'; + document.body.appendChild(element); + + const event: Event = await new Promise((resolve) => { element.addEventListener('error', (event) => { - expect((event).message).toBe(error.message); - expect((event).error).toBe(error); - resolve(null); + resolve(event); }); - document.body.appendChild(element); }); + + expect(event.type).toBe('error'); + + expect( + window.happyDOM.virtualConsolePrinter.readAsString().startsWith(`Error: error\n`) + ).toBe(true); }); it('Remain at the initial about:blank page when none of the srcdoc/src attributes are set', async () => { diff --git a/packages/happy-dom/test/nodes/html-link-element/HTMLLinkElement.test.ts b/packages/happy-dom/test/nodes/html-link-element/HTMLLinkElement.test.ts index 95477be16..6959275fc 100644 --- a/packages/happy-dom/test/nodes/html-link-element/HTMLLinkElement.test.ts +++ b/packages/happy-dom/test/nodes/html-link-element/HTMLLinkElement.test.ts @@ -1,12 +1,12 @@ import Window from '../../../src/window/Window.js'; import BrowserWindow from '../../../src/window/BrowserWindow.js'; import Document from '../../../src/nodes/document/Document.js'; -import HTMLLinkElement from '../../../src/nodes/html-link-element/HTMLLinkElement.js'; import ResourceFetch from '../../../src/fetch/ResourceFetch.js'; import Event from '../../../src/event/Event.js'; -import ErrorEvent from '../../../src/event/events/ErrorEvent.js'; import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; import EventTarget from '../../../src/event/EventTarget.js'; +import BrowserErrorCaptureEnum from '../../../src/browser/enums/BrowserErrorCaptureEnum.js'; +import DOMTokenList from '../../../src/dom/DOMTokenList.js'; describe('HTMLLinkElement', () => { let window: Window; @@ -59,7 +59,24 @@ describe('HTMLLinkElement', () => { it('Returns a DOMTokenList object.', () => { const element = document.createElement('link'); element.setAttribute('rel', 'value1 value2'); + expect(element.relList).toBeInstanceOf(DOMTokenList); expect(element.relList.value).toBe('value1 value2'); + expect(element.relList.length).toBe(2); + expect(element.relList[0]).toBe('value1'); + expect(element.relList[1]).toBe('value2'); + + expect(element.relList.supports('stylesheet')).toBe(true); + expect(element.relList.supports('modulepreload')).toBe(true); + expect(element.relList.supports('preload')).toBe(true); + expect(element.relList.supports('unsupported')).toBe(false); + }); + }); + + describe('set relList()', () => { + it('Sets the attribute "rel".', () => { + const element = document.createElement('link'); + element.relList = 'value1 value2'; + expect(element.getAttribute('rel')).toBe('value1 value2'); }); }); @@ -94,9 +111,11 @@ describe('HTMLLinkElement', () => { let loadEventTarget: EventTarget | null = null; let loadEventCurrentTarget: EventTarget | null = null; - vi.spyOn(ResourceFetch.prototype, 'fetch').mockImplementation(async function (url: string) { + vi.spyOn(ResourceFetch.prototype, 'fetch').mockImplementation(async function ( + url: string | URL + ) { loadedWindow = this.window; - loadedURL = url; + loadedURL = url; return css; }); @@ -125,7 +144,7 @@ describe('HTMLLinkElement', () => { it('Triggers error event when fetching a CSS file fails during setting the "href" and "rel" attributes.', async () => { const element = document.createElement('link'); const thrownError = new Error('error'); - let errorEvent: ErrorEvent | null = null; + let errorEvent: Event | null = null; vi.spyOn(ResourceFetch.prototype, 'fetch').mockImplementation(async function () { throw thrownError; @@ -134,7 +153,7 @@ describe('HTMLLinkElement', () => { document.body.appendChild(element); element.addEventListener('error', (event) => { - errorEvent = event; + errorEvent = event; }); element.rel = 'stylesheet'; @@ -142,8 +161,11 @@ describe('HTMLLinkElement', () => { await window.happyDOM?.waitUntilComplete(); - expect(((errorEvent)).error).toEqual(thrownError); - expect(((errorEvent)).message).toEqual('error'); + expect(((errorEvent)).type).toBe('error'); + + expect(window.happyDOM?.virtualConsolePrinter.readAsString().startsWith('Error: error')).toBe( + true + ); }); it('Does not load and evaluate external CSS files if the element is not connected to DOM.', () => { @@ -152,9 +174,11 @@ describe('HTMLLinkElement', () => { let loadedWindow: BrowserWindow | null = null; let loadedURL: string | null = null; - vi.spyOn(ResourceFetch.prototype, 'fetch').mockImplementation(async function (url: string) { + vi.spyOn(ResourceFetch.prototype, 'fetch').mockImplementation(async function ( + url: string | URL + ) { loadedWindow = this.window; - loadedURL = url; + loadedURL = url; return css; }); @@ -176,9 +200,11 @@ describe('HTMLLinkElement', () => { let loadedWindow: BrowserWindow | null = null; let loadedURL: string | null = null; - vi.spyOn(ResourceFetch.prototype, 'fetch').mockImplementation(async function (url: string) { + vi.spyOn(ResourceFetch.prototype, 'fetch').mockImplementation(async function ( + url: string | URL + ) { loadedWindow = this.window; - loadedURL = url; + loadedURL = url; return css; }); @@ -206,7 +232,7 @@ describe('HTMLLinkElement', () => { it('Triggers error event when fetching a CSS file fails while appending the element to the document.', async () => { const element = document.createElement('link'); const thrownError = new Error('error'); - let errorEvent: ErrorEvent | null = null; + let errorEvent: Event | null = null; vi.spyOn(ResourceFetch.prototype, 'fetch').mockImplementation(async function () { throw thrownError; @@ -215,15 +241,18 @@ describe('HTMLLinkElement', () => { element.rel = 'stylesheet'; element.href = 'https://localhost:8080/test/path/file.css'; element.addEventListener('error', (event) => { - errorEvent = event; + errorEvent = event; }); document.body.appendChild(element); await window.happyDOM?.waitUntilComplete(); - expect(((errorEvent)).error).toEqual(thrownError); - expect(((errorEvent)).message).toEqual('error'); + expect(((errorEvent)).type).toBe('error'); + + expect(window.happyDOM?.virtualConsolePrinter.readAsString().startsWith('Error: error')).toBe( + true + ); }); it('Does not load external CSS file when "href" attribute has been set if the element is not connected to DOM.', () => { @@ -232,9 +261,11 @@ describe('HTMLLinkElement', () => { let loadedWindow: BrowserWindow | null = null; let loadedURL: string | null = null; - vi.spyOn(ResourceFetch.prototype, 'fetch').mockImplementation(async function (url: string) { + vi.spyOn(ResourceFetch.prototype, 'fetch').mockImplementation(async function ( + url: string | URL + ) { loadedWindow = this.window; - loadedURL = url; + loadedURL = url; return css; }); @@ -253,18 +284,25 @@ describe('HTMLLinkElement', () => { document = window.document; const element = document.createElement('link'); - let errorEvent: ErrorEvent | null = null; + let errorEvent: Event | null = null; element.rel = 'stylesheet'; element.href = 'https://localhost:8080/test/path/file.css'; - element.addEventListener('error', (event) => (errorEvent = event)); + element.addEventListener('error', (event) => (errorEvent = event)); document.body.appendChild(element); expect(element.sheet).toBe(null); - expect(((errorEvent)).message).toBe( - 'Failed to load external stylesheet "https://localhost:8080/test/path/file.css". CSS file loading is disabled.' - ); + + expect(((errorEvent)).type).toBe('error'); + + expect( + window.happyDOM?.virtualConsolePrinter + .readAsString() + .startsWith( + 'NotSupportedError: Failed to load external stylesheet "https://localhost:8080/test/path/file.css". CSS file loading is disabled.' + ) + ).toBe(true); }); it('Triggers a load event when the Happy DOM setting "disableCSSFileLoading" and "handleDisabledFileLoadingAsSuccess" is set to "true".', async () => { @@ -285,5 +323,273 @@ describe('HTMLLinkElement', () => { expect(element.sheet).toBe(null); expect(((loadEvent)).type).toBe('load'); }); + + it('Preloads modules when "rel" is set to "modulepreload" and only fetches once when preload is ongoing', async () => { + const requests: string[] = []; + const window = new Window({ + url: 'https://localhost:8080/base/', + settings: { + errorCapture: BrowserErrorCaptureEnum.disabled, + fetch: { + interceptor: { + afterAsyncResponse: async ({ request }) => { + requests.push(request.url); + } + }, + virtualServers: [ + { + url: 'https://localhost:8080/base/js/', + directory: './test/nodes/html-script-element/modules/' + } + ] + } + }, + console + }); + const document = window.document; + const link = document.createElement('link'); + + link.rel = 'modulepreload'; + link.href = '/base/js/TestModuleElement.js'; + + document.head.appendChild(link); + + const script = document.createElement('script'); + + script.src = '/base/js/TestModuleElement.js'; + script.type = 'module'; + + document.body.appendChild(script); + + await window.happyDOM?.waitUntilComplete(); + + const testModule = document.createElement('test-module'); + + document.body.appendChild(testModule); + + await window.happyDOM?.waitUntilComplete(); + + expect(requests.sort()).toEqual([ + 'https://localhost:8080/base/js/TestModuleElement.js', + 'https://localhost:8080/base/js/css/style.css', + 'https://localhost:8080/base/js/json/data.json', + 'https://localhost:8080/base/js/utilities/StringUtilityClass.js', + 'https://localhost:8080/base/js/utilities/apostrophWrapper.js', + 'https://localhost:8080/base/js/utilities/lazyload.js', + 'https://localhost:8080/base/js/utilities/stringUtility.js' + ]); + + expect(window['moduleLoadOrder']).toEqual([ + 'apostrophWrapper.js', + 'StringUtilityClass.js', + 'stringUtility.js', + 'TestModuleElement.js', + 'lazyload.js' + ]); + + expect(testModule.shadowRoot?.innerHTML).toBe(`
+ Expect lower case: "value" + Expect upper case: "VALUE" + Expect lower case. "value" + Expect trimmed lower case: "value" +
Lazy-loaded module: true
`); + }); + + it('Preloads style and async script when "rel" is set to "preload"', async () => { + const requests: string[] = []; + const window = new Window({ + url: 'https://localhost:8080/', + settings: { + errorCapture: BrowserErrorCaptureEnum.disabled, + fetch: { + interceptor: { + afterAsyncResponse: async ({ request }) => { + requests.push(request.url); + }, + afterSyncResponse: ({ request }) => { + requests.push(request.url); + } + }, + virtualServers: [ + { + url: '/preload/', + directory: './test/nodes/html-link-element/preload-resources/' + } + ] + } + } + }); + const document = window.document; + const link1 = document.createElement('link'); + + link1.rel = 'preload'; + link1.as = 'script'; + link1.href = '/preload/main.js'; + + document.head.appendChild(link1); + + const link2 = document.createElement('link'); + + link2.rel = 'preload'; + link2.as = 'style'; + link2.href = '/preload/style.css'; + + document.head.appendChild(link2); + + const link3 = document.createElement('link'); + + link3.rel = 'preload'; + link3.as = 'fetch'; + link3.href = '/preload/data.json'; + + document.head.appendChild(link3); + + const script = document.createElement('script'); + + script.src = '/preload/main.js'; + script.async = true; + + document.body.appendChild(script); + + const style = document.createElement('link'); + + style.rel = 'stylesheet'; + style.href = '/preload/style.css'; + + document.head.appendChild(style); + + await window.happyDOM?.waitUntilComplete(); + + expect(requests.sort()).toEqual([ + 'https://localhost:8080/preload/data.json', + 'https://localhost:8080/preload/main.js', + 'https://localhost:8080/preload/style.css' + ]); + + expect(window.getComputedStyle(document.body).getPropertyValue('background-color')).toBe( + 'red' + ); + expect(window.happyDOM?.virtualConsolePrinter.readAsString()).toBe( + 'Resource loaded\n{"data":"loaded"}\n' + ); + }); + + it('Preloads style and sync script when "rel" is set to "preload"', async () => { + const requests: string[] = []; + const window = new Window({ + url: 'https://localhost:8080/', + settings: { + errorCapture: BrowserErrorCaptureEnum.disabled, + fetch: { + interceptor: { + afterAsyncResponse: async ({ request }) => { + requests.push(request.url); + }, + afterSyncResponse: ({ request }) => { + requests.push(request.url); + } + }, + virtualServers: [ + { + url: '/preload/', + directory: './test/nodes/html-link-element/preload-resources/' + } + ] + } + } + }); + const document = window.document; + const link1 = document.createElement('link'); + + link1.rel = 'preload'; + link1.as = 'script'; + link1.href = '/preload/main.js'; + + document.head.appendChild(link1); + + const link2 = document.createElement('link'); + + link2.rel = 'preload'; + link2.as = 'style'; + link2.href = '/preload/style.css'; + + document.head.appendChild(link2); + + const link3 = document.createElement('link'); + + link3.rel = 'preload'; + link3.as = 'fetch'; + link3.href = '/preload/data.json'; + + document.head.appendChild(link3); + + await window.happyDOM?.waitUntilComplete(); + + const script = document.createElement('script'); + + script.src = '/preload/main.js'; + + document.body.appendChild(script); + + const style = document.createElement('link'); + + style.rel = 'stylesheet'; + style.href = '/preload/style.css'; + + document.head.appendChild(style); + + await window.happyDOM?.waitUntilComplete(); + + expect(requests.sort()).toEqual([ + 'https://localhost:8080/preload/data.json', + 'https://localhost:8080/preload/main.js', + 'https://localhost:8080/preload/style.css' + ]); + + expect(window.getComputedStyle(document.body).getPropertyValue('background-color')).toBe( + 'red' + ); + expect(window.happyDOM?.virtualConsolePrinter.readAsString()).toBe( + 'Resource loaded\n{"data":"loaded"}\n' + ); + }); + + it('Ignores unsupported "as" attribute values when "rel" is set to "preload"', async () => { + const requests: string[] = []; + const window = new Window({ + url: 'https://localhost:8080/', + settings: { + errorCapture: BrowserErrorCaptureEnum.disabled, + fetch: { + interceptor: { + beforeAsyncRequest: async ({ request }) => { + requests.push(request.url); + }, + beforeSyncRequest: ({ request }) => { + requests.push(request.url); + } + }, + virtualServers: [ + { + url: '/preload/', + directory: './test/nodes/html-link-element/preload-resources/' + } + ] + } + } + }); + const document = window.document; + + const link = document.createElement('link'); + + link.rel = 'preload'; + link.as = 'image'; + + document.head.appendChild(link); + + await window.happyDOM?.waitUntilComplete(); + + expect(requests).toEqual([]); + }); }); }); diff --git a/packages/happy-dom/test/nodes/html-link-element/preload-resources/data.json b/packages/happy-dom/test/nodes/html-link-element/preload-resources/data.json new file mode 100644 index 000000000..5f6b545a3 --- /dev/null +++ b/packages/happy-dom/test/nodes/html-link-element/preload-resources/data.json @@ -0,0 +1 @@ +{ "data": "loaded" } diff --git a/packages/happy-dom/test/nodes/html-link-element/preload-resources/main.js b/packages/happy-dom/test/nodes/html-link-element/preload-resources/main.js new file mode 100644 index 000000000..769bcc118 --- /dev/null +++ b/packages/happy-dom/test/nodes/html-link-element/preload-resources/main.js @@ -0,0 +1,8 @@ +// eslint-disable-next-line no-console +console.log('Resource loaded'); + +(async () => { + const data = await fetch('preload/data.json'); + // eslint-disable-next-line no-console + console.log(await data.json()); +})(); diff --git a/packages/happy-dom/test/nodes/html-link-element/preload-resources/style.css b/packages/happy-dom/test/nodes/html-link-element/preload-resources/style.css new file mode 100644 index 000000000..438954d36 --- /dev/null +++ b/packages/happy-dom/test/nodes/html-link-element/preload-resources/style.css @@ -0,0 +1,3 @@ +body { + background-color: red; +} \ No newline at end of file diff --git a/packages/happy-dom/test/nodes/html-media-element/HTMLMediaElement.test.ts b/packages/happy-dom/test/nodes/html-media-element/HTMLMediaElement.test.ts index e45b9c38a..ec3d6d49a 100644 --- a/packages/happy-dom/test/nodes/html-media-element/HTMLMediaElement.test.ts +++ b/packages/happy-dom/test/nodes/html-media-element/HTMLMediaElement.test.ts @@ -208,6 +208,13 @@ describe('HTMLMediaElement', () => { }); }); + describe('set controlsList()', () => { + it('Sets the attribute "controlslist".', () => { + element.controlsList = 'value1 value2'; + expect(element.getAttribute('controlslist')).toBe('value1 value2'); + }); + }); + describe('get mediaKeys()', () => { it('Returns null by default', () => { expect(element.mediaKeys).toBeNull(); diff --git a/packages/happy-dom/test/nodes/html-script-element/HTMLScriptElement.test.ts b/packages/happy-dom/test/nodes/html-script-element/HTMLScriptElement.test.ts index 08ae8b252..c6cf7fbbf 100644 --- a/packages/happy-dom/test/nodes/html-script-element/HTMLScriptElement.test.ts +++ b/packages/happy-dom/test/nodes/html-script-element/HTMLScriptElement.test.ts @@ -10,6 +10,8 @@ import BrowserWindow from '../../../src/window/BrowserWindow.js'; import Fetch from '../../../src/fetch/Fetch.js'; import BrowserErrorCaptureEnum from '../../../src/browser/enums/BrowserErrorCaptureEnum.js'; import EventTarget from '../../../src/event/EventTarget.js'; +import HTMLElement from '../../../src/nodes/html-element/HTMLElement.js'; +import DOMTokenList from '../../../src/dom/DOMTokenList.js'; describe('HTMLScriptElement', () => { let window: Window; @@ -31,7 +33,7 @@ describe('HTMLScriptElement', () => { }); }); - for (const property of ['type', 'charset', 'lang']) { + for (const property of ['type', 'charset', 'lang', 'crossOrigin', 'integrity']) { describe(`get ${property}()`, () => { it(`Returns the "${property}" attribute.`, () => { const element = document.createElement('script'); @@ -49,7 +51,7 @@ describe('HTMLScriptElement', () => { }); } - for (const property of ['async', 'defer']) { + for (const property of ['async', 'defer', 'noModule']) { describe(`get ${property}()`, () => { it(`Returns "true" if the "${property}" attribute is defined.`, () => { const element = document.createElement('script'); @@ -67,6 +69,108 @@ describe('HTMLScriptElement', () => { }); } + describe('get blocking()', () => { + it('Returns a DOMTokenList object.', () => { + const element = document.createElement('script'); + element.setAttribute('blocking', 'value1 value2'); + expect(element.blocking).toBeInstanceOf(DOMTokenList); + expect(element.blocking.value).toBe('value1 value2'); + expect(element.blocking.length).toBe(2); + expect(element.blocking[0]).toBe('value1'); + expect(element.blocking[1]).toBe('value2'); + }); + }); + + describe('set blocking()', () => { + it('Sets the attribute "class".', () => { + const element = document.createElement('script'); + element.blocking = 'value1 value2'; + expect(element.getAttribute('blocking')).toBe('value1 value2'); + }); + }); + + describe('get fetchPriority()', () => { + it('Returns valid fetch priority value.', () => { + const element = document.createElement('script'); + + expect(element.fetchPriority).toBe('auto'); + + element.setAttribute('fetchpriority', 'high'); + expect(element.fetchPriority).toBe('high'); + + element.setAttribute('fetchpriority', 'low'); + expect(element.fetchPriority).toBe('low'); + + element.setAttribute('fetchpriority', 'normal'); + expect(element.fetchPriority).toBe('normal'); + + element.setAttribute('fetchpriority', 'auto'); + expect(element.fetchPriority).toBe('auto'); + + element.setAttribute('fetchpriority', 'invalid'); + expect(element.fetchPriority).toBe('auto'); + }); + }); + + describe('set fetchPriority()', () => { + it('Sets the attribute "fetchpriority".', () => { + const element = document.createElement('script'); + + element.fetchPriority = 'high'; + expect(element.getAttribute('fetchpriority')).toBe('high'); + + element.fetchPriority = <'high'>'invalid'; + expect(element.getAttribute('fetchpriority')).toBe('invalid'); + }); + }); + + describe('get referrerPolicy()', () => { + it('Returns valid referrer policy value.', () => { + const element = document.createElement('script'); + + expect(element.referrerPolicy).toBe(''); + + element.setAttribute('referrerpolicy', 'no-referrer'); + expect(element.referrerPolicy).toBe('no-referrer'); + + element.setAttribute('referrerpolicy', 'no-referrer-when-downgrade'); + expect(element.referrerPolicy).toBe('no-referrer-when-downgrade'); + + element.setAttribute('referrerpolicy', 'same-origin'); + expect(element.referrerPolicy).toBe('same-origin'); + + element.setAttribute('referrerpolicy', 'origin'); + expect(element.referrerPolicy).toBe('origin'); + + element.setAttribute('referrerpolicy', 'strict-origin'); + expect(element.referrerPolicy).toBe('strict-origin'); + + element.setAttribute('referrerpolicy', 'origin-when-cross-origin'); + expect(element.referrerPolicy).toBe('origin-when-cross-origin'); + + element.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin'); + expect(element.referrerPolicy).toBe('strict-origin-when-cross-origin'); + + element.setAttribute('referrerpolicy', 'unsafe-url'); + expect(element.referrerPolicy).toBe('unsafe-url'); + + element.setAttribute('referrerpolicy', 'invalid'); + expect(element.referrerPolicy).toBe(''); + }); + }); + + describe('set referrerPolicy()', () => { + it('Sets the attribute "referrerpolicy".', () => { + const element = document.createElement('script'); + + element.referrerPolicy = 'no-referrer'; + expect(element.getAttribute('referrerpolicy')).toBe('no-referrer'); + + element.referrerPolicy = <'no-referrer'>'invalid'; + expect(element.getAttribute('referrerpolicy')).toBe('invalid'); + }); + }); + describe('get src()', () => { it('Returns the "src" attribute.', () => { const element = document.createElement('script'); @@ -177,8 +281,7 @@ describe('HTMLScriptElement', () => { for (const attribute of [ { name: 'async', value: '' }, - { name: 'defer', value: '' }, - { name: 'type', value: 'module' } + { name: 'defer', value: '' } ]) { it(`Loads external script asynchronously when the attribute "${attribute.name}" is set to "${attribute.value}".`, async () => { let fetchedURL: string | null = null; @@ -239,9 +342,15 @@ describe('HTMLScriptElement', () => { await window.happyDOM?.waitUntilComplete(); - expect(((errorEvent)).message).toBe( - 'Failed to perform request to "https://localhost:8080/path/to/script.js". Status 404 Not Found.' - ); + expect(((errorEvent)).type).toBe('error'); + + expect( + window.happyDOM?.virtualConsolePrinter + .readAsString() + .startsWith( + 'DOMException: Failed to perform request to "https://localhost:8080/path/to/script.js". Status 404 Not Found.' + ) + ).toBe(true); }); } @@ -281,7 +390,7 @@ describe('HTMLScriptElement', () => { it('Triggers error event when loading external script synchronously with relative URL.', () => { const window = new Window({ url: 'https://localhost:8080/base/' }); const thrownError = new Error('error'); - let errorEvent: ErrorEvent | null = null; + let errorEvent: Event | null = null; vi.spyOn(ResourceFetch.prototype, 'fetchSync').mockImplementation(() => { throw thrownError; @@ -290,13 +399,16 @@ describe('HTMLScriptElement', () => { const script = window.document.createElement('script'); script.src = 'path/to/script.js'; script.addEventListener('error', (event) => { - errorEvent = event; + errorEvent = event; }); window.document.body.appendChild(script); - expect(((errorEvent)).message).toBe('error'); - expect(((errorEvent)).error).toBe(thrownError); + expect(((errorEvent)).type).toBe('error'); + + expect(window.happyDOM?.virtualConsolePrinter.readAsString().startsWith('Error: error')).toBe( + true + ); }); it('Does not evaluate types that are not supported.', () => { @@ -393,9 +505,15 @@ describe('HTMLScriptElement', () => { document.body.appendChild(script); - expect(((errorEvent)).message).toBe( - 'Failed to load external script "https://localhost:8080/path/to/script.js". JavaScript file loading is disabled.' - ); + expect(((errorEvent)).type).toBe('error'); + + expect( + window.happyDOM?.virtualConsolePrinter + .readAsString() + .startsWith( + 'NotSupportedError: Failed to load script "https://localhost:8080/path/to/script.js". JavaScript file loading is disabled.' + ) + ).toBe(true); }); it('Triggers a load event when attempting to perform an asynchrounous request and the Happy DOM setting "disableJavaScriptFileLoading" and "handleDisabledFileLoadingAsSuccess" is set to "true".', () => { @@ -434,9 +552,15 @@ describe('HTMLScriptElement', () => { document.body.appendChild(script); - expect(((errorEvent)).message).toBe( - 'Failed to load external script "https://localhost:8080/path/to/script.js". JavaScript file loading is disabled.' - ); + expect(((errorEvent)).type).toBe('error'); + + expect( + window.happyDOM?.virtualConsolePrinter + .readAsString() + .startsWith( + 'NotSupportedError: Failed to load script "https://localhost:8080/path/to/script.js". JavaScript file loading is disabled.' + ) + ).toBe(true); }); it('Triggers an error event when attempting to perform an asynchrounous request and the Happy DOM setting "disableJavaScriptFileLoading" is set to "true".', () => { @@ -456,9 +580,15 @@ describe('HTMLScriptElement', () => { document.body.appendChild(script); - expect(((errorEvent)).message).toBe( - 'Failed to load external script "https://localhost:8080/path/to/script.js". JavaScript file loading is disabled.' - ); + expect(((errorEvent)).type).toBe('error'); + + expect( + window.happyDOM?.virtualConsolePrinter + .readAsString() + .startsWith( + 'NotSupportedError: Failed to load script "https://localhost:8080/path/to/script.js". JavaScript file loading is disabled.' + ) + ).toBe(true); }); it('Triggers an error event when attempting to perform a synchrounous request and the Happy DOM setting "disableJavaScriptFileLoading" is set to "true".', () => { @@ -477,9 +607,14 @@ describe('HTMLScriptElement', () => { document.body.appendChild(script); - expect(((errorEvent)).message).toBe( - 'Failed to load external script "https://localhost:8080/path/to/script.js". JavaScript file loading is disabled.' - ); + expect(((errorEvent)).type).toBe('error'); + expect( + window.happyDOM?.virtualConsolePrinter + .readAsString() + .startsWith( + 'NotSupportedError: Failed to load script "https://localhost:8080/path/to/script.js". JavaScript file loading is disabled.' + ) + ).toBe(true); }); it('Triggers an error event on Window when attempting to perform an asynchrounous request containing invalid JavaScript.', async () => { @@ -586,5 +721,322 @@ describe('HTMLScriptElement', () => { document.body.appendChild(element); }).toThrow(new TypeError('Invalid regular expression: missing /')); }); + + it('Handles loading of a modules.', async () => { + const requests: string[] = []; + const window = new Window({ + url: 'https://localhost:8080/base/', + settings: { + fetch: { + interceptor: { + beforeAsyncRequest: async ({ request }) => { + requests.push(request.url); + } + }, + virtualServers: [ + { + url: 'https://localhost:8080/base/js/', + directory: './test/nodes/html-script-element/modules/' + } + ] + } + }, + console + }); + const document = window.document; + const script = document.createElement('script'); + let modulesLoadedAfterLoadEvent: string[] | null = null; + + script.src = 'https://localhost:8080/base/js/TestModuleElement.js'; + script.type = 'module'; + script.addEventListener('load', () => { + modulesLoadedAfterLoadEvent = window['moduleLoadOrder'].slice(); + }); + + document.body.appendChild(script); + + await window.happyDOM?.waitUntilComplete(); + + const testModule = document.createElement('test-module'); + + document.body.appendChild(testModule); + + await window.happyDOM?.waitUntilComplete(); + + expect(requests).toEqual([ + 'https://localhost:8080/base/js/TestModuleElement.js', + 'https://localhost:8080/base/js/utilities/StringUtilityClass.js', + 'https://localhost:8080/base/js/utilities/stringUtility.js', + 'https://localhost:8080/base/js/json/data.json', + 'https://localhost:8080/base/js/css/style.css', + 'https://localhost:8080/base/js/utilities/apostrophWrapper.js', + 'https://localhost:8080/base/js/utilities/lazyload.js' + ]); + + expect(window['moduleLoadOrder']).toEqual([ + 'apostrophWrapper.js', + 'StringUtilityClass.js', + 'stringUtility.js', + 'TestModuleElement.js', + 'lazyload.js' + ]); + + expect(modulesLoadedAfterLoadEvent).toEqual([ + 'apostrophWrapper.js', + 'StringUtilityClass.js', + 'stringUtility.js', + 'TestModuleElement.js' + ]); + + expect(testModule.shadowRoot?.innerHTML).toBe(`
+ Expect lower case: "value" + Expect upper case: "VALUE" + Expect lower case. "value" + Expect trimmed lower case: "value" +
Lazy-loaded module: true
`); + + expect(testModule.shadowRoot?.adoptedStyleSheets[0].cssRules[0].cssText).toBe( + 'div { background: red; }' + ); + expect( + window.getComputedStyle(testModule.shadowRoot?.querySelector('div')) + .backgroundColor + ).toBe('red'); + }); + + it('Handles modules using an import map.', async () => { + const requests: string[] = []; + const window = new Window({ + url: 'https://localhost:8080/base/', + settings: { + fetch: { + interceptor: { + beforeAsyncRequest: async ({ request }) => { + requests.push(request.url); + } + }, + virtualServers: [ + { + url: 'https://localhost:8080/base/js/', + directory: './test/nodes/html-script-element/modules-with-import-map/default/' + }, + { + url: 'https://external-scripts.com/js/', + directory: + './test/nodes/html-script-element/modules-with-import-map/external-scripts/' + }, + { + url: 'https://external-resources.com/', + directory: + './test/nodes/html-script-element/modules-with-import-map/external-resources/' + } + ] + } + }, + console + }); + const document = window.document; + const importMap = document.createElement('script'); + + importMap.type = 'importmap'; + importMap.textContent = JSON.stringify({ + imports: { + 'external-scripts/': 'https://external-scripts.com/js/' + }, + scopes: { + 'base/': { + 'external-resources/': 'https://external-resources.com/' + }, + 'https://external-scripts.com': { + 'second-external-resources/': 'https://external-resources.com/' + }, + 'invalid/': { + 'external-resources/': 'https://wrong.com' + } + } + }); + + document.body.appendChild(importMap); + + const script = document.createElement('script'); + + script.src = '/base/js/TestModuleElement.js'; + script.type = 'module'; + + document.body.appendChild(script); + + await window.happyDOM?.waitUntilComplete(); + + const testModule = document.createElement('test-module'); + + document.body.appendChild(testModule); + + await window.happyDOM?.waitUntilComplete(); + + expect(requests).toEqual([ + 'https://localhost:8080/base/js/TestModuleElement.js', + 'https://localhost:8080/base/js/utilities/StringUtilityClass.js', + 'https://external-scripts.com/js/utilities/stringUtility.js', + 'https://external-resources.com/json/data.json', + 'https://external-resources.com/css/style.css', + 'https://external-scripts.com/js/utilities/apostrophWrapper.js', + 'https://localhost:8080/base/js/utilities/lazyload.js' + ]); + + expect(window['moduleLoadOrder']).toEqual([ + 'apostrophWrapper.js', + 'StringUtilityClass.js', + 'stringUtility.js', + 'TestModuleElement.js', + 'lazyload.js' + ]); + + expect(testModule.shadowRoot?.innerHTML).toBe(`
+ Expect lower case: "value" + Expect upper case: "VALUE" + Expect lower case. "value" + Expect trimmed lower case: "value" + Additional expect lower case. "value" +
Lazy-loaded module: true
`); + + expect(testModule.shadowRoot?.adoptedStyleSheets[0].cssRules[0].cssText).toBe( + 'div { background: red; }' + ); + expect( + window.getComputedStyle(testModule.shadowRoot?.querySelector('div')) + .backgroundColor + ).toBe('red'); + }); + + it('Dispatches "error" event on script element when a module is not found', async () => { + const window = new Window({ + url: 'https://localhost:8080/base/', + settings: { + fetch: { + virtualServers: [ + { + url: 'https://localhost:8080/base/js/', + directory: './test/nodes/html-script-element/modules-with-not-found-error/' + } + ] + } + } + }); + const document = window.document; + + const script = document.createElement('script'); + let errorEvent: Event | null = null; + + script.src = '/base/js/TestModuleElement.js'; + script.type = 'module'; + script.addEventListener('error', (event) => { + errorEvent = event; + }); + + document.body.appendChild(script); + + await window.happyDOM?.waitUntilComplete(); + + expect(((errorEvent)).type).toBe('error'); + expect(((errorEvent)).bubbles).toBe(false); + expect( + window.happyDOM?.virtualConsolePrinter.readAsString().startsWith( + `GET https://localhost:8080/base/js/utilities/notFound.js 404 (Not Found) +DOMException: Failed to perform request to "https://localhost:8080/base/js/utilities/notFound.js". Status 404 Not Found.` + ) + ).toBe(true); + }); + + it('Dispatches "error" event on Window when compilation of module failed', async () => { + const window = new Window({ + url: 'https://localhost:8080/base/', + settings: { + fetch: { + virtualServers: [ + { + url: 'https://localhost:8080/base/js/', + directory: './test/nodes/html-script-element/modules-with-compilation-error/' + } + ] + } + } + }); + const document = window.document; + let errorEvent: Event | null = null; + + window.addEventListener('error', (event) => { + errorEvent = event; + }); + + const script = document.createElement('script'); + + script.src = '/base/js/TestModuleElement.js'; + script.type = 'module'; + + document.body.appendChild(script); + + await window.happyDOM?.waitUntilComplete(); + + expect(((errorEvent)).type).toBe('error'); + expect(((errorEvent)).bubbles).toBe(false); + expect( + window.happyDOM?.virtualConsolePrinter + .readAsString() + .startsWith( + `SyntaxError: Failed to parse module 'https://localhost:8080/base/js/utilities/stringUtility.js': Unexpected token 'export'` + ) + ).toBe(true); + }); + + it('Dispatches "error" event on Window when evaluation of module failed', async () => { + const window = new Window({ + url: 'https://localhost:8080/base/', + settings: { + fetch: { + virtualServers: [ + { + url: 'https://localhost:8080/base/js/', + directory: './test/nodes/html-script-element/modules-with-evaluation-error/' + } + ] + } + } + }); + const document = window.document; + let errorEvent: Event | null = null; + + window.addEventListener('error', (event) => { + errorEvent = event; + }); + + const script = document.createElement('script'); + + script.src = '/base/js/TestModuleElement.js'; + script.type = 'module'; + + document.body.appendChild(script); + + await window.happyDOM?.waitUntilComplete(); + + expect(((errorEvent)).type).toBe('error'); + expect(((errorEvent)).bubbles).toBe(false); + expect( + window.happyDOM?.virtualConsolePrinter.readAsString() + .startsWith(`ReferenceError: notFound is not defined + at eval (https://localhost:8080/base/js/utilities/stringUtility.js:12:14)`) + ).toBe(true); + }); + }); + + describe('static supports()', () => { + it('Returns true for supported types.', () => { + expect(window.HTMLScriptElement.supports('classic')).toBe(true); + expect(window.HTMLScriptElement.supports('module')).toBe(true); + expect(window.HTMLScriptElement.supports('importmap')).toBe(true); + + expect(window.HTMLScriptElement.supports('speculationrules')).toBe(false); + expect(window.HTMLScriptElement.supports('text/javascript')).toBe(false); + expect(window.HTMLScriptElement.supports('invalid')).toBe(false); + }); }); }); diff --git a/packages/happy-dom/test/nodes/html-script-element/modules-with-compilation-error/TestModuleElement.js b/packages/happy-dom/test/nodes/html-script-element/modules-with-compilation-error/TestModuleElement.js new file mode 100644 index 000000000..342ebd801 --- /dev/null +++ b/packages/happy-dom/test/nodes/html-script-element/modules-with-compilation-error/TestModuleElement.js @@ -0,0 +1,31 @@ +import { toLowerCase, toUpperCase, trim } from './utilities/stringUtility.js'; + +/* eslint-disable no-undef */ + +/** + * Module element. + */ +class TestModule extends HTMLElement { + /** + * Constructor. + */ + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.shadowRoot.adoptedStyleSheets = [Style]; + } + + /** + * Connected callback. + */ + connectedCallback() { + this.shadowRoot.innerHTML = `
+ Expect lower case: ${StringUtility.toLowerCase(Data.upperCase)} + Expect upper case: ${toUpperCase(Data.lowerCase)} + Expect lower case. ${toLowerCase(Data.upperCase)} + Expect trimmed lower case: ${trim(Data.untrimmed)} +
`; + } +} + +customElements.define('test-module', TestModule); diff --git a/packages/happy-dom/test/nodes/html-script-element/modules-with-compilation-error/utilities/stringUtility.js b/packages/happy-dom/test/nodes/html-script-element/modules-with-compilation-error/utilities/stringUtility.js new file mode 100644 index 000000000..887106584 --- /dev/null +++ b/packages/happy-dom/test/nodes/html-script-element/modules-with-compilation-error/utilities/stringUtility.js @@ -0,0 +1,8 @@ +/* eslint-disable */ + +export const toLowerCase = (str) => str.toLowerCase(); + +const toUpperCase = (str) => str.toUpperCase(); +const trim = (str) => str.trim(); + +export error { toUpperCase, trim }; diff --git a/packages/happy-dom/test/nodes/html-script-element/modules-with-evaluation-error/TestModuleElement.js b/packages/happy-dom/test/nodes/html-script-element/modules-with-evaluation-error/TestModuleElement.js new file mode 100644 index 000000000..342ebd801 --- /dev/null +++ b/packages/happy-dom/test/nodes/html-script-element/modules-with-evaluation-error/TestModuleElement.js @@ -0,0 +1,31 @@ +import { toLowerCase, toUpperCase, trim } from './utilities/stringUtility.js'; + +/* eslint-disable no-undef */ + +/** + * Module element. + */ +class TestModule extends HTMLElement { + /** + * Constructor. + */ + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.shadowRoot.adoptedStyleSheets = [Style]; + } + + /** + * Connected callback. + */ + connectedCallback() { + this.shadowRoot.innerHTML = `
+ Expect lower case: ${StringUtility.toLowerCase(Data.upperCase)} + Expect upper case: ${toUpperCase(Data.lowerCase)} + Expect lower case. ${toLowerCase(Data.upperCase)} + Expect trimmed lower case: ${trim(Data.untrimmed)} +
`; + } +} + +customElements.define('test-module', TestModule); diff --git a/packages/happy-dom/test/nodes/html-script-element/modules-with-evaluation-error/utilities/stringUtility.js b/packages/happy-dom/test/nodes/html-script-element/modules-with-evaluation-error/utilities/stringUtility.js new file mode 100644 index 000000000..d95caa4f3 --- /dev/null +++ b/packages/happy-dom/test/nodes/html-script-element/modules-with-evaluation-error/utilities/stringUtility.js @@ -0,0 +1,12 @@ +/* eslint-disable */ + +export const toLowerCase = (str) => str.toLowerCase(); + +const toUpperCase = (str) => str.toUpperCase(); +const trim = (str) => str.trim(); + +(() => { + console.log(notFound); +})(); + +export { toUpperCase, trim }; diff --git a/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/default/TestModuleElement.js b/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/default/TestModuleElement.js new file mode 100644 index 000000000..9faee4977 --- /dev/null +++ b/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/default/TestModuleElement.js @@ -0,0 +1,54 @@ +import StringUtility from './utilities/StringUtilityClass.js'; +import { + toLowerCase, + trim, + toUpperCase, + getData +} from 'external-scripts/utilities/stringUtility.js'; +import Data from 'external-resources/json/data.json' with { type: 'json' }; +import Style from 'external-resources/css/style.css' with { type: 'css' }; + +/* eslint-disable no-undef */ + +window['moduleLoadOrder'] = window['moduleLoadOrder'] || []; +window['moduleLoadOrder'].push('TestModuleElement.js'); + +/** + * Module element. + */ +class TestModule extends HTMLElement { + /** + * Constructor. + */ + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.shadowRoot.adoptedStyleSheets = [Style]; + } + + /** + * Connected callback. + */ + connectedCallback() { + this.shadowRoot.innerHTML = `
+ Expect lower case: ${StringUtility.toLowerCase(Data.upperCase)} + Expect upper case: ${toUpperCase(Data.lowerCase)} + Expect lower case. ${toLowerCase(Data.upperCase)} + Expect trimmed lower case: ${trim(Data.untrimmed)} + Additional expect lower case. ${toLowerCase(getData().upperCase)} +
`; + this.lazyLoad(); + } + + /** + * Lazy load. + */ + async lazyLoad() { + const { lazyloaded } = await import('./utilities/lazyload.js'); + const div = document.createElement('div'); + div.innerHTML = `Lazy-loaded module: ${lazyloaded}`; + this.shadowRoot.appendChild(div); + } +} + +customElements.define('test-module', TestModule); diff --git a/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/default/utilities/StringUtilityClass.js b/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/default/utilities/StringUtilityClass.js new file mode 100644 index 000000000..59ff57ba9 --- /dev/null +++ b/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/default/utilities/StringUtilityClass.js @@ -0,0 +1,21 @@ +import { apostrophWrapper } from 'external-scripts/utilities/apostrophWrapper.js'; + +/* eslint-disable no-undef */ + +window['moduleLoadOrder'] = window['moduleLoadOrder'] || []; +window['moduleLoadOrder'].push('StringUtilityClass.js'); + +/** + * String utility. + */ +export default class StringUtilityClass { + /** + * Converts a string to lower case. + * + * @param {string} value Value. + * @returns {string} Lower case value. + */ + static toLowerCase(value) { + return apostrophWrapper(value.toLowerCase()); + } +} diff --git a/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/default/utilities/lazyload.js b/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/default/utilities/lazyload.js new file mode 100644 index 000000000..034e38471 --- /dev/null +++ b/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/default/utilities/lazyload.js @@ -0,0 +1,8 @@ +/* eslint-disable no-undef */ + +window['moduleLoadOrder'] = window['moduleLoadOrder'] || []; +window['moduleLoadOrder'].push('lazyload.js'); + +const obj = { lazyloaded: true }; + +export const { lazyloaded } = obj; diff --git a/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/external-resources/css/style.css b/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/external-resources/css/style.css new file mode 100644 index 000000000..e5ee0f49e --- /dev/null +++ b/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/external-resources/css/style.css @@ -0,0 +1,3 @@ +div { + background: red; +} \ No newline at end of file diff --git a/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/external-resources/json/data.json b/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/external-resources/json/data.json new file mode 100644 index 000000000..6130c77e0 --- /dev/null +++ b/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/external-resources/json/data.json @@ -0,0 +1,5 @@ +{ + "lowerCase": "value", + "upperCase": "VALUE", + "untrimmed": " value " +} diff --git a/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/external-scripts/utilities/apostrophWrapper.js b/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/external-scripts/utilities/apostrophWrapper.js new file mode 100644 index 000000000..45cf5fda0 --- /dev/null +++ b/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/external-scripts/utilities/apostrophWrapper.js @@ -0,0 +1,6 @@ +/* eslint-disable no-undef */ + +window['moduleLoadOrder'] = window['moduleLoadOrder'] || []; +window['moduleLoadOrder'].push('apostrophWrapper.js'); + +export const apostrophWrapper = (str) => `"${str}"`; diff --git a/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/external-scripts/utilities/stringUtility.js b/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/external-scripts/utilities/stringUtility.js new file mode 100644 index 000000000..a747c89c3 --- /dev/null +++ b/packages/happy-dom/test/nodes/html-script-element/modules-with-import-map/external-scripts/utilities/stringUtility.js @@ -0,0 +1,15 @@ +import { apostrophWrapper } from './apostrophWrapper.js'; +import Data from 'second-external-resources/json/data.json' with { type: 'json' }; + +/* eslint-disable no-undef */ + +window['moduleLoadOrder'] = window['moduleLoadOrder'] || []; +window['moduleLoadOrder'].push('stringUtility.js'); + +export const toLowerCase = (str) => apostrophWrapper(str.toLowerCase()); + +const toUpperCase = (str) => apostrophWrapper(str.toUpperCase()); +const trim = (str) => apostrophWrapper(str.trim()); +const getData = () => Data; + +export { toUpperCase, trim, getData }; diff --git a/packages/happy-dom/test/nodes/html-script-element/modules-with-not-found-error/TestModuleElement.js b/packages/happy-dom/test/nodes/html-script-element/modules-with-not-found-error/TestModuleElement.js new file mode 100644 index 000000000..342ebd801 --- /dev/null +++ b/packages/happy-dom/test/nodes/html-script-element/modules-with-not-found-error/TestModuleElement.js @@ -0,0 +1,31 @@ +import { toLowerCase, toUpperCase, trim } from './utilities/stringUtility.js'; + +/* eslint-disable no-undef */ + +/** + * Module element. + */ +class TestModule extends HTMLElement { + /** + * Constructor. + */ + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.shadowRoot.adoptedStyleSheets = [Style]; + } + + /** + * Connected callback. + */ + connectedCallback() { + this.shadowRoot.innerHTML = `
+ Expect lower case: ${StringUtility.toLowerCase(Data.upperCase)} + Expect upper case: ${toUpperCase(Data.lowerCase)} + Expect lower case. ${toLowerCase(Data.upperCase)} + Expect trimmed lower case: ${trim(Data.untrimmed)} +
`; + } +} + +customElements.define('test-module', TestModule); diff --git a/packages/happy-dom/test/nodes/html-script-element/modules-with-not-found-error/utilities/stringUtility.js b/packages/happy-dom/test/nodes/html-script-element/modules-with-not-found-error/utilities/stringUtility.js new file mode 100644 index 000000000..618eb2aba --- /dev/null +++ b/packages/happy-dom/test/nodes/html-script-element/modules-with-not-found-error/utilities/stringUtility.js @@ -0,0 +1,8 @@ +import { notFound } from './notFound.js'; + +export const toLowerCase = (str) => notFound(str.toLowerCase()); + +const toUpperCase = (str) => notFound(str.toUpperCase()); +const trim = (str) => notFound(str.trim()); + +export { toUpperCase, trim }; diff --git a/packages/happy-dom/test/nodes/html-script-element/modules/TestModuleElement.js b/packages/happy-dom/test/nodes/html-script-element/modules/TestModuleElement.js new file mode 100644 index 000000000..8530e521f --- /dev/null +++ b/packages/happy-dom/test/nodes/html-script-element/modules/TestModuleElement.js @@ -0,0 +1,49 @@ +import StringUtility from './utilities/StringUtilityClass.js'; +import { toLowerCase, trim } from './utilities/stringUtility.js'; +import { toUpperCase } from './utilities/stringUtility.js'; +import Data from './json/data.json' with { type: 'json' }; +import Style from './css/style.css' with { type: 'css' }; + +/* eslint-disable no-undef */ + +window['moduleLoadOrder'] = window['moduleLoadOrder'] || []; +window['moduleLoadOrder'].push('TestModuleElement.js'); + +/** + * Module element. + */ +class TestModule extends HTMLElement { + /** + * Constructor. + */ + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.shadowRoot.adoptedStyleSheets = [Style]; + } + + /** + * Connected callback. + */ + connectedCallback() { + this.shadowRoot.innerHTML = `
+ Expect lower case: ${StringUtility.toLowerCase(Data.upperCase)} + Expect upper case: ${toUpperCase(Data.lowerCase)} + Expect lower case. ${toLowerCase(Data.upperCase)} + Expect trimmed lower case: ${trim(Data.untrimmed)} +
`; + this.lazyLoad(); + } + + /** + * Lazy load. + */ + async lazyLoad() { + const { lazyloaded } = await import('./utilities/lazyload.js'); + const div = document.createElement('div'); + div.innerHTML = `Lazy-loaded module: ${lazyloaded}`; + this.shadowRoot.appendChild(div); + } +} + +customElements.define('test-module', TestModule); diff --git a/packages/happy-dom/test/nodes/html-script-element/modules/css/style.css b/packages/happy-dom/test/nodes/html-script-element/modules/css/style.css new file mode 100644 index 000000000..e5ee0f49e --- /dev/null +++ b/packages/happy-dom/test/nodes/html-script-element/modules/css/style.css @@ -0,0 +1,3 @@ +div { + background: red; +} \ No newline at end of file diff --git a/packages/happy-dom/test/nodes/html-script-element/modules/json/data.json b/packages/happy-dom/test/nodes/html-script-element/modules/json/data.json new file mode 100644 index 000000000..6130c77e0 --- /dev/null +++ b/packages/happy-dom/test/nodes/html-script-element/modules/json/data.json @@ -0,0 +1,5 @@ +{ + "lowerCase": "value", + "upperCase": "VALUE", + "untrimmed": " value " +} diff --git a/packages/happy-dom/test/nodes/html-script-element/modules/utilities/StringUtilityClass.js b/packages/happy-dom/test/nodes/html-script-element/modules/utilities/StringUtilityClass.js new file mode 100644 index 000000000..a9cba36da --- /dev/null +++ b/packages/happy-dom/test/nodes/html-script-element/modules/utilities/StringUtilityClass.js @@ -0,0 +1,21 @@ +import { apostrophWrapper } from './apostrophWrapper.js'; + +/* eslint-disable no-undef */ + +window['moduleLoadOrder'] = window['moduleLoadOrder'] || []; +window['moduleLoadOrder'].push('StringUtilityClass.js'); + +/** + * String utility. + */ +export default class StringUtilityClass { + /** + * Converts a string to lower case. + * + * @param {string} value Value. + * @returns {string} Lower case value. + */ + static toLowerCase(value) { + return apostrophWrapper(value.toLowerCase()); + } +} diff --git a/packages/happy-dom/test/nodes/html-script-element/modules/utilities/apostrophWrapper.js b/packages/happy-dom/test/nodes/html-script-element/modules/utilities/apostrophWrapper.js new file mode 100644 index 000000000..45cf5fda0 --- /dev/null +++ b/packages/happy-dom/test/nodes/html-script-element/modules/utilities/apostrophWrapper.js @@ -0,0 +1,6 @@ +/* eslint-disable no-undef */ + +window['moduleLoadOrder'] = window['moduleLoadOrder'] || []; +window['moduleLoadOrder'].push('apostrophWrapper.js'); + +export const apostrophWrapper = (str) => `"${str}"`; diff --git a/packages/happy-dom/test/nodes/html-script-element/modules/utilities/lazyload.js b/packages/happy-dom/test/nodes/html-script-element/modules/utilities/lazyload.js new file mode 100644 index 000000000..034e38471 --- /dev/null +++ b/packages/happy-dom/test/nodes/html-script-element/modules/utilities/lazyload.js @@ -0,0 +1,8 @@ +/* eslint-disable no-undef */ + +window['moduleLoadOrder'] = window['moduleLoadOrder'] || []; +window['moduleLoadOrder'].push('lazyload.js'); + +const obj = { lazyloaded: true }; + +export const { lazyloaded } = obj; diff --git a/packages/happy-dom/test/nodes/html-script-element/modules/utilities/stringUtility.js b/packages/happy-dom/test/nodes/html-script-element/modules/utilities/stringUtility.js new file mode 100644 index 000000000..ef75927c7 --- /dev/null +++ b/packages/happy-dom/test/nodes/html-script-element/modules/utilities/stringUtility.js @@ -0,0 +1,13 @@ +import { apostrophWrapper } from './apostrophWrapper.js'; + +/* eslint-disable no-undef */ + +window['moduleLoadOrder'] = window['moduleLoadOrder'] || []; +window['moduleLoadOrder'].push('stringUtility.js'); + +export const toLowerCase = (str) => apostrophWrapper(str.toLowerCase()); + +const toUpperCase = (str) => apostrophWrapper(str.toUpperCase()); +const trim = (str) => apostrophWrapper(str.trim()); + +export { toUpperCase, trim }; diff --git a/packages/happy-dom/test/window/BrowserWindow.test.ts b/packages/happy-dom/test/window/BrowserWindow.test.ts index a0cb3c94c..f40804419 100644 --- a/packages/happy-dom/test/window/BrowserWindow.test.ts +++ b/packages/happy-dom/test/window/BrowserWindow.test.ts @@ -1580,15 +1580,17 @@ describe('BrowserWindow', () => { let loadEventTarget: EventTarget | null = null; let loadEventCurrentTarget: EventTarget | null = null; - vi.spyOn(ResourceFetch.prototype, 'fetch').mockImplementation(async function (url: string) { - if (url.endsWith('.css')) { + vi.spyOn(ResourceFetch.prototype, 'fetch').mockImplementation(async function ( + url: string | URL + ) { + if ((url).endsWith('.css')) { resourceFetchCSSWindow = this.window; - resourceFetchCSSURL = url; + resourceFetchCSSURL = url; return cssResponse; } resourceFetchJSWindow = this.window; - resourceFetchJSURL = url; + resourceFetchJSURL = url; return jsResponse; });