diff --git a/.all-contributorsrc b/.all-contributorsrc index 4944cf9..fab0bfd 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -2,9 +2,7 @@ "projectName": "svelte-testing-library", "projectOwner": "testing-library", "repoType": "github", - "files": [ - "README.md" - ], + "files": ["README.md"], "imageSize": 100, "commit": false, "contributors": [ @@ -13,133 +11,105 @@ "name": "Ben Monro", "avatar_url": "https://avatars3.githubusercontent.com/u/399236?v=4", "profile": "https://github.com/benmonro", - "contributions": [ - "code", - "test", - "ideas", - "doc" - ] + "contributions": ["code", "test", "ideas", "doc"] }, { "login": "EmilTholin", "name": "Emil Tholin", "avatar_url": "https://avatars0.githubusercontent.com/u/11573167?v=4", "profile": "https://twitter.com/EmilTholin", - "contributions": [ - "code", - "test", - "ideas" - ] + "contributions": ["code", "test", "ideas"] }, { "login": "oieduardorabelo", "name": "Eduardo Rabelo", "avatar_url": "https://avatars1.githubusercontent.com/u/829902?v=4", "profile": "https://medium.com/@oieduardorabelo", - "contributions": [ - "test", - "code", - "doc", - "example" - ] + "contributions": ["test", "code", "doc", "example"] }, { "login": "timdeschryver", "name": "Tim Deschryver", "avatar_url": "https://avatars1.githubusercontent.com/u/28659384?v=4", "profile": "http://timdeschryver.dev", - "contributions": [ - "doc" - ] + "contributions": ["doc"] }, { "login": "ematipico", "name": "Emanuele", "avatar_url": "https://avatars3.githubusercontent.com/u/602478?v=4", "profile": "http://www.ematipico.com", - "contributions": [ - "code", - "test", - "doc" - ] + "contributions": ["code", "test", "doc"] }, { "login": "pngwn", "name": "pngwn", "avatar_url": "https://avatars1.githubusercontent.com/u/12937446?v=4", "profile": "https://github.com/pngwn", - "contributions": [ - "code", - "test" - ] + "contributions": ["code", "test"] }, { "login": "eps1lon", "name": "Sebastian Silbermann", "avatar_url": "https://avatars3.githubusercontent.com/u/12292047?v=4", "profile": "https://twitter.com/sebsilbermann", - "contributions": [ - "code" - ] + "contributions": ["code"] }, { "login": "mihar-22", "name": "Rahim Alwer", "avatar_url": "https://avatars3.githubusercontent.com/u/14304599?s=460&v=4", "profile": "https://github.com/mihar-22", - "contributions": [ - "code", - "doc", - "test", - "review" - ] + "contributions": ["code", "doc", "test", "review"] }, { "login": "MirrorBytes", "name": "Bob", "avatar_url": "https://avatars3.githubusercontent.com/u/22119469?v=4", "profile": "https://github.com/MirrorBytes", - "contributions": [ - "bug", - "code" - ] + "contributions": ["bug", "code"] }, { "login": "ronmerkin", "name": "Ron Merkin", "avatar_url": "https://avatars.githubusercontent.com/u/17492527?v=4", "profile": "https://github.com/ronmerkin", - "contributions": [ - "code" - ] + "contributions": ["code"] }, { "login": "benmccann", "name": "Ben McCann", "avatar_url": "https://avatars.githubusercontent.com/u/322311?v=4", "profile": "http://www.benmccann.com", - "contributions": [ - "test" - ] + "contributions": ["test"] }, { "login": "jgbowser", "name": "John Bowser", "avatar_url": "https://avatars.githubusercontent.com/u/66637570?v=4", "profile": "https://johnbowser.dev/", - "contributions": [ - "code", - "test" - ] + "contributions": ["code", "test"] }, { - "login": "ysaskia", + "login": "ysitbon", "name": "Yoann", "avatar_url": "https://avatars.githubusercontent.com/u/1370679?v=4", - "profile": "https://github.com/ysaskia", - "contributions": [ - "code" - ] + "profile": "https://github.com/ysitbon", + "contributions": ["code"] + }, + { + "login": "yanick", + "name": "Yanick Champoux", + "avatar_url": "https://avatars.githubusercontent.com/u/19954?v=4", + "profile": "https://techblog.babyl.ca/", + "contributions": ["code"] + }, + { + "login": "mcous", + "name": "Michael Cousins", + "avatar_url": "https://avatars.githubusercontent.com/u/2963448?v=4", + "profile": "https://michael.cousins.io/", + "contributions": ["code"] } ], "contributorsPerLine": 7, @@ -147,3 +117,4 @@ "commitConvention": "none", "skipCi": true } + diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index 97b6929..0000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,21 +0,0 @@ -module.exports = { - root: true, - env: { - browser: true, - es6: true, - 'vitest-globals/env': true - }, - extends: ['standard', 'plugin:vitest-globals/recommended'], - plugins: ['svelte', 'simple-import-sort'], - rules: { - 'max-len': ['warn', { code: 100 }], - 'simple-import-sort/imports': 'error', - 'no-multiple-empty-lines': ['error', { max: 2, maxBOF: 2, maxEOF: 0 }], - }, - overrides: [ - ], - parserOptions: { - ecmaVersion: 2022, - sourceType: 'module', - }, -} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..434a8bb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,34 @@ +version: 2 +updates: + # Update npm dependencies + - package-ecosystem: 'npm' + directory: '/' + schedule: + interval: 'monthly' + groups: + lint: + patterns: + - '*eslint*' + - '*prettier*' + - '*typescript*' + test: + patterns: + - '*svelte*' + - '*testing-library*' + - '*vite*' + - '*vitest*' + - '*jsdom*' + - '*happy-dom*' + - 'expect-type' + development: + dependency-type: 'development' + + # Update GitHub Actions dependencies + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'monthly' + groups: + actions: + patterns: + - '*' diff --git a/.github/semantic.yml b/.github/semantic.yml deleted file mode 100644 index bc11441..0000000 --- a/.github/semantic.yml +++ /dev/null @@ -1 +0,0 @@ -enabled: false diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml index ad6df65..9050f17 100644 --- a/.github/workflows/lint-pr.yml +++ b/.github/workflows/lint-pr.yml @@ -1,4 +1,4 @@ -name: "Lint PR" +name: 'Lint PR' on: pull_request_target: @@ -12,6 +12,6 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v4 + - uses: amannn/action-semantic-pull-request@v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5204198..be02b70 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,9 +2,12 @@ name: CI on: push: - branches: [main] + branches: [main, next] pull_request: - branches: [main] + branches: [main, next] + schedule: + # Tuesdays at 14:45 UTC (10:45 EST) + - cron: 45 14 * * 1 concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -14,63 +17,105 @@ jobs: main: # ignore all-contributors PRs if: ${{ !contains(github.head_ref, 'all-contributors') }} + name: Svelte ${{ matrix.svelte }}, Node ${{ matrix.node }}, ${{ matrix.check }} + runs-on: ubuntu-latest + + # enable OIDC for codecov uploads + permissions: + id-token: write + strategy: + fail-fast: false matrix: - node: [16, 18, 20] - runs-on: ubuntu-latest + node: ['16', '18', '20', '22'] + svelte: ['3', '4', '5'] + check: ['test:vitest:jsdom', 'test:vitest:happy-dom', 'test:jest'] + exclude: + # Don't run Svelte 3 on Node versions greater than 20 + - { svelte: '3', node: '22' } + # Only run Svelte 5 on Node versions greater than or equal to 20 + - { svelte: '5', node: '16' } + - { svelte: '5', node: '18' } + include: + # We only need to lint once, so do it on latest Node and Svelte + - { svelte: '5', node: '22', check: 'lint' } + # Run type checks in latest applicable Node + - { svelte: '3', node: '20', check: 'types:legacy' } + - { svelte: '4', node: '22', check: 'types:legacy' } + - { svelte: '5', node: '22', check: 'types' } + steps: - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: ⎔ Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - name: 📥 Download deps - uses: bahmutov/npm-install@v1 - with: - useLockFile: false + run: npm run install:${{ matrix.svelte }} - - name: ▶️ Run validate script - run: npm run validate + - name: ▶️ Run ${{ matrix.check }} + run: npm run ${{ matrix.check }} - name: ⬆️ Upload coverage report - uses: codecov/codecov-action@v2 + if: ${{ startsWith(matrix.check, 'test:') }} + uses: codecov/codecov-action@v5 + with: + use_oidc: true + fail_ci_if_error: true + + build: + runs-on: ubuntu-latest + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + + - name: ⎔ Setup node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: 📥 Download deps + run: npm install + + - name: 🏗️ Build types + run: npm run build + + - name: ⬆️ Upload types build + uses: actions/upload-artifact@v4 + with: + name: types + path: types release: - needs: main + needs: [main, build] runs-on: ubuntu-latest if: ${{ github.repository == 'testing-library/svelte-testing-library' && - contains('refs/heads/main,refs/heads/beta,refs/heads/next,refs/heads/alpha', - github.ref) && github.event_name == 'push' }} + contains('refs/heads/main,refs/heads/next', github.ref) && + github.event_name == 'push' }} steps: - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: ⎔ Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 16 + node-version: 22 - - name: 📥 Download deps - uses: bahmutov/npm-install@v1 + - name: 📥 Downloads types build + uses: actions/download-artifact@v4 with: - useLockFile: false + name: types + path: types - name: 🚀 Release - uses: cycjimmy/semantic-release-action@v2 + uses: cycjimmy/semantic-release-action@v4 with: - semantic_version: 17 - branches: | - [ - '+([0-9])?(.{+([0-9]),x}).x', - 'main', - 'next', - 'next-major', - {name: 'beta', prerelease: true}, - {name: 'alpha', prerelease: true} - ] + semantic_version: 24 + extra_plugins: | + conventional-changelog-conventionalcommits@8 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index c09be87..ca3410b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,12 @@ public/bundle.* coverage dist .idea +*.tgz # These cause more harm than good when working with contributors yarn-error.log package-lock.json yarn.lock + +# generated typing output +types diff --git a/.npmrc b/.npmrc index 43c97e7..86916fa 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ package-lock=false +engine-strict=true diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c750ecf --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +.all-contributorsrc diff --git a/.prettierrc.yaml b/.prettierrc.yaml deleted file mode 100644 index 1d2127c..0000000 --- a/.prettierrc.yaml +++ /dev/null @@ -1,2 +0,0 @@ -semi: false -singleQuote: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..41193a3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,96 @@ +# Contributing guide + +## Pull requests + +- Consider opening an issue before submitting a pull-request to avoid unnecessary work +- Ensure pull request titles adhere to the [Conventional Commits][] specification + +[conventional commits]: https://www.conventionalcommits.org/ + +## Release + +The module is released automatically from the `main` and `next` branches using [semantic-release-action][]. Version bumps and change logs are generated from the commit messages. + +[semantic-release-action]: https://github.com/cycjimmy/semantic-release-action + +### Preview release + +If you would like to preview the release from a given branch, and... + +- You have push access to the repository +- The branch exists in GitHub + +...you can preview the next release version and changelog using: + +```shell +npm run preview-release +``` + +## Development setup + +After cloning the repository, use the `setup` script to install dependencies and run all checks: + +```shell +npm run setup +``` + +### Lint and format + +Run auto-formatting to ensure any changes adhere to the code style of the repository: + +```shell +npm run format +``` + +To run lint and format checks without making any changes: + +```shell +npm run lint +``` + +### Test + +Run unit tests once or in watch mode: + +```shell +npm test +npm run test:watch +``` + +### Using different versions of Svelte + +Use the provided script to set up your environment for different versions of Svelte: + +```shell +# Svelte 5 +npm run install:5 +npm run all + +# Svelte 4 +npm run install:4 +npm run all:legacy + +# Svelte 3 +npm run install:3 +npm run all:legacy +``` + +### Docs + +Use the `toc` script to ensure the README's table of contents is up to date: + +```shell +npm run toc +``` + +Use `contributors:add` to add a contributor to the README: + +```shell +npm run contributors:add +``` + +Use `contributors:generate` to ensure the README's contributor list is up to date: + +```shell +npm run contributors:generate +``` diff --git a/README.md b/README.md index 0808979..bf45331 100644 --- a/README.md +++ b/README.md @@ -12,17 +12,18 @@

Simple and complete Svelte testing utilities that encourage good testing practices.

