diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index bf3237bb..d5850328 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -1,5 +1,5 @@ { "installCommand": "install:csb", "sandboxes": ["new", "github/kentcdodds/react-testing-library-examples"], - "node": "12" + "node": "14" } diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 0f99d084..5db8153c 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - node: [12, 14, 16, 18] + node: [14, 16, 18] react: [latest, next, experimental] runs-on: ubuntu-latest steps: @@ -65,6 +65,7 @@ jobs: 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 (cycjimmy/semantic-release-action) needs: main runs-on: ubuntu-latest diff --git a/package.json b/package.json index d2dd6a97..70aebdad 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "types": "types/index.d.ts", "module": "dist/@testing-library/react.esm.js", "engines": { - "node": ">=12" + "node": ">=14" }, "scripts": { "prebuild": "rimraf dist", @@ -46,15 +46,15 @@ "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^8.5.0", + "@testing-library/dom": "^9.0.0", "@types/react-dom": "^18.0.0" }, "devDependencies": { "@testing-library/jest-dom": "^5.11.6", "chalk": "^4.1.2", "dotenv-cli": "^4.0.0", - "jest-diff": "^27.5.1", - "kcd-scripts": "^11.1.0", + "jest-diff": "^29.4.1", + "kcd-scripts": "^13.0.0", "npm-run-all": "^4.1.5", "react": "^18.0.0", "react-dom": "^18.0.0", @@ -67,6 +67,9 @@ }, "eslintConfig": { "extends": "./node_modules/kcd-scripts/eslint.js", + "parserOptions": { + "ecmaVersion": 2022 + }, "globals": { "globalThis": "readonly" }, @@ -76,8 +79,11 @@ "import/no-unassigned-import": "off", "import/named": "off", "testing-library/no-container": "off", + "testing-library/no-debugging-utils": "off", "testing-library/no-dom-import": "off", "testing-library/no-unnecessary-act": "off", + "testing-library/prefer-explicit-assert": "off", + "testing-library/prefer-find-by": "off", "testing-library/prefer-user-event": "off" } }, diff --git a/src/__tests__/cleanup.js b/src/__tests__/cleanup.js index 4517c098..9f17c722 100644 --- a/src/__tests__/cleanup.js +++ b/src/__tests__/cleanup.js @@ -64,7 +64,7 @@ describe('fake timers and missing act warnings', () => { let cancelled = false Promise.resolve().then(() => { microTaskSpy() - // eslint-disable-next-line jest/no-if -- false positive + // eslint-disable-next-line jest/no-if, jest/no-conditional-in-test -- false positive if (!cancelled) { setDeferredCounter(counter) } @@ -96,6 +96,7 @@ describe('fake timers and missing act warnings', () => { let cancelled = false setTimeout(() => { deferredStateUpdateSpy() + // eslint-disable-next-line jest/no-conditional-in-test -- false-positive if (!cancelled) { setDeferredCounter(counter) } diff --git a/src/__tests__/debug.js b/src/__tests__/debug.js index f3aad595..c6a1d1fe 100644 --- a/src/__tests__/debug.js +++ b/src/__tests__/debug.js @@ -42,7 +42,7 @@ test('allows same arguments as prettyDOM', () => { debug(container, 6, {highlight: false}) expect(console.log).toHaveBeenCalledTimes(1) expect(console.log.mock.calls[0]).toMatchInlineSnapshot(` - Array [ + [
..., ] @@ -52,5 +52,4 @@ test('allows same arguments as prettyDOM', () => { /* eslint no-console: "off", - testing-library/no-debug: "off", */ diff --git a/src/__tests__/end-to-end.js b/src/__tests__/end-to-end.js index cf222aec..005591d3 100644 --- a/src/__tests__/end-to-end.js +++ b/src/__tests__/end-to-end.js @@ -1,73 +1,164 @@ import * as React from 'react' import {render, waitForElementToBeRemoved, screen, waitFor} from '../' -const fetchAMessage = () => - new Promise(resolve => { - // 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() * 100) - setTimeout(() => { - resolve({returnedMessage: 'Hello World'}) - }, randomTimeout) - }) - -function ComponentWithLoader() { - const [state, setState] = React.useState({data: undefined, loading: true}) - React.useEffect(() => { - let cancelled = false - fetchAMessage().then(data => { - if (!cancelled) { - setState({data, loading: false}) - } +describe.each([ + ['real timers', () => jest.useRealTimers()], + ['fake legacy timers', () => jest.useFakeTimers('legacy')], + ['fake modern timers', () => jest.useFakeTimers('modern')], +])( + 'it waits for the data to be loaded in a macrotask using %s', + (label, useTimers) => { + beforeEach(() => { + useTimers() + }) + + afterEach(() => { + jest.useRealTimers() }) - return () => { - cancelled = true + const fetchAMessageInAMacrotask = () => + new Promise(resolve => { + // 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() * 100) + setTimeout(() => { + resolve({returnedMessage: 'Hello World'}) + }, randomTimeout) + }) + + function ComponentWithMacrotaskLoader() { + const [state, setState] = React.useState({data: undefined, loading: true}) + React.useEffect(() => { + let cancelled = false + fetchAMessageInAMacrotask().then(data => { + if (!cancelled) { + setState({data, loading: false}) + } + }) + + return () => { + cancelled = true + } + }, []) + + if (state.loading) { + return
Loading...
+ } + + return ( +
+ Loaded this message: {state.data.returnedMessage}! +
+ ) } - }, []) - if (state.loading) { - return
Loading...
- } + test('waitForElementToBeRemoved', async () => { + render() + const loading = () => screen.getByText('Loading...') + await waitForElementToBeRemoved(loading) + expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/) + }) + + test('waitFor', async () => { + render() + await waitFor(() => screen.getByText(/Loading../)) + await waitFor(() => screen.getByText(/Loaded this message:/)) + expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/) + }) - return ( -
- Loaded this message: {state.data.returnedMessage}! -
- ) -} + test('findBy', async () => { + render() + await expect(screen.findByTestId('message')).resolves.toHaveTextContent( + /Hello World/, + ) + }) + }, +) describe.each([ ['real timers', () => jest.useRealTimers()], ['fake legacy timers', () => jest.useFakeTimers('legacy')], ['fake modern timers', () => jest.useFakeTimers('modern')], -])('it waits for the data to be loaded using %s', (label, useTimers) => { - beforeEach(() => { - useTimers() - }) - - afterEach(() => { - jest.useRealTimers() - }) - - test('waitForElementToBeRemoved', async () => { - render() - const loading = () => screen.getByText('Loading...') - await waitForElementToBeRemoved(loading) - expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/) - }) - - test('waitFor', async () => { - render() - const message = () => screen.getByText(/Loaded this message:/) - await waitFor(message) - expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/) - }) - - test('findBy', async () => { - render() - await expect(screen.findByTestId('message')).resolves.toHaveTextContent( - /Hello World/, - ) - }) -}) +])( + 'it waits for the data to be loaded in a microtask using %s', + (label, useTimers) => { + beforeEach(() => { + useTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + const fetchAMessageInAMicrotask = () => + Promise.resolve({ + status: 200, + json: () => Promise.resolve({title: 'Hello World'}), + }) + + function ComponentWithMicrotaskLoader() { + const [fetchState, setFetchState] = React.useState({fetching: true}) + + React.useEffect(() => { + if (fetchState.fetching) { + fetchAMessageInAMicrotask().then(res => { + return ( + res + .json() + // By spec, the runtime can only yield back to the event loop once + // the microtask queue is empty. + // So we ensure that we actually wait for that as well before yielding back from `waitFor`. + .then(data => data) + .then(data => data) + .then(data => data) + .then(data => data) + .then(data => data) + .then(data => data) + .then(data => data) + .then(data => data) + .then(data => data) + .then(data => data) + .then(data => data) + .then(data => { + setFetchState({todo: data.title, fetching: false}) + }) + ) + }) + } + }, [fetchState]) + + if (fetchState.fetching) { + return

Loading..

+ } + + return ( +
Loaded this message: {fetchState.todo}
+ ) + } + + test('waitForElementToBeRemoved', async () => { + render() + const loading = () => screen.getByText('Loading..') + await waitForElementToBeRemoved(loading) + expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/) + }) + + test('waitFor', async () => { + render() + await waitFor(() => { + screen.getByText('Loading..') + }) + await waitFor(() => { + screen.getByText(/Loaded this message:/) + }) + expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/) + }) + + test('findBy', async () => { + render() + await expect(screen.findByTestId('message')).resolves.toHaveTextContent( + /Hello World/, + ) + }) + }, +) diff --git a/src/__tests__/new-act.js b/src/__tests__/new-act.js index 4909d4a6..0412a8a3 100644 --- a/src/__tests__/new-act.js +++ b/src/__tests__/new-act.js @@ -47,8 +47,8 @@ test('async act recovers from errors', async () => { } expect(console.error).toHaveBeenCalledTimes(1) expect(console.error.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ + [ + [ call console.error, ], ] @@ -65,8 +65,8 @@ test('async act recovers from sync errors', async () => { } expect(console.error).toHaveBeenCalledTimes(1) expect(console.error.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ + [ + [ call console.error, ], ] diff --git a/src/__tests__/renderHook.js b/src/__tests__/renderHook.js index f6b7a343..11b7009a 100644 --- a/src/__tests__/renderHook.js +++ b/src/__tests__/renderHook.js @@ -21,7 +21,7 @@ test('allows rerendering', () => { const [left, setLeft] = React.useState('left') const [right, setRight] = React.useState('right') - // eslint-disable-next-line jest/no-if + // eslint-disable-next-line jest/no-if, jest/no-conditional-in-test -- false-positive switch (branch) { case 'left': return [left, setLeft] diff --git a/src/pure.js b/src/pure.js index 94b3b2bd..845aede1 100644 --- a/src/pure.js +++ b/src/pure.js @@ -12,6 +12,20 @@ import act, { } from './act-compat' import {fireEvent} from './fire-event' +function jestFakeTimersAreEnabled() { + /* istanbul ignore else */ + if (typeof jest !== 'undefined' && jest !== null) { + return ( + // legacy timers + setTimeout._isMockFunction === true || // modern timers + // eslint-disable-next-line prefer-object-has-own -- No Object.hasOwn in all target environments we support. + Object.prototype.hasOwnProperty.call(setTimeout, 'clock') + ) + } // istanbul ignore next + + return false +} + configureDTL({ unstable_advanceTimersWrapper: cb => { return act(cb) @@ -23,7 +37,21 @@ configureDTL({ const previousActEnvironment = getIsReactActEnvironment() setReactActEnvironment(false) try { - return await cb() + const result = await cb() + // Drain microtask queue. + // Otherwise we'll restore the previous act() environment, before we resolve the `waitFor` call. + // The caller would have no chance to wrap the in-flight Promises in `act()` + await new Promise(resolve => { + setTimeout(() => { + resolve() + }, 0) + + if (jestFakeTimersAreEnabled()) { + jest.advanceTimersByTime(0) + } + }) + + return result } finally { setReactActEnvironment(previousActEnvironment) } diff --git a/tests/setup-env.js b/tests/setup-env.js index a4ddfa17..c9b976f5 100644 --- a/tests/setup-env.js +++ b/tests/setup-env.js @@ -1,2 +1,5 @@ import '@testing-library/jest-dom/extend-expect' import './failOnUnexpectedConsoleCalls' +import {TextEncoder} from 'util' + +global.TextEncoder = TextEncoder diff --git a/tests/toWarnDev.js b/tests/toWarnDev.js index ac5f1b19..ca58346f 100644 --- a/tests/toWarnDev.js +++ b/tests/toWarnDev.js @@ -24,7 +24,7 @@ SOFTWARE. */ /* eslint-disable no-unsafe-finally */ /* eslint-disable no-negated-condition */ -/* eslint-disable @babel/no-invalid-this */ +/* eslint-disable no-invalid-this */ /* eslint-disable prefer-template */ /* eslint-disable func-names */ /* eslint-disable complexity */ diff --git a/types/test.tsx b/types/test.tsx index 17ba7012..c33f07b6 100644 --- a/types/test.tsx +++ b/types/test.tsx @@ -188,7 +188,6 @@ export function testRenderHookProps() { eslint testing-library/prefer-explicit-assert: "off", testing-library/no-wait-for-empty-callback: "off", - testing-library/no-debug: "off", testing-library/prefer-screen-queries: "off" */