mirror of
https://github.com/vitest-dev/vitest.git
synced 2025-12-08 18:26:03 +00:00
239 lines
7.7 KiB
TypeScript
239 lines
7.7 KiB
TypeScript
import type { ErrorWithDiff } from 'vitest'
|
|
import type { BrowserCommandContext } from 'vitest/node'
|
|
import type { WebSocket } from 'ws'
|
|
import type { BrowserServer } from './server'
|
|
import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from './types'
|
|
import { existsSync, promises as fs } from 'node:fs'
|
|
import { ServerMockResolver } from '@vitest/mocker/node'
|
|
import { createBirpc } from 'birpc'
|
|
import { parse, stringify } from 'flatted'
|
|
import { dirname } from 'pathe'
|
|
import { createDebugger, isFileServingAllowed } from 'vitest/node'
|
|
import { WebSocketServer } from 'ws'
|
|
|
|
const debug = createDebugger('vitest:browser:api')
|
|
|
|
const BROWSER_API_PATH = '/__vitest_browser_api__'
|
|
|
|
export function setupBrowserRpc(server: BrowserServer) {
|
|
const project = server.project
|
|
const vite = server.vite
|
|
const ctx = project.ctx
|
|
|
|
const wss = new WebSocketServer({ noServer: true })
|
|
|
|
vite.httpServer?.on('upgrade', (request, socket, head) => {
|
|
if (!request.url) {
|
|
return
|
|
}
|
|
|
|
const { pathname, searchParams } = new URL(request.url, 'http://localhost')
|
|
if (pathname !== BROWSER_API_PATH) {
|
|
return
|
|
}
|
|
|
|
const type = searchParams.get('type') ?? 'tester'
|
|
const sessionId = searchParams.get('sessionId') ?? '0'
|
|
|
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
wss.emit('connection', ws, request)
|
|
|
|
const rpc = setupClient(sessionId, ws)
|
|
const state = server.state
|
|
const clients = type === 'tester' ? state.testers : state.orchestrators
|
|
clients.set(sessionId, rpc)
|
|
|
|
debug?.('[%s] Browser API connected to %s', sessionId, type)
|
|
|
|
ws.on('close', () => {
|
|
debug?.('[%s] Browser API disconnected from %s', sessionId, type)
|
|
clients.delete(sessionId)
|
|
server.state.removeCDPHandler(sessionId)
|
|
})
|
|
})
|
|
})
|
|
|
|
function checkFileAccess(path: string) {
|
|
if (!isFileServingAllowed(path, vite)) {
|
|
throw new Error(
|
|
`Access denied to "${path}". See Vite config documentation for "server.fs": https://vitejs.dev/config/server-options.html#server-fs-strict.`,
|
|
)
|
|
}
|
|
}
|
|
|
|
function setupClient(sessionId: string, ws: WebSocket) {
|
|
const mockResolver = new ServerMockResolver(server.vite, {
|
|
moduleDirectories: project.config.server?.deps?.moduleDirectories,
|
|
})
|
|
|
|
const rpc = createBirpc<WebSocketBrowserEvents, WebSocketBrowserHandlers>(
|
|
{
|
|
async onUnhandledError(error, type) {
|
|
if (error && typeof error === 'object') {
|
|
const _error = error as ErrorWithDiff
|
|
_error.stacks = server.parseErrorStacktrace(_error)
|
|
}
|
|
ctx.state.catchError(error, type)
|
|
},
|
|
async onCollected(files) {
|
|
ctx.state.collectFiles(project, files)
|
|
await ctx.report('onCollected', files)
|
|
},
|
|
async onTaskUpdate(packs) {
|
|
ctx.state.updateTasks(packs)
|
|
await ctx.report('onTaskUpdate', packs)
|
|
},
|
|
onAfterSuiteRun(meta) {
|
|
ctx.coverageProvider?.onAfterSuiteRun(meta)
|
|
},
|
|
sendLog(log) {
|
|
return ctx.report('onUserConsoleLog', log)
|
|
},
|
|
resolveSnapshotPath(testPath) {
|
|
return ctx.snapshot.resolvePath(testPath)
|
|
},
|
|
resolveSnapshotRawPath(testPath, rawPath) {
|
|
return ctx.snapshot.resolveRawPath(testPath, rawPath)
|
|
},
|
|
snapshotSaved(snapshot) {
|
|
ctx.snapshot.add(snapshot)
|
|
},
|
|
async readSnapshotFile(snapshotPath) {
|
|
checkFileAccess(snapshotPath)
|
|
if (!existsSync(snapshotPath)) {
|
|
return null
|
|
}
|
|
return fs.readFile(snapshotPath, 'utf-8')
|
|
},
|
|
async saveSnapshotFile(id, content) {
|
|
checkFileAccess(id)
|
|
await fs.mkdir(dirname(id), { recursive: true })
|
|
return fs.writeFile(id, content, 'utf-8')
|
|
},
|
|
async removeSnapshotFile(id) {
|
|
checkFileAccess(id)
|
|
if (!existsSync(id)) {
|
|
throw new Error(`Snapshot file "${id}" does not exist.`)
|
|
}
|
|
return fs.unlink(id)
|
|
},
|
|
getBrowserFileSourceMap(id) {
|
|
const mod = server.vite.moduleGraph.getModuleById(id)
|
|
return mod?.transformResult?.map
|
|
},
|
|
onCancel(reason) {
|
|
ctx.cancelCurrentRun(reason)
|
|
},
|
|
async resolveId(id, importer) {
|
|
return mockResolver.resolveId(id, importer)
|
|
},
|
|
debug(...args) {
|
|
ctx.logger.console.debug(...args)
|
|
},
|
|
getCountOfFailedTests() {
|
|
return ctx.state.getCountOfFailedTests()
|
|
},
|
|
async triggerCommand(contextId, command, testPath, payload) {
|
|
debug?.('[%s] Triggering command "%s"', contextId, command)
|
|
const provider = server.provider
|
|
if (!provider) {
|
|
throw new Error('Commands are only available for browser tests.')
|
|
}
|
|
const commands = project.config.browser?.commands
|
|
if (!commands || !commands[command]) {
|
|
throw new Error(`Unknown command "${command}".`)
|
|
}
|
|
if (provider.beforeCommand) {
|
|
await provider.beforeCommand(command, payload)
|
|
}
|
|
const context = Object.assign(
|
|
{
|
|
testPath,
|
|
project,
|
|
provider,
|
|
contextId,
|
|
},
|
|
provider.getCommandsContext(contextId),
|
|
) as any as BrowserCommandContext
|
|
let result
|
|
try {
|
|
result = await commands[command](context, ...payload)
|
|
}
|
|
finally {
|
|
if (provider.afterCommand) {
|
|
await provider.afterCommand(command, payload)
|
|
}
|
|
}
|
|
return result
|
|
},
|
|
finishBrowserTests(contextId: string) {
|
|
debug?.('[%s] Finishing browser tests for context', contextId)
|
|
return server.state.getContext(contextId)?.resolve()
|
|
},
|
|
resolveMock(rawId, importer, options) {
|
|
return mockResolver.resolveMock(rawId, importer, options)
|
|
},
|
|
invalidate(ids) {
|
|
return mockResolver.invalidate(ids)
|
|
},
|
|
|
|
// CDP
|
|
async sendCdpEvent(contextId: string, event: string, payload?: Record<string, unknown>) {
|
|
const cdp = await server.ensureCDPHandler(contextId, sessionId)
|
|
return cdp.send(event, payload)
|
|
},
|
|
async trackCdpEvent(contextId: string, type: 'on' | 'once' | 'off', event: string, listenerId: string) {
|
|
const cdp = await server.ensureCDPHandler(contextId, sessionId)
|
|
cdp[type](event, listenerId)
|
|
},
|
|
},
|
|
{
|
|
post: msg => ws.send(msg),
|
|
on: fn => ws.on('message', fn),
|
|
eventNames: ['onCancel', 'cdpEvent'],
|
|
serialize: (data: any) => stringify(data, stringifyReplace),
|
|
deserialize: parse,
|
|
onTimeoutError(functionName) {
|
|
throw new Error(`[vitest-api]: Timeout calling "${functionName}"`)
|
|
},
|
|
},
|
|
)
|
|
|
|
ctx.onCancel(reason => rpc.onCancel(reason))
|
|
|
|
return rpc
|
|
}
|
|
}
|
|
// 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.
|
|
return Object.getOwnPropertyNames(value).reduce(
|
|
(clone, prop) => ({
|
|
...clone,
|
|
[prop]: value[prop],
|
|
}),
|
|
{},
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Replacer function for serialization methods such as JS.stringify() or
|
|
* flatted.stringify().
|
|
*/
|
|
export function stringifyReplace(key: string, value: any) {
|
|
if (value instanceof Error) {
|
|
const cloned = cloneByOwnProperties(value)
|
|
return {
|
|
name: value.name,
|
|
message: value.message,
|
|
stack: value.stack,
|
|
...cloned,
|
|
}
|
|
}
|
|
else {
|
|
return value
|
|
}
|
|
}
|