Skip to content

Commit 5f603df

Browse files
committed
feat(waitFor): add complete and transparent support for fake timers
Closes #661
1 parent a114f4f commit 5f603df

File tree

4 files changed

+117
-98
lines changed

4 files changed

+117
-98
lines changed

src/__tests__/wait-for-element-to-be-removed.js

Lines changed: 1 addition & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,6 @@
1+
import {waitForElementToBeRemoved} from '..'
12
import {renderIntoDocument} from './helpers/test-utils'
23

3-
function importModule() {
4-
return require('../').waitForElementToBeRemoved
5-
}
6-
7-
let waitForElementToBeRemoved
8-
9-
beforeEach(() => {
10-
jest.useRealTimers()
11-
jest.resetModules()
12-
waitForElementToBeRemoved = importModule()
13-
})
14-
154
test('resolves on mutation only when the element is removed', async () => {
165
const {queryAllByTestId} = renderIntoDocument(`
176
<div data-testid="div"></div>
@@ -89,55 +78,6 @@ test('after successful removal, fullfills promise with empty value (undefined)',
8978
return expect(waitResult).resolves.toBeUndefined()
9079
})
9180

92-
describe('timers', () => {
93-
const expectElementToBeRemoved = async () => {
94-
const importedWaitForElementToBeRemoved = importModule()
95-
96-
const {queryAllByTestId} = renderIntoDocument(`
97-
<div data-testid="div"></div>
98-
<div data-testid="div"></div>
99-
`)
100-
const divs = queryAllByTestId('div')
101-
// first mutation
102-
setTimeout(() => {
103-
divs.forEach(d => d.setAttribute('id', 'mutated'))
104-
})
105-
// removal
106-
setTimeout(() => {
107-
divs.forEach(div => div.parentElement.removeChild(div))
108-
}, 100)
109-
110-
const promise = importedWaitForElementToBeRemoved(
111-
() => queryAllByTestId('div'),
112-
{
113-
timeout: 200,
114-
},
115-
)
116-
117-
if (setTimeout._isMockFunction) {
118-
jest.advanceTimersByTime(110)
119-
}
120-
121-
await promise
122-
}
123-
124-
it('works with real timers', async () => {
125-
jest.useRealTimers()
126-
await expectElementToBeRemoved()
127-
})
128-
it('works with fake timers', async () => {
129-
jest.useFakeTimers()
130-
await expectElementToBeRemoved()
131-
})
132-
})
133-
134-
test("doesn't change jest's timers value when importing the module", () => {
135-
jest.useFakeTimers()
136-
importModule()
137-
138-
expect(window.setTimeout._isMockFunction).toEqual(true)
139-
})
140-
14181
test('rethrows non-testing-lib errors', () => {
14282
let throwIt = false
14383
const div = document.createElement('div')

src/__tests__/wait-for.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,44 @@ test('throws nice error if provided callback is not a function', () => {
9595
'Received `callback` arg must be a function',
9696
)
9797
})
98+
99+
describe('works the same with fake timers', () => {
100+
afterEach(() => jest.useRealTimers())
101+
102+
async function runTest(options) {
103+
const doAsyncThing = () =>
104+
new Promise(r => setTimeout(() => r('data'), 300))
105+
let result
106+
doAsyncThing().then(r => (result = r))
107+
108+
await waitFor(() => expect(result).toBe('data'), options)
109+
}
110+
111+
test('real timers', async () => {
112+
// the only difference when not using fake timers is this test will
113+
// have to wait the full length of the timeout
114+
await runTest()
115+
})
116+
117+
test('legacy', async () => {
118+
jest.useFakeTimers('legacy')
119+
await runTest()
120+
})
121+
122+
test('modern', async () => {
123+
jest.useFakeTimers()
124+
await runTest()
125+
})
126+
127+
test('fake timer timeout', async () => {
128+
jest.useFakeTimers()
129+
await expect(
130+
waitFor(
131+
() => {
132+
throw new Error('always throws')
133+
},
134+
{timeout: 10},
135+
),
136+
).rejects.toMatchInlineSnapshot(`[Error: always throws]`)
137+
})
138+
})

src/helpers.js

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,44 +2,52 @@ const globalObj = typeof window === 'undefined' ? global : window
22

33
// Currently this fn only supports jest timers, but it could support other test runners in the future.
44
function runWithRealTimers(callback) {
5-
const usingJestAndTimers =
6-
typeof jest !== 'undefined' && typeof globalObj.setTimeout !== 'undefined'
7-
const usingLegacyJestFakeTimers =
8-
usingJestAndTimers &&
5+
const fakeTimersType = getJestFakeTimersType()
6+
if (fakeTimersType) {
7+
jest.useRealTimers()
8+
}
9+
10+
const callbackReturnValue = callback()
11+
12+
if (fakeTimersType) {
13+
jest.useFakeTimers(fakeTimersType)
14+
}
15+
16+
return callbackReturnValue
17+
}
18+
19+
function getJestFakeTimersType() {
20+
if (
21+
typeof jest === 'undefined' ||
22+
typeof globalObj.setTimeout === 'undefined'
23+
) {
24+
return null
25+
}
26+
27+
if (
928
typeof globalObj.setTimeout._isMockFunction !== 'undefined' &&
1029
globalObj.setTimeout._isMockFunction
30+
) {
31+
return 'legacy'
32+
}
1133

12-
let usingModernJestFakeTimers = false
1334
if (
14-
usingJestAndTimers &&
1535
typeof globalObj.setTimeout.clock !== 'undefined' &&
1636
typeof jest.getRealSystemTime !== 'undefined'
1737
) {
1838
try {
1939
// jest.getRealSystemTime is only supported for Jest's `modern` fake timers and otherwise throws
2040
jest.getRealSystemTime()
21-
usingModernJestFakeTimers = true
41+
return 'modern'
2242
} catch {
2343
// not using Jest's modern fake timers
2444
}
2545
}
26-
27-
const usingJestFakeTimers =
28-
usingLegacyJestFakeTimers || usingModernJestFakeTimers
29-
30-
if (usingJestFakeTimers) {
31-
jest.useRealTimers()
32-
}
33-
34-
const callbackReturnValue = callback()
35-
36-
if (usingJestFakeTimers) {
37-
jest.useFakeTimers(usingModernJestFakeTimers ? 'modern' : 'legacy')
38-
}
39-
40-
return callbackReturnValue
46+
return null
4147
}
4248

49+
const jestFakeTimersAreEnabled = () => Boolean(getJestFakeTimersType())
50+
4351
// we only run our tests in node, and setImmediate is supported in node.
4452
// istanbul ignore next
4553
function setImmediatePolyfill(fn) {
@@ -117,4 +125,5 @@ export {
117125
setTimeoutFn as setTimeout,
118126
runWithRealTimers,
119127
checkContainerType,
128+
jestFakeTimersAreEnabled,
120129
}

src/wait-for.js

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
setTimeout,
66
clearTimeout,
77
runWithRealTimers,
8+
jestFakeTimersAreEnabled,
89
} from './helpers'
910
import {getConfig, runWithExpensiveErrorDiagnosticsDisabled} from './config'
1011

@@ -35,22 +36,50 @@ function waitFor(
3536
}
3637

3738
if (interval < 1) interval = 1
38-
return new Promise((resolve, reject) => {
39-
let lastError
40-
const overallTimeoutTimer = setTimeout(onTimeout, timeout)
41-
const intervalId = setInterval(checkCallback, interval)
39+
return new Promise(async (resolve, reject) => {
40+
let lastError, overallTimeoutTimer, intervalId, observer
41+
let finished = false
4242

43-
const {MutationObserver} = getWindowFromNode(container)
44-
const observer = new MutationObserver(checkCallback)
45-
runWithRealTimers(() =>
46-
observer.observe(container, mutationObserverOptions),
47-
)
48-
checkCallback()
43+
// whether we're using fake timers or not, we still want the timeout support
44+
runWithRealTimers(() => {
45+
overallTimeoutTimer = setTimeout(onTimeout, timeout)
46+
})
47+
48+
const usingFakeTimers = jestFakeTimersAreEnabled()
49+
if (usingFakeTimers) {
50+
// this is a dangerous rule to disable because it could lead to an
51+
// infinite loop. However, eslint isn't smart enough to know that we're
52+
// setting finished inside `onDone` which will be called when we're done
53+
// waiting or when we've timed out.
54+
// eslint-disable-next-line no-unmodified-loop-condition
55+
while (!finished) {
56+
jest.advanceTimersByTime(interval)
57+
// in this rare case, we *need* to wait for in-flight promises
58+
// to resolve before continuing. We don't need to take advantage
59+
// of parallelization so we're fine.
60+
// https://stackoverflow.com/a/59243586/971592
61+
// eslint-disable-next-line no-await-in-loop
62+
await new Promise(r => setImmediate(r))
63+
checkCallback()
64+
}
65+
} else {
66+
intervalId = setInterval(checkCallback, interval)
67+
const {MutationObserver} = getWindowFromNode(container)
68+
observer = new MutationObserver(checkCallback)
69+
observer.observe(container, mutationObserverOptions)
70+
checkCallback()
71+
}
4972

5073
function onDone(error, result) {
51-
clearTimeout(overallTimeoutTimer)
52-
clearInterval(intervalId)
53-
setImmediate(() => observer.disconnect())
74+
finished = true
75+
runWithRealTimers(() => {
76+
clearTimeout(overallTimeoutTimer)
77+
})
78+
79+
if (!usingFakeTimers) {
80+
clearInterval(intervalId)
81+
setImmediate(() => observer.disconnect())
82+
}
5483

5584
if (error) {
5685
reject(error)
@@ -62,9 +91,9 @@ function waitFor(
6291
function checkCallback() {
6392
try {
6493
onDone(null, runWithExpensiveErrorDiagnosticsDisabled(callback))
65-
// If `callback` throws, wait for the next mutation or timeout.
94+
// If `callback` throws, wait for the next mutation, interval, or timeout.
6695
} catch (error) {
67-
// Save the callback error to reject the promise with it.
96+
// Save the most recent callback error to reject the promise with it in the event of a timeout
6897
lastError = error
6998
}
7099
}

0 commit comments

Comments
 (0)