From 2f6bdacea4abb93266cb9f1e0eb30e6963cb80c6 Mon Sep 17 00:00:00 2001 From: Vlad Cos Date: Tue, 18 Apr 2023 19:00:54 +0300 Subject: [PATCH] test: removed only modifier --- package-lock.json | 24 +++++++++- package.json | 4 +- src/abortable-promise.ts | 53 +++++++++++++++++++++ src/aborter.ts | 90 ++++++++++++++++++++++++++++++------ tests/vitest/aborter.test.ts | 53 ++++++++++++++++++++- tests/vitest/gateway.test.ts | 3 +- tests/vitest/timeout.test.ts | 62 +++++++++++++++++++++++++ tests/vitest/util.ts | 17 ++++++- 8 files changed, 285 insertions(+), 21 deletions(-) create mode 100644 src/abortable-promise.ts create mode 100644 tests/vitest/timeout.test.ts diff --git a/package-lock.json b/package-lock.json index 65861a4..96c51e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "lodash.isequal": "^4.5.0", "lodash.isplainobject": "^4.0.6", "p-debounce": "^4.0.0", - "p-defer": "^4.0.0" + "p-defer": "^4.0.0", + "promise-supplement": "^1.0.5" }, "devDependencies": { "@playwright/test": "^1.32.2", @@ -27,6 +28,7 @@ "semantic-release": "^21.0.1", "tsc-alias": "1.8.5", "tsconfig-paths": "^4.2.0", + "type-fest": "^3.8.0", "typescript": "^5.0.3", "vite": "^4.2.1", "vite-tsconfig-paths": "^4.0.8", @@ -11823,6 +11825,14 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "node_modules/promise-supplement": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/promise-supplement/-/promise-supplement-1.0.5.tgz", + "integrity": "sha512-cg6Ht9pyCK7ctw6hUU4w83GdqVTwquUWEu+paJGH0ZJwaR629IiYJ/NTzuMa5UE8dbqxutzo2n8uA6BDZPphKA==", + "engines": { + "node": ">=16" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -13731,6 +13741,18 @@ "node": ">=4" } }, + "node_modules/type-fest": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.8.0.tgz", + "integrity": "sha512-FVNSzGQz9Th+/9R6Lvv7WIAkstylfHN2/JYxkyhhmKFYh9At2DST8t6L6Lref9eYO8PXFTfG9Sg1Agg0K3vq3Q==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-length": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", diff --git a/package.json b/package.json index 3292ae5..71812f1 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "lodash.isequal": "^4.5.0", "lodash.isplainobject": "^4.0.6", "p-debounce": "^4.0.0", - "p-defer": "^4.0.0" + "p-defer": "^4.0.0", + "promise-supplement": "^1.0.5" }, "devDependencies": { "@playwright/test": "^1.32.2", @@ -33,6 +34,7 @@ "semantic-release": "^21.0.1", "tsc-alias": "1.8.5", "tsconfig-paths": "^4.2.0", + "type-fest": "^3.8.0", "typescript": "^5.0.3", "vite": "^4.2.1", "vite-tsconfig-paths": "^4.0.8", diff --git a/src/abortable-promise.ts b/src/abortable-promise.ts new file mode 100644 index 0000000..a610400 --- /dev/null +++ b/src/abortable-promise.ts @@ -0,0 +1,53 @@ +import { createPromiseMixin } from 'promise-supplement' + +type InnerShape = ( + resolve: (value: R) => void, + reject: (reason?: any) => void, +) => () => void + +export function getAbortablePromiseTemplate< + R, + O, + A extends any[], + // T = (...inputArguments: A) => any, + // T extends (...inputArguments: any[]) => InnerShape = ( + // ...inputArguments: any[] + // ) => InnerShape, +>( + inputShape: ( + ...inputArguments: A + ) => ( + resolve: (value: V) => void, + reject: (reason?: any) => void, + ) => () => void, + // inputShape: T extends (...inputArguments: A) => infer B + // ? (...inputArguments: A) => B + // : never, +): (...input: O) => Promise { + const controller = new AbortController() + const signal = controller.signal + + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types,@typescript-eslint/promise-function-async + return function (...arguments_) { + const executor = inputShape(...arguments_) + + return createPromiseMixin( + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason) + } + const abortHandler = executor(resolve, reject) + + signal.addEventListener('abort', () => { + abortHandler() + reject(signal.reason) + }) + }), + { + abort: (reason?: any) => { + controller.abort(reason) + }, + }, + ) + } +} diff --git a/src/aborter.ts b/src/aborter.ts index 26d2cda..ce294bc 100644 --- a/src/aborter.ts +++ b/src/aborter.ts @@ -1,31 +1,82 @@ import pDebounce from 'p-debounce' import pDefer from 'p-defer' +import { createPromiseMixin } from 'promise-supplement' import type { SignalFunction } from '@/src/types.js' import type { DeferredPromise } from 'p-defer' +interface Aborter { + (debounced?: boolean): DeferredPromise['promise'] & { + abortAndReject: () => void + } + abort: () => number + abortAndResolveWith: () => Awaited['promise']> + abortAndReject: () => any + getAbortController: () => AbortController +} + +interface AbortReference { + internal: AbortController + external: AbortController +} + +function refreshControllers(reference: AbortReference) { + const externalListener = () => { + console.log('aborted externally:begin ') + reference.internal.abort() + reference.external = new AbortController() + // reference.external.signal.addEventListener('abort', externalListener) + console.log('aborted externally: end') + } + + const interalListener = () => { + console.log('aborted internally: begin') + reference.internal = new AbortController() + reference.internal.signal.addEventListener('abort', interalListener) + console.log('aborted internally: end') + } + + reference.external.signal.addEventListener('abort', externalListener) + reference.internal.signal.addEventListener('abort', interalListener) + + return reference +} + export function createAborter>( expression: SignalFunction, { debug = false, debounceThreshold = 10, }: { debug?: boolean; debounceThreshold?: number | false } = {}, -): (debounced?: boolean) => DeferredPromise['promise'] { - let previousPromise: T | undefined - let controller = new AbortController() +): Aborter { + let currentRunningPromise: T | undefined + let inhibitAbortExeceptions = true + + const abortControllerPointer = refreshControllers({ + internal: new AbortController(), + external: new AbortController(), + }) + let deferred = pDefer() + + deferred.promise = createPromiseMixin(deferred.promise, { + abortAndReject: () => { + abortable.abortAndReject() + }, + }) + // eslint-disable-next-line @typescript-eslint/promise-function-async function abortable(debounced = false): DeferredPromise['promise'] { - if (!previousPromise && !debounced) { + if (!currentRunningPromise && !debounced) { // eslint-disable-next-line no-use-before-define return debouncedAbortable(true) } - if (previousPromise) { - controller.abort() + if (currentRunningPromise) { + abortControllerPointer.internal.abort() if (debug) { - previousPromise + currentRunningPromise .then((s) => { console.log(s) }) @@ -33,34 +84,43 @@ export function createAborter>( console.log(error) }) } - - controller = new AbortController() } - const newPromise = expression(controller.signal) + const newPromise = expression(abortControllerPointer.internal.signal) void newPromise .then((value) => { const oldDeferred = deferred deferred = pDefer() + oldDeferred.resolve(value) - previousPromise = undefined + currentRunningPromise = undefined return value }) .catch((error) => { if ( - error.code !== DOMException.ABORT_ERR && - error.code !== 'ABORT_ERR' + (error.code === DOMException.ABORT_ERR || + error.code === 'ABORT_ERR') && + inhibitAbortExeceptions ) { - throw error + return } + + deferred.reject(error) }) - previousPromise = newPromise + currentRunningPromise = newPromise return deferred.promise } + abortable.abortAndReject = () => { + inhibitAbortExeceptions = false + abortControllerPointer.external.abort() + } + + abortable.getAbortController = () => abortControllerPointer.external + const debouncedAbortable = debounceThreshold === false ? abortable diff --git a/tests/vitest/aborter.test.ts b/tests/vitest/aborter.test.ts index a999167..2328a95 100644 --- a/tests/vitest/aborter.test.ts +++ b/tests/vitest/aborter.test.ts @@ -1,5 +1,5 @@ import { promiseStateAsync } from 'p-state' -import { expect, it, vi } from 'vitest' +import { expect, it, vi, describe } from 'vitest' import { createAborter } from '@/src/aborter.js' import { getConventionalTimeoutPromiseAborter } from '@/tests/vitest/util' @@ -81,3 +81,54 @@ it('the sequential calls should be debounced', async () => { await debounce() expect(getConventionalTimeoutPromiseAborterSpy).toHaveBeenCalledTimes(1) }) + +describe.only('promise object augmentation', () => { + it('the promise should have a abort on its prototype', async () => { + const debounce = createAborter( + async (signal) => getConventionalTimeoutPromiseAborter(200, { signal }), + { debounceThreshold: false }, + ) + + const promise1 = debounce() + const promise2 = debounce() + + const promiseAll = expect( + Promise.all([promise1, promise2]), + ).rejects.toThrowError('This operation was aborted') + promise1.abortAndReject() + promise2.abortAndReject() + await promiseAll + }) +}) + +describe('exception handling', () => { + it('the aborter should throw exceptions for external aborts', async () => { + const debounce = createAborter( + async (signal) => getConventionalTimeoutPromiseAborter(200, { signal }), + { debounceThreshold: false }, + ) + + const promise = expect( + Promise.all([debounce(), debounce()]), + ).rejects.toThrowError('This operation was aborted') + + debounce.abortAndReject() + await promise + }) + it('the aborter should passthrough the exceptions unrelated to aborts', async () => { + const debounce = createAborter( + async () => + new Promise((_resolve, reject) => { + reject(new Error('Reason')) + }), + { debounceThreshold: false }, + ) + + const promise = expect( + Promise.all([debounce(), debounce()]), + ).rejects.toThrowError('Reason') + + debounce.abortAndReject() + await promise + }) +}) diff --git a/tests/vitest/gateway.test.ts b/tests/vitest/gateway.test.ts index 9c818e7..ed22055 100644 --- a/tests/vitest/gateway.test.ts +++ b/tests/vitest/gateway.test.ts @@ -32,12 +32,11 @@ it('should wrap executor and passthrough its options', async () => { expect(spy).toHaveBeenCalledTimes(1) }) -it('case5 ', async () => { +it.only('case5 ', async () => { const factory = createGateway() const fun = async (signal: AbortSignal) => getTimeoutPromiseAborter(200, signal) - await Promise.all([factory.run(fun), factory.run(fun)]) }) diff --git a/tests/vitest/timeout.test.ts b/tests/vitest/timeout.test.ts new file mode 100644 index 0000000..7bc3058 --- /dev/null +++ b/tests/vitest/timeout.test.ts @@ -0,0 +1,62 @@ +import { clearTimeout, setTimeout } from 'node:timers' + +import { it } from 'vitest' + +import { getAbortablePromiseTemplate } from '@/src/abortable-promise' + +// const abortableTimeout = getAbortablePromiseTemplate( +// (resolve, reject, ...inputArgs: ) => { +// const timeoutID = setTimeout(() => { +// resolve(10) +// }, 10) +// +// signal.addEventListener('abort', () => { +// clearTimeout(timeoutID) +// }) +// }, +// ) + +type Te = (resolve: (argument: T) => void) => void + +const te: Te = (resolve) => { + console.log('a') +} + +const tte: Te = function (resolve) { + console.log('a') +} + +const sum = + (timeout: T) => + (resolve) => { + const timeoutID = setTimeout(() => { + resolve(timeout) + }, timeout) + + return () => { + clearTimeout(timeoutID) + } + } + +async function test(signal: AbortSignal) { + const a = await fetch('dawdaw') +} + +it('dawd', async () => { + // const abortableFetch =getAbortablePromiseTemplate() + const abortableTimeout = getAbortablePromiseTemplate( + (timeout: number) => (resolve) => { + const timeoutID = setTimeout(() => { + resolve(timeout) + }, timeout) + + return () => { + clearTimeout(timeoutID) + } + }, + ) + const promise = await abortableTimeout('1', 'd') + console.log(promise.abort()) + console.log(await promise) + // promise.abort() +}) diff --git a/tests/vitest/util.ts b/tests/vitest/util.ts index 330e606..ccfc990 100644 --- a/tests/vitest/util.ts +++ b/tests/vitest/util.ts @@ -1,4 +1,4 @@ -import { clearTimeout } from 'node:timers' +import { clearTimeout, setTimeout as setTimeoutCallback } from 'node:timers' export async function getTimeoutPromiseAborter( ms: number, @@ -34,8 +34,23 @@ export async function getConventionalTimeoutPromiseAborter( }, ms) signal?.addEventListener('abort', () => { + console.log('aborted!') clearTimeout(timeoutID) reject(signal.reason) }) }) } + +export async function getRejectingNoopPromise() { + return new Promise((_resolve, reject) => { + reject(new Error('Reason')) + }) +} + +export async function getResolvedNoopPromise() { + return new Promise((resolve) => { + setTimeoutCallback(() => { + resolve(true) + }, 0) + }) +}