Skip to content

feat: Implement waitFor #2

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

Open
wants to merge 12 commits into
base: alpha
Choose a base branch
from
Open
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
5 changes: 5 additions & 0 deletions .codesandbox/ci.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"installCommand": "install:csb",
"sandboxes": ["new", "github/kentcdodds/react-testing-library-examples"],
"node": "18"
}
25 changes: 15 additions & 10 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
@@ -11,27 +11,27 @@ on:
- 'beta'
- 'alpha'
- '!all-contributors/**'
pull_request: {}
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions: {}

jobs:
main:
permissions:
actions: write # to cancel/stop running workflows (styfle/cancel-workflow-action)
contents: read # to fetch code (actions/checkout)
# ignore all-contributors PRs
if: ${{ !contains(github.head_ref, 'all-contributors') }}
strategy:
# Otherwise we would not know if the problem is tied to the Node.js version
fail-fast: false
matrix:
node: [14, 16, 18]
node: [14, 16, 18, 20]
runs-on: ubuntu-latest
steps:
- name: 🛑 Cancel Previous Runs
uses: styfle/[email protected]

- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
@@ -43,6 +43,10 @@ jobs:
with:
node-version: ${{ matrix.node }}

# Ideally done by actions/setup-node: https://github.com/actions/setup-node/issues/213
- name: Setup package manager
run: npm install -g [email protected]

- name: 📥 Download deps
uses: bahmutov/npm-install@v1
with:
@@ -63,18 +67,15 @@ jobs:

release:
permissions:
actions: write # to cancel/stop running workflows (styfle/cancel-workflow-action)
contents: write # to create release tags (cycjimmy/semantic-release-action)
issues: write # to post release that resolves an issue

needs: main
runs-on: ubuntu-latest
if:
${{ github.repository == 'testing-library/web-testing-library' &&
github.event_name == 'push' }}
steps:
- name: 🛑 Cancel Previous Runs
uses: styfle/[email protected]

- name: ⬇️ Checkout repo
uses: actions/checkout@v3

@@ -83,6 +84,10 @@ jobs:
with:
node-version: 14

# Ideally done by actions/setup-node: https://github.com/actions/setup-node/issues/213
- name: Setup package manager
run: npm install -g [email protected]

- name: 📥 Download deps
uses: bahmutov/npm-install@v1
with:
22 changes: 14 additions & 8 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -7,18 +7,24 @@ const {

module.exports = {
collectCoverageFrom,
coveragePathIgnorePatterns: [
...coveragePathIgnorePatterns,
'/__tests__/',
'/__node_tests__/',
],
coverageThreshold,
coveragePathIgnorePatterns: [...coveragePathIgnorePatterns, '/__tests__/'],
coverageThreshold: {
...coverageThreshold,
// full coverage across the build matrix (Node.js versions) but not in a single job
// minimum coverage of jobs using different Node.js version
'./src/waitFor.ts': {
branches: 96.77,
functions: 100,
lines: 97.95,
statements: 98,
},
},
watchPlugins: [
...watchPlugins,
require.resolve('jest-watch-select-projects'),
],
projects: [
require.resolve('./tests/jest.config.dom.js'),
require.resolve('./tests/jest.config.node.js'),
// No idea why I need to specify a project instead of having a single config
require.resolve('./tests/jest.config.js'),
],
}
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -68,8 +68,13 @@
"jest-watch-select-projects": "^2.0.0",
"jsdom": "^16.4.0",
"kcd-scripts": "^11.0.0",
"pretty-format": "^29.3.1",
"typescript": "^4.1.2"
},
"overrides": {
"browserslist": "4.21.8",
"caniuse-lite": "1.0.30001502"
},
"eslintConfig": {
"extends": [
"./node_modules/kcd-scripts/eslint.js",
7 changes: 7 additions & 0 deletions src/__tests__/__snapshots__/waitFor.test.js.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`waitFor DOM reference implementation using fake legacy timers timeout 1`] = `Not done`;

exports[`waitFor DOM reference implementation using fake modern timers timeout 1`] = `Not done`;

exports[`waitFor DOM reference implementation using real timers timeout 1`] = `Not done`;
67 changes: 67 additions & 0 deletions src/__tests__/__snapshots__/waitForDOM.test.js.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`waitFor DOM reference implementation using fake legacy timers timeout 1`] = `
Not done
Document {
"location": Location {
"assign": [Function assign],
"hash": "",
"host": "localhost",
"hostname": "localhost",
"href": "http://localhost/",
"origin": "http://localhost",
"pathname": "/",
"port": "",
"protocol": "http:",
"reload": [Function reload],
"replace": [Function replace],
"search": "",
"toString": [Function toString],
},
}
`;

