import type { HtmlTagDescriptor } from 'vite'
import type { ErrorWithDiff, SerializedConfig } from 'vitest'
import type {
BrowserProvider,
BrowserScript,
CDPSession,
BrowserServer as IBrowserServer,
Vite,
WorkspaceProject,
} from 'vitest/node'
import { existsSync } from 'node:fs'
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 { BrowserServerState } from './state'
import { getBrowserProvider } from './utils'
export class BrowserServer implements IBrowserServer {
public faviconUrl: string
public prefixTesterUrl: string
public orchestratorScripts: string | undefined
public testerScripts: HtmlTagDescriptor[] | undefined
public manifest: Promise | Vite.Manifest
public testerHtml: Promise | string
public testerFilepath: string
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
constructor(
public project: WorkspaceProject,
public base: string,
) {
this.stackTraceOptions = {
frameFilter: project.config.onStackTrace,
getSourceMap: (id) => {
const result = this.vite.moduleGraph.getModuleById(id)?.transformResult
return result?.map
},
getFileName: (id) => {
const mod = this.vite.moduleGraph.getModuleById(id)
if (mod?.file) {
return mod.file
}
const modUrl = this.vite.moduleGraph.urlToModuleMap.get(id)
if (modUrl?.file) {
return modUrl.file
}
return id
},
}
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`
this.manifest = (async () => {
return JSON.parse(
await readFile(`${distRoot}/client/.vite/manifest.json`, 'utf8'),
)
})().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'))
.then(html => (this.orchestratorHtml = html))
this.injectorJs = readFile(
resolve(distRoot, 'client/esm-client-injector.js'),
'utf8',
).then(js => (this.injectorJs = js))
this.errorCatcherUrl = join('/@fs/', resolve(distRoot, 'client/error-catcher.js'))
const builtinProviders = ['playwright', 'webdriverio', 'preview']
const providerName = project.config.browser.provider || 'preview'
if (builtinProviders.includes(providerName)) {
this.locatorsUrl = join('/@fs/', distRoot, 'locators', `${providerName}.js`)
}
this.stateJs = readFile(
resolve(distRoot, 'state.js'),
'utf-8',
).then(js => (this.stateJs = js))
}
setServer(server: Vite.ViteDevServer) {
this.vite = server
}
getSerializableConfig() {
const config = wrapConfig(this.project.getSerializableConfig())
config.env ??= {}
config.env.VITEST_BROWSER_DEBUG = process.env.VITEST_BROWSER_DEBUG || ''
return config
}
resolveTesterUrl(pathname: string) {
const [contextId, testFile] = pathname
.slice(this.prefixTesterUrl.length)
.split('/')
const decodedTestFile = decodeURIComponent(testFile)
return { contextId, testFile: decodedTestFile }
}
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))
}
async initBrowserProvider() {
if (this.provider) {
return
}
const Provider = await getBrowserProvider(this.project.config.browser, this.project)
this.provider = new Provider()
const browser = this.project.config.browser.name
if (!browser) {
throw new Error(
`[${this.project.getName()}] Browser name is required. Please, set \`test.browser.name\` option manually.`,
)
}
const supportedBrowsers = this.provider.getSupportedBrowsers()
if (supportedBrowsers.length && !supportedBrowsers.includes(browser)) {
throw new Error(
`[${this.project.getName()}] Browser "${browser}" is not supported by the browser provider "${
this.provider.name
}". Supported browsers: ${supportedBrowsers.join(', ')}.`,
)
}
const providerOptions = this.project.config.browser.providerOptions
await this.provider.initialize(this.project, {
browser,
options: providerOptions,
})
}
public parseErrorStacktrace(
e: ErrorWithDiff,
options: StackTraceParserOptions = {},
) {
return parseErrorStacktrace(e, {
...this.stackTraceOptions,
...options,
})
}
public parseStacktrace(
trace: string,
options: StackTraceParserOptions = {},
) {
return parseStacktrace(trace, {
...this.stackTraceOptions,
...options,
})
}
private cdpSessionsPromises = new Map>()
async ensureCDPHandler(contextId: string, sessionId: string) {
const cachedHandler = this.state.cdps.get(sessionId)
if (cachedHandler) {
return cachedHandler
}
const provider = this.provider
if (!provider.getCDPSession) {
throw new Error(`CDP is not supported by the provider "${provider.name}".`)
}
const promise = this.cdpSessionsPromises.get(sessionId) ?? await (async () => {
const promise = provider.getCDPSession!(contextId).finally(() => {
this.cdpSessionsPromises.delete(sessionId)
})
this.cdpSessionsPromises.set(sessionId, promise)
return promise
})()
const session = await promise
const rpc = this.state.testers.get(sessionId)
if (!rpc) {
throw new Error(`Tester RPC "${sessionId}" was not established.`)
}
const handler = new BrowserServerCDPHandler(session, rpc)
this.state.cdps.set(
sessionId,
handler,
)
return handler
}
async close() {
await this.vite.close()
}
}
function wrapConfig(config: SerializedConfig): SerializedConfig {
return {
...config,
// workaround RegExp serialization
testNamePattern: config.testNamePattern
? (config.testNamePattern.toString() as any as RegExp)
: undefined,
}
}