Skip to content

Commit

Permalink
feat: [#320] Adds support for ECMAScript modules (#1705)
Browse files Browse the repository at this point in the history
  • Loading branch information
capricorn86 authored Feb 5, 2025
1 parent 33e888d commit 7bc831d
Show file tree
Hide file tree
Showing 85 changed files with 5,293 additions and 519 deletions.
3 changes: 2 additions & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
**/tmp
**/lib
**/cjs
**/tmp
**/tmp
**/test/**/modules-with-compilation-error
12 changes: 12 additions & 0 deletions packages/happy-dom/src/PropertySymbol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
100 changes: 81 additions & 19 deletions packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@ export default class AsyncTaskManager {
private runningTaskCount = 0;
private runningTimers: NodeJS.Timeout[] = [];
private runningImmediates: NodeJS.Immediate[] = [];
private debugTrace: Map<number | NodeJS.Timeout | NodeJS.Immediate, string> = 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.
Expand All @@ -37,8 +42,8 @@ export default class AsyncTaskManager {
* @returns Promise.
*/
public waitUntilComplete(): Promise<void> {
return new Promise((resolve) => {
this.waitUntilCompleteResolvers.push(resolve);
return new Promise((resolve, reject) => {
this.waitUntilCompleteResolvers.push({ resolve, reject });
this.resolveWhenComplete();
});
}
Expand All @@ -48,8 +53,8 @@ export default class AsyncTaskManager {
*/
public abort(): Promise<void> {
if (this.aborted) {
return new Promise((resolve) => {
this.waitUntilCompleteResolvers.push(resolve);
return new Promise((resolve, reject) => {
this.waitUntilCompleteResolvers.push({ resolve, reject });
this.resolveWhenComplete();
});
}
Expand All @@ -61,8 +66,8 @@ export default class AsyncTaskManager {
*/
public destroy(): Promise<void> {
if (this.aborted) {
return new Promise((resolve) => {
this.waitUntilCompleteResolvers.push(resolve);
return new Promise((resolve, reject) => {
this.waitUntilCompleteResolvers.push({ resolve, reject });
this.resolveWhenComplete();
});
}
Expand All @@ -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);
}
}

/**
Expand All @@ -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);
}
}

Expand All @@ -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);
}
}

/**
Expand All @@ -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);
}
}

Expand All @@ -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;
}

Expand All @@ -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);
}
}

Expand All @@ -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;
}
Expand All @@ -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.
*
Expand All @@ -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);
Expand All @@ -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();
});
}
Expand Down
4 changes: 4 additions & 0 deletions packages/happy-dom/src/browser/BrowserSettingsFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export default class BrowserSettingsFactory {
device: {
...DefaultBrowserSettings.device,
...settings?.device
},
debug: {
...DefaultBrowserSettings.debug,
...settings?.debug
}
};
}
Expand Down
3 changes: 3 additions & 0 deletions packages/happy-dom/src/browser/DefaultBrowserSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,8 @@ export default <IBrowserSettings>{
device: {
prefersColorScheme: 'light',
mediaType: 'screen'
},
debug: {
traceWaitUntilComplete: -1
}
};
7 changes: 7 additions & 0 deletions packages/happy-dom/src/browser/types/IBrowserSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,11 @@ export default interface IBrowserSettings {
prefersColorScheme: string;
mediaType: string;
};

/**
* Debug settings.
*/
debug: {
traceWaitUntilComplete: number;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,11 @@ export default interface IOptionalBrowserSettings {
prefersColorScheme?: string;
mediaType?: string;
};

/**
* Debug settings.
*/
debug?: {
traceWaitUntilComplete?: number;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 }
>(<unknown>frame.window))[PropertySymbol.readyStateManager];
const readyStateManager = frame.window[PropertySymbol.readyStateManager];

readyStateManager.startTask();
const code =
Expand Down Expand Up @@ -178,9 +175,7 @@ export default class BrowserFrameNavigator {
}

// Start navigation
const readyStateManager = (<{ [PropertySymbol.readyStateManager]: DocumentReadyStateManager }>(
(<unknown>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.')),
Expand Down
16 changes: 12 additions & 4 deletions packages/happy-dom/src/dom/DOMTokenList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,29 @@ export default class DOMTokenList {
items: [],
attributeValue: ''
};
private [PropertySymbol.supports]: string[];

/**
* Constructor.
*
* @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]);

Expand Down Expand Up @@ -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);
}

/**
Expand Down
Loading

0 comments on commit 7bc831d

Please sign in to comment.