Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 92 additions & 5 deletions lib/asyncbox.ts
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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';
}
}
Comment on lines +21 to +27
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated


/**
* 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<void>;
/**
* 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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
export function sleep(options: SleepOptions): CancellablePromise<void>;
export function sleep(arg: SleepArg): CancellablePromise<void> {
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<void>((resolve, reject) => {
resolveFn = resolve;
rejectFn = reject;
timeoutId = setTimeout(resolve, ms);
}) as CancellablePromise<void>;

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;
}

/**
Expand Down Expand Up @@ -270,8 +332,33 @@ export async function waitForCondition<T>(

// 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<PropertyKey, unknown> {
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');
}
29 changes: 29 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Comment thread
mykola-mokhnach marked this conversation as resolved.
/**
* A promise with a {@linkcode cancel} method (e.g. from {@link sleep}).
*
* @typeParam T - Resolved value type; {@link sleep} uses `void`.
*/
export type CancellablePromise<T = void> = Promise<T> & {
cancel: () => void;
};

/**
* Options for {@link waitForCondition}
*/
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
108 changes: 108 additions & 0 deletions test/asyncbox-specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
waitForCondition,
withTimeout,
TimeoutError,
PromiseCancellationError,
type SleepOptions,
} from '../lib/asyncbox.js';

use(chaiAsPromised);
Expand All @@ -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 () {
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading