Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(fake-timers): advance fake timers in ms steps #1

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@
"react-error-boundary": "^3.1.0"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "4.30.0",
"@typescript-eslint/parser": "4.30.0",
"@typescript-eslint/eslint-plugin": "4.31.0",
"@typescript-eslint/parser": "4.31.0",
"all-contributors-cli": "6.20.0",
"codecov": "3.8.3",
"cross-env": "^7.0.3",
Expand Down
264 changes: 258 additions & 6 deletions src/__tests__/asyncHook.fakeTimers.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,27 @@
import { useState, useRef, useEffect } from 'react'

describe('async hook (fake timers) tests', () => {
const useSequence = (values: string[], intervalMs = 50) => {
const [first, ...otherValues] = values
const [value, setValue] = useState(() => first)
const index = useRef(0)

useEffect(() => {
const interval = setInterval(() => {
setValue(otherValues[index.current++])
if (index.current >= otherValues.length) {
clearInterval(interval)
}
}, intervalMs)
return () => {
clearInterval(interval)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, otherValues)

return value
}

beforeEach(() => {
jest.useFakeTimers()
})
Expand Down Expand Up @@ -70,7 +93,7 @@ describe('async hook (fake timers) tests', () => {

setTimeout(() => {
actual = expected
}, 101)
}, 30)

let complete = false

Expand All @@ -80,14 +103,243 @@ describe('async hook (fake timers) tests', () => {
expect(actual).toBe(expected)
complete = true
},
{ timeout: 100, interval: 50 }
{ timeout: 29, interval: 10 }
)
).rejects.toThrow(Error('Timed out in waitFor after 100ms.'))
).rejects.toThrow(Error('Timed out in waitFor after 29ms.'))

expect(complete).toBe(false)
})

test('should wait for next update', async () => {
const { result, waitForNextUpdate } = renderHook(() => useSequence(['first', 'second']))

expect(result.current).toBe('first')

await waitForNextUpdate()

expect(result.current).toBe('second')
})

test('should wait for multiple updates', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useSequence(['first', 'second', 'third'])
)

expect(result.current).toBe('first')

await waitForNextUpdate()

expect(result.current).toBe('second')

await waitForNextUpdate()

expect(result.current).toBe('third')
})

test('should reject if timeout exceeded when waiting for next update', async () => {
const { result, waitForNextUpdate } = renderHook(() => useSequence(['first', 'second']))

expect(result.current).toBe('first')

await expect(waitForNextUpdate({ timeout: 10 })).rejects.toThrow(
Error('Timed out in waitForNextUpdate after 10ms.')
)
})

test('should not reject when waiting for next update if timeout has been disabled', async () => {
const { result, waitForNextUpdate } = renderHook(() => useSequence(['first', 'second'], 1100))

expect(result.current).toBe('first')

await waitForNextUpdate({ timeout: false })

expect(result.current).toBe('second')
})

test('should wait for expectation to pass', async () => {
const { result, waitFor } = renderHook(() => useSequence(['first', 'second', 'third']))

expect(result.current).toBe('first')

let complete = false
await waitFor(() => {
expect(result.current).toBe('third')
complete = true
})
expect(complete).toBe(true)
})

test('should wait for arbitrary expectation to pass', async () => {
const { waitFor } = renderHook(() => null)

let actual = 0
const expected = 1

setTimeout(() => {
actual = expected
}, 200)

let complete = false
await waitFor(() => {
expect(actual).toBe(expected)
complete = true
})

expect(complete).toBe(true)
})

test('should not hang if expectation is already passing', async () => {
const { result, waitFor } = renderHook(() => useSequence(['first', 'second']))

expect(result.current).toBe('first')

let complete = false
await waitFor(() => {
expect(result.current).toBe('first')
complete = true
})
expect(complete).toBe(true)
})

test('should wait for truthy value', async () => {
const { result, waitFor } = renderHook(() => useSequence(['first', 'second', 'third']))

expect(result.current).toBe('first')

await waitFor(() => result.current === 'third')

expect(result.current).toBe('third')
})

test('should wait for arbitrary truthy value', async () => {
const { waitFor } = renderHook(() => null)

let actual = 0
const expected = 1

setTimeout(() => {
actual = expected
}, 200)

await waitFor(() => actual === 1)

expect(actual).toBe(expected)
})

test('should reject if timeout exceeded when waiting for expectation to pass', async () => {
const { result, waitFor } = renderHook(() => useSequence(['first', 'second', 'third']))

expect(result.current).toBe('first')

await expect(
waitFor(
() => {
expect(result.current).toBe('third')
},
{ timeout: 75 }
)
).rejects.toThrow(Error('Timed out in waitFor after 75ms.'))
})

test('should not reject when waiting for expectation to pass if timeout has been disabled', async () => {
const { result, waitFor } = renderHook(() => useSequence(['first', 'second', 'third'], 550))

expect(result.current).toBe('first')

await waitFor(
() => {
expect(result.current).toBe('third')
},
{ timeout: false }
)

expect(result.current).toBe('third')
})

