= 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 e94d814..5a65c08 100644
--- a/src/index.js
+++ b/src/index.js
@@ -3,8 +3,7 @@ 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 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 6d49434..44e68d6 100644
--- a/src/pure.js
+++ b/src/pure.js
@@ -1,114 +1,142 @@
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)
+ }
}
+/**
+ * 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()
@@ -116,22 +144,36 @@ const act = async (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/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
index 135ddbe..71977e6 100644
--- a/src/vitest.js
+++ b/src/vitest.js
@@ -1,7 +1,6 @@
+import { act, cleanup } from '@testing-library/svelte'
import { afterEach } from 'vitest'
-import { act, cleanup } from './pure.js'
-
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/src/__tests__/_vitest-setup.js b/tests/_vitest-setup.js
similarity index 68%
rename from src/__tests__/_vitest-setup.js
rename to tests/_vitest-setup.js
index 13ed819..a9d0dd3 100644
--- a/src/__tests__/_vitest-setup.js
+++ b/tests/_vitest-setup.js
@@ -1,2 +1 @@
import '@testing-library/jest-dom/vitest'
-import '../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/src/__tests__/context.test.js b/tests/context.test.js
similarity index 62%
rename from src/__tests__/context.test.js
rename to tests/context.test.js
index effdef4..e9d83eb 100644
--- a/src/__tests__/context.test.js
+++ b/tests/context.test.js
@@ -1,14 +1,14 @@
+import { render, screen } from '@testing-library/svelte'
import { expect, test } from 'vitest'
-import { render } from '..'
import Comp from './fixtures/Context.svelte'
test('can set a context', () => {
const message = 'Got it'
- const { getByText } = render(Comp, {
+ render(Comp, {
context: new Map(Object.entries({ foo: { message } })),
})
- expect(getByText(message)).toBeTruthy()
+ 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}
-
{buttonText}
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}!
+
+{buttonText}
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 @@
+
+
+click me
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 @@
+
+
+ (show = true)}>Show
+
+{#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}
+ dispatch('greeting', 'hello')}>greet
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 26d85d7..0000000
--- a/types/index.d.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-// Type definitions for Svelte Testing Library
-// Project: https://github.com/testing-library/svelte-testing-library
-// Definitions by: Rahim Alwer
-
-import {BoundFunction, EventType,Queries, queries} from '@testing-library/dom'
-import { ComponentConstructorOptions,ComponentProps, SvelteComponent } from 'svelte'
-
-export * from '@testing-library/dom'
-
-type SvelteComponentOptions = ComponentProps | Pick>, "anchor" | "props" | "hydrate" | "intro" | "context">
-
-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 and notifies Svelte to flush any pending state changes.
- *
- * If the function returns a Promise, that Promise will be resolved first.
- */
-export function act(fn?: () => unknown): Promise
diff --git a/vite.config.js b/vite.config.js
index f261029..65e1ca7 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,15 +1,26 @@
+import { createRequire } from 'node:module'
+
import { svelte } from '@sveltejs/vite-plugin-svelte'
+import { svelteTesting } from '@testing-library/svelte/vite'
import { defineConfig } from 'vite'
-// https://vitejs.dev/config/
+const require = createRequire(import.meta.url)
+
export default defineConfig({
- plugins: [svelte()],
+ plugins: [svelte({ hot: false }), svelteTesting()],
test: {
environment: 'jsdom',
- setupFiles: ['./src/__tests__/_vitest-setup.js'],
+ setupFiles: ['./tests/_vitest-setup.js'],
+ mockReset: true,
+ unstubGlobals: true,
+ unstubEnvs: true,
coverage: {
provider: 'v8',
- include: ['src'],
+ include: ['src/**/*'],
+ },
+ alias: {
+ '@testing-library/svelte/vite': require.resolve('./src/vite.js'),
+ '@testing-library/svelte': require.resolve('./src/index.js'),
},
},
})