exports[`waitFor DOM reference implementation using fake modern timers timeout 1`] = `
Not done
Document {
"location": Location {
"assign": [Function assign],
"hash": "",
"host": "localhost",
"hostname": "localhost",
"href": "http://localhost/",
"origin": "http://localhost",
"pathname": "/",
"port": "",
"protocol": "http:",
"reload": [Function reload],
"replace": [Function replace],
"search": "",
"toString": [Function toString],
},
}
`;

exports[`waitFor DOM reference implementation using real timers timeout 1`] = `
Not done
Document {
"location": Location {
"assign": [Function assign],
"hash": "",
"host": "localhost",
"hostname": "localhost",
"href": "http://localhost/",
"origin": "http://localhost",
"pathname": "/",
"port": "",
"protocol": "http:",
"reload": [Function reload],
"replace": [Function replace],
"search": "",
"toString": [Function toString],
},
}
`;
240 changes: 238 additions & 2 deletions src/__tests__/waitFor.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,241 @@
/**
* @jest-environment node
*/

import * as prettyFormat from 'pretty-format'
import {waitFor} from '../'

test('runs', async () => {
await expect(waitFor(() => {})).resolves.toBeUndefined()
function deferred() {
let resolve, reject
const promise = new Promise((res, rej) => {
resolve = res
reject = rej
})
return {promise, resolve, reject}
}

beforeEach(() => {
jest.useRealTimers()
})

test('waits callback to not throw an error', async () => {
const spy = jest.fn()
// we are using random timeout here to simulate a real-time example
// of an async operation calling a callback at a non-deterministic time
const randomTimeout = Math.floor(Math.random() * 60)
setTimeout(spy, randomTimeout)

await waitFor(() => expect(spy).toHaveBeenCalledTimes(1))
expect(spy).toHaveBeenCalledWith()
})

// we used to have a limitation where we had to set an interval of 0 to 1
// otherwise there would be problems. I don't think this limitation exists
// anymore, but we'll keep this test around to make sure a problem doesn't
// crop up.
test('can accept an interval of 0', () => waitFor(() => {}, {interval: 0}))

test('can timeout after the given timeout time', async () => {
const error = new Error('throws every time')
const result = await waitFor(
() => {
throw error
},
{timeout: 8, interval: 5},
).catch(e => e)
expect(result).toBe(error)
})

test('if no error is thrown then throws a timeout error', async () => {
const result = await waitFor(
() => {
// eslint-disable-next-line no-throw-literal
throw undefined
},
{timeout: 8, interval: 5, onTimeout: e => e},
).catch(e => e)
expect(result).toMatchInlineSnapshot(`[Error: Timed out in waitFor.]`)
})

test('if showOriginalStackTrace on a timeout error then the stack trace does not include this file', async () => {
const result = await waitFor(
() => {
// eslint-disable-next-line no-throw-literal
throw undefined
},
{timeout: 8, interval: 5, showOriginalStackTrace: true},
).catch(e => e)
expect(result.stack).not.toMatch(__dirname)
})

test('uses full stack error trace when showOriginalStackTrace present', async () => {
const error = new Error('Throws the full stack trace')
// even if the error is a TestingLibraryElementError
error.name = 'TestingLibraryElementError'
const originalStackTrace = error.stack
const result = await waitFor(
() => {
throw error
},
{timeout: 8, interval: 5, showOriginalStackTrace: true},
).catch(e => e)
expect(result.stack).toBe(originalStackTrace)
})

test('throws nice error if provided callback is not a function', () => {
const someElement = {}
expect(() => waitFor(someElement)).toThrow(
'Received `callback` arg must be a function',
)
})

test('when a promise is returned, it does not call the callback again until that promise rejects', async () => {
const sleep = t => new Promise(r => setTimeout(r, t))
const p1 = deferred()
const waitForCb = jest.fn(() => p1.promise)
const waitForPromise = waitFor(waitForCb, {interval: 1})
expect(waitForCb).toHaveBeenCalledTimes(1)
waitForCb.mockClear()
await sleep(50)
expect(waitForCb).toHaveBeenCalledTimes(0)

const p2 = deferred()
waitForCb.mockImplementation(() => p2.promise)

p1.reject('p1 rejection (should not fail this test)')
await sleep(50)

expect(waitForCb).toHaveBeenCalledTimes(1)
p2.resolve()

await waitForPromise
})

test('when a promise is returned, if that is not resolved within the timeout, then waitFor is rejected', async () => {
const sleep = t => new Promise(r => setTimeout(r, t))
const {promise} = deferred()
const waitForError = waitFor(() => promise, {timeout: 1}).catch(e => e)
await sleep(5)

expect((await waitForError).message).toMatchInlineSnapshot(
`Timed out in waitFor.`,
)
})

test('does not work after it resolves', async () => {
jest.useFakeTimers('modern')
let context = 'initial'

/** @type {import('../').FakeClock} */
const clock = {
// @testing-library/react usage to ensure `IS_REACT_ACT_ENVIRONMENT` is set when acting.
advanceTimersByTime: async timeoutMS => {
const originalContext = context
context = 'act'
try {
jest.advanceTimersByTime(timeoutMS)
} finally {
context = originalContext
}
},
flushPromises: async () => {
const originalContext = context
context = 'no-act'
try {
await await new Promise(r => {
setTimeout(r, 0)
jest.advanceTimersByTime(0)
})
} finally {
context = originalContext
}
},
}

let data = null
setTimeout(() => {
data = 'resolved'
}, 100)

await waitFor(
() => {
// eslint-disable-next-line jest/no-conditional-in-test -- false-positive
if (data === null) {
throw new Error('not found')
}
},
{clock, interval: 50},
)

expect(context).toEqual('initial')

await Promise.resolve()

expect(context).toEqual('initial')
})

/** @type {import('../').FakeClock} */
const jestFakeClock = {
advanceTimersByTime: async timeoutMS => {
jest.advanceTimersByTime(timeoutMS)
},
}
describe.each([
['real timers', {useTimers: () => jest.useRealTimers(), clock: undefined}],
[
'fake legacy timers',
{useTimers: () => jest.useFakeTimers('legacy'), clock: jestFakeClock},
],
[
'fake modern timers',
{useTimers: () => jest.useFakeTimers('modern'), clock: jestFakeClock},
],
])(
'waitFor DOM reference implementation using %s',
(label, {useTimers, clock}) => {
beforeEach(() => {
useTimers()
})

afterEach(() => {
jest.useRealTimers()
})

test('void callback', async () => {
await expect(waitFor(() => {}, {clock})).resolves.toBeUndefined()
})

test('callback passes after timeout', async () => {
let state = 'pending'
setTimeout(() => {
state = 'done'
}, 10)

await expect(
waitFor(
() => {
if (state !== 'done') {
throw new Error('Not done')
}
},
{clock, interval: 5},
),
).resolves.toBeUndefined()
})

test('timeout', async () => {
const state = 'pending'

await expect(
waitFor(
() => {
if (state !== 'done') {
throw new Error('Not done')
}
},
{clock, timeout: 10},
),
).rejects.toThrowErrorMatchingSnapshot()
})
},
)
236 changes: 236 additions & 0 deletions src/__tests__/waitForDOM.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
/**
* @jest-environment jsdom
*/

import * as prettyFormat from 'pretty-format'
import {waitFor as waitForWeb} from '../'

function jestFakeTimersAreEnabled() {
/* istanbul ignore else */
// eslint-disable-next-line
if (typeof jest !== 'undefined' && jest !== null) {
return (
// legacy timers
setTimeout._isMockFunction === true ||
// modern timers
Object.prototype.hasOwnProperty.call(setTimeout, 'clock')
)
}
// istanbul ignore next
return false
}

function getWindowFromNode(node) {
if (node.defaultView) {
// node is document
return node.defaultView
} else if (node.ownerDocument && node.ownerDocument.defaultView) {
// node is a DOM node
return node.ownerDocument.defaultView
} else {
// node is window
return node.window
}
}

/**
* Reference implementation of `waitFor` when a DOM is available.
* Supports fake timers and configureable instrumentation.
*/
function waitFor(
callback,
{
container = document,
interval = 50,
mutationObserverOptions = {
subtree: true,
childList: true,
attributes: true,
characterData: true,
},
timeout = 1000,
} = {},
) {
function getElementError(message) {
const prettifiedDOM = prettyFormat.format(container)
const error = new Error(
[message, prettifiedDOM].filter(Boolean).join('\n\n'),
)
error.name = 'TestingLibraryElementError'
return error
}

function handleTimeout(error) {
error.message = getElementError(error.message).message
return error
}

/**
* @template T
* @param {() => T} cb
* @returns T
*/
function advanceTimersWrapper(cb) {
// /dom config. /react uses act() here
return cb()
}

function runWithExpensiveErrorDiagnosticsDisabled() {
// /dom would disable certain config options when running callback
return callback()
}

/** @type {import('../').FakeClock} */
const jestFakeClock = {
advanceTimersByTime: timeoutMS => {
return advanceTimersWrapper(async () => {
jest.advanceTimersByTime(timeoutMS)
})
},
}
const clock = jestFakeTimersAreEnabled() ? jestFakeClock : undefined
const controller = new AbortController()

return new Promise((resolve, reject) => {
let promiseStatus = 'idle'

function onDone(error, result) {
controller.abort()
if (error === null) {
resolve(result)
} else {
reject(error)
}
}

function checkCallbackWithExpensiveErrorDiagnosticsDisabled() {
if (promiseStatus === 'pending') return undefined

const result = runWithExpensiveErrorDiagnosticsDisabled()
if (typeof result?.then === 'function') {
promiseStatus = 'pending'
return result.then(
resolvedValue => {
promiseStatus = 'resolved'
return resolvedValue
},
rejectedValue => {
promiseStatus = 'rejected'
throw rejectedValue
},
)
}
return result
}

const {MutationObserver} = getWindowFromNode(container)
const observer = new MutationObserver(() => {
const result = checkCallbackWithExpensiveErrorDiagnosticsDisabled()
if (typeof result?.then === 'function') {
result.then(resolvedValue => {
onDone(null, resolvedValue)
})
} else {
onDone(null, result)
}
})
observer.observe(container, mutationObserverOptions)
controller.signal.addEventListener('abort', () => {
observer.disconnect()
})

waitForWeb(checkCallbackWithExpensiveErrorDiagnosticsDisabled, {
clock,
interval,
onTimeout: handleTimeout,
signal: controller.signal,
timeout,
}).then(
result => {
onDone(null, result)
},
error => {
// https://webidl.spec.whatwg.org/#idl-DOMException
// https://dom.spec.whatwg.org/#ref-for-dom-abortcontroller-abortcontroller%E2%91%A0
const isAbortError =
error.name === 'AbortError' && error.code === DOMException.ABORT_ERR
// Ignore abort errors
if (!isAbortError) {
onDone(error, null)
}
},
)
})
}

describe.each([
['real timers', () => jest.useRealTimers()],
['fake legacy timers', () => jest.useFakeTimers('legacy')],
['fake modern timers', () => jest.useFakeTimers('modern')],
])('waitFor DOM reference implementation using %s', (label, useTimers) => {
beforeEach(() => {
useTimers()
})

afterEach(() => {
jest.useRealTimers()
})

test('void callback', async () => {
await expect(waitFor(() => {})).resolves.toBeUndefined()
})

test('callback passes after timeout', async () => {
let state = 'pending'
setTimeout(() => {
state = 'done'
}, 10)

await expect(
waitFor(
() => {
if (state !== 'done') {
throw new Error('Not done')
}
},
{interval: 5},
),
).resolves.toBeUndefined()
})

test('timeout', async () => {
const state = 'pending'

await expect(
waitFor(
() => {
if (state !== 'done') {
throw new Error('Not done')
}
},
{timeout: 10},
),
).rejects.toThrowErrorMatchingSnapshot()
})

test('can resolve early due to mutations', async () => {
const container = document.createElement('div')

setTimeout(() => {
container.appendChild(document.createTextNode('Done'))
}, 50)

const p = waitFor(
() => {
if (container.textContent !== 'Done') {
throw new Error('Not done')
}
return container.textContent
},
// this would never resolve with real timers without using a MutationObserver
{container, interval: 200, timeout: 200},
)

await expect(p).resolves.toBe('Done')
})
})
258 changes: 258 additions & 0 deletions src/__tests__/waitForNode.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
/**
* @jest-environment node
*/

import {waitFor as waitForWeb} from '../'

function sleep(timeoutMS, signal) {
return new Promise((resolve, reject) => {
const timeoutID = setTimeout(() => {
resolve()
}, timeoutMS)
signal?.addEventListener('abort', reason => {
clearTimeout(timeoutID)
reject(reason)
})
})
}

function jestFakeTimersAreEnabled() {
/* istanbul ignore else */
// eslint-disable-next-line
if (typeof jest !== 'undefined' && jest !== null) {
return (
// legacy timers
setTimeout._isMockFunction === true ||
// modern timers
Object.prototype.hasOwnProperty.call(setTimeout, 'clock')
)
}
// istanbul ignore next
return false
}

/**
* Reference implementation of `waitFor` that supports Jest fake timers
*/
function waitFor(callback, options) {
/** @type {import('../').FakeClock} */
const jestFakeClock = {
advanceTimersByTime: async timeoutMS => {
jest.advanceTimersByTime(timeoutMS)
},
}
const clock = jestFakeTimersAreEnabled() ? jestFakeClock : undefined

return waitForWeb(callback, {
clock,
...options,
})
}

// TODO: Use jest.replaceProperty(global, 'Error', ErrorWithoutStack) and `jest.restoreAllMocks`
let originalError
beforeEach(() => {
originalError = global.Error
})
afterEach(() => {
global.Error = originalError
})

test('runs', async () => {
await expect(waitFor(() => {})).resolves.toBeUndefined()
})

test('ensures the given callback is a function', () => {
expect(() => waitFor(null)).toThrowErrorMatchingInlineSnapshot(
`Received \`callback\` arg must be a function`,
)
})

const testAbortController =
typeof AbortController === 'undefined' ? test.skip : test

describe('using fake modern timers', () => {
beforeEach(() => {
jest.useFakeTimers('modern')
})
afterEach(() => {
jest.useRealTimers()
})

test('times out after 1s by default', async () => {
let resolved = false
setTimeout(() => {
resolved = true
}, 1000)

await expect(
waitFor(() => {
if (!resolved) {
throw new Error('Not resolved')
}
}),
).rejects.toThrowErrorMatchingInlineSnapshot(`Not resolved`)
})

test('times out even if the callback never settled', async () => {
await expect(
waitFor(() => {
return new Promise(() => {})
}),
).rejects.toThrowErrorMatchingInlineSnapshot(`Timed out in waitFor.`)
})

test('callback can return a promise and is not called again until the promise resolved', async () => {
const callback = jest.fn(() => {
return sleep(20)
})

await expect(waitFor(callback, {interval: 1})).resolves.toBeUndefined()
// We configured the waitFor call to ping every 1ms.
// But the callback only resolved after 20ms.
// If we would ping as instructed, we'd have 20+1 calls (1 initial, 20 for pings).
// But the implementation waits for callback to resolve first before checking again.
expect(callback).toHaveBeenCalledTimes(1)
})

test('callback is not called again until the promise rejects', async () => {
const callback = jest.fn(async () => {
await sleep(20)
throw new Error('Not done')
})

await expect(
waitFor(callback, {interval: 1, timeout: 30}),
).rejects.toThrowErrorMatchingInlineSnapshot(`Not done`)
// We configured the waitFor call to ping every 1ms.
// But the callback only rejected after 20ms.
// If we would ping as instructed, we'd have 30+1 calls (1 initial, 30 for pings until timeout was reached).
// But the implementation waits for callback to resolve first before checking again.
// So we have 1 for the initial check (that takes 20ms) and one for an interval check after the initial check resolved.
// Next ping would happen at 40ms but we already timed out at this point
expect(callback).toHaveBeenCalledTimes(2)
})

test('massages the stack trace to point to the waitFor call not the callback call', async () => {
let waitForError
try {
await waitFor(
() => {
return sleep(100)
},
{showOriginalStackTrace: false, interval: 100, timeout: 1},
)
} catch (caughtError) {
waitForError = caughtError
}

const stackTrace = waitForError.stack.split('\n').slice(1)
// The earlier a stackframe points to the actual callsite the better
const testStackFrame = stackTrace[1]
const fileLocationRegexp = /\((.*):\d+:\d+\)$/
expect(testStackFrame).toMatch(fileLocationRegexp)
const [, fileLocation] = testStackFrame.match(fileLocationRegexp)
expect(fileLocation).toBe(__filename)
})

test('does not crash in runtimes without Error.prototype.stack', async () => {
class ErrorWithoutStack extends Error {
// Not the same as "not having" but close enough
// stack a non-standard property so we have to guard against stack not existing
stack = undefined
}
const originalGlobalError = global.Error
global.Error = ErrorWithoutStack
let waitForError
try {
await waitFor(
() => {
return sleep(100)
},
{interval: 100, timeout: 1},
)
} catch (caughtError) {
waitForError = caughtError
}
// Restore early so that Jest can use Error.prototype.stack again
// Still need global restore in case something goes wrong.
global.Error = originalGlobalError

// Feel free to update this snapshot.
// It's only used to highlight how bad the default stack trace is if we timeout
// The only frame pointing to this test is the one from the wrapper.
// An actual test would not have any frames pointing to this test.
expect(waitForError.stack).toBeUndefined()
})

test('can be configured to throw an error with the original stack trace', async () => {
let waitForError
try {
await waitFor(
() => {
return sleep(100)
},
{showOriginalStackTrace: true, interval: 100, timeout: 1},
)
} catch (caughtError) {
waitForError = caughtError
}

// Feel free to update this snapshot.
// It's only used to highlight how bad the default stack trace is if we timeout
// The only frame pointing to this test is the one from the wrapper.
// An actual test would not have any frames pointing to this test.
expect(waitForError.stack).toMatchInlineSnapshot(`
Error: Timed out in waitFor.
at handleTimeout (<PROJECT_ROOT>/src/waitFor.ts:139:17)
at callTimer (<PROJECT_ROOT>/node_modules/@sinonjs/fake-timers/src/fake-timers-src.js:729:24)
at doTickInner (<PROJECT_ROOT>/node_modules/@sinonjs/fake-timers/src/fake-timers-src.js:1289:29)
at doTick (<PROJECT_ROOT>/node_modules/@sinonjs/fake-timers/src/fake-timers-src.js:1370:20)
at Object.tick (<PROJECT_ROOT>/node_modules/@sinonjs/fake-timers/src/fake-timers-src.js:1378:20)
at FakeTimers.advanceTimersByTime (<PROJECT_ROOT>/node_modules/@jest/fake-timers/build/modernFakeTimers.js:101:19)
at Object.advanceTimersByTime (<PROJECT_ROOT>/node_modules/jest-runtime/build/index.js:2228:26)
at Object.advanceTimersByTime (<PROJECT_ROOT>/src/__tests__/waitForNode.test.js:41:12)
at <PROJECT_ROOT>/src/waitFor.ts:85:21
at new Promise (<anonymous>)
`)
})

testAbortController('can be aborted with an AbortSignal', async () => {
const callback = jest.fn(() => {
throw new Error('not done')
})
const controller = new AbortController()
const waitForError = waitFor(callback, {
signal: controller.signal,
})

controller.abort('Bailing out')

await expect(waitForError).rejects.toThrowErrorMatchingInlineSnapshot(
`Aborted: Bailing out`,
)
// Initial check + one ping (after which we yield which gives us a chance to advance to the controller.abort call)
expect(callback).toHaveBeenCalledTimes(2)
})

testAbortController(
'does not even ping if the signal is already aborted',
async () => {
const callback = jest.fn(() => {
throw new Error('not done')
})
const controller = new AbortController()
controller.abort('Bailing out')

const waitForError = waitFor(callback, {
signal: controller.signal,
})

await expect(waitForError).rejects.toThrowErrorMatchingInlineSnapshot(
`Aborted: Bailing out`,
)
// Just the initial check
expect(callback).toHaveBeenCalledTimes(1)
},
)
})
6 changes: 2 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- TODO
export function waitFor(fn: () => Promise<void>): Promise<void> {
return Promise.resolve()
}
export {default as waitFor} from './waitFor'
export type {FakeClock, WaitForOptions} from './waitFor'
157 changes: 157 additions & 0 deletions src/waitFor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// This is so the stack trace the developer sees is one that's
// closer to their code (because async stack traces are hard to follow).
function copyStackTrace(target: Error, source: Error) {
if (source.stack !== undefined) {
target.stack = source.stack.replace(source.message, target.message)
}
}

export interface FakeClock {
advanceTimersByTime: (timeoutMS: number) => Promise<void>
flushPromises: () => Promise<void>
}

export interface WaitForOptions {
clock?: FakeClock
/**
* @default 50
*/
interval?: number
onTimeout?: (error: unknown) => unknown
signal?: AbortSignal
/**
* @default false
*/
showOriginalStackTrace?: boolean
/**
* @default 1000
*/
timeout?: number
}

interface WaitForImplOptions extends WaitForOptions {
stackTraceError: Error
}

function waitForImpl<T>(
callback: () => T | Promise<T>,
{
clock,
timeout = 1000,
showOriginalStackTrace = false,
stackTraceError,
interval = 50,
onTimeout = error => {
return error
},
signal,
}: WaitForImplOptions,
) {
if (typeof callback !== 'function') {
throw new TypeError('Received `callback` arg must be a function')
}

return new Promise(async (resolve, reject) => {
let lastError: unknown
let finished = false
let promiseStatus = 'idle'

const overallTimeoutTimer = setTimeout(handleTimeout, timeout)
const intervalId = setInterval(checkCallback, interval)

if (signal !== undefined) {
if (signal.aborted) {
onDone(new Error(`Aborted: ${signal.reason}`), null)
}
signal.addEventListener('abort', () => {
onDone(new Error(`Aborted: ${signal.reason}`), null)
})
}

checkCallback()

if (clock !== undefined) {
// this is a dangerous rule to disable because it could lead to an
// infinite loop. However, eslint isn't smart enough to know that we're
// setting finished inside `onDone` which will be called when we're done
// waiting or when we've timed out.
// eslint-disable-next-line no-unmodified-loop-condition, @typescript-eslint/no-unnecessary-condition
while (!finished && !signal?.aborted) {
// In this rare case, we *need* to wait for in-flight promises
// to resolve before continuing. We don't need to take advantage
// of parallelization so we're fine.
// https://stackoverflow.com/a/59243586/971592
// eslint-disable-next-line no-await-in-loop
await clock.advanceTimersByTime(interval)
}
}

function onDone(error: null, result: unknown): void
function onDone(error: unknown, result: null): void
function onDone(error: null | unknown, result: null | unknown) {
finished = true
clearTimeout(overallTimeoutTimer)
clearInterval(intervalId)

if (error) {
reject(error)
} else {
resolve(result)
}
}

function checkCallback() {
if (promiseStatus === 'pending') return
try {
const result = callback()
if (
result !== null &&
typeof result === 'object' &&
typeof (result as any).then === 'function'
) {
const thenable = result as PromiseLike<T>
promiseStatus = 'pending'
thenable.then(
resolvedValue => {
promiseStatus = 'resolved'
onDone(null, resolvedValue)
},
rejectedValue => {
promiseStatus = 'rejected'
lastError = rejectedValue
},
)
} else {
onDone(null, result)
}
// If `callback` throws, wait for the next mutation, interval, or timeout.
} catch (error: unknown) {
// Save the most recent callback error to reject the promise with it in the event of a timeout
lastError = error
}
}

function handleTimeout() {
let error: Error
if (lastError) {
error = lastError as Error
} else {
error = new Error('Timed out in waitFor.')
if (!showOriginalStackTrace) {
copyStackTrace(error, stackTraceError)
}
}
onDone(onTimeout(error), null)
}
})
}

export default function waitFor(
callback: () => void | Promise<void>,
options: WaitForOptions,

Choose a reason for hiding this comment

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

Suggested change
options: WaitForOptions,
options?: WaitForOptions,

Should options be optional? Otherwise, I get a TS error if 2nd arg is not provided.

) {
// create the error here so its stack trace is as close to the
// calling code as possible
const stackTraceError = new Error('STACK_TRACE_MESSAGE')
return waitForImpl(callback, {stackTraceError, ...options})
}
13 changes: 0 additions & 13 deletions tests/jest.config.dom.js

This file was deleted.

7 changes: 7 additions & 0 deletions tests/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const path = require('path')
const baseConfig = require('kcd-scripts/jest')

module.exports = {
...baseConfig,
rootDir: path.join(__dirname, '..'),
}
15 changes: 0 additions & 15 deletions tests/jest.config.node.js

This file was deleted.