diff --git a/.changeset/thirty-ducks-buy.md b/.changeset/thirty-ducks-buy.md new file mode 100644 index 00000000..db7f3b21 --- /dev/null +++ b/.changeset/thirty-ducks-buy.md @@ -0,0 +1,5 @@ +--- +'sv': patch +--- + +feat: `vitest` use client and server side testing for `kit` diff --git a/packages/addons/_tests/vitest/test.ts b/packages/addons/_tests/vitest/test.ts index ed451996..f6a0166d 100644 --- a/packages/addons/_tests/vitest/test.ts +++ b/packages/addons/_tests/vitest/test.ts @@ -1,3 +1,4 @@ +import { execSync } from 'node:child_process'; import { expect } from '@playwright/test'; import { setupTest } from '../_setup/suite.ts'; import vitest from '../../vitest-addon/index.ts'; @@ -7,7 +8,10 @@ const { test, variants, prepareServer } = setupTest({ vitest }); test.concurrent.for(variants)('core - %s', async (variant, { page, ...ctx }) => { const cwd = await ctx.run(variant, { vitest: {} }); - const { close } = await prepareServer({ cwd, page }); + const { close } = await prepareServer({ cwd, page }, () => { + execSync('npm run test', { cwd, stdio: 'pipe' }); + }); + // kill server process when we're done ctx.onTestFinished(async () => await close()); diff --git a/packages/addons/vitest-addon/index.ts b/packages/addons/vitest-addon/index.ts index 4eea7f8d..c0cb0c63 100644 --- a/packages/addons/vitest-addon/index.ts +++ b/packages/addons/vitest-addon/index.ts @@ -1,5 +1,5 @@ import { dedent, defineAddon, log } from '@sveltejs/cli-core'; -import { common, exports, imports, object } from '@sveltejs/cli-core/js'; +import { array, common, exports, functions, imports, object } from '@sveltejs/cli-core/js'; import { parseJson, parseScript } from '@sveltejs/cli-core/parsers'; export default defineAddon({ @@ -7,10 +7,13 @@ export default defineAddon({ shortDescription: 'unit testing', homepage: 'https://vitest.dev', options: {}, - run: ({ sv, typescript }) => { + run: ({ sv, typescript, kit }) => { const ext = typescript ? 'ts' : 'js'; sv.devDependency('vitest', '^3.0.0'); + sv.devDependency('@testing-library/svelte', '^5.2.4'); + sv.devDependency('@testing-library/jest-dom', '^6.6.3'); + sv.devDependency('jsdom', '^25.0.1'); sv.file('package.json', (content) => { const { data, generateCode } = parseJson(content); @@ -39,62 +42,109 @@ export default defineAddon({ `; }); + if (kit) { + sv.file(`${kit.routesDirectory}/page.svelte.test.${ext}`, (content) => { + if (content) return content; + + return dedent` + import { describe, test, expect } from 'vitest'; + import '@testing-library/jest-dom/vitest'; + import { render, screen } from '@testing-library/svelte'; + import Page from './+page.svelte'; + + describe('/+page.svelte', () => { + test('should render h1', () => { + render(Page); + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + }); + }); + `; + }); + } else { + sv.file(`src/App.svelte.test.${ext}`, (content) => { + if (content) return content; + + return dedent` + import { describe, test, expect } from 'vitest'; + import '@testing-library/jest-dom/vitest'; + import { render, screen } from '@testing-library/svelte'; + import App from './App.svelte'; + + describe('App.svelte', () => { + test('should render h1', () => { + render(App); + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + }); + }); + `; + }); + } + + sv.file(`vitest-setup-client.${ext}`, (content) => { + if (content) return content; + + return dedent` + import '@testing-library/jest-dom/vitest'; + import { vi } from 'vitest'; + + // required for svelte5 + jsdom as jsdom does not support matchMedia + Object.defineProperty(window, 'matchMedia', { + writable: true, + enumerable: true, + value: vi.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }) + + // add more mocks here if you need them + `; + }); + sv.file(`vite.config.${ext}`, (content) => { const { ast, generateCode } = parseScript(content); - // find `defineConfig` import declaration for "vite" - const importDecls = ast.body.filter((n) => n.type === 'ImportDeclaration'); - const defineConfigImportDecl = importDecls.find( - (importDecl) => - (importDecl.source.value === 'vite' || importDecl.source.value === 'vitest/config') && - importDecl.importKind === 'value' && - importDecl.specifiers?.some( - (specifier) => - specifier.type === 'ImportSpecifier' && specifier.imported.name === 'defineConfig' - ) - ); - - // we'll need to replace the "vite" import for a "vitest/config" import. - // if `defineConfig` is the only specifier in that "vite" import, remove the entire import declaration - if (defineConfigImportDecl?.specifiers?.length === 1) { - const idxToRemove = ast.body.indexOf(defineConfigImportDecl); - ast.body.splice(idxToRemove, 1); - } else { - // otherwise, just remove the `defineConfig` specifier - const idxToRemove = defineConfigImportDecl?.specifiers?.findIndex( - (s) => s.type === 'ImportSpecifier' && s.imported.name === 'defineConfig' - ); - if (idxToRemove) defineConfigImportDecl?.specifiers?.splice(idxToRemove, 1); - } - - const config = common.expressionFromString('defineConfig({})'); - const defaultExport = exports.defaultExport(ast, config); + imports.addNamed(ast, '@testing-library/svelte/vite', { svelteTesting: 'svelteTesting' }); - const test = object.create({ - include: common.expressionFromString("['src/**/*.{test,spec}.{js,ts}']") + const clientObjectExpression = object.create({ + extends: common.createLiteral(`./vite.config.${ext}`), + plugins: common.expressionFromString('[svelteTesting()]'), + test: object.create({ + name: common.createLiteral('client'), + environment: common.createLiteral('jsdom'), + clearMocks: common.expressionFromString('true'), + include: common.expressionFromString("['src/**/*.svelte.{test,spec}.{js,ts}']"), + exclude: common.expressionFromString("['src/lib/server/**']"), + setupFiles: common.expressionFromString(`['./vitest-setup-client.${ext}']`) + }) + }); + const serverObjectExpression = object.create({ + extends: common.createLiteral(`./vite.config.${ext}`), + test: object.create({ + name: common.createLiteral('server'), + environment: common.createLiteral('node'), + include: common.expressionFromString("['src/**/*.{test,spec}.{js,ts}']"), + exclude: common.expressionFromString("['src/**/*.svelte.{test,spec}.{js,ts}']") + }) }); - // uses the `defineConfig` helper - if ( - defaultExport.value.type === 'CallExpression' && - defaultExport.value.arguments[0]?.type === 'ObjectExpression' - ) { - // if the previous `defineConfig` was aliased, reuse the alias for the "vitest/config" import - const importSpecifier = defineConfigImportDecl?.specifiers?.find( - (sp) => sp.type === 'ImportSpecifier' && sp.imported.name === 'defineConfig' - ); - const defineConfigAlias = (importSpecifier?.local?.name ?? 'defineConfig') as string; - imports.addNamed(ast, 'vitest/config', { defineConfig: defineConfigAlias }); - - object.properties(defaultExport.value.arguments[0], { test }); - } else if (defaultExport.value.type === 'ObjectExpression') { - // if the config is just an object expression, just add the property - object.properties(defaultExport.value, { test }); - } else { - // unexpected config shape - log.warn('Unexpected vite config for vitest add-on. Could not update.'); + const defineConfigFallback = functions.call('defineConfig', []); + const { value: defineWorkspaceCall } = exports.defaultExport(ast, defineConfigFallback); + if (defineWorkspaceCall.type !== 'CallExpression') { + log.warn('Unexpected vite config. Could not update.'); } + const vitestConfig = functions.argumentByIndex(defineWorkspaceCall, 0, object.createEmpty()); + const testObject = object.property(vitestConfig, 'test', object.createEmpty()); + + const workspaceArray = object.property(testObject, 'workspace', array.createEmpty()); + array.push(workspaceArray, clientObjectExpression); + array.push(workspaceArray, serverObjectExpression); + return generateCode(); }); }