diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 39f9d9e..0000000 --- a/.eslintignore +++ /dev/null @@ -1,6 +0,0 @@ -scripts/* -.eslintignore -.prettierignore -.github/workflows/* -*.md -types diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index 91f6920..0000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,42 +0,0 @@ -module.exports = { - root: true, - env: { - browser: true, - es6: true, - }, - extends: ['standard', 'plugin:svelte/recommended', 'prettier'], - plugins: ['svelte', 'simple-import-sort'], - rules: { - 'simple-import-sort/imports': 'error', - 'simple-import-sort/exports': 'error', - }, - overrides: [ - { - files: ['*.svelte'], - parser: 'svelte-eslint-parser', - parserOptions: { - parser: '@typescript-eslint/parser', - }, - rules: { - 'no-undef-init': 'off', - 'prefer-const': 'off', - 'svelte/no-unused-svelte-ignore': 'off', - }, - }, - { - files: ['*.ts'], - parser: '@typescript-eslint/parser', - extends: [ - 'plugin:@typescript-eslint/recommended', - 'plugin:@typescript-eslint/stylistic', - 'prettier', - ], - }, - ], - parserOptions: { - ecmaVersion: 2022, - sourceType: 'module', - }, - globals: { afterEach: 'readonly', $state: 'readonly', $props: 'readonly' }, - ignorePatterns: ['!/.*'], -} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c9eb8a7..434a8bb 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -23,15 +23,6 @@ updates: development: dependency-type: 'development' - # TODO(mcous, 2024-04-30): update to ESLint v9 + flat config - ignore: - - dependency-name: 'eslint' - versions: ['>=9'] - - dependency-name: 'eslint-plugin-n' - versions: ['>=17'] - - dependency-name: 'eslint-plugin-promise' - versions: ['>=7'] - # Update GitHub Actions dependencies - package-ecosystem: 'github-actions' directory: '/' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b2ac4a..136111f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: main: # ignore all-contributors PRs if: ${{ !contains(github.head_ref, 'all-contributors') }} - name: Node ${{ matrix.node }}, Svelte ${{ matrix.svelte }}, ${{ matrix.check }} + name: Svelte ${{ matrix.svelte }}, Node ${{ matrix.node }}, ${{ matrix.check }} runs-on: ubuntu-latest # enable OIDC for codecov uploads @@ -27,19 +27,23 @@ jobs: strategy: fail-fast: false matrix: - node: ['16', '18', '20'] - svelte: ['3', '4'] + 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 - - { node: '20', svelte: '4', check: 'lint' } - # `SvelteComponent` is not generic in Svelte 3, so type-checking only passes in >= 4 - - { node: '20', svelte: '4', check: 'types:legacy' } - - { node: '20', svelte: 'next', check: 'types' } - # Only run Svelte 5 checks on latest Node - - { node: '20', svelte: 'next', check: 'test:vitest:jsdom' } - - { node: '20', svelte: 'next', check: 'test:vitest:happy-dom' } - - { node: '20', svelte: 'next', check: 'test:jest' } + # Only lint and test examples on latest Node and Svelte + - { svelte: '5', node: '22', check: 'lint' } + - { svelte: '5', node: '22', check: 'test:examples' } + # 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 @@ -51,16 +55,14 @@ jobs: node-version: ${{ matrix.node }} - name: 📥 Download deps - run: | - npm install --no-package-lock - npm install --no-save svelte@${{ matrix.svelte }} + run: npm run install:${{ matrix.svelte }} - name: ▶️ Run ${{ matrix.check }} run: npm run ${{ matrix.check }} - name: ⬆️ Upload coverage report if: ${{ startsWith(matrix.check, 'test:') }} - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: use_oidc: true fail_ci_if_error: true @@ -74,10 +76,10 @@ jobs: - name: ⎔ Setup node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: 📥 Download deps - run: npm install --no-package-lock + run: npm install - name: 🏗️ Build types run: npm run build @@ -101,7 +103,7 @@ jobs: - name: ⎔ Setup node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: 📥 Downloads types build uses: actions/download-artifact@v4 diff --git a/.gitignore b/.gitignore index 151e826..ca3410b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ public/bundle.* coverage dist .idea +*.tgz # These cause more harm than good when working with contributors yarn-error.log 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 index 5e50c20..c750ecf 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1 @@ -scripts/* -.eslintignore -.prettierignore .all-contributorsrc diff --git a/.prettierrc.yaml b/.prettierrc.yaml deleted file mode 100644 index 0a2ace3..0000000 --- a/.prettierrc.yaml +++ /dev/null @@ -1,9 +0,0 @@ -semi: false -singleQuote: true -trailingComma: es5 -plugins: - - prettier-plugin-svelte -overrides: - - files: '*.svelte' - options: - parser: svelte diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 92856ee..328bcda 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,11 +28,10 @@ npm run preview-release ## Development setup -After cloning the repository, install the project's dependencies and run the `validate` script to run all checks and tests to verify your setup. +After cloning the repository, use the `setup` script to install dependencies and run all checks: ```shell -npm install # or `pnpm install`, or `yarn install`, etc. -npm run validate +npm run setup ``` ### Lint and format @@ -40,13 +39,13 @@ npm run validate Run auto-formatting to ensure any changes adhere to the code style of the repository: ```shell -npm run format:delta +npm run format ``` To run lint and format checks without making any changes: ```shell -npm run lint:delta +npm run lint ``` ### Test @@ -58,12 +57,30 @@ 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: +Use the `docs` script to ensure the README's table of contents is up to date: ```shell -npm run toc +npm run docs ``` Use `contributors:add` to add a contributor to the README: diff --git a/README.md b/README.md index bf45331..6a5f1d9 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,10 @@
Simple and complete Svelte testing utilities that encourage good testing practices.
-[**Read The Docs**][stl-docs] | [Edit the docs][stl-docs-repo] +[**Read The Docs**][stl-docs] | [Edit the docs][stl-docs-repo] | [Examples](./examples) + [![Build Status][build-badge]][build] [![Code Coverage][coverage-badge]][coverage] [![version][version-badge]][package] @@ -29,7 +30,9 @@ [![Watch on GitHub][github-watch-badge]][github-watch] [![Star on GitHub][github-star-badge]][github-star] [![Tweet][twitter-badge]][twitter] + +Hello {name}
+{/if} diff --git a/examples/basic/basic.test.js b/examples/basic/basic.test.js new file mode 100644 index 0000000..6ea6099 --- /dev/null +++ b/examples/basic/basic.test.js @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/svelte' +import { userEvent } from '@testing-library/user-event' +import { expect, test } from 'vitest' + +import Subject from './basic.svelte' + +test('no initial greeting', () => { + render(Subject, { name: 'World' }) + + const button = screen.getByRole('button', { name: 'Greet' }) + const greeting = screen.queryByText(/hello/iu) + + expect(button).toBeInTheDocument() + expect(greeting).not.toBeInTheDocument() +}) + +test('greeting appears on click', async () => { + const user = userEvent.setup() + render(Subject, { name: 'World' }) + + const button = screen.getByRole('button') + await user.click(button) + const greeting = screen.getByText(/hello world/iu) + + expect(greeting).toBeInTheDocument() +}) diff --git a/examples/basic/readme.md b/examples/basic/readme.md new file mode 100644 index 0000000..6d98485 --- /dev/null +++ b/examples/basic/readme.md @@ -0,0 +1,68 @@ +# Basic + +This basic example demonstrates how to: + +- Pass props to your Svelte component using [render()] +- [Query][] the structure of your component's DOM elements using screen +- Interact with your component using [@testing-library/user-event][] +- Make assertions using expect, using matchers from + [@testing-library/jest-dom][] + +[query]: https://testing-library.com/docs/queries/about +[render()]: https://testing-library.com/docs/svelte-testing-library/api#render +[@testing-library/user-event]: https://testing-library.com/docs/user-event/intro +[@testing-library/jest-dom]: https://github.com/testing-library/jest-dom + +## Table of contents + +- [`basic.svelte`](#basicsvelte) +- [`basic.test.js`](#basictestjs) + +## `basic.svelte` + +```svelte file=./basic.svelte + + + + +{#if showGreeting} +Hello {name}
+{/if} +``` + +## `basic.test.js` + +```js file=./basic.test.js +import { render, screen } from '@testing-library/svelte' +import { userEvent } from '@testing-library/user-event' +import { expect, test } from 'vitest' + +import Subject from './basic.svelte' + +test('no initial greeting', () => { + render(Subject, { name: 'World' }) + + const button = screen.getByRole('button', { name: 'Greet' }) + const greeting = screen.queryByText(/hello/iu) + + expect(button).toBeInTheDocument() + expect(greeting).not.toBeInTheDocument() +}) + +test('greeting appears on click', async () => { + const user = userEvent.setup() + render(Subject, { name: 'World' }) + + const button = screen.getByRole('button') + await user.click(button) + const greeting = screen.getByText(/hello world/iu) + + expect(greeting).toBeInTheDocument() +}) +``` diff --git a/examples/binds/bind.svelte b/examples/binds/bind.svelte new file mode 100644 index 0000000..62fd858 --- /dev/null +++ b/examples/binds/bind.svelte @@ -0,0 +1,5 @@ + + + diff --git a/examples/binds/bind.test.js b/examples/binds/bind.test.js new file mode 100644 index 0000000..6d00665 --- /dev/null +++ b/examples/binds/bind.test.js @@ -0,0 +1,24 @@ +import { render, screen } from '@testing-library/svelte' +import { userEvent } from '@testing-library/user-event' +import { expect, test } from 'vitest' + +import Subject from './bind.svelte' + +test('value binding', async () => { + const user = userEvent.setup() + let value = '' + + render(Subject, { + get value() { + return value + }, + set value(nextValue) { + value = nextValue + }, + }) + + const input = screen.getByRole('textbox') + await user.type(input, 'hello world') + + expect(value).toBe('hello world') +}) diff --git a/examples/binds/no-bind.svelte b/examples/binds/no-bind.svelte new file mode 100644 index 0000000..3e4079a --- /dev/null +++ b/examples/binds/no-bind.svelte @@ -0,0 +1,9 @@ + + + diff --git a/examples/binds/readme.md b/examples/binds/readme.md new file mode 100644 index 0000000..fb7c745 --- /dev/null +++ b/examples/binds/readme.md @@ -0,0 +1,82 @@ +# Binds + +Two-way data binding using [bindable() props][] is difficult to test directly. +It's usually easier to structure your code so that you can test user-facing +results, leaving the binding as an implementation detail. + +However, if two-way binding is an important developer-facing API of your +component, you can use setters to test your binding. + +[bindable() props]: https://svelte.dev/docs/svelte/$bindable + +## Table of contents + +- [`bind.svelte`](#bindsvelte) +- [`bind.test.js`](#bindtestjs) +- [Consider avoiding binding](#consider-avoiding-binding) + +## `bind.svelte` + +```svelte file=./bind.svelte + + + +``` + +## `bind.test.js` + +```js file=./bind.test.js +import { render, screen } from '@testing-library/svelte' +import { userEvent } from '@testing-library/user-event' +import { expect, test } from 'vitest' + +import Subject from './bind.svelte' + +test('value binding', async () => { + const user = userEvent.setup() + let value = '' + + render(Subject, { + get value() { + return value + }, + set value(nextValue) { + value = nextValue + }, + }) + + const input = screen.getByRole('textbox') + await user.type(input, 'hello world') + + expect(value).toBe('hello world') +}) +``` + +## Consider avoiding binding + +Before embarking on writing tests for bindable props, consider avoiding +`bindable()` entirely. Two-way data binding can make your data flows and state +changes difficult to reason about and test effectively. Instead, you can use +value props to pass data down and callback props to pass changes back up to the +parent. + +> Well-written applications use bindings very sparingly — the vast majority of +> data flow should be top-down -- +> [Rich Harris](https://github.com/sveltejs/svelte/issues/10768#issue-2181814844) + +For example, rather than using a `bindable()` prop, use a value prop to pass the +value down and callback prop to send changes back up to the parent: + +```svelte file=./no-bind.svelte + + + +``` diff --git a/examples/contexts/context.svelte b/examples/contexts/context.svelte new file mode 100644 index 0000000..c0fbb19 --- /dev/null +++ b/examples/contexts/context.svelte @@ -0,0 +1,14 @@ + + +{message.text}
+{message.text}
++ {@render message?.(greeting)} +
diff --git a/examples/snippets/complex-snippet.test.js b/examples/snippets/complex-snippet.test.js new file mode 100644 index 0000000..55885ef --- /dev/null +++ b/examples/snippets/complex-snippet.test.js @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/svelte' +import { createRawSnippet } from 'svelte' +import { expect, test } from 'vitest' + +import Subject from './complex-snippet.svelte' + +test('renders greeting in message snippet', () => { + render(Subject, { + name: 'Alice', + message: createRawSnippet((greeting) => ({ + render: () => `${greeting()}`, + })), + }) + + const message = screen.getByTestId('message') + + expect(message).toHaveTextContent('Hello, Alice!') +}) diff --git a/examples/snippets/readme.md b/examples/snippets/readme.md new file mode 100644 index 0000000..30a70d6 --- /dev/null +++ b/examples/snippets/readme.md @@ -0,0 +1,108 @@ +# Snippets + +Snippets are difficult to test directly. It's usually easier to structure your +code so that you can test the user-facing results, leaving any snippets as an +implementation detail. However, if snippets are an important developer-facing +API of your component, there are several strategies you can use. + +## Table of contents + +- [Basic snippets example](#basic-snippets-example) + - [`basic-snippet.svelte`](#basic-snippetsvelte) + - [`basic-snippet.test.svelte`](#basic-snippettestsvelte) + - [`basic-snippet.test.js`](#basic-snippettestjs) +- [Using `createRawSnippet`](#using-createrawsnippet) + - [`complex-snippet.svelte`](#complex-snippetsvelte) + - [`complex-snippet.test.js`](#complex-snippettestjs) + +## Basic snippets example + +For simple snippets, you can use a wrapper component and "dummy" children to +test them. Setting `data-testid` attributes can be helpful when testing slots in +this manner. + +### `basic-snippet.svelte` + +```svelte file=./basic-snippet.svelte + + ++ {@render message?.(greeting)} +
+``` + +### `complex-snippet.test.js` + +```js file=./complex-snippet.test.js +import { render, screen } from '@testing-library/svelte' +import { createRawSnippet } from 'svelte' +import { expect, test } from 'vitest' + +import Subject from './complex-snippet.svelte' + +test('renders greeting in message snippet', () => { + render(Subject, { + name: 'Alice', + message: createRawSnippet((greeting) => ({ + render: () => `${greeting()}`, + })), + }) + + const message = screen.getByTestId('message') + + expect(message).toHaveTextContent('Hello, Alice!') +}) +``` diff --git a/jest.config.js b/jest.config.js index 4857b64..f4daad1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,26 +1,28 @@ import { VERSION as SVELTE_VERSION } from 'svelte/compiler' const SVELTE_TRANSFORM_PATTERN = - SVELTE_VERSION >= '5' ? '^.+\\.svelte(?:\\.js)?$' : '^.+\\.svelte$' + SVELTE_VERSION >= '5' + ? String.raw`^.+\.svelte(?:\.js)?$` + : String.raw`^.+\.svelte$` export default { - testMatch: ['count: {count}
diff --git a/src/component-types.d.ts b/src/component-types.d.ts index 9df84c2..007f1e4 100644 --- a/src/component-types.d.ts +++ b/src/component-types.d.ts @@ -1,47 +1,61 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type * as Svelte from 'svelte' +import type { + Component as ModernComponent, + ComponentConstructorOptions as LegacyConstructorOptions, + ComponentProps, + EventDispatcher, + mount, + SvelteComponent as LegacyComponent, + SvelteComponentTyped as Svelte3LegacyComponent, +} from 'svelte' -type IS_MODERN_SVELTE = any extends Svelte.Component ? false : true +type IS_MODERN_SVELTE = ModernComponent extends (...args: any[]) => any + ? true + : false + +type IS_LEGACY_SVELTE_4 = + EventDispatcher| Svelte.SvelteComponent
- : Svelte.SvelteComponent
+ ? ModernComponent
| LegacyComponent
+ : IS_LEGACY_SVELTE_4 extends true + ? LegacyComponent
+ : Svelte3LegacyComponent
/**
* The type of an imported, compiled Svelte component.
*
- * In Svelte 4, this was the Svelte component class' type.
* In Svelte 5, this distinction no longer matters.
+ * In Svelte 4, this is the Svelte component class constructor.
*/
-export type ComponentType count: {count}hello {name}
+hello {name}
diff --git a/src/__tests__/mount.test.js b/tests/mount.test.js
similarity index 100%
rename from src/__tests__/mount.test.js
rename to tests/mount.test.js
diff --git a/src/__tests__/multi-base.test.js b/tests/multi-base.test.js
similarity index 100%
rename from src/__tests__/multi-base.test.js
rename to tests/multi-base.test.js
diff --git a/src/__tests__/render-runes.test-d.ts b/tests/render-runes.test-d.ts
similarity index 54%
rename from src/__tests__/render-runes.test-d.ts
rename to tests/render-runes.test-d.ts
index 2d0c69f..1e42544 100644
--- a/src/__tests__/render-runes.test-d.ts
+++ b/tests/render-runes.test-d.ts
@@ -1,7 +1,8 @@
+import * as subject from '@testing-library/svelte'
import { expectTypeOf } from 'expect-type'
-import { describe, test } from 'vitest'
+import { describe, test, vi } from 'vitest'
-import * as subject from '../index.js'
+import LegacyComponent from './fixtures/Typed.svelte'
import Component from './fixtures/TypedRunes.svelte'
describe('types', () => {
@@ -28,7 +29,7 @@ describe('types', () => {
test('render result has container and component', () => {
const result = subject.render(Component, { name: 'Alice', count: 42 })
- expectTypeOf(result).toMatchTypeOf<{
+ expectTypeOf(result).toExtend<{
container: HTMLElement
component: { hello: string }
debug: (el?: HTMLElement) => void
@@ -37,3 +38,32 @@ describe('types', () => {
}>()
})
})
+
+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/src/__tests__/render-utilities.test-d.ts b/tests/render-utilities.test-d.ts
similarity index 74%
rename from src/__tests__/render-utilities.test-d.ts
rename to tests/render-utilities.test-d.ts
index b45614e..c72f761 100644
--- a/src/__tests__/render-utilities.test-d.ts
+++ b/tests/render-utilities.test-d.ts
@@ -1,14 +1,14 @@
+import * as subject from '@testing-library/svelte'
import { expectTypeOf } from 'expect-type'
import { describe, test } from 'vitest'
-import * as subject from '../index.js'
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.toMatchTypeOf<
+ expectTypeOf(result.getByRole).parameters.toExtend<
[role: subject.ByRoleMatcher, options?: subject.ByRoleOptions]
>()
})
@@ -27,25 +27,25 @@ describe('render query and utility types', () => {
{ queries: { getByVibes } }
)
- expectTypeOf(result.getByVibes).parameters.toMatchTypeOf<[vibes: string]>()
+ expectTypeOf(result.getByVibes).parameters.toExtend<[vibes: string]>()
})
test('act is an async function', () => {
- expectTypeOf(subject.act).toMatchTypeOf<() => Promise