-
Notifications
You must be signed in to change notification settings - Fork 3
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
eps1lon
wants to merge
12
commits into
testing-library:alpha
Choose a base branch
from
eps1lon:waitfor
base: alpha
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,011
−52
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
42a93e4
chore: remove `styfle/cancel-workflow-action` usage (#4)
MichaelDeBoey 6f6f5a8
Setup Codesandbox CI (#5)
eps1lon d86732e
feat: Rough sketch for API (includes untested implementation)
eps1lon 92ef85c
Can be in .ts
eps1lon 045294b
Test reference impl
eps1lon 39a57d0
Full test suite
eps1lon 066b255
Ensure aborted signal works
eps1lon 28ee0ba
Gate AbortController tests
eps1lon 221ad91
Lower coverage threshhold
eps1lon 69b474b
Port tests from waitFor DOM
75b76f6
Unify advanceTimersByTime and flushPromises
253fe6c
format
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: | ||
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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`; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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], | ||
}, | ||
} | ||
`; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}) | ||
}, | ||
) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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') | ||
}) | ||
}) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}, | ||
) | ||
}) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
) { | ||
// 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}) | ||
} |
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, '..'), | ||
} |
This file was deleted.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should
options
be optional? Otherwise, I get a TS error if 2nd arg is not provided.