From 7a4da48669e1305bca56c498b9c3dda1244401cc Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Mon, 13 May 2024 12:23:48 -0400 Subject: [PATCH] feat(svelte5): incorporate Svelte 5 support into main entry point --- .eslintrc.cjs | 2 + README.md | 20 +-- jest.config.js | 7 - package.json | 2 +- src/__tests__/auto-cleanup.test.js | 9 +- src/__tests__/fixtures/Comp.svelte | 2 - src/__tests__/fixtures/CompRunes.svelte | 13 ++ src/__tests__/render.test.js | 14 +- src/__tests__/rerender.test.js | 26 ++-- src/__tests__/utils.js | 11 ++ src/core-legacy.js | 44 ++++++ src/core.svelte.js | 51 +++++++ src/pure.js | 188 ++++++++++-------------- src/svelte5-index.js | 23 --- src/svelte5.js | 30 ---- vite.config.js | 16 -- 16 files changed, 230 insertions(+), 228 deletions(-) create mode 100644 src/__tests__/fixtures/CompRunes.svelte create mode 100644 src/core-legacy.js create mode 100644 src/core.svelte.js delete mode 100644 src/svelte5-index.js delete mode 100644 src/svelte5.js diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 778d507..326785a 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -25,6 +25,7 @@ module.exports = { }, rules: { 'no-undef-init': 'off', + 'prefer-const': 'off', }, }, { @@ -49,5 +50,6 @@ module.exports = { ecmaVersion: 2022, sourceType: 'module', }, + globals: { $state: 'readonly', $props: 'readonly' }, ignorePatterns: ['!/.*'], } diff --git a/README.md b/README.md index 51ca77a..12a1984 100644 --- a/README.md +++ b/README.md @@ -71,11 +71,11 @@ primary guiding principle is: 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](https://github.com/testing-library/jest-dom). @@ -102,22 +102,6 @@ See the [setup docs][] for more detailed setup instructions, including for other [vitest]: https://vitest.dev/ [setup docs]: https://testing-library.com/docs/svelte-testing-library/setup -### Svelte 5 support - -If you are riding the bleeding edge of Svelte 5, you'll need to either -import from `@testing-library/svelte/svelte5` instead of `@testing-library/svelte`, or add an alias to your `vite.config.js`: - -```js -export default defineConfig({ - plugins: [svelte(), svelteTesting()], - test: { - alias: { - '@testing-library/svelte': '@testing-library/svelte/svelte5', - }, - }, -}) -``` - ## Docs See the [**docs**](https://testing-library.com/docs/svelte-testing-library/intro) over at the Testing Library website. diff --git a/jest.config.js b/jest.config.js index d6b1fde..1f09735 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,3 @@ -import { VERSION as SVELTE_VERSION } from 'svelte/compiler' - -const IS_SVELTE_5 = SVELTE_VERSION >= '5' - export default { testMatch: ['/src/__tests__/**/*.test.js'], transform: { @@ -14,9 +10,6 @@ export default { injectGlobals: false, moduleNameMapper: { '^vitest$': '/src/__tests__/_jest-vitest-alias.js', - '^@testing-library/svelte$': IS_SVELTE_5 - ? '/src/svelte5-index.js' - : '/src/index.js', }, resetMocks: true, restoreMocks: true, diff --git a/package.json b/package.json index b67db89..eebce56 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "./svelte5": { "types": "./types/index.d.ts", - "default": "./src/svelte5-index.js" + "default": "./src/index.js" }, "./vitest": { "default": "./src/vitest.js" diff --git a/src/__tests__/auto-cleanup.test.js b/src/__tests__/auto-cleanup.test.js index b06d120..803001e 100644 --- a/src/__tests__/auto-cleanup.test.js +++ b/src/__tests__/auto-cleanup.test.js @@ -1,10 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { IS_SVELTE_5 } from './utils.js' - -const importSvelteTestingLibrary = async () => - IS_SVELTE_5 ? import('../svelte5-index.js') : import('../index.js') - const globalAfterEach = vi.fn() describe('auto-cleanup', () => { @@ -19,7 +14,7 @@ describe('auto-cleanup', () => { }) test('calls afterEach with cleanup if globally defined', async () => { - const { render } = await importSvelteTestingLibrary() + const { render } = await import('../index.js') expect(globalAfterEach).toHaveBeenCalledTimes(1) expect(globalAfterEach).toHaveBeenLastCalledWith(expect.any(Function)) @@ -35,7 +30,7 @@ describe('auto-cleanup', () => { test('does not call afterEach if process STL_SKIP_AUTO_CLEANUP is set', async () => { process.env.STL_SKIP_AUTO_CLEANUP = 'true' - await importSvelteTestingLibrary() + await import('../index.js') expect(globalAfterEach).toHaveBeenCalledTimes(0) }) diff --git a/src/__tests__/fixtures/Comp.svelte b/src/__tests__/fixtures/Comp.svelte index ba23d88..18365a4 100644 --- a/src/__tests__/fixtures/Comp.svelte +++ b/src/__tests__/fixtures/Comp.svelte @@ -13,5 +13,3 @@

Hello {name}!

- - diff --git a/src/__tests__/fixtures/CompRunes.svelte b/src/__tests__/fixtures/CompRunes.svelte new file mode 100644 index 0000000..77646e3 --- /dev/null +++ b/src/__tests__/fixtures/CompRunes.svelte @@ -0,0 +1,13 @@ + + +

Hello {name}!

+ + diff --git a/src/__tests__/render.test.js b/src/__tests__/render.test.js index ea445d5..abca67b 100644 --- a/src/__tests__/render.test.js +++ b/src/__tests__/render.test.js @@ -1,11 +1,15 @@ import { render } from '@testing-library/svelte' -import { describe, expect, test } from 'vitest' +import { beforeAll, describe, expect, test } from 'vitest' -import Comp from './fixtures/Comp.svelte' -import { IS_SVELTE_5 } from './utils.js' +import { COMPONENT_FIXTURES } from './utils.js' -describe('render', () => { +describe.each(COMPONENT_FIXTURES)('render $name', ({ component }) => { const props = { name: 'World' } + let Comp + + beforeAll(async () => { + Comp = await import(component) + }) test('renders component into the document', () => { const { getByText } = render(Comp, { props }) @@ -65,7 +69,7 @@ describe('render', () => { expect(baseElement.firstChild).toBe(container) }) - test.skipIf(IS_SVELTE_5)('should accept anchor option in Svelte v4', () => { + test('should accept anchor option', () => { const baseElement = document.body const target = document.createElement('section') const anchor = document.createElement('div') diff --git a/src/__tests__/rerender.test.js b/src/__tests__/rerender.test.js index 21a782c..7f3189e 100644 --- a/src/__tests__/rerender.test.js +++ b/src/__tests__/rerender.test.js @@ -1,10 +1,15 @@ import { act, render, screen } from '@testing-library/svelte' -import { VERSION as SVELTE_VERSION } from 'svelte/compiler' -import { describe, expect, test, vi } from 'vitest' +import { beforeAll, describe, expect, test, vi } from 'vitest' -import Comp from './fixtures/Comp.svelte' +import { COMPONENT_FIXTURES, IS_SVELTE_5, TYPE_RUNES } from './utils.js' + +describe.each(COMPONENT_FIXTURES)('rerender $type', ({ type, component }) => { + let Comp + + beforeAll(async () => { + Comp = await import(component) + }) -describe('rerender', () => { test('updates props', async () => { const { rerender } = render(Comp, { name: 'World' }) const element = screen.getByText('Hello World!') @@ -29,13 +34,12 @@ describe('rerender', () => { ) }) - test('change props with accessors', async () => { - const { component, getByText } = render( - Comp, - SVELTE_VERSION < '5' - ? { accessors: true, props: { name: 'World' } } - : { name: 'World' } - ) + test.skipIf(type === TYPE_RUNES)('change props with accessors', async () => { + const componentOptions = IS_SVELTE_5 + ? { name: 'World' } + : { accessors: true, props: { name: 'World' } } + + const { component, getByText } = render(Comp, componentOptions) const element = getByText('Hello World!') expect(element).toBeInTheDocument() diff --git a/src/__tests__/utils.js b/src/__tests__/utils.js index 69be184..4fcefe0 100644 --- a/src/__tests__/utils.js +++ b/src/__tests__/utils.js @@ -5,3 +5,14 @@ export const IS_JSDOM = window.navigator.userAgent.includes('jsdom') export const IS_HAPPYDOM = !IS_JSDOM // right now it's happy or js export const IS_SVELTE_5 = SVELTE_VERSION >= '5' + +export const TYPE_LEGACY = 'legacy' + +export const TYPE_RUNES = 'runes' + +export const COMPONENT_FIXTURES = [ + { type: TYPE_LEGACY, component: './fixtures/Comp.svelte' }, + IS_SVELTE_5 + ? { type: TYPE_RUNES, component: './fixtures/CompRunes.svelte' } + : [], +].flat() diff --git a/src/core-legacy.js b/src/core-legacy.js new file mode 100644 index 0000000..4019589 --- /dev/null +++ b/src/core-legacy.js @@ -0,0 +1,44 @@ +/** + * Legacy rendering core for svelte-testing-library. + * + * Supports Svelte <= 4. See `core.js` for more details. + */ + +export const LegacyCore = { + /** Allowed options for the component constructor. */ + componentOptions: [ + '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. + */ + renderComponent: (ComponentConstructor, componentOptions, onDestroy) => { + const component = new ComponentConstructor(componentOptions) + + component.$$.on_destroy.push(() => { + onDestroy(component) + }) + + return component + }, + + /** Update the component's props. */ + updateProps: (component, nextProps) => { + component.$set(nextProps) + }, + + /** Remove the component from the DOM. */ + cleanupComponent: (component) => { + component.$destroy() + }, +} diff --git a/src/core.svelte.js b/src/core.svelte.js new file mode 100644 index 0000000..6ffb28c --- /dev/null +++ b/src/core.svelte.js @@ -0,0 +1,51 @@ +/** + * 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 Svelte from 'svelte' + +import { LegacyCore } from './core-legacy' + +const IS_MODERN_SVELTE = typeof Svelte.mount === 'function' + +/** Props signals for each rendered component. */ +const propsByComponent = new Map() + +const ModernCore = { + /** Allowed options to the `mount` call. */ + componentOptions: ['target', 'anchor', 'props', 'events', 'context', 'intro'], + + /** Mount the component into the DOM. */ + renderComponent: (ComponentConstructor, componentOptions) => { + const props = $state(componentOptions.props ?? {}) + const component = Svelte.mount(ComponentConstructor, { + ...componentOptions, + props, + }) + + propsByComponent.set(component, props) + + return component + }, + + /** + * Update the component's props. + * + * Relies on the `$state` signal added in `renderComponent`. + */ + updateProps: (component, nextProps) => { + const prevProps = propsByComponent.get(component) + Object.assign(prevProps, nextProps) + }, + + /** Remove the component from the DOM. */ + cleanupComponent: (component) => { + propsByComponent.delete(component) + Svelte.unmount(component) + }, +} + +export const Core = IS_MODERN_SVELTE ? ModernCore : LegacyCore diff --git a/src/pure.js b/src/pure.js index 364c225..8a8e4da 100644 --- a/src/pure.js +++ b/src/pure.js @@ -4,136 +4,108 @@ import { prettyDOM, } from '@testing-library/dom' import * as Svelte from 'svelte' -import { VERSION as SVELTE_VERSION } from 'svelte/compiler' - -const IS_SVELTE_5 = /^5\./.test(SVELTE_VERSION) - -export class SvelteTestingLibrary { - svelteComponentOptions = [ - 'target', - 'accessors', - 'anchor', - 'props', - 'hydrate', - 'intro', - 'context', - ] - - targetCache = new Set() - componentCache = new Set() - - checkProps(options) { - const isProps = !Object.keys(options).some((option) => - this.svelteComponentOptions.includes(option) - ) - // Check if any props and Svelte options were accidentally mixed. - if (!isProps) { - const unrecognizedOptions = Object.keys(options).filter( - (option) => !this.svelteComponentOptions.includes(option) - ) +import { Core } from './core.svelte.js' + +const targetCache = new Set() +const componentCache = new Set() + +const checkProps = (options) => { + const isProps = !Object.keys(options).some((option) => + Core.componentOptions.includes(option) + ) - if (unrecognizedOptions.length > 0) { - throw Error(` + // Check if any props and Svelte options were accidentally mixed. + if (!isProps) { + const unrecognizedOptions = Object.keys(options).filter( + (option) => !Core.componentOptions.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 [${this.svelteComponentOptions}]. You can either change the prop names, or pass in your + are [${Core.componentOptions}]. 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 `) - } - - return options } - return { props: options } - } - - render(Component, componentOptions = {}, renderOptions = {}) { - componentOptions = this.checkProps(componentOptions) - - const baseElement = - renderOptions.baseElement ?? componentOptions.target ?? document.body - - const target = - componentOptions.target ?? - baseElement.appendChild(document.createElement('div')) - - this.targetCache.add(target) - - const ComponentConstructor = Component.default || Component - - const component = this.renderComponent(ComponentConstructor, { - ...componentOptions, - target, - }) - - return { - baseElement, - 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 - } - component.$set(props) - await Svelte.tick() - }, - unmount: () => { - this.cleanupComponent(component) - }, - ...getQueriesForElement(baseElement, renderOptions.queries), - } + return options } - renderComponent(ComponentConstructor, componentOptions) { - if (IS_SVELTE_5) { - throw new Error('for Svelte 5, use `@testing-library/svelte/svelte5`') - } - - const component = new ComponentConstructor(componentOptions) - - this.componentCache.add(component) + return { props: options } +} - // TODO(mcous, 2024-02-11): remove this behavior in the next major version - component.$$.on_destroy.push(() => { - this.componentCache.delete(component) - }) +export const render = ( + Component, + componentOptions = {}, + renderOptions = {} +) => { + componentOptions = checkProps(componentOptions) + + const baseElement = + renderOptions.baseElement ?? componentOptions.target ?? document.body + + const target = + componentOptions.target ?? + baseElement.appendChild(document.createElement('div')) + + targetCache.add(target) + + const ComponentConstructor = Component.default || Component + + const component = Core.renderComponent( + ComponentConstructor, + { ...componentOptions, target }, + cleanupComponent + ) + + componentCache.add(component) + + return { + baseElement, + 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 + } - return component + Core.updateProps(component, props) + await Svelte.tick() + }, + unmount: () => { + cleanupComponent(component) + }, + ...getQueriesForElement(baseElement, renderOptions.queries), } +} - cleanupComponent(component) { - const inCache = this.componentCache.delete(component) +const cleanupComponent = (component) => { + const inCache = componentCache.delete(component) - if (inCache) { - component.$destroy() - } + if (inCache) { + Core.cleanupComponent(component) } +} - cleanupTarget(target) { - const inCache = this.targetCache.delete(target) - - if (inCache && target.parentNode === document.body) { - document.body.removeChild(target) - } - } +const cleanupTarget = (target) => { + const inCache = targetCache.delete(target) - cleanup() { - this.componentCache.forEach(this.cleanupComponent.bind(this)) - this.targetCache.forEach(this.cleanupTarget.bind(this)) + if (inCache && target.parentNode === document.body) { + document.body.removeChild(target) } } -const instance = new SvelteTestingLibrary() - -export const render = instance.render.bind(instance) - -export const cleanup = instance.cleanup.bind(instance) +export const cleanup = () => { + componentCache.forEach(cleanupComponent) + targetCache.forEach(cleanupTarget) +} export const act = async (fn) => { if (fn) { diff --git a/src/svelte5-index.js b/src/svelte5-index.js deleted file mode 100644 index ab49641..0000000 --- a/src/svelte5-index.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable import/export */ -import { act } from './pure.js' -import { cleanup } from './svelte5.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 (typeof afterEach === 'function' && !process.env.STL_SKIP_AUTO_CLEANUP) { - afterEach(async () => { - await act() - cleanup() - }) -} - -// export all base queries, screen, etc. -export * from '@testing-library/dom' - -// export svelte-specific functions and custom `fireEvent` -// `fireEvent` must be a named export to take priority over wildcard export above -export { act, fireEvent } from './pure.js' -export { cleanup, render } from './svelte5.js' diff --git a/src/svelte5.js b/src/svelte5.js deleted file mode 100644 index a8dd494..0000000 --- a/src/svelte5.js +++ /dev/null @@ -1,30 +0,0 @@ -import { createClassComponent } from 'svelte/legacy' - -import { SvelteTestingLibrary } from './pure.js' - -class Svelte5TestingLibrary extends SvelteTestingLibrary { - svelteComponentOptions = [ - 'target', - 'props', - 'events', - 'context', - 'intro', - 'recover', - ] - - renderComponent(ComponentConstructor, componentOptions) { - const component = createClassComponent({ - ...componentOptions, - component: ComponentConstructor, - }) - - this.componentCache.add(component) - - return component - } -} - -const instance = new Svelte5TestingLibrary() - -export const render = instance.render.bind(instance) -export const cleanup = instance.cleanup.bind(instance) diff --git a/vite.config.js b/vite.config.js index 293d426..ad27ef3 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,28 +1,12 @@ -import path from 'node:path' - import { svelte } from '@sveltejs/vite-plugin-svelte' -import { VERSION as SVELTE_VERSION } from 'svelte/compiler' import { defineConfig } from 'vite' import { svelteTesting } from './src/vite.js' -const IS_SVELTE_5 = SVELTE_VERSION >= '5' - -const alias = [ - { - find: '@testing-library/svelte', - replacement: path.resolve( - __dirname, - IS_SVELTE_5 ? 'src/svelte5-index.js' : 'src/index.js' - ), - }, -] - // https://vitejs.dev/config/ export default defineConfig({ plugins: [svelte({ hot: false }), svelteTesting()], test: { - alias, environment: 'jsdom', setupFiles: ['./src/__tests__/_vitest-setup.js'], mockReset: true,