Repository: capricorn86/happy-dom
Pull request #263 (2021-05-03 23:05:28)

#261@minor: Adds support for window "load" event. Adds support for wi…
…ndow "error" event. Adds support for document "readystatechange" event. Adds support for outputting errors to console if a request to a resource fails.


Patch:
diff --git a/packages/happy-dom/src/event/EventTarget.ts b/packages/happy-dom/src/event/EventTarget.ts
index a26c55bc..359b04d4 100644
--- a/packages/happy-dom/src/event/EventTarget.ts
+++ b/packages/happy-dom/src/event/EventTarget.ts
@@ -6,7 +6,7 @@ import IEventTarget from './IEventTarget';
  * Handles events.
  */
 export default abstract class EventTarget implements IEventTarget {
-	protected readonly _listeners: {
+	public readonly _listeners: {
 		[k: string]: (((event: Event) => void) | IEventListener)[];
 	} = {};
 
diff --git a/packages/happy-dom/src/nodes/document/Document.ts b/packages/happy-dom/src/nodes/document/Document.ts
index ba55be24..de90c1db 100644
--- a/packages/happy-dom/src/nodes/document/Document.ts
+++ b/packages/happy-dom/src/nodes/document/Document.ts
@@ -31,12 +31,14 @@ import IHTMLCollection from '../element/IHTMLCollection';
 import HTMLCollectionFactory from '../element/HTMLCollectionFactory';
 import IHTMLLinkElement from '../html-link-element/IHTMLLinkElement';
 import IHTMLStyleElement from '../html-style-element/IHTMLStyleElement';
+import DocumentReadyStateEnum from './DocumentReadyStateEnum';
+import DocumentReadyStateManager from './DocumentReadyStateManager';
 
 /**
  * Document.
  */
 export default class Document extends Node implements IDocument {
-	public defaultView: Window = null;
+	public onreadystatechange: (event: Event) => void = null;
 	public nodeType = Node.DOCUMENT_NODE;
 	public adoptedStyleSheets: CSSStyleSheet[] = [];
 	protected _isConnected = true;
@@ -44,6 +46,9 @@ export default class Document extends Node implements IDocument {
 	protected _isFirstWriteAfterOpen = false;
 	public implementation: DOMImplementation;
 	public readonly children: IHTMLCollection<IElement> = HTMLCollectionFactory.create();
+	public readonly readyState = DocumentReadyStateEnum.interactive;
+	public _readyStateManager: DocumentReadyStateManager = null;
+	private _defaultView: Window = null;
 	private _cookie = '';
 
 	/**
@@ -67,6 +72,29 @@ export default class Document extends Node implements IDocument {
 		documentElement.appendChild(bodyElement);
 	}
 
+	/**
+	 * Returns default view.
+	 *
+	 * @return Default view.
+	 */
+	public get defaultView(): Window {
+		return this._defaultView;
+	}
+
+	/**
+	 * Sets a default view.
+	 *
+	 * @param defaultView Default view.
+	 */
+	public set defaultView(defaultView: Window) {
+		this._defaultView = defaultView;
+		this._readyStateManager = new DocumentReadyStateManager(defaultView);
+		this._readyStateManager.whenComplete().then(() => {
+			(<DocumentReadyStateEnum>this.readyState) = DocumentReadyStateEnum.complete;
+			this.dispatchEvent(new Event('readystatechange'));
+		});
+	}
+
 	/**
 	 * Last element child.
 	 *
@@ -168,8 +196,8 @@ export default class Document extends Node implements IDocument {
 	 * @return CSS style sheets.
 	 */
 	public get styleSheets(): CSSStyleSheet[] {
-		const styles = <(IHTMLLinkElement | IHTMLStyleElement)[]>(
-			(<unknown>this.querySelectorAll('link,style'))
+		const styles = <INodeList<IHTMLLinkElement | IHTMLStyleElement>>(
+			this.querySelectorAll('link[rel="stylesheet"][href],style')
 		);
 		const styleSheets = [];
 		for (const style of styles) {
diff --git a/packages/happy-dom/src/nodes/document/DocumentReadyStateEnum.ts b/packages/happy-dom/src/nodes/document/DocumentReadyStateEnum.ts
new file mode 100644
index 00000000..a2913d00
--- /dev/null
+++ b/packages/happy-dom/src/nodes/document/DocumentReadyStateEnum.ts
@@ -0,0 +1,6 @@
+enum DocumentReadyStateEnum {
+	loading = 'loading',
+	interactive = 'interactive',
+	complete = 'complete'
+}
+export default DocumentReadyStateEnum;
diff --git a/packages/happy-dom/src/nodes/document/DocumentReadyStateManager.ts b/packages/happy-dom/src/nodes/document/DocumentReadyStateManager.ts
new file mode 100644
index 00000000..b8e533b7
--- /dev/null
+++ b/packages/happy-dom/src/nodes/document/DocumentReadyStateManager.ts
@@ -0,0 +1,80 @@
+import IWindow from '../../window/IWindow';
+
+/**
+ * Document ready state manager.
+ */
+export default class DocumentReadyStateManager {
+	private totalTasks = 0;
+	private readyStateCallbacks: (() => void)[] = [];
+	private window: IWindow = null;
+	private timer: NodeJS.Timeout = null;
+	private isComplete = false;
+
+	/**
+	 * Constructor.
+	 */
+	constructor(window: IWindow) {
+		this.window = window;
+	}
+
+	/**
+	 * Returns a promise that is fulfilled when ready state is complete.
+	 *
+	 * @returns Promise.
+	 */
+	public whenComplete(): Promise<void> {
+		return new Promise(resolve => {
+			if (this.isComplete) {
+				resolve();
+			} else {
+				this.readyStateCallbacks.push(resolve);
+				if (this.totalTasks === 0 && !this.timer) {
+					this.timer = this.window.setTimeout(this.endTask.bind(this));
+				}
+			}
+		});
+	}
+
+	/**
+	 * Starts a task.
+	 */
+	public startTask(): void {
+		if (this.isComplete) {
+			return;
+		}
+
+		if (this.timer) {
+			this.window.clearTimeout(this.timer);
+			this.timer = null;
+		}
+
+		this.totalTasks++;
+	}
+
+	/**
+	 * Ends a task.
+	 */
+	public endTask(): void {
+		if (this.isComplete) {
+			return;
+		}
+
+		if (this.timer) {
+			this.window.clearTimeout(this.timer);
+			this.timer = null;
+		}
+
+		this.totalTasks--;
+
+		if (this.totalTasks <= 0) {
+			const callbacks = this.readyStateCallbacks;
+
+			this.readyStateCallbacks = [];
+			this.isComplete = true;
+
+			for (const callback of callbacks) {
+				callback();
+			}
+		}
+	}
+}
diff --git a/packages/happy-dom/src/nodes/document/IDocument.ts b/packages/happy-dom/src/nodes/document/IDocument.ts
index a6714976..73d6cd18 100644
--- a/packages/happy-dom/src/nodes/document/IDocument.ts
+++ b/packages/happy-dom/src/nodes/document/IDocument.ts
@@ -16,6 +16,7 @@ import IDocumentFragment from '../document-fragment/IDocumentFragment';
  * Document.
  */
 export default interface IDocument extends IParentNode {
+	onreadystatechange: (event: Event) => void;
 	readonly defaultView: Window;
 	readonly implementation: DOMImplementation;
 	readonly documentElement: IHTMLElement;
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 8cc8c075..991d0178 100644
--- a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts
+++ b/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts
@@ -2,6 +2,7 @@ import Attr from '../../attribute/Attr';
 import CSSStyleSheet from '../../css/CSSStyleSheet';
 import ResourceFetcher from '../../fetch/ResourceFetcher';
 import HTMLElement from '../html-element/HTMLElement';
+import Document from '../document/Document';
 import IHTMLLinkElement from './IHTMLLinkElement';
 import Event from '../../event/Event';
 import ErrorEvent from '../../event/events/ErrorEvent';
@@ -50,12 +51,14 @@ export default class HTMLLinkElement extends HTMLElement implements IHTMLLinkEle
 				const href = this.getAttributeNS(null, 'href');
 				const rel = this.getAttributeNS(null, 'rel');
 				if (href !== null && rel && rel.toLowerCase() === 'stylesheet') {
+					(<Document>this.ownerDocument)._readyStateManager.startTask();
 					ResourceFetcher.fetch({ window: this.ownerDocument.defaultView, url: href })
 						.then(code => {
 							const styleSheet = new CSSStyleSheet();
 							styleSheet.replaceSync(code);
 							(<CSSStyleSheet>this.sheet) = styleSheet;
 							this.dispatchEvent(new Event('load'));
+							(<Document>this.ownerDocument)._readyStateManager.endTask();
 						})
 						.catch(error => {
 							this.dispatchEvent(
@@ -64,6 +67,19 @@ export default class HTMLLinkElement extends HTMLElement implements IHTMLLinkEle
 									error
 								})
 							);
+							this.ownerDocument.defaultView.dispatchEvent(
+								new ErrorEvent('error', {
+									message: error.message,
+									error
+								})
+							);
+							(<Document>this.ownerDocument)._readyStateManager.endTask();
+							if (
+								!this._listeners['error'] &&
+								!this.ownerDocument.defaultView._listeners['error']
+							) {
+								this.ownerDocument.defaultView.console.error(error);
+							}
 						});
 				}
 			}
@@ -239,12 +255,14 @@ export default class HTMLLinkElement extends HTMLElement implements IHTMLLinkEle
 			rel.toLowerCase() === 'stylesheet' &&
 			this.isConnected
 		) {
+			(<Document>this.ownerDocument)._readyStateManager.startTask();
 			ResourceFetcher.fetch({ window: this.ownerDocument.defaultView, url: href })
 				.then(code => {
 					const styleSheet = new CSSStyleSheet();
 					styleSheet.replaceSync(code);
 					(<CSSStyleSheet>this.sheet) = styleSheet;
 					this.dispatchEvent(new Event('load'));
+					(<Document>this.ownerDocument)._readyStateManager.endTask();
 				})
 				.catch(error => {
 					this.dispatchEvent(
@@ -253,6 +271,16 @@ export default class HTMLLinkElement extends HTMLElement implements IHTMLLinkEle
 							error
 						})
 					);
+					this.ownerDocument.defaultView.dispatchEvent(
+						new ErrorEvent('error', {
+							message: error.message,
+							error
+						})
+					);
+					(<Document>this.ownerDocument)._readyStateManager.endTask();
+					if (!this._listeners['error'] && !this.ownerDocument.defaultView._listeners['error']) {
+						this.ownerDocument.defaultView.console.error(error);
+					}
 				});
 		}
 
diff --git a/packages/happy-dom/src/nodes/html-script-element/ScriptUtility.ts b/packages/happy-dom/src/nodes/html-script-element/ScriptUtility.ts
index 00a955a1..da883626 100644
--- a/packages/happy-dom/src/nodes/html-script-element/ScriptUtility.ts
+++ b/packages/happy-dom/src/nodes/html-script-element/ScriptUtility.ts
@@ -1,3 +1,4 @@
+import { Document } from '../..';
 import Event from '../../event/Event';
 import ErrorEvent from '../../event/events/ErrorEvent';
 import ResourceFetcher from '../../fetch/ResourceFetcher';
@@ -18,6 +19,7 @@ export default class ScriptUtility {
 		const async = element.getAttributeNS(null, 'async') !== null;
 		if (async) {
 			let code = null;
+			(<Document>element.ownerDocument)._readyStateManager.startTask();
 			try {
 				code = await ResourceFetcher.fetch({
 					window: element.ownerDocument.defaultView,
@@ -30,11 +32,24 @@ export default class ScriptUtility {
 						error
 					})
 				);
+				element.ownerDocument.defaultView.dispatchEvent(
+					new ErrorEvent('error', {
+						message: error.message,
+						error
+					})
+				);
+				if (
+					!element._listeners['error'] &&
+					!element.ownerDocument.defaultView._listeners['error']
+				) {
+					element.ownerDocument.defaultView.console.error(error);
+				}
 			}
 			if (code) {
 				element.ownerDocument.defaultView.eval(code);
 				element.dispatchEvent(new Event('load'));
 			}
+			(<Document>element.ownerDocument)._readyStateManager.endTask();
 		} else {
 			let code = null;
 			try {
@@ -49,6 +64,18 @@ export default class ScriptUtility {
 						error
 					})
 				);
+				element.ownerDocument.defaultView.dispatchEvent(
+					new ErrorEvent('error', {
+						message: error.message,
+						error
+					})
+				);
+				if (
+					!element._listeners['error'] &&
+					!element.ownerDocument.defaultView._listeners['error']
+				) {
+					element.ownerDocument.defaultView.console.error(error);
+				}
 			}
 			if (code) {
 				element.ownerDocument.defaultView.eval(code);
diff --git a/packages/happy-dom/src/window/IWindow.ts b/packages/happy-dom/src/window/IWindow.ts
index 1fc0be76..5ed04226 100644
--- a/packages/happy-dom/src/window/IWindow.ts
+++ b/packages/happy-dom/src/window/IWindow.ts
@@ -125,6 +125,10 @@ export default interface IWindow {
 	Screen: typeof Screen;
 	Storage: typeof Storage;
 
+	// Events
+	onload: (event: Event) => void;
+	onerror: (event: ErrorEvent) => void;
+
 	// Public Properties
 	document: Document;
 	customElements: CustomElementRegistry;
diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts
index a5f7df1a..013547b7 100644
--- a/packages/happy-dom/src/window/Window.ts
+++ b/packages/happy-dom/src/window/Window.ts
@@ -135,6 +135,10 @@ export default class Window extends EventTarget implements IWindow, NodeJS.Globa
 	public Screen = Screen;
 	public Storage = Storage;
 
+	// Events
+	public onload: (event: Event) => void = null;
+	public onerror: (event: ErrorEvent) => void = null;
+
 	// Public Properties
 	public document: Document;
 	public customElements: CustomElementRegistry = new CustomElementRegistry();
@@ -220,6 +224,9 @@ export default class Window extends EventTarget implements IWindow, NodeJS.Globa
 
 		this.document = new HTMLDocument();
 		this.document.defaultView = this;
+		this.document._readyStateManager.whenComplete().then(() => {
+			this.dispatchEvent(new Event('load'));
+		});
 
 		DOMParser._ownerDocument = DOMParser._ownerDocument || this.document;
 		FileReader._ownerDocument = FileReader._ownerDocument || this.document;


Test patch:
diff --git a/packages/happy-dom/test/nodes/document/Document.test.ts b/packages/happy-dom/test/nodes/document/Document.test.ts
index 097869d1..b797bd1b 100644
--- a/packages/happy-dom/test/nodes/document/Document.test.ts
+++ b/packages/happy-dom/test/nodes/document/Document.test.ts
@@ -21,6 +21,9 @@ import IElement from '../../../src/nodes/element/IElement';
 import INodeList from '../../../src/nodes/node/INodeList';
 import { IHTMLLinkElement } from '../../../src';
 import IResponse from '../../../src/window/IResponse';
+import ResourceFetcher from '../../../src/fetch/ResourceFetcher';
+import IHTMLScriptElement from '../../../src/nodes/html-script-element/IHTMLScriptElement';
+import DocumentReadyStateEnum from '../../../src/nodes/document/DocumentReadyStateEnum';
 
 describe('Document', () => {
 	let window: Window;
@@ -798,4 +801,76 @@ describe('Document', () => {
 			expect(adopted.ownerDocument).toBe(document);
 		});
 	});
+
+	describe('addEventListener()', () => {
+		test('Triggers "readystatechange" event if no resources needs to be loaded.', done => {
+			let readyChangeEvent = null;
+
+			document.addEventListener('readystatechange', event => {
+				readyChangeEvent = event;
+			});
+
+			expect(document.readyState).toBe(DocumentReadyStateEnum.interactive);
+
+			setTimeout(() => {
+				expect(readyChangeEvent.target).toBe(document);
+				expect(document.readyState).toBe(DocumentReadyStateEnum.complete);
+				done();
+			}, 1);
+		});
+
+		test('Triggers "readystatechange" event when all resources have been loaded.', done => {
+			const cssURL = '/path/to/file.css';
+			const jsURL = '/path/to/file.js';
+			const cssResponse = 'body { background-color: red; }';
+			const jsResponse = 'global.test = "test";';
+			let resourceFetchCSSOptions = null;
+			let resourceFetchJSOptions = null;
+			let readyChangeEvent = null;
+
+			jest.spyOn(ResourceFetcher, 'fetch').mockImplementation(async options => {
+				if (options.url.endsWith('.css')) {
+					resourceFetchCSSOptions = options;
+					return cssResponse;
+				}
+
+				resourceFetchJSOptions = options;
+				return jsResponse;
+			});
+
+			document.addEventListener('readystatechange', event => {
+				readyChangeEvent = event;
+			});
+
+			const script = <IHTMLScriptElement>document.createElement('script');
+			script.async = true;
+			script.src = jsURL;
+
+			const link = <IHTMLLinkElement>document.createElement('link');
+			link.href = cssURL;
+			link.rel = 'stylesheet';
+
+			document.body.appendChild(script);
+			document.body.appendChild(link);
+
+			expect(document.readyState).toBe(DocumentReadyStateEnum.interactive);
+
+			setTimeout(() => {
+				expect(resourceFetchCSSOptions.window).toBe(window);
+				expect(resourceFetchCSSOptions.url).toBe(cssURL);
+				expect(resourceFetchJSOptions.window).toBe(window);
+				expect(resourceFetchJSOptions.url).toBe(jsURL);
+				expect(readyChangeEvent.target).toBe(document);
+				expect(document.readyState).toBe(DocumentReadyStateEnum.complete);
+				expect(document.styleSheets.length).toBe(1);
+				expect(document.styleSheets[0].cssRules[0].cssText).toBe(cssResponse);
+
+				expect(global['test']).toBe('test');
+
+				delete global['test'];
+
+				done();
+			}, 0);
+		});
+	});
 });
diff --git a/packages/happy-dom/test/window/Window.test.ts b/packages/happy-dom/test/window/Window.test.ts
index 10c6eb5e..d9e3784f 100644
--- a/packages/happy-dom/test/window/Window.test.ts
+++ b/packages/happy-dom/test/window/Window.test.ts
@@ -1,11 +1,14 @@
-import { HTMLElement } from '../../src';
+import { Document, HTMLElement, IHTMLLinkElement } from '../../src';
 import CSSStyleDeclaration from '../../src/css/CSSStyleDeclaration';
+import ResourceFetcher from '../../src/fetch/ResourceFetcher';
+import IHTMLScriptElement from '../../src/nodes/html-script-element/IHTMLScriptElement';
 import IFetchOptions from '../../src/window/IFetchOptions';
 import IResponse from '../../src/window/IResponse';
 import Window from '../../src/window/Window';
 
 describe('Window', () => {
 	let window: Window;
+	let document: Document;
 	let fetchedUrl: string;
 	let fetchedOptions: IFetchOptions;
 	let fetchResponseBody: string;
@@ -33,6 +36,7 @@ describe('Window', () => {
 		fetchResponseBody = null;
 		fetchError = null;
 		window = new Window();
+		document = window.document;
 	});
 
 	afterEach(() => {
@@ -306,4 +310,105 @@ describe('Window', () => {
 			});
 		});
 	}
+
+	describe('addEventListener()', () => {
+		test('Triggers "load" event if no resources needs to be loaded.', done => {
+			let loadEvent = null;
+
+			window.addEventListener('load', event => {
+				loadEvent = event;
+			});
+
+			setTimeout(() => {
+				expect(loadEvent.target).toBe(window);
+				done();
+			}, 1);
+		});
+
+		test('Triggers "load" event when all resources have been loaded.', done => {
+			const cssURL = '/path/to/file.css';
+			const jsURL = '/path/to/file.js';
+			const cssResponse = 'body { background-color: red; }';
+			const jsResponse = 'global.test = "test";';
+			let resourceFetchCSSOptions = null;
+			let resourceFetchJSOptions = null;
+			let loadEvent = null;
+
+			jest.spyOn(ResourceFetcher, 'fetch').mockImplementation(async options => {
+				if (options.url.endsWith('.css')) {
+					resourceFetchCSSOptions = options;
+					return cssResponse;
+				}
+
+				resourceFetchJSOptions = options;
+				return jsResponse;
+			});
+
+			window.addEventListener('load', event => {
+				loadEvent = event;
+			});
+
+			const script = <IHTMLScriptElement>document.createElement('script');
+			script.async = true;
+			script.src = jsURL;
+
+			const link = <IHTMLLinkElement>document.createElement('link');
+			link.href = cssURL;
+			link.rel = 'stylesheet';
+
+			document.body.appendChild(script);
+			document.body.appendChild(link);
+
+			setTimeout(() => {
+				expect(resourceFetchCSSOptions.window).toBe(window);
+				expect(resourceFetchCSSOptions.url).toBe(cssURL);
+				expect(resourceFetchJSOptions.window).toBe(window);
+				expect(resourceFetchJSOptions.url).toBe(jsURL);
+				expect(loadEvent.target).toBe(window);
+				expect(document.styleSheets.length).toBe(1);
+				expect(document.styleSheets[0].cssRules[0].cssText).toBe(cssResponse);
+
+				expect(global['test']).toBe('test');
+
+				delete global['test'];
+
+				done();
+			}, 0);
+		});
+
+		test('Triggers "error" event if there are problems loading resources.', done => {
+			const cssURL = '/path/to/file.css';
+			const jsURL = '/path/to/file.js';
+			const errorEvents = [];
+
+			jest.spyOn(ResourceFetcher, 'fetch').mockImplementation(async options => {
+				throw new Error(options.url);
+			});
+
+			window.addEventListener('error', event => {
+				errorEvents.push(event);
+			});
+
+			const script = <IHTMLScriptElement>document.createElement('script');
+			script.async = true;
+			script.src = jsURL;
+
+			const link = <IHTMLLinkElement>document.createElement('link');
+			link.href = cssURL;
+			link.rel = 'stylesheet';
+
+			document.body.appendChild(script);
+			document.body.appendChild(link);
+
+			setTimeout(() => {
+				expect(errorEvents.length).toBe(2);
+				expect(errorEvents[0].target).toBe(window);
+				expect(errorEvents[0].error.message).toBe(jsURL);
+				expect(errorEvents[1].target).toBe(window);
+				expect(errorEvents[1].error.message).toBe(cssURL);
+
+				done();
+			}, 0);
+		});
+	});
 });