diff --git a/eslint.config.js b/eslint.config.js index 2b17354694d6..1bccb592d1ec 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -79,7 +79,7 @@ export default antfu( 'no-restricted-imports': [ 'error', { - paths: ['vitest', 'path'], + paths: ['vitest', 'path', 'vitest/node'], }, ], }, diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index dca4ba971633..fd1143f65254 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -1,16 +1,16 @@ import type { Plugin } from 'vitest/config' -import type { BrowserServer as IBrowserServer, TestProject } from 'vitest/node' +import type { TestProject } from 'vitest/node' import c from 'tinyrainbow' import { createViteLogger, createViteServer } from 'vitest/node' import { version } from '../../package.json' import BrowserPlugin from './plugin' +import { ParentBrowserProject } from './projectParent' import { setupBrowserRpc } from './rpc' -import { BrowserServer } from './server' export { distRoot } from './constants' export { createBrowserPool } from './pool' -export type { BrowserServer } from './server' +export type { ProjectBrowser } from './project' export async function createBrowserServer( project: TestProject, @@ -28,7 +28,7 @@ export async function createBrowserServer( ) } - const server = new BrowserServer(project, '/') + const server = new ParentBrowserProject(project, '/') const configPath = typeof configFile === 'string' ? configFile : false @@ -77,15 +77,3 @@ export async function createBrowserServer( return server } - -export function cloneBrowserServer( - project: TestProject, - browserServer: IBrowserServer, -) { - const clone = new BrowserServer( - project, - '/', - ) - clone.setServer(browserServer.vite) - return clone -} diff --git a/packages/browser/src/node/middlewares/orchestratorMiddleware.ts b/packages/browser/src/node/middlewares/orchestratorMiddleware.ts index c4d9039ba2fb..c6d5d8848a8d 100644 --- a/packages/browser/src/node/middlewares/orchestratorMiddleware.ts +++ b/packages/browser/src/node/middlewares/orchestratorMiddleware.ts @@ -1,23 +1,25 @@ import type { Connect } from 'vite' -import type { BrowserServer } from '../server' +import type { ParentBrowserProject } from '../projectParent' import { resolveOrchestrator } from '../serverOrchestrator' import { allowIframes, disableCache } from './utils' -export function createOrchestratorMiddleware(browserServer: BrowserServer): Connect.NextHandleFunction { +export function createOrchestratorMiddleware(parentServer: ParentBrowserProject): Connect.NextHandleFunction { return async function vitestOrchestratorMiddleware(req, res, next) { if (!req.url) { return next() } const url = new URL(req.url, 'http://localhost') - if (url.pathname !== browserServer.base) { + if (url.pathname !== parentServer.base) { return next() } - disableCache(res) - allowIframes(res) + const html = await resolveOrchestrator(parentServer, url, res) + if (html) { + disableCache(res) + allowIframes(res) - const html = await resolveOrchestrator(browserServer, url, res) - res.write(html, 'utf-8') - res.end() + res.write(html, 'utf-8') + res.end() + } } } diff --git a/packages/browser/src/node/middlewares/testerMiddleware.ts b/packages/browser/src/node/middlewares/testerMiddleware.ts index cb10e068857a..435ff7149ed1 100644 --- a/packages/browser/src/node/middlewares/testerMiddleware.ts +++ b/packages/browser/src/node/middlewares/testerMiddleware.ts @@ -1,9 +1,9 @@ import type { Connect } from 'vite' -import type { BrowserServer } from '../server' +import type { ParentBrowserProject } from '../projectParent' import { resolveTester } from '../serverTester' import { allowIframes, disableCache } from './utils' -export function createTesterMiddleware(browserServer: BrowserServer): Connect.NextHandleFunction { +export function createTesterMiddleware(browserServer: ParentBrowserProject): Connect.NextHandleFunction { return async function vitestTesterMiddleware(req, res, next) { if (!req.url) { return next() @@ -13,11 +13,10 @@ export function createTesterMiddleware(browserServer: BrowserServer): Connect.Ne return next() } - disableCache(res) - allowIframes(res) - const html = await resolveTester(browserServer, url, res, next) if (html) { + disableCache(res) + allowIframes(res) res.write(html, 'utf-8') res.end() } diff --git a/packages/browser/src/node/plugin.ts b/packages/browser/src/node/plugin.ts index a6a27050a272..73f43899d760 100644 --- a/packages/browser/src/node/plugin.ts +++ b/packages/browser/src/node/plugin.ts @@ -1,7 +1,7 @@ import type { Stats } from 'node:fs' import type { HtmlTagDescriptor } from 'vite' import type { Vitest } from 'vitest/node' -import type { BrowserServer } from './server' +import type { ParentBrowserProject } from './projectParent' import { lstatSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dynamicImportPlugin } from '@vitest/mocker/node' @@ -21,9 +21,9 @@ export type { BrowserCommand } from 'vitest/node' const versionRegexp = /(?:\?|&)v=\w{8}/ -export default (browserServer: BrowserServer, base = '/'): Plugin[] => { +export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => { function isPackageExists(pkg: string, root: string) { - return browserServer.vitest.packageInstaller.isPackageExists?.(pkg, { + return parentServer.vitest.packageInstaller.isPackageExists?.(pkg, { paths: [root], }) } @@ -33,7 +33,7 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { enforce: 'pre', name: 'vitest:browser', async configureServer(server) { - browserServer.setServer(server) + parentServer.setServer(server) // eslint-disable-next-line prefer-arrow-callback server.middlewares.use(function vitestHeaders(_req, res, next) { @@ -45,8 +45,8 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { } next() }) - server.middlewares.use(createOrchestratorMiddleware(browserServer)) - server.middlewares.use(createTesterMiddleware(browserServer)) + server.middlewares.use(createOrchestratorMiddleware(parentServer)) + server.middlewares.use(createTesterMiddleware(parentServer)) server.middlewares.use( `${base}favicon.svg`, @@ -57,7 +57,7 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { }, ) - const coverageFolder = resolveCoverageFolder(browserServer.vitest) + const coverageFolder = resolveCoverageFolder(parentServer.vitest) const coveragePath = coverageFolder ? coverageFolder[1] : undefined if (coveragePath && base === coveragePath) { throw new Error( @@ -81,12 +81,12 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { ) } - const screenshotFailures = browserServer.config.browser.ui && browserServer.config.browser.screenshotFailures + const uiEnabled = parentServer.config.browser.ui - if (screenshotFailures) { + if (uiEnabled) { // eslint-disable-next-line prefer-arrow-callback server.middlewares.use(`${base}__screenshot-error`, function vitestBrowserScreenshotError(req, res) { - if (!req.url || !browserServer.provider) { + if (!req.url) { res.statusCode = 404 res.end() return @@ -152,17 +152,17 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { name: 'vitest:browser:tests', enforce: 'pre', async config() { - const project = browserServer.vitest.getProjectByName(browserServer.config.name) + const project = parentServer.vitest.getProjectByName(parentServer.config.name) const { testFiles: allTestFiles } = await project.globTestFiles() const browserTestFiles = allTestFiles.filter( file => getFilePoolName(project, file) === 'browser', ) - const setupFiles = toArray(browserServer.config.setupFiles) + const setupFiles = toArray(project.config.setupFiles) // replace env values - cannot be reassign at runtime const define: Record = {} - for (const env in (browserServer.config.env || {})) { - const stringValue = JSON.stringify(browserServer.config.env[env]) + for (const env in (project.config.env || {})) { + const stringValue = JSON.stringify(project.config.env[env]) define[`import.meta.env.${env}`] = stringValue } @@ -173,7 +173,7 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { resolve(vitestDist, 'browser.js'), resolve(vitestDist, 'runners.js'), resolve(vitestDist, 'utils.js'), - ...(browserServer.config.snapshotSerializers || []), + ...(project.config.snapshotSerializers || []), ] const exclude = [ @@ -199,22 +199,22 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { 'msw/browser', ] - if (typeof browserServer.config.diff === 'string') { - entries.push(browserServer.config.diff) + if (typeof project.config.diff === 'string') { + entries.push(project.config.diff) } - if (browserServer.vitest.coverageProvider) { - const coverage = browserServer.vitest.config.coverage + if (parentServer.vitest.coverageProvider) { + const coverage = parentServer.vitest.config.coverage const provider = coverage.provider if (provider === 'v8') { - const path = tryResolve('@vitest/coverage-v8', [browserServer.config.root]) + 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', [browserServer.config.root]) + const path = tryResolve('@vitest/coverage-istanbul', [parentServer.config.root]) if (path) { entries.push(path) exclude.push('@vitest/coverage-istanbul') @@ -235,7 +235,7 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { '@vitest/browser > @testing-library/dom', ] - const fileRoot = browserTestFiles[0] ? dirname(browserTestFiles[0]) : browserServer.config.root + const fileRoot = browserTestFiles[0] ? dirname(browserTestFiles[0]) : project.config.root const svelte = isPackageExists('vitest-browser-svelte', fileRoot) if (svelte) { @@ -302,14 +302,14 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { } }, transform(code, id) { - if (id.includes(browserServer.vite.config.cacheDir) && id.includes('loupe.js')) { + if (id.includes(parentServer.vite.config.cacheDir) && id.includes('loupe.js')) { // loupe bundle has a nastry require('util') call that leaves a warning in the console const utilRequire = 'nodeUtil = require_util();' return code.replace(utilRequire, ' '.repeat(utilRequire.length)) } }, }, - BrowserContext(browserServer), + BrowserContext(parentServer), dynamicImportPlugin({ globalThisAccessor: '"__vitest_browser_runner__"', filter(id) { @@ -329,7 +329,7 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { viteConfig.esbuild.legalComments = 'inline' } - const defaultPort = browserServer.vitest._browserLastPort++ + const defaultPort = parentServer.vitest._browserLastPort++ const api = resolveApiServerConfig( viteConfig.test?.browser || {}, @@ -347,8 +347,8 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { viteConfig.server.fs.allow = viteConfig.server.fs.allow || [] viteConfig.server.fs.allow.push( ...resolveFsAllow( - browserServer.vitest.config.root, - browserServer.vitest.server.config.configFile, + parentServer.vitest.config.root, + parentServer.vitest.vite.config.configFile, ), distRoot, ) @@ -363,7 +363,7 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { { name: 'vitest:browser:in-source-tests', transform(code, id) { - const project = browserServer.vitest.getProjectByName(browserServer.config.name) + const project = parentServer.vitest.getProjectByName(parentServer.config.name) if (!project.isCachedTestFile(id) || !code.includes('import.meta.vitest')) { return } @@ -395,26 +395,30 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { name: 'vitest:browser:transform-tester-html', enforce: 'pre', async transformIndexHtml(html, ctx) { - if (ctx.filename !== browserServer.testerFilepath) { + const projectBrowser = [...parentServer.children].find((server) => { + return ctx.filename === server.testerFilepath + }) + if (!projectBrowser) { return } - if (!browserServer.testerScripts) { - const testerScripts = await browserServer.formatScripts( - browserServer.config.browser.testerScripts, + if (!parentServer.testerScripts) { + const testerScripts = await parentServer.formatScripts( + parentServer.config.browser.testerScripts, ) - browserServer.testerScripts = testerScripts + parentServer.testerScripts = testerScripts } - const stateJs = typeof browserServer.stateJs === 'string' - ? browserServer.stateJs - : await browserServer.stateJs + const stateJs = typeof parentServer.stateJs === 'string' + ? parentServer.stateJs + : await parentServer.stateJs const testerTags: HtmlTagDescriptor[] = [] - const isDefaultTemplate = resolve(distRoot, 'client/tester/tester.html') === browserServer.testerFilepath + + const isDefaultTemplate = resolve(distRoot, 'client/tester/tester.html') === projectBrowser.testerFilepath if (!isDefaultTemplate) { - const manifestContent = browserServer.manifest instanceof Promise - ? await browserServer.manifest - : browserServer.manifest + const manifestContent = parentServer.manifest instanceof Promise + ? await parentServer.manifest + : parentServer.manifest const testerEntry = manifestContent['tester/tester.html'] testerTags.push({ @@ -422,7 +426,7 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { attrs: { type: 'module', crossorigin: '', - src: `${browserServer.base}${testerEntry.file}`, + src: `${parentServer.base}${testerEntry.file}`, }, injectTo: 'head', }) @@ -434,7 +438,7 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { { tag: 'link', attrs: { - href: `${browserServer.base}${entryManifest.file}`, + href: `${parentServer.base}${entryManifest.file}`, rel: 'modulepreload', crossorigin: '', }, @@ -478,21 +482,21 @@ body { tag: 'script', attrs: { type: 'module', - src: browserServer.errorCatcherUrl, + src: parentServer.errorCatcherUrl, }, injectTo: 'head' as const, }, - browserServer.locatorsUrl + parentServer.locatorsUrl ? { tag: 'script', attrs: { type: 'module', - src: browserServer.locatorsUrl, + src: parentServer.locatorsUrl, }, injectTo: 'head', } as const : null, - ...browserServer.testerScripts, + ...parentServer.testerScripts, ...testerTags, { tag: 'script', diff --git a/packages/browser/src/node/plugins/pluginContext.ts b/packages/browser/src/node/plugins/pluginContext.ts index 400cd5c5027a..cf8080cf2035 100644 --- a/packages/browser/src/node/plugins/pluginContext.ts +++ b/packages/browser/src/node/plugins/pluginContext.ts @@ -1,7 +1,6 @@ import type { PluginContext } from 'rollup' import type { Plugin } from 'vitest/config' -import type { BrowserProvider } from 'vitest/node' -import type { BrowserServer } from '../server' +import type { ParentBrowserProject } from '../projectParent' import { fileURLToPath } from 'node:url' import { slash } from '@vitest/utils' import { dirname, resolve } from 'pathe' @@ -11,7 +10,7 @@ const ID_CONTEXT = '@vitest/browser/context' const __dirname = dirname(fileURLToPath(import.meta.url)) -export default function BrowserContext(server: BrowserServer): Plugin { +export default function BrowserContext(globalServer: ParentBrowserProject): Plugin { return { name: 'vitest:browser:virtual-module:context', enforce: 'pre', @@ -22,7 +21,7 @@ export default function BrowserContext(server: BrowserServer): Plugin { }, load(id) { if (id === VIRTUAL_ID_CONTEXT) { - return generateContextFile.call(this, server) + return generateContextFile.call(this, globalServer) } }, } @@ -30,12 +29,13 @@ export default function BrowserContext(server: BrowserServer): Plugin { async function generateContextFile( this: PluginContext, - server: BrowserServer, + globalServer: ParentBrowserProject, ) { - const commands = Object.keys(server.config.browser.commands ?? {}) + const commands = Object.keys(globalServer.commands) const filepathCode = '__vitest_worker__.filepath || __vitest_worker__.current?.file?.filepath || undefined' - const provider = server.provider + const provider = [...globalServer.children][0].provider || { name: 'preview' } + const providerName = provider.name const commandsCode = commands .filter(command => !command.startsWith('__vitest')) @@ -45,7 +45,7 @@ async function generateContextFile( .join('\n') const userEventNonProviderImport = await getUserEventImport( - provider, + providerName, this.resolve.bind(this), ) const distContextPath = slash(`/@fs/${resolve(__dirname, 'context.js')}`) @@ -60,7 +60,7 @@ const sessionId = __vitest_browser_runner__.sessionId export const server = { platform: ${JSON.stringify(process.platform)}, version: ${JSON.stringify(process.version)}, - provider: ${JSON.stringify(provider.name)}, + provider: ${JSON.stringify(provider)}, browser: __vitest_browser_runner__.config.browser.name, commands: { ${commandsCode} @@ -73,8 +73,8 @@ export { page, cdp } ` } -async function getUserEventImport(provider: BrowserProvider, resolve: (id: string, importer: string) => Promise) { - if (provider.name !== 'preview') { +async function getUserEventImport(provider: string, resolve: (id: string, importer: string) => Promise) { + if (provider !== 'preview') { return 'const _userEventSetup = undefined' } const resolved = await resolve('@testing-library/user-event', __dirname) diff --git a/packages/browser/src/node/project.ts b/packages/browser/src/node/project.ts new file mode 100644 index 000000000000..f528c6ee25e9 --- /dev/null +++ b/packages/browser/src/node/project.ts @@ -0,0 +1,122 @@ +import type { StackTraceParserOptions } from '@vitest/utils/source-map' +import type { ErrorWithDiff, SerializedConfig } from 'vitest' +import type { + BrowserProvider, + ProjectBrowser as IProjectBrowser, + ResolvedConfig, + TestProject, + Vitest, +} from 'vitest/node' +import type { ParentBrowserProject } from './projectParent' +import { existsSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { resolve } from 'pathe' +import { BrowserServerState } from './state' +import { getBrowserProvider } from './utils' + +export class ProjectBrowser implements IProjectBrowser { + public testerHtml: Promise | string + public testerFilepath: string + public locatorsUrl: string | undefined + + public provider!: BrowserProvider + public vitest: Vitest + public config: ResolvedConfig + public children = new Set() + + public parent!: ParentBrowserProject + + public state = new BrowserServerState() + + constructor( + public project: TestProject, + public base: string, + ) { + this.vitest = project.vitest + this.config = project.config + + const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..') + const distRoot = resolve(pkgRoot, 'dist') + + const testerHtmlPath = project.config.browser.testerHtmlPath + ? resolve(project.config.root, project.config.browser.testerHtmlPath) + : resolve(distRoot, 'client/tester/tester.html') + if (!existsSync(testerHtmlPath)) { + throw new Error(`Tester HTML file "${testerHtmlPath}" doesn't exist.`) + } + this.testerFilepath = testerHtmlPath + + this.testerHtml = readFile( + testerHtmlPath, + 'utf8', + ).then(html => (this.testerHtml = html)) + } + + get vite() { + return this.parent.vite + } + + wrapSerializedConfig() { + const config = wrapConfig(this.project.serializedConfig) + config.env ??= {} + config.env.VITEST_BROWSER_DEBUG = process.env.VITEST_BROWSER_DEBUG || '' + return config + } + + async initBrowserProvider(project: TestProject) { + if (this.provider) { + return + } + const Provider = await getBrowserProvider(project.config.browser, project) + this.provider = new Provider() + const browser = project.config.browser.name + const name = project.name ? `[${project.name}] ` : '' + if (!browser) { + throw new Error( + `${name}Browser name is required. Please, set \`test.browser.instances[].browser\` option manually.`, + ) + } + const supportedBrowsers = this.provider.getSupportedBrowsers() + if (supportedBrowsers.length && !supportedBrowsers.includes(browser)) { + throw new Error( + `${name}Browser "${browser}" is not supported by the browser provider "${ + this.provider.name + }". Supported browsers: ${supportedBrowsers.join(', ')}.`, + ) + } + const providerOptions = project.config.browser.providerOptions + await this.provider.initialize(project, { + browser, + options: providerOptions, + }) + } + + public parseErrorStacktrace( + e: ErrorWithDiff, + options: StackTraceParserOptions = {}, + ) { + return this.parent.parseErrorStacktrace(e, options) + } + + public parseStacktrace( + trace: string, + options: StackTraceParserOptions = {}, + ) { + return this.parent.parseStacktrace(trace, options) + } + + async close() { + await this.parent.vite.close() + } +} + +function wrapConfig(config: SerializedConfig): SerializedConfig { + return { + ...config, + // workaround RegExp serialization + testNamePattern: config.testNamePattern + ? (config.testNamePattern.toString() as any as RegExp) + : undefined, + } +} diff --git a/packages/browser/src/node/server.ts b/packages/browser/src/node/projectParent.ts similarity index 66% rename from packages/browser/src/node/server.ts rename to packages/browser/src/node/projectParent.ts index 4ecd50d527ed..9e90a954f78b 100644 --- a/packages/browser/src/node/server.ts +++ b/packages/browser/src/node/projectParent.ts @@ -1,55 +1,48 @@ import type { HtmlTagDescriptor } from 'vite' -import type { ErrorWithDiff, SerializedConfig } from 'vitest' +import type { ErrorWithDiff, ParsedStack } from 'vitest' import type { - BrowserProvider, + BrowserCommand, BrowserScript, CDPSession, - BrowserServer as IBrowserServer, ResolvedConfig, TestProject, Vite, Vitest, } from 'vitest/node' -import { existsSync } from 'node:fs' +import type { BrowserServerState } from './state' import { readFile } from 'node:fs/promises' -import { fileURLToPath } from 'node:url' -import { slash } from '@vitest/utils' import { parseErrorStacktrace, parseStacktrace, type StackTraceParserOptions } from '@vitest/utils/source-map' import { join, resolve } from 'pathe' import { BrowserServerCDPHandler } from './cdp' import builtinCommands from './commands/index' -import { BrowserServerState } from './state' -import { getBrowserProvider } from './utils' - -export class BrowserServer implements IBrowserServer { - public faviconUrl: string - public prefixTesterUrl: string +import { distRoot } from './constants' +import { ProjectBrowser } from './project' +import { slash } from './utils' +export class ParentBrowserProject { public orchestratorScripts: string | undefined public testerScripts: HtmlTagDescriptor[] | undefined + public faviconUrl: string + public prefixTesterUrl: string public manifest: Promise | Vite.Manifest - public testerHtml: Promise | string - public testerFilepath: string + + public vite!: Vite.ViteDevServer + private stackTraceOptions: StackTraceParserOptions public orchestratorHtml: Promise | string public injectorJs: Promise | string public errorCatcherUrl: string public locatorsUrl: string | undefined public stateJs: Promise | string - public state: BrowserServerState - public provider!: BrowserProvider - - public vite!: Vite.ViteDevServer - - private stackTraceOptions: StackTraceParserOptions + public commands: Record> = {} + public children = new Set() public vitest: Vitest - public config: ResolvedConfig - public readonly cdps = new Map() + public config: ResolvedConfig constructor( - project: TestProject, + public project: TestProject, public base: string, ) { this.vitest = project.vitest @@ -73,9 +66,8 @@ export class BrowserServer implements IBrowserServer { }, } - project.config.browser.commands ??= {} for (const [name, command] of Object.entries(builtinCommands)) { - project.config.browser.commands[name] ??= command + this.commands[name] ??= command } // validate names because they can't be used as identifiers @@ -85,13 +77,9 @@ export class BrowserServer implements IBrowserServer { `Invalid command name "${command}". Only alphanumeric characters, $ and _ are allowed.`, ) } + this.commands[command] = project.config.browser.commands[command] } - this.state = new BrowserServerState() - - const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..') - const distRoot = resolve(pkgRoot, 'dist') - this.prefixTesterUrl = `${base}__vitest_test__/__test__/` this.faviconUrl = `${base}__vitest__/favicon.svg` @@ -101,18 +89,6 @@ export class BrowserServer implements IBrowserServer { ) })().then(manifest => (this.manifest = manifest)) - const testerHtmlPath = project.config.browser.testerHtmlPath - ? resolve(project.config.root, project.config.browser.testerHtmlPath) - : resolve(distRoot, 'client/tester/tester.html') - if (!existsSync(testerHtmlPath)) { - throw new Error(`Tester HTML file "${testerHtmlPath}" doesn't exist.`) - } - this.testerFilepath = testerHtmlPath - - this.testerHtml = readFile( - testerHtmlPath, - 'utf8', - ).then(html => (this.testerHtml = html)) this.orchestratorHtml = (project.config.browser.ui ? readFile(resolve(distRoot, 'client/__vitest__/index.html'), 'utf8') : readFile(resolve(distRoot, 'client/orchestrator.html'), 'utf8')) @@ -134,92 +110,27 @@ export class BrowserServer implements IBrowserServer { ).then(js => (this.stateJs = js)) } - setServer(server: Vite.ViteDevServer) { - this.vite = server + public setServer(vite: Vite.ViteDevServer) { + this.vite = vite } - wrapSerializedConfig(projectName: string) { - const project = this.vitest.getProjectByName(projectName) - const config = wrapConfig(project.serializedConfig) - config.env ??= {} - config.env.VITEST_BROWSER_DEBUG = process.env.VITEST_BROWSER_DEBUG || '' - return config - } - - resolveTesterUrl(pathname: string) { - const [sessionId, testFile] = pathname - .slice(this.prefixTesterUrl.length) - .split('/') - const decodedTestFile = decodeURIComponent(testFile) - return { sessionId, testFile: decodedTestFile } - } - - async formatScripts( - scripts: BrowserScript[] | undefined, - ) { - if (!scripts?.length) { - return [] + public spawn(project: TestProject): ProjectBrowser { + if (!this.vite) { + throw new Error(`Cannot spawn child server without a parent dev server.`) } - const server = this.vite - const promises = scripts.map( - async ({ content, src, async, id, type = 'module' }, index): Promise => { - 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 { - tag: 'script', - attrs: { - type, - ...(async ? { async: '' } : {}), - ...(srcLink - ? { - src: srcLink.startsWith('http') ? srcLink : slash(`/@fs/${srcLink}`), - } - : {}), - }, - injectTo: 'head', - children: contentProcessed || '', - } - }, + const clone = new ProjectBrowser( + project, + '/', ) - return (await Promise.all(promises)) - } - - async initBrowserProvider(project: TestProject) { - if (this.provider) { - return - } - const Provider = await getBrowserProvider(project.config.browser, project) - this.provider = new Provider() - const browser = project.config.browser.name - if (!browser) { - throw new Error( - `[${project.name}] Browser name is required. Please, set \`test.browser.instances[].browser\` option manually.`, - ) - } - const supportedBrowsers = this.provider.getSupportedBrowsers() - if (supportedBrowsers.length && !supportedBrowsers.includes(browser)) { - throw new Error( - `[${project.name}] Browser "${browser}" is not supported by the browser provider "${ - this.provider.name - }". Supported browsers: ${supportedBrowsers.join(', ')}.`, - ) - } - const providerOptions = project.config.browser.providerOptions - await this.provider.initialize(project, { - browser, - options: providerOptions, - }) + clone.parent = this + this.children.add(clone) + return clone } public parseErrorStacktrace( e: ErrorWithDiff, options: StackTraceParserOptions = {}, - ) { + ): ParsedStack[] { return parseErrorStacktrace(e, { ...this.stackTraceOptions, ...options, @@ -229,13 +140,14 @@ export class BrowserServer implements IBrowserServer { public parseStacktrace( trace: string, options: StackTraceParserOptions = {}, - ) { + ): ParsedStack[] { return parseStacktrace(trace, { ...this.stackTraceOptions, ...options, }) } + public readonly cdps = new Map() private cdpSessionsPromises = new Map>() async ensureCDPHandler(sessionId: string, rpcId: string) { @@ -250,6 +162,9 @@ export class BrowserServer implements IBrowserServer { const browser = browserSession.project.browser! const provider = browser.provider + if (!provider) { + throw new Error(`Browser provider is not defined for the project "${browserSession.project.name}".`) + } if (!provider.getCDPSession) { throw new Error(`CDP is not supported by the provider "${provider.name}".`) } @@ -280,17 +195,44 @@ export class BrowserServer implements IBrowserServer { this.cdps.delete(sessionId) } - async close() { - await this.vite.close() + async formatScripts(scripts: BrowserScript[] | undefined) { + if (!scripts?.length) { + return [] + } + const server = this.vite + const promises = scripts.map( + async ({ content, src, async, id, type = 'module' }, index): Promise => { + 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 { + tag: 'script', + attrs: { + type, + ...(async ? { async: '' } : {}), + ...(srcLink + ? { + src: srcLink.startsWith('http') ? srcLink : slash(`/@fs/${srcLink}`), + } + : {}), + }, + injectTo: 'head', + children: contentProcessed || '', + } + }, + ) + return (await Promise.all(promises)) } -} -function wrapConfig(config: SerializedConfig): SerializedConfig { - return { - ...config, - // workaround RegExp serialization - testNamePattern: config.testNamePattern - ? (config.testNamePattern.toString() as any as RegExp) - : undefined, + resolveTesterUrl(pathname: string) { + const [sessionId, testFile] = pathname + .slice(this.prefixTesterUrl.length) + .split('/') + const decodedTestFile = decodeURIComponent(testFile) + return { sessionId, testFile: decodedTestFile } } } diff --git a/packages/browser/src/node/providers/playwright.ts b/packages/browser/src/node/providers/playwright.ts index 2a08cef23695..6fb088cf3541 100644 --- a/packages/browser/src/node/providers/playwright.ts +++ b/packages/browser/src/node/providers/playwright.ts @@ -128,6 +128,7 @@ export class PlaywrightBrowserProvider implements BrowserProvider { public getPage(sessionId: string) { const page = this.pages.get(sessionId) if (!page) { + console.log({ pages: [...this.pages.keys()] }) throw new Error(`Page "${sessionId}" not found in ${this.browserName} browser.`) } return page diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts index 6076466d5f4a..e153f59f0647 100644 --- a/packages/browser/src/node/rpc.ts +++ b/packages/browser/src/node/rpc.ts @@ -2,7 +2,7 @@ import type { Duplex } from 'node:stream' import type { ErrorWithDiff } from 'vitest' import type { BrowserCommandContext, ResolveSnapshotPathHandlerContext, TestProject } from 'vitest/node' import type { WebSocket } from 'ws' -import type { BrowserServer } from './server' +import type { ParentBrowserProject } from './projectParent' import type { BrowserServerState } from './state' import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from './types' import { existsSync, promises as fs } from 'node:fs' @@ -17,9 +17,9 @@ const debug = createDebugger('vitest:browser:api') const BROWSER_API_PATH = '/__vitest_browser_api__' -export function setupBrowserRpc(server: BrowserServer) { - const vite = server.vite - const vitest = server.vitest +export function setupBrowserRpc(globalServer: ParentBrowserProject) { + const vite = globalServer.vite + const vitest = globalServer.vitest const wss = new WebSocketServer({ noServer: true }) @@ -35,6 +35,7 @@ export function setupBrowserRpc(server: BrowserServer) { const type = searchParams.get('type') const rpcId = searchParams.get('rpcId') + const sessionId = searchParams.get('sessionId') const projectName = searchParams.get('projectName') if (type !== 'tester' && type !== 'orchestrator') { @@ -43,14 +44,14 @@ export function setupBrowserRpc(server: BrowserServer) { ) } - if (!rpcId || projectName == null) { + if (!sessionId || !rpcId || projectName == null) { return error( new Error(`[vitest] Invalid URL ${request.url}. "projectName", "sessionId" and "rpcId" queries are required.`), ) } if (type === 'orchestrator') { - const session = vitest._browserSessions.getSession(rpcId) + const session = vitest._browserSessions.getSession(sessionId) // it's possible the session was already resolved by the preview provider session?.connected() } @@ -76,7 +77,7 @@ export function setupBrowserRpc(server: BrowserServer) { ws.on('close', () => { debug?.('[%s] Browser API disconnected from %s', rpcId, type) clients.delete(rpcId) - server.removeCDPHandler(rpcId) + globalServer.removeCDPHandler(rpcId) }) }) }) @@ -96,7 +97,7 @@ export function setupBrowserRpc(server: BrowserServer) { } function setupClient(project: TestProject, rpcId: string, ws: WebSocket) { - const mockResolver = new ServerMockResolver(server.vite, { + const mockResolver = new ServerMockResolver(globalServer.vite, { moduleDirectories: project.config.server?.deps?.moduleDirectories, }) @@ -105,7 +106,7 @@ export function setupBrowserRpc(server: BrowserServer) { async onUnhandledError(error, type) { if (error && typeof error === 'object') { const _error = error as ErrorWithDiff - _error.stacks = server.parseErrorStacktrace(_error) + _error.stacks = globalServer.parseErrorStacktrace(_error) } vitest.state.catchError(error, type) }, @@ -154,7 +155,7 @@ export function setupBrowserRpc(server: BrowserServer) { return fs.unlink(id) }, getBrowserFileSourceMap(id) { - const mod = server.vite.moduleGraph.getModuleById(id) + const mod = globalServer.vite.moduleGraph.getModuleById(id) return mod?.transformResult?.map }, onCancel(reason) { @@ -175,7 +176,7 @@ export function setupBrowserRpc(server: BrowserServer) { if (!provider) { throw new Error('Commands are only available for browser tests.') } - const commands = project.config.browser?.commands + const commands = globalServer.commands if (!commands || !commands[command]) { throw new Error(`Unknown command "${command}".`) } @@ -212,11 +213,11 @@ export function setupBrowserRpc(server: BrowserServer) { // CDP async sendCdpEvent(sessionId: string, event: string, payload?: Record) { - const cdp = await server.ensureCDPHandler(sessionId, rpcId) + const cdp = await globalServer.ensureCDPHandler(sessionId, rpcId) return cdp.send(event, payload) }, async trackCdpEvent(sessionId: string, type: 'on' | 'once' | 'off', event: string, listenerId: string) { - const cdp = await server.ensureCDPHandler(sessionId, rpcId) + const cdp = await globalServer.ensureCDPHandler(sessionId, rpcId) cdp[type](event, listenerId) }, }, @@ -237,8 +238,8 @@ export function setupBrowserRpc(server: BrowserServer) { return rpc } } -// Serialization support utils. +// Serialization support utils. function cloneByOwnProperties(value: any) { // Clones the value's properties into a new Object. The simpler approach of // Object.assign() won't work in the case that properties are not enumerable. diff --git a/packages/browser/src/node/serverOrchestrator.ts b/packages/browser/src/node/serverOrchestrator.ts index b9a3975c92b2..f1d61fd713ef 100644 --- a/packages/browser/src/node/serverOrchestrator.ts +++ b/packages/browser/src/node/serverOrchestrator.ts @@ -1,16 +1,17 @@ import type { IncomingMessage, ServerResponse } from 'node:http' -import type { BrowserServer } from './server' +import type { ProjectBrowser } from './project' +import type { ParentBrowserProject } from './projectParent' import { replacer } from './utils' export async function resolveOrchestrator( - globalServer: BrowserServer, + globalServer: ParentBrowserProject, url: URL, res: ServerResponse, ) { let sessionId = url.searchParams.get('sessionId') // it's possible to open the page without a context if (!sessionId) { - const contexts = [...globalServer.state.orchestrators.keys()] + const contexts = [...globalServer.children].flatMap(p => [...p.state.orchestrators.keys()]) sessionId = contexts[contexts.length - 1] ?? 'none' } @@ -19,17 +20,21 @@ export async function resolveOrchestrator( const session = globalServer.vitest._browserSessions.getSession(sessionId!) const files = session?.files ?? [] - const browserServer = session?.project.browser as BrowserServer || globalServer + const browserProject = (session?.project.browser as ProjectBrowser | undefined) || [...globalServer.children][0] - const injectorJs = typeof browserServer.injectorJs === 'string' - ? browserServer.injectorJs - : await browserServer.injectorJs + if (!browserProject) { + return + } + + const injectorJs = typeof globalServer.injectorJs === 'string' + ? globalServer.injectorJs + : await globalServer.injectorJs const injector = replacer(injectorJs, { - __VITEST_PROVIDER__: JSON.stringify(browserServer.config.browser.provider || 'preview'), - __VITEST_CONFIG__: JSON.stringify(browserServer.wrapSerializedConfig(session?.project.name || '')), + __VITEST_PROVIDER__: JSON.stringify(browserProject.config.browser.provider || 'preview'), + __VITEST_CONFIG__: JSON.stringify(browserProject.wrapSerializedConfig()), __VITEST_VITE_CONFIG__: JSON.stringify({ - root: browserServer.vite.config.root, + root: browserProject.vite.config.root, }), __VITEST_FILES__: JSON.stringify(files), __VITEST_TYPE__: '"orchestrator"', @@ -64,7 +69,7 @@ export async function resolveOrchestrator( ? await globalServer.manifest : globalServer.manifest const jsEntry = manifestContent['orchestrator.html'].file - const base = browserServer.vite.config.base || '/' + const base = browserProject.parent.vite.config.base || '/' baseHtml = baseHtml .replaceAll('./assets/', `${base}__vitest__/assets/`) .replace( diff --git a/packages/browser/src/node/serverTester.ts b/packages/browser/src/node/serverTester.ts index 825fdadc17db..182997506e2d 100644 --- a/packages/browser/src/node/serverTester.ts +++ b/packages/browser/src/node/serverTester.ts @@ -1,13 +1,14 @@ import type { IncomingMessage, ServerResponse } from 'node:http' import type { Connect } from 'vite' -import type { BrowserServer } from './server' +import type { ProjectBrowser } from './project' +import type { ParentBrowserProject } from './projectParent' import crypto from 'node:crypto' import { stringify } from 'flatted' import { join } from 'pathe' import { replacer } from './utils' export async function resolveTester( - globalServer: BrowserServer, + globalServer: ParentBrowserProject, url: URL, res: ServerResponse, next: Connect.NextFunction, @@ -43,18 +44,24 @@ export async function resolveTester( const files = session.files ?? [] const method = session.method ?? 'run' - // TODO: test for different configurations in multi-browser instances - const browserServer = project.browser as BrowserServer || globalServer - const injectorJs: string = typeof browserServer.injectorJs === 'string' - ? browserServer.injectorJs - : await browserServer.injectorJs + const browserProject = (project.browser as ProjectBrowser | undefined) || [...globalServer.children][0] + + if (!browserProject) { + res.statusCode = 400 + res.end('Invalid session ID') + return + } + + const injectorJs: string = typeof globalServer.injectorJs === 'string' + ? globalServer.injectorJs + : await globalServer.injectorJs const injector = replacer(injectorJs, { - __VITEST_PROVIDER__: JSON.stringify(project.browser!.provider.name), - __VITEST_CONFIG__: JSON.stringify(browserServer.wrapSerializedConfig(project.name)), + __VITEST_PROVIDER__: JSON.stringify(project.browser!.provider!.name), + __VITEST_CONFIG__: JSON.stringify(browserProject.wrapSerializedConfig()), __VITEST_FILES__: JSON.stringify(files), __VITEST_VITE_CONFIG__: JSON.stringify({ - root: browserServer.vite.config.root, + root: browserProject.vite.config.root, }), __VITEST_TYPE__: '"tester"', __VITEST_SESSION_ID__: JSON.stringify(sessionId), @@ -62,14 +69,14 @@ export async function resolveTester( __VITEST_PROVIDED_CONTEXT__: JSON.stringify(stringify(project.getProvidedContext())), }) - const testerHtml = typeof browserServer.testerHtml === 'string' - ? browserServer.testerHtml - : await browserServer.testerHtml + const testerHtml = typeof browserProject.testerHtml === 'string' + ? browserProject.testerHtml + : await browserProject.testerHtml try { - const url = join('/@fs/', browserServer.testerFilepath) - const indexhtml = await browserServer.vite.transformIndexHtml(url, testerHtml) - return replacer(indexhtml, { + const url = join('/@fs/', browserProject.testerFilepath) + const indexhtml = await browserProject.vite.transformIndexHtml(url, testerHtml) + const html = replacer(indexhtml, { __VITEST_FAVICON__: globalServer.faviconUrl, __VITEST_INJECTOR__: injector, __VITEST_APPEND__: ` @@ -79,6 +86,7 @@ export async function resolveTester( document.querySelector('script[data-vitest-append]').remove() `, }) + return html } catch (err) { session.reject(err) diff --git a/packages/runner/src/fixture.ts b/packages/runner/src/fixture.ts index cd073ec911e3..34a12f4e27d6 100644 --- a/packages/runner/src/fixture.ts +++ b/packages/runner/src/fixture.ts @@ -5,10 +5,6 @@ import { getFixture } from './map' export interface FixtureItem extends FixtureOptions { prop: string value: any - /** - * Indicated if the injected value should be preferred over the fixture value - */ - injected?: boolean /** * Indicates whether the fixture is a function */ diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index 1c593cb744ae..e5b2adfb09c9 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -443,6 +443,10 @@ export interface FixtureOptions { * Whether to automatically set up current fixture, even though it's not being used in tests. */ auto?: boolean + /** + * Indicated if the injected value from the config should be preferred over the fixture value + */ + injected?: boolean } export type Use = (value: T) => Promise diff --git a/packages/vitest/src/node/browser/sessions.ts b/packages/vitest/src/node/browser/sessions.ts index 06c702025260..b6847e000b05 100644 --- a/packages/vitest/src/node/browser/sessions.ts +++ b/packages/vitest/src/node/browser/sessions.ts @@ -1,4 +1,4 @@ -import type { TestProject } from 'vitest/node' +import type { TestProject } from '../project' import type { BrowserServerStateSession } from '../types/browser' import { createDefer } from '@vitest/utils' diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index 9cb532cb1e26..44c090e13db8 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -9,7 +9,7 @@ import type { ProvidedContext } from '../types/general' import type { OnTestsRerunHandler, Vitest } from './core' import type { GlobalSetupFile } from './globalSetup' import type { Logger } from './logger' -import type { BrowserServer } from './types/browser' +import type { ParentProjectBrowser, ProjectBrowser } from './types/browser' import type { ResolvedConfig, SerializedConfig, @@ -52,7 +52,7 @@ export class TestProject { /** * Browser instance if the browser is enabled. This is initialized when the tests run for the first time. */ - public browser?: BrowserServer + public browser?: ProjectBrowser /** @deprecated use `vitest` instead */ public ctx: Vitest @@ -64,6 +64,7 @@ export class TestProject { /** @internal */ vitenode!: ViteNodeServer /** @internal */ typechecker?: Typechecker + /** @internal */ _config?: ResolvedConfig private runner!: ViteNodeRunner @@ -74,7 +75,6 @@ export class TestProject { private _globalSetups?: GlobalSetupFile[] private _provided: ProvidedContext = {} as any - private _config?: ResolvedConfig private _vite?: ViteDevServer constructor( @@ -177,11 +177,11 @@ export class TestProject { throw new Error('The config was not set. It means that `project.config` was called before the Vite server was established.') } // checking it once should be enough - Object.defineProperty(this, 'config', { - configurable: true, - writable: true, - value: this._config, - }) + // Object.defineProperty(this, 'config', { + // configurable: true, + // writable: true, + // value: this._config, + // }) return this._config } @@ -492,11 +492,19 @@ export class TestProject { } /** @internal */ - _initBrowserServer = deduped(async () => { - if (!this.isBrowserEnabled() || this.browser) { + _parentBrowser?: ParentProjectBrowser + /** @internal */ + _parent?: TestProject + /** @internal */ + _initParentBrowser = deduped(async () => { + if (!this.isBrowserEnabled() || this._parentBrowser) { return } - await this.vitest.packageInstaller.ensureInstalled('@vitest/browser', this.config.root, this.vitest.version) + await this.vitest.packageInstaller.ensureInstalled( + '@vitest/browser', + this.config.root, + this.vitest.version, + ) const { createBrowserServer, distRoot } = await import('@vitest/browser') const browser = await createBrowserServer( this, @@ -513,12 +521,21 @@ export class TestProject { ], [CoverageTransform(this.vitest)], ) - this.browser = browser + this._parentBrowser = browser if (this.config.browser.ui) { setup(this.vitest, browser.vite) } }) + /** @internal */ + _initBrowserServer = deduped(async () => { + await this._parent?._initParentBrowser() + + if (!this.browser && this._parent?._parentBrowser) { + this.browser = this._parent._parentBrowser.spawn(this) + } + }) + /** * Closes the project and all associated resources. This can only be called once; the closing promise is cached until the server restarts. * If the resources are needed again, create a new project. @@ -635,6 +652,18 @@ export class TestProject { await this.browser?.initBrowserProvider(this) }) + /** @internal */ + public _provideObject(context: ProvidedContext): void { + for (const _providedKey in context) { + const providedKey = _providedKey as keyof ProvidedContext + // type is very strict here, so we cast it to any + (this.provide as (key: string, value: unknown) => void)( + providedKey, + context[providedKey], + ) + } + } + /** @internal */ static _createBasicProject(vitest: Vitest): TestProject { const project = new TestProject( @@ -645,69 +674,36 @@ export class TestProject { project.runner = vitest.runner project._vite = vitest.server project._config = vitest.config - for (const _providedKey in vitest.config.provide) { - const providedKey = _providedKey as keyof ProvidedContext - // type is very strict here, so we cast it to any - (project.provide as (key: string, value: unknown) => void)( - providedKey, - vitest.config.provide[providedKey], - ) - } + project._provideObject(vitest.config.provide) return project } /** @internal */ - static _cloneBrowserProject(project: TestProject, config: ResolvedConfig): TestProject { + static _cloneBrowserProject(parent: TestProject, config: ResolvedConfig): TestProject { const clone = new TestProject( - project.path, - project.vitest, + parent.path, + parent.vitest, ) - clone.vitenode = project.vitenode - clone.runner = project.runner - clone._vite = project._vite + clone.vitenode = parent.vitenode + clone.runner = parent.runner + clone._vite = parent._vite clone._config = config - for (const _providedKey in config.provide) { - const providedKey = _providedKey as keyof ProvidedContext - // type is very strict here, so we cast it to any - (clone.provide as (key: string, value: unknown) => void)( - providedKey, - config.provide[providedKey], - ) - } - clone._initBrowserServer = deduped(async () => { - if (clone.browser) { - return - } - await project._initBrowserServer() - const { cloneBrowserServer } = await import('@vitest/browser') - clone.browser = cloneBrowserServer(clone, project.browser!) - }) - clone._initBrowserProvider = deduped(async () => { - if (!clone.isBrowserEnabled() || clone.browser?.provider) { - return - } - if (!clone.browser) { - await clone._initBrowserServer() - } - if (!project.browser?.provider) { - await project.browser?.initBrowserProvider(project) - } - await clone.browser?.initBrowserProvider(clone) - }) + clone._parent = parent + clone._provideObject(config.provide) return clone } } -function deduped(cb: () => Promise) { +function deduped Promise>(cb: T): T { let _promise: Promise | undefined - return () => { + return ((...args: any[]) => { if (!_promise) { - _promise = cb().finally(() => { + _promise = cb(...args).finally(() => { _promise = undefined }) } return _promise - } + }) as T } export { diff --git a/packages/vitest/src/node/types/browser.ts b/packages/vitest/src/node/types/browser.ts index 7e915d6068c5..8ff550fbca6d 100644 --- a/packages/vitest/src/node/types/browser.ts +++ b/packages/vitest/src/node/types/browser.ts @@ -256,7 +256,11 @@ export interface BrowserServerState { orchestrators: Map } -export interface BrowserServer { +export interface ParentProjectBrowser { + spawn: (project: TestProject) => ProjectBrowser +} + +export interface ProjectBrowser { vite: ViteDevServer state: BrowserServerState provider: BrowserProvider diff --git a/packages/vitest/src/node/workspace/resolveWorkspace.ts b/packages/vitest/src/node/workspace/resolveWorkspace.ts index 00823a357e3b..be5eca8bc895 100644 --- a/packages/vitest/src/node/workspace/resolveWorkspace.ts +++ b/packages/vitest/src/node/workspace/resolveWorkspace.ts @@ -3,7 +3,7 @@ import type { BrowserInstanceOption, ResolvedConfig, TestProjectConfiguration, U import { existsSync, promises as fs } from 'node:fs' import os from 'node:os' import { limitConcurrency } from '@vitest/runner/utils' -import { toArray } from '@vitest/utils' +import { deepClone, toArray } from '@vitest/utils' import fg from 'fast-glob' import { dirname, relative, resolve } from 'pathe' import { mergeConfig } from 'vite' @@ -138,28 +138,28 @@ export async function resolveBrowserWorkspace( names: Set, resolvedProjects: TestProject[], ) { - const newConfigs: [project: TestProject, config: ResolvedConfig][] = [] + // const newConfigs: [project: TestProject, config: ResolvedConfig][] = [] const filters = toArray(vitest.config.project).map(s => wildcardPatternToRegExp(s)) + const removeProjects = new Set() resolvedProjects.forEach((project) => { const configs = project.config.browser.instances if (!project.config.browser.enabled || !configs || configs.length === 0) { return } - const [firstConfig, ...restConfigs] = configs const originalName = project.config.name - - if (!firstConfig.browser) { - throw new Error(`The browser configuration must have a "browser" property. The first item in "browser.instances" doesn't have it. Make sure your${originalName ? ` "${originalName}"` : ''} configuration is correct.`) - } - - const newName = originalName - ? `${originalName} (${firstConfig.browser})` - : firstConfig.browser - if (names.has(newName)) { - throw new Error(`Cannot redefine the project name for a nameless project. The project name "${firstConfig.browser}" was already defined. All projects in a workspace should have unique names. Make sure your configuration is correct.`) + const filteredConfigs = !filters.length + ? configs + : configs.filter((config) => { + const browser = config.browser + const newName = config.name || (originalName ? `${originalName} (${browser})` : browser) + return filters.some(pattern => pattern.test(newName)) + }) + + // every project was filtered out + if (!filteredConfigs.length) { + return } - names.add(newName) if (project.config.browser.providerOptions) { vitest.logger.warn( @@ -167,7 +167,7 @@ export async function resolveBrowserWorkspace( ) } - restConfigs.forEach((config, index) => { + filteredConfigs.forEach((config, index) => { const browser = config.browser if (!browser) { const nth = index + 1 @@ -176,10 +176,6 @@ export async function resolveBrowserWorkspace( } const name = config.name const newName = name || (originalName ? `${originalName} (${browser})` : browser) - // skip the project if it's filtered out - if (filters.length && !filters.some(pattern => pattern.test(newName))) { - return - } if (names.has(newName)) { throw new Error( @@ -193,17 +189,15 @@ export async function resolveBrowserWorkspace( names.add(newName) const clonedConfig = cloneConfig(project, config) clonedConfig.name = newName - newConfigs.push([project, clonedConfig]) + const clone = TestProject._cloneBrowserProject(project, clonedConfig) + resolvedProjects.push(clone) }) - Object.assign(project.config, cloneConfig(project, firstConfig)) - project.config.name = newName - }) - newConfigs.forEach(([project, clonedConfig]) => { - const clone = TestProject._cloneBrowserProject(project, clonedConfig) - resolvedProjects.push(clone) + removeProjects.add(project) }) + resolvedProjects = resolvedProjects.filter(project => !removeProjects.has(project)) + const headedBrowserProjects = resolvedProjects.filter((project) => { return project.config.browser.enabled && !project.config.browser.headless }) @@ -249,7 +243,7 @@ function cloneConfig(project: TestProject, { browser, ...config }: BrowserInstan } = config const currentConfig = project.config.browser return mergeConfig({ - ...project.config, + ...deepClone(project.config), browser: { ...project.config.browser, locators: locators diff --git a/packages/vitest/src/public/node.ts b/packages/vitest/src/public/node.ts index 18a2e0c46b64..bc7fbcb502a4 100644 --- a/packages/vitest/src/public/node.ts +++ b/packages/vitest/src/public/node.ts @@ -68,10 +68,11 @@ export type { BrowserProviderModule, BrowserProviderOptions, BrowserScript, - BrowserServer, BrowserServerState, BrowserServerStateSession, CDPSession, + ParentProjectBrowser, + ProjectBrowser, ResolvedBrowserOptions, } from '../node/types/browser' /** @deprecated use `createViteServer` instead */ diff --git a/test/browser/fixtures/multiple-different-configs/basic.test.js b/test/browser/fixtures/multiple-different-configs/basic.test.js new file mode 100644 index 000000000000..f5480f9199a5 --- /dev/null +++ b/test/browser/fixtures/multiple-different-configs/basic.test.js @@ -0,0 +1,23 @@ +import { test as baseTest, expect, inject } from 'vitest'; +import { server } from '@vitest/browser/context' + +const test = baseTest.extend({ + // chromium should inject the value as "true" + // firefox doesn't provide this value in the config, it will stay undefined + providedVar: [undefined, { injected: true }] +}) + +test('html injected', ({ providedVar }) => { + // window.HTML_INJECTED_VAR is injected only for chromium via a script in customTester.html + console.log(`[${server.config.name}] HTML_INJECTED_VAR is ${window.HTML_INJECTED_VAR}`) + expect(providedVar).toBe(window.HTML_INJECTED_VAR) +}) + +test.runIf(server.config.name === 'firefox')('[firefox] firefoxValue injected', ({ providedVar }) => { + expect(providedVar).toBeUndefined() + expect(inject('firefoxValue')).toBe(true) +}) + +test.runIf(server.config.name === 'chromium')('[chromium] firefoxValue is not injected', () => { + expect(inject('firefoxValue')).toBeUndefined() +}) \ No newline at end of file diff --git a/test/browser/fixtures/multiple-different-configs/customTester.html b/test/browser/fixtures/multiple-different-configs/customTester.html new file mode 100644 index 000000000000..97cc881fe0a2 --- /dev/null +++ b/test/browser/fixtures/multiple-different-configs/customTester.html @@ -0,0 +1,13 @@ + + + + + + Document + + + + + \ No newline at end of file diff --git a/test/browser/fixtures/multiple-different-configs/vitest.config.js b/test/browser/fixtures/multiple-different-configs/vitest.config.js new file mode 100644 index 000000000000..bf4a95d5378c --- /dev/null +++ b/test/browser/fixtures/multiple-different-configs/vitest.config.js @@ -0,0 +1,30 @@ +import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url' + +export default defineConfig({ + clearScreen: false, + cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), + test: { + browser: { + provider: 'playwright', + enabled: true, + headless: true, + screenshotFailures: false, + instances: [ + { + browser: 'chromium', + testerHtmlPath: './customTester.html', + provide: { + providedVar: true, + }, + }, + { + browser: 'firefox', + provide: { + firefoxValue: true, + }, + }, + ], + }, + }, +}) diff --git a/test/browser/package.json b/test/browser/package.json index aa7c3602ba69..0d9c6742185d 100644 --- a/test/browser/package.json +++ b/test/browser/package.json @@ -12,6 +12,7 @@ "test-mocking": "vitest --root ./fixtures/mocking", "test-mocking-watch": "vitest --root ./fixtures/mocking-watch", "test-locators": "vitest --root ./fixtures/locators", + "test-different-configs": "vitest --root ./fixtures/multiple-different-configs", "test-setup-file": "vitest --root ./fixtures/setup-file", "test-snapshots": "vitest --root ./fixtures/update-snapshot", "coverage": "vitest --coverage.enabled --coverage.provider=istanbul --browser.headless=yes", diff --git a/test/browser/specs/multiple-different-configs.test.ts b/test/browser/specs/multiple-different-configs.test.ts new file mode 100644 index 000000000000..2e7551ed0e8e --- /dev/null +++ b/test/browser/specs/multiple-different-configs.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from 'vitest' +import { provider } from '../settings' +import { runBrowserTests } from './utils' + +test.runIf(provider === 'playwright')('[playwright] runs multiple different configurations correctly', async () => { + const { stdout, exitCode, stderr } = await runBrowserTests({ + root: './fixtures/multiple-different-configs', + }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(stdout).toContain('[chromium] HTML_INJECTED_VAR is true') + expect(stdout).toContain('[firefox] HTML_INJECTED_VAR is undefined') +})