From 86e1627d5c4538dc9b1c19326561693466623ef1 Mon Sep 17 00:00:00 2001 From: haphut Date: Tue, 28 Nov 2023 17:12:42 +0200 Subject: [PATCH] feat: Implement cancellable, promisified sleep Unfortunately jest cannot currently handle mocking setTimeout from "node:timers/promises", see issue https://github.com/sinonjs/fake-timers/issues/469 . We do not wish to mix callbacks and async functions. To keep our code testable with jest, let's create our own cancellable wrapper around the callback-based setTimeout. --- src/util/sleep.ts | 46 +++++++++++++++++++++++++++++++++++ tests/util/sleep.unit.test.ts | 37 ++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 src/util/sleep.ts create mode 100644 tests/util/sleep.unit.test.ts diff --git a/src/util/sleep.ts b/src/util/sleep.ts new file mode 100644 index 0000000..4dd2599 --- /dev/null +++ b/src/util/sleep.ts @@ -0,0 +1,46 @@ +/** + * Unfortunately jest cannot currently handle mocking setTimeout from + * "node:timers/promises", see issue + * https://github.com/sinonjs/fake-timers/issues/469 + * We do not wish to mix callbacks and async functions. To keep our code + * testable with jest, let's create our own cancellable wrapper around the + * callback-based setTimeout. + */ + +export class AbortError extends Error { + constructor(message = "The operation was aborted") { + super(message); + this.name = "AbortError"; + } +} + +export const createSleep = () => { + let timeoutId: NodeJS.Timeout | undefined; + let abortFunction: (() => void) | undefined; + + const sleep = (delay: number): Promise => { + return new Promise((resolve, reject) => { + abortFunction = () => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + reject(new AbortError()); + }; + + timeoutId = setTimeout(() => { + timeoutId = undefined; + resolve(); + abortFunction = undefined; + }, delay); + }); + }; + + const abort = () => { + if (abortFunction !== undefined) { + abortFunction(); + } + }; + + return { sleep, abort }; +}; diff --git a/tests/util/sleep.unit.test.ts b/tests/util/sleep.unit.test.ts new file mode 100644 index 0000000..854f634 --- /dev/null +++ b/tests/util/sleep.unit.test.ts @@ -0,0 +1,37 @@ +import { AbortError, createSleep } from "../../src/util/sleep"; + +describe("createSleep", () => { + jest.useFakeTimers(); + + test("sleep should resolve after the specified delay", () => { + const { sleep } = createSleep(); + const promise = sleep(1_000); + jest.advanceTimersByTime(1_000); + return expect(promise).resolves.toEqual(undefined); + }); + + test("sleep should reject with AbortError if aborted before the specified delay", () => { + const { sleep, abort } = createSleep(); + const promise = sleep(1_000); + abort(); + jest.advanceTimersByTime(1_000); + return expect(promise).rejects.toThrow(AbortError); + }); + + test("abort should have no effect if called after the delay has passed", () => { + const { sleep, abort } = createSleep(); + const promise = sleep(1_000); + jest.advanceTimersByTime(1_000); + abort(); + return expect(promise).resolves.toBeUndefined(); + }); + + test("abort can be called multiple times", () => { + const { sleep, abort } = createSleep(); + const promise = sleep(1_000); + abort(); + abort(); + jest.advanceTimersByTime(1_000); + return expect(promise).rejects.toThrow(AbortError); + }); +});