diff --git a/README.md b/README.md index c6449f213..ea0bcc217 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,10 @@ This package makes it possible to use Happy DOM with [Jest](https://jestjs.io/). ### [![Published on npm](https://img.shields.io/npm/v/@happy-dom/global-registrator.svg)](https://www.npmjs.com/package/@happy-dom/global-registrator) [global-registrator](https://github.com/capricorn86/happy-dom/tree/master/packages/global-registrator) +--- + +### [![Published on npm](https://img.shields.io/npm/v/@happy-dom/uncaught-exception-observer.svg)](https://www.npmjs.com/package/@happy-dom/uncaught-exception-observer) [global-registrator](https://github.com/capricorn86/happy-dom/tree/master/packages/uncaught-exception-observer) + A utility that registers Happy DOM globally, which makes it possible to use Happy DOM for testing in a Node environment. # Performance diff --git a/package-lock.json b/package-lock.json index c7eabae3f..ce56a0a9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11482,11 +11482,9 @@ } }, "packages/uncaught-exception-observer": { + "name": "@happy-dom/uncaught-exception-observer", "version": "0.0.0", "license": "MIT", - "dependencies": { - "happy-dom": "^0.0.0" - }, "devDependencies": { "@types/node": "^15.6.0", "@typescript-eslint/eslint-plugin": "^5.16.0", @@ -11499,8 +11497,12 @@ "eslint-plugin-json": "^3.1.0", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-turbo": "^0.0.7", + "happy-dom": "^0.0.0", "prettier": "^2.6.0", "typescript": "^5.0.4" + }, + "peerDependencies": { + "happy-dom": ">= 2.25.2" } } }, diff --git a/packages/happy-dom/package.json b/packages/happy-dom/package.json index fe914e608..ed0075739 100644 --- a/packages/happy-dom/package.json +++ b/packages/happy-dom/package.json @@ -73,7 +73,7 @@ "test": "vitest run", "test:ui": "vitest --ui", "test:watch": "vitest", - "test:debug": "vitest --inspect-brk --threads false" + "test:debug": "vitest run --inspect-brk --threads false" }, "dependencies": { "css.escape": "^1.5.1", diff --git a/packages/happy-dom/src/console/VirtualConsole.ts b/packages/happy-dom/src/console/VirtualConsole.ts index d952ce9dd..9f2a60834 100644 --- a/packages/happy-dom/src/console/VirtualConsole.ts +++ b/packages/happy-dom/src/console/VirtualConsole.ts @@ -1,7 +1,7 @@ -import VirtualConsolePrinter from './VirtualConsolePrinter.js'; -import VirtualConsoleLogLevelEnum from './VirtualConsoleLogLevelEnum.js'; -import VirtualConsoleLogTypeEnum from './VirtualConsoleLogTypeEnum.js'; -import IVirtualConsoleLogGroup from './IVirtualConsoleLogGroup.js'; +import IVirtualConsolePrinter from './types/IVirtualConsolePrinter.js'; +import VirtualConsoleLogLevelEnum from './enums/VirtualConsoleLogLevelEnum.js'; +import VirtualConsoleLogTypeEnum from './enums/VirtualConsoleLogTypeEnum.js'; +import IVirtualConsoleLogGroup from './types/IVirtualConsoleLogGroup.js'; import * as PerfHooks from 'perf_hooks'; /** @@ -14,7 +14,7 @@ export default class VirtualConsole implements Console { // This is not part of the browser specs. public Console: NodeJS.ConsoleConstructor; - private _printer: VirtualConsolePrinter; + private _printer: IVirtualConsolePrinter; private _count: { [label: string]: number } = {}; private _time: { [label: string]: number } = {}; private _groupID = 0; @@ -25,7 +25,7 @@ export default class VirtualConsole implements Console { * * @param printer Console printer. */ - constructor(printer: VirtualConsolePrinter) { + constructor(printer: IVirtualConsolePrinter) { this._printer = printer; } @@ -40,7 +40,7 @@ export default class VirtualConsole implements Console { this._printer.print({ type: VirtualConsoleLogTypeEnum.assert, level: VirtualConsoleLogLevelEnum.error, - message: args, + message: ['Assertion failed:', ...args], group: this._groups[this._groups.length - 1] || null }); } @@ -165,7 +165,7 @@ export default class VirtualConsole implements Console { this._groupID++; const group = { id: this._groupID, - label: label || '', + label: label || 'default', collapsed: false, parent: this._groups[this._groups.length - 1] || null }; @@ -173,7 +173,7 @@ export default class VirtualConsole implements Console { this._printer.print({ type: VirtualConsoleLogTypeEnum.group, level: VirtualConsoleLogLevelEnum.log, - message: [label || ''], + message: [label || 'default'], group }); } @@ -187,15 +187,15 @@ export default class VirtualConsole implements Console { this._groupID++; const group = { id: this._groupID, - label: label || '', + label: label || 'default', collapsed: true, parent: this._groups[this._groups.length - 1] || null }; this._groups.push(group); this._printer.print({ - type: VirtualConsoleLogTypeEnum.group, + type: VirtualConsoleLogTypeEnum.groupCollapsed, level: VirtualConsoleLogLevelEnum.log, - message: [label || ''], + message: [label || 'default'], group }); } @@ -335,7 +335,7 @@ export default class VirtualConsole implements Console { this._printer.print({ type: VirtualConsoleLogTypeEnum.trace, level: VirtualConsoleLogLevelEnum.log, - message: [...args, new Error('stack').stack], + message: [...args, new Error('stack').stack.replace('Error: stack', '')], group: this._groups[this._groups.length - 1] || null }); } diff --git a/packages/happy-dom/src/console/VirtualConsolePrinter.ts b/packages/happy-dom/src/console/VirtualConsolePrinter.ts index 8c9684eb7..934751a31 100644 --- a/packages/happy-dom/src/console/VirtualConsolePrinter.ts +++ b/packages/happy-dom/src/console/VirtualConsolePrinter.ts @@ -1,12 +1,13 @@ -import IVirtualConsoleLogEntry from './IVirtualConsoleLogEntry.js'; -import VirtualConsoleLogLevelEnum from './VirtualConsoleLogLevelEnum.js'; +import IVirtualConsoleLogEntry from './types/IVirtualConsoleLogEntry.js'; +import VirtualConsoleLogLevelEnum from './enums/VirtualConsoleLogLevelEnum.js'; import Event from '../event/Event.js'; -import VirtualConsoleUtility from './VirtualConsoleUtility.js'; +import VirtualConsoleLogEntryStringifier from './utilities/VirtualConsoleLogEntryStringifier.js'; +import IVirtualConsolePrinter from './types/IVirtualConsolePrinter.js'; /** * Virtual console printer. */ -export default class VirtualConsolePrinter { +export default class VirtualConsolePrinter implements IVirtualConsolePrinter { private _logEntries: IVirtualConsoleLogEntry[] = []; private _listeners: { print: Array<(event: Event) => void>; @@ -98,7 +99,7 @@ export default class VirtualConsolePrinter { let output = ''; for (const logEntry of logEntries) { if (logEntry.level >= logLevel) { - output += VirtualConsoleUtility.stringifyMessage(logEntry.message); + output += VirtualConsoleLogEntryStringifier.toString(logEntry); } } return output; diff --git a/packages/happy-dom/src/console/VirtualConsoleUtility.ts b/packages/happy-dom/src/console/VirtualConsoleUtility.ts deleted file mode 100644 index b76a56d17..000000000 --- a/packages/happy-dom/src/console/VirtualConsoleUtility.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Virtual console utility. - */ -export default class VirtualConsoleUtility { - /** - * Stringifies a log entry message. - * - * @param message Message. - * @returns Stringified message. - */ - public static stringifyMessage(message: Array): string { - let output = ''; - for (const part of message) { - if (typeof part === 'object' && (part === null || part.constructor.name === 'Object')) { - try { - output += JSON.stringify(part, null, 3); - } catch (error) { - output += '["Failed stringify object in log entry."]'; - } - } else { - output += String(part); - } - } - return output; - } -} diff --git a/packages/happy-dom/src/console/VirtualConsoleLogLevelEnum.ts b/packages/happy-dom/src/console/enums/VirtualConsoleLogLevelEnum.ts similarity index 100% rename from packages/happy-dom/src/console/VirtualConsoleLogLevelEnum.ts rename to packages/happy-dom/src/console/enums/VirtualConsoleLogLevelEnum.ts diff --git a/packages/happy-dom/src/console/VirtualConsoleLogTypeEnum.ts b/packages/happy-dom/src/console/enums/VirtualConsoleLogTypeEnum.ts similarity index 100% rename from packages/happy-dom/src/console/VirtualConsoleLogTypeEnum.ts rename to packages/happy-dom/src/console/enums/VirtualConsoleLogTypeEnum.ts diff --git a/packages/happy-dom/src/console/IVirtualConsoleLogEntry.ts b/packages/happy-dom/src/console/types/IVirtualConsoleLogEntry.ts similarity index 62% rename from packages/happy-dom/src/console/IVirtualConsoleLogEntry.ts rename to packages/happy-dom/src/console/types/IVirtualConsoleLogEntry.ts index 3f3ded068..11b4ab699 100644 --- a/packages/happy-dom/src/console/IVirtualConsoleLogEntry.ts +++ b/packages/happy-dom/src/console/types/IVirtualConsoleLogEntry.ts @@ -1,6 +1,6 @@ import IVirtualConsoleLogGroup from './IVirtualConsoleLogGroup.js'; -import VirtualConsoleLogLevelEnum from './VirtualConsoleLogLevelEnum.js'; -import VirtualConsoleLogTypeEnum from './VirtualConsoleLogTypeEnum.js'; +import VirtualConsoleLogLevelEnum from '../enums/VirtualConsoleLogLevelEnum.js'; +import VirtualConsoleLogTypeEnum from '../enums/VirtualConsoleLogTypeEnum.js'; export default interface IVirtualConsoleLogEntry { type: VirtualConsoleLogTypeEnum; diff --git a/packages/happy-dom/src/console/IVirtualConsoleLogGroup.ts b/packages/happy-dom/src/console/types/IVirtualConsoleLogGroup.ts similarity index 100% rename from packages/happy-dom/src/console/IVirtualConsoleLogGroup.ts rename to packages/happy-dom/src/console/types/IVirtualConsoleLogGroup.ts diff --git a/packages/happy-dom/src/console/types/IVirtualConsolePrinter.ts b/packages/happy-dom/src/console/types/IVirtualConsolePrinter.ts new file mode 100644 index 000000000..409a155fe --- /dev/null +++ b/packages/happy-dom/src/console/types/IVirtualConsolePrinter.ts @@ -0,0 +1,58 @@ +import IVirtualConsoleLogEntry from './IVirtualConsoleLogEntry.js'; +import VirtualConsoleLogLevelEnum from '../enums/VirtualConsoleLogLevelEnum.js'; +import Event from '../../event/Event.js'; + +/** + * Virtual console printer. + */ +export default interface IVirtualConsolePrinter { + /** + * Writes to the output. + * + * @param logEntry Log entry. + */ + print(logEntry: IVirtualConsoleLogEntry): void; + + /** + * Clears the output. + */ + clear(): void; + + /** + * Adds an event listener. + * + * @param eventType Event type ("print" or "clear"). + * @param listener Listener. + */ + addEventListener(eventType: 'print' | 'clear', listener: (event: Event) => void): void; + + /** + * Removes an event listener. + * + * @param eventType Event type ("print" or "clear"). + * @param listener Listener. + */ + removeEventListener(eventType: 'print' | 'clear', listener: (event: Event) => void): void; + + /** + * Dispatches an event. + * + * @param event Event. + */ + dispatchEvent(event: Event): void; + + /** + * Reads the buffer. + * + * @returns Console log entries. + */ + read(): IVirtualConsoleLogEntry[]; + + /** + * Returns the buffer as a string. + * + * @param [logLevel] Log level. + * @returns Buffer as a string of concatenated log entries. + */ + readAsString(logLevel: VirtualConsoleLogLevelEnum): string; +} diff --git a/packages/happy-dom/src/console/utilities/VirtualConsoleLogEntryStringifier.ts b/packages/happy-dom/src/console/utilities/VirtualConsoleLogEntryStringifier.ts new file mode 100644 index 000000000..e04f00f7a --- /dev/null +++ b/packages/happy-dom/src/console/utilities/VirtualConsoleLogEntryStringifier.ts @@ -0,0 +1,100 @@ +import IVirtualConsoleLogEntry from '../types/IVirtualConsoleLogEntry.js'; +import VirtualConsoleLogTypeEnum from '../enums/VirtualConsoleLogTypeEnum.js'; + +/** + * Virtual console utility. + */ +export default class VirtualConsoleLogEntryStringifier { + /** + * Stringifies a log entry. + * + * @param logEntry Log entry. + * @returns Stringified message. + */ + public static toString(logEntry: IVirtualConsoleLogEntry): string { + if (this.isLogEntryCollapsed(logEntry)) { + return ''; + } + + const tabbing = this.getLogEntryGroupTabbing(logEntry); + let output = tabbing; + for (const part of logEntry.message) { + output += output !== '' && output !== tabbing ? ' ' : ''; + if ( + typeof part === 'object' && + (part === null || part.constructor.name === 'Object' || Array.isArray(part)) + ) { + try { + output += JSON.stringify(part); + } catch (error) { + output += new Error('Failed to JSON stringify object in log entry.').stack.replace( + /\n at/gm, + '\n ' + tabbing + 'at' + ); + } + } else if (typeof part === 'object' && part['message'] && part['stack']) { + output += part['stack'].replace(/\n at/gm, '\n ' + tabbing + 'at'); + } else { + output += this.getLogEntryIcon(logEntry) + String(part); + } + } + return output + '\n'; + } + + /** + * Gets the log entry icon. + * + * @param logEntry Log entry. + * @returns Icon. + */ + private static getLogEntryIcon(logEntry: IVirtualConsoleLogEntry): string { + switch (logEntry.type) { + case VirtualConsoleLogTypeEnum.group: + return '▼ '; + case VirtualConsoleLogTypeEnum.groupCollapsed: + return '▶ '; + } + return ''; + } + + /** + * Gets the log entry group tabbing. + * + * @param logEntry Log entry. + * @returns Tabbing. + */ + private static getLogEntryGroupTabbing(logEntry: IVirtualConsoleLogEntry): string { + let tabs = ''; + let group = + logEntry.type === VirtualConsoleLogTypeEnum.group || + logEntry.type === VirtualConsoleLogTypeEnum.groupCollapsed + ? logEntry.group?.parent + : logEntry.group; + while (group) { + tabs += ' '; + group = group.parent; + } + return tabs; + } + + /** + * Checks if the log entry content is collapsed. + * + * @param logEntry Log entry. + * @returns True if collapsed. + */ + private static isLogEntryCollapsed(logEntry: IVirtualConsoleLogEntry): boolean { + let group = + logEntry.type === VirtualConsoleLogTypeEnum.group || + logEntry.type === VirtualConsoleLogTypeEnum.groupCollapsed + ? logEntry.group?.parent + : logEntry.group; + while (group) { + if (group.collapsed) { + return true; + } + group = group.parent; + } + return false; + } +} diff --git a/packages/happy-dom/src/event/EventTarget.ts b/packages/happy-dom/src/event/EventTarget.ts index a18d70406..7a57a1f62 100644 --- a/packages/happy-dom/src/event/EventTarget.ts +++ b/packages/happy-dom/src/event/EventTarget.ts @@ -139,10 +139,12 @@ export default abstract class EventTarget implements IEventTarget { const onEventName = 'on' + event.type.toLowerCase(); if (typeof this[onEventName] === 'function') { - WindowErrorUtility.captureErrorAsync( - window, - async () => await this[onEventName].call(this, event) - ); + // We can end up in a never ending loop if the listener for the error event on Window also throws an error. + if (window && (this !== window || event.type !== 'error')) { + WindowErrorUtility.captureErrorSync(window, this[onEventName].bind(this, event)); + } else { + this[onEventName].call(this, event); + } } } @@ -166,13 +168,25 @@ export default abstract class EventTarget implements IEventTarget { event._isInPassiveEventListener = true; } - if ((listener).handleEvent) { - (listener).handleEvent(event); + // We can end up in a never ending loop if the listener for the error event on Window also throws an error. + if (window && (this !== window || event.type !== 'error')) { + if ((listener).handleEvent) { + WindowErrorUtility.captureErrorSync( + window, + (listener).handleEvent.bind(this, event) + ); + } else { + WindowErrorUtility.captureErrorSync( + window, + (<(event: Event) => void>listener).bind(this, event) + ); + } } else { - WindowErrorUtility.captureErrorAsync( - window, - async () => await (<(event: Event) => void>listener).call(this, event) - ); + if ((listener).handleEvent) { + (listener).handleEvent(event); + } else { + (<(event: Event) => void>listener).call(this, event); + } } event._isInPassiveEventListener = false; diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts index 709b9cf29..25e1da991 100644 --- a/packages/happy-dom/src/nodes/element/Element.ts +++ b/packages/happy-dom/src/nodes/element/Element.ts @@ -42,7 +42,7 @@ export default class Element extends Node implements IElement { public static observedAttributes: string[]; public tagName: string = null; public nodeType = Node.ELEMENT_NODE; - public shadowRoot: IShadowRoot = null; + public shadowRoot: IShadowRoot | null = null; public prefix: string = null; public scrollHeight = 0; diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts index 0d202ff93..6105c529a 100644 --- a/packages/happy-dom/src/window/Window.ts +++ b/packages/happy-dom/src/window/Window.ts @@ -722,9 +722,9 @@ export default class Window extends EventTarget implements IWindow { * @returns Timeout ID. */ public setTimeout(callback: Function, delay = 0, ...args: unknown[]): NodeJS.Timeout { - const id = this._setTimeout(async () => { + const id = this._setTimeout(() => { this.happyDOM.asyncTaskManager.endTimer(id); - WindowErrorUtility.captureErrorAsync(this, async () => await callback(...args)); + WindowErrorUtility.captureErrorSync(this, () => callback(...args)); }, delay); this.happyDOM.asyncTaskManager.startTimer(id); return id; @@ -749,9 +749,12 @@ export default class Window extends EventTarget implements IWindow { * @returns Interval ID. */ public setInterval(callback: Function, delay = 0, ...args: unknown[]): NodeJS.Timeout { - const id = this._setInterval(async () => { - this.happyDOM.asyncTaskManager.endTimer(id); - WindowErrorUtility.captureErrorAsync(this, async () => await callback(...args)); + const id = this._setInterval(() => { + WindowErrorUtility.captureErrorSync( + this, + () => callback(...args), + () => this.clearInterval(id) + ); }, delay); this.happyDOM.asyncTaskManager.startTimer(id); return id; @@ -794,9 +797,9 @@ export default class Window extends EventTarget implements IWindow { public queueMicrotask(callback: Function): void { let isAborted = false; const taskId = this.happyDOM.asyncTaskManager.startTask(() => (isAborted = true)); - this._queueMicrotask(async () => { + this._queueMicrotask(() => { if (!isAborted) { - WindowErrorUtility.captureErrorAsync(this, async () => await callback()); + WindowErrorUtility.captureErrorSync(this, <() => unknown>callback); this.happyDOM.asyncTaskManager.endTask(taskId); } }); diff --git a/packages/happy-dom/src/window/WindowErrorUtility.ts b/packages/happy-dom/src/window/WindowErrorUtility.ts index 5823a4553..b831370d2 100644 --- a/packages/happy-dom/src/window/WindowErrorUtility.ts +++ b/packages/happy-dom/src/window/WindowErrorUtility.ts @@ -13,39 +13,62 @@ export default class WindowErrorUtility { * * @param elementOrWindow Element or Window. * @param callback Callback. + * @param [cleanup] Cleanup callback on error. * @returns Promise. */ public static async captureErrorAsync( elementOrWindow: IWindow | IElement, - callback: () => Promise + callback: () => Promise, + cleanup?: () => void ): Promise { try { return await callback(); } catch (error) { this.dispatchError(elementOrWindow, error); + if (cleanup) { + cleanup(); + } } return null; } /** * 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 captureErrorSync( elementOrWindow: IWindow | IElement, - callback: () => T + callback: () => T, + cleanup?: () => void ): T | null { + let result = null; + try { - return callback(); + result = callback(); } catch (error) { this.dispatchError(elementOrWindow, error); + if (cleanup) { + cleanup(); + } } - return null; + + if (result && result instanceof Promise) { + result.catch((error) => { + this.dispatchError(elementOrWindow, error); + if (cleanup) { + cleanup(); + } + }); + } + + return result; } /** @@ -56,12 +79,10 @@ export default class WindowErrorUtility { */ public static dispatchError(elementOrWindow: IWindow | IElement, error: Error): void { if ((elementOrWindow).console) { - (elementOrWindow).console.error(error.message + '\n' + error.stack); + (elementOrWindow).console.error(error); elementOrWindow.dispatchEvent(new ErrorEvent('error', { message: error.message, error })); } else { - (elementOrWindow).ownerDocument.defaultView.console.error( - error.message + '\n' + error.stack - ); + (elementOrWindow).ownerDocument.defaultView.console.error(error); (elementOrWindow).dispatchEvent( new ErrorEvent('error', { message: error.message, error }) ); diff --git a/packages/happy-dom/test/console/VirtualConsole.test.ts b/packages/happy-dom/test/console/VirtualConsole.test.ts new file mode 100644 index 000000000..4eef07cea --- /dev/null +++ b/packages/happy-dom/test/console/VirtualConsole.test.ts @@ -0,0 +1,279 @@ +import { beforeEach, describe, it, expect } from 'vitest'; +import VirtualConsole from '../../src/console/VirtualConsole.js'; +import VirtualConsolePrinter from '../../src/console/VirtualConsolePrinter.js'; + +const ERROR_WITH_SIMPLE_STACK = new Error('Test error'); +ERROR_WITH_SIMPLE_STACK.stack = 'Error: Test error\n at /some/where.js:1:1'; + +describe('VirtualConsole', () => { + let virtualConsolePrinter: VirtualConsolePrinter; + let virtualConsole: VirtualConsole; + + beforeEach(() => { + virtualConsolePrinter = new VirtualConsolePrinter(); + virtualConsole = new VirtualConsole(virtualConsolePrinter); + }); + + describe('assert()', () => { + it('Should print a message if the assertion is false.', () => { + virtualConsole.assert(false, 'Test', { test: true }); + expect(virtualConsolePrinter.readAsString()).toBe('Assertion failed: Test {"test":true}\n'); + }); + + it('Should not print a message if the assertion is true.', () => { + virtualConsole.assert(true, 'Test', { test: true }); + expect(virtualConsolePrinter.readAsString()).toBe(''); + }); + }); + + describe('clear()', () => { + it('Should clear the console.', () => { + virtualConsole.log('Test'); + virtualConsole.clear(); + expect(virtualConsolePrinter.readAsString()).toBe(''); + }); + }); + + describe('count()', () => { + it('Should print the number of times count() has been called.', () => { + virtualConsole.count(); + virtualConsole.count(); + expect(virtualConsolePrinter.readAsString()).toBe('default: 1\ndefault: 2\n'); + }); + + it('Should print the number of times count() has been called with a label.', () => { + virtualConsole.count('test'); + virtualConsole.count('test'); + expect(virtualConsolePrinter.readAsString()).toBe('test: 1\ntest: 2\n'); + }); + }); + + describe('countReset()', () => { + it('Should reset the counter.', () => { + virtualConsole.count(); + virtualConsole.countReset(); + virtualConsole.count(); + expect(virtualConsolePrinter.readAsString()).toBe('default: 1\ndefault: 0\ndefault: 1\n'); + }); + + it('Should reset the counter with a label.', () => { + virtualConsole.count('test'); + virtualConsole.countReset('test'); + virtualConsole.count('test'); + expect(virtualConsolePrinter.readAsString()).toBe('test: 1\ntest: 0\ntest: 1\n'); + }); + }); + + describe('debug()', () => { + it('Should print a message.', () => { + virtualConsole.debug('Test', { test: true }); + expect(virtualConsolePrinter.readAsString()).toBe('Test {"test":true}\n'); + }); + }); + + describe('dir()', () => { + it('Should print an object.', () => { + virtualConsole.dir({ test: true }); + expect(virtualConsolePrinter.readAsString()).toBe('{"test":true}\n'); + }); + }); + + describe('dirxml()', () => { + it('Should print an object.', () => { + virtualConsole.dirxml({ test: true }); + expect(virtualConsolePrinter.readAsString()).toBe('{"test":true}\n'); + }); + }); + + describe('error()', () => { + it('Should print a message.', () => { + virtualConsole.error('Test', { test: true }); + expect(virtualConsolePrinter.readAsString()).toBe('Test {"test":true}\n'); + }); + + it('Should print an Error object.', () => { + virtualConsole.error(ERROR_WITH_SIMPLE_STACK); + expect(virtualConsolePrinter.readAsString()).toBe( + 'Error: Test error\n at /some/where.js:1:1\n' + ); + }); + }); + + describe('exception()', () => { + it('Should print a message.', () => { + virtualConsole.exception('Test', { test: true }); + expect(virtualConsolePrinter.readAsString()).toBe('Test {"test":true}\n'); + }); + + it('Should print an Error object.', () => { + virtualConsole.exception(ERROR_WITH_SIMPLE_STACK); + expect(virtualConsolePrinter.readAsString()).toBe( + 'Error: Test error\n at /some/where.js:1:1\n' + ); + }); + }); + + describe('group()', () => { + it('Should create groups.', () => { + virtualConsole.group('Group 1'); + virtualConsole.log('Test 1'); + virtualConsole.log('Test 2'); + virtualConsole.group('Group 2'); + virtualConsole.log('Test 3'); + virtualConsole.groupEnd(); + virtualConsole.log('Test 4'); + virtualConsole.groupEnd(); + expect(virtualConsolePrinter.readAsString()).toBe( + '▼ Group 1\n' + + ' Test 1\n' + + ' Test 2\n' + + ' ▼ Group 2\n' + + ' Test 3\n' + + ' Test 4\n' + ); + }); + + it('Should handle a default group.', () => { + virtualConsole.log('Test 1'); + virtualConsole.group(); + virtualConsole.log('Test 2'); + virtualConsole.groupEnd(); + virtualConsole.log('Test 3'); + expect(virtualConsolePrinter.readAsString()).toBe( + 'Test 1\n' + '▼ default\n' + ' Test 2\n' + 'Test 3\n' + ); + }); + }); + + describe('groupCollapsed()', () => { + it('Should create groups collapsed.', () => { + virtualConsole.groupCollapsed('Group 1'); + virtualConsole.log('Test 1'); + virtualConsole.log('Test 2'); + virtualConsole.groupCollapsed('Group 2'); + virtualConsole.log('Test 3'); + virtualConsole.groupEnd(); + virtualConsole.groupEnd(); + virtualConsole.group('Group 3'); + virtualConsole.log('Test 4'); + expect(virtualConsolePrinter.readAsString()).toBe( + '▶ Group 1\n' + '▼ Group 3\n' + ' Test 4\n' + ); + }); + + it('Should handle a default group.', () => { + virtualConsole.log('Test 1'); + virtualConsole.groupCollapsed(); + virtualConsole.log('Test 2'); + virtualConsole.groupEnd(); + virtualConsole.log('Test 3'); + expect(virtualConsolePrinter.readAsString()).toBe('Test 1\n' + '▶ default\n' + 'Test 3\n'); + }); + }); + + describe('groupEnd()', () => { + it('Should exit the current group.', () => { + virtualConsole.group('Group 1'); + virtualConsole.log('Test 1'); + virtualConsole.groupCollapsed('Group 2'); + virtualConsole.log('Test 2'); + virtualConsole.groupEnd(); + virtualConsole.log('Test 3'); + virtualConsole.groupEnd(); + expect(virtualConsolePrinter.readAsString()).toBe( + '▼ Group 1\n' + ' Test 1\n' + ' ▶ Group 2\n' + ' Test 3\n' + ); + }); + }); + + describe('info()', () => { + it('Should print a message.', () => { + virtualConsole.info('Test', { test: true }); + expect(virtualConsolePrinter.readAsString()).toBe('Test {"test":true}\n'); + }); + }); + + describe('log()', () => { + it('Should print a message.', () => { + virtualConsole.log('Test', { test: true }); + expect(virtualConsolePrinter.readAsString()).toBe('Test {"test":true}\n'); + }); + }); + + describe('profile()', () => { + it('Should throw an exception that it is not supported.', () => { + expect(() => virtualConsole.profile()).toThrow('Method not implemented.'); + }); + }); + + describe('profileEnd()', () => { + it('Should throw an exception that it is not supported.', () => { + expect(() => virtualConsole.profileEnd()).toThrow('Method not implemented.'); + }); + }); + + describe('table()', () => { + it('Should print an object.', () => { + virtualConsole.table({ a: 1 }); + expect(virtualConsolePrinter.readAsString()).toBe('{"a":1}\n'); + }); + }); + + describe('time()', () => { + it('Should store time for the default label.', () => { + virtualConsole.time(); + virtualConsole.timeEnd(); + expect(virtualConsolePrinter.readAsString()).toMatch(/default: [\d.]+ms/); + }); + + it('Should store time for a label.', () => { + virtualConsole.time('test'); + virtualConsole.timeEnd('test'); + expect(virtualConsolePrinter.readAsString()).toMatch(/test: [\d.]+ms/); + }); + }); + + describe('timeEnd()', () => { + it('Should print the time between the stored start time and when it was ended.', () => { + virtualConsole.time(); + virtualConsole.timeEnd(); + expect(virtualConsolePrinter.readAsString()).toMatch(/default: [\d.]+ms/); + }); + }); + + describe('timeLog()', () => { + it('Should print the time between the stored start time and when it was ended.', () => { + virtualConsole.time(); + virtualConsole.timeLog(); + expect(virtualConsolePrinter.readAsString()).toMatch(/default: [\d.]+ms/); + }); + + it('Should print the time between the stored start time and when it was ended with a label.', () => { + virtualConsole.time('test'); + virtualConsole.timeLog('test'); + expect(virtualConsolePrinter.readAsString()).toMatch(/test: [\d.]+ms/); + }); + }); + + describe('timeStamp()', () => { + it('Should throw an exception that it is not supported.', () => { + expect(() => virtualConsole.timeStamp()).toThrow('Method not implemented.'); + }); + }); + + describe('trace()', () => { + it('Should print a stack trace.', () => { + virtualConsole.trace('Test', { test: true }); + expect(virtualConsolePrinter.readAsString().startsWith('Test {"test":true} \n at')).toBe( + true + ); + }); + }); + + describe('warn()', () => { + it('Should print a message.', () => { + virtualConsole.warn('Test', { test: true }); + expect(virtualConsolePrinter.readAsString()).toBe('Test {"test":true}\n'); + }); + }); +}); diff --git a/packages/happy-dom/test/console/VirtualConsolePrinter.test.ts b/packages/happy-dom/test/console/VirtualConsolePrinter.test.ts new file mode 100644 index 000000000..d0e0b9f63 --- /dev/null +++ b/packages/happy-dom/test/console/VirtualConsolePrinter.test.ts @@ -0,0 +1,459 @@ +import { beforeEach, describe, it, expect } from 'vitest'; +import VirtualConsoleLogLevelEnum from '../../src/console/enums/VirtualConsoleLogLevelEnum.js'; +import VirtualConsoleLogTypeEnum from '../../src/console/enums/VirtualConsoleLogTypeEnum.js'; +import VirtualConsole from '../../src/console/VirtualConsole.js'; +import VirtualConsolePrinter from '../../src/console/VirtualConsolePrinter.js'; +import Event from '../../src/event/Event.js'; + +const ERROR_WITH_SIMPLE_STACK = new Error('Test error'); +ERROR_WITH_SIMPLE_STACK.stack = 'Error: Test error\n at /some/where.js:1:1'; + +describe('VirtualConsolePrinter', () => { + let virtualConsolePrinter: VirtualConsolePrinter; + let virtualConsole: VirtualConsole; + + beforeEach(() => { + virtualConsolePrinter = new VirtualConsolePrinter(); + virtualConsole = new VirtualConsole(virtualConsolePrinter); + }); + + describe('print()', () => { + it('Prints log entries.', () => { + virtualConsolePrinter.print({ + type: VirtualConsoleLogTypeEnum.log, + level: VirtualConsoleLogLevelEnum.log, + message: ['Test 1', { test: 'test' }], + group: null + }); + + virtualConsolePrinter.print({ + type: VirtualConsoleLogTypeEnum.info, + level: VirtualConsoleLogLevelEnum.info, + message: ['Test 2', { test: 'test' }], + group: null + }); + + expect(virtualConsolePrinter.read()).toEqual([ + { + type: VirtualConsoleLogTypeEnum.log, + level: VirtualConsoleLogLevelEnum.log, + message: ['Test 1', { test: 'test' }], + group: null + }, + { + type: VirtualConsoleLogTypeEnum.info, + level: VirtualConsoleLogLevelEnum.info, + message: ['Test 2', { test: 'test' }], + group: null + } + ]); + + expect(virtualConsolePrinter.read()).toEqual([]); + }); + }); + + describe('clear()', () => { + it('Clears the console.', () => { + virtualConsolePrinter.print({ + type: VirtualConsoleLogTypeEnum.log, + level: VirtualConsoleLogLevelEnum.log, + message: ['Test 1', { test: 'test' }], + group: null + }); + + virtualConsolePrinter.clear(); + + expect(virtualConsolePrinter.read()).toEqual([]); + }); + }); + + describe('addEventListener()', () => { + it('Adds an event listener for "print".', () => { + let printEvent: Event | null = null; + virtualConsolePrinter.addEventListener('print', (event) => (printEvent = event)); + + virtualConsolePrinter.print({ + type: VirtualConsoleLogTypeEnum.log, + level: VirtualConsoleLogLevelEnum.log, + message: ['Test 1', { test: 'test' }], + group: null + }); + + expect(((printEvent)).type).toBe('print'); + }); + + it('Adds an event listener for "clear".', () => { + let printEvent: Event | null = null; + virtualConsolePrinter.addEventListener('clear', (event) => (printEvent = event)); + + virtualConsolePrinter.clear(); + + expect(((printEvent)).type).toBe('clear'); + }); + }); + + describe('removeEventListener()', () => { + it('Removes an event listener for "print".', () => { + let printEvent: Event | null = null; + const listener = (event: Event): Event => (printEvent = event); + + virtualConsolePrinter.addEventListener('print', listener); + virtualConsolePrinter.removeEventListener('print', listener); + + virtualConsolePrinter.print({ + type: VirtualConsoleLogTypeEnum.log, + level: VirtualConsoleLogLevelEnum.log, + message: ['Test 1', { test: 'test' }], + group: null + }); + + expect(printEvent).toBe(null); + }); + + it('Removes an event listener for "clear".', () => { + let printEvent: Event | null = null; + const listener = (event: Event): Event => (printEvent = event); + + virtualConsolePrinter.addEventListener('clear', listener); + virtualConsolePrinter.removeEventListener('clear', listener); + + virtualConsolePrinter.clear(); + + expect(printEvent).toBe(null); + }); + }); + + describe('dispatchEvent()', () => { + it('Dispatches an event.', () => { + let printEvent: Event | null = null; + virtualConsolePrinter.addEventListener('print', (event) => (printEvent = event)); + + virtualConsolePrinter.dispatchEvent(new Event('print')); + + expect(((printEvent)).type).toBe('print'); + }); + }); + + describe('read()', () => { + it('Returns the buffered console output.', () => { + virtualConsole.log('Test 1', { test: 'test' }); + virtualConsole.info('Test 2', { test: 'test' }); + virtualConsole.warn('Test 3', { test: 'test' }); + virtualConsole.error('Test 4', { test: 'test' }, new Error('Test error')); + virtualConsole.debug('Test 5', { test: 'test' }); + virtualConsole.assert(false, 'Test 6', { test: 'test' }); + virtualConsole.group('Test 7'); + virtualConsole.log('Test 8', { test: 'test' }); + virtualConsole.groupCollapsed('Test 9'); + virtualConsole.log('Test 10', { test: 'test' }); + virtualConsole.groupEnd(); + virtualConsole.log('Test 11', { test: 'test' }); + virtualConsole.groupEnd(); + virtualConsole.count('Test 12'); + virtualConsole.count('Test 12'); + virtualConsole.countReset('Test 12'); + + expect(virtualConsolePrinter?.read()).toEqual([ + { + type: VirtualConsoleLogTypeEnum.log, + level: VirtualConsoleLogLevelEnum.log, + message: ['Test 1', { test: 'test' }], + group: null + }, + { + type: VirtualConsoleLogTypeEnum.info, + level: VirtualConsoleLogLevelEnum.info, + message: ['Test 2', { test: 'test' }], + group: null + }, + { + type: VirtualConsoleLogTypeEnum.warn, + level: VirtualConsoleLogLevelEnum.warn, + message: ['Test 3', { test: 'test' }], + group: null + }, + { + type: VirtualConsoleLogTypeEnum.error, + level: VirtualConsoleLogLevelEnum.error, + message: ['Test 4', { test: 'test' }, new Error('Test error')], + group: null + }, + { + type: VirtualConsoleLogTypeEnum.debug, + level: VirtualConsoleLogLevelEnum.log, + message: ['Test 5', { test: 'test' }], + group: null + }, + { + type: VirtualConsoleLogTypeEnum.assert, + level: VirtualConsoleLogLevelEnum.error, + message: ['Assertion failed:', 'Test 6', { test: 'test' }], + group: null + }, + { + type: VirtualConsoleLogTypeEnum.group, + level: VirtualConsoleLogLevelEnum.log, + message: ['Test 7'], + group: { + id: 1, + label: 'Test 7', + collapsed: false, + parent: null + } + }, + { + type: VirtualConsoleLogTypeEnum.log, + level: VirtualConsoleLogLevelEnum.log, + message: ['Test 8', { test: 'test' }], + group: { + id: 1, + label: 'Test 7', + collapsed: false, + parent: null + } + }, + { + type: VirtualConsoleLogTypeEnum.groupCollapsed, + level: VirtualConsoleLogLevelEnum.log, + message: ['Test 9'], + group: { + id: 2, + label: 'Test 9', + collapsed: true, + parent: { + id: 1, + label: 'Test 7', + collapsed: false, + parent: null + } + } + }, + { + type: VirtualConsoleLogTypeEnum.log, + level: VirtualConsoleLogLevelEnum.log, + message: ['Test 10', { test: 'test' }], + group: { + id: 2, + label: 'Test 9', + collapsed: true, + parent: { + id: 1, + label: 'Test 7', + collapsed: false, + parent: null + } + } + }, + { + type: VirtualConsoleLogTypeEnum.log, + level: VirtualConsoleLogLevelEnum.log, + message: ['Test 11', { test: 'test' }], + group: { + id: 1, + label: 'Test 7', + collapsed: false, + parent: null + } + }, + { + type: VirtualConsoleLogTypeEnum.count, + level: VirtualConsoleLogLevelEnum.info, + message: ['Test 12: 1'], + group: null + }, + { + type: VirtualConsoleLogTypeEnum.count, + level: VirtualConsoleLogLevelEnum.info, + message: ['Test 12: 2'], + group: null + }, + { + type: VirtualConsoleLogTypeEnum.countReset, + level: VirtualConsoleLogLevelEnum.warn, + message: ['Test 12: 0'], + group: null + } + ]); + + virtualConsole.log('Test 1', { test: 'test' }); + virtualConsole.info('Test 2', { test: 'test' }); + + expect(virtualConsolePrinter?.read()).toEqual([ + { + type: VirtualConsoleLogTypeEnum.log, + level: VirtualConsoleLogLevelEnum.log, + message: ['Test 1', { test: 'test' }], + group: null + }, + { + type: VirtualConsoleLogTypeEnum.info, + level: VirtualConsoleLogLevelEnum.info, + message: ['Test 2', { test: 'test' }], + group: null + } + ]); + + virtualConsole.log('Test 1', { test: 'test' }); + virtualConsole.info('Test 2', { test: 'test' }); + + virtualConsole.clear(); + + expect(virtualConsolePrinter?.read()).toEqual([]); + }); + }); + + describe('readAsString()', () => { + it('Returns the buffered console output as a string with default log level ("log").', () => { + virtualConsole.log('Test 1', { test: 'test' }); + virtualConsole.info('Test 2', { test: 'test' }); + virtualConsole.warn('Test 3', { test: 'test' }); + virtualConsole.error('Test 4', { test: 'test' }, ERROR_WITH_SIMPLE_STACK); + virtualConsole.debug('Test 5', { test: 'test' }); + virtualConsole.assert(false, 'Test 6', { test: 'test' }); + virtualConsole.group('Test 7'); + virtualConsole.log('Test 8', { test: 'test' }); + virtualConsole.groupCollapsed('Test 9'); + virtualConsole.log('Test 10', { test: 'test' }); + virtualConsole.groupEnd(); + virtualConsole.group('Test 11'); + virtualConsole.log('Test 12', { test: 'test' }); + virtualConsole.error('Test 13', ERROR_WITH_SIMPLE_STACK); + virtualConsole.groupEnd(); + virtualConsole.log('Test 14', { test: 'test' }); + virtualConsole.groupEnd(); + virtualConsole.count('Test 15'); + virtualConsole.count('Test 15'); + virtualConsole.countReset('Test 15'); + + expect(virtualConsolePrinter?.readAsString()).toEqual( + 'Test 1 {"test":"test"}\n' + + 'Test 2 {"test":"test"}\n' + + 'Test 3 {"test":"test"}\n' + + 'Test 4 {"test":"test"} Error: Test error\n' + + ' at /some/where.js:1:1\n' + + 'Test 5 {"test":"test"}\n' + + 'Assertion failed: Test 6 {"test":"test"}\n' + + '▼ Test 7\n' + + ' Test 8 {"test":"test"}\n' + + ' ▶ Test 9\n' + + ' ▼ Test 11\n' + + ' Test 12 {"test":"test"}\n' + + ' Test 13 Error: Test error\n' + + ' at /some/where.js:1:1\n' + + ' Test 14 {"test":"test"}\n' + + 'Test 15: 1\n' + + 'Test 15: 2\n' + + 'Test 15: 0\n' + ); + + virtualConsole.log('Test 1', { test: 'test' }); + virtualConsole.info('Test 2', { test: 'test' }); + + expect(virtualConsolePrinter?.readAsString()).toEqual( + 'Test 1 {"test":"test"}\n' + 'Test 2 {"test":"test"}\n' + ); + + virtualConsole.log('Test 1', { test: 'test' }); + virtualConsole.info('Test 2', { test: 'test' }); + + virtualConsole.clear(); + + expect(virtualConsolePrinter?.readAsString()).toEqual(''); + }); + + it('Returns the buffered console output as a string with log level "info".', () => { + virtualConsole.log('Test 1', { test: 'test' }); + virtualConsole.info('Test 2', { test: 'test' }); + virtualConsole.warn('Test 3', { test: 'test' }); + virtualConsole.error('Test 4', { test: 'test' }, ERROR_WITH_SIMPLE_STACK); + virtualConsole.debug('Test 5', { test: 'test' }); + virtualConsole.assert(false, 'Test 6', { test: 'test' }); + virtualConsole.group('Test 7'); + virtualConsole.log('Test 8', { test: 'test' }); + virtualConsole.groupCollapsed('Test 9'); + virtualConsole.log('Test 10', { test: 'test' }); + virtualConsole.groupEnd(); + virtualConsole.log('Test 11', { test: 'test' }); + virtualConsole.groupEnd(); + virtualConsole.count('Test 12'); + virtualConsole.count('Test 12'); + virtualConsole.countReset('Test 12'); + + expect(virtualConsolePrinter?.readAsString(VirtualConsoleLogLevelEnum.info)).toEqual( + 'Test 2 {"test":"test"}\n' + + 'Test 3 {"test":"test"}\n' + + 'Test 4 {"test":"test"} Error: Test error\n' + + ' at /some/where.js:1:1\n' + + 'Assertion failed: Test 6 {"test":"test"}\n' + + 'Test 12: 1\n' + + 'Test 12: 2\n' + + 'Test 12: 0\n' + ); + }); + + it('Returns the buffered console output as a string with log level "warn".', () => { + virtualConsole.log('Test 1', { test: 'test' }); + virtualConsole.info('Test 2', { test: 'test' }); + virtualConsole.warn('Test 3', { test: 'test' }); + virtualConsole.error('Test 4', { test: 'test' }, ERROR_WITH_SIMPLE_STACK); + virtualConsole.debug('Test 5', { test: 'test' }); + virtualConsole.assert(false, 'Test 6', { test: 'test' }); + virtualConsole.group('Test 7'); + virtualConsole.log('Test 8', { test: 'test' }); + virtualConsole.groupCollapsed('Test 9'); + virtualConsole.log('Test 10', { test: 'test' }); + virtualConsole.groupEnd(); + virtualConsole.log('Test 11', { test: 'test' }); + virtualConsole.groupEnd(); + virtualConsole.count('Test 12'); + virtualConsole.count('Test 12'); + virtualConsole.countReset('Test 12'); + + expect(virtualConsolePrinter?.readAsString(VirtualConsoleLogLevelEnum.warn)).toEqual( + 'Test 3 {"test":"test"}\n' + + 'Test 4 {"test":"test"} Error: Test error\n' + + ' at /some/where.js:1:1\n' + + 'Assertion failed: Test 6 {"test":"test"}\n' + + 'Test 12: 0\n' + ); + }); + + it('Returns the buffered console output as a string with log level "error".', () => { + virtualConsole.log('Test 1', { test: 'test' }); + virtualConsole.info('Test 2', { test: 'test' }); + virtualConsole.warn('Test 3', { test: 'test' }); + virtualConsole.error('Test 4', { test: 'test' }, ERROR_WITH_SIMPLE_STACK); + virtualConsole.debug('Test 5', { test: 'test' }); + virtualConsole.assert(false, 'Test 6', { test: 'test' }); + virtualConsole.group('Test 7'); + virtualConsole.log('Test 8', { test: 'test' }); + virtualConsole.groupCollapsed('Test 9'); + virtualConsole.log('Test 10', { test: 'test' }); + virtualConsole.groupEnd(); + virtualConsole.log('Test 11', { test: 'test' }); + virtualConsole.groupEnd(); + virtualConsole.count('Test 12'); + virtualConsole.count('Test 12'); + virtualConsole.countReset('Test 12'); + + expect(virtualConsolePrinter?.readAsString(VirtualConsoleLogLevelEnum.error)).toEqual( + 'Test 4 {"test":"test"} Error: Test error\n' + + ' at /some/where.js:1:1\n' + + 'Assertion failed: Test 6 {"test":"test"}\n' + ); + }); + + it("Handles objects that can't be serialized.", () => { + const objectWithSelfReference = {}; + objectWithSelfReference['self'] = objectWithSelfReference; + + virtualConsole.log(objectWithSelfReference); + + expect( + virtualConsolePrinter + ?.readAsString() + .startsWith('Error: Failed to JSON stringify object in log entry.\n at ') + ).toBe(true); + }); + }); +}); 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 96535ba94..eacf6efdd 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 @@ -413,5 +413,77 @@ describe('HTMLScriptElement', () => { 'Failed to load external script "path/to/script/". JavaScript file loading is disabled.' ); }); + + it('Triggers an error event on Window when attempting to perform an asynchrounous request containing invalid JavaScript.', async () => { + let errorEvent: ErrorEvent | null = null; + + vi.spyOn(window, 'fetch').mockImplementation(() => { + return Promise.resolve({ + text: async () => 'globalThis.test = /;', + ok: true + }); + }); + + window.addEventListener('error', (event) => (errorEvent = event)); + + const script = window.document.createElement('script'); + script.src = 'https://localhost:8080/base/path/to/script/'; + script.async = true; + + document.body.appendChild(script); + + await window.happyDOM.whenAsyncComplete(); + + expect(((errorEvent)).error?.message).toBe( + 'Invalid regular expression: missing /' + ); + + const consoleOutput = window.happyDOM.virtualConsolePrinter?.readAsString() || ''; + expect(consoleOutput.startsWith('SyntaxError: Invalid regular expression: missing /')).toBe( + true + ); + }); + + it('Triggers an error event on Window when attempting to perform a synchrounous request containing invalid JavaScript.', () => { + let errorEvent: ErrorEvent | null = null; + + vi.spyOn(ResourceFetch, 'fetchSync').mockImplementation(() => 'globalThis.test = /;'); + + window.addEventListener('error', (event) => (errorEvent = event)); + + const script = window.document.createElement('script'); + script.src = 'https://localhost:8080/base/path/to/script/'; + + document.body.appendChild(script); + + expect(((errorEvent)).error?.message).toBe( + 'Invalid regular expression: missing /' + ); + + const consoleOutput = window.happyDOM.virtualConsolePrinter?.readAsString() || ''; + expect(consoleOutput.startsWith('SyntaxError: Invalid regular expression: missing /')).toBe( + true + ); + }); + + it('Triggers an error event on Window when appending an element that contains invalid Javascript.', () => { + const element = document.createElement('script'); + let errorEvent: ErrorEvent | null = null; + + window.addEventListener('error', (event) => (errorEvent = event)); + + element.text = 'globalThis.test = /;'; + + document.body.appendChild(element); + + expect(((errorEvent)).error?.message).toBe( + 'Invalid regular expression: missing /' + ); + + const consoleOutput = window.happyDOM.virtualConsolePrinter?.readAsString() || ''; + expect(consoleOutput.startsWith('SyntaxError: Invalid regular expression: missing /')).toBe( + true + ); + }); }); }); diff --git a/packages/happy-dom/test/nodes/node/Node.test.ts b/packages/happy-dom/test/nodes/node/Node.test.ts index 935e86253..96f48b503 100644 --- a/packages/happy-dom/test/nodes/node/Node.test.ts +++ b/packages/happy-dom/test/nodes/node/Node.test.ts @@ -9,7 +9,9 @@ import DOMException from '../../../src/exception/DOMException.js'; import DOMExceptionNameEnum from '../../../src/exception/DOMExceptionNameEnum.js'; import Text from '../../../src/nodes/text/Text.js'; import EventPhaseEnum from '../../../src/event/EventPhaseEnum.js'; +import ErrorEvent from '../../../src/event/events/ErrorEvent.js'; import { beforeEach, describe, it, expect } from 'vitest'; +import IShadowRoot from '../../../src/nodes/shadow-root/IShadowRoot.js'; /** * @@ -29,7 +31,7 @@ class CustomCounterElement extends HTMLElement { * Connected. */ public connectedCallback(): void { - this.shadowRoot.innerHTML = '
Test
'; + (this.shadowRoot).innerHTML = '
Test
'; (this.constructor).output.push('Counter:connected'); } @@ -334,7 +336,7 @@ describe('Node', () => { document.body.appendChild(customElement); - const rootNode = customElement.shadowRoot.querySelector('span').getRootNode(); + const rootNode = (customElement.shadowRoot).querySelector('span')?.getRootNode(); expect(rootNode === customElement.shadowRoot).toBe(true); }); @@ -344,9 +346,9 @@ describe('Node', () => { document.body.appendChild(customElement); - const rootNode = customElement.shadowRoot + const rootNode = (customElement.shadowRoot) .querySelector('span') - .getRootNode({ composed: true }); + ?.getRootNode({ composed: true }); expect(rootNode === document).toBe(true); }); @@ -850,6 +852,44 @@ describe('Node', () => { expect(documentEvents.length).toBe(1); expect(documentEvents[0] === event).toBe(true); }); + + it('Catches errors thrown in event listeners.', () => { + const node = document.createElement('span'); + const listener = (): void => { + throw new Error('Test'); + }; + + let errorEvent: ErrorEvent | null = null; + window.addEventListener('error', (event) => { + errorEvent = event; + }); + node.addEventListener('click', listener); + node.dispatchEvent(new Event('click')); + expect(((errorEvent)).error?.message).toBe('Test'); + expect(window.happyDOM.virtualConsolePrinter?.readAsString().startsWith('Error: Test')).toBe( + true + ); + }); + + it('Catches async errors thrown in event listeners.', async () => { + const node = document.createElement('span'); + const listener = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 0)); + throw new Error('Test'); + }; + + let errorEvent: ErrorEvent | null = null; + window.addEventListener('error', (event) => { + errorEvent = event; + }); + node.addEventListener('click', listener); + node.dispatchEvent(new Event('click')); + await new Promise((resolve) => setTimeout(resolve, 2)); + expect(((errorEvent)).error?.message).toBe('Test'); + expect(window.happyDOM.virtualConsolePrinter?.readAsString().startsWith('Error: Test')).toBe( + true + ); + }); }); describe('compareDocumentPosition()', () => { diff --git a/packages/happy-dom/test/window/Window.test.ts b/packages/happy-dom/test/window/Window.test.ts index 56052deed..79f2d7a72 100644 --- a/packages/happy-dom/test/window/Window.test.ts +++ b/packages/happy-dom/test/window/Window.test.ts @@ -24,6 +24,8 @@ import Event from '../../src/event/Event.js'; import ErrorEvent from '../../src/event/events/ErrorEvent.js'; import '../types.d.js'; import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; +import VirtualConsole from '../../src/console/VirtualConsole.js'; +import VirtualConsolePrinter from '../../src/console/VirtualConsolePrinter.js'; describe('Window', () => { let window: IWindow; @@ -95,6 +97,7 @@ describe('Window', () => { width: 1920, height: 1080, url: 'http://localhost:8080', + console: globalThis.console, settings: { disableJavaScriptEvaluation: true, device: { @@ -108,7 +111,9 @@ describe('Window', () => { expect(windowWithOptions.innerHeight).toBe(1080); expect(windowWithOptions.outerWidth).toBe(1920); expect(windowWithOptions.outerHeight).toBe(1080); + expect(windowWithOptions.console).toBe(globalThis.console); expect(windowWithOptions.location.href).toBe('http://localhost:8080/'); + expect(windowWithOptions.happyDOM.virtualConsolePrinter).toBe(null); expect(windowWithOptions.happyDOM.settings.disableJavaScriptEvaluation).toBe(true); expect(windowWithOptions.happyDOM.settings.disableJavaScriptFileLoading).toBe(false); expect(windowWithOptions.happyDOM.settings.disableCSSFileLoading).toBe(false); @@ -121,7 +126,11 @@ describe('Window', () => { expect(windowWithoutOptions.innerHeight).toBe(768); expect(windowWithoutOptions.outerWidth).toBe(1024); expect(windowWithoutOptions.outerHeight).toBe(768); + expect(windowWithoutOptions.console).toBeInstanceOf(VirtualConsole); expect(windowWithoutOptions.location.href).toBe('about:blank'); + expect(windowWithoutOptions.happyDOM.virtualConsolePrinter).toBeInstanceOf( + VirtualConsolePrinter + ); expect(windowWithoutOptions.happyDOM.settings.disableJavaScriptEvaluation).toBe(false); expect(windowWithoutOptions.happyDOM.settings.disableJavaScriptFileLoading).toBe(false); expect(windowWithoutOptions.happyDOM.settings.disableCSSFileLoading).toBe(false); @@ -287,6 +296,17 @@ describe('Window', () => { }); }); + describe('happyDOM.virtualConsolePrinter.readAsString()', () => { + it('Returns the buffered console output.', () => { + window.console.log('Test 1', { key1: 'value1' }); + window.console.info('Test 2', { key2: 'value2' }); + + expect(window.happyDOM.virtualConsolePrinter?.readAsString()).toBe( + `Test 1 {"key1":"value1"}\nTest 2 {"key2":"value2"}\n` + ); + }); + }); + describe('happyDOM.setWindowSize()', () => { it('Sets window width.', () => { window.happyDOM.setWindowSize({ width: 1920 }); @@ -829,6 +849,23 @@ describe('Window', () => { }, 2); }); }); + + it('Catches async errors thrown in the callback.', async () => { + await new Promise((resolve) => { + let errorEvent: ErrorEvent | null = null; + window.addEventListener('error', (event) => (errorEvent = event)); + window.setTimeout(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + throw new window.Error('Test error'); + }); + setTimeout(() => { + expect(((errorEvent)).error).instanceOf(window.Error); + expect(((errorEvent)).error?.message).toBe('Test error'); + expect(((errorEvent)).message).toBe('Test error'); + resolve(null); + }, 10); + }); + }); }); describe('queueMicrotask()', () => { @@ -867,7 +904,24 @@ describe('Window', () => { expect(((errorEvent)).error?.message).toBe('Test error'); expect(((errorEvent)).message).toBe('Test error'); resolve(null); + }, 2); + }); + }); + + it('Catches async errors thrown in the callback.', async () => { + await new Promise((resolve) => { + let errorEvent: ErrorEvent | null = null; + window.addEventListener('error', (event) => (errorEvent = event)); + window.queueMicrotask(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + throw new window.Error('Test error'); }); + setTimeout(() => { + expect(((errorEvent)).error).instanceOf(window.Error); + expect(((errorEvent)).error?.message).toBe('Test error'); + expect(((errorEvent)).message).toBe('Test error'); + resolve(null); + }, 10); }); }); }); @@ -940,16 +994,32 @@ describe('Window', () => { await new Promise((resolve) => { let errorEvent: ErrorEvent | null = null; window.addEventListener('error', (event) => (errorEvent = event)); - const interval = window.setInterval(() => { + window.setInterval(() => { throw new window.Error('Test error'); }); setTimeout(() => { - window.clearInterval(interval); expect(((errorEvent)).error).instanceOf(window.Error); expect(((errorEvent)).error?.message).toBe('Test error'); expect(((errorEvent)).message).toBe('Test error'); resolve(null); + }, 2); + }); + }); + + it('Catches async errors thrown in the callback.', async () => { + await new Promise((resolve) => { + let errorEvent: ErrorEvent | null = null; + window.addEventListener('error', (event) => (errorEvent = event)); + window.setInterval(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + throw new window.Error('Test error'); }); + setTimeout(() => { + expect(((errorEvent)).error).instanceOf(window.Error); + expect(((errorEvent)).error?.message).toBe('Test error'); + expect(((errorEvent)).message).toBe('Test error'); + resolve(null); + }, 10); }); }); }); @@ -992,7 +1062,23 @@ describe('Window', () => { expect(((errorEvent)).error?.message).toBe('Test error'); expect(((errorEvent)).message).toBe('Test error'); resolve(null); + }, 2); + }); + }); + + it('Catches async errors thrown in the callback.', async () => { + await new Promise((resolve) => { + let errorEvent: ErrorEvent | null = null; + window.addEventListener('error', (event) => (errorEvent = event)); + window.requestAnimationFrame(() => { + throw new window.Error('Test error'); }); + setTimeout(() => { + expect(((errorEvent)).error).instanceOf(window.Error); + expect(((errorEvent)).error?.message).toBe('Test error'); + expect(((errorEvent)).message).toBe('Test error'); + resolve(null); + }, 10); }); }); }); diff --git a/packages/uncaught-exception-observer/README.md b/packages/uncaught-exception-observer/README.md index 5e6738801..a0c8317b0 100644 --- a/packages/uncaught-exception-observer/README.md +++ b/packages/uncaught-exception-observer/README.md @@ -8,7 +8,9 @@ The goal of [Happy DOM](https://github.com/capricorn86/happy-dom) is to emulate [Happy DOM](https://github.com/capricorn86/happy-dom) focuses heavily on performance and can be used as an alternative to [JSDOM](https://github.com/jsdom/jsdom). -This package contains a utility that registers [Happy DOM](https://github.com/capricorn86/happy-dom) globally, which makes it possible to use [Happy DOM](https://github.com/capricorn86/happy-dom) for testing in a Node environment. +This package contains a tool that observes uncaught exceptions and Promise rejections in [Happy DOM](https://github.com/capricorn86/happy-dom). It will dispatch uncaught errors as events on the [Happy DOM](https://github.com/capricorn86/happy-dom) Window instance. + +Uncaught exceptions and rejections must be listened to on the NodeJS process at a global level. This tool will therefore not work in all environments as there may already be listeners added by other libraries on the NodeJS process that may conflict. ### DOM Features @@ -46,37 +48,41 @@ And much more.. # Installation ```bash -npm install @happy-dom/global-registrator --save-dev +npm install happy-dom @happy-dom/uncaught-exception-observer ``` # Usage -## Register - ```javascript -import { GlobalRegistrator } from '@happy-dom/global-registrator'; - -GlobalRegistrator.register(); - -document.body.innerHTML = ``; - -const button = document.querySelector('button'); - -// Outputs: "My button" -console.log(button.innerText); -``` - -## Unregister - -```javascript -import { GlobalRegistrator } from '@happy-dom/global-registrator'; - -GlobalRegistrator.register(); - -GlobalRegistrator.unregister(); - -// Outputs: "undefined" -console.log(global.document); +import { Window } from 'happy-dom'; +import { UncaughtExceptionObserver } from '@happy-dom/uncaught-exception-observer'; + +const window = new Window(); +const document = window.document; +const observer = new UncaughtExceptionObserver(); + +// Connects observer +observer.observe(window); + +window.addEventListener((error) => { + // Do something on error +}); + +document.write(` + +`); + +// Disconnects observer +observer.disconnect(); ``` # Documentation diff --git a/packages/uncaught-exception-observer/package.json b/packages/uncaught-exception-observer/package.json index 241d2e50c..746736738 100644 --- a/packages/uncaught-exception-observer/package.json +++ b/packages/uncaught-exception-observer/package.json @@ -74,8 +74,8 @@ "test": "tsc --project ./test && node ./tmp/UncaughtExceptionObserver.test.js", "test:debug": "tsc --project ./test && node --inspect-brk ./tmp/UncaughtExceptionObserver.test.js" }, - "dependencies": { - "happy-dom": "^0.0.0" + "peerDependencies": { + "happy-dom": ">= 2.25.2" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^5.16.0", @@ -90,6 +90,7 @@ "eslint-plugin-json": "^3.1.0", "eslint-plugin-turbo": "^0.0.7", "prettier": "^2.6.0", - "typescript": "^5.0.4" + "typescript": "^5.0.4", + "happy-dom": "^0.0.0" } } diff --git a/packages/uncaught-exception-observer/src/UncaughtExceptionObserver.ts b/packages/uncaught-exception-observer/src/UncaughtExceptionObserver.ts index aca382582..78ce81533 100644 --- a/packages/uncaught-exception-observer/src/UncaughtExceptionObserver.ts +++ b/packages/uncaught-exception-observer/src/UncaughtExceptionObserver.ts @@ -1,4 +1,4 @@ -import { IWindow, ErrorEvent } from 'happy-dom'; +import IWindow from 'happy-dom/lib/window/IWindow.js'; /** * Listens for uncaught exceptions coming from Happy DOM on the running Node process and dispatches error events on the Window instance. @@ -35,14 +35,16 @@ export default class UncaughtExceptionObserver { } if (error instanceof this.window.Error && error.stack?.includes('/happy-dom/')) { - this.window.console.error(error.message + '\n' + error.stack); - this.window.dispatchEvent(new ErrorEvent('error', { error, message: error.message })); + this.window.console.error(error); + this.window.dispatchEvent( + new this.window.ErrorEvent('error', { error, message: error.message }) + ); } else if ( process.listenerCount('uncaughtException') === (this.constructor).listenerCount ) { // eslint-disable-next-line no-console - console.error(error.message + '\n' + error.stack); + console.error(error); // Exit if there are no other listeners handling the error. process.exit(1); } @@ -52,14 +54,16 @@ export default class UncaughtExceptionObserver { // Therefore we want to use the "unhandledRejection" event as well. this.uncaughtRejectionListener = (error: Error) => { if (error instanceof this.window.Error && error.stack?.includes('/happy-dom/')) { - this.window.console.error(error.message + '\n' + error.stack); - this.window.dispatchEvent(new ErrorEvent('error', { error, message: error.message })); + this.window.console.error(error); + this.window.dispatchEvent( + new this.window.ErrorEvent('error', { error, message: error.message }) + ); } else if ( process.listenerCount('unhandledRejection') === (this.constructor).listenerCount ) { // eslint-disable-next-line no-console - console.error(error.message + '\n' + error.stack); + console.error(error); // Exit if there are no other listeners handling the error. process.exit(1); }