diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 41fe626..3b8aa86 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,11 +10,12 @@ jobs: fail-fast: false matrix: node-version: + - 16 - 14 - 12 steps: - uses: actions/checkout@v2 - - uses: actions/setup-node@v1 + - uses: actions/setup-node@v2 with: node-version: ${{ matrix.node-version }} - run: npm install diff --git a/index.d.ts b/index.d.ts index c4eb83e..2f86a55 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,5 +1,14 @@ type Awaited = ValueType extends undefined ? ValueType : ValueType extends PromiseLike ? ResolveValueType : ValueType; +// /~https://github.com/microsoft/TypeScript/blob/582e404a1041ce95d22939b73f0b4d95be77c6ec/lib/lib.es2020.promise.d.ts#L21-L31 +export type PromiseSettledResult = { + status: 'fulfilled'; + value: ResolveValueType; +} | { + status: 'rejected'; + reason: unknown; +}; + export interface Options { /** Number of concurrently pending promises. Minimum: `1`. @@ -133,6 +142,75 @@ export class PProgress extends Promise { options?: Options ): PProgress>; + /** + Like [`Promise.allSettled`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled) but also exposes the total progress of all of the promises like `PProgress.all`. + + @param promises - Array of promises or promise-returning functions, similar to [p-all](/~https://github.com/sindresorhus/p-all). + + @example + ``` + import pProgress, {PProgress} from 'p-progress'; + import delay from 'delay'; + + const progressPromise = () => pProgress(async progress => { + progress(0.14); + await delay(52); + progress(0.37); + await delay(104); + progress(0.41); + await delay(26); + progress(0.93); + await delay(55); + return 1; + }); + + const progressPromise2 = () => pProgress(async progress => { + progress(0.14); + await delay(52); + progress(0.37); + await delay(104); + progress(0.41); + await delay(26); + progress(0.93); + await delay(55); + throw new Error('Catch me if you can!'); + }); + + const allProgressPromise = PProgress.allSettled([ + progressPromise(), + progressPromise2() + ]); + + allProgressPromise.onProgress(console.log); + //=> 0.0925 + //=> 0.3425 + //=> 0.5925 + //=> 0.6025 + //=> 0.7325 + //=> 0.9825 + //=> 1 + + console.log(await allProgressPromise); + //=> [{status: 'fulfilled', value: 1}, {status: 'rejected', reason: Error: Catch me if you can!}] + ``` + */ + static allSettled | PromiseLike>>( + promises: readonly [...Promises], + options?: Options + ): PProgress<{ + [Promise_ in keyof Promises]: PromiseSettledResult + ? Awaited + : ( + Promises[Promise_] extends PromiseFactory + ? Awaited> + : Promises[Promise_] + )> + }>; + static allSettled( + promises: Iterable | PromiseLike>, + options?: Options + ): PProgress>>; + /** Accepts a function that gets `instance.progress` as an argument and is called for every progress event. */ diff --git a/index.js b/index.js index 506e264..94194fc 100644 --- a/index.js +++ b/index.js @@ -12,14 +12,14 @@ const sum = iterable => { export class PProgress extends Promise { static all(promises, options) { - if ( - options && typeof options.concurrency === 'number' && - !(promises.every(promise => typeof promise === 'function')) - ) { - throw new TypeError('When `options.concurrency` is set, the first argument must be an Array of Promise-returning functions'); - } + return pProgress(async progress => { + if ( + options && typeof options.concurrency === 'number' && + !(promises.every(promise => typeof promise === 'function')) + ) { + throw new TypeError('When `options.concurrency` is set, the first argument must be an Array of Promise-returning functions'); + } - return pProgress(progress => { const progressMap = new Map(); const reportProgress = () => { @@ -48,6 +48,55 @@ export class PProgress extends Promise { }); } + static allSettled(promises, {concurrency} = {}) { + return pProgress(async progress => { + if ( + typeof concurrency === 'number' && + !(promises.every(promise => typeof promise === 'function')) + ) { + throw new TypeError('When `options.concurrency` is set, the first argument must be an Array of Promise-returning functions'); + } + + const progressMap = new Map(); + + const reportProgress = () => { + progress(sum(progressMap) / promises.length); + }; + + const mapper = async index => { + const nextValue = promises[index]; + const promise = typeof nextValue === 'function' ? nextValue() : nextValue; + progressMap.set(promise, 0); + + if (promise instanceof PProgress) { + promise.onProgress(percentage => { + progressMap.set(promise, percentage); + reportProgress(); + }); + } + + try { + return { + status: 'fulfilled', + value: await promise + }; + } catch (error) { + return { + status: 'rejected', + reason: error + }; + } finally { + progressMap.set(promise, 1); + reportProgress(); + } + }; + + return pTimes(promises.length, mapper, { + concurrency + }); + }); + } + constructor(executor) { const setProgress = progress => { if (progress > 1 || progress < 0) { @@ -116,12 +165,12 @@ export class PProgress extends Promise { } } -const pProgress = input => new PProgress(async (resolve, reject, progress) => { - try { - resolve(await input(progress)); - } catch (error) { - reject(error); - } -}); - -export default pProgress; +export default function pProgress(input) { + return new PProgress(async (resolve, reject, progress) => { + try { + resolve(await input(progress)); + } catch (error) { + reject(error); + } + }); +} diff --git a/index.test-d.ts b/index.test-d.ts index 2998080..ba69303 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -1,5 +1,5 @@ import {expectType} from 'tsd'; -import pProgress, {PProgress, ProgressNotifier} from './index.js'; +import pProgress, {PProgress, ProgressNotifier, PromiseSettledResult} from './index.js'; const progressPromise = new PProgress(async (resolve, reject, progress) => { expectType<(progress: number) => void>(progress); @@ -496,3 +496,11 @@ expectType>>( {concurrency: 1} ) ); +expectType< +PProgress<[PromiseSettledResult, PromiseSettledResult]> +>( + PProgress.allSettled([ + Promise.resolve('sindresorhus.com'), + Promise.resolve(1) + ]) +); diff --git a/package.json b/package.json index 0b05191..2fa145d 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "type": "module", "exports": "./index.js", "engines": { - "node": ">=12.2" + "node": ">=12" }, "scripts": { "test": "xo && ava && tsd" @@ -44,6 +44,7 @@ "in-range": "^3.0.0", "time-span": "^5.0.0", "tsd": "^0.16.0", + "typescript": "^4.3.5", "xo": "^0.40.1" }, "xo": { diff --git a/readme.md b/readme.md index 077021e..714b2d9 100644 --- a/readme.md +++ b/readme.md @@ -104,6 +104,10 @@ await progressPromise; Convenience method to run multiple promises and get a total progress of all of them. It counts normal promises with progress `0` when pending and progress `1` when resolved. For `PProgress` type promises, it listens to their `onProgress()` method for more fine grained progress reporting. You can mix and match normal promises and `PProgress` promises. +### PProgress.allSettled(promises, options?) + +Like [`Promise.allSettled`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled) but also exposes the total progress of all of the promises like `PProgress.all`. + ```js import pProgress, {PProgress} from 'p-progress'; import delay from 'delay'; @@ -117,13 +121,24 @@ const progressPromise = () => pProgress(async progress => { await delay(26); progress(0.93); await delay(55); + return 1; +}); + +const progressPromise2 = () => pProgress(async progress => { + progress(0.14); + await delay(52); + progress(0.37); + await delay(104); + progress(0.41); + await delay(26); + progress(0.93); + await delay(55); + throw new Error('Catch me if you can!'); }); -const allProgressPromise = PProgress.all([ - delay(103), +const allProgressPromise = PProgress.allSettled([ progressPromise(), - delay(55), - delay(209) + progressPromise2() ]); allProgressPromise.onProgress(console.log); @@ -135,7 +150,8 @@ allProgressPromise.onProgress(console.log); //=> 0.9825 //=> 1 -await allProgressPromise; +console.log(await allProgressPromise); +//=> [{status: 'fulfilled', value: 1}, {status: 'rejected', reason: Error: Catch me if you can!}] ``` #### promises diff --git a/test.js b/test.js index d731928..9b55aec 100644 --- a/test.js +++ b/test.js @@ -5,11 +5,12 @@ import inRange from 'in-range'; import pProgress, {PProgress} from './index.js'; const fixture = Symbol('fixture'); +const errorFixture = new Error('fixture'); test('new PProgress()', async t => { t.plan(45); - const p = new PProgress(async (resolve, reject, progress) => { + const promise = new PProgress(async (resolve, reject, progress) => { progress(0.1); await delay(50); progress(0.3); @@ -24,37 +25,37 @@ test('new PProgress()', async t => { resolve(fixture); }); - t.true(p instanceof Promise); + t.true(promise instanceof Promise); - p.onProgress(progress => { - t.is(progress, p.progress); - t.true(progress >= 0 && progress <= 1); + promise.onProgress(progress => { + t.is(progress, promise.progress); + t.true(progress >= 0 && progress <= 1, `${progress}`); }); - p.onProgress(progress => { - t.is(progress, p.progress); - t.true(progress >= 0 && progress <= 1); + promise.onProgress(progress => { + t.is(progress, promise.progress); + t.true(progress >= 0 && progress <= 1, `${progress}`); }); // eslint-disable-next-line promise/prefer-await-to-then - p.then(result => [result, result]).then(results => { + promise.then(result => [result, result]).then(results => { t.true(Array.isArray(results)); for (const result of results) { t.is(result, fixture); } }) .onProgress(progress => { - t.is(progress, p.progress); - t.true(progress >= 0 && progress <= 1); + t.is(progress, promise.progress); + t.true(progress >= 0 && progress <= 1, `${progress}`); }); // eslint-disable-next-line promise/prefer-await-to-then - p.catch(() => {}).onProgress(progress => { - t.is(progress, p.progress); - t.true(progress >= 0 && progress <= 1); + promise.catch(() => {}).onProgress(progress => { + t.is(progress, promise.progress); + t.true(progress >= 0 && progress <= 1, `${progress}`); }); - t.is(await p, fixture); + t.is(await promise, fixture); await delay(1); }); @@ -70,17 +71,17 @@ test('pProgress()', async t => { return input; }); - const p = fn(fixture); + const promise = fn(fixture); - p.onProgress(progress => { - t.true(progress >= 0 && progress <= 1); + promise.onProgress(progress => { + t.true(progress >= 0 && progress <= 1, `${progress}`); }); - t.is(await p, fixture); + t.is(await promise, fixture); }); test('PProgress.all()', async t => { - const fixtureFn = input => pProgress(async progress => { + const fixtureFunction = input => pProgress(async progress => { progress(0.16); await delay(50); progress(0.55); @@ -88,7 +89,7 @@ test('PProgress.all()', async t => { return input; }); - const fixtureFn2 = input => pProgress(async progress => { + const fixtureFunction2 = input => pProgress(async progress => { progress(0.14); await delay(52); progress(0.37); @@ -100,20 +101,20 @@ test('PProgress.all()', async t => { return input; }); - const p = PProgress.all([ + const promise = PProgress.all([ delay(103), - fixtureFn(fixture), + fixtureFunction(fixture), delay(55), - fixtureFn2(fixture), + fixtureFunction2(fixture), delay(14), delay(209) ]); - p.onProgress(progress => { - t.true(progress >= 0 && progress <= 1); + promise.onProgress(progress => { + t.true(progress >= 0 && progress <= 1, `${progress}`); }); - t.deepEqual(await p, [ + t.deepEqual(await promise, [ undefined, fixture, undefined, @@ -124,7 +125,7 @@ test('PProgress.all()', async t => { }); test('PProgress.all() with concurrency = 1', async t => { - const fixtureFn = input => pProgress(async progress => { + const fixtureFunction = input => pProgress(async progress => { progress(0.16); await delay(50); progress(0.55); @@ -132,7 +133,7 @@ test('PProgress.all() with concurrency = 1', async t => { return input; }); - const fixtureFn2 = input => pProgress(async progress => { + const fixtureFunction2 = input => pProgress(async progress => { progress(0.41); await delay(50); progress(0.93); @@ -141,31 +142,146 @@ test('PProgress.all() with concurrency = 1', async t => { }); // Should throw when first argument is array of promises instead of promise-returning functions - t.throws(() => PProgress.all([fixtureFn(fixture), fixtureFn2(fixture)], { + await t.throwsAsync(PProgress.all([fixtureFunction(fixture), fixtureFunction2(fixture)], { concurrency: 1 }), { instanceOf: TypeError }); const end = timeSpan(); - const p = PProgress.all([ - () => fixtureFn(fixture), - () => fixtureFn2(fixture) + const promise = PProgress.all([ + () => fixtureFunction(fixture), + () => fixtureFunction2(fixture) ], { concurrency: 1 }); - p.onProgress(progress => { - t.true(progress >= 0 && progress <= 1); + promise.onProgress(progress => { + t.true(progress >= 0 && progress <= 1, `${progress}`); }); - t.deepEqual(await p, [ + t.deepEqual(await promise, [ fixture, fixture ]); t.true(inRange(end(), { start: 200, // 4 delays of 50ms each - end: 250 // Reasonable padding + end: 300 // Reasonable padding + })); +}); + +test('PProgress.allSettled()', async t => { + const fixtureFunction = input => pProgress(async progress => { + progress(0.16); + await delay(50); + progress(0.55); + await delay(100); + return input; + }); + + const fixtureFunction2 = input => pProgress(async progress => { + progress(0.14); + await delay(52); + progress(0.37); + await delay(104); + progress(0.41); + await delay(26); + progress(0.93); + await delay(55); + throw input; + }); + + const promise = PProgress.allSettled([ + delay(103), + fixtureFunction(fixture), + delay(55), + fixtureFunction2(errorFixture), + delay(14), + delay(209) + ]); + + promise.onProgress(progress => { + t.true(progress >= 0 && progress <= 1, `${progress}`); + }); + + t.deepEqual(await promise, [ + { + status: 'fulfilled', + value: undefined + }, + { + status: 'fulfilled', + value: fixture + }, + { + status: 'fulfilled', + value: undefined + }, + { + status: 'rejected', + reason: errorFixture + }, + { + status: 'fulfilled', + value: undefined + }, + { + status: 'fulfilled', + value: undefined + } + ]); +}); + +test('PProgress.allSettled() with concurrency = 1', async t => { + const fixtureFunction = input => pProgress(async progress => { + progress(0.16); + await delay(50); + progress(0.55); + await delay(50); + return input; + }); + + const fixtureFunction2 = input => pProgress(async progress => { + progress(0.41); + await delay(50); + progress(0.93); + await delay(50); + throw input; + }); + + // Should throw when first argument is array of promises instead of promise-returning functions + await t.throwsAsync(PProgress.allSettled([fixtureFunction(fixture)], { + concurrency: 1 + }), { + instanceOf: TypeError + }); + + const end = timeSpan(); + const promise = PProgress.allSettled([ + () => fixtureFunction(fixture), + () => fixtureFunction2(errorFixture) + ], { + concurrency: 1 + }); + + promise.onProgress(progress => { + t.true(progress >= 0 && progress <= 1, `${progress}`); + }); + + t.deepEqual(await promise, [ + { + status: 'fulfilled', + value: fixture + }, + { + status: 'rejected', + reason: errorFixture + } + ]); + + t.true(inRange(end(), { + start: 200, // 4 delays of 50ms each + end: 300 // Reasonable padding })); });