import type { StackTraceParserOptions } from '@vitest/utils/source-map' import type { ViteDevServer } from 'vite' import type { ParsedStack, SerializedConfig, TestError } from 'vitest' import type { BrowserCommands } from 'vitest/browser' import type { BrowserCommand, BrowserCommandContext, 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 { resolve } from 'pathe' import { distRoot } from './constants' import { BrowserServerState } from './state' import { getBrowserProvider } from './utils' export class ProjectBrowser implements IProjectBrowser { public testerHtml: Promise | string public testerFilepath: string // the tester template is read once per server, so the transformed HTML // is stable too — computing it for every iframe request pays the whole // transformIndexHtml plugin pipeline each time public testerHtmlTransformed: Promise | undefined public provider!: BrowserProvider public vitest: Vitest public vite: ViteDevServer public config: ResolvedConfig public state: BrowserServerState = new BrowserServerState() constructor( public parent: ParentBrowserProject, public project: TestProject, public base: string, ) { this.vitest = project.vitest this.config = project.config this.vite = parent.vite // instances can override testerHtmlPath const testerHtmlPath = project.config.browser.testerHtmlPath ? resolve(project.config.root, project.config.browser.testerHtmlPath) : resolve(distRoot, 'client/tester/tester.html') // TODO: when config resolution is rewritten, project and parentProject should be created before the vite server is started if (!existsSync(testerHtmlPath)) { throw new Error(`Tester HTML file "${testerHtmlPath}" doesn't exist.`) } this.testerFilepath = testerHtmlPath this.testerHtml = readFile( this.testerFilepath, 'utf8', ).then(html => (this.testerHtml = html)) } private commands = {} as Record> public registerCommand( name: K, cb: BrowserCommand< Parameters, ReturnType >, ): void { if (!/^[a-z_$][\w$]*$/i.test(name)) { throw new Error( `Invalid command name "${name}". Only alphanumeric characters, $ and _ are allowed.`, ) } this.commands[name] = cb } public triggerCommand = (( name: K, context: BrowserCommandContext, ...args: Parameters ): ReturnType => { if (name in this.commands) { return this.commands[name](context, ...args) } if (name in this.parent.commands) { return this.parent.commands[name](context, ...args) } throw new Error(`Provider ${this.provider.name} does not support command "${name}".`) }) as any wrapSerializedConfig(): SerializedConfig { const config = wrapConfig(this.project.serializedConfig) config.env ??= {} config.env.VITEST_BROWSER_DEBUG = process.env.VITEST_BROWSER_DEBUG || '' return config } async initBrowserProvider(project: TestProject): Promise { if (this.provider) { return } this.provider = await getBrowserProvider(project.config.browser, project) if (this.provider.initScripts) { this.parent.initScripts = this.provider.initScripts // make sure the script can be imported const allow = this.parent.vite.config.server.fs.allow this.provider.initScripts.forEach((script) => { if (!allow.includes(script)) { allow.push(script) } }) } } public parseErrorStacktrace( e: TestError, options: StackTraceParserOptions = {}, ): ParsedStack[] { return this.parent.parseErrorStacktrace(e, options) } public parseStacktrace( trace: string, options: StackTraceParserOptions = {}, ): ParsedStack[] { return this.parent.parseStacktrace(trace, options) } async close(): Promise { await this.vite.close() } } function wrapConfig(config: SerializedConfig): SerializedConfig { config = { ...config } // workaround RegExp serialization config.testNamePattern &&= config.testNamePattern.toString() as any as RegExp // workaround RegExp serialization if (typeof config.retry === 'object') { config.retry.condition &&= config.retry.condition.toString() as any as RegExp } return config }