diff --git a/package-lock.json b/package-lock.json index 06fb14826..16eb979b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -647,11 +647,11 @@ } }, "node_modules/@babel/runtime": { - "version": "7.20.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.1.tgz", - "integrity": "sha512-mrzLkl6U9YLF8qpqI7TB82PESyEGjm/0Ly91jG575eVxMMlb8fYfOXFZIJ8XfLrJZQbm7dlKry2bJmXBUEkdFg==", + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz", + "integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==", "dependencies": { - "regenerator-runtime": "^0.13.10" + "regenerator-runtime": "^0.13.11" }, "engines": { "node": ">=6.9.0" @@ -5709,9 +5709,9 @@ "dev": true }, "node_modules/decode-uri-component": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", - "integrity": "sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", "engines": { "node": ">=0.10" } @@ -13821,7 +13821,8 @@ "node_modules/sourcemap-codec": { "version": "1.4.8", "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==" + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead" }, "node_modules/spdx-correct": { "version": "3.1.1", @@ -15931,11 +15932,11 @@ } }, "@babel/runtime": { - "version": "7.20.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.1.tgz", - "integrity": "sha512-mrzLkl6U9YLF8qpqI7TB82PESyEGjm/0Ly91jG575eVxMMlb8fYfOXFZIJ8XfLrJZQbm7dlKry2bJmXBUEkdFg==", + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz", + "integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==", "requires": { - "regenerator-runtime": "^0.13.10" + "regenerator-runtime": "^0.13.11" } }, "@babel/template": { @@ -19918,9 +19919,9 @@ "dev": true }, "decode-uri-component": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", - "integrity": "sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og==" + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==" }, "dedent": { "version": "0.7.0", diff --git a/packages/happy-dom/README.md b/packages/happy-dom/README.md index 0e9f937c0..af769ea46 100644 --- a/packages/happy-dom/README.md +++ b/packages/happy-dom/README.md @@ -1,6 +1,5 @@ ![Happy DOM Logo](https://github.com/capricorn86/happy-dom/raw/master/docs/happy-dom-logo.jpg) - # About [Happy DOM](https://github.com/capricorn86/happy-dom) is a JavaScript implementation of a web browser without its graphical user interface. It includes many web standards from WHATWG [DOM](https://dom.spec.whatwg.org/) and [HTML](https://html.spec.whatwg.org/multipage/). @@ -9,7 +8,6 @@ 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). - ### DOM Features - Custom Elements (Web Components) @@ -26,8 +24,6 @@ The goal of [Happy DOM](https://github.com/capricorn86/happy-dom) is to emulate And much more.. - - ### Works With - [Google LitHTML](https://lit-html.polymer-project.org) @@ -40,20 +36,14 @@ And much more.. - [Vue](https://vuejs.org/) - - # Installation ```bash npm install happy-dom ``` - - # Usage - - ## Basic Usage A simple example of how you can use Happy DOM. @@ -75,8 +65,6 @@ container.appendChild(button); console.log(document.body.innerHTML); ``` - - ## VM Context The default Window class is a [VM context](https://nodejs.org/api/vm.html#vm_vm_createcontext_sandbox_options). A [VM context](https://nodejs.org/api/vm.html#vm_vm_createcontext_sandbox_options) will execute JavaScript code scoped within the context where the Window instance will be the global object. @@ -85,9 +73,9 @@ The default Window class is a [VM context](https://nodejs.org/api/vm.html#vm_vm_ import { Window } from 'happy-dom'; const window = new Window({ - innerWidth: 1024, - innerHeight: 768, - url: 'http://localhost:8080' + innerWidth: 1024, + innerHeight: 768, + url: 'http://localhost:8080' }); const document = window.document; @@ -146,9 +134,9 @@ The example below will show you how to setup a Node [VM context](https://nodejs. import { Window } from 'happy-dom'; const window = new Window({ - innerWidth: 1024, - innerHeight: 768, - url: 'http://localhost:8080' + innerWidth: 1024, + innerHeight: 768, + url: 'http://localhost:8080' }); const document = window.document; @@ -207,19 +195,15 @@ Will output: console.log(document.body.querySelector('div').getInnerHTML({ includeShadowRoots: true })); ``` - - ## Additional Features -Happy DOM exposes two functions that may be useful when working with asynchrounous code. - **whenAsyncComplete()** Returns a [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) that is resolved when all async tasks has been completed. ```javascript window.happyDOM.whenAsyncComplete().then(() => { - // Do something when all async tasks are completed. + // Do something when all async tasks are completed. }); ``` @@ -229,7 +213,7 @@ This method will cancel all running async tasks. ```javascript window.setTimeout(() => { - // This timeout will be canceled + // This timeout will be canceled }); window.happyDOM.cancelAsync(); ``` @@ -250,7 +234,60 @@ Sets the property `window.innerHeight` and dispatches a "resize" event. window.happyDOM.setInnerHeight(768); ``` +**setURL()** + +Sets the property `window.location.href`. + +```javascript +window.happyDOM.setURL('https://localhost:3000'); +``` + +## Settings + +Settings can be sent to the constructor or by setting them on the "window.happyDOM.settings" property. + +Set by constructor: + +```javascript +const window = new Window({ + innerWidth: 1024, + innerHeight: 768, + url: 'https://localhost:8080', + settings: { + disableJavaScriptFileLoading: true, + disableJavaScriptEvaluation: true, + disableCSSFileLoading: true, + enableFileSystemHttpRequests: true + } +}); +``` + +Set by property: + +```javascript +const window = new Window(); + +window.happyDOM.settings.disableJavaScriptFileLoading = true; +window.happyDOM.settings.disableJavaScriptEvaluation = true; +window.happyDOM.settings.disableCSSFileLoading = true; +window.happyDOM.settings.enableFileSystemHttpRequests = true; +``` + +**disableJavaScriptFileLoading** + +Set it to "true" to disable JavaScript file loading. Defaults to "false". + +**disableJavaScriptEvaluation** + +Set it to "true" to completely disable JavaScript evaluation. Defaults to "false". +**disableCSSFileLoading** + +Set it to "true" to disable CSS file loading using the HTMLLinkElement. Defaults to "false". + +**enableFileSystemHttpRequests** + +Set it to "true" to enable file system HTTP requests using XMLHttpRequest. Defaults to "false". # Performance @@ -268,12 +305,10 @@ window.happyDOM.setInnerHeight(768); [See how the test was done here](https://github.com/capricorn86/happy-dom-performance-test) - - # Jest Happy DOM provide with a package called [@happy-dom/jest-environment](https://github.com/capricorn86/happy-dom/tree/master/packages/jest-environment) that makes it possible to use Happy DOM with [Jest](https://jestjs.io/). # Global Registration -Happy DOM provide with a package called [@happy-dom/global-registrator](https://github.com/capricorn86/happy-dom/tree/master/packages/global-registrator) that can register Happy DOM globally. It makes it possible to use Happy DOM for testing in a Node environment. \ No newline at end of file +Happy DOM provide with a package called [@happy-dom/global-registrator](https://github.com/capricorn86/happy-dom/tree/master/packages/global-registrator) that can register Happy DOM globally. It makes it possible to use Happy DOM for testing in a Node environment. diff --git a/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts b/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts index 2c2917a78..c7751831a 100644 --- a/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts +++ b/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts @@ -3,7 +3,7 @@ */ export default class AsyncTaskManager { private static taskID = 0; - private runningTasks: number[] = []; + private runningTasks: { [k: string]: () => void } = {}; private runningTimers: NodeJS.Timeout[] = []; private queue: { resolve: () => void; reject: (error: Error) => void }[] = []; @@ -24,21 +24,27 @@ export default class AsyncTaskManager { } /** - * Cancels all tasks. + * Ends all tasks. * * @param [error] Error. */ public cancelAll(error?: Error): void { - for (const timerID of this.runningTimers) { - global.clearTimeout(timerID); - } - + const runningTimers = this.runningTimers; + const runningTasks = this.runningTasks; const promises = this.queue; - this.runningTasks = []; + this.runningTasks = {}; this.runningTimers = []; this.queue = []; + for (const timer of runningTimers) { + global.clearTimeout(timer); + } + + for (const key of Object.keys(runningTasks)) { + runningTasks[key](); + } + for (const promise of promises) { if (error) { promise.reject(error); @@ -67,7 +73,7 @@ export default class AsyncTaskManager { if (index !== -1) { this.runningTimers.splice(index, 1); } - if (!this.runningTasks.length && !this.runningTimers.length) { + if (!Object.keys(this.runningTasks).length && !this.runningTimers.length) { this.cancelAll(); } } @@ -75,11 +81,12 @@ export default class AsyncTaskManager { /** * Starts an async task. * + * @param abortHandler Abort handler. * @returns Task ID. */ - public startTask(): number { + public startTask(abortHandler?: () => void): number { const taskID = this.newTaskID(); - this.runningTasks.push(taskID); + this.runningTasks[taskID] = abortHandler ? abortHandler : () => {}; return taskID; } @@ -89,12 +96,12 @@ export default class AsyncTaskManager { * @param taskID Task ID. */ public endTask(taskID: number): void { - const index = this.runningTasks.indexOf(taskID); - if (index !== -1) { - this.runningTasks.splice(index, 1); - } - if (!this.runningTasks.length && !this.runningTimers.length) { - this.cancelAll(); + if (this.runningTasks[taskID]) { + delete this.runningTasks[taskID]; + + if (!Object.keys(this.runningTasks).length && !this.runningTimers.length) { + this.cancelAll(); + } } } @@ -104,16 +111,7 @@ export default class AsyncTaskManager { * @returns Count. */ public getTaskCount(): number { - return this.runningTasks.length; - } - - /** - * Returns the amount of running timers. - * - * @returns Count. - */ - public getTimerCount(): number { - return this.runningTimers.length; + return Object.keys(this.runningTasks).length; } /** diff --git a/packages/happy-dom/src/event/IEventListener.ts b/packages/happy-dom/src/event/IEventListener.ts index b731998d7..0423fec60 100644 --- a/packages/happy-dom/src/event/IEventListener.ts +++ b/packages/happy-dom/src/event/IEventListener.ts @@ -7,7 +7,7 @@ export default interface IEventListener { /** * Handles event. * - * @param type Event type. + * @param event Event. */ handleEvent(event: Event): void; } diff --git a/packages/happy-dom/src/exception/DOMExceptionNameEnum.ts b/packages/happy-dom/src/exception/DOMExceptionNameEnum.ts index 08aa5cb5c..0dacbb4c0 100644 --- a/packages/happy-dom/src/exception/DOMExceptionNameEnum.ts +++ b/packages/happy-dom/src/exception/DOMExceptionNameEnum.ts @@ -8,6 +8,9 @@ enum DOMExceptionNameEnum { invalidNodeTypeError = 'InvalidNodeTypeError', invalidCharacterError = 'InvalidCharacterError', notFoundError = 'NotFoundError', - domException = 'DOMException' + securityError = 'SecurityError', + networkError = 'NetworkError', + domException = 'DOMException', + invalidAccessError = 'InvalidAccessError' } export default DOMExceptionNameEnum; diff --git a/packages/happy-dom/src/fetch/FetchHandler.ts b/packages/happy-dom/src/fetch/FetchHandler.ts index 81fe86b57..733be38ea 100644 --- a/packages/happy-dom/src/fetch/FetchHandler.ts +++ b/packages/happy-dom/src/fetch/FetchHandler.ts @@ -4,27 +4,63 @@ import IDocument from '../nodes/document/IDocument'; import IResponse from './IResponse'; import Response from './Response'; import NodeFetch from 'node-fetch'; +import Request from './Request'; +import RequestInfo from './RequestInfo'; +import { URL } from 'url'; /** * Helper class for performing fetch. */ export default class FetchHandler { /** - * Returns resource data asynchonously. + * Returns resource data asynchronously. * * @param document Document. * @param url URL to resource. * @param [init] Init. * @returns Response. */ - public static fetch(document: IDocument, url: string, init?: IRequestInit): Promise { - // We want to only load NodeFetch when it is needed to improve performance and not have direct dependencies to server side packages. + public static fetch( + document: IDocument, + url: RequestInfo, + init?: IRequestInit + ): Promise { const taskManager = document.defaultView.happyDOM.asyncTaskManager; + const requestInit = { ...init, headers: { ...init?.headers } }; + const cookie = document.defaultView.document.cookie; + const referer = document.defaultView.location.origin; + + requestInit.headers['user-agent'] = document.defaultView.navigator.userAgent; + + // We need set referer to solve anti-hotlinking. + // And the browser will set the referer to the origin of the page. + // Referer is "null" when the URL is set to "about:blank". + // This is also how the browser behaves. + if (referer !== 'null') { + requestInit.headers['referer'] = referer; + } + + if (cookie) { + requestInit.headers['set-cookie'] = cookie; + } + + let request; + + if (typeof url === 'string') { + request = new Request(RelativeURL.getAbsoluteURL(document.defaultView.location, url)); + } else if (url instanceof URL) { + // URLs are always absolute, no need for getAbsoluteURL. + request = new Request(url); + } else { + request = new Request(RelativeURL.getAbsoluteURL(document.defaultView.location, url.url), { + ...url + }); + } return new Promise((resolve, reject) => { const taskID = taskManager.startTask(); - NodeFetch(RelativeURL.getAbsoluteURL(document.defaultView.location, url), init) + NodeFetch(request, requestInit) .then((response) => { if (taskManager.getTaskCount() === 0) { reject(new Error('Failed to complete fetch request. Task was canceled.')); diff --git a/packages/happy-dom/src/fetch/RequestInfo.ts b/packages/happy-dom/src/fetch/RequestInfo.ts new file mode 100644 index 000000000..f8d99f25b --- /dev/null +++ b/packages/happy-dom/src/fetch/RequestInfo.ts @@ -0,0 +1,6 @@ +import { URL } from 'url'; +import IRequest from './IRequest'; + +type RequestInfo = IRequest | string | URL; + +export default RequestInfo; diff --git a/packages/happy-dom/src/fetch/ResourceFetchHandler.ts b/packages/happy-dom/src/fetch/ResourceFetchHandler.ts index 41c2a1c92..a5db65844 100644 --- a/packages/happy-dom/src/fetch/ResourceFetchHandler.ts +++ b/packages/happy-dom/src/fetch/ResourceFetchHandler.ts @@ -7,7 +7,7 @@ import IDocument from '../nodes/document/IDocument'; */ export default class ResourceFetchHandler { /** - * Returns resource data asynchonously. + * Returns resource data asynchronously. * * @param document Document. * @param url URL. @@ -24,7 +24,7 @@ export default class ResourceFetchHandler { } /** - * Returns resource data synchonously. + * Returns resource data synchronously. * * @param document Document. * @param url URL. @@ -32,16 +32,18 @@ export default class ResourceFetchHandler { */ public static fetchSync(document: IDocument, url: string): string { // We want to only load SyncRequest when it is needed to improve performance and not have direct dependencies to server side packages. - const absoluteURL = RelativeURL.getAbsoluteURL(document.defaultView.location, url); - const syncRequest = require('sync-request'); - const response = syncRequest('GET', absoluteURL); + const absoluteURL = RelativeURL.getAbsoluteURL(document.defaultView.location, url).href; - if (response.isError()) { + const xhr = new document.defaultView.XMLHttpRequest(); + xhr.open('GET', absoluteURL, false); + xhr.send(); + + if (xhr.status !== 200) { throw new DOMException( - `Failed to perform request to "${absoluteURL}". Status code: ${response.statusCode}` + `Failed to perform request to "${absoluteURL}". Status code: ${xhr.status}` ); } - return response.getBody().toString(); + return xhr.responseText; } } diff --git a/packages/happy-dom/src/index.ts b/packages/happy-dom/src/index.ts index e11bc7ade..ddd49e8e9 100644 --- a/packages/happy-dom/src/index.ts +++ b/packages/happy-dom/src/index.ts @@ -4,7 +4,7 @@ import Window from './window/Window'; import DataTransfer from './event/DataTransfer'; import DataTransferItem from './event/DataTransferItem'; import DataTransferItemList from './event/DataTransferItemList'; -import URL from './location/URL'; +import { URL, URLSearchParams } from 'url'; import Location from './location/Location'; import MutationObserver from './mutation-observer/MutationObserver'; import ResizeObserver from './resize-observer/ResizeObserver'; @@ -115,7 +115,6 @@ import CSSMediaRule from './css/rules/CSSMediaRule'; import CSSStyleRule from './css/rules/CSSStyleRule'; import Storage from './storage/Storage'; import DOMRect from './nodes/element/DOMRect'; -import { URLSearchParams } from 'url'; import Selection from './selection/Selection'; import Range from './range/Range'; import HTMLDialogElement from './nodes/html-dialog-element/HTMLDialogElement'; diff --git a/packages/happy-dom/src/location/Location.ts b/packages/happy-dom/src/location/Location.ts index 4d6599e8d..05898745b 100644 --- a/packages/happy-dom/src/location/Location.ts +++ b/packages/happy-dom/src/location/Location.ts @@ -1,4 +1,4 @@ -import URL from './URL'; +import { URL } from 'url'; /** * @@ -8,7 +8,7 @@ export default class Location extends URL { * Constructor. */ constructor() { - super(''); + super('about:blank'); } /** @@ -17,7 +17,7 @@ export default class Location extends URL { * @param url URL. */ public replace(url: string): void { - this.parse(url); + this.href = url; } /** diff --git a/packages/happy-dom/src/location/RelativeURL.ts b/packages/happy-dom/src/location/RelativeURL.ts index b60ff1c99..3308685c2 100644 --- a/packages/happy-dom/src/location/RelativeURL.ts +++ b/packages/happy-dom/src/location/RelativeURL.ts @@ -1,4 +1,5 @@ import Location from './Location'; +import { URL } from 'url'; /** * Helper class for getting the URL relative to a Location object. @@ -10,19 +11,7 @@ export default class RelativeURL { * @param location Location. * @param url URL. */ - public static getAbsoluteURL(location: Location, url: string): string { - if (url.startsWith('/')) { - return location.origin + url; - } - - if (!url.startsWith('https://') && !url.startsWith('http://')) { - let pathname = location.pathname; - if (pathname.endsWith('/')) { - pathname = pathname.slice(0, -1); - } - return location.origin + pathname + '/' + url; - } - - return url; + public static getAbsoluteURL(location: Location, url: string): URL { + return new URL(url, location.href); } } diff --git a/packages/happy-dom/src/location/URL.ts b/packages/happy-dom/src/location/URL.ts deleted file mode 100644 index e66e4317f..000000000 --- a/packages/happy-dom/src/location/URL.ts +++ /dev/null @@ -1,102 +0,0 @@ -const URL_REGEXP = - /(https?:)\/\/([-a-zA-Z0-9@:%._\+~#=]{2,256}[a-z]{2,6})(:[0-9]*)?([-a-zA-Z0-9@:%_\+.~c&//=]*)(\?[^#]*)?(#.*)?/; -const PATH_REGEXP = /([-a-zA-Z0-9@:%_\+.~c&//=]*)(\?[^#]*)?(#.*)?/; - -/** - * - */ -export default class URL { - public protocol = ''; - public hostname = ''; - public port = ''; - public pathname = ''; - public search = ''; - public hash = ''; - public username = ''; - public password = ''; - - /** - * Constructor. - * - * @param [url] URL. - */ - constructor(url?: string) { - if (url) { - this.parse(url); - } - } - - /** - * Returns the entire URL as a string. - * - * @returns Href. - */ - public get href(): string { - const credentials = this.username ? `${this.username}:${this.password}@` : ''; - return this.protocol + '//' + credentials + this.host + this.pathname + this.search + this.hash; - } - - /** - * Sets the href. - * - * @param url URL. - */ - public set href(url: string) { - this.parse(url); - } - - /** - * Returns the origin. - * - * @returns HREF. - */ - public get origin(): string { - return this.protocol + '//' + this.host; - } - - /** - * Returns the entire URL as a string. - * - * @returns Host. - */ - public get host(): string { - return this.hostname + this.port; - } - - /** - * Returns the entire URL as a string. - */ - public toString(): string { - return this.href; - } - - /** - * Parses an URL. - * - * @param url URL. - */ - protected parse(url: string): void { - const match = url.match(URL_REGEXP); - - if (match) { - const hostnamePart = match[2] ? match[2].split('@') : ''; - const credentialsPart = hostnamePart.length > 1 ? hostnamePart[0].split(':') : null; - - this.protocol = match[1] || ''; - this.hostname = hostnamePart.length > 1 ? hostnamePart[1] : hostnamePart[0]; - this.port = match[3] || ''; - this.pathname = match[4] || '/'; - this.search = match[5] || ''; - this.hash = match[6] || ''; - this.username = credentialsPart ? credentialsPart[0] : ''; - this.password = credentialsPart ? credentialsPart[1] : ''; - } else { - const pathMatch = url.match(PATH_REGEXP); - if (pathMatch) { - this.pathname = pathMatch[1] || ''; - this.search = pathMatch[2] || ''; - this.hash = pathMatch[3] || ''; - } - } - } -} diff --git a/packages/happy-dom/src/nodes/document/Document.ts b/packages/happy-dom/src/nodes/document/Document.ts index 49a07ebcf..b7738ebe0 100644 --- a/packages/happy-dom/src/nodes/document/Document.ts +++ b/packages/happy-dom/src/nodes/document/Document.ts @@ -185,7 +185,6 @@ export default class Document extends Node implements IDocument { /** * Creates an instance of Document. * - * @param defaultView Default view. */ constructor() { super(); @@ -406,6 +405,24 @@ export default class Document extends Node implements IDocument { return this.defaultView.location.href; } + /** + * Returns URL. + * + * @returns the URL of the current document. + * */ + public get URL(): string { + return this.defaultView.location.href; + } + + /** + * Returns document URI. + * + * @returns the URL of the current document. + * */ + public get documentURI(): string { + return this.URL; + } + /** * Inserts a set of Node objects or DOMString objects after the last child of the ParentNode. DOMString objects are inserted as equivalent Text nodes. * @@ -868,7 +885,6 @@ export default class Document extends Node implements IDocument { * * @param node Node to import. * @param [deep=false] Set to "true" if the clone should be deep. - * @param Imported Node. */ public importNode(node: INode, deep = false): INode { if (!(node instanceof Node)) { diff --git a/packages/happy-dom/src/nodes/document/IDocument.ts b/packages/happy-dom/src/nodes/document/IDocument.ts index e18772d50..6e31e9ad9 100644 --- a/packages/happy-dom/src/nodes/document/IDocument.ts +++ b/packages/happy-dom/src/nodes/document/IDocument.ts @@ -39,6 +39,8 @@ export default interface IDocument extends IParentNode { readonly readyState: DocumentReadyStateEnum; readonly charset: string; readonly characterSet: string; + readonly URL: string; + readonly documentURI: string; cookie: string; // Events 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 956bba562..b04dd4d17 100644 --- a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts +++ b/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts @@ -196,7 +196,8 @@ export default class HTMLLinkElement extends HTMLElement implements IHTMLLinkEle href !== null && rel && rel.toLowerCase() === 'stylesheet' && - this.isConnected + this.isConnected && + !this.ownerDocument.defaultView.happyDOM.settings.disableCSSFileLoading ) { (this.ownerDocument)._readyStateManager.startTask(); ResourceFetchHandler.fetch(this.ownerDocument, href) @@ -255,7 +256,11 @@ export default class HTMLLinkElement extends HTMLElement implements IHTMLLinkEle super._connectToNode(parentNode); - if (isConnected !== isParentConnected && this._evaluateCSS) { + if ( + isConnected !== isParentConnected && + this._evaluateCSS && + !this.ownerDocument.defaultView.happyDOM.settings.disableCSSFileLoading + ) { const href = this.getAttributeNS(null, 'href'); const rel = this.getAttributeNS(null, 'rel'); 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 8115610a0..6affe075d 100644 --- a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +++ b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts @@ -193,7 +193,7 @@ export default class HTMLScriptElement extends HTMLElement implements IHTMLScrip if (src !== null) { ScriptUtility.loadExternalScript(this); - } else { + } else if (!this.ownerDocument.defaultView.happyDOM.settings.disableJavaScriptEvaluation) { const textContent = this.textContent; const type = this.getAttributeNS(null, 'type'); if ( 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 154801a3b..4b71f3041 100644 --- a/packages/happy-dom/src/nodes/html-script-element/ScriptUtility.ts +++ b/packages/happy-dom/src/nodes/html-script-element/ScriptUtility.ts @@ -18,6 +18,14 @@ export default class ScriptUtility { public static async loadExternalScript(element: HTMLScriptElement): Promise { const src = element.getAttributeNS(null, 'src'); const async = element.getAttributeNS(null, 'async') !== null; + + if ( + element.ownerDocument.defaultView.happyDOM.settings.disableJavaScriptFileLoading || + element.ownerDocument.defaultView.happyDOM.settings.disableJavaScriptEvaluation + ) { + return; + } + if (async) { let code = null; (element.ownerDocument)._readyStateManager.startTask(); diff --git a/packages/happy-dom/src/window/IHappyDOMSettings.ts b/packages/happy-dom/src/window/IHappyDOMSettings.ts new file mode 100644 index 000000000..cb19d869f --- /dev/null +++ b/packages/happy-dom/src/window/IHappyDOMSettings.ts @@ -0,0 +1,9 @@ +/** + * Happy DOM settings. + */ +export default interface IHappyDOMSettings { + disableJavaScriptEvaluation: boolean; + disableJavaScriptFileLoading: boolean; + disableCSSFileLoading: boolean; + enableFileSystemHttpRequests: boolean; +} diff --git a/packages/happy-dom/src/window/IWindow.ts b/packages/happy-dom/src/window/IWindow.ts index 94dee772f..fd3d72424 100644 --- a/packages/happy-dom/src/window/IWindow.ts +++ b/packages/happy-dom/src/window/IWindow.ts @@ -39,7 +39,7 @@ import KeyboardEvent from '../event/events/KeyboardEvent'; import ProgressEvent from '../event/events/ProgressEvent'; import MediaQueryListEvent from '../event/events/MediaQueryListEvent'; import EventTarget from '../event/EventTarget'; -import URL from '../location/URL'; +import { URL, URLSearchParams } from 'url'; import Location from '../location/Location'; import MutationObserver from '../mutation-observer/MutationObserver'; import DOMParser from '../dom-parser/DOMParser'; @@ -92,14 +92,18 @@ import IRequestInit from '../fetch/IRequestInit'; import IResponse from '../fetch/IResponse'; import Range from '../range/Range'; import MediaQueryList from '../match-media/MediaQueryList'; +import XMLHttpRequest from '../xml-http-request/XMLHttpRequest'; +import XMLHttpRequestUpload from '../xml-http-request/XMLHttpRequestUpload'; +import XMLHttpRequestEventTarget from '../xml-http-request/XMLHttpRequestEventTarget'; import DOMRect from '../nodes/element/DOMRect'; import Window from './Window'; import Attr from '../nodes/attr/Attr'; import NamedNodeMap from '../named-node-map/NamedNodeMap'; -import { URLSearchParams } from 'url'; import { Performance } from 'perf_hooks'; import IElement from '../nodes/element/IElement'; import ProcessingInstruction from '../nodes/processing-instruction/ProcessingInstruction'; +import IHappyDOMSettings from './IHappyDOMSettings'; +import RequestInfo from '../fetch/RequestInfo'; /** * Window without dependencies to server side specific packages. @@ -112,6 +116,8 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { asyncTaskManager: AsyncTaskManager; setInnerWidth: (width: number) => void; setInnerHeight: (height: number) => void; + setURL: (url: string) => void; + settings: IHappyDOMSettings; }; // Global classes @@ -173,6 +179,7 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { readonly DataTransferItem: typeof DataTransferItem; readonly DataTransferItemList: typeof DataTransferItemList; readonly URL: typeof URL; + readonly URLSearchParams: typeof URLSearchParams; readonly Location: typeof Location; readonly CustomElementRegistry: typeof CustomElementRegistry; readonly Window: typeof Window; @@ -186,7 +193,6 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { readonly History: typeof History; readonly Screen: typeof Screen; readonly Storage: typeof Storage; - readonly URLSearchParams: typeof URLSearchParams; readonly HTMLCollection: typeof HTMLCollection; readonly NodeList: typeof NodeList; readonly CSSUnitValue: typeof CSSUnitValue; @@ -209,6 +215,9 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { readonly Response: { new (body?: unknown | null, init?: IResponseInit): IResponse }; readonly Range: typeof Range; readonly DOMRect: typeof DOMRect; + readonly XMLHttpRequest: typeof XMLHttpRequest; + readonly XMLHttpRequestUpload: typeof XMLHttpRequestUpload; + readonly XMLHttpRequestEventTarget: typeof XMLHttpRequestEventTarget; // Events onload: (event: Event) => void; @@ -334,13 +343,13 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { * @param [init] Init. * @returns Promise. */ - fetch(url: string, init?: IRequestInit): Promise; + fetch(url: RequestInfo, init?: IRequestInit): Promise; /** * Creates a Base64-encoded ASCII string from a binary string (i.e., a string in which each character in the string is treated as a byte of binary data). * * @see https://developer.mozilla.org/en-US/docs/Web/API/btoa - * @param data Binay data. + * @param data Binary data. * @returns Base64-encoded string. */ btoa(data: unknown): string; @@ -351,7 +360,7 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { * @see https://developer.mozilla.org/en-US/docs/Web/API/atob * @see https://infra.spec.whatwg.org/#forgiving-base64-encode. * @see Https://html.spec.whatwg.org/multipage/webappapis.html#btoa. - * @param data Binay string. + * @param data Binary string. * @returns An ASCII string containing decoded data from encodedData. */ atob(data: unknown): string; diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts index 8f988f74d..ed4854f9f 100644 --- a/packages/happy-dom/src/window/Window.ts +++ b/packages/happy-dom/src/window/Window.ts @@ -40,7 +40,7 @@ import KeyboardEvent from '../event/events/KeyboardEvent'; import ProgressEvent from '../event/events/ProgressEvent'; import MediaQueryListEvent from '../event/events/MediaQueryListEvent'; import EventTarget from '../event/EventTarget'; -import URL from '../location/URL'; +import { URL, URLSearchParams } from 'url'; import Location from '../location/Location'; import NonImplementedEventTypes from '../event/NonImplementedEventTypes'; import MutationObserver from '../mutation-observer/MutationObserver'; @@ -97,7 +97,6 @@ import MimeType from '../navigator/MimeType'; import MimeTypeArray from '../navigator/MimeTypeArray'; import Plugin from '../navigator/Plugin'; import PluginArray from '../navigator/PluginArray'; -import { URLSearchParams } from 'url'; import FetchHandler from '../fetch/FetchHandler'; import { default as RangeImplementation } from '../range/Range'; import DOMRect from '../nodes/element/DOMRect'; @@ -105,12 +104,17 @@ import VMGlobalPropertyScript from './VMGlobalPropertyScript'; import * as PerfHooks from 'perf_hooks'; import VM from 'vm'; import { Buffer } from 'buffer'; +import XMLHttpRequestImplementation from '../xml-http-request/XMLHttpRequest'; +import XMLHttpRequestUpload from '../xml-http-request/XMLHttpRequestUpload'; +import XMLHttpRequestEventTarget from '../xml-http-request/XMLHttpRequestEventTarget'; import Base64 from '../base64/Base64'; import IDocument from '../nodes/document/IDocument'; import Attr from '../nodes/attr/Attr'; import NamedNodeMap from '../named-node-map/NamedNodeMap'; import IElement from '../nodes/element/IElement'; import ProcessingInstruction from '../nodes/processing-instruction/ProcessingInstruction'; +import IHappyDOMSettings from './IHappyDOMSettings'; +import RequestInfo from '../fetch/RequestInfo'; const ORIGINAL_SET_TIMEOUT = setTimeout; const ORIGINAL_CLEAR_TIMEOUT = clearTimeout; @@ -144,6 +148,15 @@ export default class Window extends EventTarget implements IWindow { (this.innerHeight) = height; this.dispatchEvent(new Event('resize')); } + }, + setURL: (url: string) => { + this.location.href = url; + }, + settings: { + disableJavaScriptEvaluation: false, + disableJavaScriptFileLoading: false, + disableCSSFileLoading: false, + enableFileSystemHttpRequests: false } }; @@ -241,6 +254,9 @@ export default class Window extends EventTarget implements IWindow { public readonly Response: { new (body?: NodeJS.ReadableStream | null, init?: IResponseInit): IResponse; }; + public readonly XMLHttpRequestUpload = XMLHttpRequestUpload; + public readonly XMLHttpRequestEventTarget = XMLHttpRequestEventTarget; + public readonly XMLHttpRequest; public readonly DOMParser; public readonly Range; public readonly FileReader; @@ -346,8 +362,14 @@ export default class Window extends EventTarget implements IWindow { * @param [options.innerWidth] Inner width. * @param [options.innerHeight] Inner height. * @param [options.url] URL. + * @param [options.settings] Settings. */ - constructor(options?: { innerWidth?: number; innerHeight?: number; url?: string }) { + constructor(options?: { + innerWidth?: number; + innerHeight?: number; + url?: string; + settings?: IHappyDOMSettings; + }) { super(); this.customElements = new CustomElementRegistry(); @@ -365,6 +387,10 @@ export default class Window extends EventTarget implements IWindow { this.location.href = options.url; } + if (options?.settings) { + this.happyDOM.settings = Object.assign(this.happyDOM.settings, options.settings); + } + this._setTimeout = ORIGINAL_SET_TIMEOUT; this._clearTimeout = ORIGINAL_CLEAR_TIMEOUT; this._setInterval = ORIGINAL_SET_INTERVAL; @@ -413,6 +439,7 @@ export default class Window extends EventTarget implements IWindow { FileReaderImplementation._ownerDocument = document; DOMParserImplementation._ownerDocument = document; RangeImplementation._ownerDocument = document; + XMLHttpRequestImplementation._ownerDocument = document; /* eslint-disable jsdoc/require-jsdoc */ class Response extends ResponseImplementation { @@ -430,10 +457,13 @@ export default class Window extends EventTarget implements IWindow { class DOMParser extends DOMParserImplementation { public static _ownerDocument: IDocument = document; } - class Range extends RangeImplementation { + class XMLHttpRequest extends XMLHttpRequestImplementation { public static _ownerDocument: IDocument = document; } + class Range extends RangeImplementation { + public static _ownerDocument: IDocument = document; + } /* eslint-enable jsdoc/require-jsdoc */ this.Response = Response; @@ -441,6 +471,7 @@ export default class Window extends EventTarget implements IWindow { this.Image = Image; this.FileReader = FileReader; this.DOMParser = DOMParser; + this.XMLHttpRequest = XMLHttpRequest; this.Range = Range; this._setupVMContext(); @@ -629,7 +660,7 @@ export default class Window extends EventTarget implements IWindow { * @param [init] Init. * @returns Promise. */ - public async fetch(url: string, init?: IRequestInit): Promise { + public async fetch(url: RequestInfo, init?: IRequestInit): Promise { return await FetchHandler.fetch(this.document, url, init); } diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts new file mode 100644 index 000000000..9fc13d972 --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -0,0 +1,998 @@ +import FS from 'fs'; +import ChildProcess from 'child_process'; +import HTTP from 'http'; +import HTTPS from 'https'; +import XMLHttpRequestEventTarget from './XMLHttpRequestEventTarget'; +import XMLHttpRequestReadyStateEnum from './XMLHttpRequestReadyStateEnum'; +import Event from '../event/Event'; +import IDocument from '../nodes/document/IDocument'; +import Blob from '../file/Blob'; +import RelativeURL from '../location/RelativeURL'; +import XMLHttpRequestUpload from './XMLHttpRequestUpload'; +import DOMException from '../exception/DOMException'; +import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum'; +import { UrlObject } from 'url'; +import XMLHttpRequestURLUtility from './utilities/XMLHttpRequestURLUtility'; +import ProgressEvent from '../event/events/ProgressEvent'; +import XMLHttpResponseTypeEnum from './XMLHttpResponseTypeEnum'; +import XMLHttpRequestCertificate from './XMLHttpRequestCertificate'; +import XMLHttpRequestSyncRequestScriptBuilder from './utilities/XMLHttpRequestSyncRequestScriptBuilder'; + +// These headers are not user setable. +// The following are allowed but banned in the spec: +// * User-agent +const FORBIDDEN_REQUEST_HEADERS = [ + 'accept-charset', + 'accept-encoding', + 'access-control-request-headers', + 'access-control-request-method', + 'connection', + 'content-length', + 'content-transfer-encoding', + 'cookie', + 'cookie2', + 'date', + 'expect', + 'host', + 'keep-alive', + 'origin', + 'referer', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'via' +]; + +// These request methods are not allowed +const FORBIDDEN_REQUEST_METHODS = ['TRACE', 'TRACK', 'CONNECT']; + +/** + * XMLHttpRequest. + * + * Based on: + * https://github.com/mjwwit/node-XMLHttpRequest/blob/master/lib/XMLHttpRequest.js + */ +export default class XMLHttpRequest extends XMLHttpRequestEventTarget { + // Owner document is set by a sub-class in the Window constructor + public static _ownerDocument: IDocument = null; + + // Constants + public static UNSENT = XMLHttpRequestReadyStateEnum.unsent; + public static OPENED = XMLHttpRequestReadyStateEnum.opened; + public static HEADERS_RECEIVED = XMLHttpRequestReadyStateEnum.headersRecieved; + public static LOADING = XMLHttpRequestReadyStateEnum.loading; + public static DONE = XMLHttpRequestReadyStateEnum.done; + + // Public properties + public upload: XMLHttpRequestUpload = new XMLHttpRequestUpload(); + + // Private properties + private readonly _ownerDocument: IDocument = null; + private _state: { + incommingMessage: HTTP.IncomingMessage | { headers: string[]; statusCode: number }; + response: ArrayBuffer | Blob | IDocument | object | string; + responseType: XMLHttpResponseTypeEnum | ''; + responseText: string; + responseXML: IDocument; + responseURL: string; + readyState: XMLHttpRequestReadyStateEnum; + asyncRequest: HTTP.ClientRequest; + asyncTaskID: number; + requestHeaders: object; + status: number; + statusText: string; + send: boolean; + error: boolean; + aborted: boolean; + } = { + incommingMessage: null, + response: null, + responseType: '', + responseText: '', + responseXML: null, + responseURL: '', + readyState: XMLHttpRequestReadyStateEnum.unsent, + asyncRequest: null, + asyncTaskID: null, + requestHeaders: {}, + status: null, + statusText: null, + send: false, + error: false, + aborted: false + }; + + private _settings: { + method: string; + url: string; + async: boolean; + user: string; + password: string; + } = { + method: null, + url: null, + async: true, + user: null, + password: null + }; + + /** + * Constructor. + */ + constructor() { + super(); + this._ownerDocument = XMLHttpRequest._ownerDocument; + } + + /** + * Returns the status. + * + * @returns Status. + */ + public get status(): number { + return this._state.status; + } + + /** + * Returns the status text. + * + * @returns Status text. + */ + public get statusText(): string { + return this._state.statusText; + } + + /** + * Returns the response URL. + * + * @returns Response URL. + */ + public get responseURL(): string { + return this._state.responseURL; + } + + /** + * Returns the ready state. + * + * @returns Ready state. + */ + public get readyState(): XMLHttpRequestReadyStateEnum { + return this._state.readyState; + } + + /** + * Get the response text. + * + * @throws {DOMException} If the response type is not text or empty. + * @returns The response text. + */ + public get responseText(): string { + if (this.responseType === XMLHttpResponseTypeEnum.text || this.responseType === '') { + return this._state.responseText; + } + throw new DOMException( + `Failed to read the 'responseText' property from 'XMLHttpRequest': The value is only accessible if the object's 'responseType' is '' or 'text' (was '${this.responseType}').`, + DOMExceptionNameEnum.invalidStateError + ); + } + + /** + * Get the responseXML. + * + * @throws {DOMException} If the response type is not text or empty. + * @returns Response XML. + */ + public get responseXML(): IDocument { + if (this.responseType === XMLHttpResponseTypeEnum.document || this.responseType === '') { + return this._state.responseXML; + } + throw new DOMException( + `Failed to read the 'responseXML' property from 'XMLHttpRequest': The value is only accessible if the object's 'responseType' is '' or 'document' (was '${this.responseType}').`, + DOMExceptionNameEnum.invalidStateError + ); + } + + /** + * Set response type. + * + * @param type Response type. + * @throws {DOMException} If the state is not unsent or opened. + * @throws {DOMException} If the request is synchronous. + */ + public set responseType(type: XMLHttpResponseTypeEnum | '') { + // ResponseType can only be set when the state is unsent or opened. + if ( + this.readyState !== XMLHttpRequestReadyStateEnum.opened && + this.readyState !== XMLHttpRequestReadyStateEnum.unsent + ) { + throw new DOMException( + `Failed to set the 'responseType' property on 'XMLHttpRequest': The object's state must be OPENED or UNSENT.`, + DOMExceptionNameEnum.invalidStateError + ); + } + // Sync requests can only have empty string or 'text' as response type. + if (!this._settings.async) { + throw new DOMException( + `Failed to set the 'responseType' property on 'XMLHttpRequest': The response type cannot be changed for synchronous requests made from a document.`, + DOMExceptionNameEnum.invalidStateError + ); + } + this._state.responseType = type; + } + + /** + * Get response Type. + * + * @returns Response type. + */ + public get responseType(): XMLHttpResponseTypeEnum | '' { + return this._state.responseType; + } + + /** + * Opens the connection. + * + * @param method Connection method (eg GET, POST). + * @param url URL for the connection. + * @param [async=true] Asynchronous connection. + * @param [user] Username for basic authentication (optional). + * @param [password] Password for basic authentication (optional). + */ + public open(method: string, url: string, async = true, user?: string, password?: string): void { + this.abort(); + + this._state.aborted = false; + this._state.error = false; + + const upperMethod = method.toUpperCase(); + + // Check for valid request method + if (FORBIDDEN_REQUEST_METHODS.includes(upperMethod)) { + throw new DOMException('Request method not allowed', DOMExceptionNameEnum.securityError); + } + + // Check responseType. + if (!async && !!this.responseType && this.responseType !== XMLHttpResponseTypeEnum.text) { + throw new DOMException( + `Failed to execute 'open' on 'XMLHttpRequest': Synchronous requests from a document must not set a response type.`, + DOMExceptionNameEnum.invalidAccessError + ); + } + + this._settings = { + method: upperMethod, + url: url, + async: async, + user: user || null, + password: password || null + }; + + this._setState(XMLHttpRequestReadyStateEnum.opened); + } + + /** + * Sets a header for the request. + * + * @param header Header name + * @param value Header value + * @returns Header added. + */ + public setRequestHeader(header: string, value: string): boolean { + if (this.readyState !== XMLHttpRequestReadyStateEnum.opened) { + throw new DOMException( + `Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED.`, + DOMExceptionNameEnum.invalidStateError + ); + } + + const lowerHeader = header.toLowerCase(); + + if (FORBIDDEN_REQUEST_HEADERS.includes(lowerHeader)) { + return false; + } + + if (this._state.send) { + throw new DOMException( + `Failed to execute 'setRequestHeader' on 'XMLHttpRequest': Request is in progress.`, + DOMExceptionNameEnum.invalidStateError + ); + } + + this._state.requestHeaders[lowerHeader] = value; + + return true; + } + + /** + * Gets a header from the server response. + * + * @param header header Name of header to get. + * @returns string Text of the header or null if it doesn't exist. + */ + public getResponseHeader(header: string): string { + const lowerHeader = header.toLowerCase(); + + // Cookie headers are excluded for security reasons as per spec. + if ( + typeof header === 'string' && + header !== 'set-cookie' && + header !== 'set-cookie2' && + this.readyState > XMLHttpRequestReadyStateEnum.opened && + this._state.incommingMessage.headers[lowerHeader] && + !this._state.error + ) { + return this._state.incommingMessage.headers[lowerHeader]; + } + + return null; + } + + /** + * Gets all the response headers. + * + * @returns A string with all response headers separated by CR+LF. + */ + public getAllResponseHeaders(): string { + if (this.readyState < XMLHttpRequestReadyStateEnum.headersRecieved || this._state.error) { + return ''; + } + + const result = []; + + for (const name of Object.keys(this._state.incommingMessage.headers)) { + // Cookie headers are excluded for security reasons as per spec. + if (name !== 'set-cookie' && name !== 'set-cookie2') { + result.push(`${name}: ${this._state.incommingMessage.headers[name]}`); + } + } + + return result.join('\r\n'); + } + + /** + * Sends the request to the server. + * + * @param data Optional data to send as request body. + */ + public send(data?: string): void { + if (this.readyState != XMLHttpRequestReadyStateEnum.opened) { + throw new DOMException( + `Failed to execute 'send' on 'XMLHttpRequest': Connection must be opened before send() is called.`, + DOMExceptionNameEnum.invalidStateError + ); + } + + if (this._state.send) { + throw new DOMException( + `Failed to execute 'send' on 'XMLHttpRequest': Send has already been called.`, + DOMExceptionNameEnum.invalidStateError + ); + } + + const { location } = this._ownerDocument.defaultView; + + const url = RelativeURL.getAbsoluteURL(location, this._settings.url); + + // Security check. + if (url.protocol === 'http:' && location.protocol === 'https:') { + throw new DOMException( + `Mixed Content: The page at '${location.href}' was loaded over HTTPS, but requested an insecure XMLHttpRequest endpoint '${url.href}'. This request has been blocked; the content must be served over HTTPS.`, + DOMExceptionNameEnum.securityError + ); + } + + // Load files off the local filesystem (file://) + if (XMLHttpRequestURLUtility.isLocal(url)) { + if (!this._ownerDocument.defaultView.happyDOM.settings.enableFileSystemHttpRequests) { + throw new DOMException( + 'File system is disabled by default for security reasons. To enable it, set the "window.happyDOM.settings.enableFileSystemHttpRequests" option to true.', + DOMExceptionNameEnum.securityError + ); + } + + if (this._settings.method !== 'GET') { + throw new DOMException( + 'Failed to send local file system request. Only "GET" method is supported for local file system requests.', + DOMExceptionNameEnum.notSupportedError + ); + } + + if (this._settings.async) { + this._sendLocalAsyncRequest(url).catch((error) => this._onError(error)); + } else { + this._sendLocalSyncRequest(url); + } + return; + } + + // TODO: CORS check. + + const host = XMLHttpRequestURLUtility.getHost(url); + const ssl = XMLHttpRequestURLUtility.isSSL(url); + + // Default to port 80. If accessing localhost on another port be sure + // To use http://localhost:port/path + const port = Number(url.port) || (ssl ? 443 : 80); + // Add query string if one is used + const uri = url.pathname + (url.search ? url.search : ''); + + // Set the Host header or the server may reject the request + this._state.requestHeaders['host'] = host; + if (!((ssl && port === 443) || port === 80)) { + this._state.requestHeaders['host'] += ':' + url.port; + } + + // Set Basic Auth if necessary + if (this._settings.user) { + this._settings.password ??= ''; + const authBuffer = Buffer.from(this._settings.user + ':' + this._settings.password); + this._state.requestHeaders['authorization'] = 'Basic ' + authBuffer.toString('base64'); + } + // Set the Content-Length header if method is POST + switch (this._settings.method) { + case 'GET': + case 'HEAD': + data = null; + break; + case 'POST': + this._state.requestHeaders['content-type'] ??= 'text/plain;charset=UTF-8'; + if (data) { + this._state.requestHeaders['content-length'] = Buffer.isBuffer(data) + ? data.length + : Buffer.byteLength(data); + } else { + this._state.requestHeaders['content-length'] = 0; + } + break; + + default: + break; + } + + const options: HTTPS.RequestOptions = { + host: host, + port: port, + path: uri, + method: this._settings.method, + headers: { ...this._getDefaultRequestHeaders(), ...this._state.requestHeaders }, + agent: false, + rejectUnauthorized: true, + key: ssl ? XMLHttpRequestCertificate.key : null, + cert: ssl ? XMLHttpRequestCertificate.cert : null + }; + + // Reset error flag + this._state.error = false; + + // Handle async requests + if (this._settings.async) { + this._sendAsyncRequest(options, ssl, data).catch((error) => this._onError(error)); + } else { + this._sendSyncRequest(options, ssl, data); + } + } + + /** + * Aborts a request. + */ + public abort(): void { + if (this._state.asyncRequest) { + this._state.asyncRequest.destroy(); + this._state.asyncRequest = null; + } + + this._state.status = null; + this._state.statusText = null; + this._state.requestHeaders = {}; + this._state.responseText = ''; + this._state.responseXML = null; + this._state.aborted = true; + this._state.error = true; + + if ( + this.readyState !== XMLHttpRequestReadyStateEnum.unsent && + (this.readyState !== XMLHttpRequestReadyStateEnum.opened || this._state.send) && + this.readyState !== XMLHttpRequestReadyStateEnum.done + ) { + this._state.send = false; + this._setState(XMLHttpRequestReadyStateEnum.done); + } + this._state.readyState = XMLHttpRequestReadyStateEnum.unsent; + + if (this._state.asyncTaskID !== null) { + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._state.asyncTaskID); + } + } + + /** + * Changes readyState and calls onreadystatechange. + * + * @param state + */ + private _setState(state: XMLHttpRequestReadyStateEnum): void { + if ( + this.readyState === state || + (this.readyState === XMLHttpRequestReadyStateEnum.unsent && this._state.aborted) + ) { + return; + } + + this._state.readyState = state; + + if ( + this._settings.async || + this.readyState < XMLHttpRequestReadyStateEnum.opened || + this.readyState === XMLHttpRequestReadyStateEnum.done + ) { + this.dispatchEvent(new Event('readystatechange')); + } + + if (this.readyState === XMLHttpRequestReadyStateEnum.done) { + let fire: Event; + + if (this._state.aborted) { + fire = new Event('abort'); + } else if (this._state.error) { + fire = new Event('error'); + } else { + fire = new Event('load'); + } + + this.dispatchEvent(fire); + this.dispatchEvent(new Event('loadend')); + } + } + + /** + * Default request headers. + * + * @returns Default request headers. + */ + private _getDefaultRequestHeaders(): { [key: string]: string } { + const { location, navigator, document } = this._ownerDocument.defaultView; + + return { + accept: '*/*', + referer: location.href, + 'user-agent': navigator.userAgent, + cookie: document._cookie.getCookiesString(location, false) + }; + } + + /** + * Sends a synchronous request. + * + * @param options + * @param ssl + * @param data + */ + private _sendSyncRequest(options: HTTPS.RequestOptions, ssl: boolean, data?: string): void { + const scriptString = XMLHttpRequestSyncRequestScriptBuilder.getScript(options, ssl, data); + + // Start the other Node Process, executing this string + const content = ChildProcess.execFileSync(process.argv[0], ['-e', scriptString], { + encoding: 'buffer', + maxBuffer: 1024 * 1024 * 1024 // TODO: Consistent buffer size: 1GB. + }); + + // If content length is 0, then there was an error + if (!content.length) { + throw new DOMException('Synchronous request failed', DOMExceptionNameEnum.networkError); + } + + const { error, data: response } = JSON.parse(content.toString()); + + if (error) { + this._onError(error); + } + + if (response) { + this._state.incommingMessage = { + statusCode: response.statusCode, + headers: response.headers + }; + this._state.status = response.statusCode; + this._state.statusText = response.statusMessage; + // Sync responseType === '' + this._state.response = response.text; + this._state.responseText = response.text; + this._state.responseXML = null; + this._state.responseURL = RelativeURL.getAbsoluteURL( + this._ownerDocument.defaultView.location, + this._settings.url + ).href; + // Set Cookies. + this._setCookies(this._state.incommingMessage.headers); + // Redirect. + if ( + this._state.incommingMessage.statusCode === 301 || + this._state.incommingMessage.statusCode === 302 || + this._state.incommingMessage.statusCode === 303 || + this._state.incommingMessage.statusCode === 307 + ) { + const redirectUrl = RelativeURL.getAbsoluteURL( + this._ownerDocument.defaultView.location, + this._state.incommingMessage.headers['location'] + ); + ssl = redirectUrl.protocol === 'https:'; + this._settings.url = redirectUrl.href; + // Recursive call. + this._sendSyncRequest( + Object.assign(options, { + host: redirectUrl.host, + path: redirectUrl.pathname + (redirectUrl.search ?? ''), + port: redirectUrl.port || (ssl ? 443 : 80), + method: this._state.incommingMessage.statusCode === 303 ? 'GET' : this._settings.method, + headers: Object.assign(options.headers, { + referer: redirectUrl.origin, + host: redirectUrl.host + }) + }), + ssl, + data + ); + } + + this._setState(XMLHttpRequestReadyStateEnum.done); + } + } + + /** + * Sends an async request. + * + * @param options + * @param ssl + * @param data + */ + private _sendAsyncRequest( + options: HTTPS.RequestOptions, + ssl: boolean, + data?: string + ): Promise { + return new Promise((resolve) => { + // Starts async task in Happy DOM + this._state.asyncTaskID = this._ownerDocument.defaultView.happyDOM.asyncTaskManager.startTask( + this.abort.bind(this) + ); + + // Use the proper protocol + const sendRequest = ssl ? HTTPS.request : HTTP.request; + + // Request is being sent, set send flag + this._state.send = true; + + // As per spec, this is called here for historical reasons. + this.dispatchEvent(new Event('readystatechange')); + + // Create the request + this._state.asyncRequest = sendRequest( + options, + async (response: HTTP.IncomingMessage) => { + await this._onAsyncResponse(response, options, ssl, data); + + resolve(); + + // Ends async task in Happy DOM + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask( + this._state.asyncTaskID + ); + } + ).on('error', (error: Error) => { + this._onError(error); + resolve(); + + // Ends async task in Happy DOM + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._state.asyncTaskID); + }); + + // Node 0.4 and later won't accept empty data. Make sure it's needed. + if (data) { + this._state.asyncRequest.write(data); + } + + this._state.asyncRequest.end(); + + this.dispatchEvent(new Event('loadstart')); + }); + } + + /** + * Handles an async response. + * + * @param options Options. + * @param ssl SSL. + * @param data Data. + * @param response Response. + * @returns Promise. + */ + private _onAsyncResponse( + response: HTTP.IncomingMessage, + options: HTTPS.RequestOptions, + ssl: boolean, + data?: string + ): Promise { + return new Promise((resolve) => { + // Set response var to the response we got back + // This is so it remains accessable outside this scope + this._state.incommingMessage = response; + + // Set Cookies + this._setCookies(this._state.incommingMessage.headers); + + // Check for redirect + // @TODO Prevent looped redirects + if ( + this._state.incommingMessage.statusCode === 301 || + this._state.incommingMessage.statusCode === 302 || + this._state.incommingMessage.statusCode === 303 || + this._state.incommingMessage.statusCode === 307 + ) { + // TODO: redirect url protocol change. + // Change URL to the redirect location + this._settings.url = this._state.incommingMessage.headers.location; + // Parse the new URL. + const redirectUrl = RelativeURL.getAbsoluteURL( + this._ownerDocument.defaultView.location, + this._settings.url + ); + this._settings.url = redirectUrl.href; + ssl = redirectUrl.protocol === 'https:'; + // Issue the new request + this._sendAsyncRequest( + { + ...options, + host: redirectUrl.hostname, + port: redirectUrl.port, + path: redirectUrl.pathname + (redirectUrl.search ?? ''), + method: this._state.incommingMessage.statusCode === 303 ? 'GET' : this._settings.method, + headers: { ...options.headers, referer: redirectUrl.origin, host: redirectUrl.host } + }, + ssl, + data + ); + // @TODO Check if an XHR event needs to be fired here + return; + } + + if (this._state.incommingMessage && this._state.incommingMessage.setEncoding) { + this._state.incommingMessage.setEncoding('utf-8'); + } + + this._setState(XMLHttpRequestReadyStateEnum.headersRecieved); + this._state.status = this._state.incommingMessage.statusCode; + this._state.statusText = this._state.incommingMessage.statusMessage; + + // Initialize response. + let tempResponse = Buffer.from(new Uint8Array(0)); + + this._state.incommingMessage.on('data', (chunk: Uint8Array) => { + // Make sure there's some data + if (chunk) { + tempResponse = Buffer.concat([tempResponse, Buffer.from(chunk)]); + } + // Don't emit state changes if the connection has been aborted. + if (this._state.send) { + this._setState(XMLHttpRequestReadyStateEnum.loading); + } + + const contentLength = Number(this._state.incommingMessage.headers['content-length']); + this.dispatchEvent( + new ProgressEvent('progress', { + lengthComputable: isNaN(contentLength) ? false : true, + loaded: tempResponse.length, + total: isNaN(contentLength) ? 0 : contentLength + }) + ); + }); + + this._state.incommingMessage.on('end', () => { + if (this._state.send) { + // The sendFlag needs to be set before setState is called. Otherwise, if we are chaining callbacks + // There can be a timing issue (the callback is called and a new call is made before the flag is reset). + this._state.send = false; + + // Set response according to responseType. + const { response, responseXML, responseText } = this._parseResponseData(tempResponse); + this._state.response = response; + this._state.responseXML = responseXML; + this._state.responseText = responseText; + this._state.responseURL = RelativeURL.getAbsoluteURL( + this._ownerDocument.defaultView.location, + this._settings.url + ).href; + // Discard the 'end' event if the connection has been aborted + this._setState(XMLHttpRequestReadyStateEnum.done); + } + + resolve(); + }); + + this._state.incommingMessage.on('error', (error) => { + this._onError(error); + resolve(); + }); + }); + } + + /** + * Sends a local file system async request. + * + * @param url URL. + * @returns Promise. + */ + private async _sendLocalAsyncRequest(url: UrlObject): Promise { + this._state.asyncTaskID = this._ownerDocument.defaultView.happyDOM.asyncTaskManager.startTask( + this.abort.bind(this) + ); + + let data: Buffer; + + try { + data = await FS.promises.readFile(decodeURI(url.pathname.slice(1))); + } catch (error) { + this._onError(error); + } + + const dataLength = data.length; + + this._setState(XMLHttpRequestReadyStateEnum.loading); + this.dispatchEvent( + new ProgressEvent('progress', { + lengthComputable: true, + loaded: dataLength, + total: dataLength + }) + ); + + if (data) { + this._parseLocalRequestData(data); + } + + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._state.asyncTaskID); + } + + /** + * Sends a local file system synchronous request. + * + * @param url URL. + */ + private _sendLocalSyncRequest(url: UrlObject): void { + let data: Buffer; + try { + data = FS.readFileSync(decodeURI(url.pathname.slice(1))); + } catch (error) { + this._onError(error); + } + + if (data) { + this._parseLocalRequestData(data); + } + } + + /** + * Parses local request data. + * + * @param data Data. + */ + private _parseLocalRequestData(data: Buffer): void { + this._state.status = 200; + this._state.statusText = 'OK'; + + const { response, responseXML, responseText } = this._parseResponseData(data); + this._state.response = response; + this._state.responseXML = responseXML; + this._state.responseText = responseText; + this._state.responseURL = RelativeURL.getAbsoluteURL( + this._ownerDocument.defaultView.location, + this._settings.url + ).href; + + this._setState(XMLHttpRequestReadyStateEnum.done); + } + + /** + * Returns response based to the "responseType" property. + * + * @param data Data. + * @returns Parsed response. + */ + private _parseResponseData(data: Buffer): { + response: ArrayBuffer | Blob | IDocument | object | string; + responseText: string; + responseXML: IDocument; + } { + switch (this.responseType) { + case XMLHttpResponseTypeEnum.arraybuffer: + // See: https://github.com/jsdom/jsdom/blob/c3c421c364510e053478520500bccafd97f5fa39/lib/jsdom/living/helpers/binary-data.js + const newAB = new ArrayBuffer(data.length); + const view = new Uint8Array(newAB); + view.set(data); + return { + response: view, + responseText: null, + responseXML: null + }; + case XMLHttpResponseTypeEnum.blob: + try { + return { + response: new this._ownerDocument.defaultView.Blob([new Uint8Array(data)], { + type: this.getResponseHeader('content-type') || '' + }), + responseText: null, + responseXML: null + }; + } catch (e) { + return { response: null, responseText: null, responseXML: null }; + } + case XMLHttpResponseTypeEnum.document: + const window = this._ownerDocument.defaultView; + const happyDOMSettings = window.happyDOM.settings; + let response: IDocument; + + // Temporary disables unsecure features. + window.happyDOM.settings = { + ...happyDOMSettings, + enableFileSystemHttpRequests: false, + disableJavaScriptEvaluation: true, + disableCSSFileLoading: true, + disableJavaScriptFileLoading: true + }; + + const domParser = new window.DOMParser(); + + try { + response = domParser.parseFromString(data.toString(), 'text/xml'); + } catch (e) { + return { response: null, responseText: null, responseXML: null }; + } + + // Restores unsecure features. + window.happyDOM.settings = happyDOMSettings; + + return { response, responseText: null, responseXML: response }; + case XMLHttpResponseTypeEnum.json: + try { + return { + response: JSON.parse(data.toString()), + responseText: null, + responseXML: null + }; + } catch (e) { + return { response: null, responseText: null, responseXML: null }; + } + case XMLHttpResponseTypeEnum.text: + case '': + default: + return { + response: data.toString(), + responseText: data.toString(), + responseXML: null + }; + } + } + + /** + * Set Cookies from response headers. + * + * @param headers String array. + */ + private _setCookies(headers: string[] | HTTP.IncomingHttpHeaders): void { + for (const cookie of [...(headers['set-cookie'] ?? []), ...(headers['set-cookie2'] ?? [])]) { + this._ownerDocument.defaultView.document._cookie.setCookiesString(cookie); + } + } + + /** + * Called when an error is encountered to deal with it. + * + * @param error Error. + */ + private _onError(error: Error | string): void { + this._state.status = 0; + this._state.statusText = error.toString(); + this._state.responseText = error instanceof Error ? error.stack : ''; + this._state.error = true; + this._setState(XMLHttpRequestReadyStateEnum.done); + } +} diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequestCertificate.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequestCertificate.ts new file mode 100644 index 000000000..af8d7744e --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequestCertificate.ts @@ -0,0 +1,52 @@ +// SSL certificate generated for Happy DOM to be able to perform HTTPS requests +export default { + cert: `-----BEGIN CERTIFICATE----- +MIIDYzCCAkugAwIBAgIUJRKB/H66hpet1VfUlm0CiXqePA4wDQYJKoZIhvcNAQEL +BQAwQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVNYWxt +bzESMBAGA1UECgwJSGFwcHkgRE9NMB4XDTIyMTAxMTIyMDM0OVoXDTMyMTAwODIy +MDM0OVowQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVN +YWxtbzESMBAGA1UECgwJSGFwcHkgRE9NMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAqerQSQEg/SxVxRiwlItithr5e5EMZo1nsqt/xOxagbmpW3IEmt0j +bpbH7iEF4DDEo7KAOwUCOwVWeFxRoag8lG2ax48wrgjlCna45XDn0Xeg1ARajL04 +gs46HZ0VrzIloVGfln0zgt/Vum5BNqs9Oc5fQoBmoP3cAn3dn4ZVcP0AKthtcyPl +q2DuNRN0PV0D2RtMSiAy9l1Ko6N5x+sAeClDyOL+sTDLngZBVeZyOKt9Id15S8Zt +XtA6VMgHnnF3jChn7pag77rsd/y5iANAVNZYqRl+Eg7xaDcsvbgH46UBOrBcB39Q +tTh5Mtjoxep5e3ZDFG+kQ1HUE+iz5O5n0wIDAQABo1MwUTAdBgNVHQ4EFgQU69s9 +YSobG/m2SN4L/7zTaF7iDbwwHwYDVR0jBBgwFoAU69s9YSobG/m2SN4L/7zTaF7i +DbwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAi/WUXx2oal8L +YnPlIuKfh49n/K18wXSYG//oFYwxfVxqpYH8hUiXVm/GUcXCxS++hUkaKLqXmH9q +MKJiCrZr3vS+2nsBKopkICu/TLdROl0sAI9lByfnEbfSAzjxe1IWJdK8NdY0y5m5 +9pEr/URVIAp/CxrneyASb4q0Jg5To3FR7vYc+2X6wZn0MundKMg6Dp9/A37jiF3l +Tt/EJp299YZcsUzh+LnRuggRjnoOVu1aLcLFlaUiwZfy9m8mLG6B/mdW/qNzNMh9 +Oqvg1zfGdpz/4D/2UUUBn6pq1vbsoAaF3OesoA3mfDcegDf/H9woJlpT0Wql+e68 +Y3FblSokcA== +-----END CERTIFICATE-----`, + key: `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCp6tBJASD9LFXF +GLCUi2K2Gvl7kQxmjWeyq3/E7FqBualbcgSa3SNulsfuIQXgMMSjsoA7BQI7BVZ4 +XFGhqDyUbZrHjzCuCOUKdrjlcOfRd6DUBFqMvTiCzjodnRWvMiWhUZ+WfTOC39W6 +bkE2qz05zl9CgGag/dwCfd2fhlVw/QAq2G1zI+WrYO41E3Q9XQPZG0xKIDL2XUqj +o3nH6wB4KUPI4v6xMMueBkFV5nI4q30h3XlLxm1e0DpUyAeecXeMKGfulqDvuux3 +/LmIA0BU1lipGX4SDvFoNyy9uAfjpQE6sFwHf1C1OHky2OjF6nl7dkMUb6RDUdQT +6LPk7mfTAgMBAAECggEAKkwTkTjAt4UjzK56tl+EMQTB+ep/hb/JgoaChci4Nva6 +m9LkJpDJ0yuhlTuPNOGu8XjrxsVWas7HWarRf0Zb3i7yip6wZYI9Ub+AA015x4DZ +/i0fRU2NFbK0cM67qSL4jxG8gj+kZP3HPGNZxHwX/53JxMolwgmvjMc8NgvAlSFd +NnV9h4xtbhUh1NGS5zmP3iU2rwnE8JrIEzwy6axLom7nekAgkdcbAr0UoBs8gcgH +aYNhU4Gz3tGcZZ0IXAfT/bJIH1Ko8AGv4pssWc3BXcmmNdm/+kzvHIxEIV7Qegmo +XG1ZyZCyD/0b4/3e8ySDBEDqwR+HeyTW2isWG2agAQKBgQDp44aTwr3dkIXY30xv +FPfUOipg/B49dWnffYJ9MWc1FT9ijNPAngWSk0EIiEQIazICcUBI4Yji6/KeyqLJ +GdLpDi1CkKqtyh73mjELinYp3EUQgEa77aQogGa2+nMOVfu+O5CtloUrv/A18jX3 ++VEyaEASK0fWmnSI0OdlxQHIAQKBgQC5+xOls2F3MlKASvWRLlnW1wHqlDTtVoYg +5Nh8syZH4Ci2UH8tON3A5/7SWNM0t1cgV6Cw4zW8Z2spgIT/W0iYYrQ4hHL1xdCu ++CxL1km4Gy8Uwpsd+KdFahFqF/XTmLzW0HXLxWSK0fTwmdV0SFrKF3MXfTCU2AeZ +jJoMFb6P0wKBgQC3Odw6s0vkYAzLGhuZxfZkVvDOK5RRF0NKpttr0iEFL9EJFkPo +2KKK8jr3QTDy229BBJGUxsJi6u6VwS8HlehpVQbV59kd7oKV/EBBx0XMg1fDlopT +PNbmN7i/zbIG4AsoOyebJZjL7kBzMn1e9vzKHWtcEHXlw/hZGja8vjooAQKBgAeg +xK2HLfg1mCyq5meN/yFQsENu0LzrT5UJzddPgcJw7zqLEqxIKNBAs7Ls8by3yFsL +PQwERa/0jfCl1M6kb9XQNpQa2pw6ANUsWKTDpUJn2wZ+9N3F1RaDwzMWyH5lRVmK +M0qoTfdjpSg5Jwgd75taWt4bxGJWeflSSv8z5R0BAoGAWL8c527AbeBvx2tOYKkD +2TFranvANNcoMrbeviZSkkGvMNDP3p8b6juJwXOIeWNr8q4vFgCzLmq6d1/9gYm2 +3XJwwyD0LKlqzkBrrKU47qrnmMosUrIRlrAzd3HbShOptxc6Iz2apSaUDKGKXkaw +gl5OpEjeliU7Mus0BVS858g= +-----END PRIVATE KEY-----` +}; diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequestEventTarget.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequestEventTarget.ts new file mode 100644 index 000000000..90cb15e1e --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequestEventTarget.ts @@ -0,0 +1,17 @@ +import ProgressEvent from '../event/events/ProgressEvent'; +import EventTarget from '../event/EventTarget'; + +export type ProgressEventListener = (event: ProgressEvent) => void; + +/** + * References: https://xhr.spec.whatwg.org/#xmlhttprequesteventtarget. + */ +export default class XMLHttpRequestEventTarget extends EventTarget { + public onloadstart: ProgressEventListener | null = null; + public onprogress: ProgressEventListener | null = null; + public onabort: (event: ProgressEvent) => void | null = null; + public onerror: ProgressEventListener | null = null; + public onload: ProgressEventListener | null = null; + public ontimeout: ProgressEventListener | null = null; + public onloadend: ProgressEventListener | null = null; +} diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequestReadyStateEnum.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequestReadyStateEnum.ts new file mode 100644 index 000000000..3996ceb6b --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequestReadyStateEnum.ts @@ -0,0 +1,9 @@ +enum XMLHttpRequestReadyStateEnum { + unsent = 0, + opened = 1, + headersRecieved = 2, + loading = 3, + done = 4 +} + +export default XMLHttpRequestReadyStateEnum; diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts new file mode 100644 index 000000000..d0d4c5dcc --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts @@ -0,0 +1,6 @@ +import XMLHttpRequestEventTarget from './XMLHttpRequestEventTarget'; + +/** + * References: https://xhr.spec.whatwg.org/#xmlhttprequestupload. + */ +export default class XMLHttpRequestUpload extends XMLHttpRequestEventTarget {} diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpResponseTypeEnum.ts b/packages/happy-dom/src/xml-http-request/XMLHttpResponseTypeEnum.ts new file mode 100644 index 000000000..553b32cca --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/XMLHttpResponseTypeEnum.ts @@ -0,0 +1,9 @@ +enum XMLHttpResponseTypeEnum { + arraybuffer = 'arraybuffer', + blob = 'blob', + document = 'document', + json = 'json', + text = 'text' +} + +export default XMLHttpResponseTypeEnum; diff --git a/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestSyncRequestScriptBuilder.ts b/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestSyncRequestScriptBuilder.ts new file mode 100644 index 000000000..9e8910a1e --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestSyncRequestScriptBuilder.ts @@ -0,0 +1,53 @@ +import HTTPS from 'https'; + +/** + * Synchroneous XMLHttpRequest script builder. + */ +export default class XMLHttpRequestSyncRequestScriptBuilder { + /** + * Sends a synchronous request. + * + * @param options Options. + * @param ssl SSL. + * @param data Data. + */ + public static getScript(options: HTTPS.RequestOptions, ssl: boolean, data?: string): string { + // Synchronous + // Note: console.log === stdout + // The async request the other Node process executes + return ` + const HTTP = require('http'); + const HTTPS = require('https'); + const sendRequest = HTTP${ssl ? 'S' : ''}.request; + const options = ${JSON.stringify(options)}; + const request = sendRequest(options, (response) => { + let responseText = ''; + let responseData = Buffer.alloc(0); + response.setEncoding('utf8'); + response.on('data', (chunk) => { + responseText += chunk; + responseData = Buffer.concat([responseData, Buffer.from(chunk)]); + }); + response.on('end', () => { + console.log(JSON.stringify({ + error: null, + data: { + statusCode: response.statusCode, + statusMessage: response.statusMessage, + headers: response.headers, + text: responseText, + data: responseData.toString('base64') + } + })); + }); + response.on('error', (error) => { + console.log(JSON.stringify({ error: error.toString(), data: null })); + }); + }); + request.write(\`${JSON.stringify(data ?? '') + .slice(1, -1) + .replace(/'/g, "\\'")}\`); + request.end(); + `; + } +} diff --git a/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestURLUtility.ts b/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestURLUtility.ts new file mode 100644 index 000000000..551283d35 --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestURLUtility.ts @@ -0,0 +1,64 @@ +import { URL } from 'url'; + +/** + * URL utility. + */ +export default class XMLHttpRequestURLUtility { + /** + * Returns "true" if SSL. + * + * @param url URL. + * @returns "true" if SSL. + */ + public static isSSL(url: URL): boolean { + return url.protocol === 'https:'; + } + + /** + * Returns "true" if SSL. + * + * @param url URL. + * @returns "true" if SSL. + */ + public static isLocal(url: URL): boolean { + return url.protocol === 'file:'; + } + + /** + * Returns "true" if protocol is valid. + * + * @param url URL. + * @returns "true" if valid. + */ + public static isSupportedProtocol(url: URL): boolean { + switch (url.protocol) { + case 'https:': + case 'http:': + case 'file:': + case undefined: + case '': + return true; + } + + return false; + } + + /** + * Returns host. + * + * @param url URL. + * @returns Host. + */ + public static getHost(url: URL): string { + switch (url.protocol) { + case 'http:': + case 'https:': + return url.hostname; + case undefined: + case '': + return 'localhost'; + default: + return null; + } + } +} diff --git a/packages/happy-dom/test/fetch/ResourceFetchHandler.test.ts b/packages/happy-dom/test/fetch/ResourceFetchHandler.test.ts index 6fac713ea..b9862724a 100644 --- a/packages/happy-dom/test/fetch/ResourceFetchHandler.test.ts +++ b/packages/happy-dom/test/fetch/ResourceFetchHandler.test.ts @@ -3,18 +3,17 @@ import IWindow from '../../src/window/IWindow'; import IDocument from '../../src/nodes/document/IDocument'; import ResourceFetchHandler from '../../src/fetch/ResourceFetchHandler'; import IResponse from '../../src/fetch/IResponse'; +import XMLHttpRequestSyncRequestScriptBuilder from '../../src/xml-http-request/utilities/XMLHttpRequestSyncRequestScriptBuilder'; +import XMLHttpRequestCertificate from '../../src/xml-http-request/XMLHttpRequestCertificate'; -const MOCKED_SYNC_REQUEST = global['mockedModules']['sync-request']; +const URL = 'https://localhost:8080/base/'; describe('ResourceFetchHandler', () => { let window: IWindow; let document: IDocument; beforeEach(() => { - MOCKED_SYNC_REQUEST.options = null; - MOCKED_SYNC_REQUEST.statusCode = 200; - MOCKED_SYNC_REQUEST.body = 'test'; - window = new Window(); + window = new Window({ url: URL }); document = window.document; }); @@ -43,27 +42,47 @@ describe('ResourceFetchHandler', () => { describe('fetchSync()', () => { it('Returns resource data synchrounously.', () => { - window.location.href = 'https://localhost:8080/base/'; - const test = ResourceFetchHandler.fetchSync(document, 'path/to/script/'); - expect(MOCKED_SYNC_REQUEST.options).toEqual({ - method: 'GET', - url: 'https://localhost:8080/base/path/to/script/' + expect(mockedModules.modules.child_process.execFileSync.parameters.command).toEqual( + process.argv[0] + ); + expect(mockedModules.modules.child_process.execFileSync.parameters.args[0]).toBe('-e'); + expect(mockedModules.modules.child_process.execFileSync.parameters.args[1]).toBe( + XMLHttpRequestSyncRequestScriptBuilder.getScript( + { + host: 'localhost', + port: 8080, + path: '/base/path/to/script/', + method: 'GET', + headers: { + accept: '*/*', + referer: 'https://localhost:8080/base/', + 'user-agent': window.navigator.userAgent, + cookie: '', + host: 'localhost:8080' + }, + agent: false, + rejectUnauthorized: true, + key: XMLHttpRequestCertificate.key, + cert: XMLHttpRequestCertificate.cert + }, + true + ) + ); + expect(mockedModules.modules.child_process.execFileSync.parameters.options).toEqual({ + encoding: 'buffer', + maxBuffer: 1024 * 1024 * 1024 }); - expect(test).toBe('test'); + + expect(test).toBe('child_process.execFileSync.returnValue.data.text'); }); it('Handles error when resource is fetched synchrounously.', () => { - window.location.href = 'https://localhost:8080/base/'; - - MOCKED_SYNC_REQUEST.statusCode = 404; - + mockedModules.modules.child_process.execFileSync.returnValue.data.statusCode = 404; expect(() => { ResourceFetchHandler.fetchSync(document, 'path/to/script/'); - }).toThrowError( - 'Failed to perform request to "https://localhost:8080/base/path/to/script/". Status code: 404' - ); + }).toThrowError(`Failed to perform request to "${URL}path/to/script/". Status code: 404`); }); }); }); diff --git a/packages/happy-dom/test/location/RelativeURL.test.ts b/packages/happy-dom/test/location/RelativeURL.test.ts index e47013779..010341279 100644 --- a/packages/happy-dom/test/location/RelativeURL.test.ts +++ b/packages/happy-dom/test/location/RelativeURL.test.ts @@ -11,22 +11,22 @@ describe('RelativeURL', () => { describe('getAbsoluteURL()', () => { it('Returns absolute URL when location is "https://localhost:8080/base/" and URL is "path/to/resource/".', () => { location.href = 'https://localhost:8080/base/'; - expect(RelativeURL.getAbsoluteURL(location, 'path/to/resource/')).toBe( + expect(RelativeURL.getAbsoluteURL(location, 'path/to/resource/').href).toBe( 'https://localhost:8080/base/path/to/resource/' ); }); it('Returns absolute URL when location is "https://localhost:8080" and URL is "path/to/resource/".', () => { location.href = 'https://localhost:8080'; - expect(RelativeURL.getAbsoluteURL(location, 'path/to/resource/')).toBe( + expect(RelativeURL.getAbsoluteURL(location, 'path/to/resource/').href).toBe( 'https://localhost:8080/path/to/resource/' ); }); it('Returns absolute URL when URL is "https://localhost:8080/path/to/resource/".', () => { - expect(RelativeURL.getAbsoluteURL(location, 'https://localhost:8080/path/to/resource/')).toBe( - 'https://localhost:8080/path/to/resource/' - ); + expect( + RelativeURL.getAbsoluteURL(location, 'https://localhost:8080/path/to/resource/').href + ).toBe('https://localhost:8080/path/to/resource/'); }); }); }); diff --git a/packages/happy-dom/test/location/URL.test.ts b/packages/happy-dom/test/location/URL.test.ts index 7a8477f49..ca4cf1eda 100644 --- a/packages/happy-dom/test/location/URL.test.ts +++ b/packages/happy-dom/test/location/URL.test.ts @@ -1,10 +1,14 @@ -import URL from '../../src/location/URL'; +import Window from '../../src/window/Window'; describe('URL', () => { + let window: Window; + beforeEach(() => { + window = new Window(); + }); describe('constructor()', () => { it('Parses "https://google.com/some-path/?key=value&key2=value2#hash".', () => { const href = 'https://google.com/some-path/?key=value&key2=value2#hash'; - const url = new URL(href); + const url = new window.URL(href); expect(url.href).toBe(href); expect(url.protocol).toBe('https:'); expect(url.hostname).toBe('google.com'); @@ -20,7 +24,7 @@ describe('URL', () => { it('Parses "https://user:password@google.com/some-path/".', () => { const href = 'https://user:password@google.com/some-path/'; - const url = new URL(href); + const url = new window.URL(href); expect(url.href).toBe(href); expect(url.protocol).toBe('https:'); expect(url.hostname).toBe('google.com'); @@ -36,11 +40,11 @@ describe('URL', () => { it('Parses "https://google.com:8080/some-path/".', () => { const href = 'https://google.com:8080/some-path/'; - const url = new URL(href); + const url = new window.URL(href); expect(url.href).toBe(href); expect(url.protocol).toBe('https:'); expect(url.hostname).toBe('google.com'); - expect(url.port).toBe(':8080'); + expect(url.port).toBe('8080'); expect(url.pathname).toBe('/some-path/'); expect(url.search).toBe(''); expect(url.hash).toBe(''); @@ -52,7 +56,7 @@ describe('URL', () => { it('Parses "https://google.com".', () => { const formatHref = 'https://google.com/'; const href = 'https://google.com'; - const url = new URL(href); + const url = new window.URL(href); expect(url.href).toBe(formatHref); expect(url.protocol).toBe('https:'); expect(url.hostname).toBe('google.com'); diff --git a/packages/happy-dom/test/nodes/document/Document.test.ts b/packages/happy-dom/test/nodes/document/Document.test.ts index f93374aed..ce9730d17 100644 --- a/packages/happy-dom/test/nodes/document/Document.test.ts +++ b/packages/happy-dom/test/nodes/document/Document.test.ts @@ -191,7 +191,7 @@ describe('Document', () => { }); it('Sets a cookie with a path.', () => { - window.location.href = '/path/to/cookie/'; + window.location.href = 'https://sub.test.com/path/to/cookie/'; document.cookie = 'name1=value1; path=path/to'; document.cookie = 'name2=value2; path=/path/to'; document.cookie = 'name3=value3; path=/path/to/cookie/'; @@ -199,7 +199,7 @@ describe('Document', () => { }); it('Does not set cookie if the path does not match the current path.', () => { - window.location.href = '/path/to/cookie/'; + window.location.href = 'https://sub.test.com/path/to/cookie/'; document.cookie = 'name1=value1; path=/cookie/'; expect(document.cookie).toBe(''); }); @@ -421,6 +421,19 @@ describe('Document', () => { }); }); + describe('URL', () => { + it('Returns the URL of the document.', () => { + document.location.href = 'http://localhost:8080/path/to/file.html'; + expect(document.URL).toBe('http://localhost:8080/path/to/file.html'); + }); + }); + describe('documentURI', () => { + it('Returns the documentURI of the document.', () => { + document.location.href = 'http://localhost:8080/path/to/file.html'; + expect(document.documentURI).toBe('http://localhost:8080/path/to/file.html'); + }); + }); + describe('append()', () => { it('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/setup.js b/packages/happy-dom/test/setup.js index bc51b5c89..567c1d783 100644 --- a/packages/happy-dom/test/setup.js +++ b/packages/happy-dom/test/setup.js @@ -1,68 +1,136 @@ -global.mockedModules = { - 'sync-request': { - statusCode: null, - body: null, - options: null - }, +/* eslint-disable camelcase */ + +const modules = { 'node-fetch': { - url: null, - init: null, - error: null, - response: { - arrayBuffer: Symbol('arrayBuffer'), - blob: Symbol('blob'), - buffer: Symbol('buffer'), - json: Symbol('json'), - text: Symbol('text'), - textConverted: Symbol('textConverted') + parameters: { + url: null, + init: null + }, + returnValue: { + error: null, + response: { + arrayBuffer: 'nodeFetch.arrayBuffer', + blob: 'nodeFetch.blob', + buffer: 'nodeFetch.buffer', + json: 'nodeFetch.json', + text: 'nodeFetch.text', + textConverted: 'nodeFetch.textConverted' + } + } + }, + fs: { + promises: { + readFile: { + parameters: { + path: null + }, + returnValue: { + data: 'fs.promises.readFile' + } + } + }, + readFileSync: { + parameters: { + path: null + }, + returnValue: { + data: 'fs.readFileSync' + } + } + }, + child_process: { + execFileSync: { + parameters: { + command: null, + args: null, + options: null + }, + returnValue: { + data: { + statusCode: 200, + statusMessage: 'child_process.execFileSync.returnValue.data.statusMessage', + headers: { key1: 'value1', key2: 'value2', 'content-length': '48' }, + text: 'child_process.execFileSync.returnValue.data.text', + data: Buffer.from('child_process.execFileSync.returnValue.data.text').toString('base64') + }, + error: null + } + } + }, + http: { + request: { + parameters: { + options: null + }, + internal: { + body: '', + destroyed: false + }, + returnValue: { + response: { + headers: { key1: 'value1', key2: 'value2', 'content-length': '17' }, + statusCode: 200, + statusMessage: 'http.request.statusMessage', + body: 'http.request.body', + error: null + }, + request: { + error: null + } + } } } }; -jest.mock('sync-request', () => (method, url) => { - global.mockedModules['sync-request'].options = { - method, - url - }; - return { - getBody: () => global.mockedModules['sync-request'].body, - isError: () => global.mockedModules['sync-request'].statusCode !== 200, - statusCode: global.mockedModules['sync-request'].statusCode - }; -}); +global.mockedModules = { + modules: JSON.parse(JSON.stringify(modules)), + reset: () => { + global.mockedModules.modules = JSON.parse(JSON.stringify(modules)); + } +}; /* eslint-disable jsdoc/require-jsdoc */ class NodeFetchResponse { arrayBuffer() { - return Promise.resolve(global.mockedModules['node-fetch'].response.arrayBuffer); + return Promise.resolve( + global.mockedModules.modules['node-fetch'].returnValue.response.arrayBuffer + ); } blob() { - return Promise.resolve(global.mockedModules['node-fetch'].response.blob); + return Promise.resolve(global.mockedModules.modules['node-fetch'].returnValue.response.blob); } buffer() { - return Promise.resolve(global.mockedModules['node-fetch'].response.buffer); + return Promise.resolve(global.mockedModules.modules['node-fetch'].returnValue.response.buffer); } json() { - return Promise.resolve(global.mockedModules['node-fetch'].response.json); + return Promise.resolve(global.mockedModules.modules['node-fetch'].returnValue.response.json); } text() { - return Promise.resolve(global.mockedModules['node-fetch'].response.text); + return Promise.resolve(global.mockedModules.modules['node-fetch'].returnValue.response.text); } textConverted() { - return Promise.resolve(global.mockedModules['node-fetch'].response.textConverted); + return Promise.resolve( + global.mockedModules.modules['node-fetch'].returnValue.response.textConverted + ); + } +} + +class NodeFetchRequest extends NodeFetchResponse { + constructor(url) { + super(); + this.url = url; } } -class NodeFetchRequest extends NodeFetchResponse {} class NodeFetchHeaders {} jest.mock('node-fetch', () => { return Object.assign( - (url, options) => { - global.mockedModules['node-fetch'].url = url; - global.mockedModules['node-fetch'].init = options; - if (global.mockedModules['node-fetch'].error) { - return Promise.reject(global.mockedModules['node-fetch'].error); + (request, options) => { + global.mockedModules.modules['node-fetch'].parameters.url = request.url.href; + global.mockedModules.modules['node-fetch'].parameters.init = options; + if (global.mockedModules.modules['node-fetch'].error) { + return Promise.reject(global.mockedModules.modules['node-fetch'].returnValue.error); } return Promise.resolve(new NodeFetchResponse()); }, @@ -73,3 +141,116 @@ jest.mock('node-fetch', () => { } ); }); + +jest.mock('fs', () => ({ + promises: { + readFile: (path) => { + global.mockedModules.modules.fs.promises.readFile.parameters.path = path; + return Promise.resolve(global.mockedModules.modules.fs.promises.readFile.returnValue.data); + } + }, + readFileSync: (path) => { + global.mockedModules.modules.fs.readFileSync.parameters.path = path; + return global.mockedModules.modules.fs.readFileSync.returnValue.data; + } +})); + +jest.mock('child_process', () => ({ + execFileSync: (command, args, options) => { + global.mockedModules.modules.child_process.execFileSync.parameters = { + command, + args, + options + }; + return JSON.stringify(global.mockedModules.modules.child_process.execFileSync.returnValue); + } +})); + +class IncomingMessage { + constructor() { + this.headers = {}; + this.statusCode = null; + this.statusMessage = null; + this._eventListeners = { + data: [], + end: [], + error: [] + }; + } + + on(event, callback) { + this._eventListeners[event].push(callback); + } +} + +const httpMock = () => { + return { + request: (options, callback) => { + let errorCallback = null; + let timeout = null; + global.mockedModules.modules.http.request.parameters = { + options + }; + const request = { + write: (chunk) => (global.mockedModules.modules.http.request.internal.body += chunk), + end: () => { + timeout = setTimeout(() => { + if (global.mockedModules.modules.http.request.returnValue.request.error) { + if (errorCallback) { + errorCallback(global.mockedModules.modules.http.request.returnValue.request.error); + } + } else { + const response = new IncomingMessage(); + + response.headers = + global.mockedModules.modules.http.request.returnValue.response.headers; + response.statusCode = + global.mockedModules.modules.http.request.returnValue.response.statusCode; + response.statusMessage = + global.mockedModules.modules.http.request.returnValue.response.statusMessage; + + callback(response); + + if (global.mockedModules.modules.http.request.returnValue.response.error) { + for (const listener of response._eventListeners.error) { + listener(global.mockedModules.modules.http.request.returnValue.response.error); + } + } else { + for (const listener of response._eventListeners.data) { + listener(global.mockedModules.modules.http.request.returnValue.response.body); + } + for (const listener of response._eventListeners.end) { + listener(); + } + } + } + }); + }, + on: (event, callback) => { + if (event === 'error') { + errorCallback = callback; + } + return request; + }, + destroy: () => { + clearTimeout(timeout); + global.mockedModules.modules.http.request.internal.destroyed = true; + } + }; + return request; + }, + STATUS_CODES: { + 200: 'OK', + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable' + } + }; +}; + +jest.mock('http', httpMock); +jest.mock('https', httpMock); diff --git a/packages/happy-dom/test/types.d.ts b/packages/happy-dom/test/types.d.ts new file mode 100644 index 000000000..4a383c43d --- /dev/null +++ b/packages/happy-dom/test/types.d.ts @@ -0,0 +1,86 @@ +declare let mockedModules: { + modules: { + 'node-fetch': { + parameters: { + url: string; + init: { + headers: { [k: string]: string }; + }; + }; + returnValue: { + error: Error; + response: { + arrayBuffer: Buffer; + blob: object; + buffer: Buffer; + json: object; + text: string; + textConverted: string; + }; + }; + }; + fs: { + promises: { + readFile: { + parameters: { + path: string; + }; + returnValue: { + data: Buffer; + }; + }; + }; + readFileSync: { + parameters: { + path: string; + }; + returnValue: { + data: Buffer; + }; + }; + }; + child_process: { + execFileSync: { + parameters: { + command: string; + args: string[]; + options: { encoding: string; maxBuffer: Buffer }; + }; + returnValue: { + data: { + statusCode: number; + statusMessage: string; + headers: { [k: string]: string }; + text: string; + data: string; + }; + error: string; + }; + }; + }; + http: { + request: { + parameters: { + options: object; + }; + internal: { + body: string; + destroyed: boolean; + }; + returnValue: { + response: { + headers: { [k: string]: string }; + statusCode: number; + statusMessage: string; + body: string; + error: Error; + }; + request: { + error: Error; + }; + }; + }; + }; + }; + reset: () => void; +}; diff --git a/packages/happy-dom/test/window/Window.test.ts b/packages/happy-dom/test/window/Window.test.ts index 188e049af..857ed964c 100644 --- a/packages/happy-dom/test/window/Window.test.ts +++ b/packages/happy-dom/test/window/Window.test.ts @@ -12,23 +12,20 @@ import Selection from '../../src/selection/Selection'; import DOMException from '../../src/exception/DOMException'; import DOMExceptionNameEnum from '../../src/exception/DOMExceptionNameEnum'; import CustomElement from '../../test/CustomElement'; - -const MOCKED_NODE_FETCH = global['mockedModules']['node-fetch']; +import { URL } from 'url'; describe('Window', () => { let window: IWindow; let document: IDocument; beforeEach(() => { - MOCKED_NODE_FETCH.url = null; - MOCKED_NODE_FETCH.init = null; - MOCKED_NODE_FETCH.error = null; window = new Window(); document = window.document; window.customElements.define('custom-element', CustomElement); }); afterEach(() => { + mockedModules.reset(); jest.restoreAllMocks(); }); @@ -154,7 +151,7 @@ describe('Window', () => { it(`Handles the "${method}" method with the async task manager.`, async () => { const response = new window.Response(); const result = await response[method](); - expect(result).toBe(MOCKED_NODE_FETCH.response[method]); + expect(result).toBe(mockedModules.modules['node-fetch'].returnValue.response[method]); }); } }); @@ -169,7 +166,7 @@ describe('Window', () => { it(`Handles the "${method}" method with the async task manager.`, async () => { const request = new window.Request('test'); const result = await request[method](); - expect(result).toBe(MOCKED_NODE_FETCH.response[method]); + expect(result).toBe(mockedModules.modules['node-fetch'].returnValue.response[method]); }); } }); @@ -461,45 +458,105 @@ describe('Window', () => { describe('fetch()', () => { for (const method of ['arrayBuffer', 'blob', 'buffer', 'json', 'text', 'textConverted']) { it(`Handles successful "${method}" request.`, async () => { - const expectedUrl = 'https://localhost:8080/path/'; - const expectedOptions = {}; + window.location.href = 'https://localhost:8080'; + document.cookie = 'name1=value1'; + document.cookie = 'name2=value2'; + const expectedUrl = 'https://localhost:8080/path/'; + const expectedOptions = { + method: 'PUT', + headers: { + 'test-header': 'test-value' + } + }; const response = await window.fetch(expectedUrl, expectedOptions); const result = await response[method](); - expect(MOCKED_NODE_FETCH.url).toBe(expectedUrl); - expect(MOCKED_NODE_FETCH.init).toBe(expectedOptions); - expect(result).toEqual(MOCKED_NODE_FETCH.response[method]); + expect(mockedModules.modules['node-fetch'].parameters.init).toEqual({ + ...expectedOptions, + headers: { + ...expectedOptions.headers, + 'user-agent': window.navigator.userAgent, + 'set-cookie': 'name1=value1; name2=value2', + referer: window.location.origin + } + }); + expect(mockedModules.modules['node-fetch'].parameters.url).toBe(expectedUrl); + expect(result).toEqual(mockedModules.modules['node-fetch'].returnValue.response[method]); }); } it('Handles relative URL.', async () => { const expectedPath = '/path/'; - const expectedOptions = {}; window.location.href = 'https://localhost:8080'; - const response = await window.fetch(expectedPath, expectedOptions); + const response = await window.fetch(expectedPath); const textResponse = await response.text(); - expect(MOCKED_NODE_FETCH.url).toBe('https://localhost:8080' + expectedPath); - expect(MOCKED_NODE_FETCH.init).toBe(expectedOptions); - expect(textResponse).toEqual(MOCKED_NODE_FETCH.response.text); + expect(mockedModules.modules['node-fetch'].parameters.url).toBe( + 'https://localhost:8080' + expectedPath + ); + + expect(textResponse).toEqual(mockedModules.modules['node-fetch'].returnValue.response.text); + }); + + it('Handles URL object.', async () => { + const expectedURL = 'https://localhost:8080/path/'; + + window.location.href = 'https://localhost:8080'; + + const response = await window.fetch(new URL(expectedURL)); + const textResponse = await response.text(); + + expect(mockedModules.modules['node-fetch'].parameters.url).toBe(expectedURL); + + expect(textResponse).toEqual(mockedModules.modules['node-fetch'].returnValue.response.text); + }); + + it('Handles Request object with absolute URL.', async () => { + const expectedURL = 'https://localhost:8080/path/'; + + window.location.href = 'https://localhost:8080'; + + const response = await window.fetch(new window.Request(expectedURL)); + const textResponse = await response.text(); + + expect(mockedModules.modules['node-fetch'].parameters.url).toBe(expectedURL); + + expect(textResponse).toEqual(mockedModules.modules['node-fetch'].returnValue.response.text); + }); + + it('Handles Request object with relative URL.', async () => { + const expectedPath = '/path/'; + + window.location.href = 'https://localhost:8080'; + + const response = await window.fetch(new window.Request(expectedPath)); + const textResponse = await response.text(); + + expect(mockedModules.modules['node-fetch'].parameters.url).toBe( + 'https://localhost:8080' + expectedPath + ); + + expect(textResponse).toEqual(mockedModules.modules['node-fetch'].returnValue.response.text); }); it('Handles error JSON request.', async () => { - MOCKED_NODE_FETCH.error = new Error('error'); + mockedModules.modules['node-fetch'].returnValue.error = new Error('error'); + window.location.href = 'https://localhost:8080'; try { - await window.fetch('/url/', {}); + await window.fetch('/url/'); } catch (error) { - expect(error).toBe(MOCKED_NODE_FETCH.error); + expect(error).toBe(mockedModules.modules['node-fetch'].returnValue.error); } }); }); describe('happyDOM.whenAsyncComplete()', () => { it('Resolves the Promise returned by whenAsyncComplete() when all async tasks has been completed.', async () => { + window.location.href = 'https://localhost:8080'; let isFirstWhenAsyncCompleteCalled = false; window.happyDOM.whenAsyncComplete().then(() => { isFirstWhenAsyncCompleteCalled = true; @@ -539,6 +596,7 @@ describe('Window', () => { describe('happyDOM.cancelAsync()', () => { it('Cancels all ongoing asynchrounous tasks.', (done) => { + window.location.href = 'https://localhost:8080'; let isFirstWhenAsyncCompleteCalled = false; window.happyDOM.whenAsyncComplete().then(() => { isFirstWhenAsyncCompleteCalled = true; diff --git a/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts b/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts new file mode 100644 index 000000000..886f36e7d --- /dev/null +++ b/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts @@ -0,0 +1,938 @@ +import XMLHttpRequest from '../../src/xml-http-request/XMLHttpRequest'; +import Window from '../../src/window/Window'; +import IWindow from '../../src/window/IWindow'; +import XMLHttpRequestReadyStateEnum from '../../src/xml-http-request/XMLHttpRequestReadyStateEnum'; +import XMLHttpResponseTypeEnum from '../../src/xml-http-request/XMLHttpResponseTypeEnum'; +import XMLHttpRequestSyncRequestScriptBuilder from '../../src/xml-http-request/utilities/XMLHttpRequestSyncRequestScriptBuilder'; +import XMLHttpRequestCertificate from '../../src/xml-http-request/XMLHttpRequestCertificate'; +import { ProgressEvent } from 'src'; + +const WINDOW_URL = 'https://localhost:8080'; +const REQUEST_URL = '/path/to/resource/'; +const FORBIDDEN_REQUEST_METHODS = ['TRACE', 'TRACK', 'CONNECT']; +const FORBIDDEN_REQUEST_HEADERS = [ + 'accept-charset', + 'accept-encoding', + 'access-control-request-headers', + 'access-control-request-method', + 'connection', + 'content-length', + 'content-transfer-encoding', + 'cookie', + 'cookie2', + 'date', + 'expect', + 'host', + 'keep-alive', + 'origin', + 'referer', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'via' +]; + +describe('XMLHttpRequest', () => { + let window: IWindow; + let request: XMLHttpRequest; + + beforeEach(() => { + window = new Window({ + url: WINDOW_URL + }); + request = new window.XMLHttpRequest(); + }); + + afterEach(() => { + mockedModules.reset(); + }); + + describe('get status()', () => { + it('Returns status for synchrounous requests.', () => { + expect(request.status).toBe(null); + + request.open('GET', REQUEST_URL, false); + request.send(); + + expect(request.status).toBe(200); + }); + + it('Returns status for asynchrounous requests.', (done) => { + expect(request.status).toBe(null); + + request.open('GET', REQUEST_URL, true); + + request.addEventListener('load', () => { + expect(request.status).toBe(200); + done(); + }); + + request.send(); + }); + }); + + describe('get statusText()', () => { + it('Returns status text for synchrounous requests.', () => { + expect(request.statusText).toBe(null); + + request.open('GET', REQUEST_URL, false); + request.send(); + + expect(request.statusText).toBe('child_process.execFileSync.returnValue.data.statusMessage'); + }); + + it('Returns status text for asynchrounous requests.', (done) => { + expect(request.statusText).toBe(null); + + request.open('GET', REQUEST_URL, true); + + request.addEventListener('load', () => { + expect(request.statusText).toBe('http.request.statusMessage'); + done(); + }); + + request.send(); + }); + }); + + describe('get responseURL()', () => { + it('Returns response URL for synchrounous requests.', () => { + expect(request.responseURL).toBe(''); + + request.open('GET', REQUEST_URL, false); + request.send(); + + expect(request.responseURL).toBe(WINDOW_URL + REQUEST_URL); + }); + + it('Returns response URL for asynchrounous requests.', (done) => { + expect(request.responseURL).toBe(''); + + request.open('GET', REQUEST_URL, true); + + request.addEventListener('load', () => { + expect(request.responseURL).toBe(WINDOW_URL + REQUEST_URL); + done(); + }); + + request.send(); + }); + }); + + describe('get readyState()', () => { + it('Returns ready state for synchrounous requests.', () => { + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.unsent); + + request.open('GET', REQUEST_URL, false); + request.send(); + + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + }); + + it('Returns ready state for asynchrounous requests.', (done) => { + let isProgressTriggered = false; + + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.unsent); + + request.open('GET', REQUEST_URL, true); + + request.addEventListener('progress', () => { + isProgressTriggered = true; + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.loading); + }); + + request.addEventListener('load', () => { + expect(isProgressTriggered).toBe(true); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + done(); + }); + + request.send(); + }); + }); + + describe('get responseText()', () => { + it('Returns response text for synchrounous requests.', () => { + request.open('GET', REQUEST_URL, false); + request.send(); + expect(request.responseText).toBe('child_process.execFileSync.returnValue.data.text'); + }); + + it('Returns response text for asynchrounous requests.', (done) => { + request.open('GET', REQUEST_URL, true); + request.addEventListener('load', () => { + expect(request.responseText).toBe('http.request.body'); + done(); + }); + request.send(); + }); + + it(`Throws an exception if responseType is not empty string or "${XMLHttpResponseTypeEnum.text}".`, () => { + request.responseType = XMLHttpResponseTypeEnum.json; + expect(() => request.responseText).toThrowError( + `Failed to read the 'responseText' property from 'XMLHttpRequest': The value is only accessible if the object's 'responseType' is '' or 'text' (was '${XMLHttpResponseTypeEnum.json}').` + ); + }); + }); + + describe('set responseType()', () => { + it('Sets response type.', () => { + request.responseType = XMLHttpResponseTypeEnum.document; + expect(request.responseType).toBe(XMLHttpResponseTypeEnum.document); + }); + + it(`Throws an exception if readyState is "loading".`, (done) => { + request.open('GET', REQUEST_URL, true); + request.addEventListener('progress', () => { + expect(() => (request.responseType = XMLHttpResponseTypeEnum.json)).toThrowError( + `Failed to set the 'responseType' property on 'XMLHttpRequest': The object's state must be OPENED or UNSENT.` + ); + }); + request.addEventListener('load', () => done()); + request.send(); + }); + + it(`Throws an exception if the request is synchrounous.`, () => { + request.open('GET', REQUEST_URL, false); + expect(() => (request.responseType = XMLHttpResponseTypeEnum.json)).toThrowError( + `Failed to set the 'responseType' property on 'XMLHttpRequest': The response type cannot be changed for synchronous requests made from a document.` + ); + }); + }); + + describe('open()', () => { + it('Opens a request.', () => { + request.open('GET', REQUEST_URL, true); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.opened); + }); + + it('Throws an exception for forbidden request methods.', () => { + for (const forbiddenMethod of FORBIDDEN_REQUEST_METHODS) { + expect(() => request.open(forbiddenMethod, REQUEST_URL, true)).toThrowError( + 'Request method not allowed' + ); + } + }); + + it(`Throws an exception if the request is set to be synchronous and responseType is not ${XMLHttpResponseTypeEnum.text}.`, () => { + request.responseType = XMLHttpResponseTypeEnum.json; + expect(() => request.open('GET', REQUEST_URL, false)).toThrowError( + `Failed to execute 'open' on 'XMLHttpRequest': Synchronous requests from a document must not set a response type.` + ); + }); + }); + + describe('setRequestHeader()', () => { + it('Sets a request header on a synchronous request.', () => { + request.open('GET', REQUEST_URL, false); + expect(request.setRequestHeader('test-header', 'test')).toBe(true); + request.send(); + + expect(mockedModules.modules.child_process.execFileSync.parameters.args[1]).toBe( + XMLHttpRequestSyncRequestScriptBuilder.getScript( + { + host: 'localhost', + port: 8080, + path: REQUEST_URL, + method: 'GET', + headers: { + accept: '*/*', + referer: WINDOW_URL + '/', + 'user-agent': window.navigator.userAgent, + cookie: '', + 'test-header': 'test', + host: window.location.host + }, + agent: false, + rejectUnauthorized: true, + key: XMLHttpRequestCertificate.key, + cert: XMLHttpRequestCertificate.cert + }, + true + ) + ); + }); + + it('Sets a request header on an asynchrounous request.', (done) => { + request.open('GET', REQUEST_URL, true); + expect(request.setRequestHeader('test-header', 'test')).toBe(true); + request.addEventListener('load', () => { + expect(mockedModules.modules.http.request.parameters.options['headers']).toEqual({ + accept: '*/*', + cookie: '', + host: window.location.host, + referer: WINDOW_URL + '/', + 'user-agent': window.navigator.userAgent, + 'test-header': 'test' + }); + done(); + }); + request.send(); + }); + + it('Does not set forbidden headers.', () => { + request.open('GET', REQUEST_URL, true); + for (const header of FORBIDDEN_REQUEST_HEADERS) { + expect(request.setRequestHeader(header, 'test')).toBe(false); + } + }); + + it(`Throws an exception if ready state is not "opened".`, () => { + expect(() => request.setRequestHeader('key', 'value')).toThrowError( + `Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED.` + ); + }); + }); + + describe('getResponseHeader()', () => { + it('Returns response header for a synchrounous request.', () => { + mockedModules.modules.child_process.execFileSync.returnValue.data.headers['set-cookie'] = + 'cookie'; + mockedModules.modules.child_process.execFileSync.returnValue.data.headers['set-cookie2'] = + 'cookie'; + + request.open('GET', REQUEST_URL, false); + request.send(); + + expect(request.getResponseHeader('key1')).toBe('value1'); + expect(request.getResponseHeader('key2')).toBe('value2'); + expect(request.getResponseHeader('key3')).toBe(null); + + // These cookies should always be null for security reasons. + expect(request.getResponseHeader('set-cookie')).toBe(null); + expect(request.getResponseHeader('set-cookie2')).toBe(null); + }); + + it('Returns response header for an asynchrounous request.', (done) => { + mockedModules.modules.http.request.returnValue.response.headers['set-cookie'] = 'cookie'; + mockedModules.modules.http.request.returnValue.response.headers['set-cookie2'] = 'cookie'; + + request.open('GET', REQUEST_URL, false); + + request.addEventListener('load', () => { + expect(request.getResponseHeader('key1')).toBe('value1'); + expect(request.getResponseHeader('key2')).toBe('value2'); + expect(request.getResponseHeader('key3')).toBe(null); + + // These cookies should always be null for security reasons. + expect(request.getResponseHeader('set-cookie')).toBe(null); + expect(request.getResponseHeader('set-cookie2')).toBe(null); + + done(); + }); + + request.send(); + }); + + it('Returns null when there is no response.', () => { + expect(request.getResponseHeader('key1')).toBe(null); + }); + }); + + describe('getAllResponseHeaders()', () => { + it('Returns all response headers for a synchrounous request.', () => { + mockedModules.modules.child_process.execFileSync.returnValue.data.headers['set-cookie'] = + 'cookie'; + mockedModules.modules.child_process.execFileSync.returnValue.data.headers['set-cookie2'] = + 'cookie'; + + request.open('GET', REQUEST_URL, false); + request.send(); + + expect(request.getAllResponseHeaders()).toBe( + 'key1: value1\r\nkey2: value2\r\ncontent-length: 48' + ); + }); + + it('Returns all response headers for an asynchrounous request.', (done) => { + mockedModules.modules.http.request.returnValue.response.headers['set-cookie'] = 'cookie'; + mockedModules.modules.http.request.returnValue.response.headers['set-cookie2'] = 'cookie'; + + request.open('GET', REQUEST_URL, true); + + request.addEventListener('load', () => { + expect(request.getAllResponseHeaders()).toBe( + 'key1: value1\r\nkey2: value2\r\ncontent-length: 17' + ); + done(); + }); + + request.send(); + }); + + it('Returns empty string when there is no response.', () => { + expect(request.getAllResponseHeaders()).toBe(''); + }); + }); + + describe('send()', () => { + it('Throws an exception if the request has not been opened.', () => { + expect(() => request.send()).toThrowError( + `Failed to execute 'send' on 'XMLHttpRequest': Connection must be opened before send() is called.` + ); + }); + + it('Throws an exception if the request has already been sent.', () => { + request.open('GET', REQUEST_URL, true); + request.send(); + + expect(() => request.send()).toThrowError( + `Failed to execute 'send' on 'XMLHttpRequest': Send has already been called.` + ); + }); + + it('Throws an exception when the page is HTTPS and the request is HTTP.', () => { + const unsecureURL = 'http://unsecure.happydom'; + request.open('GET', unsecureURL, false); + + expect(() => request.send()).toThrowError( + `Mixed Content: The page at '${window.location.href}' was loaded over HTTPS, but requested an insecure XMLHttpRequest endpoint '${unsecureURL}/'. This request has been blocked; the content must be served over HTTPS.` + ); + }); + + it('Throws an exception when doing a synchronous request towards a local file if "window.happyDOM.settings.enableFileSystemHttpRequests" has not been enabled.', () => { + request.open('GET', 'file://C:/path/to/file.txt', false); + + expect(() => request.send()).toThrowError( + 'File system is disabled by default for security reasons. To enable it, set the "window.happyDOM.settings.enableFileSystemHttpRequests" option to true.' + ); + }); + + it('Throws an exception when doing an asynchronous request towards a local file if "window.happyDOM.settings.enableFileSystemHttpRequests" has not been enabled.', () => { + request.open('GET', 'file://C:/path/to/file.txt', true); + + expect(() => request.send()).toThrowError( + 'File system is disabled by default for security reasons. To enable it, set the "window.happyDOM.settings.enableFileSystemHttpRequests" option to true.' + ); + }); + + it('Throws an exception when doing a synchronous request towards a local file with another method than "GET".', () => { + window.happyDOM.settings.enableFileSystemHttpRequests = true; + + request.open('POST', 'file://C:/path/to/file.txt', false); + + expect(() => request.send()).toThrowError( + 'Failed to send local file system request. Only "GET" method is supported for local file system requests.' + ); + }); + + it('Throws an exception when doing a asynchronous request towards a local file with another method than "GET".', () => { + window.happyDOM.settings.enableFileSystemHttpRequests = true; + + request.open('POST', 'file://C:/path/to/file.txt', true); + + expect(() => request.send()).toThrowError( + 'Failed to send local file system request. Only "GET" method is supported for local file system requests.' + ); + }); + + it('Performs a synchronous request towards a local file.', () => { + window.happyDOM.settings.enableFileSystemHttpRequests = true; + + request.open('GET', 'file://C:/path/to/file.txt', false); + + request.send(); + + expect(mockedModules.modules.fs.readFileSync.parameters.path).toBe('C:/path/to/file.txt'); + expect(request.responseText).toBe('fs.readFileSync'); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + }); + + it('Performs an asynchronous request towards a local file.', (done) => { + const expectedResponseText = 'fs.promises.readFile'; + window.happyDOM.settings.enableFileSystemHttpRequests = true; + + request.open('GET', 'file://C:/path/to/file.txt', true); + + let isProgressTriggered = false; + + request.addEventListener('progress', (event: ProgressEvent) => { + isProgressTriggered = true; + expect(event.lengthComputable).toBe(true); + expect(event.loaded).toBe(expectedResponseText.length); + expect(event.total).toBe(expectedResponseText.length); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.loading); + }); + + request.addEventListener('load', () => { + expect(mockedModules.modules.fs.promises.readFile.parameters.path).toBe( + 'C:/path/to/file.txt' + ); + expect(request.responseText).toBe(expectedResponseText); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + expect(isProgressTriggered).toBe(true); + done(); + }); + + request.send(); + }); + + it('Performs a synchronous GET request with the HTTP protocol.', () => { + const windowURL = 'http://localhost:8080'; + + window.happyDOM.setURL(windowURL); + + request.open('GET', REQUEST_URL, false); + + request.send(); + + expect( + mockedModules.modules.child_process.execFileSync.parameters.command.endsWith('node') + ).toBe(true); + + expect(mockedModules.modules.child_process.execFileSync.parameters.options).toEqual({ + encoding: 'buffer', + maxBuffer: 1024 * 1024 * 1024 + }); + + expect(mockedModules.modules.child_process.execFileSync.parameters.args[1]).toBe( + XMLHttpRequestSyncRequestScriptBuilder.getScript( + { + host: 'localhost', + port: 8080, + path: REQUEST_URL, + method: 'GET', + headers: { + accept: '*/*', + referer: windowURL + '/', + 'user-agent': window.navigator.userAgent, + cookie: '', + host: window.location.host + }, + agent: false, + rejectUnauthorized: true, + key: null, + cert: null + }, + false + ) + ); + + expect(request.responseText).toBe('child_process.execFileSync.returnValue.data.text'); + }); + + it('Performs a synchronous GET request with the HTTPS protocol.', () => { + request.open('GET', REQUEST_URL, false); + + request.send(); + + expect( + mockedModules.modules.child_process.execFileSync.parameters.command.endsWith('node') + ).toBe(true); + + expect(mockedModules.modules.child_process.execFileSync.parameters.options).toEqual({ + encoding: 'buffer', + maxBuffer: 1024 * 1024 * 1024 + }); + + expect(mockedModules.modules.child_process.execFileSync.parameters.args[1]).toBe( + XMLHttpRequestSyncRequestScriptBuilder.getScript( + { + host: 'localhost', + port: 8080, + path: REQUEST_URL, + method: 'GET', + headers: { + accept: '*/*', + referer: WINDOW_URL + '/', + 'user-agent': window.navigator.userAgent, + cookie: '', + host: window.location.host + }, + agent: false, + rejectUnauthorized: true, + key: XMLHttpRequestCertificate.key, + cert: XMLHttpRequestCertificate.cert + }, + true + ) + ); + + expect(request.responseText).toBe('child_process.execFileSync.returnValue.data.text'); + }); + + it('Performs an asynchronous GET request with the HTTP protocol listening to the "loadend" event.', (done) => { + const expectedResponseText = 'http.request.body'; + const windowURL = 'http://localhost:8080'; + + window.location.href = windowURL; + + request.open('GET', REQUEST_URL, true); + + let isProgressTriggered = false; + + request.addEventListener('progress', (event: ProgressEvent) => { + isProgressTriggered = true; + expect(event.lengthComputable).toBe(true); + expect(event.loaded).toBe(expectedResponseText.length); + expect(event.total).toBe(expectedResponseText.length); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.loading); + }); + + request.addEventListener('loadend', () => { + expect(mockedModules.modules.http.request.parameters.options).toEqual({ + host: 'localhost', + port: 8080, + path: REQUEST_URL, + method: 'GET', + headers: { + accept: '*/*', + referer: windowURL + '/', + 'user-agent': window.navigator.userAgent, + cookie: '', + host: window.location.host + }, + agent: false, + rejectUnauthorized: true, + key: null, + cert: null + }); + expect(mockedModules.modules.http.request.internal.body).toBe(''); + expect(request.responseText).toBe(expectedResponseText); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + expect(isProgressTriggered).toBe(true); + done(); + }); + + request.send(); + }); + + it('Performs an asynchronous GET request with the HTTPS protocol and query string.', (done) => { + const expectedResponseText = 'http.request.body'; + const queryString = '?query=string'; + + request.open('GET', REQUEST_URL + queryString, true); + + let isProgressTriggered = false; + + request.addEventListener('progress', (event: ProgressEvent) => { + isProgressTriggered = true; + expect(event.lengthComputable).toBe(true); + expect(event.loaded).toBe(expectedResponseText.length); + expect(event.total).toBe(expectedResponseText.length); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.loading); + }); + + request.addEventListener('load', () => { + expect(mockedModules.modules.http.request.parameters.options).toEqual({ + host: 'localhost', + port: 8080, + path: REQUEST_URL + queryString, + method: 'GET', + headers: { + accept: '*/*', + referer: WINDOW_URL + '/', + 'user-agent': window.navigator.userAgent, + cookie: '', + host: window.location.host + }, + agent: false, + rejectUnauthorized: true, + key: XMLHttpRequestCertificate.key, + cert: XMLHttpRequestCertificate.cert + }); + expect(mockedModules.modules.http.request.internal.body).toBe(''); + expect(request.responseText).toBe(expectedResponseText); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + expect(isProgressTriggered).toBe(true); + done(); + }); + + request.send(); + }); + + it('Handles responses without content length.', (done) => { + const expectedResponseText = 'http.request.body'; + + delete mockedModules.modules.http.request.returnValue.response.headers['content-length']; + + request.open('GET', REQUEST_URL, true); + + request.addEventListener('progress', (event: ProgressEvent) => { + expect(event.lengthComputable).toBe(false); + expect(event.loaded).toBe(expectedResponseText.length); + expect(event.total).toBe(0); + done(); + }); + + request.send(); + }); + + it('Performs an asynchronous GET request with the HTTPS protocol.', (done) => { + const expectedResponseText = 'http.request.body'; + + request.open('GET', REQUEST_URL, true); + + let isProgressTriggered = false; + + request.addEventListener('progress', (event: ProgressEvent) => { + isProgressTriggered = true; + expect(event.lengthComputable).toBe(true); + expect(event.loaded).toBe(expectedResponseText.length); + expect(event.total).toBe(expectedResponseText.length); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.loading); + }); + + request.addEventListener('load', () => { + expect(mockedModules.modules.http.request.parameters.options).toEqual({ + host: 'localhost', + port: 8080, + path: REQUEST_URL, + method: 'GET', + headers: { + accept: '*/*', + referer: WINDOW_URL + '/', + 'user-agent': window.navigator.userAgent, + cookie: '', + host: window.location.host + }, + agent: false, + rejectUnauthorized: true, + key: XMLHttpRequestCertificate.key, + cert: XMLHttpRequestCertificate.cert + }); + expect(mockedModules.modules.http.request.internal.body).toBe(''); + expect(request.responseText).toBe(expectedResponseText); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + expect(isProgressTriggered).toBe(true); + done(); + }); + + request.send(); + }); + + it('Performs an asynchronous basic auth request with username and password.', (done) => { + const username = 'username'; + const password = 'password'; + const expectedResponseText = 'http.request.body'; + + request.open('GET', REQUEST_URL, true, username, password); + + let isProgressTriggered = false; + + request.addEventListener('progress', (event: ProgressEvent) => { + isProgressTriggered = true; + expect(event.lengthComputable).toBe(true); + expect(event.loaded).toBe(expectedResponseText.length); + expect(event.total).toBe(expectedResponseText.length); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.loading); + }); + + request.addEventListener('load', () => { + expect(mockedModules.modules.http.request.parameters.options).toEqual({ + host: 'localhost', + port: 8080, + path: REQUEST_URL, + method: 'GET', + headers: { + accept: '*/*', + referer: WINDOW_URL + '/', + 'user-agent': window.navigator.userAgent, + cookie: '', + host: window.location.host, + authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` + }, + agent: false, + rejectUnauthorized: true, + key: XMLHttpRequestCertificate.key, + cert: XMLHttpRequestCertificate.cert + }); + expect(mockedModules.modules.http.request.internal.body).toBe(''); + expect(request.responseText).toBe(expectedResponseText); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + expect(isProgressTriggered).toBe(true); + done(); + }); + + request.send(); + }); + + it('Performs an asynchronous basic auth request with only username.', (done) => { + const username = 'username'; + const expectedResponseText = 'http.request.body'; + + request.open('GET', REQUEST_URL, true, username); + + request.addEventListener('load', () => { + expect(mockedModules.modules.http.request.parameters.options).toEqual({ + host: 'localhost', + port: 8080, + path: REQUEST_URL, + method: 'GET', + headers: { + accept: '*/*', + referer: WINDOW_URL + '/', + 'user-agent': window.navigator.userAgent, + cookie: '', + host: window.location.host, + authorization: `Basic ${Buffer.from(`${username}:`).toString('base64')}` + }, + agent: false, + rejectUnauthorized: true, + key: XMLHttpRequestCertificate.key, + cert: XMLHttpRequestCertificate.cert + }); + expect(mockedModules.modules.http.request.internal.body).toBe(''); + expect(request.responseText).toBe(expectedResponseText); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + done(); + }); + + request.send(); + }); + + it('Performs an asynchronous POST request.', (done) => { + const postData = 'post.data'; + const expectedResponseText = 'http.request.body'; + + request.open('POST', REQUEST_URL, true); + + let isProgressTriggered = false; + + request.addEventListener('progress', (event: ProgressEvent) => { + isProgressTriggered = true; + expect(event.lengthComputable).toBe(true); + expect(event.loaded).toBe(expectedResponseText.length); + expect(event.total).toBe(expectedResponseText.length); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.loading); + }); + + request.addEventListener('load', () => { + expect(mockedModules.modules.http.request.parameters.options).toEqual({ + host: 'localhost', + port: 8080, + path: REQUEST_URL, + method: 'POST', + headers: { + accept: '*/*', + 'content-length': postData.length, + 'content-type': 'text/plain;charset=UTF-8', + referer: WINDOW_URL + '/', + 'user-agent': window.navigator.userAgent, + cookie: '', + host: window.location.host + }, + agent: false, + rejectUnauthorized: true, + key: XMLHttpRequestCertificate.key, + cert: XMLHttpRequestCertificate.cert + }); + expect(mockedModules.modules.http.request.internal.body).toBe(postData); + expect(request.responseText).toBe(expectedResponseText); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + expect(isProgressTriggered).toBe(true); + done(); + }); + + request.send(postData); + }); + + it('Handles error in request when performing an asynchronous request.', (done) => { + mockedModules.modules.http.request.returnValue.request.error = new Error('error'); + + request.open('GET', REQUEST_URL, true); + + request.addEventListener('load', () => { + throw new Error('Load event should not be triggered.'); + }); + + request.addEventListener('error', () => { + expect(request.status).toBe(0); + expect(request.statusText).toBe('Error: error'); + expect( + request.responseText.startsWith('Error: error') && request.responseText.includes(' at ') + ).toBe(true); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + done(); + }); + + request.send(); + }); + + it('Handles error in response when performing an asynchronous request.', (done) => { + mockedModules.modules.http.request.returnValue.response.error = new Error('error'); + + request.open('GET', REQUEST_URL, true); + + request.addEventListener('load', () => { + throw new Error('Load event should not be triggered.'); + }); + + request.addEventListener('error', () => { + expect(request.status).toBe(0); + expect(request.statusText).toBe('Error: error'); + expect( + request.responseText.startsWith('Error: error') && request.responseText.includes(' at ') + ).toBe(true); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + done(); + }); + + request.send(); + }); + + it('Handles error in response when performing a synchronous request.', (done) => { + mockedModules.modules.child_process.execFileSync.returnValue.error = 'Error'; + + request.open('GET', REQUEST_URL, false); + + request.addEventListener('error', () => { + expect(request.status).toBe(0); + expect(request.statusText).toBe('Error'); + expect(request.responseText).toBe(''); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + done(); + }); + + request.send(); + }); + + it('Handles Happy DOM asynchrounous tasks.', async () => { + request.open('GET', REQUEST_URL, true); + request.send(); + + await window.happyDOM.whenAsyncComplete(); + + expect(request.responseText).toBe('http.request.body'); + }); + }); + + describe('abort()', () => { + it('Aborts an asynchrounous request.', () => { + let isAbortTriggered = false; + let isLoadEndTriggered = false; + request.open('GET', REQUEST_URL, true); + request.send(); + request.addEventListener('abort', () => { + isAbortTriggered = true; + }); + request.addEventListener('loadend', () => { + isLoadEndTriggered = true; + }); + request.abort(); + expect(isAbortTriggered).toBe(true); + expect(isLoadEndTriggered).toBe(true); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.unsent); + }); + + it('Ends an ongoing Happy DOM asynchrounous task.', async () => { + request.open('GET', REQUEST_URL, true); + request.send(); + request.abort(); + + await window.happyDOM.whenAsyncComplete(); + + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.unsent); + }); + + it('Aborts an ongoing request when cancelling all Happy DOM asynchrounous tasks.', async () => { + request.open('GET', REQUEST_URL, true); + request.send(); + + window.happyDOM.cancelAsync(); + + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.unsent); + }); + }); +}); diff --git a/packages/jest-environment/test/javascript/JavaScript.test.ts b/packages/jest-environment/test/javascript/JavaScript.test.ts index 3729d5838..5c952d170 100644 --- a/packages/jest-environment/test/javascript/JavaScript.test.ts +++ b/packages/jest-environment/test/javascript/JavaScript.test.ts @@ -26,6 +26,49 @@ describe('JavaScript', () => { expect(body.includes('node_modules')).toBe(true); }); + it('Can perform a real asynchronous XMLHttpRequest request to Github.com', (done) => { + const request = new XMLHttpRequest(); + + request.open( + 'GET', + 'https://raw.githubusercontent.com/capricorn86/happy-dom/master/.gitignore', + true + ); + + request.addEventListener('load', () => { + expect(request.getResponseHeader('content-type')).toBe('text/plain; charset=utf-8'); + expect(request.responseText.includes('node_modules')).toBe(true); + expect(request.status).toBe(200); + expect(request.statusText).toBe('OK'); + expect(request.responseURL).toBe( + 'https://raw.githubusercontent.com/capricorn86/happy-dom/master/.gitignore' + ); + done(); + }); + + request.send(); + }); + + it('Can perform a real synchronous XMLHttpRequest request to Github.com', () => { + const request = new XMLHttpRequest(); + + request.open( + 'GET', + 'https://raw.githubusercontent.com/capricorn86/happy-dom/master/.gitignore', + false + ); + + request.send(); + + expect(request.getResponseHeader('content-type')).toBe('text/plain; charset=utf-8'); + expect(request.responseText.includes('node_modules')).toBe(true); + expect(request.status).toBe(200); + expect(request.statusText).toBe('OK'); + expect(request.responseURL).toBe( + 'https://raw.githubusercontent.com/capricorn86/happy-dom/master/.gitignore' + ); + }); + it('Binds global methods to the Window context', () => { const eventListener = (): void => {}; addEventListener('click', eventListener);