test('should check on interval when waiting for expectation to pass', async () => {
const { result, waitFor } = renderHook(() => useSequence(['first', 'second', 'third']))

let checks = 0

await waitFor(
() => {
checks++
return result.current === 'third'
},
{ interval: 100 }
)

expect(checks).toBe(3)
})

test('should wait for value to change', async () => {
const { result, waitForValueToChange } = renderHook(() =>
useSequence(['first', 'second', 'third'])
)

expect(result.current).toBe('first')

await waitForValueToChange(() => result.current === 'third')

expect(result.current).toBe('third')
})

test('should wait for arbitrary value to change', async () => {
const { waitForValueToChange } = renderHook(() => null)

let actual = 0
const expected = 1

setTimeout(() => {
actual = expected
}, 200)

await waitForValueToChange(() => actual)

expect(actual).toBe(expected)
})

test('should reject if timeout exceeded when waiting for value to change', async () => {
const { result, waitForValueToChange } = renderHook(() =>
useSequence(['first', 'second', 'third'])
)

expect(result.current).toBe('first')

await expect(
waitForValueToChange(() => result.current === 'third', {
timeout: 75
})
).rejects.toThrow(Error('Timed out in waitForValueToChange after 75ms.'))
})

test('should not reject when waiting for value to change if timeout is disabled', async () => {
const { result, waitForValueToChange } = renderHook(() =>
useSequence(['first', 'second', 'third'], 550)
)

expect(result.current).toBe('first')

await waitForValueToChange(() => result.current === 'third', {
timeout: false
})

expect(result.current).toBe('third')
})

test('should reject if selector throws error', async () => {
const { result, waitForValueToChange } = renderHook(() => useSequence(['first', 'second']))

expect(result.current).toBe('first')

await expect(
waitForValueToChange(() => {
if (result.current === 'second') {
throw new Error('Something Unexpected')
}
return result.current
})
).rejects.toThrow(Error('Something Unexpected'))
})
})
})

// eslint-disable-next-line jest/no-export
export {}
21 changes: 12 additions & 9 deletions src/core/asyncUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,36 +10,39 @@ import {
import { createTimeoutController } from '../helpers/createTimeoutController'
import { TimeoutError } from '../helpers/error'

const DEFAULT_TIMEOUT = 1000
const DEFAULT_INTERVAL = 50
const DEFAULT_TIMEOUT = 1000

function asyncUtils(act: Act, addResolver: (callback: () => void) => void): AsyncUtils {
const wait = async (callback: () => boolean | void, { interval, timeout }: WaitOptions) => {
const wait = async (
callback: () => boolean | void,
{ interval, timeout }: Required<WaitOptions>
) => {
const checkResult = () => {
const callbackResult = callback()
return callbackResult ?? callbackResult === undefined
}

const timeoutSignal = createTimeoutController(timeout as number | boolean, false)
const timeoutController = createTimeoutController(timeout, { allowFakeTimers: !interval })

const waitForResult = async () => {
while (true) {
const intervalSignal = createTimeoutController(interval as number | boolean, true)
timeoutSignal.onTimeout(() => intervalSignal.cancel())
const intervalController = createTimeoutController(interval, { allowFakeTimers: true })
timeoutController.onTimeout(() => intervalController.cancel())

await intervalSignal.wrap(new Promise<void>(addResolver))
await intervalController.wrap(new Promise<void>(addResolver))

if (checkResult() || timeoutSignal.timedOut) {
if (checkResult() || timeoutController.timedOut) {
return
}
}
}

if (!checkResult()) {
await act(() => timeoutSignal.wrap(waitForResult()))
await act(() => timeoutController.wrap(waitForResult()))
}

return !timeoutSignal.timedOut
return !timeoutController.timedOut
}

const waitFor = async (
Expand Down
24 changes: 16 additions & 8 deletions src/helpers/createTimeoutController.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { jestFakeTimersAreEnabled } from './jestFakeTimersAreEnabled'
import { fakeTimersAreEnabled, advanceTimers } from './fakeTimers'

function createTimeoutController(timeout: number | boolean, allowFakeTimers: boolean) {
function createTimeoutController(
timeout: number | false,
{ allowFakeTimers }: { allowFakeTimers: boolean }
) {
let timeoutId: NodeJS.Timeout
const timeoutCallbacks: Array<() => void> = []

Expand All @@ -18,17 +21,22 @@ function createTimeoutController(timeout: number | boolean, allowFakeTimers: boo
timeoutController.timedOut = true
timeoutCallbacks.forEach((callback) => callback())
resolve()
}, timeout as number)

if (jestFakeTimersAreEnabled() && allowFakeTimers) {
jest.advanceTimersByTime(timeout as number)
}
}, timeout)
}

let finished = false

promise
.then(resolve)
.catch(reject)
.finally(() => timeoutController.cancel())
.finally(() => {
finished = true
timeoutController.cancel()
})

if (allowFakeTimers && fakeTimersAreEnabled()) {
advanceTimers(timeout, () => finished)
}
})
},
cancel() {
Expand Down