diff --git a/.all-contributorsrc b/.all-contributorsrc index 2a0be067..b22c9414 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1,12 +1,13 @@ { "projectName": "react-testing-library", - "projectOwner": "kentcdodds", + "projectOwner": "testing-library", "repoType": "github", "files": [ "README.md" ], "imageSize": 100, "commit": false, + "skipCi": false, "contributors": [ { "login": "kentcdodds", @@ -575,7 +576,8 @@ "profile": "https://github.com/weyert", "contributions": [ "ideas", - "review" + "review", + "design" ] }, { @@ -678,8 +680,711 @@ "bug", "code", "ideas", + "test", + "review" + ] + }, + { + "login": "mohamedmagdy17593", + "name": "mohamedmagdy17593", + "avatar_url": "https://avatars0.githubusercontent.com/u/40938625?v=4", + "profile": "https://github.com/mohamedmagdy17593", + "contributions": [ + "code" + ] + }, + { + "login": "lorensr", + "name": "Loren ☺️", + "avatar_url": "https://avatars2.githubusercontent.com/u/251288?v=4", + "profile": "http://lorensr.me", + "contributions": [ + "doc" + ] + }, + { + "login": "MarkFalconbridge", + "name": "MarkFalconbridge", + "avatar_url": "https://avatars1.githubusercontent.com/u/20678943?v=4", + "profile": "https://github.com/MarkFalconbridge", + "contributions": [ + "bug", + "code" + ] + }, + { + "login": "viniciusavieira", + "name": "Vinicius", + "avatar_url": "https://avatars0.githubusercontent.com/u/2073019?v=4", + "profile": "https://github.com/viniciusavieira", + "contributions": [ + "doc", + "example" + ] + }, + { + "login": "pschyma", + "name": "Peter Schyma", + "avatar_url": "https://avatars2.githubusercontent.com/u/2489928?v=4", + "profile": "https://github.com/pschyma", + "contributions": [ + "code" + ] + }, + { + "login": "ianschmitz", + "name": "Ian Schmitz", + "avatar_url": "https://avatars1.githubusercontent.com/u/6355370?v=4", + "profile": "https://github.com/ianschmitz", + "contributions": [ + "doc" + ] + }, + { + "login": "joual", + "name": "Joel Marcotte", + "avatar_url": "https://avatars0.githubusercontent.com/u/157877?v=4", + "profile": "https://github.com/joual", + "contributions": [ + "bug", + "test", + "code" + ] + }, + { + "login": "aledustet", + "name": "Alejandro Dustet", + "avatar_url": "https://avatars3.githubusercontent.com/u/2413802?v=4", + "profile": "http://aledustet.com", + "contributions": [ + "bug" + ] + }, + { + "login": "bcarroll22", + "name": "Brandon Carroll", + "avatar_url": "https://avatars2.githubusercontent.com/u/11020406?v=4", + "profile": "https://github.com/bcarroll22", + "contributions": [ + "doc" + ] + }, + { + "login": "lucas0707", + "name": "Lucas Machado", + "avatar_url": "https://avatars1.githubusercontent.com/u/26284338?v=4", + "profile": "https://github.com/lucas0707", + "contributions": [ + "doc" + ] + }, + { + "login": "pascalduez", + "name": "Pascal Duez", + "avatar_url": "https://avatars3.githubusercontent.com/u/335467?v=4", + "profile": "http://pascalduez.me", + "contributions": [ + "platform" + ] + }, + { + "login": "NMinhNguyen", + "name": "Minh Nguyen", + "avatar_url": "https://avatars3.githubusercontent.com/u/2852660?v=4", + "profile": "https://twitter.com/minh_ngvyen", + "contributions": [ + "code" + ] + }, + { + "login": "LiaoJimmy", + "name": "LiaoJimmy", + "avatar_url": "https://avatars0.githubusercontent.com/u/11155585?v=4", + "profile": "http://iababy46.blogspot.tw/", + "contributions": [ + "doc" + ] + }, + { + "login": "threepointone", + "name": "Sunil Pai", + "avatar_url": "https://avatars2.githubusercontent.com/u/18808?v=4", + "profile": "https://github.com/threepointone", + "contributions": [ + "code", + "test" + ] + }, + { + "login": "gaearon", + "name": "Dan Abramov", + "avatar_url": "https://avatars0.githubusercontent.com/u/810438?v=4", + "profile": "http://twitter.com/dan_abramov", + "contributions": [ + "review" + ] + }, + { + "login": "ChristianMurphy", + "name": "Christian Murphy", + "avatar_url": "https://avatars3.githubusercontent.com/u/3107513?v=4", + "profile": "https://github.com/ChristianMurphy", + "contributions": [ + "infra" + ] + }, + { + "login": "jeetiss", + "name": "Ivakhnenko Dmitry", + "avatar_url": "https://avatars1.githubusercontent.com/u/6726016?v=4", + "profile": "https://jeetiss.github.io/", + "contributions": [ + "code" + ] + }, + { + "login": "jamesgeorge007", + "name": "James George", + "avatar_url": "https://avatars2.githubusercontent.com/u/25279263?v=4", + "profile": "https://ghuser.io/jamesgeorge007", + "contributions": [ + "doc" + ] + }, + { + "login": "JSFernandes", + "name": "João Fernandes", + "avatar_url": "https://avatars1.githubusercontent.com/u/1075053?v=4", + "profile": "https://joaofernandes.me/", + "contributions": [ + "doc" + ] + }, + { + "login": "alejandroperea", + "name": "Alejandro Perea", + "avatar_url": "https://avatars3.githubusercontent.com/u/6084749?v=4", + "profile": "https://github.com/alejandroperea", + "contributions": [ + "review" + ] + }, + { + "login": "nickmccurdy", + "name": "Nick McCurdy", + "avatar_url": "https://avatars0.githubusercontent.com/u/927220?v=4", + "profile": "https://nickmccurdy.com/", + "contributions": [ + "review", + "question", + "infra" + ] + }, + { + "login": "eps1lon", + "name": "Sebastian Silbermann", + "avatar_url": "https://avatars3.githubusercontent.com/u/12292047?v=4", + "profile": "https://twitter.com/sebsilbermann", + "contributions": [ + "review" + ] + }, + { + "login": "afontcu", + "name": "Adrià Fontcuberta", + "avatar_url": "https://avatars0.githubusercontent.com/u/9197791?v=4", + "profile": "https://afontcu.dev", + "contributions": [ + "review", + "doc" + ] + }, + { + "login": "johnnyreilly", + "name": "John Reilly", + "avatar_url": "https://avatars0.githubusercontent.com/u/1010525?v=4", + "profile": "https://blog.johnnyreilly.com/", + "contributions": [ + "review" + ] + }, + { + "login": "MichaelDeBoey", + "name": "Michaël De Boey", + "avatar_url": "https://avatars3.githubusercontent.com/u/6643991?v=4", + "profile": "https://michaeldeboey.be", + "contributions": [ + "review", + "code" + ] + }, + { + "login": "cimbul", + "name": "Tim Yates", + "avatar_url": "https://avatars2.githubusercontent.com/u/927923?v=4", + "profile": "https://cimbul.com", + "contributions": [ + "review" + ] + }, + { + "login": "eventualbuddha", + "name": "Brian Donovan", + "avatar_url": "https://avatars3.githubusercontent.com/u/1938?v=4", + "profile": "https://github.com/eventualbuddha", + "contributions": [ + "code" + ] + }, + { + "login": "JaysQubeXon", + "name": "Noam Gabriel Jacobson", + "avatar_url": "https://avatars1.githubusercontent.com/u/18309230?v=4", + "profile": "https://github.com/JaysQubeXon", + "contributions": [ + "doc" + ] + }, + { + "login": "rvdkooy", + "name": "Ronald van der Kooij", + "avatar_url": "https://avatars1.githubusercontent.com/u/4119960?v=4", + "profile": "https://github.com/rvdkooy", + "contributions": [ + "test", + "code" + ] + }, + { + "login": "aayushrajvanshi", + "name": "Aayush Rajvanshi", + "avatar_url": "https://avatars0.githubusercontent.com/u/14968551?v=4", + "profile": "https://github.com/aayushrajvanshi", + "contributions": [ + "doc" + ] + }, + { + "login": "ely-alamillo", + "name": "Ely Alamillo", + "avatar_url": "https://avatars2.githubusercontent.com/u/24350492?v=4", + "profile": "https://elyalamillo.com", + "contributions": [ + "code", + "test" + ] + }, + { + "login": "danieljcafonso", + "name": "Daniel Afonso", + "avatar_url": "https://avatars3.githubusercontent.com/u/35337607?v=4", + "profile": "https://github.com/danieljcafonso", + "contributions": [ + "code", + "test" + ] + }, + { + "login": "LaurensBosscher", + "name": "Laurens Bosscher", + "avatar_url": "https://avatars0.githubusercontent.com/u/13363196?v=4", + "profile": "http://www.laurensbosscher.nl", + "contributions": [ + "code" + ] + }, + { + "login": "sakito21", + "name": "Sakito Mukai", + "avatar_url": "https://avatars1.githubusercontent.com/u/15010907?v=4", + "profile": "https://twitter.com/__sakito__", + "contributions": [ + "doc" + ] + }, + { + "login": "tteke", + "name": "Türker Teke", + "avatar_url": "https://avatars3.githubusercontent.com/u/12457162?v=4", + "profile": "http://turkerteke.com", + "contributions": [ + "doc" + ] + }, + { + "login": "zbrogz", + "name": "Zach Brogan", + "avatar_url": "https://avatars1.githubusercontent.com/u/319162?v=4", + "profile": "http://linkedin.com/in/zachbrogan", + "contributions": [ + "code", + "test" + ] + }, + { + "login": "ryota-murakami", + "name": "Ryota Murakami", + "avatar_url": "https://avatars2.githubusercontent.com/u/5501268?v=4", + "profile": "https://ryota-murakami.github.io/", + "contributions": [ + "doc" + ] + }, + { + "login": "hottmanmichael", + "name": "Michael Hottman", + "avatar_url": "https://avatars3.githubusercontent.com/u/10534502?v=4", + "profile": "https://github.com/hottmanmichael", + "contributions": [ + "ideas" + ] + }, + { + "login": "stevenfitzpatrick", + "name": "Steven Fitzpatrick", + "avatar_url": "https://avatars0.githubusercontent.com/u/23268855?v=4", + "profile": "https://github.com/stevenfitzpatrick", + "contributions": [ + "bug" + ] + }, + { + "login": "juangl", + "name": "Juan Je García", + "avatar_url": "https://avatars0.githubusercontent.com/u/1887029?v=4", + "profile": "https://github.com/juangl", + "contributions": [ + "doc" + ] + }, + { + "login": "Ishaan28malik", + "name": "Championrunner", + "avatar_url": "https://avatars3.githubusercontent.com/u/27343592?v=4", + "profile": "https://ghuser.io/Ishaan28malik", + "contributions": [ + "doc" + ] + }, + { + "login": "samtsai", + "name": "Sam Tsai", + "avatar_url": "https://avatars0.githubusercontent.com/u/225526?v=4", + "profile": "https://github.com/samtsai", + "contributions": [ + "code", + "test", + "doc" + ] + }, + { + "login": "screendriver", + "name": "Christian Rackerseder", + "avatar_url": "https://avatars0.githubusercontent.com/u/149248?v=4", + "profile": "https://www.echooff.dev", + "contributions": [ + "code" + ] + }, + { + "login": "NiGhTTraX", + "name": "Andrei Picus", + "avatar_url": "https://avatars0.githubusercontent.com/u/485061?v=4", + "profile": "https://github.com/NiGhTTraX", + "contributions": [ + "bug", + "review" + ] + }, + { + "login": "kettanaito", + "name": "Artem Zakharchenko", + "avatar_url": "https://avatars3.githubusercontent.com/u/14984911?v=4", + "profile": "https://redd.one", + "contributions": [ + "doc" + ] + }, + { + "login": "michael-siek", + "name": "Michael", + "avatar_url": "https://avatars0.githubusercontent.com/u/45568605?v=4", + "profile": "http://michaelsiek.com", + "contributions": [ + "doc" + ] + }, + { + "login": "2dubbing", + "name": "Braden Lee", + "avatar_url": "https://avatars2.githubusercontent.com/u/15885679?v=4", + "profile": "http://2dubbing.tistory.com", + "contributions": [ + "doc" + ] + }, + { + "login": "kamranayub", + "name": "Kamran Ayub", + "avatar_url": "https://avatars1.githubusercontent.com/u/563819?v=4", + "profile": "http://kamranicus.com/", + "contributions": [ + "code", "test" ] + }, + { + "login": "MatanBobi", + "name": "Matan Borenkraout", + "avatar_url": "https://avatars2.githubusercontent.com/u/12711091?v=4", + "profile": "https://twitter.com/matanbobi", + "contributions": [ + "code" + ] + }, + { + "login": "radar", + "name": "Ryan Bigg", + "avatar_url": "https://avatars3.githubusercontent.com/u/2687?v=4", + "profile": "http://ryanbigg.com", + "contributions": [ + "maintenance" + ] + }, + { + "login": "antonhalim", + "name": "Anton Halim", + "avatar_url": "https://avatars1.githubusercontent.com/u/10498035?v=4", + "profile": "https://antonhalim.com", + "contributions": [ + "doc" + ] + }, + { + "login": "artem-malko", + "name": "Artem Malko", + "avatar_url": "https://avatars0.githubusercontent.com/u/1823689?v=4", + "profile": "http://artmalko.ru", + "contributions": [ + "code" + ] + }, + { + "login": "ljosberinn", + "name": "Gerrit Alex", + "avatar_url": "https://avatars1.githubusercontent.com/u/29307652?v=4", + "profile": "http://gerritalex.de", + "contributions": [ + "code" + ] + }, + { + "login": "karthick3018", + "name": "Karthick Raja", + "avatar_url": "https://avatars1.githubusercontent.com/u/47154512?v=4", + "profile": "https://github.com/karthick3018", + "contributions": [ + "code" + ] + }, + { + "login": "theashraf", + "name": "Abdelrahman Ashraf", + "avatar_url": "https://avatars1.githubusercontent.com/u/39750790?v=4", + "profile": "https://github.com/theashraf", + "contributions": [ + "code" + ] + }, + { + "login": "lidoravitan", + "name": "Lidor Avitan", + "avatar_url": "https://avatars0.githubusercontent.com/u/35113398?v=4", + "profile": "https://github.com/lidoravitan", + "contributions": [ + "doc" + ] + }, + { + "login": "ljharb", + "name": "Jordan Harband", + "avatar_url": "https://avatars1.githubusercontent.com/u/45469?v=4", + "profile": "https://github.com/ljharb", + "contributions": [ + "review", + "ideas" + ] + }, + { + "login": "marcosvega91", + "name": "Marco Moretti", + "avatar_url": "https://avatars2.githubusercontent.com/u/5365582?v=4", + "profile": "https://github.com/marcosvega91", + "contributions": [ + "code" + ] + }, + { + "login": "sanchit121", + "name": "sanchit121", + "avatar_url": "https://avatars2.githubusercontent.com/u/30828115?v=4", + "profile": "https://github.com/sanchit121", + "contributions": [ + "bug", + "code" + ] + }, + { + "login": "solufa", + "name": "Solufa", + "avatar_url": "https://avatars.githubusercontent.com/u/9402912?v=4", + "profile": "https://github.com/solufa", + "contributions": [ + "bug", + "code" + ] + }, + { + "login": "AriPerkkio", + "name": "Ari Perkkiö", + "avatar_url": "https://avatars.githubusercontent.com/u/14806298?v=4", + "profile": "https://codepen.io/ariperkkio/", + "contributions": [ + "test" + ] + }, + { + "login": "jhnns", + "name": "Johannes Ewald", + "avatar_url": "https://avatars.githubusercontent.com/u/781746?v=4", + "profile": "https://github.com/jhnns", + "contributions": [ + "code" + ] + }, + { + "login": "anpaopao", + "name": "Angus J. Pope", + "avatar_url": "https://avatars.githubusercontent.com/u/44686792?v=4", + "profile": "https://github.com/anpaopao", + "contributions": [ + "doc" + ] + }, + { + "login": "leschdom", + "name": "Dominik Lesch", + "avatar_url": "https://avatars.githubusercontent.com/u/62334278?v=4", + "profile": "https://github.com/leschdom", + "contributions": [ + "doc" + ] + }, + { + "login": "ImADrafter", + "name": "Marcos Gómez", + "avatar_url": "https://avatars.githubusercontent.com/u/44379989?v=4", + "profile": "https://github.com/ImADrafter", + "contributions": [ + "doc" + ] + }, + { + "login": "akashshyamdev", + "name": "Akash Shyam", + "avatar_url": "https://avatars.githubusercontent.com/u/56759828?v=4", + "profile": "https://www.akashshyam.online/", + "contributions": [ + "bug" + ] + }, + { + "login": "fmeum", + "name": "Fabian Meumertzheim", + "avatar_url": "https://avatars.githubusercontent.com/u/4312191?v=4", + "profile": "https://hen.ne.ke", + "contributions": [ + "code", + "bug" + ] + }, + { + "login": "Nokel81", + "name": "Sebastian Malton", + "avatar_url": "https://avatars.githubusercontent.com/u/8225332?v=4", + "profile": "https://github.com/Nokel81", + "contributions": [ + "bug", + "code" + ] + }, + { + "login": "mboettcher", + "name": "Martin Böttcher", + "avatar_url": "https://avatars.githubusercontent.com/u/2325337?v=4", + "profile": "https://github.com/mboettcher", + "contributions": [ + "code" + ] + }, + { + "login": "TkDodo", + "name": "Dominik Dorfmeister", + "avatar_url": "https://avatars.githubusercontent.com/u/1021430?v=4", + "profile": "http://tkdodo.eu", + "contributions": [ + "code" + ] + }, + { + "login": "stephensauceda", + "name": "Stephen Sauceda", + "avatar_url": "https://avatars.githubusercontent.com/u/1017723?v=4", + "profile": "https://stephensauceda.com", + "contributions": [ + "doc" + ] + }, + { + "login": "cmdcolin", + "name": "Colin Diesh", + "avatar_url": "https://avatars.githubusercontent.com/u/6511937?v=4", + "profile": "http://cmdcolin.github.io", + "contributions": [ + "doc" + ] + }, + { + "login": "yinm", + "name": "Yusuke Iinuma", + "avatar_url": "https://avatars.githubusercontent.com/u/13295106?v=4", + "profile": "http://yinm.info", + "contributions": [ + "code" + ] + }, + { + "login": "trappar", + "name": "Jeff Way", + "avatar_url": "https://avatars.githubusercontent.com/u/525726?v=4", + "profile": "https://github.com/trappar", + "contributions": [ + "code" + ] + }, + { + "login": "bernardobelchior", + "name": "Bernardo Belchior", + "avatar_url": "https://avatars.githubusercontent.com/u/12778398?v=4", + "profile": "http://belchior.me", + "contributions": [ + "code", + "doc" + ] } - ] + ], + "contributorsPerLine": 7, + "repoHost": "https://github.com", + "commitType": "docs", + "commitConvention": "angular" } diff --git a/.bundle.main.env b/.bundle.main.env new file mode 100644 index 00000000..669fe7e4 --- /dev/null +++ b/.bundle.main.env @@ -0,0 +1,2 @@ +BUILD_GLOBALS={"react-dom/test-utils":"ReactTestUtils","react":"React","react-dom":"ReactDOM"} + diff --git a/.bundle.pure.env b/.bundle.pure.env new file mode 100644 index 00000000..fed4df2e --- /dev/null +++ b/.bundle.pure.env @@ -0,0 +1,3 @@ +BUILD_FILENAME_SUFFIX=.pure +BUILD_INPUT=src/pure.js + diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json new file mode 100644 index 00000000..002bafb4 --- /dev/null +++ b/.codesandbox/ci.json @@ -0,0 +1,5 @@ +{ + "installCommand": "install:csb", + "sandboxes": ["new", "github/kentcdodds/react-testing-library-examples"], + "node": "18" +} diff --git a/.gitattributes b/.gitattributes index 391f0a4e..6313b56c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1 @@ -* text=auto -*.js text eol=lf +* text=auto eol=lf diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 724a7df6..496c8563 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -20,11 +20,10 @@ Thanks for your interest in the project. We appreciate bugs filed and PRs submit ❓ Questions: For questions related to using the library, please visit a support community - instead of filing an issue on GitHub. - * React Spectrum - https://spectrum.chat/react-testing-library - * Reactiflux on Discord - https://www.reactiflux.com + instead of filing an issue on GitHub. You can follow the instructions in this + codesandbox to make a reproduction of your issue: https://kcd.im/rtl-help + * Discord + https://discord.gg/testing-library * Stack Overflow https://stackoverflow.com/questions/tagged/react-testing-library @@ -43,14 +42,26 @@ tutorial to learn how: http://kcd.im/pull-request --> -- `react-testing-library` version: -- `react` version: -- `node` version: -- `npm` (or `yarn`) version: +- `@testing-library/react` version: +- Testing Framework and version: + +- DOM Environment: + + + Relevant code or config ```javascript + ``` What you did: @@ -60,11 +71,11 @@ What happened: Reproduction repository: -https://github.com/alexkrolick/dom-testing-library-template -- `react-testing-library` version: -- `react` version: -- `node` version: -- `npm` (or `yarn`) version: +- `@testing-library/react` version: +- Testing Framework and version: + +- DOM Environment: + + + ### Relevant code or config: @@ -33,6 +44,11 @@ tutorial to learn how: http://kcd.im/pull-request var your => (code) => here; ``` + + ### What you did: @@ -44,13 +60,8 @@ var your => (code) => here; ### Reproduction: ### Problem description: diff --git a/.github/ISSUE_TEMPLATE/Question.md b/.github/ISSUE_TEMPLATE/Question.md index 52790875..e625486b 100644 --- a/.github/ISSUE_TEMPLATE/Question.md +++ b/.github/ISSUE_TEMPLATE/Question.md @@ -12,9 +12,13 @@ and feature requests so we recommend not using this medium to ask them here 😁 ## ❓ Support Forums -- React Spectrum https://spectrum.chat/react-testing-library -- Reactiflux on Discord https://www.reactiflux.com +For questions related to using the library, please visit a support community +instead of filing an issue on GitHub. You can follow the instructions in this +codesandbox to make a reproduction of your issue: https://kcd.im/rtl-help + +- Discord https://discord.gg/testing-library - Stack Overflow https://stackoverflow.com/questions/tagged/react-testing-library +- Documentation: https://github.com/testing-library/testing-library-docs **ISSUES WHICH ARE QUESTIONS WILL BE CLOSED** diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index df54e83c..f5000e21 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -34,12 +34,11 @@ merge of your pull request! -- [ ] Documentation added to the [docs site](https://github.com/alexkrolick/testing-library-docs) +- [ ] Documentation added to the + [docs site](https://github.com/testing-library/testing-library-docs) - [ ] Tests -- [ ] Typescript definitions updated +- [ ] TypeScript definitions updated - [ ] Ready to be merged -- [ ] Added myself to contributors table - diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 00000000..f239c717 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,117 @@ +name: validate +on: + push: + branches: + # Match SemVer major release branches + # e.g. "12.x" or "8.x" + - '[0-9]+.x' + - 'main' + - 'next' + - 'next-major' + - 'beta' + - 'alpha' + - '!all-contributors/**' + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + actions: write # to cancel/stop running workflows (styfle/cancel-workflow-action) + contents: read # to fetch code (actions/checkout) + +jobs: + main: + continue-on-error: ${{ matrix.react != 'latest' }} + # ignore all-contributors PRs + if: ${{ !contains(github.head_ref, 'all-contributors') }} + strategy: + fail-fast: false + matrix: + node: [18, 20] + react: ['18.x', latest, canary, experimental] + runs-on: ubuntu-latest + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + + - name: ⎔ Setup node + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + with: + useLockFile: false + + # TODO: Can be removed if https://github.com/kentcdodds/kcd-scripts/pull/146 is released + - name: Verify format (`npm run format` committed?) + run: npm run format -- --check --no-write + + # as requested by the React team :) + # https://reactjs.org/blog/2019/10/22/react-release-channels.html#using-the-next-channel-for-integration-testing + - name: ⚛️ Setup react + run: npm install react@${{ matrix.react }} react-dom@${{ matrix.react }} + + - name: ⚛️ Setup react types + if: ${{ matrix.react != 'canary' && matrix.react != 'experimental' }} + run: + npm install @types/react@${{ matrix.react }} @types/react-dom@${{ + matrix.react }} + + - name: ▶️ Run validate script + run: npm run validate + + - name: ⬆️ Upload coverage report + uses: codecov/codecov-action@v5 + with: + fail_ci_if_error: true + flags: ${{ matrix.react }} + token: ${{ secrets.CODECOV_TOKEN }} + + 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 (cycjimmy/semantic-release-action) + + needs: main + runs-on: ubuntu-latest + if: + ${{ github.repository == 'testing-library/react-testing-library' && + github.event_name == 'push' }} + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + + - name: ⎔ Setup node + uses: actions/setup-node@v4 + with: + node-version: 14 + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + with: + useLockFile: false + + - name: 🏗 Run build script + run: npm run build + + - name: 🚀 Release + uses: cycjimmy/semantic-release-action@v2 + with: + semantic_version: 17 + branches: | + [ + '+([0-9])?(.{+([0-9]),x}).x', + 'main', + 'next', + 'next-major', + {name: 'beta', prerelease: true}, + {name: 'alpha', prerelease: true} + ] + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 5bb9facf..8e0c70cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,9 @@ node_modules coverage dist -.opt-in -.opt-out .DS_Store -.eslintcache - -yarn-error.log # these cause more harm than good # when working with contributors package-lock.json yarn.lock - diff --git a/.huskyrc.js b/.huskyrc.js new file mode 100644 index 00000000..5e45c45d --- /dev/null +++ b/.huskyrc.js @@ -0,0 +1 @@ +module.exports = require('kcd-scripts/husky') diff --git a/.npmrc b/.npmrc index d2722898..1df2a6d8 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,2 @@ -registry=http://registry.npmjs.org/ +registry=https://registry.npmjs.org/ package-lock=false diff --git a/.prettierignore b/.prettierignore index 30117ea2..9c628283 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,3 @@ -package.json node_modules -dist coverage +dist diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index f3685197..00000000 --- a/.prettierrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "printWidth": 80, - "tabWidth": 2, - "useTabs": false, - "semi": false, - "singleQuote": true, - "trailingComma": "all", - "bracketSpacing": false, - "jsxBracketSameLine": false, - "proseWrap": "always" -} diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 00000000..4679d9bf --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1 @@ +module.exports = require('kcd-scripts/prettier') diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 08be7ec0..00000000 --- a/.travis.yml +++ /dev/null @@ -1,13 +0,0 @@ -sudo: false -language: node_js -cache: - directories: - - ~/.npm -notifications: - email: false -node_js: '8' -install: npm install -script: npm run validate -after_success: kcd-scripts travis-after-success -branches: - only: master diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 070cb5f6..47681ae0 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,74 +2,127 @@ ## Our Pledge -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, gender identity and expression, level of -experience, nationality, personal appearance, race, religion, or sexual identity -and orientation. +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. ## Our Standards -Examples of behavior that contributes to creating a positive environment -include: +Examples of behavior that contributes to a positive environment for our +community include: -- Using welcoming and inclusive language -- Being respectful of differing viewpoints and experiences -- Gracefully accepting constructive criticism -- Focusing on what is best for the community -- Showing empathy towards other community members +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community -Examples of unacceptable behavior by participants include: +Examples of unacceptable behavior include: -- The use of sexualized language or imagery and unwelcome sexual attention or - advances -- Trolling, insulting/derogatory comments, and personal or political attacks +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment -- Publishing others' private information, such as a physical or electronic - address, without explicit permission +- Publishing others' private information, such as a physical or email address, + without their explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting -## Our Responsibilities +## Enforcement Responsibilities -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. -Project maintainers have the right and responsibility to remove, edit, or reject +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, or to ban temporarily or permanently any -contributor for other behaviors that they deem inappropriate, threatening, -offensive, or harmful. +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. ## Scope -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at kent+coc@doddsfamily.us. All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an -incident. Further details of specific enforcement policies may be posted -separately. +reported to the community leaders responsible for enforcement at +me+coc@kentcdodds.com. All complaints will be reviewed and investigated promptly +and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 1.4, available at [http://contributor-covenant.org/version/1/4][version] +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org -[homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/4/ +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 89f9e343..e16e9d61 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,34 +11,20 @@ series [How to Contribute to an Open Source Project on GitHub][egghead] 2. Run `npm run setup -s` to install dependencies and run validation 3. Create a branch for your PR with `git checkout -b pr/your-branch-name` -> Tip: Keep your `master` branch pointing at the original repository and make -> pull requests from branches on your fork. To do this, run: +> Tip: Keep your `main` branch pointing at the original repository and make pull +> requests from branches on your fork. To do this, run: > > ``` -> git remote add upstream https://github.com/kentcdodds/react-testing-library.git +> git remote add upstream https://github.com/testing-library/react-testing-library.git > git fetch upstream -> git branch --set-upstream-to=upstream/master master +> git branch --set-upstream-to=upstream/main main > ``` > > This will add the original repository as a "remote" called "upstream," Then -> fetch the git information from that remote, then set your local `master` -> branch to use the upstream master branch whenever you run `git pull`. Then you -> can make all of your pull request branches based on this `master` branch. -> Whenever you want to update your version of `master`, do a regular `git pull`. - -## Add yourself as a contributor - -This project follows the [all contributors][all-contributors] specification. To -add yourself to the table of contributors on the `README.md`, please use the -automated script as part of your PR: - -```console -npm run add-contributor -``` - -Follow the prompt and commit `.all-contributorsrc` and `README.md` in the PR. If -you've already added yourself to the list and are making a new type of -contribution, you can run it again and select the added contribution type. +> fetch the git information from that remote, then set your local `main` branch +> to use the upstream main branch whenever you run `git pull`. Then you can make +> all of your pull request branches based on this `main` branch. Whenever you +> want to update your version of `main`, do a regular `git pull`. ## Committing and Pushing changes @@ -46,25 +32,15 @@ Please make sure to run the tests before you commit your changes. You can run `npm run test:update` which will update any snapshots that need updating. Make sure to include those changes (if they exist) in your commit. -### opt into git hooks - -There are git hooks set up with this project that are automatically installed -when you install dependencies. They're really handy, but are turned off by -default (so as to not hinder new contributors). You can opt into these by -creating a file called `.opt-in` at the root of the project and putting this -inside: - -``` -pre-commit -``` - -### Add typings +### Update Typings If your PR introduced some changes in the API, you are more than welcome to -modify the Typescript type definition to reflect those changes. Just modify the -`/typings/index.d.ts` file accordingly. If you have never seen Typescript +modify the TypeScript type definition to reflect those changes. Just modify the +`/types/index.d.ts` file accordingly. If you have never seen TypeScript definitions before, you can read more about it in its -[documentation pages](https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html) +[documentation pages](https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html). +Though this library itself is not written in TypeScript we use +[dtslint](https://github.com/microsoft/dtslint) to lint our typings. ## Help needed @@ -74,6 +50,5 @@ Also, please watch the repo and respond to questions/bug reports/feature requests! Thanks! [egghead]: - https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github -[all-contributors]: https://github.com/kentcdodds/all-contributors -[issues]: https://github.com/kentcdodds/react-testing-library/issues + https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github +[issues]: https://github.com/testing-library/react-testing-library/issues diff --git a/LICENSE b/LICENSE index 4c43675b..ca399d57 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ The MIT License (MIT) -Copyright (c) 2017 Kent C. Dodds +Copyright (c) 2017-Present Kent C. Dodds Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 7ae254f6..7e18d5dd 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,24 @@
Simple and complete React DOM testing utilities that encourage good testing practices.
+
diff --git a/src/__tests__/act.js b/src/__tests__/act.js
index 49729f3b..5430f28b 100644
--- a/src/__tests__/act.js
+++ b/src/__tests__/act.js
@@ -1,8 +1,5 @@
-import 'jest-dom/extend-expect'
-import React from 'react'
-import {render, cleanup, fireEvent} from '../'
-
-afterEach(cleanup)
+import * as React from 'react'
+import {act, render, fireEvent, screen} from '../'
test('render calls useEffect immediately', () => {
const effectCb = jest.fn()
@@ -14,6 +11,12 @@ test('render calls useEffect immediately', () => {
expect(effectCb).toHaveBeenCalledTimes(1)
})
+test('findByTestId returns the element', async () => {
+ const ref = React.createRef()
+ render()
+ expect(await screen.findByTestId('foo')).toBe(ref.current)
+})
+
test('fireEvent triggers useEffect calls', () => {
const effectCb = jest.fn()
function Counter() {
@@ -40,3 +43,27 @@ test('calls to hydrate will run useEffects', () => {
render( , {hydrate: true})
expect(effectCb).toHaveBeenCalledTimes(1)
})
+
+test('cleans up IS_REACT_ACT_ENVIRONMENT if its callback throws', () => {
+ global.IS_REACT_ACT_ENVIRONMENT = false
+
+ expect(() =>
+ act(() => {
+ throw new Error('threw')
+ }),
+ ).toThrow('threw')
+
+ expect(global.IS_REACT_ACT_ENVIRONMENT).toEqual(false)
+})
+
+test('cleans up IS_REACT_ACT_ENVIRONMENT if its async callback throws', async () => {
+ global.IS_REACT_ACT_ENVIRONMENT = false
+
+ await expect(() =>
+ act(async () => {
+ throw new Error('thenable threw')
+ }),
+ ).rejects.toThrow('thenable threw')
+
+ expect(global.IS_REACT_ACT_ENVIRONMENT).toEqual(false)
+})
diff --git a/src/__tests__/auto-cleanup-skip.js b/src/__tests__/auto-cleanup-skip.js
new file mode 100644
index 00000000..5696d4e3
--- /dev/null
+++ b/src/__tests__/auto-cleanup-skip.js
@@ -0,0 +1,18 @@
+import * as React from 'react'
+
+let render
+beforeAll(() => {
+ process.env.RTL_SKIP_AUTO_CLEANUP = 'true'
+ const rtl = require('../')
+ render = rtl.render
+})
+
+// This one verifies that if RTL_SKIP_AUTO_CLEANUP is set
+// then we DON'T auto-wire up the afterEach for folks
+test('first', () => {
+ render(hi)
+})
+
+test('second', () => {
+ expect(document.body.innerHTML).toEqual('hi')
+})
diff --git a/src/__tests__/auto-cleanup.js b/src/__tests__/auto-cleanup.js
new file mode 100644
index 00000000..450a6136
--- /dev/null
+++ b/src/__tests__/auto-cleanup.js
@@ -0,0 +1,13 @@
+import * as React from 'react'
+import {render} from '../'
+
+// This just verifies that by importing RTL in an
+// environment which supports afterEach (like jest)
+// we'll get automatic cleanup between tests.
+test('first', () => {
+ render(hi)
+})
+
+test('second', () => {
+ expect(document.body).toBeEmptyDOMElement()
+})
diff --git a/src/__tests__/bugs.js b/src/__tests__/bugs.js
deleted file mode 100644
index 05146cf7..00000000
--- a/src/__tests__/bugs.js
+++ /dev/null
@@ -1,10 +0,0 @@
-// this is where we'll put bug reproductions/regressions
-// to make sure we never see them again
-
-import React from 'react'
-import {render, cleanup} from '../'
-
-test('cleanup does not error when an element is not a child', () => {
- render(, {container: document.createElement('div')})
- cleanup()
-})
diff --git a/src/__tests__/cleanup.js b/src/__tests__/cleanup.js
new file mode 100644
index 00000000..9f17c722
--- /dev/null
+++ b/src/__tests__/cleanup.js
@@ -0,0 +1,126 @@
+import * as React from 'react'
+import {render, cleanup} from '../'
+
+test('cleans up the document', () => {
+ const spy = jest.fn()
+ const divId = 'my-div'
+
+ class Test extends React.Component {
+ componentWillUnmount() {
+ expect(document.getElementById(divId)).toBeInTheDocument()
+ spy()
+ }
+
+ render() {
+ return
+ }
+ }
+
+ render( )
+ cleanup()
+ expect(document.body).toBeEmptyDOMElement()
+ expect(spy).toHaveBeenCalledTimes(1)
+})
+
+test('cleanup does not error when an element is not a child', () => {
+ render(, {container: document.createElement('div')})
+ cleanup()
+})
+
+test('cleanup runs effect cleanup functions', () => {
+ const spy = jest.fn()
+
+ const Test = () => {
+ React.useEffect(() => spy)
+
+ return null
+ }
+
+ render( )
+ cleanup()
+ expect(spy).toHaveBeenCalledTimes(1)
+})
+
+describe('fake timers and missing act warnings', () => {
+ beforeEach(() => {
+ jest.resetAllMocks()
+ jest.spyOn(console, 'error').mockImplementation(() => {
+ // assert messages explicitly
+ })
+ jest.useFakeTimers()
+ })
+
+ afterEach(() => {
+ jest.restoreAllMocks()
+ jest.useRealTimers()
+ })
+
+ test('cleanup does not flush microtasks', () => {
+ const microTaskSpy = jest.fn()
+ function Test() {
+ const counter = 1
+ const [, setDeferredCounter] = React.useState(null)
+ React.useEffect(() => {
+ let cancelled = false
+ Promise.resolve().then(() => {
+ microTaskSpy()
+ // eslint-disable-next-line jest/no-if, jest/no-conditional-in-test -- false positive
+ if (!cancelled) {
+ setDeferredCounter(counter)
+ }
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [counter])
+
+ return null
+ }
+ render( )
+
+ cleanup()
+
+ expect(microTaskSpy).toHaveBeenCalledTimes(0)
+ // console.error is mocked
+ // eslint-disable-next-line no-console
+ expect(console.error).toHaveBeenCalledTimes(0)
+ })
+
+ test('cleanup does not swallow missing act warnings', () => {
+ const deferredStateUpdateSpy = jest.fn()
+ function Test() {
+ const counter = 1
+ const [, setDeferredCounter] = React.useState(null)
+ React.useEffect(() => {
+ let cancelled = false
+ setTimeout(() => {
+ deferredStateUpdateSpy()
+ // eslint-disable-next-line jest/no-conditional-in-test -- false-positive
+ if (!cancelled) {
+ setDeferredCounter(counter)
+ }
+ }, 0)
+
+ return () => {
+ cancelled = true
+ }
+ }, [counter])
+
+ return null
+ }
+ render( )
+
+ jest.runAllTimers()
+ cleanup()
+
+ expect(deferredStateUpdateSpy).toHaveBeenCalledTimes(1)
+ // console.error is mocked
+ // eslint-disable-next-line no-console
+ expect(console.error).toHaveBeenCalledTimes(1)
+ // eslint-disable-next-line no-console
+ expect(console.error.mock.calls[0][0]).toMatch(
+ 'a test was not wrapped in act(...)',
+ )
+ })
+})
diff --git a/src/__tests__/config.js b/src/__tests__/config.js
new file mode 100644
index 00000000..7fdb1e00
--- /dev/null
+++ b/src/__tests__/config.js
@@ -0,0 +1,66 @@
+import {configure, getConfig} from '../'
+
+describe('configuration API', () => {
+ let originalConfig
+ beforeEach(() => {
+ // Grab the existing configuration so we can restore
+ // it at the end of the test
+ configure(existingConfig => {
+ originalConfig = existingConfig
+ // Don't change the existing config
+ return {}
+ })
+ })
+
+ afterEach(() => {
+ configure(originalConfig)
+ })
+
+ describe('DTL options', () => {
+ test('configure can set by a plain JS object', () => {
+ const testIdAttribute = 'not-data-testid'
+ configure({testIdAttribute})
+
+ expect(getConfig().testIdAttribute).toBe(testIdAttribute)
+ })
+
+ test('configure can set by a function', () => {
+ // setup base option
+ const baseTestIdAttribute = 'data-testid'
+ configure({testIdAttribute: baseTestIdAttribute})
+
+ const modifiedPrefix = 'modified-'
+ configure(existingConfig => ({
+ testIdAttribute: `${modifiedPrefix}${existingConfig.testIdAttribute}`,
+ }))
+
+ expect(getConfig().testIdAttribute).toBe(
+ `${modifiedPrefix}${baseTestIdAttribute}`,
+ )
+ })
+ })
+
+ describe('RTL options', () => {
+ test('configure can set by a plain JS object', () => {
+ configure({reactStrictMode: true})
+
+ expect(getConfig().reactStrictMode).toBe(true)
+ })
+
+ test('configure can set by a function', () => {
+ configure(existingConfig => ({
+ reactStrictMode: !existingConfig.reactStrictMode,
+ }))
+
+ expect(getConfig().reactStrictMode).toBe(true)
+ })
+ })
+
+ test('configure can set DTL and RTL options at once', () => {
+ const testIdAttribute = 'not-data-testid'
+ configure({testIdAttribute, reactStrictMode: true})
+
+ expect(getConfig().testIdAttribute).toBe(testIdAttribute)
+ expect(getConfig().reactStrictMode).toBe(true)
+ })
+})
diff --git a/src/__tests__/debug.js b/src/__tests__/debug.js
index f7c0a927..c6a1d1fe 100644
--- a/src/__tests__/debug.js
+++ b/src/__tests__/debug.js
@@ -1,12 +1,11 @@
-import React from 'react'
-import {render, cleanup} from '../'
+import * as React from 'react'
+import {render, screen} from '../'
beforeEach(() => {
jest.spyOn(console, 'log').mockImplementation(() => {})
})
afterEach(() => {
- cleanup()
console.log.mockRestore()
})
@@ -20,4 +19,37 @@ test('debug pretty prints the container', () => {
)
})
-/* eslint no-console:0 */
+test('debug pretty prints multiple containers', () => {
+ const HelloWorld = () => (
+ <>
+ Hello World
+ Hello World
+ >
+ )
+ const {debug} = render( )
+ const multipleElements = screen.getAllByTestId('testId')
+ debug(multipleElements)
+
+ expect(console.log).toHaveBeenCalledTimes(2)
+ expect(console.log).toHaveBeenCalledWith(
+ expect.stringContaining('Hello World'),
+ )
+})
+
+test('allows same arguments as prettyDOM', () => {
+ const HelloWorld = () => Hello World
+ const {debug, container} = render( )
+ debug(container, 6, {highlight: false})
+ expect(console.log).toHaveBeenCalledTimes(1)
+ expect(console.log.mock.calls[0]).toMatchInlineSnapshot(`
+ [
+
+ ...,
+ ]
+ `)
+})
+
+/*
+eslint
+ no-console: "off",
+*/
diff --git a/src/__tests__/end-to-end.js b/src/__tests__/end-to-end.js
index 78087e58..f93c23be 100644
--- a/src/__tests__/end-to-end.js
+++ b/src/__tests__/end-to-end.js
@@ -1,41 +1,234 @@
-import React from 'react'
-import {render, wait, cleanup} from '../'
-
-afterEach(cleanup)
-
-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)
- })
-
-class ComponentWithLoader extends React.Component {
- state = {loading: true}
- async componentDidMount() {
- const data = await fetchAMessage()
- this.setState({data, loading: false}) // eslint-disable-line
- }
- render() {
- if (this.state.loading) {
- return Loading...
+import * as React from 'react'
+import {render, waitForElementToBeRemoved, screen, waitFor} from '../'
+
+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()
+ })
+
+ 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}!
+
+ )
+ }
+
+ 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/,
+ )
+ })
+ },
+)
+
+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 many 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/,
+ )
+ })
+ },
+)
+
+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 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().then(data => {
+ setFetchState({todo: data.title, fetching: false})
+ })
+ })
+ }
+ }, [fetchState])
+
+ if (fetchState.fetching) {
+ return Loading..
+ }
+
+ return (
+ Loaded this message: {fetchState.todo}
+ )
}
- return (
-
- Loaded this message: {this.state.data.returnedMessage}!
-
- )
- }
-}
-
-test('it waits for the data to be loaded', async () => {
- const {queryByText, queryByTestId} = render( )
-
- expect(queryByText('Loading...')).toBeTruthy()
-
- await wait(() => expect(queryByText('Loading...')).toBeNull())
- expect(queryByTestId('message').textContent).toMatch(/Hello World/)
-})
+
+ 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__/error-handlers.js b/src/__tests__/error-handlers.js
new file mode 100644
index 00000000..60db1410
--- /dev/null
+++ b/src/__tests__/error-handlers.js
@@ -0,0 +1,183 @@
+/* eslint-disable jest/no-if */
+/* eslint-disable jest/no-conditional-in-test */
+/* eslint-disable jest/no-conditional-expect */
+import * as React from 'react'
+import {render, renderHook} from '../'
+
+const isReact19 = React.version.startsWith('19.')
+
+const testGateReact19 = isReact19 ? test : test.skip
+
+test('render errors', () => {
+ function Thrower() {
+ throw new Error('Boom!')
+ }
+
+ if (isReact19) {
+ expect(() => {
+ render( )
+ }).toThrow('Boom!')
+ } else {
+ expect(() => {
+ expect(() => {
+ render( )
+ }).toThrow('Boom!')
+ }).toErrorDev([
+ 'Error: Uncaught [Error: Boom!]',
+ // React retries on error
+ 'Error: Uncaught [Error: Boom!]',
+ ])
+ }
+})
+
+test('onUncaughtError is not supported in render', () => {
+ function Thrower() {
+ throw new Error('Boom!')
+ }
+ const onUncaughtError = jest.fn(() => {})
+
+ expect(() => {
+ render( , {
+ onUncaughtError(error, errorInfo) {
+ console.log({error, errorInfo})
+ },
+ })
+ }).toThrow(
+ 'onUncaughtError is not supported. The `render` call will already throw on uncaught errors.',
+ )
+
+ expect(onUncaughtError).toHaveBeenCalledTimes(0)
+})
+
+testGateReact19('onCaughtError is supported in render', () => {
+ const thrownError = new Error('Boom!')
+ const handleComponentDidCatch = jest.fn()
+ const onCaughtError = jest.fn()
+ class ErrorBoundary extends React.Component {
+ state = {error: null}
+ static getDerivedStateFromError(error) {
+ return {error}
+ }
+ componentDidCatch(error, errorInfo) {
+ handleComponentDidCatch(error, errorInfo)
+ }
+ render() {
+ if (this.state.error) {
+ return null
+ }
+ return this.props.children
+ }
+ }
+ function Thrower() {
+ throw thrownError
+ }
+
+ render(
+
+
+ ,
+ {
+ onCaughtError,
+ },
+ )
+
+ expect(onCaughtError).toHaveBeenCalledWith(thrownError, {
+ componentStack: expect.any(String),
+ errorBoundary: expect.any(Object),
+ })
+})
+
+test('onRecoverableError is supported in render', () => {
+ const onRecoverableError = jest.fn()
+
+ const container = document.createElement('div')
+ container.innerHTML = 'server'
+ // We just hope we forwarded the callback correctly (which is guaranteed since we just pass it along)
+ // Frankly, I'm too lazy to assert on React 18 hydration errors since they're a mess.
+ // eslint-disable-next-line jest/no-conditional-in-test
+ if (isReact19) {
+ render(client, {
+ container,
+ hydrate: true,
+ onRecoverableError,
+ })
+ expect(onRecoverableError).toHaveBeenCalledTimes(1)
+ } else {
+ expect(() => {
+ render(client, {
+ container,
+ hydrate: true,
+ onRecoverableError,
+ })
+ }).toErrorDev(['', ''], {withoutStack: 1})
+ expect(onRecoverableError).toHaveBeenCalledTimes(2)
+ }
+})
+
+test('onUncaughtError is not supported in renderHook', () => {
+ function useThrower() {
+ throw new Error('Boom!')
+ }
+ const onUncaughtError = jest.fn(() => {})
+
+ expect(() => {
+ renderHook(useThrower, {
+ onUncaughtError(error, errorInfo) {
+ console.log({error, errorInfo})
+ },
+ })
+ }).toThrow(
+ 'onUncaughtError is not supported. The `render` call will already throw on uncaught errors.',
+ )
+
+ expect(onUncaughtError).toHaveBeenCalledTimes(0)
+})
+
+testGateReact19('onCaughtError is supported in renderHook', () => {
+ const thrownError = new Error('Boom!')
+ const handleComponentDidCatch = jest.fn()
+ const onCaughtError = jest.fn()
+ class ErrorBoundary extends React.Component {
+ state = {error: null}
+ static getDerivedStateFromError(error) {
+ return {error}
+ }
+ componentDidCatch(error, errorInfo) {
+ handleComponentDidCatch(error, errorInfo)
+ }
+ render() {
+ if (this.state.error) {
+ return null
+ }
+ return this.props.children
+ }
+ }
+ function useThrower() {
+ throw thrownError
+ }
+
+ renderHook(useThrower, {
+ onCaughtError,
+ wrapper: ErrorBoundary,
+ })
+
+ expect(onCaughtError).toHaveBeenCalledWith(thrownError, {
+ componentStack: expect.any(String),
+ errorBoundary: expect.any(Object),
+ })
+})
+
+// Currently, there's no recoverable error without hydration.
+// The option is still supported though.
+test('onRecoverableError is supported in renderHook', () => {
+ const onRecoverableError = jest.fn()
+
+ renderHook(
+ () => {
+ // TODO: trigger recoverable error
+ },
+ {
+ onRecoverableError,
+ },
+ )
+})
diff --git a/src/__tests__/events.js b/src/__tests__/events.js
index c091a366..587bfdae 100644
--- a/src/__tests__/events.js
+++ b/src/__tests__/events.js
@@ -1,5 +1,5 @@
-import React from 'react'
-import {render, cleanup, fireEvent} from '../'
+import * as React from 'react'
+import {render, fireEvent} from '../'
const eventTypes = [
{
@@ -62,6 +62,22 @@ const eventTypes = [
],
elementType: 'button',
},
+ {
+ type: 'Pointer',
+ events: [
+ 'pointerOver',
+ 'pointerEnter',
+ 'pointerDown',
+ 'pointerMove',
+ 'pointerUp',
+ 'pointerCancel',
+ 'pointerOut',
+ 'pointerLeave',
+ 'gotPointerCapture',
+ 'lostPointerCapture',
+ ],
+ elementType: 'button',
+ },
{
type: 'Selection',
events: ['select'],
@@ -128,8 +144,6 @@ const eventTypes = [
},
]
-afterEach(cleanup)
-
eventTypes.forEach(({type, events, elementType, init}) => {
describe(`${type} Events`, () => {
events.forEach(eventName => {
@@ -155,6 +169,41 @@ eventTypes.forEach(({type, events, elementType, init}) => {
})
})
+eventTypes.forEach(({type, events, elementType, init}) => {
+ describe(`Native ${type} Events`, () => {
+ events.forEach(eventName => {
+ let nativeEventName = eventName.toLowerCase()
+
+ // The doubleClick synthetic event maps to the dblclick native event
+ if (nativeEventName === 'doubleclick') {
+ nativeEventName = 'dblclick'
+ }
+
+ it(`triggers native ${nativeEventName}`, () => {
+ const ref = React.createRef()
+ const spy = jest.fn()
+ const Element = elementType
+
+ const NativeEventElement = () => {
+ React.useEffect(() => {
+ const element = ref.current
+ element.addEventListener(nativeEventName, spy)
+ return () => {
+ element.removeEventListener(nativeEventName, spy)
+ }
+ })
+ return
+ }
+
+ render( )
+
+ fireEvent[eventName](ref.current, init)
+ expect(spy).toHaveBeenCalledTimes(1)
+ })
+ })
+ })
+})
+
test('onChange works', () => {
const handleChange = jest.fn()
const {
@@ -178,3 +227,30 @@ test('calling `fireEvent` directly works too', () => {
}),
)
})
+
+test('blur/focus bubbles in react', () => {
+ const handleBlur = jest.fn()
+ const handleBubbledBlur = jest.fn()
+ const handleFocus = jest.fn()
+ const handleBubbledFocus = jest.fn()
+ const {container} = render(
+
+
+ ,
+ )
+ const button = container.firstChild.firstChild
+
+ fireEvent.focus(button)
+
+ expect(handleBlur).toHaveBeenCalledTimes(0)
+ expect(handleBubbledBlur).toHaveBeenCalledTimes(0)
+ expect(handleFocus).toHaveBeenCalledTimes(1)
+ expect(handleBubbledFocus).toHaveBeenCalledTimes(1)
+
+ fireEvent.blur(button)
+
+ expect(handleBlur).toHaveBeenCalledTimes(1)
+ expect(handleBubbledBlur).toHaveBeenCalledTimes(1)
+ expect(handleFocus).toHaveBeenCalledTimes(1)
+ expect(handleBubbledFocus).toHaveBeenCalledTimes(1)
+})
diff --git a/src/__tests__/fetch.js b/src/__tests__/fetch.js
deleted file mode 100644
index c607508c..00000000
--- a/src/__tests__/fetch.js
+++ /dev/null
@@ -1,50 +0,0 @@
-import React from 'react'
-import axiosMock from 'axios'
-import {render, fireEvent, cleanup, wait} from '../'
-
-afterEach(cleanup)
-
-// instead of importing it, we'll define it inline here
-// import Fetch from '../fetch'
-
-class Fetch extends React.Component {
- state = {}
- componentDidUpdate(prevProps) {
- if (this.props.url !== prevProps.url) {
- this.fetch()
- }
- }
- fetch = async () => {
- const response = await axiosMock.get(this.props.url)
- this.setState({data: response.data})
- }
- render() {
- const {data} = this.state
- return (
-
-
- {data ? {data.greeting} : null}
-
- )
- }
-}
-
-test('Fetch makes an API call and displays the greeting when load-greeting is clicked', async () => {
- // Arrange
- axiosMock.get.mockResolvedValueOnce({data: {greeting: 'hello there'}})
- const url = '/greeting'
- const {container, getByText} = render( )
-
- // Act
- fireEvent.click(getByText('Fetch'))
-
- await wait()
-
- // Assert
- expect(axiosMock.get).toHaveBeenCalledTimes(1)
- expect(axiosMock.get).toHaveBeenCalledWith(url)
- // this assertion is funny because if the textContent were not "hello there"
- // then the `getByText` would throw anyway... 🤔
- expect(getByText('hello there').textContent).toBe('hello there')
- expect(container.firstChild).toMatchSnapshot()
-})
diff --git a/src/__tests__/forms.js b/src/__tests__/forms.js
deleted file mode 100644
index f3b7ebe9..00000000
--- a/src/__tests__/forms.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import React from 'react'
-import {render, fireEvent, cleanup} from '../'
-
-afterEach(cleanup)
-
-function Login({onSubmit}) {
- return (
-
-
-
- )
-}
-
-test('login form submits', () => {
- const fakeUser = {username: 'jackiechan', password: 'hiya! 🥋'}
- const handleSubmit = jest.fn()
- const {getByLabelText, getByText} = render( )
-
- const usernameNode = getByLabelText(/username/i)
- const passwordNode = getByLabelText(/password/i)
- const submitButtonNode = getByText(/submit/i)
-
- // Act
- usernameNode.value = fakeUser.username
- passwordNode.value = fakeUser.password
- fireEvent.click(submitButtonNode)
-
- // Assert
- expect(handleSubmit).toHaveBeenCalledTimes(1)
- expect(handleSubmit).toHaveBeenCalledWith(fakeUser)
-})
-
-/* eslint jsx-a11y/label-has-for:0, jsx-a11y/aria-proptypes:0 */
diff --git a/src/__tests__/multi-base.js b/src/__tests__/multi-base.js
new file mode 100644
index 00000000..ef5a7e11
--- /dev/null
+++ b/src/__tests__/multi-base.js
@@ -0,0 +1,45 @@
+import * as React from 'react'
+import {render} from '../'
+
+// these are created once per test suite and reused for each case
+let treeA, treeB
+beforeAll(() => {
+ treeA = document.createElement('div')
+ treeB = document.createElement('div')
+ document.body.appendChild(treeA)
+ document.body.appendChild(treeB)
+})
+
+afterAll(() => {
+ treeA.parentNode.removeChild(treeA)
+ treeB.parentNode.removeChild(treeB)
+})
+
+test('baseElement isolates trees from one another', () => {
+ const {getByText: getByTextInA} = render(Jekyll, {
+ baseElement: treeA,
+ })
+ const {getByText: getByTextInB} = render(Hyde, {
+ baseElement: treeB,
+ })
+
+ expect(() => getByTextInA('Jekyll')).not.toThrow(
+ 'Unable to find an element with the text: Jekyll.',
+ )
+ expect(() => getByTextInB('Jekyll')).toThrow(
+ 'Unable to find an element with the text: Jekyll.',
+ )
+
+ expect(() => getByTextInA('Hyde')).toThrow(
+ 'Unable to find an element with the text: Hyde.',
+ )
+ expect(() => getByTextInB('Hyde')).not.toThrow(
+ 'Unable to find an element with the text: Hyde.',
+ )
+})
+
+// https://github.com/testing-library/eslint-plugin-testing-library/issues/188
+/*
+eslint
+ testing-library/prefer-screen-queries: "off",
+*/
diff --git a/src/__tests__/new-act.js b/src/__tests__/new-act.js
new file mode 100644
index 00000000..0464ad24
--- /dev/null
+++ b/src/__tests__/new-act.js
@@ -0,0 +1,79 @@
+let asyncAct
+
+jest.mock('react', () => {
+ return {
+ ...jest.requireActual('react'),
+ act: cb => {
+ return cb()
+ },
+ }
+})
+
+beforeEach(() => {
+ jest.resetModules()
+ asyncAct = require('../act-compat').default
+ jest.spyOn(console, 'error').mockImplementation(() => {})
+})
+
+afterEach(() => {
+ jest.restoreAllMocks()
+})
+
+test('async act works when it does not exist (older versions of react)', async () => {
+ const callback = jest.fn()
+ await asyncAct(async () => {
+ await Promise.resolve()
+ await callback()
+ })
+ expect(console.error).toHaveBeenCalledTimes(0)
+ expect(callback).toHaveBeenCalledTimes(1)
+
+ callback.mockClear()
+ console.error.mockClear()
+
+ await asyncAct(async () => {
+ await Promise.resolve()
+ await callback()
+ })
+ expect(console.error).toHaveBeenCalledTimes(0)
+ expect(callback).toHaveBeenCalledTimes(1)
+})
+
+test('async act recovers from errors', async () => {
+ try {
+ await asyncAct(async () => {
+ await null
+ throw new Error('test error')
+ })
+ } catch (err) {
+ console.error('call console.error')
+ }
+ expect(console.error).toHaveBeenCalledTimes(1)
+ expect(console.error.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ call console.error,
+ ],
+ ]
+ `)
+})
+
+test('async act recovers from sync errors', async () => {
+ try {
+ await asyncAct(() => {
+ throw new Error('test error')
+ })
+ } catch (err) {
+ console.error('call console.error')
+ }
+ expect(console.error).toHaveBeenCalledTimes(1)
+ expect(console.error.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ call console.error,
+ ],
+ ]
+ `)
+})
+
+/* eslint no-console:0 */
diff --git a/src/__tests__/no-act.js b/src/__tests__/no-act.js
deleted file mode 100644
index 7bf6cd6a..00000000
--- a/src/__tests__/no-act.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import {act} from '..'
-
-jest.mock('react-dom/test-utils', () => ({}))
-
-test('act works even when there is no act from test utils', () => {
- const callback = jest.fn()
- act(callback)
- expect(callback).toHaveBeenCalledTimes(1)
-})
diff --git a/src/__tests__/render.js b/src/__tests__/render.js
index 5ee0dc6f..6f5b5b39 100644
--- a/src/__tests__/render.js
+++ b/src/__tests__/render.js
@@ -1,92 +1,299 @@
-import 'jest-dom/extend-expect'
-import React from 'react'
+import * as React from 'react'
import ReactDOM from 'react-dom'
-import {render, cleanup} from '../'
+import ReactDOMServer from 'react-dom/server'
+import {fireEvent, render, screen, configure} from '../'
-afterEach(cleanup)
+const isReact18 = React.version.startsWith('18.')
+const isReact19 = React.version.startsWith('19.')
-test('renders div into document', () => {
- const ref = React.createRef()
- const {container} = render()
- expect(container.firstChild).toBe(ref.current)
-})
+const testGateReact18 = isReact18 ? test : test.skip
+const testGateReact19 = isReact19 ? test : test.skip
-test('works great with react portals', () => {
- class MyPortal extends React.Component {
- constructor(...args) {
- super(...args)
- this.portalNode = document.createElement('div')
- this.portalNode.dataset.testid = 'my-portal'
- }
- componentDidMount() {
- document.body.appendChild(this.portalNode)
- }
- componentWillUnmount() {
- this.portalNode.parentNode.removeChild(this.portalNode)
+describe('render API', () => {
+ let originalConfig
+ beforeEach(() => {
+ // Grab the existing configuration so we can restore
+ // it at the end of the test
+ configure(existingConfig => {
+ originalConfig = existingConfig
+ // Don't change the existing config
+ return {}
+ })
+ })
+
+ afterEach(() => {
+ configure(originalConfig)
+ })
+
+ test('renders div into document', () => {
+ const ref = React.createRef()
+ const {container} = render()
+ expect(container.firstChild).toBe(ref.current)
+ })
+
+ test('works great with react portals', () => {
+ class MyPortal extends React.Component {
+ constructor(...args) {
+ super(...args)
+ this.portalNode = document.createElement('div')
+ this.portalNode.dataset.testid = 'my-portal'
+ }
+ componentDidMount() {
+ document.body.appendChild(this.portalNode)
+ }
+ componentWillUnmount() {
+ this.portalNode.parentNode.removeChild(this.portalNode)
+ }
+ render() {
+ return ReactDOM.createPortal(
+ ,
+ this.portalNode,
+ )
+ }
}
- render() {
- return ReactDOM.createPortal(
- ,
- this.portalNode,
+
+ function Greet({greeting, subject}) {
+ return (
+
+
+ {greeting} {subject}
+
+
)
}
- }
-
- function Greet({greeting, subject}) {
- return (
-
-
- {greeting} {subject}
-
-
+
+ const {unmount} = render( )
+ expect(screen.getByText('Hello World')).toBeInTheDocument()
+ const portalNode = screen.getByTestId('my-portal')
+ expect(portalNode).toBeInTheDocument()
+ unmount()
+ expect(portalNode).not.toBeInTheDocument()
+ })
+
+ test('returns baseElement which defaults to document.body', () => {
+ const {baseElement} = render()
+ expect(baseElement).toBe(document.body)
+ })
+
+ test('supports fragments', () => {
+ class Test extends React.Component {
+ render() {
+ return (
+
+ DocumentFragment
is pretty cool!
+
+ )
+ }
+ }
+
+ const {asFragment} = render( )
+ expect(asFragment()).toMatchSnapshot()
+ })
+
+ test('renders options.wrapper around node', () => {
+ const WrapperComponent = ({children}) => (
+ {children}
)
- }
-
- const {unmount, getByTestId, getByText} = render( )
- expect(getByText('Hello World')).toBeInTheDocument()
- const portalNode = getByTestId('my-portal')
- expect(portalNode).toBeInTheDocument()
- unmount()
- expect(portalNode).not.toBeInTheDocument()
-})
-test('returns baseElement which defaults to document.body', () => {
- const {baseElement} = render()
- expect(baseElement).toBe(document.body)
-})
+ const {container} = render(, {
+ wrapper: WrapperComponent,
+ })
+
+ expect(screen.getByTestId('wrapper')).toBeInTheDocument()
+ expect(container.firstChild).toMatchInlineSnapshot(`
+
+
+
+ `)
+ })
+
+ test('renders options.wrapper around node when reactStrictMode is true', () => {
+ configure({reactStrictMode: true})
-it('cleansup document', () => {
- const spy = jest.fn()
+ const WrapperComponent = ({children}) => (
+ {children}
+ )
+ const {container} = render(, {
+ wrapper: WrapperComponent,
+ })
+
+ expect(screen.getByTestId('wrapper')).toBeInTheDocument()
+ expect(container.firstChild).toMatchInlineSnapshot(`
+
+
+
+ `)
+ })
- class Test extends React.Component {
- componentWillUnmount() {
+ test('renders twice when reactStrictMode is true', () => {
+ configure({reactStrictMode: true})
+
+ const spy = jest.fn()
+ function Component() {
spy()
+ return null
}
- render() {
- return
+ render( )
+ expect(spy).toHaveBeenCalledTimes(2)
+ })
+
+ test('flushes useEffect cleanup functions sync on unmount()', () => {
+ const spy = jest.fn()
+ function Component() {
+ React.useEffect(() => spy, [])
+ return null
}
- }
+ const {unmount} = render( )
+ expect(spy).toHaveBeenCalledTimes(0)
- render( )
- cleanup()
- expect(document.body.innerHTML).toBe('')
- expect(spy).toHaveBeenCalledTimes(1)
-})
+ unmount()
+
+ expect(spy).toHaveBeenCalledTimes(1)
+ })
+
+ test('can be called multiple times on the same container', () => {
+ const container = document.createElement('div')
+
+ const {unmount} = render(, {container})
+
+ expect(container).toContainHTML('')
+
+ render(, {container})
+
+ expect(container).toContainHTML('')
+
+ unmount()
+
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ test('hydrate will make the UI interactive', () => {
+ function App() {
+ const [clicked, handleClick] = React.useReducer(n => n + 1, 0)
-it('supports fragments', () => {
- class Test extends React.Component {
- render() {
return (
-
- DocumentFragment
is pretty cool!
-
+
)
}
- }
+ const ui =
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ container.innerHTML = ReactDOMServer.renderToString(ui)
+
+ expect(container).toHaveTextContent('clicked:0')
+
+ render(ui, {container, hydrate: true})
+
+ fireEvent.click(container.querySelector('button'))
+
+ expect(container).toHaveTextContent('clicked:1')
+ })
+
+ test('hydrate can have a wrapper', () => {
+ const wrapperComponentMountEffect = jest.fn()
+ function WrapperComponent({children}) {
+ React.useEffect(() => {
+ wrapperComponentMountEffect()
+ })
+
+ return children
+ }
+ const ui =
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ container.innerHTML = ReactDOMServer.renderToString(ui)
+
+ render(ui, {container, hydrate: true, wrapper: WrapperComponent})
+
+ expect(wrapperComponentMountEffect).toHaveBeenCalledTimes(1)
+ })
+
+ testGateReact18('legacyRoot uses legacy ReactDOM.render', () => {
+ expect(() => {
+ render(, {legacyRoot: true})
+ }).toErrorDev(
+ [
+ "Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
+ ],
+ {withoutStack: true},
+ )
+ })
+
+ testGateReact19('legacyRoot throws', () => {
+ expect(() => {
+ render(, {legacyRoot: true})
+ }).toThrowErrorMatchingInlineSnapshot(
+ `\`legacyRoot: true\` is not supported in this version of React. If your app runs React 19 or later, you should remove this flag. If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.`,
+ )
+ })
+
+ testGateReact18('legacyRoot uses legacy ReactDOM.hydrate', () => {
+ const ui =
+ const container = document.createElement('div')
+ container.innerHTML = ReactDOMServer.renderToString(ui)
+ expect(() => {
+ render(ui, {container, hydrate: true, legacyRoot: true})
+ }).toErrorDev(
+ [
+ "Warning: ReactDOM.hydrate is no longer supported in React 18. Use hydrateRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
+ ],
+ {withoutStack: true},
+ )
+ })
+
+ testGateReact19('legacyRoot throws even with hydrate', () => {
+ const ui =
+ const container = document.createElement('div')
+ container.innerHTML = ReactDOMServer.renderToString(ui)
+ expect(() => {
+ render(ui, {container, hydrate: true, legacyRoot: true})
+ }).toThrowErrorMatchingInlineSnapshot(
+ `\`legacyRoot: true\` is not supported in this version of React. If your app runs React 19 or later, you should remove this flag. If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.`,
+ )
+ })
+
+ test('reactStrictMode in renderOptions has precedence over config when rendering', () => {
+ const wrapperComponentMountEffect = jest.fn()
+ function WrapperComponent({children}) {
+ React.useEffect(() => {
+ wrapperComponentMountEffect()
+ })
+
+ return children
+ }
+ const ui =
+ configure({reactStrictMode: false})
+
+ render(ui, {wrapper: WrapperComponent, reactStrictMode: true})
+
+ expect(wrapperComponentMountEffect).toHaveBeenCalledTimes(2)
+ })
+
+ test('reactStrictMode in config is used when renderOptions does not specify reactStrictMode', () => {
+ const wrapperComponentMountEffect = jest.fn()
+ function WrapperComponent({children}) {
+ React.useEffect(() => {
+ wrapperComponentMountEffect()
+ })
+
+ return children
+ }
+ const ui =
+ configure({reactStrictMode: true})
+
+ render(ui, {wrapper: WrapperComponent})
- const {asFragment} = render( )
- expect(asFragment()).toMatchSnapshot()
- cleanup()
- expect(document.body.innerHTML).toBe('')
+ expect(wrapperComponentMountEffect).toHaveBeenCalledTimes(2)
+ })
})
diff --git a/src/__tests__/renderHook.js b/src/__tests__/renderHook.js
new file mode 100644
index 00000000..f331e90e
--- /dev/null
+++ b/src/__tests__/renderHook.js
@@ -0,0 +1,141 @@
+import React, {useEffect} from 'react'
+import {configure, renderHook} from '../pure'
+
+const isReact18 = React.version.startsWith('18.')
+const isReact19 = React.version.startsWith('19.')
+
+const testGateReact18 = isReact18 ? test : test.skip
+const testGateReact19 = isReact19 ? test : test.skip
+
+test('gives committed result', () => {
+ const {result} = renderHook(() => {
+ const [state, setState] = React.useState(1)
+
+ React.useEffect(() => {
+ setState(2)
+ }, [])
+
+ return [state, setState]
+ })
+
+ expect(result.current).toEqual([2, expect.any(Function)])
+})
+
+test('allows rerendering', () => {
+ const {result, rerender} = renderHook(
+ ({branch}) => {
+ const [left, setLeft] = React.useState('left')
+ const [right, setRight] = React.useState('right')
+
+ // eslint-disable-next-line jest/no-if, jest/no-conditional-in-test -- false-positive
+ switch (branch) {
+ case 'left':
+ return [left, setLeft]
+ case 'right':
+ return [right, setRight]
+
+ default:
+ throw new Error(
+ 'No Props passed. This is a bug in the implementation',
+ )
+ }
+ },
+ {initialProps: {branch: 'left'}},
+ )
+
+ expect(result.current).toEqual(['left', expect.any(Function)])
+
+ rerender({branch: 'right'})
+
+ expect(result.current).toEqual(['right', expect.any(Function)])
+})
+
+test('allows wrapper components', async () => {
+ const Context = React.createContext('default')
+ function Wrapper({children}) {
+ return {children}
+ }
+ const {result} = renderHook(
+ () => {
+ return React.useContext(Context)
+ },
+ {
+ wrapper: Wrapper,
+ },
+ )
+
+ expect(result.current).toEqual('provided')
+})
+
+testGateReact18('legacyRoot uses legacy ReactDOM.render', () => {
+ const Context = React.createContext('default')
+ function Wrapper({children}) {
+ return {children}
+ }
+ let result
+ expect(() => {
+ result = renderHook(
+ () => {
+ return React.useContext(Context)
+ },
+ {
+ wrapper: Wrapper,
+ legacyRoot: true,
+ },
+ ).result
+ }).toErrorDev(
+ [
+ "Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
+ ],
+ {withoutStack: true},
+ )
+ expect(result.current).toEqual('provided')
+})
+
+testGateReact19('legacyRoot throws', () => {
+ const Context = React.createContext('default')
+ function Wrapper({children}) {
+ return {children}
+ }
+ expect(() => {
+ renderHook(
+ () => {
+ return React.useContext(Context)
+ },
+ {
+ wrapper: Wrapper,
+ legacyRoot: true,
+ },
+ ).result
+ }).toThrowErrorMatchingInlineSnapshot(
+ `\`legacyRoot: true\` is not supported in this version of React. If your app runs React 19 or later, you should remove this flag. If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.`,
+ )
+})
+
+describe('reactStrictMode', () => {
+ let originalConfig
+ beforeEach(() => {
+ // Grab the existing configuration so we can restore
+ // it at the end of the test
+ configure(existingConfig => {
+ originalConfig = existingConfig
+ // Don't change the existing config
+ return {}
+ })
+ })
+
+ afterEach(() => {
+ configure(originalConfig)
+ })
+
+ test('reactStrictMode in renderOptions has precedence over config when rendering', () => {
+ const hookMountEffect = jest.fn()
+ configure({reactStrictMode: false})
+
+ renderHook(() => useEffect(() => hookMountEffect()), {
+ reactStrictMode: true,
+ })
+
+ expect(hookMountEffect).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/src/__tests__/rerender.js b/src/__tests__/rerender.js
index a35144b5..6c48c4dd 100644
--- a/src/__tests__/rerender.js
+++ b/src/__tests__/rerender.js
@@ -1,35 +1,98 @@
-import React from 'react'
-import {render, cleanup} from '../'
-import 'jest-dom/extend-expect'
-
-afterEach(cleanup)
-
-test('rerender will re-render the element', () => {
- const Greeting = props => {props.message}
- const {container, rerender} = render( )
- expect(container.firstChild).toHaveTextContent('hi')
- rerender( )
- expect(container.firstChild).toHaveTextContent('hey')
-})
+import * as React from 'react'
+import {render, configure} from '../'
+
+describe('rerender API', () => {
+ let originalConfig
+ beforeEach(() => {
+ // Grab the existing configuration so we can restore
+ // it at the end of the test
+ configure(existingConfig => {
+ originalConfig = existingConfig
+ // Don't change the existing config
+ return {}
+ })
+ })
+
+ afterEach(() => {
+ configure(originalConfig)
+ })
+
+ test('rerender will re-render the element', () => {
+ const Greeting = props => {props.message}
+ const {container, rerender} = render( )
+ expect(container.firstChild).toHaveTextContent('hi')
+ rerender( )
+ expect(container.firstChild).toHaveTextContent('hey')
+ })
+
+ test('hydrate will not update props until next render', () => {
+ const initialInputElement = document.createElement('input')
+ const container = document.createElement('div')
+ container.appendChild(initialInputElement)
+ document.body.appendChild(container)
+
+ const firstValue = 'hello'
+ initialInputElement.value = firstValue
-test('hydrate will not update props until next render', () => {
- const initial = ''
+ const {rerender} = render( null} />, {
+ container,
+ hydrate: true,
+ })
- const container = document.body.appendChild(document.createElement('div'))
- container.innerHTML = initial
- const input = container.querySelector('input')
- const firstValue = 'hello'
+ expect(initialInputElement).toHaveValue(firstValue)
- if (!input) throw new Error('No element')
- input.value = firstValue
- const {rerender} = render( null} />, {
- container,
- hydrate: true,
+ const secondValue = 'goodbye'
+ rerender( null} />)
+ expect(initialInputElement).toHaveValue(secondValue)
})
- const secondValue = 'goodbye'
+ test('re-renders options.wrapper around node when reactStrictMode is true', () => {
+ configure({reactStrictMode: true})
- expect(input.value).toBe(firstValue)
- rerender( null} />)
- expect(input.value).toBe(secondValue)
+ const WrapperComponent = ({children}) => (
+ {children}
+ )
+ const Greeting = props => {props.message}
+ const {container, rerender} = render( , {
+ wrapper: WrapperComponent,
+ })
+
+ expect(container.firstChild).toMatchInlineSnapshot(`
+
+
+ hi
+
+
+ `)
+
+ rerender( )
+ expect(container.firstChild).toMatchInlineSnapshot(`
+
+
+ hey
+
+
+ `)
+ })
+
+ test('re-renders twice when reactStrictMode is true', () => {
+ configure({reactStrictMode: true})
+
+ const spy = jest.fn()
+ function Component() {
+ spy()
+ return null
+ }
+
+ const {rerender} = render( )
+ expect(spy).toHaveBeenCalledTimes(2)
+
+ spy.mockClear()
+ rerender( )
+ expect(spy).toHaveBeenCalledTimes(2)
+ })
})
diff --git a/src/__tests__/stopwatch.js b/src/__tests__/stopwatch.js
index 34f58561..e3eaebbe 100644
--- a/src/__tests__/stopwatch.js
+++ b/src/__tests__/stopwatch.js
@@ -1,7 +1,5 @@
-import React from 'react'
-import {render, cleanup, fireEvent} from '../'
-
-afterEach(cleanup)
+import * as React from 'react'
+import {render, fireEvent, screen} from '../'
class StopWatch extends React.Component {
state = {lapse: 0, running: false}
@@ -39,20 +37,18 @@ class StopWatch extends React.Component {
}
}
-const wait = time => new Promise(resolve => setTimeout(resolve, time))
+const sleep = t => new Promise(resolve => setTimeout(resolve, t))
test('unmounts a component', async () => {
- jest.spyOn(console, 'error').mockImplementation(() => {})
- const {unmount, getByText, container} = render( )
- fireEvent.click(getByText('Start'))
+ const {unmount, container} = render( )
+ fireEvent.click(screen.getByText('Start'))
unmount()
// hey there reader! You don't need to have an assertion like this one
// this is just me making sure that the unmount function works.
// You don't need to do this in your apps. Just rely on the fact that this works.
- expect(container.innerHTML).toBe('')
+ expect(container).toBeEmptyDOMElement()
// just wait to see if the interval is cleared or not
// if it's not, then we'll call setState on an unmounted component
// and get an error.
- // eslint-disable-next-line no-console
- await wait(() => expect(console.error).not.toHaveBeenCalled())
+ await sleep(5)
})
diff --git a/src/__tests__/test-hook.js b/src/__tests__/test-hook.js
deleted file mode 100644
index 4b54bfb8..00000000
--- a/src/__tests__/test-hook.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import React, {useState, useEffect} from 'react'
-import 'jest-dom/extend-expect'
-import {testHook, cleanup, act} from '../'
-
-afterEach(cleanup)
-
-test('testHook calls the callback', () => {
- const spy = jest.fn()
- testHook(spy)
- expect(spy).toHaveBeenCalledTimes(1)
-})
-test('confirm we can safely call a React Hook from within the callback', () => {
- testHook(() => useState())
-})
-test('returns a function to unmount component', () => {
- let isMounted
- const {unmount} = testHook(() => {
- useEffect(() => {
- isMounted = true
- return () => {
- isMounted = false
- }
- })
- })
- expect(isMounted).toBe(true)
- unmount()
- expect(isMounted).toBe(false)
-})
-test('returns a function to rerender component', () => {
- let renderCount = 0
- const {rerender} = testHook(() => {
- useEffect(() => {
- renderCount++
- })
- })
-
- expect(renderCount).toBe(1)
- rerender()
- expect(renderCount).toBe(2)
-})
-test('accepts wrapper option to wrap rendered hook with', () => {
- const ctxA = React.createContext()
- const ctxB = React.createContext()
- const useHook = () => {
- return React.useContext(ctxA) * React.useContext(ctxB)
- }
- let actual
- testHook(
- () => {
- actual = useHook()
- },
- {
- // eslint-disable-next-line react/display-name
- wrapper: props => (
-
-
-
- ),
- },
- )
- expect(actual).toBe(12)
-})
-test('returns result ref with latest result from hook execution', () => {
- function useCounter({initialCount = 0, step = 1} = {}) {
- const [count, setCount] = React.useState(initialCount)
- const increment = () => setCount(c => c + step)
- const decrement = () => setCount(c => c - step)
- return {count, increment, decrement}
- }
-
- const {result} = testHook(useCounter)
- expect(result.current.count).toBe(0)
- act(() => {
- result.current.increment()
- })
- expect(result.current.count).toBe(1)
-})
diff --git a/src/act-compat.js b/src/act-compat.js
index 8b76d157..6eaec0fb 100644
--- a/src/act-compat.js
+++ b/src/act-compat.js
@@ -1,20 +1,91 @@
-import React from 'react'
-import ReactDOM from 'react-dom'
-import {act as reactAct} from 'react-dom/test-utils'
-
-// act is supported react-dom@16.8.0
-// so for versions that don't have act from test utils
-// we do this little polyfill. No warnings, but it's
-// better than nothing.
-function actPolyfill(cb) {
- ReactDOM.unstable_batchedUpdates(cb)
- ReactDOM.render(, document.createElement('div'))
+import * as React from 'react'
+import * as DeprecatedReactTestUtils from 'react-dom/test-utils'
+
+const reactAct =
+ typeof React.act === 'function' ? React.act : DeprecatedReactTestUtils.act
+
+function getGlobalThis() {
+ /* istanbul ignore else */
+ if (typeof globalThis !== 'undefined') {
+ return globalThis
+ }
+ /* istanbul ignore next */
+ if (typeof self !== 'undefined') {
+ return self
+ }
+ /* istanbul ignore next */
+ if (typeof window !== 'undefined') {
+ return window
+ }
+ /* istanbul ignore next */
+ if (typeof global !== 'undefined') {
+ return global
+ }
+ /* istanbul ignore next */
+ throw new Error('unable to locate global object')
+}
+
+function setIsReactActEnvironment(isReactActEnvironment) {
+ getGlobalThis().IS_REACT_ACT_ENVIRONMENT = isReactActEnvironment
+}
+
+function getIsReactActEnvironment() {
+ return getGlobalThis().IS_REACT_ACT_ENVIRONMENT
+}
+
+function withGlobalActEnvironment(actImplementation) {
+ return callback => {
+ const previousActEnvironment = getIsReactActEnvironment()
+ setIsReactActEnvironment(true)
+ try {
+ // The return value of `act` is always a thenable.
+ let callbackNeedsToBeAwaited = false
+ const actResult = actImplementation(() => {
+ const result = callback()
+ if (
+ result !== null &&
+ typeof result === 'object' &&
+ typeof result.then === 'function'
+ ) {
+ callbackNeedsToBeAwaited = true
+ }
+ return result
+ })
+ if (callbackNeedsToBeAwaited) {
+ const thenable = actResult
+ return {
+ then: (resolve, reject) => {
+ thenable.then(
+ returnValue => {
+ setIsReactActEnvironment(previousActEnvironment)
+ resolve(returnValue)
+ },
+ error => {
+ setIsReactActEnvironment(previousActEnvironment)
+ reject(error)
+ },
+ )
+ },
+ }
+ } else {
+ setIsReactActEnvironment(previousActEnvironment)
+ return actResult
+ }
+ } catch (error) {
+ // Can't be a `finally {}` block since we don't know if we have to immediately restore IS_REACT_ACT_ENVIRONMENT
+ // or if we have to await the callback first.
+ setIsReactActEnvironment(previousActEnvironment)
+ throw error
+ }
+ }
}
-const act = reactAct || actPolyfill
+const act = withGlobalActEnvironment(reactAct)
-function rtlAct(...args) {
- return act(...args)
+export default act
+export {
+ setIsReactActEnvironment as setReactActEnvironment,
+ getIsReactActEnvironment,
}
-export default rtlAct
+/* eslint no-console:0 */
diff --git a/src/config.js b/src/config.js
new file mode 100644
index 00000000..dc8a5035
--- /dev/null
+++ b/src/config.js
@@ -0,0 +1,34 @@
+import {
+ getConfig as getConfigDTL,
+ configure as configureDTL,
+} from '@testing-library/dom'
+
+let configForRTL = {
+ reactStrictMode: false,
+}
+
+function getConfig() {
+ return {
+ ...getConfigDTL(),
+ ...configForRTL,
+ }
+}
+
+function configure(newConfig) {
+ if (typeof newConfig === 'function') {
+ // Pass the existing config out to the provided function
+ // and accept a delta in return
+ newConfig = newConfig(getConfig())
+ }
+
+ const {reactStrictMode, ...configForDTL} = newConfig
+
+ configureDTL(configForDTL)
+
+ configForRTL = {
+ ...configForRTL,
+ reactStrictMode,
+ }
+}
+
+export {getConfig, configure}
diff --git a/src/fire-event.js b/src/fire-event.js
new file mode 100644
index 00000000..cb790c7f
--- /dev/null
+++ b/src/fire-event.js
@@ -0,0 +1,69 @@
+import {fireEvent as dtlFireEvent} from '@testing-library/dom'
+
+// react-testing-library's version of fireEvent will call
+// dom-testing-library's version of fireEvent. The reason
+// we make this distinction however is because we have
+// a few extra events that work a bit differently
+const fireEvent = (...args) => dtlFireEvent(...args)
+
+Object.keys(dtlFireEvent).forEach(key => {
+ fireEvent[key] = (...args) => dtlFireEvent[key](...args)
+})
+
+// React event system tracks native mouseOver/mouseOut events for
+// running onMouseEnter/onMouseLeave handlers
+// @link https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/react-dom/src/events/EnterLeaveEventPlugin.js#L24-L31
+const mouseEnter = fireEvent.mouseEnter
+const mouseLeave = fireEvent.mouseLeave
+fireEvent.mouseEnter = (...args) => {
+ mouseEnter(...args)
+ return fireEvent.mouseOver(...args)
+}
+fireEvent.mouseLeave = (...args) => {
+ mouseLeave(...args)
+ return fireEvent.mouseOut(...args)
+}
+
+const pointerEnter = fireEvent.pointerEnter
+const pointerLeave = fireEvent.pointerLeave
+fireEvent.pointerEnter = (...args) => {
+ pointerEnter(...args)
+ return fireEvent.pointerOver(...args)
+}
+fireEvent.pointerLeave = (...args) => {
+ pointerLeave(...args)
+ return fireEvent.pointerOut(...args)
+}
+
+const select = fireEvent.select
+fireEvent.select = (node, init) => {
+ select(node, init)
+ // React tracks this event only on focused inputs
+ node.focus()
+
+ // React creates this event when one of the following native events happens
+ // - contextMenu
+ // - mouseUp
+ // - dragEnd
+ // - keyUp
+ // - keyDown
+ // so we can use any here
+ // @link https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/react-dom/src/events/SelectEventPlugin.js#L203-L224
+ fireEvent.keyUp(node, init)
+}
+
+// React event system tracks native focusout/focusin events for
+// running blur/focus handlers
+// @link https://github.com/facebook/react/pull/19186
+const blur = fireEvent.blur
+const focus = fireEvent.focus
+fireEvent.blur = (...args) => {
+ fireEvent.focusOut(...args)
+ return blur(...args)
+}
+fireEvent.focus = (...args) => {
+ fireEvent.focusIn(...args)
+ return focus(...args)
+}
+
+export {fireEvent}
diff --git a/src/index.js b/src/index.js
index 31f99339..bb0d0270 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,158 +1,41 @@
-import React from 'react'
-import ReactDOM from 'react-dom'
-import {
- getQueriesForElement,
- prettyDOM,
- fireEvent as dtlFireEvent,
-} from 'dom-testing-library'
-import act from './act-compat'
-
-const mountedContainers = new Set()
-
-function render(
- ui,
- {container, baseElement = container, queries, hydrate = false} = {},
-) {
- if (!container) {
- // default to document.body instead of documentElement to avoid output of potentially-large
- // head elements (such as JSS style blocks) in debug output
- baseElement = document.body
- container = document.body.appendChild(document.createElement('div'))
- }
-
- // we'll add it to the mounted containers regardless of whether it's actually
- // added to document.body so the cleanup method works regardless of whether
- // they're passing us a custom container or not.
- mountedContainers.add(container)
-
- if (hydrate) {
- act(() => {
- ReactDOM.hydrate(ui, container)
+import {getIsReactActEnvironment, setReactActEnvironment} from './act-compat'
+import {cleanup} from './pure'
+
+// if we're running in a test runner that supports afterEach
+// or teardown then we'll automatically run cleanup afterEach test
+// this ensures that tests run in isolation from each other
+// if you don't like this then either import the `pure` module
+// or set the RTL_SKIP_AUTO_CLEANUP env variable to 'true'.
+if (typeof process === 'undefined' || !process.env?.RTL_SKIP_AUTO_CLEANUP) {
+ // ignore teardown() in code coverage because Jest does not support it
+ /* istanbul ignore else */
+ if (typeof afterEach === 'function') {
+ afterEach(() => {
+ cleanup()
})
- } else {
- act(() => {
- ReactDOM.render(ui, container)
+ } else if (typeof teardown === 'function') {
+ // Block is guarded by `typeof` check.
+ // eslint does not support `typeof` guards.
+ // eslint-disable-next-line no-undef
+ teardown(() => {
+ cleanup()
})
}
- return {
- container,
- baseElement,
- // eslint-disable-next-line no-console
- debug: (el = baseElement) => console.log(prettyDOM(el)),
- unmount: () => ReactDOM.unmountComponentAtNode(container),
- rerender: rerenderUi => {
- render(rerenderUi, {container, baseElement})
- // Intentionally do not return anything to avoid unnecessarily complicating the API.
- // folks can use all the same utilities we return in the first place that are bound to the container
- },
- asFragment: () => {
- /* istanbul ignore if (jsdom limitation) */
- if (typeof document.createRange === 'function') {
- return document
- .createRange()
- .createContextualFragment(container.innerHTML)
- }
-
- const template = document.createElement('template')
- template.innerHTML = container.innerHTML
- return template.content
- },
- ...getQueriesForElement(baseElement, queries),
- }
-}
-
-function TestHook({callback, children}) {
- children(callback())
- return null
-}
-
-function testHook(callback, options = {}) {
- const result = {
- current: null,
- }
- const toRender = () => {
- const hookRender = (
-
- {res => {
- result.current = res
- }}
-
- )
- if (options.wrapper) {
- return React.createElement(options.wrapper, null, hookRender)
- }
- return hookRender
- }
- const {unmount, rerender: rerenderComponent} = render(toRender())
- return {
- result,
- unmount,
- rerender: () => {
- rerenderComponent(toRender())
- },
- }
-}
-
-function cleanup() {
- mountedContainers.forEach(cleanupAtContainer)
-}
-
-// maybe one day we'll expose this (perhaps even as a utility returned by render).
-// but let's wait until someone asks for it.
-function cleanupAtContainer(container) {
- if (container.parentNode === document.body) {
- document.body.removeChild(container)
- }
- ReactDOM.unmountComponentAtNode(container)
- mountedContainers.delete(container)
-}
-// react-testing-library's version of fireEvent will call
-// dom-testing-library's version of fireEvent wrapped inside
-// an "act" call so that after all event callbacks have been
-// been called, the resulting useEffect callbacks will also
-// be called.
-function fireEvent(...args) {
- let returnValue
- act(() => {
- returnValue = dtlFireEvent(...args)
- })
- return returnValue
-}
+ // No test setup with other test runners available
+ /* istanbul ignore else */
+ if (typeof beforeAll === 'function' && typeof afterAll === 'function') {
+ // This matches the behavior of React < 18.
+ let previousIsReactActEnvironment = getIsReactActEnvironment()
+ beforeAll(() => {
+ previousIsReactActEnvironment = getIsReactActEnvironment()
+ setReactActEnvironment(true)
+ })
-Object.keys(dtlFireEvent).forEach(key => {
- fireEvent[key] = (...args) => {
- let returnValue
- act(() => {
- returnValue = dtlFireEvent[key](...args)
+ afterAll(() => {
+ setReactActEnvironment(previousIsReactActEnvironment)
})
- return returnValue
}
-})
-
-// React event system tracks native mouseOver/mouseOut events for
-// running onMouseEnter/onMouseLeave handlers
-// @link https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/react-dom/src/events/EnterLeaveEventPlugin.js#L24-L31
-fireEvent.mouseEnter = fireEvent.mouseOver
-fireEvent.mouseLeave = fireEvent.mouseOut
-
-fireEvent.select = (node, init) => {
- // React tracks this event only on focused inputs
- node.focus()
-
- // React creates this event when one of the following native events happens
- // - contextMenu
- // - mouseUp
- // - dragEnd
- // - keyUp
- // - keyDown
- // so we can use any here
- // @link https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/react-dom/src/events/SelectEventPlugin.js#L203-L224
- fireEvent.keyUp(node, init)
}
-// just re-export everything from dom-testing-library
-export * from 'dom-testing-library'
-export {render, testHook, cleanup, fireEvent, act}
-
-/* eslint func-name-matching:0 */
+export * from './pure'
diff --git a/src/pure.js b/src/pure.js
new file mode 100644
index 00000000..0f9c487d
--- /dev/null
+++ b/src/pure.js
@@ -0,0 +1,363 @@
+import * as React from 'react'
+import ReactDOM from 'react-dom'
+import * as ReactDOMClient from 'react-dom/client'
+import {
+ getQueriesForElement,
+ prettyDOM,
+ configure as configureDTL,
+} from '@testing-library/dom'
+import act, {
+ getIsReactActEnvironment,
+ setReactActEnvironment,
+} from './act-compat'
+import {fireEvent} from './fire-event'
+import {getConfig, configure} from './config'
+
+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)
+ },
+ // We just want to run `waitFor` without IS_REACT_ACT_ENVIRONMENT
+ // But that's not necessarily how `asyncWrapper` is used since it's a public method.
+ // Let's just hope nobody else is using it.
+ asyncWrapper: async cb => {
+ const previousActEnvironment = getIsReactActEnvironment()
+ setReactActEnvironment(false)
+ try {
+ 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)
+ }
+ },
+ eventWrapper: cb => {
+ let result
+ act(() => {
+ result = cb()
+ })
+ return result
+ },
+})
+
+// Ideally we'd just use a WeakMap where containers are keys and roots are values.
+// We use two variables so that we can bail out in constant time when we render with a new container (most common use case)
+/**
+ * @type {Set}
+ */
+const mountedContainers = new Set()
+/**
+ * @type Array<{container: import('react-dom').Container, root: ReturnType}>
+ */
+const mountedRootEntries = []
+
+function strictModeIfNeeded(innerElement, reactStrictMode) {
+ return reactStrictMode ?? getConfig().reactStrictMode
+ ? React.createElement(React.StrictMode, null, innerElement)
+ : innerElement
+}
+
+function wrapUiIfNeeded(innerElement, wrapperComponent) {
+ return wrapperComponent
+ ? React.createElement(wrapperComponent, null, innerElement)
+ : innerElement
+}
+
+function createConcurrentRoot(
+ container,
+ {
+ hydrate,
+ onCaughtError,
+ onRecoverableError,
+ ui,
+ wrapper: WrapperComponent,
+ reactStrictMode,
+ },
+) {
+ let root
+ if (hydrate) {
+ act(() => {
+ root = ReactDOMClient.hydrateRoot(
+ container,
+ strictModeIfNeeded(
+ wrapUiIfNeeded(ui, WrapperComponent),
+ reactStrictMode,
+ ),
+ {onCaughtError, onRecoverableError},
+ )
+ })
+ } else {
+ root = ReactDOMClient.createRoot(container, {
+ onCaughtError,
+ onRecoverableError,
+ })
+ }
+
+ return {
+ hydrate() {
+ /* istanbul ignore if */
+ if (!hydrate) {
+ throw new Error(
+ 'Attempted to hydrate a non-hydrateable root. This is a bug in `@testing-library/react`.',
+ )
+ }
+ // Nothing to do since hydration happens when creating the root object.
+ },
+ render(element) {
+ root.render(element)
+ },
+ unmount() {
+ root.unmount()
+ },
+ }
+}
+
+function createLegacyRoot(container) {
+ return {
+ hydrate(element) {
+ ReactDOM.hydrate(element, container)
+ },
+ render(element) {
+ ReactDOM.render(element, container)
+ },
+ unmount() {
+ ReactDOM.unmountComponentAtNode(container)
+ },
+ }
+}
+
+function renderRoot(
+ ui,
+ {
+ baseElement,
+ container,
+ hydrate,
+ queries,
+ root,
+ wrapper: WrapperComponent,
+ reactStrictMode,
+ },
+) {
+ act(() => {
+ if (hydrate) {
+ root.hydrate(
+ strictModeIfNeeded(
+ wrapUiIfNeeded(ui, WrapperComponent),
+ reactStrictMode,
+ ),
+ container,
+ )
+ } else {
+ root.render(
+ strictModeIfNeeded(
+ wrapUiIfNeeded(ui, WrapperComponent),
+ reactStrictMode,
+ ),
+ container,
+ )
+ }
+ })
+
+ return {
+ container,
+ baseElement,
+ debug: (el = baseElement, maxLength, options) =>
+ Array.isArray(el)
+ ? // eslint-disable-next-line no-console
+ el.forEach(e => console.log(prettyDOM(e, maxLength, options)))
+ : // eslint-disable-next-line no-console,
+ console.log(prettyDOM(el, maxLength, options)),
+ unmount: () => {
+ act(() => {
+ root.unmount()
+ })
+ },
+ rerender: rerenderUi => {
+ renderRoot(rerenderUi, {
+ container,
+ baseElement,
+ root,
+ wrapper: WrapperComponent,
+ reactStrictMode,
+ })
+ // Intentionally do not return anything to avoid unnecessarily complicating the API.
+ // folks can use all the same utilities we return in the first place that are bound to the container
+ },
+ asFragment: () => {
+ /* istanbul ignore else (old jsdom limitation) */
+ if (typeof document.createRange === 'function') {
+ return document
+ .createRange()
+ .createContextualFragment(container.innerHTML)
+ } else {
+ const template = document.createElement('template')
+ template.innerHTML = container.innerHTML
+ return template.content
+ }
+ },
+ ...getQueriesForElement(baseElement, queries),
+ }
+}
+
+function render(
+ ui,
+ {
+ container,
+ baseElement = container,
+ legacyRoot = false,
+ onCaughtError,
+ onUncaughtError,
+ onRecoverableError,
+ queries,
+ hydrate = false,
+ wrapper,
+ reactStrictMode,
+ } = {},
+) {
+ if (onUncaughtError !== undefined) {
+ throw new Error(
+ 'onUncaughtError is not supported. The `render` call will already throw on uncaught errors.',
+ )
+ }
+ if (legacyRoot && typeof ReactDOM.render !== 'function') {
+ const error = new Error(
+ '`legacyRoot: true` is not supported in this version of React. ' +
+ 'If your app runs React 19 or later, you should remove this flag. ' +
+ 'If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.',
+ )
+ Error.captureStackTrace(error, render)
+ throw error
+ }
+
+ if (!baseElement) {
+ // default to document.body instead of documentElement to avoid output of potentially-large
+ // head elements (such as JSS style blocks) in debug output
+ baseElement = document.body
+ }
+ if (!container) {
+ container = baseElement.appendChild(document.createElement('div'))
+ }
+
+ let root
+ // eslint-disable-next-line no-negated-condition -- we want to map the evolution of this over time. The root is created first. Only later is it re-used so we don't want to read the case that happens later first.
+ if (!mountedContainers.has(container)) {
+ const createRootImpl = legacyRoot ? createLegacyRoot : createConcurrentRoot
+ root = createRootImpl(container, {
+ hydrate,
+ onCaughtError,
+ onRecoverableError,
+ ui,
+ wrapper,
+ reactStrictMode,
+ })
+
+ mountedRootEntries.push({container, root})
+ // we'll add it to the mounted containers regardless of whether it's actually
+ // added to document.body so the cleanup method works regardless of whether
+ // they're passing us a custom container or not.
+ mountedContainers.add(container)
+ } else {
+ mountedRootEntries.forEach(rootEntry => {
+ // Else is unreachable since `mountedContainers` has the `container`.
+ // Only reachable if one would accidentally add the container to `mountedContainers` but not the root to `mountedRootEntries`
+ /* istanbul ignore else */
+ if (rootEntry.container === container) {
+ root = rootEntry.root
+ }
+ })
+ }
+
+ return renderRoot(ui, {
+ container,
+ baseElement,
+ queries,
+ hydrate,
+ wrapper,
+ root,
+ reactStrictMode,
+ })
+}
+
+function cleanup() {
+ mountedRootEntries.forEach(({root, container}) => {
+ act(() => {
+ root.unmount()
+ })
+ if (container.parentNode === document.body) {
+ document.body.removeChild(container)
+ }
+ })
+ mountedRootEntries.length = 0
+ mountedContainers.clear()
+}
+
+function renderHook(renderCallback, options = {}) {
+ const {initialProps, ...renderOptions} = options
+
+ if (renderOptions.legacyRoot && typeof ReactDOM.render !== 'function') {
+ const error = new Error(
+ '`legacyRoot: true` is not supported in this version of React. ' +
+ 'If your app runs React 19 or later, you should remove this flag. ' +
+ 'If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.',
+ )
+ Error.captureStackTrace(error, renderHook)
+ throw error
+ }
+
+ const result = React.createRef()
+
+ function TestComponent({renderCallbackProps}) {
+ const pendingResult = renderCallback(renderCallbackProps)
+
+ React.useEffect(() => {
+ result.current = pendingResult
+ })
+
+ return null
+ }
+
+ const {rerender: baseRerender, unmount} = render(
+ ,
+ renderOptions,
+ )
+
+ function rerender(rerenderCallbackProps) {
+ return baseRerender(
+ ,
+ )
+ }
+
+ return {result, rerender, unmount}
+}
+
+// just re-export everything from dom-testing-library
+export * from '@testing-library/dom'
+export {render, renderHook, cleanup, act, fireEvent, getConfig, configure}
+
+/* eslint func-name-matching:0 */
diff --git a/tests/failOnUnexpectedConsoleCalls.js b/tests/failOnUnexpectedConsoleCalls.js
new file mode 100644
index 00000000..83e0c641
--- /dev/null
+++ b/tests/failOnUnexpectedConsoleCalls.js
@@ -0,0 +1,129 @@
+// Fork of https://github.com/facebook/react/blob/513417d6951fa3ff5729302b7990b84604b11afa/scripts/jest/setupTests.js#L71-L161
+/**
+MIT License
+
+Copyright (c) Facebook, Inc. and its affiliates.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ */
+/* eslint-disable prefer-template */
+/* eslint-disable func-names */
+const util = require('util')
+const chalk = require('chalk')
+const shouldIgnoreConsoleError = require('./shouldIgnoreConsoleError')
+
+const patchConsoleMethod = (methodName, unexpectedConsoleCallStacks) => {
+ const newMethod = function (format, ...args) {
+ // Ignore uncaught errors reported by jsdom
+ // and React addendums because they're too noisy.
+ if (methodName === 'error' && shouldIgnoreConsoleError(format, args)) {
+ return
+ }
+
+ // Capture the call stack now so we can warn about it later.
+ // The call stack has helpful information for the test author.
+ // Don't throw yet though b'c it might be accidentally caught and suppressed.
+ const stack = new Error().stack
+ unexpectedConsoleCallStacks.push([
+ stack.substr(stack.indexOf('\n') + 1),
+ util.format(format, ...args),
+ ])
+ }
+
+ console[methodName] = newMethod
+
+ return newMethod
+}
+
+const isSpy = spy =>
+ (spy.calls && typeof spy.calls.count === 'function') ||
+ spy._isMockFunction === true
+
+const flushUnexpectedConsoleCalls = (
+ mockMethod,
+ methodName,
+ expectedMatcher,
+ unexpectedConsoleCallStacks,
+) => {
+ if (console[methodName] !== mockMethod && !isSpy(console[methodName])) {
+ throw new Error(
+ `Test did not tear down console.${methodName} mock properly.`,
+ )
+ }
+ if (unexpectedConsoleCallStacks.length > 0) {
+ const messages = unexpectedConsoleCallStacks.map(
+ ([stack, message]) =>
+ `${chalk.red(message)}\n` +
+ `${stack
+ .split('\n')
+ .map(line => chalk.gray(line))
+ .join('\n')}`,
+ )
+
+ const message =
+ `Expected test not to call ${chalk.bold(
+ `console.${methodName}()`,
+ )}.\n\n` +
+ 'If the warning is expected, test for it explicitly by:\n' +
+ `1. Using the ${chalk.bold('.' + expectedMatcher + '()')} ` +
+ `matcher, or...\n` +
+ `2. Mock it out using ${chalk.bold(
+ 'spyOnDev',
+ )}(console, '${methodName}') or ${chalk.bold(
+ 'spyOnProd',
+ )}(console, '${methodName}'), and test that the warning occurs.`
+
+ throw new Error(`${message}\n\n${messages.join('\n\n')}`)
+ }
+}
+
+const unexpectedErrorCallStacks = []
+const unexpectedWarnCallStacks = []
+
+const errorMethod = patchConsoleMethod('error', unexpectedErrorCallStacks)
+const warnMethod = patchConsoleMethod('warn', unexpectedWarnCallStacks)
+
+const flushAllUnexpectedConsoleCalls = () => {
+ flushUnexpectedConsoleCalls(
+ errorMethod,
+ 'error',
+ 'toErrorDev',
+ unexpectedErrorCallStacks,
+ )
+ flushUnexpectedConsoleCalls(
+ warnMethod,
+ 'warn',
+ 'toWarnDev',
+ unexpectedWarnCallStacks,
+ )
+ unexpectedErrorCallStacks.length = 0
+ unexpectedWarnCallStacks.length = 0
+}
+
+const resetAllUnexpectedConsoleCalls = () => {
+ unexpectedErrorCallStacks.length = 0
+ unexpectedWarnCallStacks.length = 0
+}
+
+expect.extend({
+ ...require('./toWarnDev'),
+})
+
+beforeEach(resetAllUnexpectedConsoleCalls)
+afterEach(flushAllUnexpectedConsoleCalls)
diff --git a/tests/setup-env.js b/tests/setup-env.js
new file mode 100644
index 00000000..1a4401de
--- /dev/null
+++ b/tests/setup-env.js
@@ -0,0 +1,9 @@
+import '@testing-library/jest-dom/extend-expect'
+import './failOnUnexpectedConsoleCalls'
+import {TextEncoder} from 'util'
+import {MessageChannel} from 'worker_threads'
+
+global.TextEncoder = TextEncoder
+// TODO: Revisit once https://github.com/jsdom/jsdom/issues/2448 is resolved
+// This isn't perfect but good enough.
+global.MessageChannel = MessageChannel
diff --git a/tests/shouldIgnoreConsoleError.js b/tests/shouldIgnoreConsoleError.js
new file mode 100644
index 00000000..1c722ba1
--- /dev/null
+++ b/tests/shouldIgnoreConsoleError.js
@@ -0,0 +1,52 @@
+// Fork of https://github.com/facebook/react/blob/513417d6951fa3ff5729302b7990b84604b11afa/scripts/jest/shouldIgnoreConsoleError.js
+/**
+MIT License
+
+Copyright (c) Facebook, Inc. and its affiliates.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ */
+
+module.exports = function shouldIgnoreConsoleError(format) {
+ if (process.env.NODE_ENV !== 'production') {
+ if (typeof format === 'string') {
+ if (format.indexOf('Error: Uncaught [') === 0) {
+ // This looks like an uncaught error from invokeGuardedCallback() wrapper
+ // in development that is reported by jsdom. Ignore because it's noisy.
+ return true
+ }
+ if (format.indexOf('The above error occurred') === 0) {
+ // This looks like an error addendum from ReactFiberErrorLogger.
+ // Ignore it too.
+ return true
+ }
+ if (
+ format.startsWith(
+ 'Warning: `ReactDOMTestUtils.act` is deprecated in favor of `React.act`.',
+ )
+ ) {
+ // This is a React bug in 18.3.0.
+ // Versions with `ReactDOMTestUtils.ac` being deprecated, should have `React.act`
+ return true
+ }
+ }
+ }
+ // Looks legit
+ return false
+}
diff --git a/tests/toWarnDev.js b/tests/toWarnDev.js
new file mode 100644
index 00000000..3005125e
--- /dev/null
+++ b/tests/toWarnDev.js
@@ -0,0 +1,303 @@
+// Fork of https://github.com/facebook/react/blob/513417d6951fa3ff5729302b7990b84604b11afa/scripts/jest/matchers/toWarnDev.js
+/**
+MIT License
+
+Copyright (c) Facebook, Inc. and its affiliates.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ */
+/* eslint-disable no-unsafe-finally */
+/* eslint-disable no-negated-condition */
+/* eslint-disable no-invalid-this */
+/* eslint-disable prefer-template */
+/* eslint-disable func-names */
+/* eslint-disable complexity */
+const util = require('util')
+const jestDiff = require('jest-diff').diff
+const shouldIgnoreConsoleError = require('./shouldIgnoreConsoleError')
+
+function normalizeCodeLocInfo(str) {
+ if (typeof str !== 'string') {
+ return str
+ }
+ // This special case exists only for the special source location in
+ // ReactElementValidator. That will go away if we remove source locations.
+ str = str.replace(/Check your code at .+?:\d+/g, 'Check your code at **')
+ // V8 format:
+ // at Component (/path/filename.js:123:45)
+ // React format:
+ // in Component (at filename.js:123)
+ // eslint-disable-next-line prefer-arrow-callback
+ return str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function (m, name) {
+ return '\n in ' + name + ' (at **)'
+ })
+}
+
+const createMatcherFor = (consoleMethod, matcherName) =>
+ function matcher(callback, expectedMessages, options = {}) {
+ if (process.env.NODE_ENV !== 'production') {
+ // Warn about incorrect usage of matcher.
+ if (typeof expectedMessages === 'string') {
+ expectedMessages = [expectedMessages]
+ } else if (!Array.isArray(expectedMessages)) {
+ throw Error(
+ `${matcherName}() requires a parameter of type string or an array of strings ` +
+ `but was given ${typeof expectedMessages}.`,
+ )
+ }
+ if (
+ options != null &&
+ (typeof options !== 'object' || Array.isArray(options))
+ ) {
+ throw new Error(
+ `${matcherName}() second argument, when present, should be an object. ` +
+ 'Did you forget to wrap the messages into an array?',
+ )
+ }
+ if (arguments.length > 3) {
+ // `matcher` comes from Jest, so it's more than 2 in practice
+ throw new Error(
+ `${matcherName}() received more than two arguments. ` +
+ 'Did you forget to wrap the messages into an array?',
+ )
+ }
+
+ const withoutStack = options.withoutStack
+ const logAllErrors = options.logAllErrors
+ const warningsWithoutComponentStack = []
+ const warningsWithComponentStack = []
+ const unexpectedWarnings = []
+
+ let lastWarningWithMismatchingFormat = null
+ let lastWarningWithExtraComponentStack = null
+
+ // Catch errors thrown by the callback,
+ // But only rethrow them if all test expectations have been satisfied.
+ // Otherwise an Error in the callback can mask a failed expectation,
+ // and result in a test that passes when it shouldn't.
+ let caughtError
+
+ const isLikelyAComponentStack = message =>
+ typeof message === 'string' &&
+ (message.includes('\n in ') || message.includes('\n at '))
+
+ const consoleSpy = (format, ...args) => {
+ // Ignore uncaught errors reported by jsdom
+ // and React addendums because they're too noisy.
+ if (
+ !logAllErrors &&
+ consoleMethod === 'error' &&
+ shouldIgnoreConsoleError(format, args)
+ ) {
+ return
+ }
+
+ const message = util.format(format, ...args)
+ const normalizedMessage = normalizeCodeLocInfo(message)
+
+ // Remember if the number of %s interpolations
+ // doesn't match the number of arguments.
+ // We'll fail the test if it happens.
+ let argIndex = 0
+ String(format).replace(/%s/g, () => argIndex++)
+ if (argIndex !== args.length) {
+ lastWarningWithMismatchingFormat = {
+ format,
+ args,
+ expectedArgCount: argIndex,
+ }
+ }
+
+ // Protect against accidentally passing a component stack
+ // to warning() which already injects the component stack.
+ if (
+ args.length >= 2 &&
+ isLikelyAComponentStack(args[args.length - 1]) &&
+ isLikelyAComponentStack(args[args.length - 2])
+ ) {
+ lastWarningWithExtraComponentStack = {
+ format,
+ }
+ }
+
+ for (let index = 0; index < expectedMessages.length; index++) {
+ const expectedMessage = expectedMessages[index]
+ if (
+ normalizedMessage === expectedMessage ||
+ normalizedMessage.includes(expectedMessage)
+ ) {
+ if (isLikelyAComponentStack(normalizedMessage)) {
+ warningsWithComponentStack.push(normalizedMessage)
+ } else {
+ warningsWithoutComponentStack.push(normalizedMessage)
+ }
+ expectedMessages.splice(index, 1)
+ return
+ }
+ }
+
+ let errorMessage
+ if (expectedMessages.length === 0) {
+ errorMessage =
+ 'Unexpected warning recorded: ' +
+ this.utils.printReceived(normalizedMessage)
+ } else if (expectedMessages.length === 1) {
+ errorMessage =
+ 'Unexpected warning recorded: ' +
+ jestDiff(expectedMessages[0], normalizedMessage)
+ } else {
+ errorMessage =
+ 'Unexpected warning recorded: ' +
+ jestDiff(expectedMessages, [normalizedMessage])
+ }
+
+ // Record the call stack for unexpected warnings.
+ // We don't throw an Error here though,
+ // Because it might be suppressed by ReactFiberScheduler.
+ unexpectedWarnings.push(new Error(errorMessage))
+ }
+
+ // TODO Decide whether we need to support nested toWarn* expectations.
+ // If we don't need it, add a check here to see if this is already our spy,
+ // And throw an error.
+ const originalMethod = console[consoleMethod]
+
+ // Avoid using Jest's built-in spy since it can't be removed.
+ console[consoleMethod] = consoleSpy
+
+ try {
+ callback()
+ } catch (error) {
+ caughtError = error
+ } finally {
+ // Restore the unspied method so that unexpected errors fail tests.
+ console[consoleMethod] = originalMethod
+
+ // Any unexpected Errors thrown by the callback should fail the test.
+ // This should take precedence since unexpected errors could block warnings.
+ if (caughtError) {
+ throw caughtError
+ }
+
+ // Any unexpected warnings should be treated as a failure.
+ if (unexpectedWarnings.length > 0) {
+ return {
+ message: () => unexpectedWarnings[0].stack,
+ pass: false,
+ }
+ }
+
+ // Any remaining messages indicate a failed expectations.
+ if (expectedMessages.length > 0) {
+ return {
+ message: () =>
+ `Expected warning was not recorded:\n ${this.utils.printReceived(
+ expectedMessages[0],
+ )}`,
+ pass: false,
+ }
+ }
+
+ if (typeof withoutStack === 'number') {
+ // We're expecting a particular number of warnings without stacks.
+ if (withoutStack !== warningsWithoutComponentStack.length) {
+ return {
+ message: () =>
+ `Expected ${withoutStack} warnings without a component stack but received ${warningsWithoutComponentStack.length}:\n` +
+ warningsWithoutComponentStack.map(warning =>
+ this.utils.printReceived(warning),
+ ),
+ pass: false,
+ }
+ }
+ } else if (withoutStack === true) {
+ // We're expecting that all warnings won't have the stack.
+ // If some warnings have it, it's an error.
+ if (warningsWithComponentStack.length > 0) {
+ return {
+ message: () =>
+ `Received warning unexpectedly includes a component stack:\n ${this.utils.printReceived(
+ warningsWithComponentStack[0],
+ )}\nIf this warning intentionally includes the component stack, remove ` +
+ `{withoutStack: true} from the ${matcherName}() call. If you have a mix of ` +
+ `warnings with and without stack in one ${matcherName}() call, pass ` +
+ `{withoutStack: N} where N is the number of warnings without stacks.`,
+ pass: false,
+ }
+ }
+ } else if (withoutStack === false || withoutStack === undefined) {
+ // We're expecting that all warnings *do* have the stack (default).
+ // If some warnings don't have it, it's an error.
+ if (warningsWithoutComponentStack.length > 0) {
+ return {
+ message: () =>
+ `Received warning unexpectedly does not include a component stack:\n ${this.utils.printReceived(
+ warningsWithoutComponentStack[0],
+ )}\nIf this warning intentionally omits the component stack, add ` +
+ `{withoutStack: true} to the ${matcherName} call.`,
+ pass: false,
+ }
+ }
+ } else {
+ throw Error(
+ `The second argument for ${matcherName}(), when specified, must be an object. It may have a ` +
+ `property called "withoutStack" whose value may be undefined, boolean, or a number. ` +
+ `Instead received ${typeof withoutStack}.`,
+ )
+ }
+
+ if (lastWarningWithMismatchingFormat !== null) {
+ return {
+ message: () =>
+ `Received ${
+ lastWarningWithMismatchingFormat.args.length
+ } arguments for a message with ${
+ lastWarningWithMismatchingFormat.expectedArgCount
+ } placeholders:\n ${this.utils.printReceived(
+ lastWarningWithMismatchingFormat.format,
+ )}`,
+ pass: false,
+ }
+ }
+
+ if (lastWarningWithExtraComponentStack !== null) {
+ return {
+ message: () =>
+ `Received more than one component stack for a warning:\n ${this.utils.printReceived(
+ lastWarningWithExtraComponentStack.format,
+ )}\nDid you accidentally pass a stack to warning() as the last argument? ` +
+ `Don't forget warning() already injects the component stack automatically.`,
+ pass: false,
+ }
+ }
+
+ return {pass: true}
+ }
+ } else {
+ // Any uncaught errors or warnings should fail tests in production mode.
+ callback()
+
+ return {pass: true}
+ }
+ }
+
+module.exports = {
+ toWarnDev: createMatcherFor('warn', 'toWarnDev'),
+ toErrorDev: createMatcherFor('error', 'toErrorDev'),
+}
diff --git a/types/index.d.ts b/types/index.d.ts
new file mode 100644
index 00000000..bdd60567
--- /dev/null
+++ b/types/index.d.ts
@@ -0,0 +1,287 @@
+// TypeScript Version: 3.8
+import * as ReactDOMClient from 'react-dom/client'
+import {
+ queries,
+ Queries,
+ BoundFunction,
+ prettyFormat,
+ Config as ConfigDTL,
+} from '@testing-library/dom'
+import {act as reactDeprecatedAct} from 'react-dom/test-utils'
+//@ts-ignore
+import {act as reactAct} from 'react'
+
+export * from '@testing-library/dom'
+
+export interface Config extends ConfigDTL {
+ reactStrictMode: boolean
+}
+
+export interface ConfigFn {
+ (existingConfig: Config): Partial
+}
+
+export function configure(configDelta: ConfigFn | Partial): void
+
+export function getConfig(): Config
+
+export type RenderResult<
+ Q extends Queries = typeof queries,
+ Container extends RendererableContainer | HydrateableContainer = HTMLElement,
+ BaseElement extends RendererableContainer | HydrateableContainer = Container,
+> = {
+ container: Container
+ baseElement: BaseElement
+ debug: (
+ baseElement?:
+ | RendererableContainer
+ | HydrateableContainer
+ | Array
+ | undefined,
+ maxLength?: number | undefined,
+ options?: prettyFormat.OptionsReceived | undefined,
+ ) => void
+ rerender: (ui: React.ReactNode) => void
+ unmount: () => void
+ asFragment: () => DocumentFragment
+} & {[P in keyof Q]: BoundFunction}
+
+/** @deprecated */
+export type BaseRenderOptions<
+ Q extends Queries,
+ Container extends RendererableContainer | HydrateableContainer,
+ BaseElement extends RendererableContainer | HydrateableContainer,
+> = RenderOptions
+
+type RendererableContainer = ReactDOMClient.Container
+type HydrateableContainer = Parameters[0]
+/** @deprecated */
+export interface ClientRenderOptions<
+ Q extends Queries,
+ Container extends RendererableContainer,
+ BaseElement extends RendererableContainer = Container,
+> extends BaseRenderOptions {
+ /**
+ * If `hydrate` is set to `true`, then it will render with `ReactDOM.hydrate`. This may be useful if you are using server-side
+ * rendering and use ReactDOM.hydrate to mount your components.
+ *
+ * @see https://testing-library.com/docs/react-testing-library/api/#hydrate)
+ */
+ hydrate?: false | undefined
+}
+/** @deprecated */
+export interface HydrateOptions<
+ Q extends Queries,
+ Container extends HydrateableContainer,
+ BaseElement extends HydrateableContainer = Container,
+> extends BaseRenderOptions {
+ /**
+ * If `hydrate` is set to `true`, then it will render with `ReactDOM.hydrate`. This may be useful if you are using server-side
+ * rendering and use ReactDOM.hydrate to mount your components.
+ *
+ * @see https://testing-library.com/docs/react-testing-library/api/#hydrate)
+ */
+ hydrate: true
+}
+
+export interface RenderOptions<
+ Q extends Queries = typeof queries,
+ Container extends RendererableContainer | HydrateableContainer = HTMLElement,
+ BaseElement extends RendererableContainer | HydrateableContainer = Container,
+> {
+ /**
+ * By default, React Testing Library will create a div and append that div to the document.body. Your React component will be rendered in the created div. If you provide your own HTMLElement container via this option,
+ * it will not be appended to the document.body automatically.
+ *
+ * For example: If you are unit testing a `` element, it cannot be a child of a div. In this case, you can
+ * specify a table as the render container.
+ *
+ * @see https://testing-library.com/docs/react-testing-library/api/#container
+ */
+ container?: Container | undefined
+ /**
+ * Defaults to the container if the container is specified. Otherwise `document.body` is used for the default. This is used as
+ * the base element for the queries as well as what is printed when you use `debug()`.
+ *
+ * @see https://testing-library.com/docs/react-testing-library/api/#baseelement
+ */
+ baseElement?: BaseElement | undefined
+ /**
+ * If `hydrate` is set to `true`, then it will render with `ReactDOM.hydrate`. This may be useful if you are using server-side
+ * rendering and use ReactDOM.hydrate to mount your components.
+ *
+ * @see https://testing-library.com/docs/react-testing-library/api/#hydrate)
+ */
+ hydrate?: boolean | undefined
+ /**
+ * Only works if used with React 18.
+ * Set to `true` if you want to force synchronous `ReactDOM.render`.
+ * Otherwise `render` will default to concurrent React if available.
+ */
+ legacyRoot?: boolean | undefined
+ /**
+ * Only supported in React 19.
+ * Callback called when React catches an error in an Error Boundary.
+ * Called with the error caught by the Error Boundary, and an `errorInfo` object containing the `componentStack`.
+ *
+ * @see {@link https://react.dev/reference/react-dom/client/createRoot#parameters createRoot#options}
+ */
+ onCaughtError?: ReactDOMClient.RootOptions extends {
+ onCaughtError: infer OnCaughtError
+ }
+ ? OnCaughtError
+ : never
+ /**
+ * Callback called when React automatically recovers from errors.
+ * Called with an error React throws, and an `errorInfo` object containing the `componentStack`.
+ * Some recoverable errors may include the original error cause as `error.cause`.
+ *
+ * @see {@link https://react.dev/reference/react-dom/client/createRoot#parameters createRoot#options}
+ */
+ onRecoverableError?: ReactDOMClient.RootOptions['onRecoverableError']
+ /**
+ * Not supported at the moment
+ */
+ onUncaughtError?: never
+ /**
+ * Queries to bind. Overrides the default set from DOM Testing Library unless merged.
+ *
+ * @see https://testing-library.com/docs/react-testing-library/api/#queries
+ */
+ queries?: Q | undefined
+ /**
+ * Pass a React Component as the wrapper option to have it rendered around the inner element. This is most useful for creating
+ * reusable custom render functions for common data providers. See setup for examples.
+ *
+ * @see https://testing-library.com/docs/react-testing-library/api/#wrapper
+ */
+ wrapper?: React.JSXElementConstructor<{children: React.ReactNode}> | undefined
+ /**
+ * When enabled, is rendered around the inner element.
+ * If defined, overrides the value of `reactStrictMode` set in `configure`.
+ */
+ reactStrictMode?: boolean
+}
+
+type Omit = Pick>
+
+/**
+ * Render into a container which is appended to document.body. It should be used with cleanup.
+ */
+export function render<
+ Q extends Queries = typeof queries,
+ Container extends RendererableContainer | HydrateableContainer = HTMLElement,
+ BaseElement extends RendererableContainer | HydrateableContainer = Container,
+>(
+ ui: React.ReactNode,
+ options: RenderOptions,
+): RenderResult
+export function render(
+ ui: React.ReactNode,
+ options?: Omit | undefined,
+): RenderResult
+
+export interface RenderHookResult {
+ /**
+ * Triggers a re-render. The props will be passed to your renderHook callback.
+ */
+ rerender: (props?: Props) => void
+ /**
+ * This is a stable reference to the latest value returned by your renderHook
+ * callback
+ */
+ result: {
+ /**
+ * The value returned by your renderHook callback
+ */
+ current: Result
+ }
+ /**
+ * Unmounts the test component. This is useful for when you need to test
+ * any cleanup your useEffects have.
+ */
+ unmount: () => void
+}
+
+/** @deprecated */
+export type BaseRenderHookOptions<
+ Props,
+ Q extends Queries,
+ Container extends RendererableContainer | HydrateableContainer,
+ BaseElement extends Element | DocumentFragment,
+> = RenderHookOptions
+
+/** @deprecated */
+export interface ClientRenderHookOptions<
+ Props,
+ Q extends Queries,
+ Container extends Element | DocumentFragment,
+ BaseElement extends Element | DocumentFragment = Container,
+> extends BaseRenderHookOptions {
+ /**
+ * If `hydrate` is set to `true`, then it will render with `ReactDOM.hydrate`. This may be useful if you are using server-side
+ * rendering and use ReactDOM.hydrate to mount your components.
+ *
+ * @see https://testing-library.com/docs/react-testing-library/api/#hydrate)
+ */
+ hydrate?: false | undefined
+}
+
+/** @deprecated */
+export interface HydrateHookOptions<
+ Props,
+ Q extends Queries,
+ Container extends Element | DocumentFragment,
+ BaseElement extends Element | DocumentFragment = Container,
+> extends BaseRenderHookOptions {
+ /**
+ * If `hydrate` is set to `true`, then it will render with `ReactDOM.hydrate`. This may be useful if you are using server-side
+ * rendering and use ReactDOM.hydrate to mount your components.
+ *
+ * @see https://testing-library.com/docs/react-testing-library/api/#hydrate)
+ */
+ hydrate: true
+}
+
+export interface RenderHookOptions<
+ Props,
+ Q extends Queries = typeof queries,
+ Container extends RendererableContainer | HydrateableContainer = HTMLElement,
+ BaseElement extends RendererableContainer | HydrateableContainer = Container,
+> extends BaseRenderOptions {
+ /**
+ * The argument passed to the renderHook callback. Can be useful if you plan
+ * to use the rerender utility to change the values passed to your hook.
+ */
+ initialProps?: Props | undefined
+}
+
+/**
+ * Allows you to render a hook within a test React component without having to
+ * create that component yourself.
+ */
+export function renderHook<
+ Result,
+ Props,
+ Q extends Queries = typeof queries,
+ Container extends RendererableContainer | HydrateableContainer = HTMLElement,
+ BaseElement extends RendererableContainer | HydrateableContainer = Container,
+>(
+ render: (initialProps: Props) => Result,
+ options?: RenderHookOptions | undefined,
+): RenderHookResult
+
+/**
+ * Unmounts React trees that were mounted with render.
+ */
+export function cleanup(): void
+
+/**
+ * Simply calls React.act(cb)
+ * If that's not available (older version of react) then it
+ * simply calls the deprecated version which is ReactTestUtils.act(cb)
+ */
+// IfAny from https://stackoverflow.com/a/61626123/3406963
+export const act: 0 extends 1 & typeof reactAct
+ ? typeof reactDeprecatedAct
+ : typeof reactAct
diff --git a/types/pure.d.ts b/types/pure.d.ts
new file mode 100644
index 00000000..7b527195
--- /dev/null
+++ b/types/pure.d.ts
@@ -0,0 +1 @@
+export * from './'
diff --git a/types/test.tsx b/types/test.tsx
new file mode 100644
index 00000000..825d5699
--- /dev/null
+++ b/types/test.tsx
@@ -0,0 +1,317 @@
+import * as React from 'react'
+import {render, fireEvent, screen, waitFor, renderHook} from '.'
+import * as pure from './pure'
+
+export async function testRender() {
+ const view = render()
+
+ // single queries
+ view.getByText('foo')
+ view.queryByText('foo')
+ await view.findByText('foo')
+
+ // multiple queries
+ view.getAllByText('bar')
+ view.queryAllByText('bar')
+ await view.findAllByText('bar')
+
+ // helpers
+ const {container, rerender, debug} = view
+ expectType(container)
+ return {container, rerender, debug}
+}
+
+export async function testPureRender() {
+ const view = pure.render()
+
+ // single queries
+ view.getByText('foo')
+ view.queryByText('foo')
+ await view.findByText('foo')
+
+ // multiple queries
+ view.getAllByText('bar')
+ view.queryAllByText('bar')
+ await view.findAllByText('bar')
+
+ // helpers
+ const {container, rerender, debug} = view
+ expectType(container)
+ return {container, rerender, debug}
+}
+
+export function testRenderOptions() {
+ const container = document.createElement('div')
+ const options = {container}
+ const {container: returnedContainer} = render(, options)
+ expectType(returnedContainer)
+
+ render(, {wrapper: () => null})
+}
+
+export function testSVGRenderOptions() {
+ const container = document.createElementNS(
+ 'http://www.w3.org/2000/svg',
+ 'svg',
+ )
+ const options = {container}
+ const {container: returnedContainer} = render( , options)
+ expectType(returnedContainer)
+}
+
+export function testFireEvent() {
+ const {container} = render()
+ fireEvent.click(container)
+}
+
+export function testConfigure() {
+ // test for DTL's config
+ pure.configure({testIdAttribute: 'foobar'})
+ pure.configure(existingConfig => ({
+ testIdAttribute: `modified-${existingConfig.testIdAttribute}`,
+ }))
+
+ // test for RTL's config
+ pure.configure({reactStrictMode: true})
+ pure.configure(existingConfig => ({
+ reactStrictMode: !existingConfig.reactStrictMode,
+ }))
+}
+
+export function testGetConfig() {
+ // test for DTL's config
+ pure.getConfig().testIdAttribute
+
+ // test for RTL's config
+ pure.getConfig().reactStrictMode
+}
+
+export function testDebug() {
+ const {debug, getAllByTestId} = render(
+ <>
+ Hello World
+ Hello World
+ >,
+ )
+ debug(getAllByTestId('testid'))
+}
+
+export async function testScreen() {
+ render()
+
+ await screen.findByRole('button')
+}
+
+export async function testWaitFor() {
+ const {container} = render()
+ fireEvent.click(container)
+ await waitFor(() => {})
+}
+
+export function testQueries() {
+ const {getByLabelText} = render(
+ ,
+ )
+ expectType>(
+ getByLabelText('Username'),
+ )
+
+ const container = document.createElement('div')
+ const options = {container}
+ const {getByText} = render(Hello World, options)
+ expectType>(
+ getByText('Hello World'),
+ )
+}
+
+export function wrappedRender(
+ ui: React.ReactNode,
+ options?: pure.RenderOptions,
+) {
+ const Wrapper = ({
+ children,
+ }: {
+ children: React.ReactNode
+ }): React.JSX.Element => {
+ return {children}
+ }
+
+ return pure.render(ui, {
+ wrapper: Wrapper,
+ // testing exactOptionalPropertyTypes comaptibility
+ hydrate: options?.hydrate,
+ ...options,
+ })
+}
+
+export function wrappedRenderB(
+ ui: React.ReactNode,
+ options?: pure.RenderOptions,
+) {
+ const Wrapper: React.FunctionComponent<{children?: React.ReactNode}> = ({
+ children,
+ }) => {
+ return {children}
+ }
+
+ return pure.render(ui, {wrapper: Wrapper, ...options})
+}
+
+export function wrappedRenderC(
+ ui: React.ReactNode,
+ options?: pure.RenderOptions,
+) {
+ interface AppWrapperProps {
+ children?: React.ReactNode
+ userProviderProps?: {user: string}
+ }
+ const AppWrapperProps: React.FunctionComponent = ({
+ children,
+ userProviderProps = {user: 'TypeScript'},
+ }) => {
+ return {children}
+ }
+
+ return pure.render(ui, {wrapper: AppWrapperProps, ...options})
+}
+
+export function wrappedRenderHook(
+ hook: () => unknown,
+ options?: pure.RenderHookOptions,
+) {
+ interface AppWrapperProps {
+ children?: React.ReactNode
+ userProviderProps?: {user: string}
+ }
+ const AppWrapperProps: React.FunctionComponent = ({
+ children,
+ userProviderProps = {user: 'TypeScript'},
+ }) => {
+ return {children}
+ }
+
+ return pure.renderHook(hook, {...options})
+}
+
+export function testBaseElement() {
+ const {baseElement: baseDefaultElement} = render()
+ expectType(baseDefaultElement)
+
+ const container = document.createElement('input')
+ const {baseElement: baseElementFromContainer} = render(, {container})
+ expectType(
+ baseElementFromContainer,
+ )
+
+ const baseElementOption = document.createElement('input')
+ const {baseElement: baseElementFromOption} = render(, {
+ baseElement: baseElementOption,
+ })
+ expectType(
+ baseElementFromOption,
+ )
+}
+
+export function testRenderHook() {
+ const {result, rerender, unmount} = renderHook(() => React.useState(2)[0])
+
+ expectType(result.current)
+
+ rerender()
+
+ unmount()
+
+ renderHook(() => null, {wrapper: () => null})
+}
+
+export function testRenderHookProps() {
+ const {result, rerender, unmount} = renderHook(
+ ({defaultValue}) => React.useState(defaultValue)[0],
+ {initialProps: {defaultValue: 2}},
+ )
+
+ expectType(result.current)
+
+ rerender()
+
+ unmount()
+}
+
+export function testContainer() {
+ render('a', {container: document.createElement('div')})
+ render('a', {container: document.createDocumentFragment()})
+ // Only allowed in React 19
+ render('a', {container: document})
+ render('a', {container: document.createElement('div'), hydrate: true})
+ // Only allowed for createRoot but typing `render` appropriately makes it harder to compose.
+ render('a', {container: document.createDocumentFragment(), hydrate: true})
+ render('a', {container: document, hydrate: true})
+
+ renderHook(() => null, {container: document.createElement('div')})
+ renderHook(() => null, {container: document.createDocumentFragment()})
+ // Only allowed in React 19
+ renderHook(() => null, {container: document})
+ renderHook(() => null, {
+ container: document.createElement('div'),
+ hydrate: true,
+ })
+ // Only allowed for createRoot but typing `render` appropriately makes it harder to compose.
+ renderHook(() => null, {
+ container: document.createDocumentFragment(),
+ hydrate: true,
+ })
+ renderHook(() => null, {container: document, hydrate: true})
+}
+
+export function testErrorHandlers() {
+ // React 19 types are not used in tests. Verify manually if this works with `"@types/react": "npm:types-react@rc"`
+ render(null, {
+ // Should work with React 19 types
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ onCaughtError: () => {},
+ })
+ render(null, {
+ // Should never work as it's not supported yet.
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ onUncaughtError: () => {},
+ })
+ render(null, {
+ onRecoverableError: (error, errorInfo) => {
+ console.error(error)
+ console.log(errorInfo.componentStack)
+ },
+ })
+}
+
+/*
+eslint
+ testing-library/prefer-explicit-assert: "off",
+ testing-library/no-wait-for-empty-callback: "off",
+ testing-library/prefer-screen-queries: "off"
+*/
+
+// https://stackoverflow.com/questions/53807517/how-to-test-if-two-types-are-exactly-the-same
+type IfEquals = (() => G extends T
+ ? 1
+ : 2) extends () => G extends U ? 1 : 2
+ ? Yes
+ : No
+
+/**
+ * Issues a type error if `Expected` is not identical to `Actual`.
+ *
+ * `Expected` should be declared when invoking `expectType`.
+ * `Actual` should almost always we be a `typeof value` statement.
+ *
+ * Source: https://github.com/mui-org/material-ui/blob/6221876a4b468a3330ffaafa8472de7613933b87/packages/material-ui-types/index.d.ts#L73-L84
+ *
+ * @example `expectType(value)`
+ * TypeScript issues a type error since `value is not assignable to never`.
+ * This means `typeof value` is not identical to `number | string`
+ * @param actual
+ */
+declare function expectType(
+ actual: IfEquals,
+): void
diff --git a/types/tsconfig.json b/types/tsconfig.json
new file mode 100644
index 00000000..bad26af7
--- /dev/null
+++ b/types/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../node_modules/kcd-scripts/shared-tsconfig.json",
+ "compilerOptions": {
+ "exactOptionalPropertyTypes": true,
+ "skipLibCheck": false
+ },
+ "include": ["."]
+}
diff --git a/typings/index.d.ts b/typings/index.d.ts
deleted file mode 100644
index 16594936..00000000
--- a/typings/index.d.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import {queries, BoundFunction} from 'dom-testing-library'
-
-export * from 'dom-testing-library'
-
-interface Query extends Function {
- (container: HTMLElement, ...args: any[]): HTMLElement[] | HTMLElement | null
-}
-
-interface Queries {
- [T: string]: Query
-}
-
-export type RenderResult = {
- container: HTMLElement
- baseElement: HTMLElement
- debug: (baseElement?: HTMLElement | DocumentFragment) => void
- rerender: (ui: React.ReactElement) => void
- unmount: () => boolean
- asFragment: () => DocumentFragment
-} & {[P in keyof Q]: BoundFunction}
-
-export type HookResult = {
- result: React.MutableRefObject
- rerender: () => void
- unmount: () => boolean
-}
-
-export type HookOptions = {
- wrapper: React.FunctionComponent
-}
-
-export interface RenderOptions {
- container?: HTMLElement
- baseElement?: HTMLElement
- hydrate?: boolean
- queries?: Q
-}
-
-type Omit = Pick>
-
-/**
- * Render into a container which is appended to document.body. It should be used with cleanup.
- */
-export function render(
- ui: React.ReactElement,
- options?: Omit,
-): RenderResult
-export function render(
- ui: React.ReactElement,
- options: RenderOptions,
-): RenderResult
-
-/**
- * Renders a test component that calls back to the test.
- */
-export function testHook(
- callback: () => T,
- options?: Partial,
-): HookResult
-
-/**
- * Unmounts React trees that were mounted with render.
- */
-export function cleanup(): void
-
-/**
- * Simply calls ReactDOMTestUtils.act(cb)
- * If that's not available (older version of react) then it
- * simply calls the given callback immediately
- */
-export function act(callback: () => void): void