Skip to content

Commit

Permalink
feat(async): add makeCancelable and pause functions (#64)
Browse files Browse the repository at this point in the history
* feat(async): add makeCancelable and pause functions

* style(lint): adjust complexity threshold in eslint config

* test(async): add test case to cover standard promise rejection

* chore: increase complexity threshold

* chore: further adjust complexity thresholds

* chore: add threshold to pause delay test

* chore: add test for error handling post cancelation

* chore: update comment formatting
  • Loading branch information
cahilfoley authored Jul 16, 2019
1 parent 011fd91 commit 37132d0
Show file tree
Hide file tree
Showing 10 changed files with 146 additions and 14 deletions.
7 changes: 7 additions & 0 deletions .codeclimate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ engines:
channel: 'eslint-5'
fixme:
enabled: true
checks:
method-complexity:
config:
threshold: 10
return-statements:
config:
threshold: 6
ratings:
paths:
- '**.ts'
Expand Down
12 changes: 0 additions & 12 deletions .eslintrc.js

This file was deleted.

15 changes: 15 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"parser": "@typescript-eslint/parser",
"extends": [
"plugin:@typescript-eslint/recommended",
"prettier/@typescript-eslint",
"plugin:prettier/recommended"
],
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module"
},
"rules": {
"complexity": [2, 10]
}
}
5 changes: 3 additions & 2 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all"
}
"trailingComma": "all",
"printWidth": 100
}
2 changes: 2 additions & 0 deletions src/async/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as makeCancelable } from './makeCancelable'
export { default as pause } from './pause'
40 changes: 40 additions & 0 deletions src/async/makeCancelable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import makeCancelable, { canceledError } from './makeCancelable'
import pause from './pause'

describe('Cancel promise (makeCancelable)', () => {
it('should resolve normally if not cancelled', async () => {
const cancelablePromise = makeCancelable(pause(20))
const result = cancelablePromise.then(() => 'resolved')
await expect(result).resolves.toEqual('resolved')
})

it('should reject with a canceled error if cancel is called', async () => {
const cancelablePromise = makeCancelable(pause(20))
const result = cancelablePromise.then(() => 'resolved')
cancelablePromise.cancel()
await expect(result).rejects.toEqual(canceledError)
})

it('should allow errors to bubble up to the cancelable promise', async () => {
const error = new Error('Error in bad promise')
const badPromise = new Promise((resolve, reject) => setTimeout(() => reject(error), 50))

const cancelablePromise = makeCancelable(badPromise)
const result = cancelablePromise.then(() => 'resolved')

await expect(result).rejects.toEqual(error)
})

it('should reject with a canceled error if cancel is called before the error occurs', async () => {
const error = new Error('Error in bad promise')
// Will throw an error in 50 ms
const badPromise = new Promise((resolve, reject) => setTimeout(() => reject(error), 50))

const cancelablePromise = makeCancelable(badPromise)
// Will cancel in 20 ms, before the error above throws
setTimeout(() => cancelablePromise.cancel(), 20)
const result = cancelablePromise.then(() => 'resolved')

await expect(result).rejects.toEqual(canceledError)
})
})
53 changes: 53 additions & 0 deletions src/async/makeCancelable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
export const canceledError = { isCanceled: true }

/** A promise that can have it's resolution cancelled */
export interface CancelablePromise<T> extends Promise<T> {
cancel(): void
}

/**
*
* Allows the provided promise to be canceled after starting. This does not stop the promise from executing but will
* cause it to reject with the value `{ isCanceled: true }` once it finishes, regardless of outcome.
*
* @param promise The promise that is executing
* @return The cancelable version of the promise
*
* @example
* const promise = new Promise((res, rej) => {
* setTimeout(() => res('I finished!'), 3000)
* })
*
* // Create a cancelable version of the promise
* const cancelablePromise = makeCancelable(promise)
*
* // Stop the cancelable promise from resolving
* cancelablePromise.cancel()
*
* promise
* .then(result => console.log('Normal', result)) // This will log `'I finished!'` after 3000ms
* .catch(err => console.log('Normal', err)) // Will reject as per normal
*
* cancelablePromise
* .then(result => console.log('Cancelable', result)) // Never fires, the promise will not resolve after being cancelled
* .catch(err => console.log('Cancelable', err)) // Resolves after 3000ms with the value `{ isCanceled: true }`
*
* @category async
*
*/
export default function makeCancelable<T>(promise: Promise<T>): CancelablePromise<T> {
let hasCanceled = false

const cancelablePromise: Partial<CancelablePromise<T>> = new Promise((resolve, reject) => {
promise.then(
val => (hasCanceled ? reject(canceledError) : resolve(val)),
error => (hasCanceled ? reject(canceledError) : reject(error)),
)
})

cancelablePromise.cancel = () => {
hasCanceled = true
}

return cancelablePromise as CancelablePromise<T>
}
10 changes: 10 additions & 0 deletions src/async/pause.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import pause from './pause'

describe('Wait for delay (pause)', () => {
it('should resolve after a minimum of the provided ms', async () => {
const delay = 50
const start = Date.now()
await pause(delay)
expect(Date.now() - start).toBeGreaterThanOrEqual(delay - 5) // 5 ms threshold for random CPU variance
})
})
15 changes: 15 additions & 0 deletions src/async/pause.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
*
* Creates a promise that resolves in the provided number of milliseconds.
*
* This function is basically a promise version of `setTimeout`
*
* @param ms The number of ms to pause for
* @return The executing promise
*
* @category async
*
*/
export default function wait(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './accessors'
export * from './array'
export * from './async'
export * from './function'
export * from './tests'
export * from './transforms'
Expand Down

0 comments on commit 37132d0

Please sign in to comment.