-[**Read The Docs**](https://testing-library.com/docs/svelte-testing-library/intro) | -[Edit the docs](https://github.com/alexkrolick/testing-library-docs) +[**Read The Docs**][stl-docs] | [Edit the docs][stl-docs-repo] [![Build Status][build-badge]][build] [![Code Coverage][coverage-badge]][coverage] -[![version][version-badge]][package] [![downloads][downloads-badge]][npmtrends] +[![version][version-badge]][package] +[![downloads][downloads-badge]][downloads] [![MIT License][license-badge]][license] -[![All Contributors](https://img.shields.io/badge/all_contributors-8-orange.svg?style=flat-square)](#contributors-) -[![PRs Welcome][prs-badge]][prs] [![Code of Conduct][coc-badge]][coc] +[![All Contributors][contributors-badge]][contributors] +[![PRs Welcome][prs-badge]][prs] +[![Code of Conduct][coc-badge]][coc] [![Discord][discord-badge]][discord] [![Watch on GitHub][github-watch-badge]][github-watch] @@ -33,69 +34,131 @@
+[stl-docs]: https://testing-library.com/docs/svelte-testing-library/intro +[stl-docs-repo]: https://github.com/testing-library/testing-library-docs +[build-badge]: https://img.shields.io/github/actions/workflow/status/testing-library/svelte-testing-library/release.yml?style=flat-square +[build]: https://github.com/testing-library/svelte-testing-library/actions +[coverage-badge]: https://img.shields.io/codecov/c/github/testing-library/svelte-testing-library.svg?style=flat-square +[coverage]: https://codecov.io/github/testing-library/svelte-testing-library +[version-badge]: https://img.shields.io/npm/v/@testing-library/svelte.svg?style=flat-square +[package]: https://www.npmjs.com/package/@testing-library/svelte +[downloads-badge]: https://img.shields.io/npm/dm/@testing-library/svelte.svg?style=flat-square +[downloads]: http://www.npmtrends.com/@testing-library/svelte +[license-badge]: https://img.shields.io/github/license/testing-library/svelte-testing-library?color=b&style=flat-square +[license]: https://github.com/testing-library/svelte-testing-library/blob/main/LICENSE +[contributors-badge]: https://img.shields.io/badge/all_contributors-8-orange.svg?style=flat-square +[contributors]: #contributors +[prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square +[prs]: http://makeapullrequest.com +[coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square +[coc]: https://github.com/testing-library/svelte-testing-library/blob/main/CODE_OF_CONDUCT.md +[discord-badge]: https://img.shields.io/discord/723559267868737556.svg?color=7389D8&labelColor=6A7EC2&logo=discord&logoColor=ffffff&style=flat-square +[discord]: https://discord.gg/testing-library +[github-watch-badge]: https://img.shields.io/github/watchers/testing-library/svelte-testing-library.svg?style=social +[github-watch]: https://github.com/testing-library/svelte-testing-library/watchers +[github-star-badge]: https://img.shields.io/github/stars/testing-library/svelte-testing-library.svg?style=social +[github-star]: https://github.com/testing-library/svelte-testing-library/stargazers +[twitter]: https://twitter.com/intent/tweet?text=Check%20out%20svelte-testing-library%20by%20%40@TestingLib%20https%3A%2F%2Fgithub.com%2Ftesting-library%2Fsvelte-testing-library%20%F0%9F%91%8D +[twitter-badge]: https://img.shields.io/twitter/url/https/github.com/testing-library/svelte-testing-library.svg?style=social + ## Table of Contents - - [The Problem](#the-problem) - [This Solution](#this-solution) - [Installation](#installation) +- [Setup](#setup) - [Docs](#docs) - [Issues](#issues) - [🐛 Bugs](#-bugs) - [💡 Feature Requests](#-feature-requests) - [❓ Questions](#-questions) - [Contributors](#contributors) -- [LICENSE](#license) ## The Problem -You want to write tests for your Svelte components so that they avoid including implementation -details, and are maintainable in the long run. +You want to write maintainable tests for your [Svelte][svelte] components. + +[svelte]: https://svelte.dev/ ## This Solution -The `svelte-testing-library` is a very lightweight solution for testing Svelte -components. It provides light utility functions on top of `svelte` and -`dom-testing-library`, in a way that encourages better testing practices. Its -primary guiding principle is: +`@testing-library/svelte` is a lightweight library for testing Svelte +components. It provides functions on top of `svelte` and +`@testing-library/dom` so you can mount Svelte components and query their +rendered output in the DOM. Its primary guiding principle is: > [The more your tests resemble the way your software is used, the more > confidence they can give you.][guiding-principle] +[guiding-principle]: https://twitter.com/kentcdodds/status/977018512689455106 + ## Installation This module is distributed via [npm][npm] which is bundled with [node][node] and should be installed as one of your project's `devDependencies`: -``` +```shell npm install --save-dev @testing-library/svelte ``` -This library has `peerDependencies` listings for `svelte >= 3`. +This library supports `svelte` versions `3`, `4`, and `5`. + +You may also be interested in installing `@testing-library/jest-dom` so you can +use [the custom jest matchers][jest-dom]. + +[npm]: https://www.npmjs.com/ +[node]: https://nodejs.org +[jest-dom]: https://github.com/testing-library/jest-dom + +## Setup + +We recommend using `@testing-library/svelte` with [Vitest][] as your test +runner. To get started, add the `svelteTesting` plugin to your Vite or Vitest +config. + +```diff + // vite.config.js + import { svelte } from '@sveltejs/vite-plugin-svelte' ++ import { svelteTesting } from '@testing-library/svelte/vite' + + export default defineConfig({ + plugins: [ + svelte(), ++ svelteTesting(), + ] + }); +``` + +See the [setup docs][] for more detailed setup instructions, including for other +test runners like Jest. -You may also be interested in installing `@testing-library/jest-dom` so you can use -[the custom jest matchers](https://github.com/testing-library/jest-dom). +[vitest]: https://vitest.dev/ +[setup docs]: https://testing-library.com/docs/svelte-testing-library/setup ## Docs -See the [**docs**](https://testing-library.com/docs/svelte-testing-library/intro) over at the Testing Library website. +See the [**docs**][stl-docs] over at the Testing Library website. ## Issues _Looking to contribute? Look for the [Good First Issue][good-first-issue] label._ +[good-first-issue]: https://github.com/testing-library/svelte-testing-library/issues?utf8=✓&q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3A"good+first+issue"+ + ### 🐛 Bugs Please file an issue for bugs, missing documentation, or unexpected behavior. [**See Bugs**][bugs] +[bugs]: https://github.com/testing-library/svelte-testing-library/issues?q=is%3Aissue+is%3Aopen+label%3Abug+sort%3Acreated-desc + ### 💡 Feature Requests Please file an issue to suggest new features. Vote on feature requests by adding @@ -103,6 +166,8 @@ a 👍. This helps maintainers prioritize what to work on. [**See Feature Requests**][requests] +[requests]: https://github.com/testing-library/svelte-testing-library/issues?q=is%3Aissue+sort%3Areactions-%2B1-desc+label%3Aenhancement+is%3Aopen + ### ❓ Questions For questions related to using the library, please visit a support community @@ -111,6 +176,8 @@ instead of filing an issue on GitHub. - [Discord][discord] - [Stack Overflow][stackoverflow] +[stackoverflow]: https://stackoverflow.com/questions/tagged/svelte-testing-library + ## Contributors Thanks goes to these people ([emoji key][emojis]): @@ -119,23 +186,29 @@ Thanks goes to these people ([emoji key][emojis]): - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + +

Ben Monro

💻 ⚠️ 🤔 📖

Emil Tholin

💻 ⚠️ 🤔

Eduardo Rabelo

⚠️ 💻 📖 💡

Tim Deschryver

📖

Emanuele

💻 ⚠️ 📖

pngwn

💻 ⚠️

Sebastian Silbermann

💻

Rahim Alwer

💻 📖 ⚠️ 👀

Bob

🐛 💻

Ron Merkin

💻

Ben McCann

⚠️

John Bowser

💻 ⚠️

Yoann

💻
Ben Monro
Ben Monro

💻 ⚠️ 🤔 📖
Emil Tholin
Emil Tholin

💻 ⚠️ 🤔
Eduardo Rabelo
Eduardo Rabelo

⚠️ 💻 📖 💡
Tim Deschryver
Tim Deschryver

📖
Emanuele
Emanuele

💻 ⚠️ 📖
pngwn
pngwn

💻 ⚠️
Sebastian Silbermann
Sebastian Silbermann

💻
Rahim Alwer
Rahim Alwer

💻 📖 ⚠️ 👀
Bob
Bob

🐛 💻
Ron Merkin
Ron Merkin

💻
Ben McCann
Ben McCann

⚠️
John Bowser
John Bowser

💻 ⚠️
Yoann
Yoann

💻
Yanick Champoux
Yanick Champoux

💻
Michael Cousins
Michael Cousins

💻
@@ -146,45 +219,5 @@ Thanks goes to these people ([emoji key][emojis]): This project follows the [all-contributors][all-contributors] specification. Contributions of any kind welcome! -## LICENSE - -[MIT](LICENSE) - - - -[npm]: https://www.npmjs.com/ -[node]: https://nodejs.org -[build-badge]: https://img.shields.io/travis/testing-library/svelte-testing-library.svg?style=flat-square -[build]: https://travis-ci.org/testing-library/svelte-testing-library -[coverage-badge]: https://img.shields.io/codecov/c/github/testing-library/svelte-testing-library.svg?style=flat-square -[coverage]: https://codecov.io/github/testing-library/svelte-testing-library -[version-badge]: https://img.shields.io/npm/v/@testing-library/svelte.svg?style=flat-square -[package]: https://www.npmjs.com/package/@testing-library/svelte -[downloads-badge]: https://img.shields.io/npm/dm/@testing-library/svelte.svg?style=flat-square -[npmtrends]: http://www.npmtrends.com/@testing-library/svelte -[discord-badge]: https://img.shields.io/discord/723559267868737556.svg?color=7389D8&labelColor=6A7EC2&logo=discord&logoColor=ffffff&style=flat-square -[discord]: https://discord.gg/testing-library -[license-badge]: https://img.shields.io/github/license/testing-library/svelte-testing-library?color=b -[license]: https://github.com/testing-library/svelte-testing-library/blob/main/LICENSE -[prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square -[prs]: http://makeapullrequest.com -[donate-badge]: https://img.shields.io/badge/$-support-green.svg?style=flat-square -[coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square -[coc]: https://github.com/testing-library/svelte-testing-library/blob/main/CODE_OF_CONDUCT.md -[github-watch-badge]: https://img.shields.io/github/watchers/testing-library/svelte-testing-library.svg?style=social -[github-watch]: https://github.com/testing-library/svelte-testing-library/watchers -[github-star-badge]: https://img.shields.io/github/stars/testing-library/svelte-testing-library.svg?style=social -[github-star]: https://github.com/testing-library/svelte-testing-library/stargazers -[twitter]: https://twitter.com/intent/tweet?text=Check%20out%20svelte-testing-library%20by%20%40@TestingLib%20https%3A%2F%2Fgithub.com%2Ftesting-library%2Fsvelte-testing-library%20%F0%9F%91%8D -[twitter-badge]: https://img.shields.io/twitter/url/https/github.com/testing-library/svelte-testing-library.svg?style=social [emojis]: https://github.com/all-contributors/all-contributors#emoji-key [all-contributors]: https://github.com/all-contributors/all-contributors -[set-immediate]: https://developer.mozilla.org/en-US/docs/Web/API/Window/setImmediate -[guiding-principle]: https://twitter.com/kentcdodds/status/977018512689455106 -[bugs]: https://github.com/testing-library/svelte-testing-library/issues?q=is%3Aissue+is%3Aopen+label%3Abug+sort%3Acreated-desc -[requests]: https://github.com/testing-library/svelte-testing-library/issues?q=is%3Aissue+sort%3Areactions-%2B1-desc+label%3Aenhancement+is%3Aopen -[good-first-issue]: https://github.com/testing-library/svelte-testing-library/issues?utf8=✓&q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3A"good+first+issue"+ -[stackoverflow]: https://stackoverflow.com/questions/tagged/svelte-testing-library - - - diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..c03de8c --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,95 @@ +import js from '@eslint/js' +import eslintPluginVitest from '@vitest/eslint-plugin' +import eslintConfigPrettier from 'eslint-config-prettier' +import eslintPluginJestDom from 'eslint-plugin-jest-dom' +import eslintPluginPromise from 'eslint-plugin-promise' +import eslintPluginSimpleImportSort from 'eslint-plugin-simple-import-sort' +import eslintPluginSvelte from 'eslint-plugin-svelte' +import eslintPluginTestingLibrary from 'eslint-plugin-testing-library' +import eslintPluginUnicorn from 'eslint-plugin-unicorn' +import globals from 'globals' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + js.configs.recommended, + tseslint.configs.strict, + tseslint.configs.stylistic, + eslintPluginUnicorn.configs['flat/recommended'], + eslintPluginPromise.configs['flat/recommended'], + eslintPluginSvelte.configs['flat/recommended'], + eslintPluginSvelte.configs['flat/prettier'], + eslintConfigPrettier, + { + name: 'settings', + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + parserOptions: { + parser: tseslint.parser, + extraFileExtensions: ['.svelte'], + }, + globals: { + ...globals.browser, + ...globals.node, + ...globals.jest, + }, + }, + }, + { + name: 'ignores', + ignores: ['coverage', 'types'], + }, + { + name: 'simple-import-sort', + plugins: { + 'simple-import-sort': eslintPluginSimpleImportSort, + }, + rules: { + 'simple-import-sort/imports': 'error', + 'simple-import-sort/exports': 'error', + }, + }, + { + name: 'tests', + files: ['**/*.test.js'], + extends: [ + eslintPluginVitest.configs.recommended, + eslintPluginJestDom.configs['flat/recommended'], + eslintPluginTestingLibrary.configs['flat/dom'], + ], + rules: { + 'testing-library/no-node-access': [ + 'error', + { allowContainerFirstChild: true }, + ], + }, + }, + { + name: 'extras', + rules: { + 'unicorn/prevent-abbreviations': 'off', + }, + }, + { + name: 'svelte-extras', + files: ['**/*.svelte'], + rules: { + 'svelte/no-unused-svelte-ignore': 'off', + 'unicorn/filename-case': ['error', { case: 'pascalCase' }], + 'unicorn/no-useless-undefined': 'off', + }, + }, + { + name: 'ts-extras', + files: ['**/*.ts'], + extends: [ + tseslint.configs.strictTypeChecked, + tseslint.configs.stylisticTypeChecked, + ], + languageOptions: { + parserOptions: { + projectService: true, + }, + }, + } +) diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..b590229 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,29 @@ +import { VERSION as SVELTE_VERSION } from 'svelte/compiler' + +const SVELTE_TRANSFORM_PATTERN = + SVELTE_VERSION >= '5' + ? String.raw`^.+\.svelte(?:\.js)?$` + : String.raw`^.+\.svelte$` + +export default { + testMatch: ['/tests/**/*.test.js'], + transform: { + [SVELTE_TRANSFORM_PATTERN]: 'svelte-jester', + }, + moduleFileExtensions: ['js', 'svelte'], + extensionsToTreatAsEsm: ['.svelte'], + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['/tests/_jest-setup.js'], + injectGlobals: false, + moduleNameMapper: { + '^vitest$': '/tests/_jest-vitest-alias.js', + [String.raw`^@testing-library\/svelte$`]: '/src/index.js', + }, + resetMocks: true, + restoreMocks: true, + collectCoverageFrom: ['/src/**/*'], + coveragePathIgnorePatterns: [ + '/src/vite.js', + '/src/vitest.js', + ], +} diff --git a/package.json b/package.json index 62be775..fea66a7 100644 --- a/package.json +++ b/package.json @@ -2,14 +2,32 @@ "name": "@testing-library/svelte", "version": "0.0.0-semantically-released", "description": "Simple and complete Svelte testing utilities that encourage good testing practices.", - "exports": "src/index.js", + "main": "src/index.js", + "exports": { + ".": { + "types": "./types/index.d.ts", + "default": "./src/index.js" + }, + "./svelte5": { + "types": "./types/index.d.ts", + "default": "./src/index.js" + }, + "./vitest": { + "types": "./types/vitest.d.ts", + "default": "./src/vitest.js" + }, + "./vite": { + "types": "./types/vite.d.ts", + "default": "./src/vite.js" + } + }, "type": "module", "types": "types/index.d.ts", "license": "MIT", "homepage": "https://github.com/testing-library/svelte-testing-library#readme", "repository": { "type": "git", - "url": "https://github.com/testing-library/svelte-testing-library" + "url": "git+https://github.com/testing-library/svelte-testing-library.git" }, "bugs": { "url": "https://github.com/testing-library/svelte-testing-library/issues" @@ -30,76 +48,81 @@ "e2e" ], "files": [ - "src/", - "types/index.d.ts" + "src", + "types" ], "scripts": { + "all": "npm-run-all contributors:generate toc format types build test:vitest:* test:jest", + "all:legacy": "npm-run-all types:legacy test:vitest:* test:jest", "toc": "doctoc README.md", - "lint": "eslint src --fix", - "test": "vitest run src", - "test:watch": "npm run test -- --watch", - "test:update": "npm run test -- --updateSnapshot --coverage", - "setup": "npm install && npm run validate", - "validate": "npm-run-all lint test", + "lint": "prettier . --check && eslint .", + "format": "prettier . --write && eslint . --fix", + "setup": "npm run install:5 && npm run all", + "test": "vitest run --coverage", + "test:watch": "vitest", + "test:vitest:jsdom": "vitest run --coverage --environment jsdom", + "test:vitest:happy-dom": "vitest run --coverage --environment happy-dom", + "test:jest": "npx --node-options=\"--experimental-vm-modules --no-warnings\" jest --coverage", + "types": "svelte-check", + "types:legacy": "svelte-check --tsconfig tsconfig.legacy.json", + "build": "tsc -p tsconfig.build.json && cp src/component-types.d.ts types", "contributors:add": "all-contributors add", - "contributors:generate": "all-contributors generate" + "contributors:generate": "all-contributors generate", + "preview-release": "./scripts/preview-release", + "install:3": "./scripts/install-dependencies 3", + "install:4": "./scripts/install-dependencies 4", + "install:5": "./scripts/install-dependencies 5" }, "peerDependencies": { - "svelte": "^3 || ^4" + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } }, "dependencies": { - "@testing-library/dom": "^8.1.0" + "@testing-library/dom": "9.x.x || 10.x.x" }, "devDependencies": { - "@commitlint/cli": "^13.1.0", - "@commitlint/config-conventional": "^13.1.0", - "@sveltejs/vite-plugin-svelte": "^2.4.1", - "@testing-library/jest-dom": "^5.16.5", - "@vitest/coverage-c8": "^0.32.0", - "all-contributors-cli": "^6.9.0", - "doctoc": "^2.0.0", - "eslint": "^8.42.0", - "eslint-plugin-import": "^2.27.5", - "eslint-plugin-n": "^16.0.0", - "eslint-plugin-promise": "^6.1.1", - "eslint-plugin-simple-import-sort": "^10.0.0", - "eslint-config-standard": "^17.1.0", - "eslint-plugin-svelte": "^2.30.0", - "eslint-plugin-vitest-globals": "^1.3.1", - "husky": "^7.0.1", - "jsdom": "^22.1.0", - "lint-staged": "^11.1.1", + "@eslint/js": "^9.26.0", + "@jest/globals": "^29.7.0", + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.6.1", + "@vitest/coverage-v8": "^3.1.3", + "@vitest/eslint-plugin": "^1.1.44", + "all-contributors-cli": "^6.26.1", + "doctoc": "^2.2.1", + "eslint": "^9.26.0", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-jest-dom": "^5.5.0", + "eslint-plugin-promise": "^7.2.1", + "eslint-plugin-simple-import-sort": "^12.1.1", + "eslint-plugin-svelte": "^3.5.1", + "eslint-plugin-testing-library": "^7.1.1", + "eslint-plugin-unicorn": "^59.0.1", + "expect-type": "^1.2.1", + "globals": "^16.1.0", + "happy-dom": "^17.4.6", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jsdom": "^26.1.0", "npm-run-all": "^4.1.5", - "prettier": "^2.0.1", - "svelte": "^3.59.1", - "vite": "^4.3.9", - "vitest": "^0.32.0" - }, - "husky": { - "hooks": { - "pre-commit": "lint-staged", - "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" - } - }, - "lint-staged": { - "README.md": [ - "npm run toc", - "prettier --parser markdown --write", - "git add" - ], - ".all-contributorsrc": [ - "npm run contributors:generate", - "git add" - ], - "**/*.js": [ - "npm run lint", - "npm test", - "git add" - ] - }, - "commitlint": { - "extends": [ - "@commitlint/config-conventional" - ] + "prettier": "^3.5.3", + "prettier-plugin-svelte": "^3.3.3", + "svelte": "^5.28.2", + "svelte-check": "^4.1.7", + "svelte-jester": "^5.0.0", + "typescript": "^5.8.3", + "typescript-eslint": "^8.32.0", + "typescript-svelte-plugin": "^0.3.46", + "vite": "^6.3.5", + "vitest": "^3.1.3" } } diff --git a/prettier.config.js b/prettier.config.js new file mode 100644 index 0000000..8c5a52d --- /dev/null +++ b/prettier.config.js @@ -0,0 +1,14 @@ +export default { + semi: false, + singleQuote: true, + trailingComma: 'es5', + plugins: ['prettier-plugin-svelte'], + overrides: [ + { + files: '*.svelte', + options: { + parser: 'svelte', + }, + }, + ], +} diff --git a/release.config.js b/release.config.js new file mode 100644 index 0000000..7aeece8 --- /dev/null +++ b/release.config.js @@ -0,0 +1,4 @@ +export default { + preset: 'conventionalcommits', + branches: ['main', { name: 'next', prerelease: true }], +} diff --git a/scripts/install-dependencies b/scripts/install-dependencies new file mode 100755 index 0000000..aef6257 --- /dev/null +++ b/scripts/install-dependencies @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Install dependencies for a given version of Svelte +set -euxo pipefail + +svelte_version="${1-}" +node_version=$(node --version | sed 's/^v\([0-9]*\).*/\1/') +env_dir="tests/envs/svelte$svelte_version" +env_dir_by_node="$env_dir/node$node_version" + +if [[ -d $env_dir_by_node ]]; then + env_dir="$env_dir_by_node" +fi + +if [[ "$svelte_version" == "5" ]]; then + rm -rf coverage node_modules + npm install + exit 0 +fi + +if [[ -z "$svelte_version" ]]; then + echo "Invalid usage: missing Svelte version" >&2; + exit 1 +fi + +if [[ ! -d "$env_dir" ]]; then + echo "Error: package.json for Svelte $svelte_version, Node $node_version not found" 1>&2 + exit 2 +fi + +rm -rf coverage node_modules "$env_dir/node_modules" +pushd "$env_dir" +npm install --no-package-lock --engine-strict +npm ls "$env_dir" svelte +popd +mv "$env_dir/node_modules" . diff --git a/scripts/preview-release b/scripts/preview-release new file mode 100755 index 0000000..82ebdee --- /dev/null +++ b/scripts/preview-release @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Preview the next release from a branch +# +# Prerequisites: +# - You must have push access to repository at the `origin` URL +# - The branch you are on must exist on `origin` + +set -euxo pipefail + +branch="$(git rev-parse --abbrev-ref HEAD)" +repository_url="$(git remote get-url origin)" + +npx \ + --package semantic-release@24 \ + --package conventional-changelog-conventionalcommits@8 \ + -- \ + semantic-release \ + --plugins="@semantic-release/commit-analyzer,@semantic-release/release-notes-generator" \ + --dry-run \ + --branches="$branch" \ + --repository-url="$repository_url" diff --git a/src/__tests__/__snapshots__/auto-cleanup-skip.test.js.snap b/src/__tests__/__snapshots__/auto-cleanup-skip.test.js.snap deleted file mode 100644 index 2631c86..0000000 --- a/src/__tests__/__snapshots__/auto-cleanup-skip.test.js.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`auto-cleanup-skip > second 1`] = `""`; diff --git a/src/__tests__/__snapshots__/render.test.js.snap b/src/__tests__/__snapshots__/render.test.js.snap deleted file mode 100644 index a31cf2e..0000000 --- a/src/__tests__/__snapshots__/render.test.js.snap +++ /dev/null @@ -1,25 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`render > should accept svelte component options 1`] = ` - -
-

- Hello - World - ! -

- -
- we have context -
- - - -
-
- -`; diff --git a/src/__tests__/act.test.js b/src/__tests__/act.test.js deleted file mode 100644 index 6eafc0d..0000000 --- a/src/__tests__/act.test.js +++ /dev/null @@ -1,56 +0,0 @@ -import { beforeEach, describe, expect, test } from 'vitest' - -import { act, fireEvent, render as stlRender } from '..' -import Comp from './fixtures/Comp.svelte' - -describe('act', () => { - let props - - const render = () => { - return stlRender(Comp, { - props - }) - } - - beforeEach(() => { - props = { - name: 'World' - } - }) - - test('state updates are flushed', async () => { - const { getByText } = render() - const button = getByText('Button') - - expect(button).toHaveTextContent('Button') - - await act(() => { - button.click() - }) - - expect(button).toHaveTextContent('Button Clicked') - }) - - test('findByTestId returns the element', async () => { - const { findByTestId } = render() - - expect(await findByTestId('test')).toHaveTextContent(`Hello ${props.name}!`) - }) - - test('accepts async functions', async () => { - const sleep = (ms) => - new Promise((resolve) => { - setTimeout(() => resolve(), ms) - }) - - const { getByText } = render() - const button = getByText('Button') - - await act(async () => { - await sleep(100) - await fireEvent.click(button) - }) - - expect(button).toHaveTextContent('Button Clicked') - }) -}) diff --git a/src/__tests__/auto-cleanup-skip.test.js b/src/__tests__/auto-cleanup-skip.test.js deleted file mode 100644 index 265f55b..0000000 --- a/src/__tests__/auto-cleanup-skip.test.js +++ /dev/null @@ -1,23 +0,0 @@ -import { beforeAll, describe, expect, test } from 'vitest' - -import Comp from './fixtures/Comp.svelte' - -describe('auto-cleanup-skip', () => { - let render - - beforeAll(async () => { - process.env.STL_SKIP_AUTO_CLEANUP = 'true' - const stl = await import('..') - render = stl.render - }) - - // This one verifies that if STL_SKIP_AUTO_CLEANUP is set - // then we DON'T auto-wire up the afterEach for folks - test('first', () => { - render(Comp, { props: { name: 'world' } }) - }) - - test('second', () => { - expect(document.body.innerHTML).toMatchSnapshot() - }) -}) diff --git a/src/__tests__/auto-cleanup.test.js b/src/__tests__/auto-cleanup.test.js deleted file mode 100644 index 349ee39..0000000 --- a/src/__tests__/auto-cleanup.test.js +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, test } from 'vitest' - -import { render } from '..' -import Comp from './fixtures/Comp.svelte' - -describe('auto-cleanup', () => { - // This just verifies that by importing STL in an - // environment which supports afterEach (like jest) - // we'll get automatic cleanup between tests. - test('first', () => { - render(Comp, { props: { name: 'world' } }) - }) - - test('second', () => { - expect(document.body.innerHTML).toEqual('') - }) -}) - -describe('cleanup of two components', () => { - // This just verifies that by importing STL in an - // environment which supports afterEach (like jest) - // we'll get automatic cleanup between tests. - test('first', () => { - render(Comp, { props: { name: 'world' } }) - render(Comp, { props: { name: 'universe' } }) - }) - - test('second', () => { - expect(document.body.innerHTML).toEqual('') - }) -}) diff --git a/src/__tests__/debug.test.js b/src/__tests__/debug.test.js deleted file mode 100644 index 1072a0f..0000000 --- a/src/__tests__/debug.test.js +++ /dev/null @@ -1,24 +0,0 @@ -import { prettyDOM } from '@testing-library/dom' -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' - -import { render } from '..' -import Comp from './fixtures/Comp.svelte' - -describe('debug', () => { - beforeEach(() => { - vi.spyOn(console, 'log').mockImplementation(() => { }) - }) - - afterEach(() => { - console.log.mockRestore() - }) - - test('pretty prints the container', () => { - const { container, debug } = render(Comp, { props: { name: 'world' } }) - - debug() - - expect(console.log).toHaveBeenCalledTimes(1) - expect(console.log).toHaveBeenCalledWith(prettyDOM(container)) - }) -}) diff --git a/src/__tests__/fixtures/Comp2.svelte b/src/__tests__/fixtures/Comp2.svelte deleted file mode 100644 index 104e81a..0000000 --- a/src/__tests__/fixtures/Comp2.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - - - -

Hello {name}!

- - diff --git a/src/__tests__/fixtures/Stopwatch.svelte b/src/__tests__/fixtures/Stopwatch.svelte deleted file mode 100644 index 2c275e5..0000000 --- a/src/__tests__/fixtures/Stopwatch.svelte +++ /dev/null @@ -1,40 +0,0 @@ - - - - -{lapse}ms - - - - diff --git a/src/__tests__/render.test.js b/src/__tests__/render.test.js deleted file mode 100644 index 6beb984..0000000 --- a/src/__tests__/render.test.js +++ /dev/null @@ -1,104 +0,0 @@ -import { beforeEach, describe, expect, test } from 'vitest' - -import { act, render as stlRender } from '..' -import Comp from './fixtures/Comp.svelte' -import CompDefault from './fixtures/Comp2.svelte' - -describe('render', () => { - let props - - const render = (additional = {}) => { - return stlRender(Comp, { - target: document.body, - props, - ...additional - }) - } - - beforeEach(() => { - props = { - name: 'World' - } - }) - - test('renders component into the document', () => { - const { getByText } = render() - - expect(getByText('Hello World!')).toBeInTheDocument() - }) - - // Dear reader, this is not something you generally want to do in your tests. - test('programmatically change props', async () => { - const { component, getByText } = render() - - expect(getByText('Hello World!')).toBeInTheDocument() - - await act(() => { - component.$set({ name: 'Worlds' }) - }) - - expect(getByText('Hello Worlds!')).toBeInTheDocument() - }) - - test('change props with accessors', async () => { - const { component, getByText } = render({ accessors: true }) - - expect(getByText('Hello World!')).toBeInTheDocument() - - expect(component.name).toBe('World') - - await act(() => { - component.value = 'Planet' - }) - - expect(getByText('Hello World!')).toBeInTheDocument() - }) - - test('should accept props directly', () => { - const { getByText } = stlRender(Comp, { name: 'World' }) - expect(getByText('Hello World!')).toBeInTheDocument() - }) - - test('should accept svelte component options', () => { - const target = document.createElement('div') - const div = document.createElement('div') - document.body.appendChild(target) - target.appendChild(div) - const { container } = stlRender(Comp, { - target, - anchor: div, - props: { name: 'World' }, - context: new Map([['name', 'context']]) - }) - expect(container).toMatchSnapshot() - }) - - test('should throw error when mixing svelte component options and props', () => { - expect(() => { - stlRender(Comp, { anchor: '', name: 'World' }) - }).toThrow(/Unknown options were found/) - }) - - test('should return a container object, which contains the DOM of the rendered component', () => { - const { container } = render() - - expect(container.innerHTML).toBe(document.body.innerHTML) - }) - - test('correctly find component constructor on the default property', () => { - const { getByText } = render(CompDefault, { props: { name: 'World' } }) - - expect(getByText('Hello World!')).toBeInTheDocument() - }) - - test("accept the 'context' option", () => { - const { getByText } = stlRender(Comp, { - props: { - name: 'Universe' - }, - context: new Map([['name', 'context']]) - }) - - expect(getByText('we have context')).toBeInTheDocument() - }) -}) diff --git a/src/__tests__/rerender.test.js b/src/__tests__/rerender.test.js deleted file mode 100644 index e3eddb2..0000000 --- a/src/__tests__/rerender.test.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { describe, expect, test } from 'vitest' - -import { render } from '..' -import Comp from './fixtures/Comp.svelte' - -describe('rerender', () => { - test('mounts new component successfully', () => { - const { container, rerender } = render(Comp, { props: { name: 'World 1' } }) - - expect(container.firstChild).toHaveTextContent('Hello World 1!') - rerender({ props: { name: 'World 2' } }) - expect(container.firstChild).toHaveTextContent('Hello World 2!') - }) - - test('destroys old component', () => { - let isDestroyed - - const { rerender, component } = render(Comp, { props: { name: '' } }) - - component.$$.on_destroy.push(() => { - isDestroyed = true - }) - rerender({ props: { name: '' } }) - expect(isDestroyed).toBeTruthy() - }) - - test('destroys old components on multiple rerenders', () => { - const { rerender, queryByText } = render(Comp, { props: { name: 'Neil' } }) - - rerender({ props: { name: 'Alex' } }) - expect(queryByText('Hello Neil!')).not.toBeInTheDocument() - rerender({ props: { name: 'Geddy' } }) - expect(queryByText('Hello Alex!')).not.toBeInTheDocument() - }) -}) diff --git a/src/__tests__/unmount.test.js b/src/__tests__/unmount.test.js deleted file mode 100644 index 86a29dd..0000000 --- a/src/__tests__/unmount.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, test, vi } from 'vitest' - -import { act, fireEvent, render } from '..' -import Stopwatch from './fixtures/Stopwatch.svelte' - -describe('unmount', () => { - test('unmounts component successfully', async () => { - console.warn = vi.fn() - - const { unmount, getByText, container } = render(Stopwatch) - - await fireEvent.click(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('
') - - await act() - expect(console.warn).not.toHaveBeenCalled() - }) - - test('destroying component directly and calling unmount does not log warning', async () => { - console.warn = vi.fn() - - const { unmount, component } = render(Stopwatch) - - component.$destroy() - unmount() - - expect(console.warn).not.toHaveBeenCalled() - }) -}) diff --git a/src/component-types.d.ts b/src/component-types.d.ts new file mode 100644 index 0000000..007f1e4 --- /dev/null +++ b/src/component-types.d.ts @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { + Component as ModernComponent, + ComponentConstructorOptions as LegacyConstructorOptions, + ComponentProps, + EventDispatcher, + mount, + SvelteComponent as LegacyComponent, + SvelteComponentTyped as Svelte3LegacyComponent, +} from 'svelte' + +type IS_MODERN_SVELTE = ModernComponent extends (...args: any[]) => any + ? true + : false + +type IS_LEGACY_SVELTE_4 = + EventDispatcher extends (...args: any[]) => any ? true : false + +/** A compiled, imported Svelte component. */ +export type Component< + P extends Record = any, + E extends Record = any, +> = IS_MODERN_SVELTE extends true + ? ModernComponent | LegacyComponent

+ : IS_LEGACY_SVELTE_4 extends true + ? LegacyComponent

+ : Svelte3LegacyComponent

+ +/** + * The type of an imported, compiled Svelte component. + * + * In Svelte 5, this distinction no longer matters. + * In Svelte 4, this is the Svelte component class constructor. + */ +export type ComponentType = C extends LegacyComponent + ? new (...args: any[]) => C + : C + +/** The props of a component. */ +export type Props = ComponentProps + +/** + * The exported fields of a component. + * + * In Svelte 5, this is the set of variables marked as `export`'d. + * In Svelte 4, this is simply the instance of the component class. + */ +export type Exports = IS_MODERN_SVELTE extends true + ? C extends ModernComponent + ? E + : C & { $set: never; $on: never; $destroy: never } + : C + +/** + * Options that may be passed to `mount` when rendering the component. + * + * In Svelte 4, these are the options passed to the component constructor. + */ +export type MountOptions = IS_MODERN_SVELTE extends true + ? Parameters, Exports>>[1] + : LegacyConstructorOptions> diff --git a/src/core/index.js b/src/core/index.js new file mode 100644 index 0000000..9e41adf --- /dev/null +++ b/src/core/index.js @@ -0,0 +1,19 @@ +/** + * Rendering core for svelte-testing-library. + * + * Defines how components are added to and removed from the DOM. + * Will switch to legacy, class-based mounting logic + * if it looks like we're in a Svelte <= 4 environment. + */ +import * as LegacyCore from './legacy.js' +import * as ModernCore from './modern.svelte.js' +import { createValidateOptions } from './validate-options.js' + +const { mount, unmount, updateProps, allowedOptions } = + ModernCore.IS_MODERN_SVELTE ? ModernCore : LegacyCore + +/** Validate component options. */ +const validateOptions = createValidateOptions(allowedOptions) + +export { mount, unmount, updateProps, validateOptions } +export { UnknownSvelteOptionsError } from './validate-options.js' diff --git a/src/core/legacy.js b/src/core/legacy.js new file mode 100644 index 0000000..c9e6d1c --- /dev/null +++ b/src/core/legacy.js @@ -0,0 +1,46 @@ +/** + * Legacy rendering core for svelte-testing-library. + * + * Supports Svelte <= 4. + */ + +/** Allowed options for the component constructor. */ +const allowedOptions = [ + 'target', + 'accessors', + 'anchor', + 'props', + 'hydrate', + 'intro', + 'context', +] + +/** + * Mount the component into the DOM. + * + * The `onDestroy` callback is included for strict backwards compatibility + * with previous versions of this library. It's mostly unnecessary logic. + */ +const mount = (Component, options, onDestroy) => { + const component = new Component(options) + + if (typeof onDestroy === 'function') { + component.$$.on_destroy.push(() => { + onDestroy(component) + }) + } + + return component +} + +/** Remove the component from the DOM. */ +const unmount = (component) => { + component.$destroy() +} + +/** Update the component's props. */ +const updateProps = (component, nextProps) => { + component.$set(nextProps) +} + +export { allowedOptions, mount, unmount, updateProps } diff --git a/src/core/modern.svelte.js b/src/core/modern.svelte.js new file mode 100644 index 0000000..34893f5 --- /dev/null +++ b/src/core/modern.svelte.js @@ -0,0 +1,51 @@ +/** + * Modern rendering core for svelte-testing-library. + * + * Supports Svelte >= 5. + */ +import * as Svelte from 'svelte' + +/** Props signals for each rendered component. */ +const propsByComponent = new Map() + +/** Whether we're using Svelte >= 5. */ +const IS_MODERN_SVELTE = typeof Svelte.mount === 'function' + +/** Allowed options to the `mount` call. */ +const allowedOptions = [ + 'target', + 'anchor', + 'props', + 'events', + 'context', + 'intro', +] + +/** Mount the component into the DOM. */ +const mount = (Component, options) => { + const props = $state(options.props ?? {}) + const component = Svelte.mount(Component, { ...options, props }) + + Svelte.flushSync() + propsByComponent.set(component, props) + + return component +} + +/** Remove the component from the DOM. */ +const unmount = (component) => { + propsByComponent.delete(component) + Svelte.flushSync(() => Svelte.unmount(component)) +} + +/** + * Update the component's props. + * + * Relies on the `$state` signal added in `mount`. + */ +const updateProps = (component, nextProps) => { + const prevProps = propsByComponent.get(component) + Object.assign(prevProps, nextProps) +} + +export { allowedOptions, IS_MODERN_SVELTE, mount, unmount, updateProps } diff --git a/src/core/validate-options.js b/src/core/validate-options.js new file mode 100644 index 0000000..c0d794b --- /dev/null +++ b/src/core/validate-options.js @@ -0,0 +1,39 @@ +class UnknownSvelteOptionsError extends TypeError { + constructor(unknownOptions, allowedOptions) { + super(`Unknown options. + + Unknown: [ ${unknownOptions.join(', ')} ] + Allowed: [ ${allowedOptions.join(', ')} ] + + To pass both Svelte options and props to a component, + or to use props that share a name with a Svelte option, + you must place all your props under the \`props\` key: + + render(Component, { props: { /** props here **/ } }) +`) + this.name = 'UnknownSvelteOptionsError' + } +} + +const createValidateOptions = (allowedOptions) => (options) => { + const isProps = !Object.keys(options).some((option) => + allowedOptions.includes(option) + ) + + if (isProps) { + return { props: options } + } + + // Check if any props and Svelte options were accidentally mixed. + const unknownOptions = Object.keys(options).filter( + (option) => !allowedOptions.includes(option) + ) + + if (unknownOptions.length > 0) { + throw new UnknownSvelteOptionsError(unknownOptions, allowedOptions) + } + + return options +} + +export { createValidateOptions, UnknownSvelteOptionsError } diff --git a/src/index.js b/src/index.js index 7688e5b..5a65c08 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,9 @@ -import { act, cleanup } from './pure' +import { act, cleanup } from './pure.js' // If we're running in a test runner that supports afterEach // 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 STL_SKIP_AUTO_CLEANUP env variable to 'true'. +// if you don't like this then set the STL_SKIP_AUTO_CLEANUP env variable. if (typeof afterEach === 'function' && !process.env.STL_SKIP_AUTO_CLEANUP) { afterEach(async () => { await act() @@ -12,4 +11,11 @@ if (typeof afterEach === 'function' && !process.env.STL_SKIP_AUTO_CLEANUP) { }) } -export * from './pure' +// export all base queries, screen, etc. +export * from '@testing-library/dom' + +// export svelte-specific functions and custom `fireEvent` +export { UnknownSvelteOptionsError } from './core/index.js' +export * from './pure.js' +// `fireEvent` must be named to take priority over wildcard from @testing-library/dom +export { fireEvent } from './pure.js' diff --git a/src/pure.js b/src/pure.js index 04d3cb0..44e68d6 100644 --- a/src/pure.js +++ b/src/pure.js @@ -1,138 +1,179 @@ import { - fireEvent as dtlFireEvent, + fireEvent as baseFireEvent, getQueriesForElement, - prettyDOM + prettyDOM, } from '@testing-library/dom' import { tick } from 'svelte' -const containerCache = new Set() -const componentCache = new Set() - -const svelteComponentOptions = [ - 'accessors', - 'anchor', - 'props', - 'hydrate', - 'intro', - 'context' -] - -const render = ( - Component, - { target, ...options } = {}, - { container, queries } = {} -) => { - container = container || document.body - target = target || container.appendChild(document.createElement('div')) - - const ComponentConstructor = Component.default || Component - - const checkProps = (options) => { - const isProps = !Object.keys(options).some((option) => - svelteComponentOptions.includes(option) - ) - - // Check if any props and Svelte options were accidentally mixed. - if (!isProps) { - const unrecognizedOptions = Object.keys(options).filter( - (option) => !svelteComponentOptions.includes(option) - ) - - if (unrecognizedOptions.length > 0) { - throw Error(` - Unknown options were found [${unrecognizedOptions}]. This might happen if you've mixed - passing in props with Svelte options into the render function. Valid Svelte options - are [${svelteComponentOptions}]. You can either change the prop names, or pass in your - props for that component via the \`props\` option.\n\n - Eg: const { /** Results **/ } = render(MyComponent, { props: { /** props here **/ } })\n\n - `) - } +import { mount, unmount, updateProps, validateOptions } from './core/index.js' - return options - } - - return { props: options } - } +const targetCache = new Set() +const componentCache = new Set() - let component = new ComponentConstructor({ - target, - ...checkProps(options) - }) +/** + * Customize how Svelte renders the component. + * + * @template {import('./component-types.js').Component} C + * @typedef {import('./component-types.js').Props | Partial>} SvelteComponentOptions + */ + +/** + * Customize how Testing Library sets up the document and binds queries. + * + * @template {import('@testing-library/dom').Queries} [Q=typeof import('@testing-library/dom').queries] + * @typedef {{ + * baseElement?: HTMLElement + * queries?: Q + * }} RenderOptions + */ + +/** + * The rendered component and bound testing functions. + * + * @template {import('./component-types.js').Component} C + * @template {import('@testing-library/dom').Queries} [Q=typeof import('@testing-library/dom').queries] + * + * @typedef {{ + * container: HTMLElement + * baseElement: HTMLElement + * component: import('./component-types.js').Exports + * debug: (el?: HTMLElement | DocumentFragment) => void + * rerender: (props: Partial>) => Promise + * unmount: () => void + * } & { + * [P in keyof Q]: import('@testing-library/dom').BoundFunction + * }} RenderResult + */ + +/** + * Render a component into the document. + * + * @template {import('./component-types.js').Component} C + * @template {import('@testing-library/dom').Queries} [Q=typeof import('@testing-library/dom').queries] + * + * @param {import('./component-types.js').ComponentType} Component - The component to render. + * @param {SvelteComponentOptions} options - Customize how Svelte renders the component. + * @param {RenderOptions} renderOptions - Customize how Testing Library sets up the document and binds queries. + * @returns {RenderResult} The rendered component and bound testing functions. + */ +const render = (Component, options = {}, renderOptions = {}) => { + options = validateOptions(options) + + const baseElement = + renderOptions.baseElement ?? options.target ?? document.body + + const queries = getQueriesForElement(baseElement, renderOptions.queries) + + const target = + // eslint-disable-next-line unicorn/prefer-dom-node-append + options.target ?? baseElement.appendChild(document.createElement('div')) + + targetCache.add(target) + + const component = mount( + Component.default ?? Component, + { ...options, target }, + cleanupComponent + ) - containerCache.add({ container, target, component }) componentCache.add(component) - component.$$.on_destroy.push(() => { - componentCache.delete(component) - }) - return { - container, + baseElement, component, - debug: (el = container) => console.log(prettyDOM(el)), - rerender: (options) => { - if (componentCache.has(component)) component.$destroy() - - // eslint-disable-next-line no-new - component = new ComponentConstructor({ - target, - ...checkProps(options) - }) - - containerCache.add({ container, target, component }) - componentCache.add(component) - - component.$$.on_destroy.push(() => { - componentCache.delete(component) - }) + container: target, + debug: (el = baseElement) => { + console.log(prettyDOM(el)) + }, + rerender: async (props) => { + if (props.props) { + console.warn( + 'rerender({ props: {...} }) deprecated, use rerender({...}) instead' + ) + props = props.props + } + + updateProps(component, props) + await tick() }, unmount: () => { - if (componentCache.has(component)) component.$destroy() + cleanupComponent(component) }, - ...getQueriesForElement(container, queries) + ...queries, } } -const cleanupAtContainer = (cached) => { - const { target, component } = cached +/** Remove a component from the component cache. */ +const cleanupComponent = (component) => { + const inCache = componentCache.delete(component) - if (componentCache.has(component)) component.$destroy() - - if (target.parentNode === document.body) { - document.body.removeChild(target) + if (inCache) { + unmount(component) } +} + +/** Remove a target element from the target cache. */ +const cleanupTarget = (target) => { + const inCache = targetCache.delete(target) - containerCache.delete(cached) + if (inCache && target.parentNode === document.body) { + target.remove() + } } +/** Unmount all components and remove elements added to ``. */ const cleanup = () => { - Array.from(containerCache.keys()).forEach(cleanupAtContainer) + for (const component of componentCache) { + cleanupComponent(component) + } + for (const target of targetCache) { + cleanupTarget(target) + } } -const act = (fn) => { - const value = fn && fn() - if (value !== undefined && typeof value.then === 'function') { - return value.then(() => tick()) +/** + * Call a function and wait for Svelte to flush pending changes. + * + * @param {() => unknown} [fn] - A function, which may be `async`, to call before flushing updates. + * @returns {Promise} + */ +const act = async (fn) => { + if (fn) { + await fn() } return tick() } +/** + * @typedef {(...args: Parameters) => Promise>} FireFunction + */ + +/** + * @typedef {{ + * [K in import('@testing-library/dom').EventType]: (...args: Parameters) => Promise> + * }} FireObject + */ + +/** + * Fire an event on an element. + * + * Consider using `@testing-library/user-event` instead, if possible. + * @see https://testing-library.com/docs/user-event/intro/ + * + * @type {FireFunction & FireObject} + */ const fireEvent = async (...args) => { - const event = dtlFireEvent(...args) + const event = baseFireEvent(...args) await tick() return event } -Object.keys(dtlFireEvent).forEach((key) => { +for (const [key, baseEvent] of Object.entries(baseFireEvent)) { fireEvent[key] = async (...args) => { - const event = dtlFireEvent[key](...args) + const event = baseEvent(...args) await tick() return event } -}) - -/* eslint-disable import/export */ - -export * from '@testing-library/dom' +} -export { render, cleanup, fireEvent, act } +export { act, cleanup, fireEvent, render } diff --git a/src/test-setup.js b/src/test-setup.js deleted file mode 100644 index fb76eb6..0000000 --- a/src/test-setup.js +++ /dev/null @@ -1,11 +0,0 @@ -import * as matchers from '@testing-library/jest-dom/dist/matchers' -import { afterEach, expect } from 'vitest' - -import { act, cleanup } from './pure' - -expect.extend(matchers) - -afterEach(async () => { - await act() - cleanup() -}) diff --git a/src/vite.js b/src/vite.js new file mode 100644 index 0000000..b57c886 --- /dev/null +++ b/src/vite.js @@ -0,0 +1,123 @@ +import path from 'node:path' +import url from 'node:url' + +/** + * Vite plugin to configure @testing-library/svelte. + * + * Ensures Svelte is imported correctly in tests + * and that the DOM is cleaned up after each test. + * + * @param {{resolveBrowser?: boolean, autoCleanup?: boolean, noExternal?: boolean}} options + * @returns {import('vite').Plugin} + */ +export const svelteTesting = ({ + resolveBrowser = true, + autoCleanup = true, + noExternal = true, +} = {}) => ({ + name: 'vite-plugin-svelte-testing-library', + config: (config) => { + if (!process.env.VITEST) { + return + } + + if (resolveBrowser) { + addBrowserCondition(config) + } + + if (autoCleanup) { + addAutoCleanup(config) + } + + if (noExternal) { + addNoExternal(config) + } + }, +}) + +/** + * Add `browser` to `resolve.conditions` before `node`. + * + * This ensures that Svelte's browser code is used in tests, + * rather than its SSR code. + * + * @param {import('vitest/config').UserConfig} config + */ +const addBrowserCondition = (config) => { + const resolve = config.resolve ?? {} + const conditions = resolve.conditions ?? [] + const nodeConditionIndex = conditions.indexOf('node') + const browserConditionIndex = conditions.indexOf('browser') + + if ( + nodeConditionIndex !== -1 && + (nodeConditionIndex < browserConditionIndex || browserConditionIndex === -1) + ) { + conditions.splice(nodeConditionIndex, 0, 'browser') + } + + resolve.conditions = conditions + config.resolve = resolve +} + +/** + * Add auto-cleanup file to Vitest's setup files. + * + * @param {import('vitest/config').UserConfig} config + */ +const addAutoCleanup = (config) => { + const test = config.test ?? {} + let setupFiles = test.setupFiles ?? [] + + if (test.globals) { + return + } + + if (typeof setupFiles === 'string') { + setupFiles = [setupFiles] + } + + setupFiles.push( + path.join(path.dirname(url.fileURLToPath(import.meta.url)), './vitest.js') + ) + + test.setupFiles = setupFiles + config.test = test +} + +/** + * Add `@testing-library/svelte` to Vite's noExternal rules, if not present. + * + * This ensures `@testing-library/svelte` is processed by `@sveltejs/vite-plugin-svelte` + * in certain monorepo setups. + */ +const addNoExternal = (config) => { + const ssr = config.ssr ?? {} + let noExternal = ssr.noExternal ?? [] + + if (noExternal === true) { + return + } + + if (typeof noExternal === 'string' || noExternal instanceof RegExp) { + noExternal = [noExternal] + } + + if (!Array.isArray(noExternal)) { + return + } + + for (const rule of noExternal) { + if (typeof rule === 'string' && rule === '@testing-library/svelte') { + return + } + + if (rule instanceof RegExp && rule.test('@testing-library/svelte')) { + return + } + } + + noExternal.push('@testing-library/svelte') + ssr.noExternal = noExternal + config.ssr = ssr +} diff --git a/src/vitest.js b/src/vitest.js new file mode 100644 index 0000000..71977e6 --- /dev/null +++ b/src/vitest.js @@ -0,0 +1,7 @@ +import { act, cleanup } from '@testing-library/svelte' +import { afterEach } from 'vitest' + +afterEach(async () => { + await act() + cleanup() +}) diff --git a/svelte.config.js b/svelte.config.js index 61eb947..b0683fd 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -1,7 +1,7 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' export default { - // Consult https://svelte.dev/docs#compile-time-svelte-preprocess - // for more information about preprocessors - preprocess: vitePreprocess(), + // Consult https://svelte.dev/docs#compile-time-svelte-preprocess + // for more information about preprocessors + preprocess: vitePreprocess(), } diff --git a/tests/_env.js b/tests/_env.js new file mode 100644 index 0000000..96b30f4 --- /dev/null +++ b/tests/_env.js @@ -0,0 +1,26 @@ +import { VERSION as SVELTE_VERSION } from 'svelte/compiler' + +export const IS_JSDOM = globalThis.navigator.userAgent.includes('jsdom') + +export const IS_HAPPYDOM = !IS_JSDOM // right now it's happy or js + +export const IS_JEST = Boolean(process.env.JEST_WORKER_ID) + +export const IS_SVELTE_5 = SVELTE_VERSION >= '5' + +export const MODE_LEGACY = 'legacy' + +export const MODE_RUNES = 'runes' + +export const COMPONENT_FIXTURES = [ + { + mode: MODE_LEGACY, + component: './fixtures/Comp.svelte', + isEnabled: true, + }, + { + mode: MODE_RUNES, + component: './fixtures/CompRunes.svelte', + isEnabled: IS_SVELTE_5, + }, +].filter(({ isEnabled }) => isEnabled) diff --git a/tests/_jest-setup.js b/tests/_jest-setup.js new file mode 100644 index 0000000..d1c255c --- /dev/null +++ b/tests/_jest-setup.js @@ -0,0 +1,9 @@ +import '@testing-library/jest-dom/jest-globals' + +import { afterEach } from '@jest/globals' +import { act, cleanup } from '@testing-library/svelte' + +afterEach(async () => { + await act() + cleanup() +}) diff --git a/tests/_jest-vitest-alias.js b/tests/_jest-vitest-alias.js new file mode 100644 index 0000000..6628c80 --- /dev/null +++ b/tests/_jest-vitest-alias.js @@ -0,0 +1,25 @@ +import { describe, jest, test } from '@jest/globals' + +export { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, + jest as vi, +} from '@jest/globals' + +// Add support for describe.skipIf and test.skipIf +describe.skipIf = (condition) => (condition ? describe.skip : describe) +test.skipIf = (condition) => (condition ? test.skip : test) + +// Add support for `stubGlobal` +jest.stubGlobal = (property, stub) => { + if (typeof stub === 'function') { + jest.spyOn(globalThis, property).mockImplementation(stub) + } else { + jest.replaceProperty(globalThis, property, stub) + } +} diff --git a/tests/_vitest-setup.js b/tests/_vitest-setup.js new file mode 100644 index 0000000..a9d0dd3 --- /dev/null +++ b/tests/_vitest-setup.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest' diff --git a/tests/act.test.js b/tests/act.test.js new file mode 100644 index 0000000..9308d75 --- /dev/null +++ b/tests/act.test.js @@ -0,0 +1,33 @@ +import { setTimeout } from 'node:timers/promises' + +import { act, render, screen } from '@testing-library/svelte' +import { describe, expect, test } from 'vitest' + +import Comp from './fixtures/Comp.svelte' + +describe('act', () => { + test('state updates are flushed', async () => { + render(Comp) + const button = screen.getByText('Button') + + expect(button).toHaveTextContent('Button') + + await act(() => { + button.click() + }) + + expect(button).toHaveTextContent('Button Clicked') + }) + + test('accepts async functions', async () => { + render(Comp) + const button = screen.getByText('Button') + + await act(async () => { + await setTimeout(100) + button.click() + }) + + expect(button).toHaveTextContent('Button Clicked') + }) +}) diff --git a/tests/auto-cleanup.test.js b/tests/auto-cleanup.test.js new file mode 100644 index 0000000..d6ba28b --- /dev/null +++ b/tests/auto-cleanup.test.js @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import { IS_JEST } from './_env.js' + +// TODO(mcous, 2024-12-08): clearing module cache and re-importing +// in Jest breaks Svelte's environment checking heuristics. +// Re-implement this test in a more accurate environment, without mocks. +describe.skipIf(IS_JEST)('auto-cleanup', () => { + const globalAfterEach = vi.fn() + + beforeEach(() => { + vi.resetModules() + globalThis.afterEach = globalAfterEach + }) + + afterEach(() => { + delete process.env.STL_SKIP_AUTO_CLEANUP + delete globalThis.afterEach + }) + + test('calls afterEach with cleanup if globally defined', async () => { + const { render } = await import('@testing-library/svelte') + + expect(globalAfterEach).toHaveBeenCalledTimes(1) + expect(globalAfterEach).toHaveBeenLastCalledWith(expect.any(Function)) + const globalCleanup = globalAfterEach.mock.lastCall[0] + + const { default: Comp } = await import('./fixtures/Comp.svelte') + render(Comp, { props: { name: 'world' } }) + await globalCleanup() + + expect(document.body).toBeEmptyDOMElement() + }) + + test('does not call afterEach if process STL_SKIP_AUTO_CLEANUP is set', async () => { + process.env.STL_SKIP_AUTO_CLEANUP = 'true' + + await import('@testing-library/svelte') + + expect(globalAfterEach).toHaveBeenCalledTimes(0) + }) +}) diff --git a/tests/cleanup.test.js b/tests/cleanup.test.js new file mode 100644 index 0000000..d0ae026 --- /dev/null +++ b/tests/cleanup.test.js @@ -0,0 +1,35 @@ +import { cleanup, render } from '@testing-library/svelte' +import { describe, expect, test, vi } from 'vitest' + +import Mounter from './fixtures/Mounter.svelte' + +const onExecuted = vi.fn() +const onDestroyed = vi.fn() +const renderSubject = () => render(Mounter, { onExecuted, onDestroyed }) + +describe('cleanup', () => { + test('cleanup deletes element', async () => { + renderSubject() + cleanup() + + expect(document.body).toBeEmptyDOMElement() + }) + + test('cleanup unmounts component', () => { + renderSubject() + cleanup() + + expect(onDestroyed).toHaveBeenCalledTimes(1) + }) + + test('cleanup handles unexpected errors during mount', () => { + onExecuted.mockImplementation(() => { + throw new Error('oh no!') + }) + + expect(renderSubject).toThrowError() + cleanup() + + expect(document.body).toBeEmptyDOMElement() + }) +}) diff --git a/tests/context.test.js b/tests/context.test.js new file mode 100644 index 0000000..e9d83eb --- /dev/null +++ b/tests/context.test.js @@ -0,0 +1,14 @@ +import { render, screen } from '@testing-library/svelte' +import { expect, test } from 'vitest' + +import Comp from './fixtures/Context.svelte' + +test('can set a context', () => { + const message = 'Got it' + + render(Comp, { + context: new Map(Object.entries({ foo: { message } })), + }) + + expect(screen.getByText(message)).toBeInTheDocument() +}) diff --git a/tests/debug.test.js b/tests/debug.test.js new file mode 100644 index 0000000..e2a9eda --- /dev/null +++ b/tests/debug.test.js @@ -0,0 +1,18 @@ +import { prettyDOM } from '@testing-library/dom' +import { render } from '@testing-library/svelte' +import { describe, expect, test, vi } from 'vitest' + +import Comp from './fixtures/Comp.svelte' + +describe('debug', () => { + test('pretty prints the base element', () => { + vi.stubGlobal('console', { log: vi.fn(), warn: vi.fn(), error: vi.fn() }) + + const { baseElement, debug } = render(Comp, { props: { name: 'world' } }) + + debug() + + expect(console.log).toHaveBeenCalledTimes(1) + expect(console.log).toHaveBeenCalledWith(prettyDOM(baseElement)) + }) +}) diff --git a/tests/envs/svelte3/node16/package.json b/tests/envs/svelte3/node16/package.json new file mode 100644 index 0000000..8acb921 --- /dev/null +++ b/tests/envs/svelte3/node16/package.json @@ -0,0 +1,24 @@ +{ + "private": true, + "engines": { + "node": "16.x.x" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "2.x.x", + "@testing-library/dom": "9.x.x", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.6.1", + "@vitest/coverage-v8": "0.x.x", + "expect-type": "^1.2.1", + "happy-dom": "14.x.x", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jsdom": "22.x.x", + "npm-run-all": "^4.1.5", + "svelte": "3.x.x", + "svelte-check": "3.x.x", + "svelte-jester": "3.x.x", + "vite": "4.x.x", + "vitest": "0.x.x" + } +} diff --git a/tests/envs/svelte3/package.json b/tests/envs/svelte3/package.json new file mode 100644 index 0000000..d563d97 --- /dev/null +++ b/tests/envs/svelte3/package.json @@ -0,0 +1,24 @@ +{ + "private": true, + "engines": { + "node": ">=18" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "2.x.x", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.6.1", + "@vitest/coverage-v8": "0.x.x", + "expect-type": "^1.2.1", + "happy-dom": "^17.4.6", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jsdom": "^26.1.0", + "npm-run-all": "^4.1.5", + "svelte": "3.x.x", + "svelte-check": "3.x.x", + "svelte-jester": "3.x.x", + "vite": "4.x.x", + "vitest": "0.x.x" + } +} diff --git a/tests/envs/svelte4/node16/package.json b/tests/envs/svelte4/node16/package.json new file mode 100644 index 0000000..ce420a1 --- /dev/null +++ b/tests/envs/svelte4/node16/package.json @@ -0,0 +1,24 @@ +{ + "private": true, + "engines": { + "node": "16.x.x" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "2.x.x", + "@testing-library/dom": "9.x.x", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.6.1", + "@vitest/coverage-v8": "0.x.x", + "expect-type": "^1.2.1", + "happy-dom": "14.x.x", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jsdom": "22.x.x", + "npm-run-all": "^4.1.5", + "svelte": "4.x.x", + "svelte-check": "3.x.x", + "svelte-jester": "3.x.x", + "vite": "4.x.x", + "vitest": "0.x.x" + } +} diff --git a/tests/envs/svelte4/package.json b/tests/envs/svelte4/package.json new file mode 100644 index 0000000..b90351d --- /dev/null +++ b/tests/envs/svelte4/package.json @@ -0,0 +1,24 @@ +{ + "private": true, + "engines": { + "node": ">=18" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "3.x.x", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.6.1", + "@vitest/coverage-v8": "2.x.x", + "expect-type": "^1.2.1", + "happy-dom": "^17.4.6", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jsdom": "^26.1.0", + "npm-run-all": "^4.1.5", + "svelte": "4.x.x", + "svelte-check": "^4.1.7", + "svelte-jester": "^5.0.0", + "vite": "5.x.x", + "vitest": "2.x.x" + } +} diff --git a/src/__tests__/events.test.js b/tests/events.test.js similarity index 51% rename from src/__tests__/events.test.js rename to tests/events.test.js index fccf990..9864692 100644 --- a/src/__tests__/events.test.js +++ b/tests/events.test.js @@ -1,30 +1,32 @@ +import { fireEvent, render, screen } from '@testing-library/svelte' import { describe, expect, test } from 'vitest' -import { fireEvent, render } from '..' import Comp from './fixtures/Comp.svelte' describe('events', () => { test('state changes are flushed after firing an event', async () => { - const { getByText } = render(Comp, { props: { name: 'World' } }) - const button = getByText('Button') + render(Comp, { props: { name: 'World' } }) + const button = screen.getByText('Button') - await fireEvent.click(button) + const result = fireEvent.click(button) + await expect(result).resolves.toBe(true) expect(button).toHaveTextContent('Button Clicked') }) test('calling `fireEvent` directly works too', async () => { - const { getByText } = render(Comp, { props: { name: 'World' } }) - const button = getByText('Button') + render(Comp, { props: { name: 'World' } }) + const button = screen.getByText('Button') - await fireEvent( + const result = fireEvent( button, new MouseEvent('click', { bubbles: true, - cancelable: true + cancelable: true, }) ) + await expect(result).resolves.toBe(true) expect(button).toHaveTextContent('Button Clicked') }) }) diff --git a/src/__tests__/fixtures/Comp.svelte b/tests/fixtures/Comp.svelte similarity index 58% rename from src/__tests__/fixtures/Comp.svelte rename to tests/fixtures/Comp.svelte index ec04c05..86d8acd 100644 --- a/src/__tests__/fixtures/Comp.svelte +++ b/tests/fixtures/Comp.svelte @@ -1,23 +1,18 @@ +

Hello {name}!

-
we have {contextName}
- diff --git a/tests/fixtures/CompRunes.svelte b/tests/fixtures/CompRunes.svelte new file mode 100644 index 0000000..77646e3 --- /dev/null +++ b/tests/fixtures/CompRunes.svelte @@ -0,0 +1,13 @@ + + +

Hello {name}!

+ + diff --git a/tests/fixtures/Context.svelte b/tests/fixtures/Context.svelte new file mode 100644 index 0000000..d6515d5 --- /dev/null +++ b/tests/fixtures/Context.svelte @@ -0,0 +1,7 @@ + + +
{ctx.message}
diff --git a/tests/fixtures/Mounter.svelte b/tests/fixtures/Mounter.svelte new file mode 100644 index 0000000..68f72f9 --- /dev/null +++ b/tests/fixtures/Mounter.svelte @@ -0,0 +1,19 @@ + + + diff --git a/tests/fixtures/Transitioner.svelte b/tests/fixtures/Transitioner.svelte new file mode 100644 index 0000000..2ee1557 --- /dev/null +++ b/tests/fixtures/Transitioner.svelte @@ -0,0 +1,18 @@ + + + + +{#if show} +
(introDone = true)}> + {#if introDone} +

Done

+ {:else} +

Pending

+ {/if} +
+{/if} diff --git a/tests/fixtures/Typed.svelte b/tests/fixtures/Typed.svelte new file mode 100644 index 0000000..dad8e14 --- /dev/null +++ b/tests/fixtures/Typed.svelte @@ -0,0 +1,14 @@ + + +

hello {name}

+

count: {count}

+ diff --git a/tests/fixtures/TypedRunes.svelte b/tests/fixtures/TypedRunes.svelte new file mode 100644 index 0000000..0fb690b --- /dev/null +++ b/tests/fixtures/TypedRunes.svelte @@ -0,0 +1,8 @@ + + +

hello {name}

+

count: {count}

diff --git a/tests/mount.test.js b/tests/mount.test.js new file mode 100644 index 0000000..df6351d --- /dev/null +++ b/tests/mount.test.js @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/svelte' +import { describe, expect, test, vi } from 'vitest' + +import Mounter from './fixtures/Mounter.svelte' + +const onMounted = vi.fn() +const onDestroyed = vi.fn() +const renderSubject = () => render(Mounter, { onMounted, onDestroyed }) + +describe('mount and destroy', () => { + test('component is mounted', async () => { + renderSubject() + + const content = screen.getByRole('button') + + expect(content).toBeInTheDocument() + expect(onMounted).toHaveBeenCalledTimes(1) + }) + + test('component is destroyed', async () => { + const { unmount } = renderSubject() + + unmount() + + const content = screen.queryByRole('button') + + expect(content).not.toBeInTheDocument() + expect(onDestroyed).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/__tests__/multi-base.test.js b/tests/multi-base.test.js similarity index 81% rename from src/__tests__/multi-base.test.js rename to tests/multi-base.test.js index 39f28d1..bf5fd4e 100644 --- a/src/__tests__/multi-base.test.js +++ b/tests/multi-base.test.js @@ -1,6 +1,6 @@ +import { render } from '@testing-library/svelte' import { describe, expect, test } from 'vitest' -import { render } from '..' import Comp from './fixtures/Comp.svelte' describe('multi-base', () => { @@ -13,11 +13,11 @@ describe('multi-base', () => { { target: treeA, props: { - name: 'Tree A' - } + name: 'Tree A', + }, }, { - container: treeA + baseElement: treeA, } ) @@ -26,11 +26,11 @@ describe('multi-base', () => { { target: treeB, props: { - name: 'Tree B' - } + name: 'Tree B', + }, }, { - container: treeB + baseElement: treeB, } ) diff --git a/tests/render-runes.test-d.ts b/tests/render-runes.test-d.ts new file mode 100644 index 0000000..1e42544 --- /dev/null +++ b/tests/render-runes.test-d.ts @@ -0,0 +1,69 @@ +import * as subject from '@testing-library/svelte' +import { expectTypeOf } from 'expect-type' +import { describe, test, vi } from 'vitest' + +import LegacyComponent from './fixtures/Typed.svelte' +import Component from './fixtures/TypedRunes.svelte' + +describe('types', () => { + test('render is a function that accepts a Svelte component', () => { + subject.render(Component, { name: 'Alice', count: 42 }) + subject.render(Component, { props: { name: 'Alice', count: 42 } }) + }) + + test('rerender is a function that accepts partial props', async () => { + const { rerender } = subject.render(Component, { name: 'Alice', count: 42 }) + + await rerender({ name: 'Bob' }) + await rerender({ count: 0 }) + }) + + test('invalid prop types are rejected', () => { + // @ts-expect-error: name should be a string + subject.render(Component, { name: 42 }) + + // @ts-expect-error: name should be a string + subject.render(Component, { props: { name: 42 } }) + }) + + test('render result has container and component', () => { + const result = subject.render(Component, { name: 'Alice', count: 42 }) + + expectTypeOf(result).toExtend<{ + container: HTMLElement + component: { hello: string } + debug: (el?: HTMLElement) => void + rerender: (props: { name?: string; count?: number }) => Promise + unmount: () => void + }>() + }) +}) + +describe('legacy component types', () => { + test('render accepts events', () => { + const onGreeting = vi.fn() + subject.render(LegacyComponent, { + props: { name: 'Alice', count: 42 }, + events: { greeting: onGreeting }, + }) + }) + + test('component $set and $on are not allowed', () => { + const onGreeting = vi.fn() + const { component } = subject.render(LegacyComponent, { + name: 'Alice', + count: 42, + }) + + expectTypeOf(component).toExtend<{ hello: string }>() + + // @ts-expect-error: Svelte 5 mount does not return `$set` + component.$on('greeting', onGreeting) + + // @ts-expect-error: Svelte 5 mount does not return `$set` + component.$set({ name: 'Bob' }) + + // @ts-expect-error: Svelte 5 mount does not return `$destroy` + component.$destroy() + }) +}) diff --git a/tests/render-utilities.test-d.ts b/tests/render-utilities.test-d.ts new file mode 100644 index 0000000..c72f761 --- /dev/null +++ b/tests/render-utilities.test-d.ts @@ -0,0 +1,65 @@ +import * as subject from '@testing-library/svelte' +import { expectTypeOf } from 'expect-type' +import { describe, test } from 'vitest' + +import Component from './fixtures/Comp.svelte' + +describe('render query and utility types', () => { + test('render result has default queries', () => { + const result = subject.render(Component, { name: 'Alice' }) + + expectTypeOf(result.getByRole).parameters.toExtend< + [role: subject.ByRoleMatcher, options?: subject.ByRoleOptions] + >() + }) + + test('render result can have custom queries', () => { + const [getByVibes] = subject.buildQueries( + (_container: HTMLElement, vibes: string) => { + throw new Error(`unimplemented ${vibes}`) + }, + () => '', + () => '' + ) + const result = subject.render( + Component, + { name: 'Alice' }, + { queries: { getByVibes } } + ) + + expectTypeOf(result.getByVibes).parameters.toExtend<[vibes: string]>() + }) + + test('act is an async function', () => { + expectTypeOf(subject.act).toExtend<() => Promise>() + }) + + test('act accepts a sync function', () => { + expectTypeOf(subject.act).toExtend<(fn: () => void) => Promise>() + }) + + test('act accepts an async function', () => { + expectTypeOf(subject.act).toExtend< + (fn: () => Promise) => Promise + >() + }) + + test('fireEvent is an async function', () => { + expectTypeOf(subject.fireEvent).toExtend< + ( + element: Element | Node | Document | Window, + event: Event + ) => Promise + >() + }) + + test('fireEvent[eventName] is an async function', () => { + expectTypeOf(subject.fireEvent.click).toExtend< + ( + element: Element | Node | Document | Window, + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + options?: {} + ) => Promise + >() + }) +}) diff --git a/tests/render.test-d.ts b/tests/render.test-d.ts new file mode 100644 index 0000000..eb1c639 --- /dev/null +++ b/tests/render.test-d.ts @@ -0,0 +1,58 @@ +import * as subject from '@testing-library/svelte' +import { expectTypeOf } from 'expect-type' +import { ComponentProps } from 'svelte' +import { describe, test } from 'vitest' + +import Component from './fixtures/Typed.svelte' + +describe('types', () => { + test('render is a function that accepts a Svelte component', () => { + subject.render(Component, { name: 'Alice', count: 42 }) + subject.render(Component, { props: { name: 'Alice', count: 42 } }) + }) + + test('rerender is a function that accepts partial props', async () => { + const { rerender } = subject.render(Component, { name: 'Alice', count: 42 }) + + await rerender({ name: 'Bob' }) + await rerender({ count: 0 }) + }) + + test('non-components are rejected', () => { + // eslint-disable-next-line @typescript-eslint/no-extraneous-class + class NotComponent {} + + // @ts-expect-error: component should be a Svelte component + subject.render(NotComponent) + }) + + test('invalid prop types are rejected', () => { + // @ts-expect-error: name should be a string + subject.render(Component, { name: 42 }) + + // @ts-expect-error: name should be a string + subject.render(Component, { props: { name: 42 } }) + }) + + test('render result has container and component', () => { + const result = subject.render(Component, { name: 'Alice', count: 42 }) + + expectTypeOf(result).toExtend<{ + container: HTMLElement + component: { hello: string } + debug: (el?: HTMLElement) => void + rerender: (props: { name?: string; count?: number }) => Promise + unmount: () => void + }>() + }) + + test('render function may be wrapped', () => { + const renderSubject = (props: ComponentProps) => { + return subject.render(Component, props) + } + + renderSubject({ name: 'Alice', count: 42 }) + // @ts-expect-error: name should be a string + renderSubject(Component, { name: 42 }) + }) +}) diff --git a/tests/render.test.js b/tests/render.test.js new file mode 100644 index 0000000..19bf2c3 --- /dev/null +++ b/tests/render.test.js @@ -0,0 +1,86 @@ +import { render, screen } from '@testing-library/svelte' +import { beforeAll, describe, expect, test } from 'vitest' + +import { COMPONENT_FIXTURES } from './_env.js' + +describe.each(COMPONENT_FIXTURES)('render ($mode)', ({ component }) => { + const props = { name: 'World' } + let Comp + + beforeAll(async () => { + Comp = await import(component) + }) + + test('renders component into the document', () => { + render(Comp, { props }) + + expect(screen.getByText('Hello World!')).toBeInTheDocument() + }) + + test('accepts props directly', () => { + render(Comp, props) + expect(screen.getByText('Hello World!')).toBeInTheDocument() + }) + + test('throws error when mixing svelte component options and props', () => { + expect(() => { + render(Comp, { props, name: 'World' }) + }).toThrow(/Unknown options/) + }) + + test('throws error when mixing target option and props', () => { + expect(() => { + render(Comp, { target: document.createElement('div'), name: 'World' }) + }).toThrow(/Unknown options/) + }) + + test('should return a container object wrapping the DOM of the rendered component', () => { + const { container } = render(Comp, props) + const firstElement = screen.getByTestId('test') + + expect(container.firstChild).toBe(firstElement) + }) + + test('should return a baseElement object, which holds the container', () => { + const { baseElement, container } = render(Comp, props) + + expect(baseElement).toBe(document.body) + expect(baseElement.firstChild).toBe(container) + }) + + test('if target is provided, use it as container and baseElement', () => { + const target = document.createElement('div') + const { baseElement, container } = render(Comp, { props, target }) + + expect(container).toBe(target) + expect(baseElement).toBe(target) + }) + + test('allow baseElement to be specified', () => { + const customBaseElement = document.createElement('div') + + const { baseElement, container } = render( + Comp, + { props }, + { baseElement: customBaseElement } + ) + + expect(baseElement).toBe(customBaseElement) + expect(baseElement.firstChild).toBe(container) + }) + + test('should accept anchor option', () => { + const baseElement = document.body + const target = document.createElement('section') + const anchor = document.createElement('div') + baseElement.append(target) + target.append(anchor) + + render(Comp, { props, target, anchor }, { baseElement }) + const firstElement = screen.getByTestId('test') + + expect(target.firstChild).toBe(firstElement) + // eslint-disable-next-line testing-library/no-node-access + expect(target.lastChild).toBe(anchor) + }) +}) diff --git a/tests/rerender.test.js b/tests/rerender.test.js new file mode 100644 index 0000000..2fdff0d --- /dev/null +++ b/tests/rerender.test.js @@ -0,0 +1,54 @@ +import { act, render, screen } from '@testing-library/svelte' +import { beforeAll, describe, expect, test, vi } from 'vitest' + +import { COMPONENT_FIXTURES, IS_SVELTE_5, MODE_RUNES } from './_env.js' + +describe.each(COMPONENT_FIXTURES)('rerender ($mode)', ({ mode, component }) => { + let Comp + + beforeAll(async () => { + Comp = await import(component) + }) + + test('updates props', async () => { + const { rerender } = render(Comp, { name: 'World' }) + const element = screen.getByText('Hello World!') + + await rerender({ name: 'Dolly' }) + + expect(element).toHaveTextContent('Hello Dolly!') + }) + + test('warns if incorrect arguments shape used', async () => { + vi.stubGlobal('console', { log: vi.fn(), warn: vi.fn(), error: vi.fn() }) + + const { rerender } = render(Comp, { name: 'World' }) + const element = screen.getByText('Hello World!') + + await rerender({ props: { name: 'Dolly' } }) + + expect(element).toHaveTextContent('Hello Dolly!') + expect(console.warn).toHaveBeenCalledTimes(1) + expect(console.warn).toHaveBeenCalledWith( + expect.stringMatching(/deprecated/iu) + ) + }) + + test.skipIf(mode === MODE_RUNES)('change props with accessors', async () => { + const componentOptions = IS_SVELTE_5 + ? { name: 'World' } + : { accessors: true, props: { name: 'World' } } + + const { component } = render(Comp, componentOptions) + const element = screen.getByText('Hello World!') + + expect(element).toBeInTheDocument() + expect(component.name).toBe('World') + + await act(() => { + component.name = 'Planet' + }) + + expect(element).toHaveTextContent('Hello Planet!') + }) +}) diff --git a/tests/transition.test.js b/tests/transition.test.js new file mode 100644 index 0000000..27b236e --- /dev/null +++ b/tests/transition.test.js @@ -0,0 +1,32 @@ +import { render, screen, waitFor } from '@testing-library/svelte' +import { userEvent } from '@testing-library/user-event' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { IS_JSDOM, IS_SVELTE_5 } from './_env.js' +import Transitioner from './fixtures/Transitioner.svelte' + +describe.skipIf(IS_SVELTE_5)('transitions', () => { + if (IS_JSDOM) { + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', (fn) => + setTimeout(() => fn(new Date()), 16) + ) + }) + } + + test('on:introend', async () => { + const user = userEvent.setup() + + render(Transitioner) + const start = screen.getByRole('button') + await user.click(start) + + const pending = screen.getByTestId('intro-pending') + expect(pending).toBeInTheDocument() + + await waitFor(() => { + const done = screen.queryByTestId('intro-done') + expect(done).toBeInTheDocument() + }) + }) +}) diff --git a/tests/vite-plugin.test.js b/tests/vite-plugin.test.js new file mode 100644 index 0000000..92fc8f5 --- /dev/null +++ b/tests/vite-plugin.test.js @@ -0,0 +1,227 @@ +import { svelteTesting } from '@testing-library/svelte/vite' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { IS_JEST } from './_env.js' + +describe.skipIf(IS_JEST)('vite plugin', () => { + beforeEach(() => { + vi.stubEnv('VITEST', '1') + }) + + test('does not modify config if disabled', () => { + const subject = svelteTesting({ + resolveBrowser: false, + autoCleanup: false, + noExternal: false, + }) + + const result = {} + subject.config(result) + + expect(result).toEqual({}) + }) + + test('does not modify config if not Vitest', () => { + vi.stubEnv('VITEST', '') + + const subject = svelteTesting() + + const result = {} + subject.config(result) + + expect(result).toEqual({}) + }) + + test.each([ + { + config: () => ({ resolve: { conditions: ['node'] } }), + expectedConditions: ['browser', 'node'], + }, + { + config: () => ({ resolve: { conditions: ['svelte', 'node'] } }), + expectedConditions: ['svelte', 'browser', 'node'], + }, + ])( + 'adds browser condition if necessary', + ({ config, expectedConditions }) => { + const subject = svelteTesting({ + resolveBrowser: true, + autoCleanup: false, + noExternal: false, + }) + + const result = config() + subject.config(result) + + expect(result).toEqual({ + resolve: { + conditions: expectedConditions, + }, + }) + } + ) + + test.each([ + { + config: () => ({}), + expectedConditions: [], + }, + { + config: () => ({ resolve: { conditions: [] } }), + expectedConditions: [], + }, + { + config: () => ({ resolve: { conditions: ['svelte'] } }), + expectedConditions: ['svelte'], + }, + ])( + 'skips browser condition if possible', + ({ config, expectedConditions }) => { + const subject = svelteTesting({ + resolveBrowser: true, + autoCleanup: false, + noExternal: false, + }) + + const result = config() + subject.config(result) + + expect(result).toEqual({ + resolve: { + conditions: expectedConditions, + }, + }) + } + ) + + test.each([ + { + config: () => ({}), + expectedSetupFiles: [expect.stringMatching(/src\/vitest.js$/u)], + }, + { + config: () => ({ test: { setupFiles: [] } }), + expectedSetupFiles: [expect.stringMatching(/src\/vitest.js$/u)], + }, + { + config: () => ({ test: { setupFiles: 'other-file.js' } }), + expectedSetupFiles: [ + 'other-file.js', + expect.stringMatching(/src\/vitest.js$/u), + ], + }, + ])('adds cleanup', ({ config, expectedSetupFiles }) => { + const subject = svelteTesting({ + resolveBrowser: false, + autoCleanup: true, + noExternal: false, + }) + + const result = config() + subject.config(result) + + expect(result).toEqual({ + test: { + setupFiles: expectedSetupFiles, + }, + }) + }) + + test('skips cleanup in global mode', () => { + const subject = svelteTesting({ + resolveBrowser: false, + autoCleanup: true, + noExternal: false, + }) + + const result = { test: { globals: true } } + subject.config(result) + + expect(result).toEqual({ + test: { + globals: true, + }, + }) + }) + + test.each([ + { + config: () => ({ ssr: { noExternal: [] } }), + expectedNoExternal: ['@testing-library/svelte'], + }, + { + config: () => ({}), + expectedNoExternal: ['@testing-library/svelte'], + }, + { + config: () => ({ ssr: { noExternal: 'other-file.js' } }), + expectedNoExternal: ['other-file.js', '@testing-library/svelte'], + }, + { + config: () => ({ ssr: { noExternal: /other/u } }), + expectedNoExternal: [/other/u, '@testing-library/svelte'], + }, + ])('adds noExternal rule', ({ config, expectedNoExternal }) => { + const subject = svelteTesting({ + resolveBrowser: false, + autoCleanup: false, + noExternal: true, + }) + + const result = config() + subject.config(result) + + expect(result).toEqual({ + ssr: { + noExternal: expectedNoExternal, + }, + }) + }) + + test.each([ + { + config: () => ({ ssr: { noExternal: true } }), + expectedNoExternal: true, + }, + { + config: () => ({ ssr: { noExternal: '@testing-library/svelte' } }), + expectedNoExternal: '@testing-library/svelte', + }, + { + config: () => ({ ssr: { noExternal: /svelte/u } }), + expectedNoExternal: /svelte/u, + }, + ])('skips noExternal if able', ({ config, expectedNoExternal }) => { + const subject = svelteTesting({ + resolveBrowser: false, + autoCleanup: false, + noExternal: true, + }) + + const result = config() + subject.config(result) + + expect(result).toEqual({ + ssr: { + noExternal: expectedNoExternal, + }, + }) + }) + + test('bails on noExternal if input is unexpected', () => { + const subject = svelteTesting({ + resolveBrowser: false, + autoCleanup: false, + noExternal: true, + }) + + const result = { ssr: { noExternal: false } } + subject.config(result) + + expect(result).toEqual({ + ssr: { + noExternal: false, + }, + }) + }) +}) diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..bfc566b --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": ["./tsconfig.json"], + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "noEmit": false, + "rootDir": "src", + "outDir": "types" + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fda8aba --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "node16", + "allowJs": true, + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "types": ["svelte", "vite/client", "vitest", "vitest/globals"], + "baseUrl": "./", + "paths": { + "@testing-library/svelte": ["./src"] + }, + "plugins": [{ "name": "typescript-svelte-plugin" }] + }, + "include": ["src", "tests"] +} diff --git a/tsconfig.legacy.json b/tsconfig.legacy.json new file mode 100644 index 0000000..304d872 --- /dev/null +++ b/tsconfig.legacy.json @@ -0,0 +1,8 @@ +{ + "extends": ["./tsconfig.json"], + "exclude": [ + "tests/render-runes.test-d.ts", + "tests/fixtures/CompRunes.svelte", + "tests/fixtures/TypedRunes.svelte" + ] +} diff --git a/types/index.d.ts b/types/index.d.ts deleted file mode 100644 index 9b688ed..0000000 --- a/types/index.d.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Type definitions for Svelte Testing Library -// Project: https://github.com/testing-library/svelte-testing-library -// Definitions by: Rahim Alwer - -import {queries, Queries, BoundFunction, EventType} from '@testing-library/dom' -import { SvelteComponent, ComponentProps } from 'svelte/types/runtime' - -export * from '@testing-library/dom' - -type SvelteComponentOptions = ComponentProps | {props: ComponentProps} - -type Omit = Pick> - -type Constructor = new (...args: any[]) => T; - -/** - * Render a Component into the Document. - */ -export type RenderResult = { - container: HTMLElement - component: C - debug: (el?: HTMLElement | DocumentFragment) => void - rerender: (options: SvelteComponentOptions) => void - unmount: () => void -} & { [P in keyof Q]: BoundFunction } - -export interface RenderOptions { - container?: HTMLElement - queries?: Q -} - -export function render( - component: Constructor, - componentOptions?: SvelteComponentOptions, - renderOptions?: Omit -): RenderResult - -export function render( - component: Constructor, - componentOptions?: SvelteComponentOptions, - renderOptions?: RenderOptions, -): RenderResult - -/** - * Unmounts trees that were mounted with render. - */ -export function cleanup(): void - -/** - * Fires DOM events on an element provided by @testing-library/dom. Since Svelte needs to flush - * pending state changes via `tick`, these methods have been override and now return a promise. - */ -export type FireFunction = (element: Document | Element | Window, event: Event) => Promise; - -export type FireObject = { - [K in EventType]: (element: Document | Element | Window, options?: {}) => Promise; -}; - -export const fireEvent: FireFunction & FireObject; - -/** - * Calls a function or resolves a Promise and notifies Svelte to immediately flushes any pending - * state changes. - */ -export function act(fn?: Function | Promise): Promise diff --git a/vite.config.js b/vite.config.js index 65a1618..65e1ca7 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,11 +1,26 @@ -import { defineConfig } from 'vite' +import { createRequire } from 'node:module' + import { svelte } from '@sveltejs/vite-plugin-svelte' +import { svelteTesting } from '@testing-library/svelte/vite' +import { defineConfig } from 'vite' + +const require = createRequire(import.meta.url) -// https://vitejs.dev/config/ export default defineConfig({ - plugins: [svelte()], - test: { - environment: 'jsdom', - setupFiles: ['./src/test-setup.js'], + plugins: [svelte({ hot: false }), svelteTesting()], + test: { + environment: 'jsdom', + setupFiles: ['./tests/_vitest-setup.js'], + mockReset: true, + unstubGlobals: true, + unstubEnvs: true, + coverage: { + provider: 'v8', + include: ['src/**/*'], + }, + alias: { + '@testing-library/svelte/vite': require.resolve('./src/vite.js'), + '@testing-library/svelte': require.resolve('./src/index.js'), }, + }, })