diff --git a/src/__tests__/wait-for.test.tsx b/src/__tests__/wait-for.test.tsx index 88c8429d3..d3e9c70bb 100644 --- a/src/__tests__/wait-for.test.tsx +++ b/src/__tests__/wait-for.test.tsx @@ -2,249 +2,245 @@ import * as React from 'react'; import { Pressable, Text, TouchableOpacity, View } from 'react-native'; import { configure, fireEvent, render, screen, waitFor } from '..'; +import type { TimerType } from '../test-utils/timers'; +import { setupTimeType } from '../test-utils/timers'; -class Banana extends React.Component { - changeFresh = () => { - this.props.onChangeFresh(); - }; - - render() { - return ( - - {this.props.fresh && Fresh} - - Change freshness! - - - ); - } -} - -class BananaContainer extends React.Component { - state = { fresh: false }; - - onChangeFresh = async () => { - await new Promise((resolve) => setTimeout(resolve, 300)); - this.setState({ fresh: true }); - }; - - render() { - return ; - } -} - -afterEach(() => { +beforeEach(() => { jest.useRealTimers(); }); -test('waits for element until it stops throwing', async () => { - await render(); +describe('successful waiting', () => { + test('waits for expect() assertion to pass', async () => { + const mockFunction = jest.fn(); - await fireEvent.press(screen.getByText('Change freshness!')); + function AsyncComponent() { + React.useEffect(() => { + setTimeout(() => mockFunction(), 100); + }, []); - expect(screen.queryByText('Fresh')).toBeNull(); + return ; + } - const freshBananaText = await waitFor(() => screen.getByText('Fresh')); + await render(); + await waitFor(() => expect(mockFunction).toHaveBeenCalled()); + expect(mockFunction).toHaveBeenCalledTimes(1); + }); - expect(freshBananaText.props.children).toBe('Fresh'); -}); + test.each(['real', 'fake', 'fake-legacy'] as const)( + 'waits for query with %s timers', + async (timerType) => { + setupTimeType(timerType as TimerType); + function AsyncComponent() { + const [text, setText] = React.useState('Loading...'); -test('waits for element until timeout is met', async () => { - await render(); + React.useEffect(() => { + setTimeout(() => setText('Loaded'), 100); + }, []); - await fireEvent.press(screen.getByText('Change freshness!')); + return {text}; + } - await expect(waitFor(() => screen.getByText('Fresh'), { timeout: 100 })).rejects.toThrow(); + setupTimeType(timerType); + await render(); + await waitFor(() => screen.getByText('Loaded')); + expect(screen.getByText('Loaded')).toBeOnTheScreen(); + }, + ); - // Async action ends after 300ms and we only waited 100ms, so we need to wait - // for the remaining async actions to finish - await waitFor(() => screen.getByText('Fresh')); -}); + test('waits for async event with fireEvent', async () => { + const Component = ({ onDelayedPress }: { onDelayedPress: () => void }) => { + return ( + + setTimeout(() => { + onDelayedPress(); + }, 100) + } + > + Press + + ); + }; -test('waitFor defaults to asyncWaitTimeout config option', async () => { - configure({ asyncUtilTimeout: 100 }); - await render(); + const onDelayedPress = jest.fn(); + await render(); - await fireEvent.press(screen.getByText('Change freshness!')); - await expect(waitFor(() => screen.getByText('Fresh'))).rejects.toThrow(); + await fireEvent.press(screen.getByText('Press')); + await waitFor(() => { + expect(onDelayedPress).toHaveBeenCalled(); + }); + }); - // Async action ends after 300ms and we only waited 100ms, so we need to wait - // for the remaining async actions to finish - await waitFor(() => screen.getByText('Fresh'), { timeout: 1000 }); -}); + test.each(['real', 'fake', 'fake-legacy'] as const)( + 'flushes scheduled updates before returning with %s timers', + async (timerType) => { + setupTimeType(timerType); + + function Component({ onPress }: { onPress: (color: string) => void }) { + const [color, setColor] = React.useState('green'); + const [syncedColor, setSyncedColor] = React.useState(color); + + // Cascading state updates + React.useEffect(() => { + setSyncedColor(color); + }, [color]); + + React.useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises, promise/catch-or-return, promise/prefer-await-to-then + Promise.resolve('red').then((c) => setColor(c)); + }, []); + + return ( + + {color} + onPress(syncedColor)}> + Trigger + + + ); + } -test('waitFor timeout option takes precendence over `asyncWaitTimeout` config option', async () => { - configure({ asyncUtilTimeout: 2000 }); - await render(); + const onPress = jest.fn(); + await render(); + await waitFor(() => screen.getByText('red')); - await fireEvent.press(screen.getByText('Change freshness!')); - await expect(waitFor(() => screen.getByText('Fresh'), { timeout: 100 })).rejects.toThrow(); + // Check that the `onPress` callback is called with the already-updated value of `syncedColor`. + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).toHaveBeenCalledWith('red'); + }, + ); - // Async action ends after 300ms and we only waited 100ms, so we need to wait - // for the remaining async actions to finish - await waitFor(() => screen.getByText('Fresh')); -}); + test('continues waiting when expectation returns a promise that rejects', async () => { + let attemptCount = 0; + const maxAttempts = 3; + + await waitFor( + () => { + attemptCount++; + if (attemptCount < maxAttempts) { + return Promise.reject(new Error('Not ready yet')); + } + return Promise.resolve('Success'); + }, + { timeout: 1000 }, + ); -test('waits for element with custom interval', async () => { - const mockFn = jest.fn(() => { - throw Error('test'); + expect(attemptCount).toBe(maxAttempts); }); - - try { - await waitFor(() => mockFn(), { timeout: 400, interval: 200 }); - } catch { - // suppress - } - - expect(mockFn).toHaveBeenCalledTimes(2); }); -// this component is convoluted on purpose. It is not a good react pattern, but it is valid -// react code that will run differently between different react versions (17 and 18), so we need -// explicit tests for it -const Comp = ({ onPress }: { onPress: () => void }) => { - const [state, setState] = React.useState(false); +describe('timeout errors', () => { + test.each(['real', 'fake', 'fake-legacy'] as const)( + 'throws timeout error when condition never succeeds with %s timers', + async (timerType) => { + setupTimeType(timerType); + function Component() { + return Hello; + } - React.useEffect(() => { - if (state) { - onPress(); - } - }, [state, onPress]); - - return ( - { - await Promise.resolve(); - setState(true); - }} - > - Trigger - + await render(); + await expect( + waitFor(() => screen.getByText('Never appears'), { timeout: 100 }), + ).rejects.toThrow('Unable to find an element with text: Never appears'); + }, ); -}; - -test('waits for async event with fireEvent', async () => { - const spy = jest.fn(); - await render(); - await fireEvent.press(screen.getByText('Trigger')); - - await waitFor(() => { - expect(spy).toHaveBeenCalled(); + test('throws generic timeout error when promise rejects with falsy value until timeout', async () => { + await expect( + waitFor(() => Promise.reject(null), { + timeout: 100, + }), + ).rejects.toThrow('Timed out in waitFor.'); }); -}); - -test.each([false, true])( - 'waits for element until it stops throwing using fake timers (legacyFakeTimers = %s)', - async (legacyFakeTimers) => { - jest.useFakeTimers({ legacyFakeTimers }); - await render(); - await fireEvent.press(screen.getByText('Change freshness!')); - expect(screen.queryByText('Fresh')).toBeNull(); + test('uses custom error from onTimeout callback when timeout occurs', async () => { + const customErrorMessage = 'Custom timeout error: Element never appeared'; - jest.advanceTimersByTime(300); - const freshBananaText = await waitFor(() => screen.getByText('Fresh')); + await render(); + await expect( + waitFor(() => screen.getByText('Never appears'), { + timeout: 100, + onTimeout: () => new Error(customErrorMessage), + }), + ).rejects.toThrow(customErrorMessage); + }); - expect(freshBananaText.props.children).toBe('Fresh'); - }, -); + test('onTimeout callback returning falsy value keeps original error', async () => { + await render(); + // When onTimeout returns null/undefined/false, the original error should be kept (line 181 false branch) + await expect( + waitFor(() => screen.getByText('Never appears'), { + timeout: 100, + onTimeout: () => null as any, + }), + ).rejects.toThrow('Unable to find an element with text: Never appears'); + }); +}); -test.each([false, true])( - 'waits for assertion until timeout is met with fake timers (legacyFakeTimers = %s)', - async (legacyFakeTimers) => { - jest.useFakeTimers({ legacyFakeTimers }); +describe('error handling', () => { + test('throws TypeError when expectation is not a function', async () => { + await expect(waitFor(null as any)).rejects.toThrow( + 'Received `expectation` arg must be a function', + ); + }); - const mockFn = jest.fn(() => { - throw Error('test'); - }); + test('converts non-Error thrown value to Error when timeout occurs', async () => { + const errorMessage = 'Custom string error'; + let caughtError: unknown; try { - await waitFor(() => mockFn(), { timeout: 400, interval: 200 }); - } catch { - // suppress + await waitFor( + () => { + throw errorMessage; + }, + { timeout: 50 }, + ); + } catch (error) { + caughtError = error; } - expect(mockFn).toHaveBeenCalledTimes(3); - }, -); + expect(caughtError).toBeInstanceOf(Error); + expect((caughtError as Error).message).toBe(errorMessage); + }); -test.each([false, true])( - 'waits for assertion until timeout is met with fake timers (legacyFakeTimers = %s)', - async (legacyFakeTimers) => { - jest.useFakeTimers({ legacyFakeTimers }); + test('throws error when switching from real timers to fake timers during waitFor', async () => { + await render(); - const mockErrorFn = jest.fn(() => { - throw Error('test'); + const waitForPromise = waitFor(() => { + // This will never pass, but we'll switch timers before timeout + return screen.getByText('Never appears'); }); - const mockHandleFn = jest.fn((e) => e); + // Switch to fake timers while waitFor is running + jest.useFakeTimers(); - try { - await waitFor(() => mockErrorFn(), { - timeout: 400, - interval: 200, - onTimeout: mockHandleFn, - }); - } catch { - // suppress - } + await expect(waitForPromise).rejects.toThrow( + 'Changed from using real timers to fake timers while using waitFor', + ); + }); - expect(mockErrorFn).toHaveBeenCalledTimes(3); - expect(mockHandleFn).toHaveBeenCalledTimes(1); - }, -); + test('throws error when switching from fake timers to real timers during waitFor', async () => { + jest.useFakeTimers(); + await render(); -const blockThread = (timeToBlockThread: number, legacyFakeTimers: boolean) => { - jest.useRealTimers(); - const end = Date.now() + timeToBlockThread; - - while (Date.now() < end) { - // do nothing - } - - jest.useFakeTimers({ legacyFakeTimers }); -}; - -test.each([true, false])( - 'it should not depend on real time when using fake timers (legacyFakeTimers = %s)', - async (legacyFakeTimers) => { - jest.useFakeTimers({ legacyFakeTimers }); - const WAIT_FOR_INTERVAL = 20; - const WAIT_FOR_TIMEOUT = WAIT_FOR_INTERVAL * 5; - - const mockErrorFn = jest.fn(() => { - // Wait 2 times interval so that check time is longer than interval - blockThread(WAIT_FOR_INTERVAL * 2, legacyFakeTimers); - throw new Error('test'); + const waitForPromise = waitFor(() => { + // This will never pass, but we'll switch timers before timeout + return screen.getByText('Never appears'); }); - await expect( - async () => - await waitFor(mockErrorFn, { - timeout: WAIT_FOR_TIMEOUT, - interval: WAIT_FOR_INTERVAL, - }), - ).rejects.toThrow(); - - // Verify that the `waitFor` callback has been called the expected number of times - // (timeout / interval + 1), so it confirms that the real duration of callback did not - // cause the real clock timeout when running using fake timers. - expect(mockErrorFn).toHaveBeenCalledTimes(WAIT_FOR_TIMEOUT / WAIT_FOR_INTERVAL + 1); - }, -); - -test.each([false, true])( - 'awaiting something that succeeds before timeout works with fake timers (legacyFakeTimers = %s)', - async (legacyFakeTimers) => { - jest.useFakeTimers({ legacyFakeTimers }); - - let calls = 0; + // Switch to real timers while waitFor is running + jest.useRealTimers(); + + await expect(waitForPromise).rejects.toThrow( + 'Changed from using fake timers to real timers while using waitFor', + ); + }); +}); + +describe('configuration', () => { + test('waits for element with custom interval', async () => { const mockFn = jest.fn(() => { - calls += 1; - if (calls < 3) { - throw Error('test'); - } + throw Error('test'); }); try { @@ -253,97 +249,150 @@ test.each([false, true])( // suppress } - expect(mockFn).toHaveBeenCalledTimes(3); - }, -); - -test.each([ - [false, false], - [true, false], - [true, true], -])( - 'flushes scheduled updates before returning (fakeTimers = %s, legacyFakeTimers = %s)', - async (fakeTimers, legacyFakeTimers) => { - if (fakeTimers) { - jest.useFakeTimers({ legacyFakeTimers }); - } + expect(mockFn).toHaveBeenCalledTimes(2); + }); - function Apple({ onPress }: { onPress: (color: string) => void }) { - const [color, setColor] = React.useState('green'); - const [syncedColor, setSyncedColor] = React.useState(color); + test('waitFor defaults to asyncUtilTimeout config option', async () => { + function Component() { + const [active, setActive] = React.useState(false); - // On mount, set the color to "red" in a promise microtask - React.useEffect(() => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises, promise/catch-or-return, promise/prefer-await-to-then - Promise.resolve('red').then((c) => setColor(c)); - }, []); - - // Sync the `color` state to `syncedColor` state, but with a delay caused by the effect - React.useEffect(() => { - setSyncedColor(color); - }, [color]); + const handlePress = () => { + setTimeout(() => setActive(true), 300); + }; return ( - - {color} - onPress(syncedColor)}> - Trigger + + {active && Active} + + Activate ); } - const onPress = jest.fn(); - await render(); + configure({ asyncUtilTimeout: 100 }); + await render(); + await fireEvent.press(screen.getByText('Activate')); + expect(screen.queryByText('Active')).toBeNull(); + await expect(waitFor(() => screen.getByText('Active'))).rejects.toThrow(); - // Required: this `waitFor` will succeed on first check, because the "root" view is there - // since the initial mount. - await waitFor(() => screen.getByTestId('root')); + // Async action ends after 300ms and we only waited 100ms, so we need to wait + // for the remaining async actions to finish + await waitFor(() => screen.getByText('Active'), { timeout: 1000 }); + }); - // This `waitFor` will also succeed on first check, because the promise that sets the - // `color` state to "red" resolves right after the previous `await waitFor` statement. - await waitFor(() => screen.getByText('red')); + test('waitFor timeout option takes precedence over asyncUtilTimeout config option', async () => { + function AsyncTextToggle() { + const [active, setActive] = React.useState(false); - // Check that the `onPress` callback is called with the already-updated value of `syncedColor`. - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).toHaveBeenCalledWith('red'); - }, -); + const handlePress = () => { + setTimeout(() => setActive(true), 300); + }; -test('waitFor throws if expectation is not a function', async () => { - await expect( - // @ts-expect-error intentionally passing non-function - waitFor('not a function'), - ).rejects.toThrowErrorMatchingInlineSnapshot(`"Received \`expectation\` arg must be a function"`); + return ( + + {active && Active} + + Activate + + + ); + } + + configure({ asyncUtilTimeout: 2000 }); + await render(); + await fireEvent.press(screen.getByText('Activate')); + expect(screen.queryByText('Active')).toBeNull(); + await expect(waitFor(() => screen.getByText('Active'), { timeout: 100 })).rejects.toThrow(); + + // Async action ends after 300ms and we only waited 100ms, so we need to wait + // for the remaining async actions to finish + await waitFor(() => screen.getByText('Active')); + }); }); -test.each([false, true])( - 'waitFor throws clear error when switching from fake timers to real timers (legacyFakeTimers = %s)', - async (legacyFakeTimers) => { - jest.useFakeTimers({ legacyFakeTimers }); +describe('fake timers behavior', () => { + test.each(['fake', 'fake-legacy'] as const)( + 'should not depend on real time when using %s timers', + async (timerType) => { + setupTimeType(timerType); + const WAIT_FOR_INTERVAL = 20; + const WAIT_FOR_TIMEOUT = WAIT_FOR_INTERVAL * 5; + + const blockThread = (timeToBlockThread: number, timerType: TimerType): void => { + jest.useRealTimers(); + const end = Date.now() + timeToBlockThread; + + while (Date.now() < end) { + // do nothing + } + + setupTimeType(timerType); + }; + + const mockErrorFn = jest.fn(() => { + // Wait 2 times interval so that check time is longer than interval + blockThread(WAIT_FOR_INTERVAL * 2, timerType); + throw new Error('test'); + }); - const waitForPromise = waitFor(() => { - // Switch to real timers during waitFor - this should trigger an error - jest.useRealTimers(); - throw new Error('test'); - }); + await expect( + async () => + await waitFor(mockErrorFn, { + timeout: WAIT_FOR_TIMEOUT, + interval: WAIT_FOR_INTERVAL, + }), + ).rejects.toThrow(); + + // Verify that the `waitFor` callback has been called the expected number of times + // (timeout / interval + 1), so it confirms that the real duration of callback did not + // cause the real clock timeout when running using fake timers. + expect(mockErrorFn).toHaveBeenCalledTimes(WAIT_FOR_TIMEOUT / WAIT_FOR_INTERVAL + 1); + }, + ); - await expect(waitForPromise).rejects.toThrow( - 'Changed from using fake timers to real timers while using waitFor', - ); - }, -); + test.each(['fake', 'fake-legacy'] as const)( + 'waits for assertion until timeout is met with %s timers and interval', + async (timerType) => { + setupTimeType(timerType); -test('waitFor throws clear error when switching from real timers to fake timers', async () => { - jest.useRealTimers(); + const expection = jest.fn(() => { + throw Error('test'); + }); - const waitForPromise = waitFor(() => { - // Switch to fake timers during waitFor - this should trigger an error - jest.useFakeTimers(); - throw new Error('test'); - }); + try { + await waitFor(() => expection(), { timeout: 400, interval: 200 }); + } catch { + // suppress + } + + expect(expection).toHaveBeenCalledTimes(3); + }, + ); + + test.each(['fake', 'fake-legacy'] as const)( + 'waits for assertion until timeout is met with %s timers, interval, and onTimeout', + async (timerType) => { + setupTimeType(timerType); + + const expection = jest.fn(() => { + throw Error('test'); + }); + + const onTimeout = jest.fn((e) => e); + + try { + await waitFor(() => expection(), { + timeout: 400, + interval: 200, + onTimeout: onTimeout, + }); + } catch { + // suppress + } - await expect(waitForPromise).rejects.toThrow( - 'Changed from using real timers to fake timers while using waitFor', + expect(expection).toHaveBeenCalledTimes(3); + expect(onTimeout).toHaveBeenCalledTimes(1); + }, ); }); diff --git a/src/helpers/__tests__/errors.test.ts b/src/helpers/__tests__/errors.test.ts index 09fc7abe7..e39a0a835 100644 --- a/src/helpers/__tests__/errors.test.ts +++ b/src/helpers/__tests__/errors.test.ts @@ -1,4 +1,4 @@ -import { copyStackTrace, ErrorWithStack } from '../errors'; +import { copyStackTraceIfNeeded, ErrorWithStack } from '../errors'; describe('ErrorWithStack', () => { test('should create an error with message', () => { @@ -29,13 +29,13 @@ describe('ErrorWithStack', () => { }); }); -describe('copyStackTrace', () => { +describe('copyStackTraceIfNeeded', () => { test('should copy stack trace from source to target when both are Error instances', () => { const target = new Error('Target error'); const source = new Error('Source error'); source.stack = 'Error: Source error\n at test.js:1:1'; - copyStackTrace(target, source); + copyStackTraceIfNeeded(target, source); expect(target.stack).toBe('Error: Target error\n at test.js:1:1'); const target2 = new Error('Target error'); @@ -43,7 +43,7 @@ describe('copyStackTrace', () => { source2.stack = 'Error: Source error\n at test.js:1:1\nError: Source error\n at test.js:2:2'; - copyStackTrace(target2, source2); + copyStackTraceIfNeeded(target2, source2); // Should replace only the first occurrence expect(target2.stack).toBe( 'Error: Target error\n at test.js:1:1\nError: Source error\n at test.js:2:2', @@ -55,20 +55,26 @@ describe('copyStackTrace', () => { const source = new Error('Source error'); source.stack = 'Error: Source error\n at test.js:1:1'; - copyStackTrace(targetNotError, source); + copyStackTraceIfNeeded(targetNotError, source); expect(targetNotError).toEqual({ message: 'Not an error' }); const target = new Error('Target error'); const originalStack = target.stack; - const sourceNotError = { message: 'Not an error' }; - copyStackTrace(target, sourceNotError as Error); + copyStackTraceIfNeeded(target, undefined); + expect(target.stack).toBe(originalStack); + + copyStackTraceIfNeeded(target, null as unknown as Error); + expect(target.stack).toBe(originalStack); + + const sourceNotError = { message: 'Not an error' }; + copyStackTraceIfNeeded(target, sourceNotError as Error); expect(target.stack).toBe(originalStack); const sourceNoStack = new Error('Source error'); delete sourceNoStack.stack; - copyStackTrace(target, sourceNoStack); + copyStackTraceIfNeeded(target, sourceNoStack); expect(target.stack).toBe(originalStack); }); }); diff --git a/src/helpers/errors.ts b/src/helpers/errors.ts index a6965619c..20c56a747 100644 --- a/src/helpers/errors.ts +++ b/src/helpers/errors.ts @@ -8,8 +8,8 @@ export class ErrorWithStack extends Error { } } -export function copyStackTrace(target: unknown, stackTraceSource: Error) { - if (target instanceof Error && stackTraceSource.stack) { +export function copyStackTraceIfNeeded(target: unknown, stackTraceSource: Error | undefined) { + if (stackTraceSource != null && target instanceof Error && stackTraceSource.stack) { target.stack = stackTraceSource.stack.replace(stackTraceSource.message, target.message); } } diff --git a/src/test-utils/timers.ts b/src/test-utils/timers.ts new file mode 100644 index 000000000..78bd46538 --- /dev/null +++ b/src/test-utils/timers.ts @@ -0,0 +1,11 @@ +export type TimerType = 'real' | 'fake' | 'fake-legacy'; + +export function setupTimeType(type: TimerType): void { + if (type === 'fake-legacy') { + jest.useFakeTimers({ legacyFakeTimers: true }); + } else if (type === 'fake') { + jest.useFakeTimers({ legacyFakeTimers: false }); + } else { + jest.useRealTimers(); + } +} diff --git a/src/wait-for.ts b/src/wait-for.ts index 409623a8c..29ad802b4 100644 --- a/src/wait-for.ts +++ b/src/wait-for.ts @@ -2,7 +2,7 @@ import { act } from './act'; import { getConfig } from './config'; import { flushMicroTasks } from './flush-micro-tasks'; -import { copyStackTrace, ErrorWithStack } from './helpers/errors'; +import { copyStackTraceIfNeeded, ErrorWithStack } from './helpers/errors'; import { clearTimeout, getJestFakeTimersType, @@ -55,9 +55,7 @@ function waitForInternal( const error = new Error( `Changed from using fake timers to real timers while using waitFor. This is not allowed and will result in very strange behavior. Please ensure you're awaiting all async things your test is doing before changing to real timers. For more info, please go to https://github.com/testing-library/dom-testing-library/issues/830`, ); - if (stackTraceError) { - copyStackTrace(error, stackTraceError); - } + copyStackTraceIfNeeded(error, stackTraceError); reject(error); return; } @@ -121,9 +119,7 @@ function waitForInternal( const error = new Error( `Changed from using real timers to fake timers while using waitFor. This is not allowed and will result in very strange behavior. Please ensure you're awaiting all async things your test is doing before changing to fake timers. For more info, please go to https://github.com/testing-library/dom-testing-library/issues/830`, ); - if (stackTraceError) { - copyStackTrace(error, stackTraceError); - } + copyStackTraceIfNeeded(error, stackTraceError); return reject(error); } else { return checkExpectation(); @@ -131,7 +127,11 @@ function waitForInternal( } function checkExpectation() { - if (promiseStatus === 'pending') return; + /* istanbul ignore next */ + if (promiseStatus === 'pending') { + return; + } + try { const result = expectation(); @@ -171,14 +171,10 @@ function waitForInternal( error = new Error(String(lastError)); } - if (stackTraceError) { - copyStackTrace(error, stackTraceError); - } + copyStackTraceIfNeeded(error, stackTraceError); } else { error = new Error('Timed out in waitFor.'); - if (stackTraceError) { - copyStackTrace(error, stackTraceError); - } + copyStackTraceIfNeeded(error, stackTraceError); } if (typeof onTimeout === 'function') { const result = onTimeout(error);