diff --git a/lib/asyncbox.ts b/lib/asyncbox.ts index 806dfca..18fa183 100644 --- a/lib/asyncbox.ts +++ b/lib/asyncbox.ts @@ -1,5 +1,12 @@ import {limitFunction} from 'p-limit'; -import type {LongSleepOptions, MapFilterOptions, WaitForConditionOptions} from './types.js'; +import type { + CancellablePromise, + LongSleepOptions, + MapFilterOptions, + SleepArg, + SleepOptions, + WaitForConditionOptions, +} from './types.js'; const LONG_SLEEP_THRESHOLD = 5000; // anything over 5000ms will turn into a spin @@ -11,12 +18,67 @@ export class TimeoutError extends Error { } } +/** Thrown when a promise is cancelled via `cancel`. */ +export class PromiseCancellationError extends Error { + constructor(message: string = 'Promise cancelled') { + super(message); + this.name = 'PromiseCancellationError'; + } +} + /** - * An async/await version of setTimeout - * @param ms - The number of milliseconds to wait + * An async/await version of `setTimeout`. The returned promise has a `cancel()` method that clears + * the timer and usually rejects with {@link PromiseCancellationError} unless you use the object form with + * `cancelError: null` to resolve on cancel. + * + * @param ms - Milliseconds to wait + * @returns A thenable you can `await`; call `.cancel()` to abort early. + */ +export function sleep(ms: number): CancellablePromise; +/** + * Object form: `ms` plus optional `cancelError` (non-empty string or `Error`, `null` to resolve on + * cancel, or omitted / `undefined` / `''` for default {@link PromiseCancellationError}). Any non-array + * object with a finite `ms` is accepted at runtime (including class instances), matching structural typing. + * + * @param options - Duration and optional cancellation behavior + * @returns A thenable you can `await`; call `.cancel()` to abort early. */ -export async function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); +export function sleep(options: SleepOptions): CancellablePromise; +export function sleep(arg: SleepArg): CancellablePromise { + const {ms, cancelError} = parseSleepArg(arg); + let timeoutId: NodeJS.Timeout | undefined; + let resolveFn: ((value: void) => void) | undefined; + let rejectFn: ((error: Error) => void) | undefined; + + const promise = new Promise((resolve, reject) => { + resolveFn = resolve; + rejectFn = reject; + timeoutId = setTimeout(resolve, ms); + }) as CancellablePromise; + + promise.cancel = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + if (cancelError === null) { + resolveFn?.(undefined); + } else { + let err: Error; + if (typeof cancelError === 'string' && cancelError) { + err = new PromiseCancellationError(cancelError); + } else if (cancelError instanceof Error) { + err = cancelError; + } else { + err = new PromiseCancellationError(); + } + rejectFn?.(err); + } + resolveFn = undefined; + rejectFn = undefined; + }; + + return promise; } /** @@ -270,8 +332,33 @@ export async function waitForCondition( // Re-export types export type { + CancellablePromise, Progress, ProgressCallback, LongSleepOptions, + SleepArg, + SleepOptions, WaitForConditionOptions, } from './types.js'; + +/** Non-array object values (including class instances); excludes `null` and arrays. */ +function isSleepArgObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function parseSleepArg(arg: SleepArg): {ms: number; cancelError?: string | Error | null} { + if (typeof arg === 'number') { + if (!Number.isFinite(arg)) { + throw new TypeError('sleep: expected a finite number or an object with ms'); + } + return {ms: arg}; + } + if (isSleepArgObject(arg)) { + const ms = arg.ms; + if (typeof ms !== 'number' || !Number.isFinite(ms)) { + throw new TypeError('sleep: options.ms must be a finite number'); + } + return {ms, cancelError: arg.cancelError as string | Error | null | undefined}; + } + throw new TypeError('sleep: expected a finite number or an object with ms'); +} diff --git a/lib/types.ts b/lib/types.ts index 40ec4ca..b4c3527 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -36,6 +36,35 @@ export interface LongSleepOptions { */ export type MapFilterOptions = boolean | {concurrency: number}; +/** + * Object form of {@link sleep}'s argument: duration plus optional cancellation rejection override. + * Structural typing allows class instances; {@link sleep} accepts any non-array object with a finite `ms`. + */ +export interface SleepOptions { + /** Duration in milliseconds */ + ms: number; + /** + * When {@link sleep}'s `cancel` runs: a non-empty string becomes {@link PromiseCancellationError} + * with that message; an `Error` rejects with that same instance; `null` resolves the promise + * instead of rejecting; omitted, `undefined`, or `''` use the default {@link PromiseCancellationError}. + */ + cancelError?: string | Error | null; +} + +/** + * Argument to {@link sleep}: either milliseconds or an options object (see {@link SleepOptions}). + */ +export type SleepArg = number | SleepOptions; + +/** + * A promise with a {@linkcode cancel} method (e.g. from {@link sleep}). + * + * @typeParam T - Resolved value type; {@link sleep} uses `void`. + */ +export type CancellablePromise = Promise & { + cancel: () => void; +}; + /** * Options for {@link waitForCondition} */ diff --git a/package.json b/package.json index d39aaa3..08b72a0 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,8 @@ "@appium/tsconfig": "^1.0.0", "@semantic-release/changelog": "^6.0.1", "@semantic-release/git": "^10.0.1", + "@types/chai": "^5.2.3", + "@types/chai-as-promised": "^8.0.2", "@types/mocha": "^10.0.10", "@types/node": "^25.0.3", "chai": "^6.2.1", diff --git a/test/asyncbox-specs.ts b/test/asyncbox-specs.ts index c8856de..89f9223 100644 --- a/test/asyncbox-specs.ts +++ b/test/asyncbox-specs.ts @@ -10,6 +10,8 @@ import { waitForCondition, withTimeout, TimeoutError, + PromiseCancellationError, + type SleepOptions, } from '../lib/asyncbox.js'; use(chaiAsPromised); @@ -20,6 +22,112 @@ describe('sleep', function () { await sleep(20); expect(Date.now() - now).to.be.at.least(19); }); + it('should expose cancel on the promise', function () { + const d = sleep(100); + expect(d.cancel).to.be.a('function'); + }); + it('should reject with PromiseCancellationError when cancelled', async function () { + const d = sleep(10_000); + d.cancel(); + await expect(d).to.be.rejectedWith(PromiseCancellationError); + }); + it('should reject immediately on cancel without waiting for ms', async function () { + const d = sleep(10_000); + const start = Date.now(); + d.cancel(); + await expect(d).to.be.rejectedWith(PromiseCancellationError); + expect(Date.now() - start).to.be.below(100); + }); + it('should use default PromiseCancellationError when cancelError is empty string', async function () { + const d = sleep({ms: 10_000, cancelError: ''}); + d.cancel(); + try { + await d; + expect.fail('expected rejection'); + } catch (err: unknown) { + expect(err).to.be.instanceOf(PromiseCancellationError); + expect((err as PromiseCancellationError).message).to.equal('Promise cancelled'); + } + }); + it('should reject with PromiseCancellationError using cancelError string when cancelled', async function () { + const d = sleep({ms: 10_000, cancelError: 'aborted'}); + d.cancel(); + try { + await d; + expect.fail('expected rejection'); + } catch (err: unknown) { + expect(err).to.be.instanceOf(PromiseCancellationError); + expect((err as PromiseCancellationError).message).to.equal('aborted'); + } + }); + it('should reject with a provided Error instance on cancel', async function () { + class CustomCancel extends Error { + constructor(message?: string) { + super(message ?? 'custom default'); + this.name = 'CustomCancel'; + } + } + const customErr = new CustomCancel('nope'); + const d = sleep({ms: 10_000, cancelError: customErr}); + d.cancel(); + try { + await d; + expect.fail('expected rejection'); + } catch (err: unknown) { + expect(err).to.equal(customErr); + expect(err).to.be.instanceOf(CustomCancel); + expect((err as CustomCancel).message).to.equal('nope'); + } + }); + it('should resolve when cancelled if cancelError is null', async function () { + const d = sleep({ms: 10_000, cancelError: null}); + const start = Date.now(); + d.cancel(); + await d; + expect(Date.now() - start).to.be.below(100); + }); + it('should throw TypeError when ms is not finite', function () { + expect(() => sleep(Number.NaN)).to.throw(TypeError, /finite number or an object with ms/); + expect(() => sleep(Number.POSITIVE_INFINITY)).to.throw( + TypeError, + /finite number or an object with ms/, + ); + }); + it('should throw TypeError when arg is not a number or object', function () { + expect(() => sleep(null as unknown as number)).to.throw( + TypeError, + /finite number or an object with ms/, + ); + expect(() => sleep([] as unknown as number)).to.throw( + TypeError, + /finite number or an object with ms/, + ); + }); + it('should throw TypeError when object has invalid ms', function () { + expect(() => sleep({} as unknown as SleepOptions)).to.throw( + TypeError, + /options\.ms must be a finite number/, + ); + expect(() => sleep({ms: Number.NaN})).to.throw( + TypeError, + /options\.ms must be a finite number/, + ); + }); + it('should accept a null-prototype object with ms', async function () { + const o = Object.create(null) as {ms: number}; + o.ms = 20; + const now = Date.now(); + await sleep(o); + expect(Date.now() - now).to.be.at.least(19); + }); + it('should accept a class instance that satisfies SleepOptions', async function () { + class Opts implements SleepOptions { + ms = 25; + } + const now = Date.now(); + await sleep(new Opts()); + expect(Date.now() - now).to.be.at.least(24); + }); }); describe('withTimeout', function () { diff --git a/tsconfig.json b/tsconfig.json index 97b6ba6..c01e843 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "@appium/tsconfig/tsconfig.json", "compilerOptions": { - "strict": false, + "strict": true, "outDir": "build", "types": ["node", "mocha"], "checkJs": true