Repository: capricorn86/happy-dom
Pull request #134 (2020-10-22 14:24:48)

#133@patch: Fixes bug where async tasks doesn't get canceled properly…
… in Jest.


Patch:
diff --git a/packages/happy-dom/README.md b/packages/happy-dom/README.md
index 1ee0cbac..5aee6911 100644
--- a/packages/happy-dom/README.md
+++ b/packages/happy-dom/README.md
@@ -143,8 +143,12 @@ Has all the basic functionality of a DOM except for fetch(). This has been exclu
 
 AsyncWindow extends Window and adds support for fetch(). It also has a method called "window.whenAsyncComplete()" which 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.
 
+**whenAsyncComplete()**
 The "window.whenAsyncComplete()" method can be used when executing scripts that contains asynchrounous tasks that has to be completed before the render of the page has been completed.
 
+**cancelAsync()**
+The "window.cancelAsync()" will cancel all running async tasks.
+
 
 # Performance
 
diff --git a/packages/happy-dom/src/window/AsyncTaskManager.ts b/packages/happy-dom/src/window/AsyncTaskManager.ts
index 3c1614e4..be2f96a7 100644
--- a/packages/happy-dom/src/window/AsyncTaskManager.ts
+++ b/packages/happy-dom/src/window/AsyncTaskManager.ts
@@ -1,12 +1,10 @@
+import AsyncTaskTypeEnum from './AsyncTaskTypeEnum';
+
 /**
  * Handles async tasks.
  */
 export default class AsyncTaskManager {
-	// Private Properties
-	private isDisposed = false;
-	private tasks: { [k: string]: number } = {};
-	private hasError = false;
-	private timeout: NodeJS.Timeout = null;
+	private tasks: { [k: string]: (NodeJS.Timeout | string)[] } = {};
 	private queue: { resolve: () => void; reject: (error: Error) => void }[] = [];
 
 	/**
@@ -17,59 +15,98 @@ export default class AsyncTaskManager {
 	 */
 	public async whenComplete(): Promise<void> {
 		return new Promise((resolve, reject) => {
-			this.startTask('complete');
+			const timerID = global.setTimeout(() => {
+				this.endTimer(timerID);
+			}, 0);
+			this.startTimer(timerID);
 			this.queue.push({ resolve, reject });
-			global.clearTimeout(this.timeout);
-			this.timeout = global.setTimeout(() => this.endTask('complete'), 0);
 		});
 	}
 
 	/**
-	 * Disposes the window.
+	 * Cancels all tasks.
+	 *
+	 * @param [error] Error.
 	 */
-	public dispose(): void {
-		this.isDisposed = true;
-		this.hasError = false;
+	public cancelAllTasks(error?: Error): void {
+		if (this.tasks[AsyncTaskTypeEnum.timer]) {
+			for (const id of this.tasks[AsyncTaskTypeEnum.timer]) {
+				global.clearTimeout(<NodeJS.Timeout>id);
+			}
+		}
+
+		const promises = this.queue;
+
 		this.tasks = {};
 		this.queue = [];
-		global.clearTimeout(this.timeout);
+
+		for (const promise of promises) {
+			if (error) {
+				promise.reject(error);
+			} else {
+				promise.resolve();
+			}
+		}
+	}
+
+	/**
+	 * Starts a timer.
+	 *
+	 * @param id Timer ID.
+	 */
+	public startTimer(id: NodeJS.Timeout = null): void {
+		this.tasks[AsyncTaskTypeEnum.timer] = this.tasks[AsyncTaskTypeEnum.timer] || [];
+		this.tasks[AsyncTaskTypeEnum.timer].push(id);
+	}
+
+	/**
+	 * Ends a timer.
+	 *
+	 * @param id Timer ID.
+	 */
+	public endTimer(id: NodeJS.Timeout = null): void {
+		if (this.tasks[AsyncTaskTypeEnum.timer]) {
+			const index = this.tasks[AsyncTaskTypeEnum.timer].indexOf(id);
+			if (index !== -1) {
+				this.tasks[AsyncTaskTypeEnum.timer].splice(index, 1);
+				if (this.tasks[AsyncTaskTypeEnum.timer].length === 0) {
+					delete this.tasks[AsyncTaskTypeEnum.timer];
+
+					if (Object.keys(this.tasks).length === 0) {
+						this.cancelAllTasks();
+					}
+				}
+			}
+		}
 	}
 
 	/**
 	 * Starts an async task.
 	 *
-	 * @param name Name of task.
+	 * @param type Task type.
 	 */
-	public startTask(name: string): void {
-		this.tasks[name] = this.tasks[name] || 0;
-		this.tasks[name]++;
+	public startTask(type: AsyncTaskTypeEnum): void {
+		this.tasks[type] = this.tasks[type] || [];
+		this.tasks[type].push(type);
 	}
 
 	/**
 	 * Ends an async task.
 	 *
-	 * @param name Name of task.
+	 * @param type Task type.
 	 * @param [error] Error.
 	 */
-	public endTask(name: string, error?: Error): void {
-		if (!this.isDisposed && this.tasks[name] !== undefined) {
-			this.tasks[name]--;
+	public endTask(type: AsyncTaskTypeEnum, error?: Error): void {
+		if (error) {
+			this.cancelAllTasks(error);
+		} else if (this.tasks[type]) {
+			this.tasks[type].pop();
 
-			const promises = this.queue;
+			if (this.tasks[type].length === 0) {
+				delete this.tasks[type];
 
-			if (error) {
-				this.hasError = true;
-				this.queue = [];
-				for (const promise of promises) {
-					promise.reject(error);
-				}
-			} else if (this.tasks[name] === 0 && !this.hasError) {
-				delete this.tasks[name];
 				if (Object.keys(this.tasks).length === 0) {
-					this.queue = [];
-					for (const promise of promises) {
-						promise.resolve();
-					}
+					this.cancelAllTasks();
 				}
 			}
 		}
diff --git a/packages/happy-dom/src/window/AsyncTaskTypeEnum.ts b/packages/happy-dom/src/window/AsyncTaskTypeEnum.ts
new file mode 100644
index 00000000..cae401bc
--- /dev/null
+++ b/packages/happy-dom/src/window/AsyncTaskTypeEnum.ts
@@ -0,0 +1,5 @@
+enum AsyncTaskTypeEnum {
+	timer = 'timer',
+	fetch = 'fetch'
+}
+export default AsyncTaskTypeEnum;
diff --git a/packages/happy-dom/src/window/AsyncWindow.ts b/packages/happy-dom/src/window/AsyncWindow.ts
index 4f1684b4..48077de6 100644
--- a/packages/happy-dom/src/window/AsyncWindow.ts
+++ b/packages/happy-dom/src/window/AsyncWindow.ts
@@ -3,6 +3,7 @@ import { URLSearchParams } from 'url';
 import { Readable } from 'stream';
 import Window from './Window';
 import AsyncTaskManager from './AsyncTaskManager';
+import AsyncTaskTypeEnum from './AsyncTaskTypeEnum';
 
 const FETCH_RESPONSE_TYPE_METHODS = ['blob', 'json', 'formData', 'text'];
 
@@ -11,7 +12,7 @@ const FETCH_RESPONSE_TYPE_METHODS = ['blob', 'json', 'formData', 'text'];
  */
 export default class AsyncWindow extends Window {
 	// Private Properties
-	private async = new AsyncTaskManager();
+	private _asyncTaskManager = new AsyncTaskManager();
 
 	/**
 	 * Constructor.
@@ -32,15 +33,16 @@ export default class AsyncWindow extends Window {
 	 *
 	 * @override
 	 * @param callback Function to be executed.
-	 * @param [delay] Delay in ms.
+	 * @param [delay=0] Delay in ms.
 	 * @return Timeout ID.
 	 */
-	public setTimeout(callback: () => void, delay?: number): NodeJS.Timeout {
-		this.async.startTask('timeout');
-		return global.setTimeout(() => {
+	public setTimeout(callback: () => void, delay = 0): NodeJS.Timeout {
+		const id = global.setTimeout(() => {
+			this._asyncTaskManager.endTimer(id);
 			callback();
-			this.async.endTask('timeout');
 		}, delay);
+		this._asyncTaskManager.startTimer(id);
+		return id;
 	}
 
 	/**
@@ -51,7 +53,7 @@ export default class AsyncWindow extends Window {
 	 */
 	public clearTimeout(id: NodeJS.Timeout): void {
 		global.clearTimeout(id);
-		this.async.endTask('timeout');
+		this._asyncTaskManager.endTimer(id);
 	}
 
 	/**
@@ -59,12 +61,13 @@ export default class AsyncWindow extends Window {
 	 *
 	 * @override
 	 * @param callback Function to be executed.
-	 * @param [delay] Delay in ms.
+	 * @param [delay=0] Delay in ms.
 	 * @return Interval ID.
 	 */
-	public setInterval(callback: () => void, delay?: number): NodeJS.Timeout {
-		this.async.startTask('interval');
-		return global.setInterval(callback, delay);
+	public setInterval(callback: () => void, delay = 0): NodeJS.Timeout {
+		const id = global.setInterval(callback, delay);
+		this._asyncTaskManager.startTimer(id);
+		return id;
 	}
 
 	/**
@@ -75,7 +78,7 @@ export default class AsyncWindow extends Window {
 	 */
 	public clearInterval(id: NodeJS.Timeout): void {
 		global.clearInterval(id);
-		this.async.endTask('interval');
+		this._asyncTaskManager.endTimer(id);
 	}
 
 	/**
@@ -88,7 +91,7 @@ export default class AsyncWindow extends Window {
 	public requestAnimationFrame(callback: (timestamp: number) => void): NodeJS.Timeout {
 		return this.setTimeout(() => {
 			callback(2);
-		}, 0);
+		});
 	}
 
 	/**
@@ -119,7 +122,7 @@ export default class AsyncWindow extends Window {
 		}
 	): Promise<Response> {
 		return new Promise((resolve, reject) => {
-			this.async.startTask('fetch');
+			this._asyncTaskManager.startTask(AsyncTaskTypeEnum.fetch);
 
 			fetch(url, options)
 				.then(response => {
@@ -127,27 +130,27 @@ export default class AsyncWindow extends Window {
 						const asyncMethod = response[methodName];
 						response[methodName] = () => {
 							return new Promise((resolve, reject) => {
-								this.async.startTask('fetch');
+								this._asyncTaskManager.startTask(AsyncTaskTypeEnum.fetch);
 
 								asyncMethod
 									.then(response => {
 										resolve(response);
-										this.async.endTask('fetch');
+										this._asyncTaskManager.endTask(AsyncTaskTypeEnum.fetch);
 									})
 									.catch(error => {
 										reject(error);
-										this.async.endTask('fetch', error);
+										this._asyncTaskManager.endTask(AsyncTaskTypeEnum.fetch, error);
 									});
 							});
 						};
 					}
 
 					resolve(response);
-					this.async.endTask('fetch');
+					this._asyncTaskManager.endTask(AsyncTaskTypeEnum.fetch);
 				})
 				.catch(error => {
 					reject(error);
-					this.async.endTask('fetch', error);
+					this._asyncTaskManager.endTask(AsyncTaskTypeEnum.fetch, error);
 				});
 		});
 	}
@@ -159,15 +162,13 @@ export default class AsyncWindow extends Window {
 	 * @returns Promise.
 	 */
 	public async whenAsyncComplete(): Promise<void> {
-		return this.async.whenComplete();
+		return this._asyncTaskManager.whenComplete();
 	}
 
 	/**
-	 * Disposes the window.
+	 * Cancels all async tasks running.
 	 */
-	public dispose(): void {
-		super.dispose();
-		this.async.dispose();
-		this.async = null;
+	public cancelAsync(): void {
+		this._asyncTaskManager.cancelAllTasks();
 	}
 }
diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts
index f65aba75..875f2146 100644
--- a/packages/happy-dom/src/window/Window.ts
+++ b/packages/happy-dom/src/window/Window.ts
@@ -158,11 +158,6 @@ export default class Window extends EventTarget implements NodeJS.Global {
 	 */
 	public scrollTo(): void {}
 
-	/**
-	 * Disposes the window.
-	 */
-	public dispose(): void {}
-
 	/**
 	 * Sets a timer which executes a function once the timer expires.
 	 *


Test patch:
diff --git a/packages/happy-dom/test/window/AsyncWindow.test.ts b/packages/happy-dom/test/window/AsyncWindow.test.ts
new file mode 100644
index 00000000..6226d15c
--- /dev/null
+++ b/packages/happy-dom/test/window/AsyncWindow.test.ts
@@ -0,0 +1,221 @@
+import AsyncWindow from '../../src/window/AsyncWindow';
+
+describe('AsyncWindow', () => {
+	let window;
+
+	beforeEach(() => {
+		window = new AsyncWindow();
+	});
+
+	afterEach(() => {
+		jest.restoreAllMocks();
+	});
+
+	describe('setTimeout()', () => {
+		test('Sets a timeout.', done => {
+			const timeoutId = window.setTimeout(() => done());
+			expect(timeoutId.constructor.name).toBe('Timeout');
+		});
+	});
+
+	describe('clearTimeout()', () => {
+		test('Clears a timeout.', () => {
+			const timeoutId = window.setTimeout(() => {
+				throw new Error('This timeout should have been canceled.');
+			});
+			window.clearTimeout(timeoutId);
+		});
+	});
+
+	describe('setInterval()', () => {
+		test('Sets an interval.', done => {
+			let count = 0;
+			const intervalId = window.setInterval(() => {
+				count++;
+				if (count > 2) {
+					clearInterval(intervalId);
+					done();
+				}
+			});
+		});
+	});
+
+	describe('clearInterval()', () => {
+		test('Clears an interval.', () => {
+			const intervalId = window.setInterval(() => {
+				throw new Error('This interval should have been canceled.');
+			});
+			window.clearInterval(intervalId);
+		});
+	});
+
+	describe('requestAnimationFrame()', () => {
+		test('Requests an animation frame.', done => {
+			const timeoutId = window.requestAnimationFrame(() => done());
+			expect(timeoutId.constructor.name).toBe('Timeout');
+		});
+	});
+
+	describe('cancelAnimationFrame()', () => {
+		test('Cancels an animation frame.', () => {
+			const timeoutId = window.requestAnimationFrame(() => {
+				throw new Error('This timeout should have been canceled.');
+			});
+			window.cancelAnimationFrame(timeoutId);
+		});
+	});
+
+	describe('fetch()', () => {
+		test('Handles successful JSON request.', async () => {
+			const expectedUrl = '/url/';
+			const expectedOptions = {};
+			const expectedJsonResponse = {};
+			jest.spyOn(window, 'fetch').mockImplementation((url, options) => {
+				expect(url).toBe(expectedUrl);
+				expect(options).toBe(expectedOptions);
+				return Promise.resolve({
+					json: () => Promise.resolve(expectedJsonResponse)
+				});
+			});
+			const response = await window.fetch(expectedUrl, expectedOptions);
+			const jsonResponse = await response.json();
+			expect(jsonResponse).toBe(expectedJsonResponse);
+		});
+
+		test('Handles successful Text request.', async () => {
+			const expectedUrl = '/url/';
+			const expectedOptions = {};
+			const expectedTextResponse = 'response';
+			jest.spyOn(window, 'fetch').mockImplementation((url, options) => {
+				expect(url).toBe(expectedUrl);
+				expect(options).toBe(expectedOptions);
+				return Promise.resolve({
+					text: () => Promise.resolve(expectedTextResponse)
+				});
+			});
+			const response = await window.fetch(expectedUrl, expectedOptions);
+			const textResponse = await response.text();
+			expect(textResponse).toBe(expectedTextResponse);
+		});
+
+		test('Handles error JSON request.', async () => {
+			const expectedUrl = '/url/';
+			const expectedOptions = {};
+			const expectedError = new Error('error');
+			jest.spyOn(window, 'fetch').mockImplementation((url, options) => {
+				expect(url).toBe(expectedUrl);
+				expect(options).toBe(expectedOptions);
+				return Promise.reject(expectedError);
+			});
+			try {
+				await window.fetch(expectedUrl, expectedOptions);
+			} catch (error) {
+				expect(error).toBe(expectedError);
+			}
+		});
+	});
+
+	describe('whenAsyncComplete()', () => {
+		test('Resolves the Promise returned by whenAsyncComplete() when all async tasks has been completed.', async () => {
+			jest.spyOn(window, 'fetch').mockImplementation(() => {
+				return Promise.resolve({
+					json: () => Promise.resolve({}),
+					text: () => Promise.resolve({})
+				});
+			});
+
+			let isFirstWhenAsyncCompleteCalled = false;
+			window.whenAsyncComplete().then(() => {
+				isFirstWhenAsyncCompleteCalled = true;
+			});
+			let tasksDone = 0;
+			const intervalID = window.setInterval(() => {
+				tasksDone++;
+			});
+			window.clearInterval(intervalID);
+			window.setTimeout(() => {
+				tasksDone++;
+			});
+			window.setTimeout(() => {
+				tasksDone++;
+			});
+			window.requestAnimationFrame(() => {
+				tasksDone++;
+			});
+			window.requestAnimationFrame(() => {
+				tasksDone++;
+			});
+			window.fetch('/url/').then(response =>
+				response.json().then(() => {
+					tasksDone++;
+				})
+			);
+			window.fetch('/url/').then(response =>
+				response.text().then(() => {
+					tasksDone++;
+				})
+			);
+			await window.whenAsyncComplete();
+			expect(tasksDone).toBe(6);
+			expect(isFirstWhenAsyncCompleteCalled).toBe(true);
+		});
+	});
+
+	describe('cancelAsync()', () => {
+		test('Cancels all ongoing asynchrounous tasks.', done => {
+			jest.spyOn(window, 'fetch').mockImplementation(() => {
+				return Promise.resolve({
+					json: () => Promise.resolve({}),
+					text: () => Promise.resolve({})
+				});
+			});
+
+			let isFirstWhenAsyncCompleteCalled = false;
+			window.whenAsyncComplete().then(() => {
+				isFirstWhenAsyncCompleteCalled = true;
+			});
+			let tasksDone = 0;
+			const intervalID = window.setInterval(() => {
+				tasksDone++;
+			});
+			window.clearInterval(intervalID);
+			window.setTimeout(() => {
+				tasksDone++;
+			});
+			window.setTimeout(() => {
+				tasksDone++;
+			});
+			window.requestAnimationFrame(() => {
+				tasksDone++;
+			});
+			window.requestAnimationFrame(() => {
+				tasksDone++;
+			});
+			window.fetch('/url/').then(response =>
+				response.json().then(() => {
+					tasksDone++;
+				})
+			);
+			window.fetch('/url/').then(response =>
+				response.text().then(() => {
+					tasksDone++;
+				})
+			);
+
+			let isSecondWhenAsyncCompleteCalled = false;
+			window.whenAsyncComplete().then(() => {
+				isSecondWhenAsyncCompleteCalled = true;
+			});
+
+			window.cancelAsync();
+
+			expect(tasksDone).toBe(0);
+
+			global.setTimeout(() => {
+				expect(isFirstWhenAsyncCompleteCalled).toBe(true);
+				expect(isSecondWhenAsyncCompleteCalled).toBe(true);
+				done();
+			}, 1);
+		});
+	});
+});