diff --git a/README.md b/README.md index bffa902..ac97210 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ clock instance, not the browser's internals. Calling `install` with no arguments achieves this. You can call `uninstall` later to restore things as they were again. -Note that in NodeJS also the [timers](https://nodejs.org/api/timers.html) module will receive fake timers when using global scope. +Note that in NodeJS the [timers](https://nodejs.org/api/timers.html) and [timers/promises](https://nodejs.org/api/timers.html#timers-promises-api) modules will also receive fake timers when using global scope. ```js // In the browser distribution, a global `FakeTimers` is already available @@ -148,7 +148,7 @@ The `loopLimit` argument sets the maximum number of timers that will be run when ### `var clock = FakeTimers.install([config])` Installs FakeTimers using the specified config (otherwise with epoch `0` on the global scope). -Note that in NodeJS also the [timers](https://nodejs.org/api/timers.html) module will receive fake timers when using global scope. +Note that in NodeJS the [timers](https://nodejs.org/api/timers.html) and [timers/promises](https://nodejs.org/api/timers.html#timers-promises-api) modules will also receive fake timers when using global scope. The following configuration options are available | Parameter | Type | Default | Description | diff --git a/src/fake-timers-src.js b/src/fake-timers-src.js index fb8eaaf..f628466 100644 --- a/src/fake-timers-src.js +++ b/src/fake-timers-src.js @@ -1,13 +1,18 @@ "use strict"; const globalObject = require("@sinonjs/commons").global; -let timersModule; +let timersModule, timersPromisesModule; if (typeof require === "function" && typeof module === "object") { try { timersModule = require("timers"); } catch (e) { // ignored } + try { + timersPromisesModule = require("timers/promises"); + } catch (e) { + // ignored + } } /** @@ -94,6 +99,7 @@ if (typeof require === "function" && typeof module === "object") { * @property {Function[]} methods - the methods that are faked * @property {boolean} [shouldClearNativeTimers] inherited from config * @property {{methodName:string, original:any}[] | undefined} timersModuleMethods + * @property {{methodName:string, original:any}[] | undefined} timersPromisesModuleMethods */ /* eslint-enable jsdoc/require-property-description */ @@ -954,6 +960,16 @@ function withGlobal(_global) { timersModule[entry.methodName] = entry.original; } } + if (clock.timersPromisesModuleMethods !== undefined) { + for ( + let j = 0; + j < clock.timersPromisesModuleMethods.length; + j++ + ) { + const entry = clock.timersPromisesModuleMethods[j]; + timersPromisesModule[entry.methodName] = entry.original; + } + } } if (config.shouldAdvanceTime === true) { @@ -1834,6 +1850,9 @@ function withGlobal(_global) { if (_global === globalObject && timersModule) { clock.timersModuleMethods = []; } + if (_global === globalObject && timersPromisesModule) { + clock.timersPromisesModuleMethods = []; + } for (i = 0, l = clock.methods.length; i < l; i++) { const nameOfMethodToReplace = clock.methods[i]; @@ -1872,6 +1891,206 @@ function withGlobal(_global) { timersModule[nameOfMethodToReplace] = _global[nameOfMethodToReplace]; } + if (clock.timersPromisesModuleMethods !== undefined) { + if (nameOfMethodToReplace === "setTimeout") { + clock.timersPromisesModuleMethods.push({ + methodName: "setTimeout", + original: timersPromisesModule.setTimeout, + }); + + timersPromisesModule.setTimeout = ( + delay, + value, + options = {}, + ) => + new Promise((resolve, reject) => { + const abort = () => { + options.signal.removeEventListener( + "abort", + abort, + ); + // This is safe, there is no code path that leads to this function + // being invoked before handle has been assigned. + // eslint-disable-next-line no-use-before-define + clock.clearTimeout(handle); + reject(options.signal.reason); + }; + + const handle = clock.setTimeout(() => { + options.signal?.removeEventListener( + "abort", + abort, + ); + + resolve(value); + }, delay); + + if (options.signal?.aborted) { + abort(); + } else { + options.signal?.addEventListener( + "abort", + abort, + ); + } + }); + } else if (nameOfMethodToReplace === "setImmediate") { + clock.timersPromisesModuleMethods.push({ + methodName: "setImmediate", + original: timersPromisesModule.setImmediate, + }); + + timersPromisesModule.setImmediate = (value, options = {}) => + new Promise((resolve, reject) => { + const abort = () => { + options.signal.removeEventListener( + "abort", + abort, + ); + // This is safe, there is no code path that leads to this function + // being invoked before handle has been assigned. + // eslint-disable-next-line no-use-before-define + clock.clearImmediate(handle); + reject(options.signal.reason); + }; + + const handle = clock.setImmediate(() => { + options.signal?.removeEventListener( + "abort", + abort, + ); + + resolve(value); + }); + + if (options.signal?.aborted) { + abort(); + } else { + options.signal?.addEventListener( + "abort", + abort, + ); + } + }); + } else if (nameOfMethodToReplace === "setInterval") { + clock.timersPromisesModuleMethods.push({ + methodName: "setInterval", + original: timersPromisesModule.setInterval, + }); + + timersPromisesModule.setInterval = ( + delay, + value, + options = {}, + ) => ({ + [Symbol.asyncIterator]: () => { + const createResolvable = () => { + let resolve, reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + promise.resolve = resolve; + promise.reject = reject; + return promise; + }; + + let done = false; + let hasThrown = false; + let returnCall; + let nextAvailable = 0; + const nextQueue = []; + + const handle = clock.setInterval(() => { + if (nextQueue.length > 0) { + nextQueue.shift().resolve(); + } else { + nextAvailable++; + } + }, delay); + + const abort = () => { + options.signal.removeEventListener( + "abort", + abort, + ); + clock.clearInterval(handle); + done = true; + for (const resolvable of nextQueue) { + resolvable.resolve(); + } + }; + + if (options.signal?.aborted) { + done = true; + } else { + options.signal?.addEventListener( + "abort", + abort, + ); + } + + return { + next: async () => { + if (options.signal?.aborted && !hasThrown) { + hasThrown = true; + throw options.signal.reason; + } + + if (done) { + return { done: true, value: undefined }; + } + + if (nextAvailable > 0) { + nextAvailable--; + return { done: false, value: value }; + } + + const resolvable = createResolvable(); + nextQueue.push(resolvable); + + await resolvable; + + if (returnCall && nextQueue.length === 0) { + returnCall.resolve(); + } + + if (options.signal?.aborted && !hasThrown) { + hasThrown = true; + throw options.signal.reason; + } + + if (done) { + return { done: true, value: undefined }; + } + + return { done: false, value: value }; + }, + return: async () => { + if (done) { + return { done: true, value: undefined }; + } + + if (nextQueue.length > 0) { + returnCall = createResolvable(); + await returnCall; + } + + clock.clearInterval(handle); + done = true; + + options.signal?.removeEventListener( + "abort", + abort, + ); + + return { done: true, value: undefined }; + }, + }; + }, + }); + } + } } return clock; diff --git a/test/fake-timers-test.js b/test/fake-timers-test.js index 58fadb2..f067c5e 100644 --- a/test/fake-timers-test.js +++ b/test/fake-timers-test.js @@ -21,7 +21,7 @@ const { utilPromisifyAvailable, } = require("./helpers/setup-tests"); -let timersModule; +let timersModule, timersPromisesModule; /* eslint-disable no-underscore-dangle */ globalObject.__runs = globalObject.__runs || 0; @@ -35,6 +35,11 @@ if (typeof require === "function" && typeof module === "object") { } catch (e) { // ignored } + try { + timersPromisesModule = require("timers/promises"); + } catch (e) { + // ignored + } } describe("FakeTimers", function () { @@ -4859,6 +4864,495 @@ describe("FakeTimers", function () { assert.same(timersModule.setTimeout, original); }); }); + describe("Node timers/promises module", function () { + let clock; + + before(function () { + if (!timersPromisesModule) { + this.skip(); + } + }); + + afterEach(function () { + if (clock) { + clock.uninstall(); + clock = undefined; + } + }); + + it("should install all methods", function () { + const methodNames = ["setTimeout", "setImmediate", "setInterval"]; + const originals = Object.fromEntries( + methodNames.map((it) => [it, timersPromisesModule[it]]), + ); + + clock = FakeTimers.install(); + + for (const methodName of methodNames) { + refute.equals( + timersPromisesModule[methodName], + originals[methodName], + ); + } + }); + it("should uninstall all methods", function () { + const methodNames = ["setTimeout", "setImmediate", "setInterval"]; + const originals = Object.fromEntries( + methodNames.map((it) => [it, timersPromisesModule[it]]), + ); + + clock = FakeTimers.install(); + clock.uninstall(); + + for (const methodName of methodNames) { + assert.equals( + timersPromisesModule[methodName], + originals[methodName], + ); + } + }); + it("should only install & uninstall provided methods", function () { + const methodNames = ["setTimeout", "setImmediate"]; + const originals = Object.fromEntries( + methodNames.map((it) => [it, timersPromisesModule[it]]), + ); + + clock = FakeTimers.install({ + toFake: methodNames, + }); + + for (const methodName of methodNames) { + refute.equals( + timersPromisesModule[methodName], + originals[methodName], + ); + } + + clock.uninstall(); + + for (const methodName of methodNames) { + assert.equals( + timersPromisesModule[methodName], + originals[methodName], + ); + } + }); + it("should not install methods not provided", function () { + const original = timersPromisesModule.setInterval; + clock = FakeTimers.install({ + toFake: ["setTimeout", "setImmediate"], + }); + + assert.equals(timersPromisesModule.setInterval, original); + }); + it("should not install when using custom global object", function () { + const methodNames = ["setTimeout", "setImmediate", "setInterval"]; + const originals = Object.fromEntries( + methodNames.map((it) => [it, timersPromisesModule[it]]), + ); + + clock = FakeTimers.withGlobal({ + Date: Date, + setTimeout: sinon.fake(), + clearTimeout: sinon.fake(), + }).install({ + ignoreMissingTimers: true, + }); + + for (const methodName of methodNames) { + assert.equals( + timersPromisesModule[methodName], + originals[methodName], + ); + } + }); + + describe("The setTimeout function", function () { + it("should resolve after specified time", async function () { + clock = FakeTimers.install(); + const promise = timersPromisesModule.setTimeout(100); + + let resolved = false; + promise.then(() => { + resolved = true; + }); + + await clock.tickAsync(100); + + assert.equals(resolved, true); + }); + it("should not resolve before specified time", async function () { + clock = FakeTimers.install(); + const promise = timersPromisesModule.setTimeout(100); + + let resolved = false; + promise.then(() => { + resolved = true; + }); + + await clock.tickAsync(50); + + assert.equals(resolved, false); + }); + it("should resolve with specified value", async function () { + clock = FakeTimers.install(); + const promise = timersPromisesModule.setTimeout( + 100, + "example value", + ); + + clock.tick(100); + const result = await promise; + assert.equals(result, "example value"); + }); + it("should reject early when aborting", async function () { + clock = FakeTimers.install(); + const abortController = new AbortController(); + const promise = timersPromisesModule.setTimeout(100, null, { + signal: abortController.signal, + }); + + abortController.abort(); + + await assert.rejects(promise); + }); + it("should remove abort listener when resolving", async function () { + clock = FakeTimers.install(); + const abortController = new AbortController(); + abortController.signal.removeEventListener = sinon.stub(); + const promise = timersPromisesModule.setTimeout(100, null, { + signal: abortController.signal, + }); + + clock.tick(100); + await promise; + + assert.equals( + abortController.signal.removeEventListener.called, + true, + ); + }); + it("should remove abort listener when aborting", async function () { + clock = FakeTimers.install(); + const abortController = new AbortController(); + abortController.signal.removeEventListener = sinon.stub(); + const promise = timersPromisesModule.setTimeout(100, null, { + signal: abortController.signal, + }); + + abortController.abort(); + await promise.catch(() => {}); + + assert.equals( + abortController.signal.removeEventListener.called, + true, + ); + }); + }); + describe("The setImmediate function", function () { + it("should resolve immediately after tick", async function () { + clock = FakeTimers.install(); + const promise = timersPromisesModule.setImmediate(); + + let resolved = false; + promise.then(() => { + resolved = true; + }); + + await clock.tickAsync(0); + + assert.equals(resolved, true); + }); + it("should resolve with specified value", async function () { + clock = FakeTimers.install(); + const promise = + timersPromisesModule.setImmediate("example value"); + + clock.tick(0); + const result = await promise; + assert.equals(result, "example value"); + }); + it("should reject early when aborting", async function () { + clock = FakeTimers.install(); + const abortController = new AbortController(); + const promise = timersPromisesModule.setImmediate(null, { + signal: abortController.signal, + }); + + abortController.abort(); + + await assert.rejects(promise); + }); + it("should remove abort listener when resolving", async function () { + clock = FakeTimers.install(); + const abortController = new AbortController(); + abortController.signal.removeEventListener = sinon.stub(); + const promise = timersPromisesModule.setImmediate(null, { + signal: abortController.signal, + }); + + clock.tick(0); + await promise; + + assert.equals( + abortController.signal.removeEventListener.called, + true, + ); + }); + it("should remove abort listener when aborting", async function () { + clock = FakeTimers.install(); + const abortController = new AbortController(); + abortController.signal.removeEventListener = sinon.stub(); + const promise = timersPromisesModule.setImmediate(null, { + signal: abortController.signal, + }); + + abortController.abort(); + await promise.catch(() => {}); + + assert.equals( + abortController.signal.removeEventListener.called, + true, + ); + }); + }); + describe("The setInterval function", function () { + it("should resolve after specified time", async function () { + clock = FakeTimers.install(); + const iterable = timersPromisesModule.setInterval(100); + const iter = iterable[Symbol.asyncIterator](); + + let resolved = false; + iter.next().then(() => { + resolved = true; + }); + + await clock.tickAsync(100); + + assert.equals(resolved, true); + }); + it("should not resolve before specified time", async function () { + clock = FakeTimers.install(); + const iterable = timersPromisesModule.setInterval(100); + const iter = iterable[Symbol.asyncIterator](); + + let resolved = false; + iter.next().then(() => { + resolved = true; + }); + + await clock.tickAsync(50); + + assert.equals(resolved, false); + }); + it("should resolve at specified interval", async function () { + clock = FakeTimers.install(); + const iterable = timersPromisesModule.setInterval(100); + const iter = iterable[Symbol.asyncIterator](); + + let first = false; + iter.next().then(() => { + first = true; + }); + await clock.tickAsync(100); + + assert.equals(first, true); + + let second = false; + iter.next().then(() => { + second = true; + }); + await clock.tickAsync(100); + + assert.equals(second, true); + + let third = false; + iter.next().then(() => { + third = true; + }); + await clock.tickAsync(100); + + assert.equals(third, true); + }); + it("should resolve as not done", async function () { + clock = FakeTimers.install(); + const iterable = timersPromisesModule.setInterval(100); + const iter = iterable[Symbol.asyncIterator](); + + clock.tick(100); + const result = await iter.next(); + + assert.equals(result.done, false); + }); + it("should resolve with specified value", async function () { + clock = FakeTimers.install(); + const iterable = timersPromisesModule.setInterval( + 100, + "example value", + ); + const iter = iterable[Symbol.asyncIterator](); + + clock.tick(100); + const result = await iter.next(); + + assert.equals(result.value, "example value"); + }); + it("should immediately resolve when behind", async function () { + clock = FakeTimers.install(); + const iterable = timersPromisesModule.setInterval(100); + const iter = iterable[Symbol.asyncIterator](); + + clock.tick(300); + + await assert.resolves(iter.next()); + await assert.resolves(iter.next()); + await assert.resolves(iter.next()); + }); + it("should handle concurrent next calls as if sequential", async function () { + clock = FakeTimers.install(); + const iterable = timersPromisesModule.setInterval(100); + const iter = iterable[Symbol.asyncIterator](); + + let first = false; + let second = false; + let third = false; + iter.next().then(() => { + first = true; + }); + iter.next().then(() => { + second = true; + }); + iter.next().then(() => { + third = true; + }); + + await clock.tickAsync(100); + + assert.equals(first, true); + assert.equals(second, false); + assert.equals(third, false); + + await clock.tickAsync(100); + + assert.equals(second, true); + assert.equals(third, false); + + await clock.tickAsync(100); + + assert.equals(third, true); + }); + it("should resolve as done after return has been called", async function () { + clock = FakeTimers.install(); + const iterable = timersPromisesModule.setInterval(100); + const iter = iterable[Symbol.asyncIterator](); + + const returnResult = await iter.return(); + const nextResult = await iter.next(); + + assert.equals(returnResult.done, true); + assert.equals(nextResult.done, true); + }); + it("should wait to resolve return until all outstanding next calls have resolved", async function () { + clock = FakeTimers.install(); + const iterable = timersPromisesModule.setInterval(100); + const iter = iterable[Symbol.asyncIterator](); + + let first, second, third; + iter.next().then((it) => { + first = it; + }); + iter.next().then((it) => { + second = it; + }); + iter.next().then((it) => { + third = it; + }); + + let returned; + iter.return().then((it) => { + returned = it; + }); + + await clock.tickAsync(100); + assert.equals(first.done, false); + assert.isUndefined(returned); + + await clock.tickAsync(100); + assert.equals(second.done, false); + assert.isUndefined(returned); + + await clock.tickAsync(100); + assert.equals(third.done, false); + assert.equals(returned.done, true); + }); + it("should reject early when aborting", async function () { + clock = FakeTimers.install(); + const abortController = new AbortController(); + const iterable = timersPromisesModule.setInterval(100, null, { + signal: abortController.signal, + }); + const iter = iterable[Symbol.asyncIterator](); + + const promise = iter.next(); + abortController.abort(); + + await assert.rejects(promise); + }); + it("should resolve as done after initial reject when aborting", async function () { + clock = FakeTimers.install(); + const abortController = new AbortController(); + const iterable = timersPromisesModule.setInterval(100, null, { + signal: abortController.signal, + }); + const iter = iterable[Symbol.asyncIterator](); + + const first = iter.next(); + const second = iter.next(); + const third = iter.next(); + + abortController.abort(); + + await assert.rejects(first); + + const secondResult = await second; + const thirdResult = await third; + assert.equals(secondResult.done, true); + assert.equals(thirdResult.done, true); + }); + it("should remove abort listener when returning", async function () { + clock = FakeTimers.install(); + const abortController = new AbortController(); + abortController.signal.removeEventListener = sinon.stub(); + const iterable = timersPromisesModule.setInterval(100, null, { + signal: abortController.signal, + }); + const iter = iterable[Symbol.asyncIterator](); + + await iter.return(); + + assert.equals( + abortController.signal.removeEventListener.called, + true, + ); + }); + it("should remove abort listener when aborting", function () { + clock = FakeTimers.install(); + const abortController = new AbortController(); + abortController.signal.removeEventListener = sinon.stub(); + const iterable = timersPromisesModule.setInterval(100, null, { + signal: abortController.signal, + }); + iterable[Symbol.asyncIterator](); + + abortController.abort(); + + assert.equals( + abortController.signal.removeEventListener.called, + true, + ); + }); + }); + }); }); describe("loop limit stack trace", function () {