Repository: capricorn86/happy-dom
Pull request #254 (2021-04-28 14:43:46)

#242@minor: Adds support for HTMLStyleElement. Adds support for HTMLL…
…inkElement. Adds support for Document.styleSheets. Adds support for load and error event to HTMLScriptElement. Fixes bug where Window.fetch() did not work anymore.


Patch:
diff --git a/packages/happy-dom/src/config/ElementClass.ts b/packages/happy-dom/src/config/ElementClass.ts
index a642f2ca..7096e467 100644
--- a/packages/happy-dom/src/config/ElementClass.ts
+++ b/packages/happy-dom/src/config/ElementClass.ts
@@ -7,6 +7,8 @@ import SVGSVGElement from '../nodes/svg-element/SVGSVGElement';
 import SVGElement from '../nodes/svg-element/SVGElement';
 import HTMLScriptElement from '../nodes/html-script-element/HTMLScriptElement';
 import HTMLImageElement from '../nodes/html-image-element/HTMLImageElement';
+import HTMLStyleElement from '../nodes/html-style-element/HTMLStyleElement';
+import HTMLLinkElement from '../nodes/html-link-element/HTMLLinkElement';
 
 export default {
 	HTMLTemplateElement: HTMLTemplateElement,
@@ -17,9 +19,9 @@ export default {
 	HTMLHeadElement: HTMLElement,
 	HTMLTitleElement: HTMLElement,
 	HTMLBaseElement: HTMLElement,
-	HTMLLinkElement: HTMLElement,
+	HTMLLinkElement: HTMLLinkElement,
 	HTMLMetaElement: HTMLElement,
-	HTMLStyleElement: HTMLElement,
+	HTMLStyleElement: HTMLStyleElement,
 	HTMLBodyElement: HTMLElement,
 	HTMLHeadingElement: HTMLElement,
 	HTMLParagraphElement: HTMLElement,
diff --git a/packages/happy-dom/src/config/ElementTag.ts b/packages/happy-dom/src/config/ElementTag.ts
index deb7753f..286722f2 100644
--- a/packages/happy-dom/src/config/ElementTag.ts
+++ b/packages/happy-dom/src/config/ElementTag.ts
@@ -6,6 +6,8 @@ import SVGSVGElement from '../nodes/svg-element/SVGSVGElement';
 import SVGElement from '../nodes/svg-element/SVGElement';
 import HTMLScriptElement from '../nodes/html-script-element/HTMLScriptElement';
 import HTMLImageElement from '../nodes/html-image-element/HTMLImageElement';
+import HTMLLinkElement from '../nodes/html-link-element/HTMLLinkElement';
+import HTMLStyleElement from '../nodes/html-style-element/HTMLStyleElement';
 
 export default {
 	template: HTMLTemplateElement,
@@ -14,6 +16,8 @@ export default {
 	textarea: HTMLTextAreaElement,
 	script: HTMLScriptElement,
 	img: HTMLImageElement,
+	link: HTMLLinkElement,
+	style: HTMLStyleElement,
 	svg: SVGSVGElement,
 	circle: SVGElement,
 	ellipse: SVGElement,
diff --git a/packages/happy-dom/src/fetch/ResourceFetcher.ts b/packages/happy-dom/src/fetch/ResourceFetcher.ts
new file mode 100644
index 00000000..26884b0f
--- /dev/null
+++ b/packages/happy-dom/src/fetch/ResourceFetcher.ts
@@ -0,0 +1,55 @@
+import RelativeURL from '../location/RelativeURL';
+import Window from '../window/Window';
+import DOMException from '../exception/DOMException';
+
+/**
+ * Helper class for performing an asynchonous or synchrounous request to a resource.
+ */
+export default class ResourceFetcher {
+	/**
+	 * Returns resource data asynchonously.
+	 *
+	 * @param options Options.
+	 * @param options.window Location.
+	 * @param options.url URL.
+	 * @returns Response.
+	 */
+	public static async fetch(options: { window: Window; url: string }): Promise<string> {
+		const response = await options.window.fetch(options.url);
+		if (!response.ok) {
+			throw new DOMException(
+				`Failed to perform request to "${options.url}". Status code: ${response.status}`
+			);
+		}
+		return await response.text();
+	}
+
+	/**
+	 * Returns resource data synchonously.
+	 *
+	 * @param options Options.
+	 * @param options.window Location.
+	 * @param options.url URL.
+	 * @returns Response.
+	 */
+	public static fetchSync(options: { window: Window; url: string }): string {
+		const url = RelativeURL.getAbsoluteURL(options.window.location, options.url);
+		let request = null;
+
+		try {
+			request = require('sync-request');
+		} catch (error) {
+			throw new DOMException('Failed to load script. "sync-request" could not be loaded.');
+		}
+
+		const response = request('GET', url);
+
+		if (response.isError()) {
+			throw new DOMException(
+				`Failed to perform request to "${url}". Status code: ${response.statusCode}`
+			);
+		}
+
+		return response.getBody();
+	}
+}
diff --git a/packages/happy-dom/src/index.ts b/packages/happy-dom/src/index.ts
index a0ab8e80..0c4c7514 100644
--- a/packages/happy-dom/src/index.ts
+++ b/packages/happy-dom/src/index.ts
@@ -45,6 +45,10 @@ import HTMLTextAreaElement from './nodes/html-text-area-element/HTMLTextAreaElem
 import IHTMLTextAreaElement from './nodes/html-text-area-element/IHTMLTextAreaElement';
 import HTMLImageElement from './nodes/html-image-element/HTMLImageElement';
 import IHTMLImageElement from './nodes/html-image-element/IHTMLImageElement';
+import HTMLLinkElement from './nodes/html-link-element/HTMLLinkElement';
+import IHTMLLinkElement from './nodes/html-link-element/IHTMLLinkElement';
+import HTMLStyleElement from './nodes/html-style-element/HTMLStyleElement';
+import IHTMLStyleElement from './nodes/html-style-element/IHTMLStyleElement';
 import SVGElement from './nodes/svg-element/SVGElement';
 import ISVGElement from './nodes/svg-element/ISVGElement';
 import SVGGraphicsElement from './nodes/svg-element/SVGGraphicsElement';
@@ -122,6 +126,10 @@ export {
 	IHTMLTextAreaElement,
 	HTMLImageElement,
 	IHTMLImageElement,
+	HTMLLinkElement,
+	IHTMLLinkElement,
+	HTMLStyleElement,
+	IHTMLStyleElement,
 	SVGElement,
 	ISVGElement,
 	SVGGraphicsElement,
diff --git a/packages/happy-dom/src/nodes/document/Document.ts b/packages/happy-dom/src/nodes/document/Document.ts
index 37e979ce..ba55be24 100644
--- a/packages/happy-dom/src/nodes/document/Document.ts
+++ b/packages/happy-dom/src/nodes/document/Document.ts
@@ -29,6 +29,8 @@ import IDocumentFragment from '../document-fragment/IDocumentFragment';
 import INodeList from '../node/INodeList';
 import IHTMLCollection from '../element/IHTMLCollection';
 import HTMLCollectionFactory from '../element/HTMLCollectionFactory';
+import IHTMLLinkElement from '../html-link-element/IHTMLLinkElement';
+import IHTMLStyleElement from '../html-style-element/IHTMLStyleElement';
 
 /**
  * Document.
@@ -160,6 +162,25 @@ export default class Document extends Node implements IDocument {
 		return <IHTMLElement>ParentNodeUtility.getElementByTagName(this, 'head');
 	}
 
+	/**
+	 * Returns CSS style sheets.
+	 *
+	 * @return CSS style sheets.
+	 */
+	public get styleSheets(): CSSStyleSheet[] {
+		const styles = <(IHTMLLinkElement | IHTMLStyleElement)[]>(
+			(<unknown>this.querySelectorAll('link,style'))
+		);
+		const styleSheets = [];
+		for (const style of styles) {
+			const sheet = style.sheet;
+			if (sheet) {
+				styleSheets.push(sheet);
+			}
+		}
+		return styleSheets;
+	}
+
 	/**
 	 * Inserts a set of Node objects or DOMString objects after the last child of the ParentNode. DOMString objects are inserted as equivalent Text nodes.
 	 *
diff --git a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts b/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts
new file mode 100644
index 00000000..8cc8c075
--- /dev/null
+++ b/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts
@@ -0,0 +1,261 @@
+import Attr from '../../attribute/Attr';
+import CSSStyleSheet from '../../css/CSSStyleSheet';
+import ResourceFetcher from '../../fetch/ResourceFetcher';
+import HTMLElement from '../html-element/HTMLElement';
+import IHTMLLinkElement from './IHTMLLinkElement';
+import Event from '../../event/Event';
+import ErrorEvent from '../../event/events/ErrorEvent';
+
+/**
+ * HTML Link Element.
+ *
+ * Reference:
+ * https://developer.mozilla.org/en-US/docs/Web/API/HTMLLinkElement
+ */
+export default class HTMLLinkElement extends HTMLElement implements IHTMLLinkElement {
+	public onerror: (event: ErrorEvent) => void = null;
+	public onload: (event: Event) => void = null;
+	public readonly sheet: CSSStyleSheet = null;
+	public _evaluateCSS = true;
+
+	/**
+	 * "true" if connected to DOM.
+	 *
+	 * @return "true" if connected.
+	 */
+	public get isConnected(): boolean {
+		return this._isConnected;
+	}
+
+	/**
+	 * Sets the connected state.
+	 *
+	 * @param isConnected "true" if connected.
+	 */
+	public set isConnected(isConnected) {
+		if (this._isConnected !== isConnected) {
+			this._isConnected = isConnected;
+
+			for (const child of this.childNodes) {
+				child.isConnected = isConnected;
+			}
+
+			// eslint-disable-next-line
+			if (this.shadowRoot) {
+				// eslint-disable-next-line
+				this.shadowRoot.isConnected = isConnected;
+			}
+
+			if (isConnected && this._evaluateCSS) {
+				const href = this.getAttributeNS(null, 'href');
+				const rel = this.getAttributeNS(null, 'rel');
+				if (href !== null && rel && rel.toLowerCase() === 'stylesheet') {
+					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'));
+						})
+						.catch(error => {
+							this.dispatchEvent(
+								new ErrorEvent('error', {
+									message: error.message,
+									error
+								})
+							);
+						});
+				}
+			}
+
+			if (isConnected && this.connectedCallback) {
+				this.connectedCallback();
+			} else if (!isConnected && this.disconnectedCallback) {
+				this.disconnectedCallback();
+			}
+		}
+	}
+
+	/**
+	 * Returns as.
+	 *
+	 * @return as.
+	 */
+	public get as(): string {
+		return this.getAttributeNS(null, 'as') || '';
+	}
+
+	/**
+	 * Sets crossOrigin.
+	 *
+	 * @param crossOrigin crossOrigin.
+	 */
+	public set as(as: string) {
+		this.setAttributeNS(null, 'as', as);
+	}
+
+	/**
+	 * Returns crossOrigin.
+	 *
+	 * @return crossOrigin.
+	 */
+	public get crossOrigin(): string {
+		return this.getAttribute('crossorigin') || '';
+	}
+
+	/**
+	 * Sets crossOrigin.
+	 *
+	 * @param crossOrigin crossOrigin.
+	 */
+	public set crossOrigin(crossOrigin: string) {
+		this.setAttributeNS(null, 'crossorigin', crossOrigin);
+	}
+
+	/**
+	 * Returns href.
+	 *
+	 * @return Href.
+	 */
+	public get href(): string {
+		return this.getAttributeNS(null, 'href') || '';
+	}
+
+	/**
+	 * Sets href.
+	 *
+	 * @param href Href.
+	 */
+	public set href(href: string) {
+		this.setAttributeNS(null, 'href', href);
+	}
+
+	/**
+	 * Returns hreflang.
+	 *
+	 * @return hreflang.
+	 */
+	public get hreflang(): string {
+		return this.getAttributeNS(null, 'hreflang') || '';
+	}
+
+	/**
+	 * Sets hreflang.
+	 *
+	 * @param hreflang hreflang.
+	 */
+	public set hreflang(hreflang: string) {
+		this.setAttributeNS(null, 'hreflang', hreflang);
+	}
+
+	/**
+	 * Returns media.
+	 *
+	 * @return media.
+	 */
+	public get media(): string {
+		return this.getAttributeNS(null, 'media') || '';
+	}
+
+	/**
+	 * Sets media.
+	 *
+	 * @param media media.
+	 */
+	public set media(media: string) {
+		this.setAttributeNS(null, 'media', media);
+	}
+
+	/**
+	 * Returns referrerPolicy.
+	 *
+	 * @return referrerPolicy.
+	 */
+	public get referrerPolicy(): string {
+		return this.getAttribute('referrerPolicy') || '';
+	}
+
+	/**
+	 * Sets referrerPolicy.
+	 *
+	 * @param referrerPolicy referrerPolicy.
+	 */
+	public set referrerPolicy(referrerPolicy: string) {
+		this.setAttributeNS(null, 'referrerPolicy', referrerPolicy);
+	}
+
+	/**
+	 * Returns rel.
+	 *
+	 * @return rel.
+	 */
+	public get rel(): string {
+		return this.getAttributeNS(null, 'rel') || '';
+	}
+
+	/**
+	 * Sets rel.
+	 *
+	 * @param rel rel.
+	 */
+	public set rel(rel: string) {
+		this.setAttributeNS(null, 'rel', rel);
+	}
+
+	/**
+	 * Returns type.
+	 *
+	 * @return type.
+	 */
+	public get type(): string {
+		return this.getAttributeNS(null, 'type') || '';
+	}
+
+	/**
+	 * Sets type.
+	 *
+	 * @param type type.
+	 */
+	public set type(type: string) {
+		this.setAttributeNS(null, 'type', type);
+	}
+
+	/**
+	 * The setAttributeNode() method adds a new Attr node to the specified element.
+	 *
+	 * @override
+	 * @param attribute Attribute.
+	 * @returns Replaced attribute.
+	 */
+	public setAttributeNode(attribute: Attr): Attr {
+		const replacedAttribute = super.setAttributeNode(attribute);
+		const rel = this.getAttributeNS(null, 'rel');
+		const href = this.getAttributeNS(null, 'href');
+
+		if (
+			(attribute.name === 'rel' || attribute.name === 'href') &&
+			href !== null &&
+			rel &&
+			rel.toLowerCase() === 'stylesheet' &&
+			this.isConnected
+		) {
+			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'));
+				})
+				.catch(error => {
+					this.dispatchEvent(
+						new ErrorEvent('error', {
+							message: error.message,
+							error
+						})
+					);
+				});
+		}
+
+		return replacedAttribute;
+	}
+}
diff --git a/packages/happy-dom/src/nodes/html-link-element/IHTMLLinkElement.ts b/packages/happy-dom/src/nodes/html-link-element/IHTMLLinkElement.ts
new file mode 100644
index 00000000..3a7253d1
--- /dev/null
+++ b/packages/happy-dom/src/nodes/html-link-element/IHTMLLinkElement.ts
@@ -0,0 +1,20 @@
+import CSSStyleSheet from '../../css/CSSStyleSheet';
+import IHTMLElement from '../html-element/IHTMLElement';
+
+/**
+ * HTML Link Element.
+ *
+ * Reference:
+ * https://developer.mozilla.org/en-US/docs/Web/API/HTMLLinkElement
+ */
+export default interface IHTMLLinkElement extends IHTMLElement {
+	readonly sheet: CSSStyleSheet;
+	as: string;
+	crossOrigin: string;
+	href: string;
+	hreflang: string;
+	media: string;
+	referrerPolicy: string;
+	rel: string;
+	type: string;
+}
diff --git a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts
index 31e40f81..b0a9dd45 100644
--- a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts
+++ b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts
@@ -2,6 +2,8 @@ import Attr from '../../attribute/Attr';
 import HTMLElement from '../html-element/HTMLElement';
 import IHTMLScriptElement from './IHTMLScriptElement';
 import ScriptUtility from './ScriptUtility';
+import Event from '../../event/Event';
+import ErrorEvent from '../../event/events/ErrorEvent';
 
 /**
  * HTML Script Element.
@@ -10,6 +12,8 @@ import ScriptUtility from './ScriptUtility';
  * https://developer.mozilla.org/en-US/docs/Web/API/HTMLScriptElement
  */
 export default class HTMLScriptElement extends HTMLElement implements IHTMLScriptElement {
+	public onerror: (event: ErrorEvent) => void = null;
+	public onload: (event: Event) => void = null;
 	public _evaluateScript = true;
 
 	/**
@@ -44,11 +48,7 @@ export default class HTMLScriptElement extends HTMLElement implements IHTMLScrip
 				const src = this.getAttributeNS(null, 'src');
 
 				if (src !== null) {
-					ScriptUtility.loadExternalScript({
-						window: this.ownerDocument.defaultView,
-						url: src,
-						async: this.getAttributeNS(null, 'async') !== null
-					});
+					ScriptUtility.loadExternalScript(this);
 				} else {
 					const textContent = this.textContent;
 					if (textContent) {
@@ -210,11 +210,7 @@ export default class HTMLScriptElement extends HTMLElement implements IHTMLScrip
 		const replacedAttribute = super.setAttributeNode(attribute);
 
 		if (attribute.name === 'src' && attribute.value !== null && this.isConnected) {
-			ScriptUtility.loadExternalScript({
-				window: this.ownerDocument.defaultView,
-				url: attribute.value,
-				async: this.getAttributeNS(null, 'async') !== null
-			});
+			ScriptUtility.loadExternalScript(this);
 		}
 
 		return replacedAttribute;
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 992fb5a5..00a955a1 100644
--- a/packages/happy-dom/src/nodes/html-script-element/ScriptUtility.ts
+++ b/packages/happy-dom/src/nodes/html-script-element/ScriptUtility.ts
@@ -1,5 +1,7 @@
-import RelativeURL from '../../location/RelativeURL';
-import Window from '../../window/Window';
+import Event from '../../event/Event';
+import ErrorEvent from '../../event/events/ErrorEvent';
+import ResourceFetcher from '../../fetch/ResourceFetcher';
+import HTMLScriptElement from './HTMLScriptElement';
 
 /**
  * Helper class for getting the URL relative to a Location object.
@@ -9,33 +11,49 @@ export default class ScriptUtility {
 	 * Returns a URL relative to the given Location object.
 	 *
 	 * @param options Options.
-	 * @param options.window Location.
-	 * @param options.url URL.
-	 * @param options.async Async.
+	 * @param options.element Element.
 	 */
-	public static loadExternalScript(options: { window: Window; url: string; async: boolean }): void {
-		if (options.async) {
-			options.window
-				.fetch(options.url)
-				.then(response => response.text())
-				.then(code => {
-					options.window.eval(code);
-				})
-				.catch(error => {
-					throw error;
+	public static async loadExternalScript(element: HTMLScriptElement): Promise<void> {
+		const src = element.getAttributeNS(null, 'src');
+		const async = element.getAttributeNS(null, 'async') !== null;
+		if (async) {
+			let code = null;
+			try {
+				code = await ResourceFetcher.fetch({
+					window: element.ownerDocument.defaultView,
+					url: src
 				});
+			} catch (error) {
+				element.dispatchEvent(
+					new ErrorEvent('error', {
+						message: error.message,
+						error
+					})
+				);
+			}
+			if (code) {
+				element.ownerDocument.defaultView.eval(code);
+				element.dispatchEvent(new Event('load'));
+			}
 		} else {
-			const url = RelativeURL.getAbsoluteURL(options.window.location, options.url);
-			let request = null;
-
+			let code = null;
 			try {
-				request = require('sync-request');
+				code = ResourceFetcher.fetchSync({
+					window: element.ownerDocument.defaultView,
+					url: src
+				});
 			} catch (error) {
-				throw new Error('Failed to load script. "sync-request" could not be loaded.');
+				element.dispatchEvent(
+					new ErrorEvent('error', {
+						message: error.message,
+						error
+					})
+				);
+			}
+			if (code) {
+				element.ownerDocument.defaultView.eval(code);
+				element.dispatchEvent(new Event('load'));
 			}
-
-			const code = request('GET', url).getBody();
-			options.window.eval(code);
 		}
 	}
 }
diff --git a/packages/happy-dom/src/nodes/html-style-element/HTMLStyleElement.ts b/packages/happy-dom/src/nodes/html-style-element/HTMLStyleElement.ts
new file mode 100644
index 00000000..be780443
--- /dev/null
+++ b/packages/happy-dom/src/nodes/html-style-element/HTMLStyleElement.ts
@@ -0,0 +1,83 @@
+import CSSStyleSheet from '../../css/CSSStyleSheet';
+import HTMLElement from '../html-element/HTMLElement';
+import IHTMLStyleElement from './IHTMLStyleElement';
+
+/**
+ * HTML Style Element.
+ *
+ * Reference:
+ * https://developer.mozilla.org/en-US/docs/Web/API/HTMLStyleElement
+ */
+export default class HTMLStyleElement extends HTMLElement implements IHTMLStyleElement {
+	/**
+	 * Returns CSS style sheet.
+	 *
+	 * @return CSS style sheet.
+	 */
+	public get sheet(): CSSStyleSheet {
+		if (!this._isConnected) {
+			return null;
+		}
+		const styleSheet = new CSSStyleSheet();
+		styleSheet.replaceSync(this.innerText);
+		return styleSheet;
+	}
+
+	/**
+	 * Returns media.
+	 *
+	 * @return media.
+	 */
+	public get media(): string {
+		return this.getAttributeNS(null, 'media') || '';
+	}
+
+	/**
+	 * Sets media.
+	 *
+	 * @param media media.
+	 */
+	public set media(media: string) {
+		this.setAttributeNS(null, 'media', media);
+	}
+
+	/**
+	 * Returns type.
+	 *
+	 * @return type.
+	 */
+	public get type(): string {
+		return this.getAttributeNS(null, 'type') || '';
+	}
+
+	/**
+	 * Sets type.
+	 *
+	 * @param type type.
+	 */
+	public set type(type: string) {
+		this.setAttributeNS(null, 'type', type);
+	}
+
+	/**
+	 * Returns disabled.
+	 *
+	 * @return Disabled.
+	 */
+	public get disabled(): boolean {
+		return this.getAttributeNS(null, 'disabled') !== null;
+	}
+
+	/**
+	 * Sets disabled.
+	 *
+	 * @param disabled Disabled.
+	 */
+	public set disabled(disabled: boolean) {
+		if (!disabled) {
+			this.removeAttributeNS(null, 'disabled');
+		} else {
+			this.setAttributeNS(null, 'disabled', '');
+		}
+	}
+}
diff --git a/packages/happy-dom/src/nodes/html-style-element/IHTMLStyleElement.ts b/packages/happy-dom/src/nodes/html-style-element/IHTMLStyleElement.ts
new file mode 100644
index 00000000..997d038e
--- /dev/null
+++ b/packages/happy-dom/src/nodes/html-style-element/IHTMLStyleElement.ts
@@ -0,0 +1,15 @@
+import CSSStyleSheet from '../../css/CSSStyleSheet';
+import IHTMLElement from '../html-element/IHTMLElement';
+
+/**
+ * HTML Style Element.
+ *
+ * Reference:
+ * https://developer.mozilla.org/en-US/docs/Web/API/HTMLStyleElement
+ */
+export default interface IHTMLStyleElement extends IHTMLElement {
+	media: string;
+	type: string;
+	disabled: boolean;
+	readonly sheet: CSSStyleSheet;
+}
diff --git a/packages/happy-dom/src/window/AsyncTaskManager.ts b/packages/happy-dom/src/window/AsyncTaskManager.ts
index be2f96a7..f22b323b 100644
--- a/packages/happy-dom/src/window/AsyncTaskManager.ts
+++ b/packages/happy-dom/src/window/AsyncTaskManager.ts
@@ -111,4 +111,17 @@ export default class AsyncTaskManager {
 			}
 		}
 	}
+
+	/**
+	 * Returns the amount of running tasks by type.
+	 *
+	 * @param type Task type.
+	 * @returns Count.
+	 */
+	public getRunningCount(type: AsyncTaskTypeEnum): number {
+		if (this.tasks[type]) {
+			return this.tasks[type].length;
+		}
+		return 0;
+	}
 }
diff --git a/packages/happy-dom/src/window/IFetchOptions.ts b/packages/happy-dom/src/window/IFetchOptions.ts
new file mode 100644
index 00000000..6229a12a
--- /dev/null
+++ b/packages/happy-dom/src/window/IFetchOptions.ts
@@ -0,0 +1,11 @@
+import { URLSearchParams } from 'url';
+
+/**
+ * Fetch options.
+ */
+export default interface IFetchOptions {
+	method?: string;
+	headers?: Map<string, string> | { [k: string]: string };
+	body?: URLSearchParams | string;
+	redirect?: string;
+}
diff --git a/packages/happy-dom/src/window/IResponse.ts b/packages/happy-dom/src/window/IResponse.ts
index 2ed50afd..b7d50b61 100644
--- a/packages/happy-dom/src/window/IResponse.ts
+++ b/packages/happy-dom/src/window/IResponse.ts
@@ -1,3 +1,5 @@
+import Blob from '../file/Blob';
+
 /**
  * Fetch response.
  */
@@ -22,4 +24,19 @@ export default interface IResponse {
 	 * @returns Text.
 	 */
 	text(): Promise<string>;
+
+	/**
+	 * Returns blob.
+	 *
+	 * @returns Blob.
+	 */
+	blob(): Promise<Blob>;
+
+	/**
+	 * Returns form data.
+	 *
+	 * @returns Form data.
+	 */
+	/* eslint-disable @typescript-eslint/no-explicit-any*/
+	formData(): Promise<any>;
 }
diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts
index 2a74ef7c..a6a51bdb 100644
--- a/packages/happy-dom/src/window/Window.ts
+++ b/packages/happy-dom/src/window/Window.ts
@@ -52,10 +52,12 @@ import ErrorEvent from '../event/events/ErrorEvent';
 import Screen from '../screen/Screen';
 import AsyncTaskManager from './AsyncTaskManager';
 import IResponse from './IResponse';
-import { URLSearchParams } from 'url';
 import AsyncTaskTypeEnum from './AsyncTaskTypeEnum';
 import RelativeURL from '../location/RelativeURL';
 import Storage from '../storage/Storage';
+import HTMLLinkElement from '../nodes/html-link-element/HTMLLinkElement';
+import HTMLStyleElement from '../nodes/html-style-element/HTMLStyleElement';
+import IFetchOptions from './IFetchOptions';
 
 const FETCH_RESPONSE_TYPE_METHODS = ['blob', 'json', 'formData', 'text'];
 
@@ -83,6 +85,8 @@ export default class Window extends EventTarget implements NodeJS.Global {
 	public HTMLTextAreaElement = HTMLTextAreaElement;
 	public HTMLImageElement = HTMLImageElement;
 	public HTMLScriptElement = HTMLScriptElement;
+	public HTMLLinkElement = HTMLLinkElement;
+	public HTMLStyleElement = HTMLStyleElement;
 	public SVGSVGElement = SVGSVGElement;
 	public SVGElement = SVGElement;
 	public Text = Text;
@@ -401,15 +405,7 @@ export default class Window extends EventTarget implements NodeJS.Global {
 	 * @param [options] Options.
 	 * @returns Promise.
 	 */
-	public async fetch(
-		url: string,
-		options?: {
-			method?: string;
-			headers?: Map<string, string> | { [k: string]: string };
-			body?: URLSearchParams | string;
-			redirect?: string;
-		}
-	): Promise<IResponse> {
+	public async fetch(url: string, options?: IFetchOptions): Promise<IResponse> {
 		return new Promise((resolve, reject) => {
 			let fetch = null;
 
@@ -423,27 +419,38 @@ export default class Window extends EventTarget implements NodeJS.Global {
 
 			fetch(RelativeURL.getAbsoluteURL(this.location, url), options)
 				.then(response => {
-					for (const methodName of FETCH_RESPONSE_TYPE_METHODS) {
-						const asyncMethod = response[methodName];
-						response[methodName] = () => {
-							return new Promise((resolve, reject) => {
-								this.happyDOM.asyncTaskManager.startTask(AsyncTaskTypeEnum.fetch);
+					if (this.happyDOM.asyncTaskManager.getRunningCount(AsyncTaskTypeEnum.fetch) === 0) {
+						reject(new Error('Failed to complete fetch request. Task was canceled.'));
+					} else {
+						for (const methodName of FETCH_RESPONSE_TYPE_METHODS) {
+							const asyncMethod = response[methodName];
+							response[methodName] = () => {
+								return new Promise((resolve, reject) => {
+									this.happyDOM.asyncTaskManager.startTask(AsyncTaskTypeEnum.fetch);
 
-								asyncMethod
-									.then(response => {
-										resolve(response);
-										this.happyDOM.asyncTaskManager.endTask(AsyncTaskTypeEnum.fetch);
-									})
-									.catch(error => {
-										reject(error);
-										this.happyDOM.asyncTaskManager.endTask(AsyncTaskTypeEnum.fetch, error);
-									});
-							});
-						};
-					}
+									asyncMethod()
+										.then(response => {
+											if (
+												this.happyDOM.asyncTaskManager.getRunningCount(AsyncTaskTypeEnum.fetch) ===
+												0
+											) {
+												reject(new Error('Failed to complete fetch request. Task was canceled.'));
+											} else {
+												resolve(response);
+												this.happyDOM.asyncTaskManager.endTask(AsyncTaskTypeEnum.fetch);
+											}
+										})
+										.catch(error => {
+											reject(error);
+											this.happyDOM.asyncTaskManager.endTask(AsyncTaskTypeEnum.fetch, error);
+										});
+								});
+							};
+						}
 
-					resolve(response);
-					this.happyDOM.asyncTaskManager.endTask(AsyncTaskTypeEnum.fetch);
+						resolve(response);
+						this.happyDOM.asyncTaskManager.endTask(AsyncTaskTypeEnum.fetch);
+					}
 				})
 				.catch(error => {
 					reject(error);
diff --git a/packages/happy-dom/src/xml-parser/XMLParser.ts b/packages/happy-dom/src/xml-parser/XMLParser.ts
index 3503bdd0..a9c21c5c 100755
--- a/packages/happy-dom/src/xml-parser/XMLParser.ts
+++ b/packages/happy-dom/src/xml-parser/XMLParser.ts
@@ -8,6 +8,7 @@ import NamespaceURI from '../config/NamespaceURI';
 import HTMLScriptElement from '../nodes/html-script-element/HTMLScriptElement';
 import INode from '../nodes/node/INode';
 import IElement from '../nodes/element/IElement';
+import HTMLLinkElement from '../nodes/html-link-element/HTMLLinkElement';
 
 const MARKUP_REGEXP = /<(\/?)([a-z][-.0-9_a-z]*)\s*([^>]*?)(\/?)>/gi;
 const COMMENT_REGEXP = /<!--(.*?)-->/gi;
@@ -57,6 +58,11 @@ export default class XMLParser {
 					(<HTMLScriptElement>newElement)._evaluateScript = evaluateScripts;
 				}
 
+				// An assumption that the same rule should be applied for the HTMLLinkElement is made here.
+				if (tagName === 'link') {
+					(<HTMLLinkElement>newElement)._evaluateCSS = evaluateScripts;
+				}
+
 				// The HTML engine can guess that the namespace is SVG for SVG tags
 				// even if "xmlns" is not set if the parent namespace is HTML.
 				if (tagName === 'svg' && parent.namespaceURI === NamespaceURI.html) {


Test patch:
diff --git a/packages/happy-dom/test/fetch/ResourceFetcher.test.ts b/packages/happy-dom/test/fetch/ResourceFetcher.test.ts
new file mode 100644
index 00000000..97272ddc
--- /dev/null
+++ b/packages/happy-dom/test/fetch/ResourceFetcher.test.ts
@@ -0,0 +1,86 @@
+import Window from '../../src/window/Window';
+import ResourceFetcher from '../../src/fetch/ResourceFetcher';
+import IResponse from '../../src/window/IResponse';
+
+describe('ResourceFetcher', () => {
+	let window: Window, syncRequestStatusCode, syncRequestBody, syncRequestOptions;
+
+	beforeAll(() => {
+		jest.mock('sync-request', () => (method: string, url: string) => {
+			syncRequestOptions = {
+				method,
+				url
+			};
+			return {
+				getBody: () => syncRequestBody,
+				isError: () => syncRequestStatusCode !== 200,
+				statusCode: syncRequestStatusCode
+			};
+		});
+	});
+
+	beforeEach(() => {
+		syncRequestOptions = null;
+		syncRequestStatusCode = 200;
+		syncRequestBody = 'test';
+		window = new Window();
+	});
+
+	afterEach(() => {
+		jest.restoreAllMocks();
+	});
+
+	describe('fetch()', () => {
+		test('Returns resource data asynchrounously.', async () => {
+			let fetchedURL = null;
+
+			jest.spyOn(window, 'fetch').mockImplementation(url => {
+				fetchedURL = url;
+				return Promise.resolve(<IResponse>{
+					text: () => Promise.resolve('test'),
+					ok: true
+				});
+			});
+
+			const test = await ResourceFetcher.fetch({
+				window,
+				url: 'path/to/script/'
+			});
+
+			expect(fetchedURL).toBe('path/to/script/');
+			expect(test).toBe('test');
+		});
+	});
+
+	describe('fetchSync()', () => {
+		test('Returns resource data synchrounously.', () => {
+			window.location.href = 'https://localhost:8080/base/';
+
+			const test = ResourceFetcher.fetchSync({
+				window,
+				url: 'path/to/script/'
+			});
+
+			expect(syncRequestOptions).toEqual({
+				method: 'GET',
+				url: 'https://localhost:8080/base/path/to/script/'
+			});
+			expect(test).toBe('test');
+		});
+
+		test('Handles error when resource is fetched synchrounously.', () => {
+			window.location.href = 'https://localhost:8080/base/';
+
+			syncRequestStatusCode = 404;
+
+			expect(() => {
+				ResourceFetcher.fetchSync({
+					window,
+					url: 'path/to/script/'
+				});
+			}).toThrowError(
+				'Failed to perform request to "https://localhost:8080/base/path/to/script/". Status code: 404'
+			);
+		});
+	});
+});
diff --git a/packages/happy-dom/test/nodes/document/Document.test.ts b/packages/happy-dom/test/nodes/document/Document.test.ts
index 7f10e23a..097869d1 100644
--- a/packages/happy-dom/test/nodes/document/Document.test.ts
+++ b/packages/happy-dom/test/nodes/document/Document.test.ts
@@ -19,6 +19,8 @@ import HTMLTemplateElement from '../../../src/nodes/html-template-element/HTMLTe
 import IHTMLCollection from '../../../src/nodes/element/IHTMLCollection';
 import IElement from '../../../src/nodes/element/IElement';
 import INodeList from '../../../src/nodes/node/INodeList';
+import { IHTMLLinkElement } from '../../../src';
+import IResponse from '../../../src/window/IResponse';
 
 describe('Document', () => {
 	let window: Window;
@@ -198,6 +200,51 @@ describe('Document', () => {
 		});
 	});
 
+	describe('get styleSheets()', () => {
+		test('Returns all stylesheets loaded to the document.', done => {
+			const textNode = document.createTextNode(
+				'body { background-color: red }\ndiv { background-color: green }'
+			);
+			const style = document.createElement('style');
+			const link = <IHTMLLinkElement>document.createElement('link');
+			let fetchedUrl = null;
+			let fetchedOptions = null;
+
+			link.rel = 'stylesheet';
+			link.href = '/path/to/file.css';
+
+			jest.spyOn(window, 'fetch').mockImplementation((url, options) => {
+				fetchedUrl = url;
+				fetchedOptions = options;
+				return <Promise<IResponse>>Promise.resolve({
+					text: () => Promise.resolve('button { background-color: red }'),
+					ok: true
+				});
+			});
+
+			style.appendChild(textNode);
+
+			document.appendChild(style);
+			document.appendChild(link);
+
+			setTimeout(() => {
+				expect(fetchedUrl).toBe('/path/to/file.css');
+				expect(fetchedOptions).toBe(undefined);
+
+				const styleSheets = document.styleSheets;
+
+				expect(styleSheets.length).toBe(2);
+				expect(styleSheets[0].cssRules.length).toBe(1);
+				expect(styleSheets[0].cssRules[0].cssText).toBe('button { background-color: red; }');
+				expect(styleSheets[1].cssRules.length).toBe(2);
+				expect(styleSheets[1].cssRules[0].cssText).toBe('body { background-color: red; }');
+				expect(styleSheets[1].cssRules[1].cssText).toBe('div { background-color: green; }');
+
+				done();
+			}, 0);
+		});
+	});
+
 	describe('append()', () => {
 		test('Inserts a set of Node objects or DOMString objects after the last child of the ParentNode. DOMString objects are inserted as equivalent Text nodes.', () => {
 			const node1 = document.createComment('test1');
diff --git a/packages/happy-dom/test/nodes/html-link-element/HTMLLinkElement.test.ts b/packages/happy-dom/test/nodes/html-link-element/HTMLLinkElement.test.ts
new file mode 100644
index 00000000..ae0bc128
--- /dev/null
+++ b/packages/happy-dom/test/nodes/html-link-element/HTMLLinkElement.test.ts
@@ -0,0 +1,206 @@
+import Window from '../../../src/window/Window';
+import Document from '../../../src/nodes/document/Document';
+import HTMLLinkElement from '../../../src/nodes/html-link-element/HTMLLinkElement';
+import ResourceFetcher from '../../../src/fetch/ResourceFetcher';
+
+describe('HTMLLinkElement', () => {
+	let window: Window;
+	let document: Document;
+
+	beforeEach(() => {
+		window = new Window();
+		document = window.document;
+	});
+
+	afterEach(() => {
+		jest.restoreAllMocks();
+	});
+
+	for (const property of [
+		'as',
+		'crossOrigin',
+		'href',
+		'hreflang',
+		'media',
+		'referrerPolicy',
+		'rel',
+		'type'
+	]) {
+		describe(`get ${property}()`, () => {
+			test(`Returns the "${property}" attribute.`, () => {
+				const element = document.createElement('link');
+				element.setAttribute(property, 'test');
+				expect(element[property]).toBe('test');
+			});
+		});
+
+		describe(`set ${property}()`, () => {
+			test(`Sets the attribute "${property}".`, () => {
+				const element = document.createElement('link');
+				element[property] = 'test';
+				expect(element.getAttribute(property)).toBe('test');
+			});
+		});
+	}
+
+	describe('get href()', () => {
+		test('Returns the "href" attribute.', () => {
+			const element = <HTMLLinkElement>document.createElement('link');
+			element.setAttribute('href', 'test');
+			expect(element.href).toBe('test');
+		});
+	});
+
+	describe('set href()', () => {
+		test('Sets the attribute "href".', () => {
+			const element = <HTMLLinkElement>document.createElement('link');
+			element.href = 'test';
+			expect(element.getAttribute('href')).toBe('test');
+		});
+
+		test('Loads and evaluates an external CSS file when the attribute "href" and "rel" is set and the element is connected to DOM.', done => {
+			const element = <HTMLLinkElement>document.createElement('link');
+			const css = 'div { background: red; }';
+			let loadedOptions = null;
+			let loadEvent = null;
+
+			jest.spyOn(ResourceFetcher, 'fetch').mockImplementation(async options => {
+				loadedOptions = options;
+				return css;
+			});
+
+			document.body.appendChild(element);
+
+			element.addEventListener('load', event => {
+				loadEvent = event;
+			});
+
+			element.rel = 'stylesheet';
+			element.href = 'test';
+
+			setTimeout(() => {
+				expect(Object.keys(loadedOptions).length).toBe(2);
+				expect(loadedOptions.window).toBe(window);
+				expect(loadedOptions.url).toBe('test');
+				expect(element.sheet.cssRules.length).toBe(1);
+				expect(element.sheet.cssRules[0].cssText).toBe('div { background: red; }');
+				expect(loadEvent.target).toBe(element);
+				done();
+			}, 0);
+		});
+
+		test('Triggers error event when fetching a CSS file fails during setting the "href" and "rel" attributes.', done => {
+			const element = <HTMLLinkElement>document.createElement('link');
+			const thrownError = new Error('error');
+			let errorEvent = null;
+
+			jest.spyOn(ResourceFetcher, 'fetch').mockImplementation(async () => {
+				throw thrownError;
+			});
+
+			document.body.appendChild(element);
+
+			element.addEventListener('error', event => {
+				errorEvent = event;
+			});
+
+			element.rel = 'stylesheet';
+			element.href = 'test';
+
+			setTimeout(() => {
+				expect(errorEvent.error).toEqual(thrownError);
+				expect(errorEvent.message).toEqual('error');
+				done();
+			}, 0);
+		});
+
+		test('Does not load and evaluate external CSS files if the element is not connected to DOM.', () => {
+			const element = <HTMLLinkElement>document.createElement('link');
+			const css = 'div { background: red; }';
+			let loadedOptions = null;
+
+			jest.spyOn(ResourceFetcher, 'fetch').mockImplementation(async options => {
+				loadedOptions = options;
+				return css;
+			});
+
+			element.rel = 'stylesheet';
+			element.href = 'test';
+
+			expect(loadedOptions).toBe(null);
+		});
+	});
+
+	describe('set isConnected()', () => {
+		test('Loads and evaluates an external script when "href" attribute has been set, but does not evaluate text content.', done => {
+			const element = <HTMLLinkElement>document.createElement('link');
+			const css = 'div { background: red; }';
+			let loadEvent = null;
+			let loadedOptions = null;
+
+			jest.spyOn(ResourceFetcher, 'fetch').mockImplementation(async options => {
+				loadedOptions = options;
+				return css;
+			});
+
+			element.rel = 'stylesheet';
+			element.href = 'test';
+			element.addEventListener('load', event => {
+				loadEvent = event;
+			});
+
+			document.body.appendChild(element);
+
+			setTimeout(() => {
+				expect(Object.keys(loadedOptions).length).toBe(2);
+				expect(loadedOptions.window).toBe(window);
+				expect(loadedOptions.url).toBe('test');
+				expect(element.sheet.cssRules.length).toBe(1);
+				expect(element.sheet.cssRules[0].cssText).toBe('div { background: red; }');
+				expect(loadEvent.target).toBe(element);
+				done();
+			}, 0);
+		});
+
+		test('Triggers error event when fetching a CSS file fails while appending the element to the document.', done => {
+			const element = <HTMLLinkElement>document.createElement('link');
+			const thrownError = new Error('error');
+			let errorEvent = null;
+
+			jest.spyOn(ResourceFetcher, 'fetch').mockImplementation(async () => {
+				throw thrownError;
+			});
+
+			element.rel = 'stylesheet';
+			element.href = 'test';
+			element.addEventListener('error', event => {
+				errorEvent = event;
+			});
+
+			document.body.appendChild(element);
+
+			setTimeout(() => {
+				expect(errorEvent.error).toEqual(thrownError);
+				expect(errorEvent.message).toEqual('error');
+				done();
+			}, 0);
+		});
+
+		test('Does not load external scripts when "href" attribute has been set if the element is not connected to DOM.', () => {
+			const element = <HTMLLinkElement>document.createElement('link');
+			const css = 'div { background: red; }';
+			let loadedOptions = null;
+
+			jest.spyOn(ResourceFetcher, 'fetch').mockImplementation(async options => {
+				loadedOptions = options;
+				return css;
+			});
+
+			element.rel = 'stylesheet';
+			element.href = 'test';
+
+			expect(loadedOptions).toBe(null);
+			expect(element.sheet).toBe(null);
+		});
+	});
+});
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 6a88e5b1..fbc6c94b 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
@@ -69,35 +69,32 @@ describe('HTMLScriptElement', () => {
 
 		test('Loads and evaluates an external script when the attribute "src" is set and the element is connected to DOM.', () => {
 			const element = <HTMLScriptElement>document.createElement('script');
-			let loadedOptions = null;
+			let loadedElement = null;
 
-			jest
-				.spyOn(ScriptUtility, 'loadExternalScript')
-				.mockImplementation(options => (loadedOptions = options));
+			jest.spyOn(ScriptUtility, 'loadExternalScript').mockImplementation(async element => {
+				loadedElement = element;
+			});
 
 			document.body.appendChild(element);
 
 			element.async = true;
 			element.src = 'test';
 
-			expect(Object.keys(loadedOptions).length).toBe(3);
-			expect(loadedOptions.window).toBe(window);
-			expect(loadedOptions.url).toBe('test');
-			expect(loadedOptions.async).toBe(true);
+			expect(loadedElement).toBe(element);
 		});
 
 		test('Does not evaluate script if the element is not connected to DOM.', () => {
 			const element = <HTMLScriptElement>document.createElement('script');
-			let loadedOptions = null;
+			let loadedElement = null;
 
-			jest
-				.spyOn(ScriptUtility, 'loadExternalScript')
-				.mockImplementation(options => (loadedOptions = options));
+			jest.spyOn(ScriptUtility, 'loadExternalScript').mockImplementation(async element => {
+				loadedElement = element;
+			});
 
 			element.async = true;
 			element.src = 'test';
 
-			expect(loadedOptions).toBe(null);
+			expect(loadedElement).toBe(null);
 		});
 	});
 
@@ -181,11 +178,11 @@ describe('HTMLScriptElement', () => {
 
 		test('Loads and evaluates an external script when "src" attribute has been set, but does not evaluate text content.', () => {
 			const element = <HTMLScriptElement>document.createElement('script');
-			let loadedOptions = null;
+			let loadedElement = null;
 
-			jest
-				.spyOn(ScriptUtility, 'loadExternalScript')
-				.mockImplementation(options => (loadedOptions = options));
+			jest.spyOn(ScriptUtility, 'loadExternalScript').mockImplementation(async element => {
+				loadedElement = element;
+			});
 
 			element.src = 'test';
 			element.text = 'global.test = "test";';
@@ -193,25 +190,22 @@ describe('HTMLScriptElement', () => {
 			document.body.appendChild(element);
 
 			expect(global['test']).toBe(undefined);
-			expect(Object.keys(loadedOptions).length).toBe(3);
-			expect(loadedOptions.window).toBe(window);
-			expect(loadedOptions.url).toBe('test');
-			expect(loadedOptions.async).toBe(false);
+			expect(loadedElement).toBe(element);
 		});
 
 		test('Does not load external scripts when "src" attribute has been set if the element is not connected to DOM.', () => {
 			const element = <HTMLScriptElement>document.createElement('script');
-			let loadedOptions = null;
+			let loadedElement = null;
 
-			jest
-				.spyOn(ScriptUtility, 'loadExternalScript')
-				.mockImplementation(options => (loadedOptions = options));
+			jest.spyOn(ScriptUtility, 'loadExternalScript').mockImplementation(async element => {
+				loadedElement = element;
+			});
 
 			element.src = 'test';
 			element.text = 'global.test = "test";';
 
 			expect(global['test']).toBe(undefined);
-			expect(loadedOptions).toBe(null);
+			expect(loadedElement).toBe(null);
 		});
 	});
 });
diff --git a/packages/happy-dom/test/nodes/html-script-element/ScriptUtility.test.ts b/packages/happy-dom/test/nodes/html-script-element/ScriptUtility.test.ts
index 9c6c20ab..bd1687ad 100644
--- a/packages/happy-dom/test/nodes/html-script-element/ScriptUtility.test.ts
+++ b/packages/happy-dom/test/nodes/html-script-element/ScriptUtility.test.ts
@@ -1,6 +1,8 @@
 import Window from '../../../src/window/Window';
 import ScriptUtility from '../../../src/nodes/html-script-element/ScriptUtility';
 import IResponse from '../../../src/window/IResponse';
+import HTMLScriptElement from '../../../src/nodes/html-script-element/HTMLScriptElement';
+import ResourceFetcher from '../../../src/fetch/ResourceFetcher';
 
 describe('ScriptUtility', () => {
 	let window: Window;
@@ -16,47 +18,106 @@ describe('ScriptUtility', () => {
 	describe('loadExternalScript()', () => {
 		test('Loads external script asynchronously.', async () => {
 			let fetchedURL = null;
+			let loadEvent = null;
 
 			jest.spyOn(window, 'fetch').mockImplementation(url => {
 				fetchedURL = url;
 				return Promise.resolve(<IResponse>{
-					text: () => Promise.resolve('global.test = "test";')
+					text: () => Promise.resolve('global.test = "test";'),
+					ok: true
 				});
 			});
 
-			ScriptUtility.loadExternalScript({ window, url: 'path/to/script/', async: true });
-
-			expect(fetchedURL).toBe('path/to/script/');
+			const script = <HTMLScriptElement>window.document.createElement('script');
+			script.src = 'path/to/script/';
+			script.async = true;
+			script.addEventListener('load', event => {
+				loadEvent = event;
+			});
 
-			await window.happyDOM.whenAsyncComplete();
+			await ScriptUtility.loadExternalScript(script);
 
+			expect(loadEvent.target).toBe(script);
+			expect(fetchedURL).toBe('path/to/script/');
 			expect(global['test']).toBe('test');
 
 			delete global['test'];
 		});
 
-		test('Loads external script synchronously with relative URL.', () => {
-			let fetchedMethod = null;
-			let fetchedURL = null;
+		test('Triggers error event when loading external script asynchronously.', async () => {
+			let errorEvent = null;
+
+			jest.spyOn(window, 'fetch').mockImplementation(() => {
+				return Promise.resolve(<IResponse>{
+					text: () => null,
+					ok: false,
+					status: 404
+				});
+			});
+
+			const script = <HTMLScriptElement>window.document.createElement('script');
+			script.src = 'path/to/script/';
+			script.async = true;
+			script.addEventListener('error', event => {
+				errorEvent = event;
+			});
+
+			await ScriptUtility.loadExternalScript(script);
+
+			expect(errorEvent.message).toBe(
+				'Failed to perform request to "path/to/script/". Status code: 404'
+			);
+		});
+
+		test('Loads external script synchronously with relative URL.', async () => {
+			let fetchedOptions = null;
+			let loadEvent = null;
+
 			window.location.href = 'https://localhost:8080/base/';
 
-			jest.mock('sync-request', () => {
-				return (method: string, url: string) => {
-					fetchedMethod = method;
-					fetchedURL = url;
-					return {
-						getBody: () => 'global.test = "test";'
-					};
-				};
+			jest.spyOn(ResourceFetcher, 'fetchSync').mockImplementation(options => {
+				fetchedOptions = options;
+				return 'global.test = "test";';
+			});
+
+			const script = <HTMLScriptElement>window.document.createElement('script');
+			script.src = 'path/to/script/';
+			script.addEventListener('load', event => {
+				loadEvent = event;
 			});
 
-			ScriptUtility.loadExternalScript({ window, url: 'path/to/script/', async: false });
+			await ScriptUtility.loadExternalScript(script);
 
-			expect(fetchedMethod).toBe('GET');
-			expect(fetchedURL).toBe('https://localhost:8080/base/path/to/script/');
+			expect(loadEvent.target).toBe(script);
+			expect(fetchedOptions).toEqual({
+				window,
+				url: 'path/to/script/'
+			});
 			expect(global['test']).toBe('test');
 
 			delete global['test'];
 		});
+
+		test('Triggers error event when loading external script synchronously with relative URL.', () => {
+			const thrownError = new Error('error');
+			let errorEvent = null;
+
+			window.location.href = 'https://localhost:8080/base/';
+
+			jest.spyOn(ResourceFetcher, 'fetchSync').mockImplementation(() => {
+				throw thrownError;
+			});
+
+			const script = <HTMLScriptElement>window.document.createElement('script');
+			script.src = 'path/to/script/';
+			script.addEventListener('error', event => {
+				errorEvent = event;
+			});
+
+			ScriptUtility.loadExternalScript(script);
+
+			expect(errorEvent.message).toBe('error');
+			expect(errorEvent.error).toBe(thrownError);
+		});
 	});
 });
diff --git a/packages/happy-dom/test/nodes/html-style-element/HTMLStyleElement.test.ts b/packages/happy-dom/test/nodes/html-style-element/HTMLStyleElement.test.ts
new file mode 100644
index 00000000..b76b893f
--- /dev/null
+++ b/packages/happy-dom/test/nodes/html-style-element/HTMLStyleElement.test.ts
@@ -0,0 +1,67 @@
+import Window from '../../../src/window/Window';
+import Document from '../../../src/nodes/document/Document';
+import HTMLStyleElement from '../../../src/nodes/html-style-element/HTMLStyleElement';
+
+describe('HTMLStyleElement', () => {
+	let window: Window;
+	let document: Document;
+	let element: HTMLStyleElement;
+
+	beforeEach(() => {
+		window = new Window();
+		document = window.document;
+		element = <HTMLStyleElement>document.createElement('style');
+	});
+
+	afterEach(() => {
+		jest.restoreAllMocks();
+	});
+
+	for (const property of ['media', 'type']) {
+		describe(`get ${property}()`, () => {
+			test(`Returns the "${property}" attribute.`, () => {
+				element.setAttribute(property, 'test');
+				expect(element[property]).toBe('test');
+			});
+		});
+
+		describe(`set ${property}()`, () => {
+			test(`Sets the attribute "${property}".`, () => {
+				element[property] = 'test';
+				expect(element.getAttribute(property)).toBe('test');
+			});
+		});
+	}
+
+	describe(`get disabled()`, () => {
+		test('Returns attribute value.', () => {
+			expect(element.disabled).toBe(false);
+			element.setAttribute('disabled', '');
+			expect(element.disabled).toBe(true);
+		});
+	});
+
+	describe(`set disabled()`, () => {
+		test('Sets attribute value.', () => {
+			element.disabled = true;
+			expect(element.getAttribute('disabled')).toBe('');
+		});
+	});
+
+	describe(`get sheet()`, () => {
+		test('Returns "null" if not connected to DOM.', () => {
+			expect(element.sheet).toBe(null);
+		});
+
+		test('Returns an CSSStyleSheet instance with its text content as style rules.', () => {
+			const textNode = document.createTextNode(
+				'body { background-color: red }\ndiv { background-color: green }'
+			);
+			element.appendChild(textNode);
+			document.head.appendChild(element);
+			expect(element.sheet.cssRules.length).toBe(2);
+			expect(element.sheet.cssRules[0].cssText).toBe('body { background-color: red; }');
+			expect(element.sheet.cssRules[1].cssText).toBe('div { background-color: green; }');
+		});
+	});
+});
diff --git a/packages/happy-dom/test/window/Window.test.ts b/packages/happy-dom/test/window/Window.test.ts
index c2674ea7..10c6eb5e 100644
--- a/packages/happy-dom/test/window/Window.test.ts
+++ b/packages/happy-dom/test/window/Window.test.ts
@@ -1,10 +1,37 @@
+import { HTMLElement } from '../../src';
 import CSSStyleDeclaration from '../../src/css/CSSStyleDeclaration';
+import IFetchOptions from '../../src/window/IFetchOptions';
+import IResponse from '../../src/window/IResponse';
 import Window from '../../src/window/Window';
 
 describe('Window', () => {
-	let window;
+	let window: Window;
+	let fetchedUrl: string;
+	let fetchedOptions: IFetchOptions;
+	let fetchResponseBody: string;
+	let fetchError: Error;
+
+	beforeAll(() => {
+		jest.mock('node-fetch', () => (url: string, options: IFetchOptions) => {
+			fetchedUrl = url;
+			fetchedOptions = options;
+			if (fetchError) {
+				return Promise.reject(fetchError);
+			}
+			return <Promise<IResponse>>Promise.resolve({
+				text: () => Promise.resolve(fetchResponseBody),
+				json: () => Promise.resolve(JSON.parse(fetchResponseBody)),
+				blob: () => Promise.resolve(null),
+				formData: () => Promise.resolve(null)
+			});
+		});
+	});
 
 	beforeEach(() => {
+		fetchedUrl = null;
+		fetchedOptions = null;
+		fetchResponseBody = null;
+		fetchError = null;
 		window = new Window();
 	});
 
@@ -14,7 +41,7 @@ describe('Window', () => {
 
 	describe('getComputedStyle()', () => {
 		test('Returns a CSSStyleDeclaration object with computed styles that are live updated whenever the element styles are changed.', () => {
-			const element = window.document.createElement('div');
+			const element = <HTMLElement>window.document.createElement('div');
 			const computedStyle = window.getComputedStyle(element);
 
 			element.style.direction = 'rtl';
@@ -85,63 +112,62 @@ describe('Window', () => {
 
 	describe('fetch()', () => {
 		test('Handles successful JSON request.', async () => {
-			const expectedUrl = '/url/';
+			const expectedUrl = 'https://localhost:8080/path/';
 			const expectedOptions = {};
-			const expectedJsonResponse = {};
-			jest.spyOn(window, 'fetch').mockImplementation((url, options) => {
-				expect(url).toBe(expectedUrl);
-				expect(options).toBe(expectedOptions);
-				return Promise.resolve({
-					json: () => Promise.resolve(expectedJsonResponse)
-				});
-			});
+
+			fetchResponseBody = '{}';
+
 			const response = await window.fetch(expectedUrl, expectedOptions);
 			const jsonResponse = await response.json();
-			expect(jsonResponse).toBe(expectedJsonResponse);
+
+			expect(fetchedUrl).toBe(expectedUrl);
+			expect(fetchedOptions).toBe(expectedOptions);
+			expect(jsonResponse).toEqual({});
 		});
 
 		test('Handles successful Text request.', async () => {
-			const expectedUrl = '/url/';
+			const expectedUrl = 'https://localhost:8080/path/';
 			const expectedOptions = {};
-			const expectedTextResponse = 'response';
-			jest.spyOn(window, 'fetch').mockImplementation((url, options) => {
-				expect(url).toBe(expectedUrl);
-				expect(options).toBe(expectedOptions);
-				return Promise.resolve({
-					text: () => Promise.resolve(expectedTextResponse)
-				});
-			});
+
+			fetchResponseBody = 'text';
+
 			const response = await window.fetch(expectedUrl, expectedOptions);
 			const textResponse = await response.text();
-			expect(textResponse).toBe(expectedTextResponse);
+
+			expect(fetchedUrl).toBe(expectedUrl);
+			expect(fetchedOptions).toBe(expectedOptions);
+			expect(textResponse).toEqual(fetchResponseBody);
 		});
 
-		test('Handles error JSON request.', async () => {
-			const expectedUrl = '/url/';
+		test('Handles relative URL.', async () => {
+			const expectedPath = '/path/';
 			const expectedOptions = {};
-			const expectedError = new Error('error');
-			jest.spyOn(window, 'fetch').mockImplementation((url, options) => {
-				expect(url).toBe(expectedUrl);
-				expect(options).toBe(expectedOptions);
-				return Promise.reject(expectedError);
-			});
+
+			window.location.href = 'https://localhost:8080';
+
+			fetchResponseBody = 'text';
+
+			const response = await window.fetch(expectedPath, expectedOptions);
+			const textResponse = await response.text();
+
+			expect(fetchedUrl).toBe('https://localhost:8080' + expectedPath);
+			expect(fetchedOptions).toBe(expectedOptions);
+			expect(textResponse).toEqual(fetchResponseBody);
+		});
+
+		test('Handles error JSON request.', async () => {
+			fetchError = new Error('error');
+
 			try {
-				await window.fetch(expectedUrl, expectedOptions);
+				await window.fetch('/url/', {});
 			} catch (error) {
-				expect(error).toBe(expectedError);
+				expect(error).toBe(fetchError);
 			}
 		});
 	});
 
 	describe('happyDOM.whenAsyncComplete()', () => {
 		test('Resolves the Promise returned by whenAsyncComplete() when all async tasks has been completed.', async () => {
-			jest.spyOn(window, 'fetch').mockImplementation(() => {
-				return Promise.resolve({
-					json: () => Promise.resolve({}),
-					text: () => Promise.resolve({})
-				});
-			});
-
 			let isFirstWhenAsyncCompleteCalled = false;
 			window.happyDOM.whenAsyncComplete().then(() => {
 				isFirstWhenAsyncCompleteCalled = true;
@@ -181,13 +207,6 @@ describe('Window', () => {
 
 	describe('happyDOM.cancelAsync()', () => {
 		test('Cancels all ongoing asynchrounous tasks.', done => {
-			jest.spyOn(window, 'fetch').mockImplementation(() => {
-				return Promise.resolve({
-					json: () => Promise.resolve({}),
-					text: () => Promise.resolve({})
-				});
-			});
-
 			let isFirstWhenAsyncCompleteCalled = false;
 			window.happyDOM.whenAsyncComplete().then(() => {
 				isFirstWhenAsyncCompleteCalled = true;
@@ -209,16 +228,30 @@ describe('Window', () => {
 			window.requestAnimationFrame(() => {
 				tasksDone++;
 			});
-			window.fetch('/url/').then(response =>
-				response.json().then(() => {
-					tasksDone++;
-				})
-			);
-			window.fetch('/url/').then(response =>
-				response.text().then(() => {
-					tasksDone++;
-				})
-			);
+
+			window
+				.fetch('/url/')
+				.then(response =>
+					response
+						.json()
+						.then(() => {
+							tasksDone++;
+						})
+						.catch(() => {})
+				)
+				.catch(() => {});
+
+			window
+				.fetch('/url/')
+				.then(response =>
+					response
+						.json()
+						.then(() => {
+							tasksDone++;
+						})
+						.catch(() => {})
+				)
+				.catch(() => {});
 
 			let isSecondWhenAsyncCompleteCalled = false;
 			window.happyDOM.whenAsyncComplete().then(() => {