import { fileURLToPath } from 'node:url' import { readFile } from 'node:fs/promises' import { basename, join, resolve } from 'pathe' import sirv from 'sirv' import type { Plugin, ViteDevServer } from 'vite' import type { ResolvedConfig } from 'vitest' import type { BrowserScript, WorkspaceProject } from 'vitest/node' import { coverageConfigDefaults } from 'vitest/config' import { slash } from '@vitest/utils' import { injectVitestModule } from './esmInjector' export default (project: WorkspaceProject, base = '/'): Plugin[] => { const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..') const distRoot = resolve(pkgRoot, 'dist') return [ { enforce: 'pre', name: 'vitest:browser', async config(viteConfig) { // Enables using ignore hint for coverage providers with @preserve keyword if (viteConfig.esbuild !== false) { viteConfig.esbuild ||= {} viteConfig.esbuild.legalComments = 'inline' } }, async configureServer(server) { const testerHtml = readFile(resolve(distRoot, 'client/tester.html'), 'utf8') const runnerHtml = readFile(resolve(distRoot, 'client/index.html'), 'utf8') const injectorJs = readFile(resolve(distRoot, 'client/esm-client-injector.js'), 'utf8') const favicon = `${base}favicon.svg` const testerPrefix = `${base}__vitest_test__/__test__/` server.middlewares.use((_req, res, next) => { const headers = server.config.server.headers if (headers) { for (const name in headers) res.setHeader(name, headers[name]!) } next() }) let indexScripts: string | undefined let testerScripts: string | undefined server.middlewares.use(async (req, res, next) => { if (!req.url) return next() const url = new URL(req.url, 'http://localhost') if (!url.pathname.startsWith(testerPrefix) && url.pathname !== base) return next() res.setHeader('Cache-Control', 'no-cache, max-age=0, must-revalidate') res.setHeader('Content-Type', 'text/html; charset=utf-8') const files = project.browserState?.files ?? [] const config = wrapConfig(project.getSerializableConfig()) config.env ??= {} config.env.VITEST_BROWSER_DEBUG = process.env.VITEST_BROWSER_DEBUG || '' const injector = replacer(await injectorJs, { __VITEST_CONFIG__: JSON.stringify(config), __VITEST_FILES__: JSON.stringify(files), __VITEST_API_TOKEN__: JSON.stringify(project.ctx.config.api.token), }) if (url.pathname === base) { if (!indexScripts) indexScripts = await formatScripts(project.config.browser.indexScripts, server) const html = replacer(await runnerHtml, { __VITEST_FAVICON__: favicon, __VITEST_TITLE__: 'Vitest Browser Runner', __VITEST_SCRIPTS__: indexScripts, __VITEST_INJECTOR__: injector, }) res.write(html, 'utf-8') res.end() return } const decodedTestFile = decodeURIComponent(url.pathname.slice(testerPrefix.length)) // if decoded test file is "__vitest_all__" or not in the list of known files, run all tests const tests = decodedTestFile === '__vitest_all__' || !files.includes(decodedTestFile) ? '__vitest_browser_runner__.files' : JSON.stringify([decodedTestFile]) if (!testerScripts) testerScripts = await formatScripts(project.config.browser.testerScripts, server) const html = replacer(await testerHtml, { __VITEST_FAVICON__: favicon, __VITEST_TITLE__: 'Vitest Browser Tester', __VITEST_SCRIPTS__: testerScripts, __VITEST_INJECTOR__: injector, __VITEST_APPEND__: // TODO: have only a single global variable to not pollute the global scope ``, }) res.write(html, 'utf-8') res.end() }) server.middlewares.use( base, sirv(resolve(distRoot, 'client'), { single: false, dev: true, }), ) const coverageFolder = resolveCoverageFolder(project) const coveragePath = coverageFolder ? coverageFolder[1] : undefined if (coveragePath && base === coveragePath) throw new Error(`The ui base path and the coverage path cannot be the same: ${base}, change coverage.reportsDirectory`) coverageFolder && server.middlewares.use(coveragePath!, sirv(coverageFolder[0], { single: true, dev: true, setHeaders: (res) => { res.setHeader('Cache-Control', 'public,max-age=0,must-revalidate') }, })) }, }, { name: 'vitest:browser:tests', enforce: 'pre', async config() { const { include, exclude, includeSource, dir, root, } = project.config const projectRoot = dir || root const entries = await project.globAllTestFiles(include, exclude, includeSource, projectRoot) return { optimizeDeps: { entries: [ ...entries, 'vitest', 'vitest/utils', 'vitest/browser', 'vitest/runners', '@vitest/utils', ], exclude: [ 'vitest', 'vitest/utils', 'vitest/browser', 'vitest/runners', '@vitest/utils', // loupe is manually transformed 'loupe', ], include: [ 'vitest > @vitest/utils > pretty-format', 'vitest > @vitest/snapshot > pretty-format', 'vitest > @vitest/snapshot > magic-string', 'vitest > diff-sequences', 'vitest > pretty-format', 'vitest > pretty-format > ansi-styles', 'vitest > pretty-format > ansi-regex', 'vitest > chai', ], }, } }, transform(code, id) { if (id.includes('loupe/loupe.js')) { const exportsList = ['custom', 'inspect', 'registerConstructor', 'registerStringTag'] const codeAppend = exportsList.map(i => `export const ${i} = globalThis.loupe.${i}`).join('\n') return `${code}\n${codeAppend}\nexport default globalThis.loupe` } }, async resolveId(id) { if (!/\?browserv=\w+$/.test(id)) return let useId = id.slice(0, id.lastIndexOf('?')) if (useId.startsWith('/@fs/')) useId = useId.slice(5) if (/^\w:/.test(useId)) useId = useId.replace(/\\/g, '/') return useId }, }, { name: 'vitest:browser:esm-injector', enforce: 'post', transform(source, id) { const hijackESM = project.config.browser.slowHijackESM ?? false if (!hijackESM) return return injectVitestModule(source, id, this.parse) }, }, ] } function resolveCoverageFolder(project: WorkspaceProject) { const options = project.ctx.config const htmlReporter = options.coverage?.enabled ? options.coverage.reporter.find((reporter) => { if (typeof reporter === 'string') return reporter === 'html' return reporter[0] === 'html' }) : undefined if (!htmlReporter) return undefined // reportsDirectory not resolved yet const root = resolve( options.root || options.root || process.cwd(), options.coverage.reportsDirectory || coverageConfigDefaults.reportsDirectory, ) const subdir = (Array.isArray(htmlReporter) && htmlReporter.length > 1 && 'subdir' in htmlReporter[1]) ? htmlReporter[1].subdir : undefined if (!subdir || typeof subdir !== 'string') return [root, `/${basename(root)}/`] return [resolve(root, subdir), `/${basename(root)}/${subdir}/`] } function wrapConfig(config: ResolvedConfig): ResolvedConfig { return { ...config, // workaround RegExp serialization testNamePattern: config.testNamePattern ? config.testNamePattern.toString() as any as RegExp : undefined, } } function replacer(code: string, values: Record) { return code.replace(/{\s*(\w+)\s*}/g, (_, key) => values[key] ?? '') } async function formatScripts(scripts: BrowserScript[] | undefined, server: ViteDevServer) { if (!scripts?.length) return '' const promises = scripts.map(async ({ content, src, async, id, type = 'module' }, index) => { const srcLink = (src ? (await server.pluginContainer.resolveId(src))?.id : undefined) || src const transformId = srcLink || join(server.config.root, `virtual__${id || `injected-${index}.js`}`) await server.moduleGraph.ensureEntryFromUrl(transformId) const contentProcessed = content && type === 'module' ? (await server.pluginContainer.transform(content, transformId)).code : content return `` }) return (await Promise.all(promises)).join('\n') }