mirror of
https://github.com/hanzoai/vitest.git
synced 2026-09-24 17:04:25 +00:00
643 lines
20 KiB
TypeScript
643 lines
20 KiB
TypeScript
import type { HtmlTagDescriptor } from 'vite'
|
|
import type { Plugin } from 'vitest/config'
|
|
import type { ParentBrowserProject } from './projectParent'
|
|
import { createReadStream, readFileSync } from 'node:fs'
|
|
import { createRequire } from 'node:module'
|
|
import { dynamicImportPlugin } from '@vitest/mocker/node'
|
|
import { toArray } from '@vitest/utils/helpers'
|
|
import MagicString from 'magic-string'
|
|
import { dirname, join, resolve } from 'pathe'
|
|
import sirv from 'sirv'
|
|
import {
|
|
isFileServingAllowed,
|
|
isValidApiRequest,
|
|
resolveApiServerConfig,
|
|
resolveFsAllow,
|
|
rolldownVersion,
|
|
distDir as vitestDist,
|
|
} from 'vitest/node'
|
|
import { distRoot } from './constants'
|
|
import { createOrchestratorMiddleware } from './middlewares/orchestratorMiddleware'
|
|
import { createTesterMiddleware } from './middlewares/testerMiddleware'
|
|
import BrowserContext from './plugins/pluginContext'
|
|
|
|
export type { BrowserCommand } from 'vitest/node'
|
|
|
|
const versionRegexp = /(?:\?|&)v=\w{8}/
|
|
|
|
export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => {
|
|
function isPackageExists(pkg: string, root: string) {
|
|
return parentServer.vitest.packageInstaller.isPackageExists?.(pkg, {
|
|
paths: [root],
|
|
})
|
|
}
|
|
|
|
return [
|
|
{
|
|
enforce: 'pre',
|
|
name: 'vitest:browser',
|
|
async configureServer(server) {
|
|
parentServer.setServer(server)
|
|
|
|
// eslint-disable-next-line prefer-arrow-callback
|
|
server.middlewares.use(function vitestHeaders(_req, res, next) {
|
|
const headers = server.config.server.headers
|
|
if (headers) {
|
|
for (const name in headers) {
|
|
res.setHeader(name, headers[name]!)
|
|
}
|
|
}
|
|
next()
|
|
})
|
|
// strip _vitest_original query added by importActual so that
|
|
// the plugin pipeline sees the original import id (e.g. virtual modules's load hook).
|
|
server.middlewares.use((req, _res, next) => {
|
|
if (
|
|
req.url?.includes('_vitest_original')
|
|
&& parentServer.project.config.browser.provider?.name === 'playwright'
|
|
) {
|
|
req.url = req.url
|
|
.replace(/[?&]_vitest_original(?=[&#]|$)/, '')
|
|
.replace(/[?&]ext\b[^&#]*/, '')
|
|
.replace(/\?$/, '')
|
|
}
|
|
next()
|
|
})
|
|
server.middlewares.use(createOrchestratorMiddleware(parentServer))
|
|
server.middlewares.use(createTesterMiddleware(parentServer))
|
|
|
|
server.middlewares.use(
|
|
`${base}favicon.svg`,
|
|
(_, res) => {
|
|
const content = readFileSync(resolve(distRoot, 'client/favicon.svg'))
|
|
res.write(content, 'utf-8')
|
|
res.end()
|
|
},
|
|
)
|
|
|
|
// Serve coverage HTML at ./coverage if configured
|
|
const coverageHtmlDir = parentServer.vitest.config.coverage?.htmlDir
|
|
if (coverageHtmlDir) {
|
|
server.middlewares.use(
|
|
'/__vitest_test__/coverage',
|
|
sirv(coverageHtmlDir, {
|
|
single: true,
|
|
dev: true,
|
|
setHeaders: (res) => {
|
|
const csp = res.getHeader('Content-Security-Policy')
|
|
if (typeof csp === 'string') {
|
|
// add frame-ancestors to allow the iframe to be loaded by Vitest,
|
|
// but keep the rest of the CSP
|
|
res.setHeader(
|
|
'Content-Security-Policy',
|
|
csp.replace(/frame-ancestors [^;]+/, 'frame-ancestors *'),
|
|
)
|
|
}
|
|
res.setHeader(
|
|
'Cache-Control',
|
|
'public,max-age=0,must-revalidate',
|
|
)
|
|
},
|
|
}),
|
|
)
|
|
}
|
|
|
|
server.middlewares.use((req, res, next) => {
|
|
// 9000 mega head move
|
|
// Vite always caches optimized dependencies, but users might mock
|
|
// them in _some_ tests, while keeping original modules in others
|
|
// there is no way to configure that in Vite, so we patch it here
|
|
// to always ignore the cache-control set by Vite in the next middleware
|
|
if (req.url && versionRegexp.test(req.url) && !req.url.includes('chunk-')) {
|
|
res.setHeader('Cache-Control', 'no-cache')
|
|
const setHeader = res.setHeader.bind(res)
|
|
res.setHeader = function (name, value) {
|
|
if (name === 'Cache-Control') {
|
|
return res
|
|
}
|
|
return setHeader(name, value)
|
|
}
|
|
}
|
|
next()
|
|
})
|
|
// handle attachments the same way as in packages/ui/node/index.ts
|
|
server.middlewares.use((req, res, next) => {
|
|
if (!req.url) {
|
|
return next()
|
|
}
|
|
|
|
const url = new URL(req.url, 'http://localhost')
|
|
|
|
if (url.pathname !== '/__vitest_attachment__') {
|
|
return next()
|
|
}
|
|
|
|
const path = url.searchParams.get('path')
|
|
const contentType = url.searchParams.get('contentType')
|
|
|
|
if (!isValidApiRequest(parentServer.config, req) || !contentType || !path) {
|
|
return next()
|
|
}
|
|
|
|
const fsPath = decodeURIComponent(path)
|
|
|
|
if (!isFileServingAllowed(parentServer.vite.config, fsPath)) {
|
|
return next()
|
|
}
|
|
|
|
try {
|
|
res.setHeader(
|
|
'content-type',
|
|
contentType,
|
|
)
|
|
|
|
return createReadStream(fsPath)
|
|
.pipe(res)
|
|
.on('close', () => res.end())
|
|
}
|
|
catch (err) {
|
|
return next(err)
|
|
}
|
|
})
|
|
},
|
|
},
|
|
{
|
|
name: 'vitest:browser:tests',
|
|
enforce: 'pre',
|
|
async config() {
|
|
// this plugin can be used in different projects, but all of them
|
|
// have the same `include` pattern, so it doesn't matter which project we use
|
|
const project = parentServer.project
|
|
// only glob benchmarks when a browser-enabled bench project exists in
|
|
// the workspace — keeps the optimizeDeps entries symmetrical across
|
|
// the test/bench project clones so the optimizer doesn't re-scan when
|
|
// the user switches modes
|
|
const hasBrowserBenchProject = parentServer.vitest.projects.some(p =>
|
|
p.config.browser.enabled && p.config.benchmark.enabled,
|
|
)
|
|
const benchInclude = hasBrowserBenchProject
|
|
? project.config.benchmark.include
|
|
: []
|
|
const dir = project.config.dir || project.config.root
|
|
const [{ testFiles: browserTestFiles }, browserBenchFiles] = await Promise.all([
|
|
project.globTestFiles(),
|
|
benchInclude.length > 0
|
|
? project.globFiles(
|
|
benchInclude,
|
|
project.config.benchmark.exclude ?? project.config.exclude,
|
|
dir,
|
|
)
|
|
: [],
|
|
])
|
|
const setupFiles = toArray(project.config.setupFiles)
|
|
|
|
// replace env values - cannot be reassign at runtime
|
|
const define: Record<string, string> = {}
|
|
for (const env in (project.config.env || {})) {
|
|
const stringValue = JSON.stringify(project.config.env[env])
|
|
define[`import.meta.env.${env}`] = stringValue
|
|
}
|
|
|
|
const entries: string[] = [
|
|
...new Set([...browserTestFiles, ...browserBenchFiles]),
|
|
...setupFiles,
|
|
resolve(vitestDist, 'index.js'),
|
|
resolve(vitestDist, 'browser.js'),
|
|
resolve(vitestDist, 'runners.js'),
|
|
resolve(vitestDist, 'utils.js'),
|
|
...(project.config.snapshotSerializers || []),
|
|
]
|
|
|
|
const exclude = [
|
|
'vitest',
|
|
'vitest/browser',
|
|
'vitest/internal/browser',
|
|
'vite/module-runner',
|
|
'@vitest/browser/utils',
|
|
'@vitest/browser/context',
|
|
'@vitest/browser/client',
|
|
'@vitest/utils',
|
|
'@vitest/utils/source-map',
|
|
'@vitest/runner',
|
|
'@vitest/spy',
|
|
'@vitest/utils/error',
|
|
'std-env',
|
|
'tinybench',
|
|
'tinyspy',
|
|
'tinyrainbow',
|
|
'pathe',
|
|
'msw',
|
|
'msw/browser',
|
|
]
|
|
|
|
if (typeof project.config.diff === 'string') {
|
|
entries.push(project.config.diff)
|
|
}
|
|
|
|
if (parentServer.vitest.coverageProvider) {
|
|
const coverage = parentServer.vitest._coverageOptions
|
|
const provider = coverage.provider
|
|
if (provider === 'v8') {
|
|
const path = tryResolve('@vitest/coverage-v8', [parentServer.config.root])
|
|
if (path) {
|
|
entries.push(path)
|
|
exclude.push('@vitest/coverage-v8/browser')
|
|
}
|
|
}
|
|
else if (provider === 'istanbul') {
|
|
const path = tryResolve('@vitest/coverage-istanbul', [parentServer.config.root])
|
|
if (path) {
|
|
entries.push(path)
|
|
exclude.push('@vitest/coverage-istanbul')
|
|
}
|
|
}
|
|
else if (provider === 'custom' && coverage.customProviderModule) {
|
|
entries.push(coverage.customProviderModule)
|
|
}
|
|
}
|
|
|
|
const include = [
|
|
'vitest > expect-type',
|
|
'vitest > magic-string',
|
|
'vitest > chai',
|
|
]
|
|
|
|
const provider = parentServer.config.browser.provider || [...parentServer.children][0]?.provider
|
|
if (provider?.name === 'preview') {
|
|
include.push(
|
|
'@vitest/browser-preview > @testing-library/user-event',
|
|
'@vitest/browser-preview > @testing-library/dom',
|
|
)
|
|
}
|
|
|
|
const fileRoot = browserTestFiles[0] ? dirname(browserTestFiles[0]) : project.config.root
|
|
|
|
const svelte = isPackageExists('vitest-browser-svelte', fileRoot)
|
|
if (svelte) {
|
|
exclude.push('vitest-browser-svelte')
|
|
}
|
|
|
|
// since we override the resolution in the esbuild plugin, Vite can no longer optimizer it
|
|
const vue = isPackageExists('vitest-browser-vue', fileRoot)
|
|
if (vue) {
|
|
// we override them in the esbuild plugin so optimizer can no longer intercept it
|
|
include.push(
|
|
'vitest-browser-vue',
|
|
'vitest-browser-vue > @vue/test-utils',
|
|
'vitest-browser-vue > @vue/test-utils > @vue/compiler-core',
|
|
)
|
|
}
|
|
const vueTestUtils = isPackageExists('@vue/test-utils', fileRoot)
|
|
if (vueTestUtils) {
|
|
include.push('@vue/test-utils')
|
|
}
|
|
|
|
const otelConfig = project.config.experimental.openTelemetry
|
|
if (otelConfig?.enabled && otelConfig.browserSdkPath) {
|
|
entries.push(otelConfig.browserSdkPath)
|
|
include.push('@opentelemetry/api')
|
|
}
|
|
else {
|
|
exclude.push('@opentelemetry/api')
|
|
}
|
|
|
|
return {
|
|
define,
|
|
resolve: {
|
|
dedupe: ['vitest'],
|
|
},
|
|
optimizeDeps: {
|
|
entries,
|
|
exclude,
|
|
include,
|
|
},
|
|
}
|
|
},
|
|
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:resolve-virtual',
|
|
async resolveId(rawId) {
|
|
if (rawId === '/mockServiceWorker.js') {
|
|
return this.resolve('msw/mockServiceWorker.js', distRoot, {
|
|
skipSelf: true,
|
|
})
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'vitest:browser:assets',
|
|
configureServer(server) {
|
|
server.middlewares.use(
|
|
'/__vitest__',
|
|
sirv(resolve(distRoot, 'client/__vitest__')),
|
|
)
|
|
},
|
|
resolveId(id) {
|
|
if (id.startsWith('/__vitest_browser__/')) {
|
|
return resolve(distRoot, 'client', id.slice(1))
|
|
}
|
|
},
|
|
transform(code, id) {
|
|
if (id.includes(parentServer.vite.config.cacheDir) && id.includes('loupe.js')) {
|
|
// loupe bundle has a nasty require('util') call that leaves a warning in the console
|
|
const utilRequire = 'nodeUtil = require_util();'
|
|
return code.replace(utilRequire, ' '.repeat(utilRequire.length))
|
|
}
|
|
},
|
|
},
|
|
BrowserContext(parentServer),
|
|
dynamicImportPlugin({
|
|
globalThisAccessor: '"__vitest_browser_runner__"',
|
|
filter(id, environment) {
|
|
if (environment.name !== 'client') {
|
|
return false
|
|
}
|
|
if (id.includes(distRoot)) {
|
|
return false
|
|
}
|
|
return true
|
|
},
|
|
}),
|
|
{
|
|
name: 'vitest:browser:config',
|
|
enforce: 'post',
|
|
async config(viteConfig) {
|
|
// Enables using ignore hint for coverage providers with @preserve keyword
|
|
// Only set esbuild options when not using rolldown-vite (Vite 8+),
|
|
// which uses oxc for transformation instead of esbuild
|
|
if (!rolldownVersion && viteConfig.esbuild !== false) {
|
|
viteConfig.esbuild ||= {}
|
|
viteConfig.esbuild.legalComments = 'inline'
|
|
}
|
|
|
|
const defaultPort = parentServer.vitest.state._data.browserLastPort++
|
|
|
|
const api = resolveApiServerConfig(
|
|
viteConfig.test?.browser || {},
|
|
defaultPort,
|
|
)
|
|
|
|
viteConfig.server = {
|
|
...viteConfig.server,
|
|
port: defaultPort,
|
|
...api,
|
|
middlewareMode: false,
|
|
open: false,
|
|
}
|
|
viteConfig.server.fs ??= {}
|
|
viteConfig.server.fs.allow = viteConfig.server.fs.allow || []
|
|
viteConfig.server.fs.allow.push(
|
|
...resolveFsAllow(
|
|
parentServer.vitest.config.root,
|
|
parentServer.vitest.vite.config.configFile,
|
|
),
|
|
distRoot,
|
|
)
|
|
|
|
return {
|
|
resolve: {
|
|
alias: viteConfig.test?.alias,
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'vitest:browser:in-source-tests',
|
|
transform: {
|
|
filter: {
|
|
code: /import\.meta\.vitest/,
|
|
},
|
|
handler(code, id) {
|
|
const filename = cleanUrl(id)
|
|
|
|
if (!code.includes('import.meta.vitest')) {
|
|
return
|
|
}
|
|
const s = new MagicString(code, { filename })
|
|
s.prepend(
|
|
`Object.defineProperty(import.meta, 'vitest', { get() { return typeof __vitest_worker__ !== 'undefined' && __vitest_worker__.filepath === "${filename.replace(/"/g, '\\"')}" ? __vitest_index__ : undefined } });\n`,
|
|
)
|
|
return {
|
|
code: s.toString(),
|
|
map: s.generateMap({ hires: true }),
|
|
}
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: 'vitest:browser:worker',
|
|
transform(code, id, _options) {
|
|
// https://github.com/vitejs/vite/blob/ba56cf43b5480f8519349f7d7fe60718e9af5f1a/packages/vite/src/node/plugins/worker.ts#L46
|
|
if (/(?:\?|&)worker_file&type=\w+(?:&|$)/.test(id)) {
|
|
const s = new MagicString(code)
|
|
s.prepend('globalThis.__vitest_browser_runner__ = { wrapDynamicImport: f => f() };\n')
|
|
return {
|
|
code: s.toString(),
|
|
map: s.generateMap({ hires: 'boundary' }),
|
|
}
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'vitest:browser:transform-tester-html',
|
|
enforce: 'pre',
|
|
async transformIndexHtml(html, ctx) {
|
|
const projectBrowser = [...parentServer.children].find((server) => {
|
|
return ctx.filename === server.testerFilepath
|
|
})
|
|
if (!projectBrowser) {
|
|
return
|
|
}
|
|
|
|
const stateJs = typeof parentServer.stateJs === 'string'
|
|
? parentServer.stateJs
|
|
: await parentServer.stateJs
|
|
|
|
const testerTags: HtmlTagDescriptor[] = []
|
|
|
|
const isDefaultTemplate = resolve(distRoot, 'client/tester/tester.html') === projectBrowser.testerFilepath
|
|
if (!isDefaultTemplate) {
|
|
const manifestContent = parentServer.manifest instanceof Promise
|
|
? await parentServer.manifest
|
|
: parentServer.manifest
|
|
const testerEntry = manifestContent['tester/tester.html']
|
|
|
|
testerTags.push({
|
|
tag: 'script',
|
|
attrs: {
|
|
type: 'module',
|
|
crossorigin: '',
|
|
src: `${parentServer.base}${testerEntry.file}`,
|
|
},
|
|
injectTo: 'head',
|
|
})
|
|
|
|
for (const importName of testerEntry.imports || []) {
|
|
const entryManifest = manifestContent[importName]
|
|
if (entryManifest) {
|
|
testerTags.push(
|
|
{
|
|
tag: 'link',
|
|
attrs: {
|
|
href: `${parentServer.base}${entryManifest.file}`,
|
|
rel: 'modulepreload',
|
|
crossorigin: '',
|
|
},
|
|
injectTo: 'head',
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
// inject the reset style only in the default template,
|
|
// allowing users to customize the style in their own template
|
|
testerTags.push({
|
|
tag: 'style',
|
|
children: `
|
|
html {
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
body {
|
|
padding: 0;
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
}`,
|
|
injectTo: 'head',
|
|
})
|
|
}
|
|
|
|
return [
|
|
{
|
|
tag: 'script',
|
|
children: '{__VITEST_INJECTOR__}',
|
|
injectTo: 'head-prepend' as const,
|
|
},
|
|
{
|
|
tag: 'script',
|
|
children: stateJs,
|
|
injectTo: 'head-prepend',
|
|
} as const,
|
|
{
|
|
tag: 'script',
|
|
attrs: {
|
|
type: 'module',
|
|
src: parentServer.errorCatcherUrl,
|
|
},
|
|
injectTo: 'head' as const,
|
|
},
|
|
{
|
|
tag: 'script',
|
|
attrs: {
|
|
type: 'module',
|
|
src: parentServer.matchersUrl,
|
|
},
|
|
injectTo: 'head' as const,
|
|
},
|
|
...parentServer.initScripts.map(script => ({
|
|
tag: 'script',
|
|
attrs: {
|
|
type: 'module',
|
|
src: join('/@fs/', script),
|
|
},
|
|
injectTo: 'head',
|
|
} as const)),
|
|
...testerTags,
|
|
].filter(s => s != null)
|
|
},
|
|
},
|
|
{
|
|
name: 'vitest:browser:support-testing-library',
|
|
enforce: 'pre',
|
|
config() {
|
|
const rolldownPlugin = {
|
|
name: 'vue-test-utils-rewrite',
|
|
resolveId: {
|
|
// test-utils: resolve to CJS instead of the browser because the browser version expects a global Vue object
|
|
// compiler-core: only CJS version allows slots as strings
|
|
filter: { id: /^@vue\/(test-utils|compiler-core)$/ },
|
|
handler(source: string, importer: string) {
|
|
const resolved = getRequire().resolve(source, {
|
|
paths: [importer!],
|
|
})
|
|
return resolved
|
|
},
|
|
},
|
|
}
|
|
const esbuildPlugin = {
|
|
name: 'test-utils-rewrite',
|
|
// "any" because vite doesn't expose any types for this
|
|
setup(build: any) {
|
|
// test-utils: resolve to CJS instead of the browser because the browser version expects a global Vue object
|
|
// compiler-core: only CJS version allows slots as strings
|
|
build.onResolve({ filter: /^@vue\/(test-utils|compiler-core)$/ }, (args: any) => {
|
|
const resolved = getRequire().resolve(args.path, {
|
|
paths: [args.importer],
|
|
})
|
|
return { path: resolved }
|
|
})
|
|
},
|
|
}
|
|
|
|
return {
|
|
optimizeDeps: rolldownVersion
|
|
? { rolldownOptions: { plugins: [rolldownPlugin] } }
|
|
: { esbuildOptions: { plugins: [esbuildPlugin] } },
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'vitest:browser:__vitest_browser_import_meta_env_init__',
|
|
transform: {
|
|
handler(code) {
|
|
// this transform runs after `vitest:meta-env-replacer` so that
|
|
// `import.meta.env` will be handled by Vite import analysis to match behavior.
|
|
if (code.includes('__vitest_browser_import_meta_env_init__')) {
|
|
return code.replace('__vitest_browser_import_meta_env_init__', 'import.meta.env')
|
|
}
|
|
},
|
|
},
|
|
},
|
|
]
|
|
}
|
|
|
|
function tryResolve(path: string, paths: string[]) {
|
|
try {
|
|
const _require = getRequire()
|
|
return _require.resolve(path, { paths })
|
|
}
|
|
catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
let _require: NodeRequire
|
|
function getRequire() {
|
|
if (!_require) {
|
|
_require = createRequire(import.meta.url)
|
|
}
|
|
return _require
|
|
}
|
|
|
|
const postfixRE = /[?#].*$/
|
|
function cleanUrl(url: string): string {
|
|
return url.replace(postfixRE, '')
|
|
}
|