= 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 46fd662..5a65c08 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,11 +1,9 @@
-/* eslint-disable import/export */
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()
@@ -17,5 +15,7 @@ if (typeof afterEach === 'function' && !process.env.STL_SKIP_AUTO_CLEANUP) {
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, cleanup, fireEvent, render } from './pure.js'
+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 364c225..44e68d6 100644
--- a/src/pure.js
+++ b/src/pure.js
@@ -1,157 +1,179 @@
import {
- fireEvent as dtlFireEvent,
+ fireEvent as baseFireEvent,
getQueriesForElement,
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)
- )
-
- 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
- props for that component via the \`props\` option.\n\n
- Eg: const { /** Results **/ } = render(MyComponent, { props: { /** props here **/ } })\n\n
- `)
+import { tick } from 'svelte'
+
+import { mount, unmount, updateProps, validateOptions } from './core/index.js'
+
+const targetCache = new Set()
+const componentCache = new Set()
+
+/**
+ * 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
+ )
+
+ 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 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),
- }
+ updateProps(component, props)
+ await tick()
+ },
+ unmount: () => {
+ cleanupComponent(component)
+ },
+ ...queries,
}
+}
- 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)
-
- // TODO(mcous, 2024-02-11): remove this behavior in the next major version
- component.$$.on_destroy.push(() => {
- this.componentCache.delete(component)
- })
+/** Remove a component from the component cache. */
+const cleanupComponent = (component) => {
+ const inCache = componentCache.delete(component)
- return component
+ if (inCache) {
+ unmount(component)
}
+}
- cleanupComponent(component) {
- const inCache = this.componentCache.delete(component)
+/** Remove a target element from the target cache. */
+const cleanupTarget = (target) => {
+ const inCache = targetCache.delete(target)
- if (inCache) {
- component.$destroy()
- }
+ if (inCache && target.parentNode === document.body) {
+ target.remove()
}
+}
- cleanupTarget(target) {
- const inCache = this.targetCache.delete(target)
-
- if (inCache && target.parentNode === document.body) {
- document.body.removeChild(target)
- }
+/** Unmount all components and remove elements added to ``. */
+const cleanup = () => {
+ for (const component of componentCache) {
+ cleanupComponent(component)
}
-
- cleanup() {
- this.componentCache.forEach(this.cleanupComponent.bind(this))
- this.targetCache.forEach(this.cleanupTarget.bind(this))
+ for (const target of targetCache) {
+ cleanupTarget(target)
}
}
-const instance = new SvelteTestingLibrary()
-
-export const render = instance.render.bind(instance)
-
-export const cleanup = instance.cleanup.bind(instance)
-
-export const act = async (fn) => {
+/**
+ * Call a function and wait for Svelte to flush pending changes.
+ *
+ * @param {() => unknown} [fn] - A function, which may be `async`, to call before flushing updates.
+ * @returns {Promise}
+ */
+const act = async (fn) => {
if (fn) {
await fn()
}
- return Svelte.tick()
+ return tick()
}
-export const fireEvent = async (...args) => {
- const event = dtlFireEvent(...args)
- await Svelte.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 = 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)
- await Svelte.tick()
+ const event = baseEvent(...args)
+ await tick()
return event
}
-})
+}
+
+export { act, cleanup, fireEvent, render }
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/src/vite.js b/src/vite.js
index 0062b89..b57c886 100644
--- a/src/vite.js
+++ b/src/vite.js
@@ -1,5 +1,5 @@
-import { dirname, join } from 'node:path'
-import { fileURLToPath } from 'node:url'
+import path from 'node:path'
+import url from 'node:url'
/**
* Vite plugin to configure @testing-library/svelte.
@@ -7,12 +7,13 @@ import { fileURLToPath } from 'node:url'
* Ensures Svelte is imported correctly in tests
* and that the DOM is cleaned up after each test.
*
- * @param {{resolveBrowser?: boolean, autoCleanup?: boolean}} options
+ * @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) => {
@@ -27,6 +28,10 @@ export const svelteTesting = ({
if (autoCleanup) {
addAutoCleanup(config)
}
+
+ if (noExternal) {
+ addNoExternal(config)
+ }
},
})
@@ -45,8 +50,8 @@ const addBrowserCondition = (config) => {
const browserConditionIndex = conditions.indexOf('browser')
if (
- nodeConditionIndex >= 0 &&
- (nodeConditionIndex < browserConditionIndex || browserConditionIndex < 0)
+ nodeConditionIndex !== -1 &&
+ (nodeConditionIndex < browserConditionIndex || browserConditionIndex === -1)
) {
conditions.splice(nodeConditionIndex, 0, 'browser')
}
@@ -64,12 +69,55 @@ const addAutoCleanup = (config) => {
const test = config.test ?? {}
let setupFiles = test.setupFiles ?? []
+ if (test.globals) {
+ return
+ }
+
if (typeof setupFiles === 'string') {
setupFiles = [setupFiles]
}
- setupFiles.push(join(dirname(fileURLToPath(import.meta.url)), './vitest.js'))
+ 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/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 100%
rename from src/__tests__/_vitest-setup.js
rename to tests/_vitest-setup.js
diff --git a/src/__tests__/act.test.js b/tests/act.test.js
similarity index 73%
rename from src/__tests__/act.test.js
rename to tests/act.test.js
index 75c9ded..9308d75 100644
--- a/src/__tests__/act.test.js
+++ b/tests/act.test.js
@@ -1,14 +1,14 @@
import { setTimeout } from 'node:timers/promises'
-import { act, render } from '@testing-library/svelte'
+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 () => {
- const { getByText } = render(Comp)
- const button = getByText('Button')
+ render(Comp)
+ const button = screen.getByText('Button')
expect(button).toHaveTextContent('Button')
@@ -20,8 +20,8 @@ describe('act', () => {
})
test('accepts async functions', async () => {
- const { getByText } = render(Comp)
- const button = getByText('Button')
+ render(Comp)
+ const button = screen.getByText('Button')
await act(async () => {
await setTimeout(100)
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/src/__tests__/cleanup.test.js b/tests/cleanup.test.js
similarity index 94%
rename from src/__tests__/cleanup.test.js
rename to tests/cleanup.test.js
index 7131624..d0ae026 100644
--- a/src/__tests__/cleanup.test.js
+++ b/tests/cleanup.test.js
@@ -19,7 +19,7 @@ describe('cleanup', () => {
renderSubject()
cleanup()
- expect(onDestroyed).toHaveBeenCalledOnce()
+ expect(onDestroyed).toHaveBeenCalledTimes(1)
})
test('cleanup handles unexpected errors during mount', () => {
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 da54b9d..e9d83eb 100644
--- a/src/__tests__/context.test.js
+++ b/tests/context.test.js
@@ -1,4 +1,4 @@
-import { render } from '@testing-library/svelte'
+import { render, screen } from '@testing-library/svelte'
import { expect, test } from 'vitest'
import Comp from './fixtures/Context.svelte'
@@ -6,9 +6,9 @@ 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/src/__tests__/debug.test.js b/tests/debug.test.js
similarity index 100%
rename from src/__tests__/debug.test.js
rename to tests/debug.test.js
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 70%
rename from src/__tests__/events.test.js
rename to tests/events.test.js
index e0aba4d..9864692 100644
--- a/src/__tests__/events.test.js
+++ b/tests/events.test.js
@@ -1,12 +1,12 @@
-import { fireEvent, render } from '@testing-library/svelte'
+import { fireEvent, render, screen } from '@testing-library/svelte'
import { describe, expect, test } from 'vitest'
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')
const result = fireEvent.click(button)
@@ -15,8 +15,8 @@ describe('events', () => {
})
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')
const result = fireEvent(
button,
diff --git a/src/__tests__/fixtures/Comp.svelte b/tests/fixtures/Comp.svelte
similarity index 84%
rename from src/__tests__/fixtures/Comp.svelte
rename to tests/fixtures/Comp.svelte
index ba23d88..86d8acd 100644
--- a/src/__tests__/fixtures/Comp.svelte
+++ b/tests/fixtures/Comp.svelte
@@ -1,3 +1,4 @@
+
+
+Hello {name}!
+
+
diff --git a/src/__tests__/fixtures/Context.svelte b/tests/fixtures/Context.svelte
similarity index 100%
rename from src/__tests__/fixtures/Context.svelte
rename to tests/fixtures/Context.svelte
diff --git a/src/__tests__/fixtures/Mounter.svelte b/tests/fixtures/Mounter.svelte
similarity index 91%
rename from src/__tests__/fixtures/Mounter.svelte
rename to tests/fixtures/Mounter.svelte
index 51ebcd8..68f72f9 100644
--- a/src/__tests__/fixtures/Mounter.svelte
+++ b/tests/fixtures/Mounter.svelte
@@ -16,4 +16,4 @@
})
-
+
diff --git a/src/__tests__/fixtures/Transitioner.svelte b/tests/fixtures/Transitioner.svelte
similarity index 100%
rename from src/__tests__/fixtures/Transitioner.svelte
rename to tests/fixtures/Transitioner.svelte
diff --git a/tests/fixtures/Typed.svelte b/tests/fixtures/Typed.svelte
new file mode 100644
index 0000000..dad8e14
--- /dev/null
+++ b/tests/fixtures/Typed.svelte
@@ -0,0 +1,14 @@
+
+
+hello {name}
+count: {count}
+
diff --git a/tests/fixtures/TypedRunes.svelte b/tests/fixtures/TypedRunes.svelte
new file mode 100644
index 0000000..0fb690b
--- /dev/null
+++ b/tests/fixtures/TypedRunes.svelte
@@ -0,0 +1,8 @@
+
+
+hello {name}
+count: {count}
diff --git a/src/__tests__/mount.test.js b/tests/mount.test.js
similarity index 75%
rename from src/__tests__/mount.test.js
rename to tests/mount.test.js
index 48d985f..df6351d 100644
--- a/src/__tests__/mount.test.js
+++ b/tests/mount.test.js
@@ -1,4 +1,4 @@
-import { act, render, screen } from '@testing-library/svelte'
+import { render, screen } from '@testing-library/svelte'
import { describe, expect, test, vi } from 'vitest'
import Mounter from './fixtures/Mounter.svelte'
@@ -14,20 +14,17 @@ describe('mount and destroy', () => {
const content = screen.getByRole('button')
expect(content).toBeInTheDocument()
- await act()
- expect(onMounted).toHaveBeenCalledOnce()
+ expect(onMounted).toHaveBeenCalledTimes(1)
})
test('component is destroyed', async () => {
const { unmount } = renderSubject()
- await act()
unmount()
const content = screen.queryByRole('button')
expect(content).not.toBeInTheDocument()
- await act()
- expect(onDestroyed).toHaveBeenCalledOnce()
+ expect(onDestroyed).toHaveBeenCalledTimes(1)
})
})
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/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/src/__tests__/render.test.js b/tests/render.test.js
similarity index 67%
rename from src/__tests__/render.test.js
rename to tests/render.test.js
index ea445d5..19bf2c3 100644
--- a/src/__tests__/render.test.js
+++ b/tests/render.test.js
@@ -1,21 +1,25 @@
-import { render } from '@testing-library/svelte'
-import { describe, expect, test } from 'vitest'
+import { render, screen } from '@testing-library/svelte'
+import { beforeAll, describe, expect, test } from 'vitest'
-import Comp from './fixtures/Comp.svelte'
-import { IS_SVELTE_5 } from './utils.js'
+import { COMPONENT_FIXTURES } from './_env.js'
-describe('render', () => {
+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', () => {
- const { getByText } = render(Comp, { props })
+ render(Comp, { props })
- expect(getByText('Hello World!')).toBeInTheDocument()
+ expect(screen.getByText('Hello World!')).toBeInTheDocument()
})
test('accepts props directly', () => {
- const { getByText } = render(Comp, props)
- expect(getByText('Hello World!')).toBeInTheDocument()
+ render(Comp, props)
+ expect(screen.getByText('Hello World!')).toBeInTheDocument()
})
test('throws error when mixing svelte component options and props', () => {
@@ -31,8 +35,8 @@ describe('render', () => {
})
test('should return a container object wrapping the DOM of the rendered component', () => {
- const { container, getByTestId } = render(Comp, props)
- const firstElement = getByTestId('test')
+ const { container } = render(Comp, props)
+ const firstElement = screen.getByTestId('test')
expect(container.firstChild).toBe(firstElement)
})
@@ -65,21 +69,18 @@ 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')
- baseElement.appendChild(target)
- target.appendChild(anchor)
+ baseElement.append(target)
+ target.append(anchor)
- const { getByTestId } = render(
- Comp,
- { props, target, anchor },
- { baseElement }
- )
- const firstElement = getByTestId('test')
+ 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/src/__tests__/rerender.test.js b/tests/rerender.test.js
similarity index 59%
rename from src/__tests__/rerender.test.js
rename to tests/rerender.test.js
index ca4b8e8..2fdff0d 100644
--- a/src/__tests__/rerender.test.js
+++ b/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, MODE_RUNES } from './_env.js'
+
+describe.each(COMPONENT_FIXTURES)('rerender ($mode)', ({ mode, 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!')
@@ -23,20 +28,19 @@ describe('rerender', () => {
await rerender({ props: { name: 'Dolly' } })
expect(element).toHaveTextContent('Hello Dolly!')
- expect(console.warn).toHaveBeenCalledOnce()
+ expect(console.warn).toHaveBeenCalledTimes(1)
expect(console.warn).toHaveBeenCalledWith(
expect.stringMatching(/deprecated/iu)
)
})
- test('change props with accessors', async () => {
- const { component, getByText } = render(
- Comp,
- SVELTE_VERSION < '5'
- ? { accessors: true, props: { name: 'World' } }
- : { name: 'World' }
- )
- const element = getByText('Hello World!')
+ 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')
diff --git a/src/__tests__/transition.test.js b/tests/transition.test.js
similarity index 76%
rename from src/__tests__/transition.test.js
rename to tests/transition.test.js
index a9c8e02..27b236e 100644
--- a/src/__tests__/transition.test.js
+++ b/tests/transition.test.js
@@ -2,16 +2,17 @@ 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'
-import { IS_JSDOM, IS_SVELTE_5 } from './utils.js'
describe.skipIf(IS_SVELTE_5)('transitions', () => {
- beforeEach(() => {
- if (!IS_JSDOM) return
-
- const raf = (fn) => setTimeout(() => fn(new Date()), 16)
- vi.stubGlobal('requestAnimationFrame', raf)
- })
+ if (IS_JSDOM) {
+ beforeEach(() => {
+ vi.stubGlobal('requestAnimationFrame', (fn) =>
+ setTimeout(() => fn(new Date()), 16)
+ )
+ })
+ }
test('on:introend', async () => {
const user = userEvent.setup()
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
index 504b256..fda8aba 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,10 +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", "types"],
+ "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 a206467..0000000
--- a/types/index.d.ts
+++ /dev/null
@@ -1,82 +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
- | Partial>>
-
-type Constructor = new (...args: any[]) => T
-
-/**
- * Render a Component into the Document.
- */
-export type RenderResult<
- C extends SvelteComponent,
- Q extends Queries = typeof queries,
-> = {
- container: HTMLElement
- baseElement: HTMLElement
- component: C
- debug: (el?: HTMLElement | DocumentFragment) => void
- rerender: (props: Partial>) => Promise
- unmount: () => void
-} & { [P in keyof Q]: BoundFunction }
-
-export interface RenderOptions {
- baseElement?: HTMLElement
- queries?: Q
-}
-
-export function render<
- C extends SvelteComponent,
- Q extends Queries = typeof queries,
->(
- 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/types/types.test-d.ts b/types/types.test-d.ts
deleted file mode 100644
index 4a42bb1..0000000
--- a/types/types.test-d.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { expectTypeOf } from 'expect-type'
-import type { ComponentProps, SvelteComponent } from 'svelte'
-import { describe, test } from 'vitest'
-
-import Simple from '../src/__tests__/fixtures/Simple.svelte'
-import * as subject from './index.js'
-
-describe('types', () => {
- test('render is a function that accepts a Svelte component', () => {
- subject.render(Simple, { name: 'Alice', count: 42 })
- subject.render(Simple, { props: { name: 'Alice', count: 42 } })
- })
-
- test('rerender is a function that accepts partial props', async () => {
- const { rerender } = subject.render(Simple, { 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(Simple, { name: 42 })
-
- // @ts-expect-error: name should be a string
- subject.render(Simple, { props: { name: 42 } })
- })
-
- test('render result has container and component', () => {
- const result = subject.render(Simple, { name: 'Alice', count: 42 })
-
- expectTypeOf(result).toMatchTypeOf<{
- container: HTMLElement
- component: SvelteComponent<{ name: string }>
- debug: (el?: HTMLElement) => void
- rerender: (props: Partial>) => Promise
- unmount: () => void
- }>()
- })
-
- test('render result has default queries', () => {
- const result = subject.render(Simple, { name: 'Alice', count: 42 })
-
- expectTypeOf(result.getByRole).parameters.toMatchTypeOf<
- [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(
- Simple,
- { name: 'Alice', count: 42 },
- { queries: { getByVibes } }
- )
-
- expectTypeOf(result.getByVibes).parameters.toMatchTypeOf<[vibes: string]>()
- })
-})
diff --git a/types/vite.d.ts b/types/vite.d.ts
deleted file mode 100644
index 470e487..0000000
--- a/types/vite.d.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import type { Plugin } from 'vite'
-
-/**
- * Vite plugin to configure @testing-library/svelte.
- *
- * Ensures Svelte is imported correctly in tests
- * and that the DOM is cleaned up after each test.
- */
-export function svelteTesting(options?: {
- resolveBrowser?: boolean
- autoCleanup?: boolean
-}): Plugin
diff --git a/vite.config.js b/vite.config.js
index 293d426..65e1ca7 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,35 +1,26 @@
-import path from 'node:path'
+import { createRequire } from 'node:module'
import { svelte } from '@sveltejs/vite-plugin-svelte'
-import { VERSION as SVELTE_VERSION } from 'svelte/compiler'
+import { svelteTesting } from '@testing-library/svelte/vite'
import { defineConfig } from 'vite'
-import { svelteTesting } from './src/vite.js'
+const require = createRequire(import.meta.url)
-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'],
+ 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'),
},
},
})