diff --git a/docs/config/index.md b/docs/config/index.md
index 5f8a9091b3b2..d0206a66699e 100644
--- a/docs/config/index.md
+++ b/docs/config/index.md
@@ -1518,7 +1518,21 @@ Run the browser in a `headless` mode. If you are running Vitest in CI, it will b
- **Default:** `true`
- **CLI:** `--browser.isolate`, `--browser.isolate=false`
-Isolate test environment after each test.
+Run every test in a separate iframe.
+
+### browser.fileParallelism 1.3.0+
+
+- **Type:** `boolean`
+- **Default:** the same as [`fileParallelism`](#fileparallelism-110)
+- **CLI:** `--browser.fileParallelism=false`
+
+Create all test iframes at the same time so they are running in parallel.
+
+This makes it impossible to use interactive APIs (like clicking or hovering) because there are several iframes on the screen at the same time, but if your tests don't rely on those APIs, it might be much faster to just run all of them at the same time.
+
+::: tip
+If you disabled isolation via [`browser.isolate=false`](#browserisolate), your test files will still run one after another because of the nature of the test runner.
+:::
#### browser.api
diff --git a/packages/browser/src/client/client.ts b/packages/browser/src/client/client.ts
new file mode 100644
index 000000000000..80806d587f8e
--- /dev/null
+++ b/packages/browser/src/client/client.ts
@@ -0,0 +1,21 @@
+import type { CancelReason } from '@vitest/runner'
+import { createClient } from '@vitest/ws-client'
+
+export const PORT = import.meta.hot ? '51204' : location.port
+export const HOST = [location.hostname, PORT].filter(Boolean).join(':')
+export const ENTRY_URL = `${
+ location.protocol === 'https:' ? 'wss:' : 'ws:'
+}//${HOST}/__vitest_api__`
+
+let setCancel = (_: CancelReason) => {}
+export const onCancel = new Promise((resolve) => {
+ setCancel = resolve
+})
+
+export const client = createClient(ENTRY_URL, {
+ handlers: {
+ onCancel: setCancel,
+ },
+})
+
+export const channel = new BroadcastChannel('vitest')
diff --git a/packages/browser/src/client/index.html b/packages/browser/src/client/index.html
index 796f831ab884..c8dee89a68d5 100644
--- a/packages/browser/src/client/index.html
+++ b/packages/browser/src/client/index.html
@@ -2,7 +2,7 @@
-
+
Vitest Browser Runner
+
-
+
diff --git a/packages/browser/src/client/logger.ts b/packages/browser/src/client/logger.ts
index 8a4e615f0ea4..c65eaa3f3290 100644
--- a/packages/browser/src/client/logger.ts
+++ b/packages/browser/src/client/logger.ts
@@ -3,8 +3,8 @@ import { importId } from './utils'
const { Date, console } = globalThis
-export async function setupConsoleLogSpy(basePath: string) {
- const { stringify, format, inspect } = await importId('vitest/utils', basePath) as typeof import('vitest/utils')
+export async function setupConsoleLogSpy() {
+ const { stringify, format, inspect } = await importId('vitest/utils') as typeof import('vitest/utils')
const { log, info, error, dir, dirxml, trace, time, timeEnd, timeLog, warn, debug, count, countReset } = console
const formatInput = (input: unknown) => {
if (input instanceof Node)
diff --git a/packages/browser/src/client/main.ts b/packages/browser/src/client/main.ts
index 5a103644575c..8415846c62fb 100644
--- a/packages/browser/src/client/main.ts
+++ b/packages/browser/src/client/main.ts
@@ -1,284 +1,137 @@
-import { createClient } from '@vitest/ws-client'
-import type { ResolvedConfig } from 'vitest'
-import type { CancelReason, VitestRunner } from '@vitest/runner'
-import { parseRegexp } from '@vitest/utils'
-import type { VitestExecutor } from '../../../vitest/src/runtime/execute'
-import { createBrowserRunner } from './runner'
-import { importId as _importId } from './utils'
-import { setupConsoleLogSpy } from './logger'
-import { createSafeRpc, rpc, rpcDone } from './rpc'
-import { setupDialogsSpy } from './dialog'
-import { BrowserSnapshotEnvironment } from './snapshot'
-import { VitestBrowserClientMocker } from './mocker'
-
-export const PORT = import.meta.hot ? '51204' : location.port
-export const HOST = [location.hostname, PORT].filter(Boolean).join(':')
-export const ENTRY_URL = `${
- location.protocol === 'https:' ? 'wss:' : 'ws:'
-}//${HOST}/__vitest_api__`
-
-let config: ResolvedConfig | undefined
-let runner: VitestRunner | undefined
-const browserHashMap = new Map()
+import { channel, client } from './client'
+import { rpcDone } from './rpc'
+import { getBrowserState, getConfig } from './utils'
const url = new URL(location.href)
-const testId = url.searchParams.get('id') || 'unknown'
-const reloadTries = Number(url.searchParams.get('reloadTries') || '0')
-
-const basePath = () => config?.base || '/'
-const importId = (id: string) => _importId(id, basePath())
-const viteClientPath = () => `${basePath()}@vite/client`
-
-function getQueryPaths() {
- return url.searchParams.getAll('path')
-}
-
-let setCancel = (_: CancelReason) => {}
-const onCancel = new Promise((resolve) => {
- setCancel = resolve
-})
-export const client = createClient(ENTRY_URL, {
- handlers: {
- onCancel: setCancel,
- },
-})
+const ID_ALL = '__vitest_all__'
-const ws = client.ws
+const iframes = new Map()
-async function loadConfig() {
- let retries = 5
- do {
- try {
- await new Promise(resolve => setTimeout(resolve, 150))
- config = await client.rpc.getConfig()
- config = unwrapConfig(config)
- return
- }
- catch (_) {
- // just ignore
- }
- }
- while (--retries > 0)
-
- throw new Error('cannot load configuration after 5 retries')
+function debug(...args: unknown[]) {
+ const debug = getConfig().env.VITEST_BROWSER_DEBUG
+ if (debug && debug !== 'false')
+ client.rpc.debug(...args.map(String))
}
-function unwrapConfig(config: ResolvedConfig): ResolvedConfig {
- return {
- ...config,
- // workaround RegExp serialization
- testNamePattern:
- config.testNamePattern
- ? parseRegexp((config.testNamePattern as any as string))
- : undefined,
+function createIframe(container: HTMLDivElement, file: string) {
+ if (iframes.has(file)) {
+ container.removeChild(iframes.get(file)!)
+ iframes.delete(file)
}
-}
-function on(event: string, listener: (...args: any[]) => void) {
- window.addEventListener(event, listener)
- return () => window.removeEventListener(event, listener)
+ const iframe = document.createElement('iframe')
+ iframe.setAttribute('loading', 'eager')
+ iframe.setAttribute('src', `${url.pathname}__vitest_test__/__test__/${encodeURIComponent(file)}`)
+ iframes.set(file, iframe)
+ container.appendChild(iframe)
+ return iframe
}
-function serializeError(unhandledError: any) {
- return {
- ...unhandledError,
- name: unhandledError.name,
- message: unhandledError.message,
- stack: String(unhandledError.stack),
- }
+async function done() {
+ await rpcDone()
+ await client.rpc.finishBrowserTests()
}
-// we can't import "processError" yet because error might've been thrown before the module was loaded
-async function defaultErrorReport(type: string, unhandledError: any) {
- const error = serializeError(unhandledError)
- if (testId !== 'no-isolate')
- error.VITEST_TEST_PATH = testId
- await client.rpc.onUnhandledError(error, type)
- await client.rpc.onDone(testId)
+interface IframeDoneEvent {
+ type: 'done'
+ filenames: string[]
}
-function catchWindowErrors(cb: (e: ErrorEvent) => void) {
- let userErrorListenerCount = 0
- function throwUnhandlerError(e: ErrorEvent) {
- if (userErrorListenerCount === 0 && e.error != null)
- cb(e)
- else
- console.error(e.error)
- }
- const addEventListener = window.addEventListener.bind(window)
- const removeEventListener = window.removeEventListener.bind(window)
- window.addEventListener('error', throwUnhandlerError)
- window.addEventListener = function (...args: Parameters) {
- if (args[0] === 'error')
- userErrorListenerCount++
- return addEventListener.apply(this, args)
- }
- window.removeEventListener = function (...args: Parameters) {
- if (args[0] === 'error' && userErrorListenerCount)
- userErrorListenerCount--
- return removeEventListener.apply(this, args)
- }
- return function clearErrorHandlers() {
- window.removeEventListener('error', throwUnhandlerError)
- }
-}
-
-const stopErrorHandler = catchWindowErrors(e => defaultErrorReport('Error', e.error))
-const stopRejectionHandler = on('unhandledrejection', e => defaultErrorReport('Unhandled Rejection', e.reason))
-
-let runningTests = false
-
-async function reportUnexpectedError(rpc: typeof client.rpc, type: string, error: any) {
- const { processError } = await importId('vitest/browser') as typeof import('vitest/browser')
- const processedError = processError(error)
- if (testId !== 'no-isolate')
- error.VITEST_TEST_PATH = testId
- await rpc.onUnhandledError(processedError, type)
- if (!runningTests)
- await rpc.onDone(testId)
+interface IframeErrorEvent {
+ type: 'error'
+ error: any
+ errorType: string
+ files: string[]
}
-ws.addEventListener('open', async () => {
- await loadConfig()
-
- let safeRpc: typeof client.rpc
- try {
- // if importing /@id/ failed, we reload the page waiting until Vite prebundles it
- const { getSafeTimers } = await importId('vitest/utils') as typeof import('vitest/utils')
- safeRpc = createSafeRpc(client, getSafeTimers)
- }
- catch (err: any) {
- if (reloadTries >= 10) {
- const error = serializeError(new Error('Vitest failed to load "vitest/utils" after 10 retries.'))
- error.cause = serializeError(err)
-
- await client.rpc.onUnhandledError(error, 'Reload Error')
- await client.rpc.onDone(testId)
- return
- }
-
- const tries = reloadTries + 1
- const newUrl = new URL(location.href)
- newUrl.searchParams.set('reloadTries', String(tries))
- location.href = newUrl.href
- return
- }
-
- stopErrorHandler()
- stopRejectionHandler()
-
- catchWindowErrors(event => reportUnexpectedError(safeRpc, 'Error', event.error))
- on('unhandledrejection', event => reportUnexpectedError(safeRpc, 'Unhandled Rejection', event.reason))
-
- // @ts-expect-error untyped global for internal use
- globalThis.__vitest_browser__ = true
- // @ts-expect-error mocking vitest apis
- globalThis.__vitest_worker__ = {
- config,
- browserHashMap,
- environment: {
- name: 'browser',
- },
- // @ts-expect-error untyped global for internal use
- moduleCache: globalThis.__vi_module_cache__,
- rpc: client.rpc,
- safeRpc,
- durations: {
- environment: 0,
- prepare: 0,
- },
- providedContext: await client.rpc.getProvidedContext(),
- }
- // @ts-expect-error mocking vitest apis
- globalThis.__vitest_mocker__ = new VitestBrowserClientMocker()
-
- const paths = getQueryPaths()
-
- const iFrame = document.getElementById('vitest-ui') as HTMLIFrameElement
- iFrame.setAttribute('src', '/__vitest__/')
-
- await setupConsoleLogSpy(basePath())
- setupDialogsSpy()
- await runTests(paths, config!)
-})
+type IframeChannelEvent = IframeDoneEvent | IframeErrorEvent
-async function prepareTestEnvironment(config: ResolvedConfig) {
- // need to import it before any other import, otherwise Vite optimizer will hang
- await import(viteClientPath())
+client.ws.addEventListener('open', async () => {
+ const config = getConfig()
+ const container = document.querySelector('#vitest-tester') as HTMLDivElement
+ const testFiles = getBrowserState().files
- const {
- startTests,
- setupCommonEnv,
- loadDiffConfig,
- loadSnapshotSerializers,
- takeCoverageInsideWorker,
- } = await importId('vitest/browser') as typeof import('vitest/browser')
-
- const executor = {
- executeId: (id: string) => importId(id),
- }
-
- if (!runner) {
- const { VitestTestRunner } = await importId('vitest/runners') as typeof import('vitest/runners')
- const BrowserRunner = createBrowserRunner(VitestTestRunner, { takeCoverage: () => takeCoverageInsideWorker(config.coverage, executor) })
- runner = new BrowserRunner({ config, browserHashMap })
- }
-
- return {
- startTests,
- setupCommonEnv,
- loadDiffConfig,
- loadSnapshotSerializers,
- executor,
- runner,
- }
-}
+ debug('test files', testFiles.join(', '))
-async function runTests(paths: string[], config: ResolvedConfig) {
- let preparedData: Awaited> | undefined
- // if importing /@id/ failed, we reload the page waiting until Vite prebundles it
- try {
- preparedData = await prepareTestEnvironment(config)
- }
- catch (err) {
- location.reload()
+ // TODO: fail tests suite because no tests found?
+ if (!testFiles.length) {
+ await done()
return
}
- const { startTests, setupCommonEnv, loadDiffConfig, loadSnapshotSerializers, executor, runner } = preparedData!
-
- onCancel.then((reason) => {
- runner?.onCancel?.(reason)
+ const runningFiles = new Set(testFiles)
+
+ channel.addEventListener('message', async (e: MessageEvent): Promise => {
+ debug('channel event', JSON.stringify(e.data))
+ switch (e.data.type) {
+ case 'done': {
+ const filenames = e.data.filenames
+ filenames.forEach(filename => runningFiles.delete(filename))
+
+ if (!runningFiles.size)
+ await done()
+ break
+ }
+ // error happened at the top level, this should never happen in user code, but it can trigger during development
+ case 'error': {
+ const iframeId = e.data.files.length > 1 ? ID_ALL : e.data.files[0]
+ iframes.delete(iframeId)
+ await client.rpc.onUnhandledError(e.data.error, e.data.errorType)
+ if (iframeId === ID_ALL)
+ runningFiles.clear()
+ else
+ runningFiles.delete(iframeId)
+ if (!runningFiles.size)
+ await done()
+ break
+ }
+ default: {
+ await client.rpc.onUnhandledError({
+ name: 'Unexpected Event',
+ message: `Unexpected event: ${(e.data as any).type}`,
+ }, 'Unexpected Event')
+ await done()
+ }
+ }
})
- if (!config.snapshotOptions.snapshotEnvironment)
- config.snapshotOptions.snapshotEnvironment = new BrowserSnapshotEnvironment()
-
- try {
- const [diffOptions] = await Promise.all([
- loadDiffConfig(config, executor as VitestExecutor),
- loadSnapshotSerializers(config, executor as VitestExecutor),
- ])
- runner.config.diffOptions = diffOptions
-
- await setupCommonEnv(config)
- const files = paths.map((path) => {
- return (`${config.root}/${path}`).replace(/\/+/g, '/')
- })
-
- const now = `${new Date().getTime()}`
- files.forEach(i => browserHashMap.set(i, [true, now]))
-
- runningTests = true
-
- for (const file of files)
- await startTests([file], runner)
- }
- finally {
- runningTests = false
-
- await rpcDone()
- await rpc().onDone(testId)
+ const fileParallelism = config.browser.fileParallelism ?? config.fileParallelism
+
+ if (config.isolate === false) {
+ createIframe(
+ container,
+ ID_ALL,
+ )
+ }
+ else {
+ // if fileParallelism is enabled, we can create all iframes at once
+ if (fileParallelism) {
+ for (const file of testFiles) {
+ createIframe(
+ container,
+ file,
+ )
+ }
+ }
+ else {
+ // otherwise, we need to wait for each iframe to finish before creating the next one
+ // this is the most stable way to run tests in the browser
+ for (const file of testFiles) {
+ createIframe(
+ container,
+ file,
+ )
+ await new Promise((resolve) => {
+ channel.addEventListener('message', function handler(e: MessageEvent) {
+ // done and error can only be triggered by the previous iframe
+ if (e.data.type === 'done' || e.data.type === 'error') {
+ channel.removeEventListener('message', handler)
+ resolve()
+ }
+ })
+ })
+ }
+ }
}
-}
+})
diff --git a/packages/browser/src/client/public/esm-client-injector.js b/packages/browser/src/client/public/esm-client-injector.js
new file mode 100644
index 000000000000..07b628c7f88e
--- /dev/null
+++ b/packages/browser/src/client/public/esm-client-injector.js
@@ -0,0 +1,61 @@
+const moduleCache = new Map()
+
+function wrapModule(module) {
+ if (module instanceof Promise) {
+ moduleCache.set(module, { promise: module, evaluated: false })
+ return module
+ .then(m => '__vi_inject__' in m ? m.__vi_inject__ : m)
+ .finally(() => moduleCache.delete(module))
+ }
+ return '__vi_inject__' in module ? module.__vi_inject__ : module
+}
+
+function exportAll(exports, sourceModule) {
+ if (exports === sourceModule)
+ return
+
+ if (Object(sourceModule) !== sourceModule || Array.isArray(sourceModule))
+ return
+
+ for (const key in sourceModule) {
+ if (key !== 'default') {
+ try {
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ configurable: true,
+ get: () => sourceModule[key],
+ })
+ }
+ catch (_err) { }
+ }
+ }
+}
+
+window.__vitest_browser_runner__ = {
+ exportAll,
+ wrapModule,
+ moduleCache,
+ config: { __VITEST_CONFIG__ },
+ files: { __VITEST_FILES__ },
+}
+
+const config = __vitest_browser_runner__.config
+
+if (config.testNamePattern)
+ config.testNamePattern = parseRegexp(config.testNamePattern)
+
+function parseRegexp(input) {
+ // Parse input
+ const m = input.match(/(\/?)(.+)\1([a-z]*)/i)
+
+ // match nothing
+ if (!m)
+ return /$^/
+
+ // Invalid flags
+ if (m[3] && !/^(?!.*?(.).*?\1)[gmixXsuUAJ]+$/.test(m[3]))
+ return RegExp(input)
+
+ // Create the regular expression
+ return new RegExp(m[2], m[3])
+}
diff --git a/packages/browser/src/client/rpc.ts b/packages/browser/src/client/rpc.ts
index 7cf2153b5712..883fdc0cd59e 100644
--- a/packages/browser/src/client/rpc.ts
+++ b/packages/browser/src/client/rpc.ts
@@ -2,6 +2,7 @@ import type {
getSafeTimers,
} from '@vitest/utils'
import type { VitestClient } from '@vitest/ws-client'
+import { importId } from './utils'
const { get } = Reflect
@@ -42,6 +43,8 @@ export async function rpcDone() {
export function createSafeRpc(client: VitestClient, getTimers: () => any): VitestClient['rpc'] {
return new Proxy(client.rpc, {
get(target, p, handler) {
+ if (p === 'then')
+ return
const sendCall = get(target, p, handler)
const safeSendCall = (...args: any[]) => withSafeTimers(getTimers, async () => {
const result = sendCall(...args)
@@ -59,7 +62,13 @@ export function createSafeRpc(client: VitestClient, getTimers: () => any): Vites
})
}
+export async function loadSafeRpc(client: VitestClient) {
+ // if importing /@id/ failed, we reload the page waiting until Vite prebundles it
+ const { getSafeTimers } = await importId('vitest/utils') as typeof import('vitest/utils')
+ return createSafeRpc(client, getSafeTimers)
+}
+
export function rpc(): VitestClient['rpc'] {
// @ts-expect-error not typed global
- return globalThis.__vitest_worker__.safeRpc
+ return globalThis.__vitest_worker__.rpc
}
diff --git a/packages/browser/src/client/runner.ts b/packages/browser/src/client/runner.ts
index 924e6f71670a..64259d1e83c1 100644
--- a/packages/browser/src/client/runner.ts
+++ b/packages/browser/src/client/runner.ts
@@ -1,12 +1,16 @@
import type { File, TaskResultPack, Test, VitestRunner } from '@vitest/runner'
import type { ResolvedConfig } from 'vitest'
+import type { VitestExecutor } from 'vitest/execute'
import { rpc } from './rpc'
+import { getConfig, importId } from './utils'
+import { BrowserSnapshotEnvironment } from './snapshot'
interface BrowserRunnerOptions {
config: ResolvedConfig
- browserHashMap: Map
}
+export const browserHashMap = new Map()
+
interface CoverageHandler {
takeCoverage: () => Promise
}
@@ -17,12 +21,11 @@ export function createBrowserRunner(
): { new(options: BrowserRunnerOptions): VitestRunner } {
return class BrowserTestRunner extends VitestRunner {
public config: ResolvedConfig
- hashMap = new Map()
+ hashMap = browserHashMap
constructor(options: BrowserRunnerOptions) {
super(options.config)
this.config = options.config
- this.hashMap = options.browserHashMap
}
async onAfterRunTask(task: Test) {
@@ -76,3 +79,31 @@ export function createBrowserRunner(
}
}
}
+
+let cachedRunner: VitestRunner | null = null
+
+export async function initiateRunner() {
+ if (cachedRunner)
+ return cachedRunner
+ const config = getConfig()
+ const [{ VitestTestRunner }, { takeCoverageInsideWorker, loadDiffConfig, loadSnapshotSerializers }] = await Promise.all([
+ importId('vitest/runners') as Promise,
+ importId('vitest/browser') as Promise,
+ ])
+ const BrowserRunner = createBrowserRunner(VitestTestRunner, {
+ takeCoverage: () => takeCoverageInsideWorker(config.coverage, { executeId: importId }),
+ })
+ if (!config.snapshotOptions.snapshotEnvironment)
+ config.snapshotOptions.snapshotEnvironment = new BrowserSnapshotEnvironment()
+ const runner = new BrowserRunner({
+ config,
+ })
+ const executor = { executeId: importId } as VitestExecutor
+ const [diffOptions] = await Promise.all([
+ loadDiffConfig(config, executor),
+ loadSnapshotSerializers(config, executor),
+ ])
+ runner.config.diffOptions = diffOptions
+ cachedRunner = runner
+ return runner
+}
diff --git a/packages/browser/src/client/tester.html b/packages/browser/src/client/tester.html
new file mode 100644
index 000000000000..de8ea361c58b
--- /dev/null
+++ b/packages/browser/src/client/tester.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+ {__VITEST_TITLE__}
+
+
+
+
+
+ {__VITEST_APPEND__}
+
+
diff --git a/packages/browser/src/client/tester.ts b/packages/browser/src/client/tester.ts
new file mode 100644
index 000000000000..1e926157ed80
--- /dev/null
+++ b/packages/browser/src/client/tester.ts
@@ -0,0 +1,185 @@
+import type { WorkerGlobalState } from 'vitest'
+import { channel, client, onCancel } from './client'
+import { setupDialogsSpy } from './dialog'
+import { setupConsoleLogSpy } from './logger'
+import { browserHashMap, initiateRunner } from './runner'
+import { getBrowserState, getConfig, importId } from './utils'
+import { loadSafeRpc } from './rpc'
+import { VitestBrowserClientMocker } from './mocker'
+import { registerUnexpectedErrors, registerUnhandledErrors, serializeError } from './unhandled'
+
+const stopErrorHandler = registerUnhandledErrors()
+
+const url = new URL(location.href)
+const reloadStart = url.searchParams.get('__reloadStart')
+
+function debug(...args: unknown[]) {
+ const debug = getConfig().env.VITEST_BROWSER_DEBUG
+ if (debug && debug !== 'false')
+ client.rpc.debug(...args.map(String))
+}
+
+async function tryCall(fn: () => Promise): Promise {
+ try {
+ return await fn()
+ }
+ catch (err: any) {
+ const now = Date.now()
+ // try for 30 seconds
+ const canTry = !reloadStart || (now - Number(reloadStart) < 30_000)
+ debug('failed to resolve runner', err?.message, 'trying again:', canTry, 'time is', now, 'reloadStart is', reloadStart)
+ if (!canTry) {
+ const error = serializeError(new Error('Vitest failed to load its runner after 30 seconds.'))
+ error.cause = serializeError(err)
+
+ await client.rpc.onUnhandledError(error, 'Preload Error')
+ return false
+ }
+
+ if (!reloadStart) {
+ const newUrl = new URL(location.href)
+ newUrl.searchParams.set('__reloadStart', now.toString())
+ debug('set the new url because reload start is not set to', newUrl)
+ location.href = newUrl.toString()
+ }
+ else {
+ debug('reload the iframe because reload start is set', location.href)
+ location.reload()
+ }
+ }
+}
+
+async function prepareTestEnvironment(files: string[]) {
+ debug('trying to resolve runner', `${reloadStart}`)
+ const config = getConfig()
+
+ const viteClientPath = `${config.base || '/'}@vite/client`
+ await import(viteClientPath)
+
+ const rpc: any = await loadSafeRpc(client)
+
+ stopErrorHandler()
+ registerUnexpectedErrors(rpc)
+
+ const providedContext = await client.rpc.getProvidedContext()
+
+ const state: WorkerGlobalState = {
+ ctx: {
+ pool: 'browser',
+ worker: './browser.js',
+ workerId: 1,
+ config,
+ projectName: config.name,
+ files,
+ environment: {
+ name: 'browser',
+ options: null,
+ },
+ providedContext,
+ invalidates: [],
+ },
+ onCancel,
+ mockMap: new Map(),
+ config,
+ environment: {
+ name: 'browser',
+ transformMode: 'web',
+ setup() {
+ throw new Error('Not called in the browser')
+ },
+ },
+ moduleCache: getBrowserState().moduleCache,
+ rpc,
+ durations: {
+ environment: 0,
+ prepare: 0,
+ },
+ providedContext,
+ }
+ // @ts-expect-error untyped global for internal use
+ globalThis.__vitest_browser__ = true
+ // @ts-expect-error mocking vitest apis
+ globalThis.__vitest_worker__ = state
+ // @ts-expect-error mocking vitest apis
+ globalThis.__vitest_mocker__ = new VitestBrowserClientMocker()
+
+ await setupConsoleLogSpy()
+ setupDialogsSpy()
+
+ const { startTests, setupCommonEnv } = await importId('vitest/browser') as typeof import('vitest/browser')
+
+ const version = url.searchParams.get('browserv') || '0'
+ files.forEach((filename) => {
+ const currentVersion = browserHashMap.get(filename)
+ if (!currentVersion || currentVersion[1] !== version)
+ browserHashMap.set(filename, [true, version])
+ })
+
+ const runner = await initiateRunner()
+
+ onCancel.then((reason) => {
+ runner.onCancel?.(reason)
+ })
+
+ return {
+ runner,
+ config,
+ state,
+ setupCommonEnv,
+ startTests,
+ }
+}
+
+function done(files: string[]) {
+ channel.postMessage({ type: 'done', filenames: files })
+}
+
+async function runTests(files: string[]) {
+ await client.waitForConnection()
+
+ debug('client is connected to ws server')
+
+ let preparedData: Awaited> | undefined | false
+
+ // if importing /@id/ failed, we reload the page waiting until Vite prebundles it
+ try {
+ preparedData = await tryCall(() => prepareTestEnvironment(files))
+ }
+ catch (error) {
+ debug('data cannot be loaded becuase it threw an error')
+ await client.rpc.onUnhandledError(serializeError(error), 'Preload Error')
+ done(files)
+ return
+ }
+
+ // cannot load data, finish the test
+ if (preparedData === false) {
+ debug('data cannot be loaded, finishing the test')
+ done(files)
+ return
+ }
+
+ // page is reloading
+ if (!preparedData) {
+ debug('page is reloading, waiting for the next run')
+ return
+ }
+
+ debug('runner resolved successfully')
+
+ const { config, runner, state, setupCommonEnv, startTests } = preparedData
+
+ try {
+ await setupCommonEnv(config)
+ for (const file of files)
+ await startTests([file], runner)
+ }
+ finally {
+ state.environmentTeardownRun = true
+ debug('finished running tests')
+ done(files)
+ }
+}
+
+// @ts-expect-error untyped global for internal use
+window.__vitest_browser_runner__.runTests = runTests
diff --git a/packages/browser/src/client/unhandled.ts b/packages/browser/src/client/unhandled.ts
new file mode 100644
index 000000000000..4dcaa527148a
--- /dev/null
+++ b/packages/browser/src/client/unhandled.ts
@@ -0,0 +1,69 @@
+import type { client } from './client'
+import { channel } from './client'
+import { getBrowserState, importId } from './utils'
+
+function on(event: string, listener: (...args: any[]) => void) {
+ window.addEventListener(event, listener)
+ return () => window.removeEventListener(event, listener)
+}
+
+export function serializeError(unhandledError: any) {
+ return {
+ ...unhandledError,
+ name: unhandledError.name,
+ message: unhandledError.message,
+ stack: String(unhandledError.stack),
+ }
+}
+
+// we can't import "processError" yet because error might've been thrown before the module was loaded
+async function defaultErrorReport(type: string, unhandledError: any) {
+ const error = serializeError(unhandledError)
+ channel.postMessage({ type: 'error', files: getBrowserState().runningFiles, error, errorType: type })
+}
+
+function catchWindowErrors(cb: (e: ErrorEvent) => void) {
+ let userErrorListenerCount = 0
+ function throwUnhandlerError(e: ErrorEvent) {
+ if (userErrorListenerCount === 0 && e.error != null)
+ cb(e)
+ else
+ console.error(e.error)
+ }
+ const addEventListener = window.addEventListener.bind(window)
+ const removeEventListener = window.removeEventListener.bind(window)
+ window.addEventListener('error', throwUnhandlerError)
+ window.addEventListener = function (...args: Parameters) {
+ if (args[0] === 'error')
+ userErrorListenerCount++
+ return addEventListener.apply(this, args)
+ }
+ window.removeEventListener = function (...args: Parameters) {
+ if (args[0] === 'error' && userErrorListenerCount)
+ userErrorListenerCount--
+ return removeEventListener.apply(this, args)
+ }
+ return function clearErrorHandlers() {
+ window.removeEventListener('error', throwUnhandlerError)
+ }
+}
+
+export function registerUnhandledErrors() {
+ const stopErrorHandler = catchWindowErrors(e => defaultErrorReport('Error', e.error))
+ const stopRejectionHandler = on('unhandledrejection', e => defaultErrorReport('Unhandled Rejection', e.reason))
+ return () => {
+ stopErrorHandler()
+ stopRejectionHandler()
+ }
+}
+
+export function registerUnexpectedErrors(rpc: typeof client.rpc) {
+ catchWindowErrors(event => reportUnexpectedError(rpc, 'Error', event.error))
+ on('unhandledrejection', event => reportUnexpectedError(rpc, 'Unhandled Rejection', event.reason))
+}
+
+async function reportUnexpectedError(rpc: typeof client.rpc, type: string, error: any) {
+ const { processError } = await importId('vitest/browser') as typeof import('vitest/browser')
+ const processedError = processError(error)
+ await rpc.onUnhandledError(processedError, type)
+}
diff --git a/packages/browser/src/client/utils.ts b/packages/browser/src/client/utils.ts
index a4c1a856117f..69c7f0556262 100644
--- a/packages/browser/src/client/utils.ts
+++ b/packages/browser/src/client/utils.ts
@@ -1,5 +1,25 @@
-export async function importId(id: string, basePath: string) {
- const name = `${basePath}@id/${id}`
- // @ts-expect-error mocking vitest apis
- return __vi_wrap_module__(import(name))
+import type { ResolvedConfig, WorkerGlobalState } from 'vitest'
+
+export async function importId(id: string) {
+ const name = `${getConfig().base || '/'}@id/${id}`
+ return getBrowserState().wrapModule(import(name))
+}
+
+export function getConfig(): ResolvedConfig {
+ return getBrowserState().config
+}
+
+interface BrowserRunnerState {
+ files: string[]
+ runningFiles: string[]
+ moduleCache: WorkerGlobalState['moduleCache']
+ config: ResolvedConfig
+ exportAll(): void
+ wrapModule(module: any): any
+ runTests(tests: string[]): Promise
+}
+
+export function getBrowserState(): BrowserRunnerState {
+ // @ts-expect-error not typed global
+ return window.__vitest_browser_runner__
}
diff --git a/packages/browser/src/client/vite.config.ts b/packages/browser/src/client/vite.config.ts
index 99b08c2ea3d7..435096a2a61c 100644
--- a/packages/browser/src/client/vite.config.ts
+++ b/packages/browser/src/client/vite.config.ts
@@ -13,6 +13,12 @@ export default defineConfig({
outDir: '../../dist/client',
emptyOutDir: false,
assetsDir: '__vitest_browser__',
+ rollupOptions: {
+ input: {
+ main: resolve(__dirname, './index.html'),
+ tester: resolve(__dirname, './tester.html'),
+ },
+ },
},
plugins: [
{
@@ -33,7 +39,7 @@ export default defineConfig({
if (fs.existsSync(browser))
fs.rmSync(browser, { recursive: true })
- fs.mkdirSync(browser)
+ fs.mkdirSync(browser, { recursive: true })
fs.mkdirSync(resolve(browser, 'assets'))
files.forEach((f) => {
diff --git a/packages/browser/src/node/esmInjector.ts b/packages/browser/src/node/esmInjector.ts
index 0edc80480dad..51ef7f632e5d 100644
--- a/packages/browser/src/node/esmInjector.ts
+++ b/packages/browser/src/node/esmInjector.ts
@@ -6,7 +6,7 @@ import type { Expression, ImportDeclaration, Node, Positioned } from '@vitest/ut
const viInjectedKey = '__vi_inject__'
// const viImportMetaKey = '__vi_import_meta__' // to allow overwrite
-const viExportAllHelper = '__vi_export_all__'
+const viExportAllHelper = '__vitest_browser_runner__.exportAll'
const skipHijack = [
'/@vite/client',
@@ -230,7 +230,7 @@ export function injectVitestModule(code: string, id: string, parse: PluginContex
// s.update(node.start, node.end, viImportMetaKey)
},
onDynamicImport(node) {
- const replace = '__vi_wrap_module__(import('
+ const replace = '__vitest_browser_runner__.wrapModule(import('
s.overwrite(node.start, (node.source as Positioned).start, replace)
s.overwrite(node.end - 1, node.end, '))')
},
diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts
index 5fee91206247..0f25198e7b4f 100644
--- a/packages/browser/src/node/index.ts
+++ b/packages/browser/src/node/index.ts
@@ -1,12 +1,17 @@
import { fileURLToPath } from 'node:url'
-
+import { readFile } from 'node:fs/promises'
import { basename, resolve } from 'pathe'
import sirv from 'sirv'
import type { Plugin } from 'vite'
+import type { ResolvedConfig } from 'vitest'
import type { WorkspaceProject } from 'vitest/node'
import { coverageConfigDefaults } from 'vitest/config'
import { injectVitestModule } from './esmInjector'
+function replacer(code: string, values: Record) {
+ return code.replace(/{\s*(\w+)\s*}/g, (_, key) => values[key] ?? '')
+}
+
export default (project: WorkspaceProject, base = '/'): Plugin[] => {
const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..')
const distRoot = resolve(pkgRoot, 'dist')
@@ -23,18 +28,74 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
}
},
async configureServer(server) {
+ const testerHtml = readFile(resolve(distRoot, 'client/tester.html'), 'utf8')
+ const runnerHtml = readFile(resolve(distRoot, 'client/index.html'), 'utf8')
+ const injectorJs = readFile(resolve(distRoot, 'client/esm-client-injector.js'), 'utf8')
+ const favicon = `${base}favicon.svg`
+ const testerPrefix = `${base}__vitest_test__/__test__/`
+ server.middlewares.use((_req, res, next) => {
+ const headers = server.config.server.headers
+ if (headers) {
+ for (const name in headers)
+ res.setHeader(name, headers[name]!)
+ }
+ next()
+ })
+ server.middlewares.use(async (req, res, next) => {
+ if (!req.url)
+ return next()
+ const url = new URL(req.url, 'http://localhost')
+ if (!url.pathname.startsWith(testerPrefix) && url.pathname !== base)
+ return next()
+
+ res.setHeader('Cache-Control', 'no-cache, max-age=0, must-revalidate')
+ res.setHeader('Content-Type', 'text/html; charset=utf-8')
+
+ const files = project.browserState?.files ?? []
+
+ const config = wrapConfig(project.getSerializableConfig())
+ config.env ??= {}
+ config.env.VITEST_BROWSER_DEBUG = process.env.VITEST_BROWSER_DEBUG || ''
+
+ const injector = replacer(await injectorJs, {
+ __VITEST_CONFIG__: JSON.stringify(config),
+ __VITEST_FILES__: JSON.stringify(files),
+ })
+
+ if (url.pathname === base) {
+ const html = replacer(await runnerHtml, {
+ __VITEST_FAVICON__: favicon,
+ __VITEST_TITLE__: 'Vitest Browser Runner',
+ __VITEST_INJECTOR__: injector,
+ })
+ res.write(html, 'utf-8')
+ res.end()
+ return
+ }
+
+ const decodedTestFile = decodeURIComponent(url.pathname.slice(testerPrefix.length))
+ // if decoded test file is "__vitest_all__" or not in the list of known files, run all tests
+ const tests = decodedTestFile === '__vitest_all__' || !files.includes(decodedTestFile) ? '__vitest_browser_runner__.files' : JSON.stringify([decodedTestFile])
+
+ const html = replacer(await testerHtml, {
+ __VITEST_FAVICON__: favicon,
+ __VITEST_TITLE__: 'Vitest Browser Tester',
+ __VITEST_INJECTOR__: injector,
+ __VITEST_APPEND__:
+ // TODO: have only a single global variable to not pollute the global scope
+``,
+ })
+ res.write(html, 'utf-8')
+ res.end()
+ })
server.middlewares.use(
base,
sirv(resolve(distRoot, 'client'), {
single: false,
dev: true,
- setHeaders(res, _pathname, _stats) {
- const headers = server.config.server.headers
- if (headers) {
- for (const name in headers)
- res.setHeader(name, headers[name]!)
- }
- },
}),
)
@@ -69,9 +130,11 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
optimizeDeps: {
entries: [
...entries,
+ 'vitest',
'vitest/utils',
'vitest/browser',
'vitest/runners',
+ '@vitest/utils',
],
exclude: [
'vitest',
@@ -158,3 +221,14 @@ function resolveCoverageFolder(project: WorkspaceProject) {
return [resolve(root, subdir), `/${basename(root)}/${subdir}/`]
}
+
+function wrapConfig(config: ResolvedConfig): ResolvedConfig {
+ return {
+ ...config,
+ // workaround RegExp serialization
+ testNamePattern:
+ config.testNamePattern
+ ? config.testNamePattern.toString() as any as RegExp
+ : undefined,
+ }
+}
diff --git a/packages/browser/src/node/providers/none.ts b/packages/browser/src/node/providers/none.ts
index e38ffd6d9726..20613a9522d3 100644
--- a/packages/browser/src/node/providers/none.ts
+++ b/packages/browser/src/node/providers/none.ts
@@ -1,4 +1,3 @@
-import type { Awaitable } from 'vitest'
import type { BrowserProvider, WorkspaceProject } from 'vitest/node'
export class NoneBrowserProvider implements BrowserProvider {
@@ -22,10 +21,6 @@ export class NoneBrowserProvider implements BrowserProvider {
throw new Error('You\'ve enabled headless mode for "none" provider but it doesn\'t support it.')
}
- catchError(_cb: (error: Error) => Awaitable) {
- return () => {}
- }
-
async openPage(_url: string) {
this.open = true
if (!this.ctx.browser)
diff --git a/packages/browser/src/node/providers/playwright.ts b/packages/browser/src/node/providers/playwright.ts
index 31e343e1c1a4..e6cf5c048fcf 100644
--- a/packages/browser/src/node/providers/playwright.ts
+++ b/packages/browser/src/node/providers/playwright.ts
@@ -1,8 +1,6 @@
import type { Browser, LaunchOptions, Page } from 'playwright'
import type { BrowserProvider, BrowserProviderInitializationOptions, WorkspaceProject } from 'vitest/node'
-type Awaitable = T | PromiseLike
-
export const playwrightBrowsers = ['firefox', 'webkit', 'chromium'] as const
export type PlaywrightBrowser = typeof playwrightBrowsers[number]
@@ -48,20 +46,9 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
this.cachedBrowser = browser
this.cachedPage = await browser.newPage(this.options?.page)
- this.cachedPage.on('close', () => {
- browser.close()
- })
-
return this.cachedPage
}
- catchError(cb: (error: Error) => Awaitable) {
- this.cachedPage?.on('pageerror', cb)
- return () => {
- this.cachedPage?.off('pageerror', cb)
- }
- }
-
async openPage(url: string) {
const browserPage = await this.openBrowserPage()
await browserPage.goto(url)
diff --git a/packages/browser/src/node/providers/webdriver.ts b/packages/browser/src/node/providers/webdriver.ts
index 9480ba6a5716..5a63411ae9b2 100644
--- a/packages/browser/src/node/providers/webdriver.ts
+++ b/packages/browser/src/node/providers/webdriver.ts
@@ -1,8 +1,6 @@
import type { BrowserProvider, BrowserProviderInitializationOptions, WorkspaceProject } from 'vitest/node'
import type { RemoteOptions } from 'webdriverio'
-type Awaitable = T | PromiseLike
-
const webdriverBrowsers = ['firefox', 'chrome', 'edge', 'safari'] as const
type WebdriverBrowser = typeof webdriverBrowsers[number]
@@ -81,11 +79,6 @@ export class WebdriverBrowserProvider implements BrowserProvider {
await browserInstance.url(url)
}
- // TODO
- catchError(_cb: (error: Error) => Awaitable) {
- return () => {}
- }
-
async close() {
await Promise.all([
this.cachedBrowser?.sessionId ? this.cachedBrowser?.deleteSession?.() : null,
diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts
index 1734adb3dcad..a56dd53b15fc 100644
--- a/packages/vitest/src/api/setup.ts
+++ b/packages/vitest/src/api/setup.ts
@@ -11,9 +11,9 @@ import type { ViteDevServer } from 'vite'
import type { StackTraceParserOptions } from '@vitest/utils/source-map'
import { API_PATH } from '../constants'
import type { Vitest } from '../node'
-import type { File, ModuleGraphData, Reporter, ResolvedConfig, TaskResultPack, UserConsoleLog } from '../types'
+import type { File, ModuleGraphData, Reporter, TaskResultPack, UserConsoleLog } from '../types'
import { getModuleGraph, isPrimitive, stringifyReplace } from '../utils'
-import { WorkspaceProject } from '../node/workspace'
+import type { WorkspaceProject } from '../node/workspace'
import { parseErrorStacktrace } from '../utils/source-map'
import type { TransformResultWithSource, WebSocketEvents, WebSocketHandlers } from './types'
@@ -51,9 +51,6 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: Vi
async onUnhandledError(error, type) {
ctx.state.catchError(error, type)
},
- async onDone(testId) {
- return ctx.state.browserTestPromises.get(testId)?.resolve(true)
- },
async onCollected(files) {
ctx.state.collectFiles(files)
await ctx.report('onCollected', files)
@@ -114,9 +111,6 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: Vi
await ctx.rerunFiles(files)
},
getConfig() {
- if (vitestOrWorkspace instanceof WorkspaceProject)
- return wrapConfig(vitestOrWorkspace.getSerializableConfig())
-
return vitestOrWorkspace.config
},
async getTransformResult(id) {
@@ -140,16 +134,30 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: Vi
onCancel(reason) {
ctx.cancelCurrentRun(reason)
},
+ debug(...args) {
+ ctx.logger.console.debug(...args)
+ },
getCountOfFailedTests() {
return ctx.state.getCountOfFailedTests()
},
- // browser should have a separate RPC in the future, UI doesn't care for provided context
- getProvidedContext() {
- return 'ctx' in vitestOrWorkspace ? vitestOrWorkspace.getProvidedContext() : ({} as any)
- },
getUnhandledErrors() {
return ctx.state.getUnhandledErrors()
},
+
+ // TODO: have a separate websocket conection for private browser API
+ getBrowserFiles() {
+ if (!('ctx' in vitestOrWorkspace))
+ throw new Error('`getBrowserTestFiles` is only available in the browser API')
+ return vitestOrWorkspace.browserState?.files ?? []
+ },
+ finishBrowserTests() {
+ if (!('ctx' in vitestOrWorkspace))
+ throw new Error('`finishBrowserTests` is only available in the browser API')
+ return vitestOrWorkspace.browserState?.resolve()
+ },
+ getProvidedContext() {
+ return 'ctx' in vitestOrWorkspace ? vitestOrWorkspace.getProvidedContext() : ({} as any)
+ },
},
{
post: msg => ws.send(msg),
@@ -224,14 +232,3 @@ class WebSocketReporter implements Reporter {
})
}
}
-
-function wrapConfig(config: ResolvedConfig): ResolvedConfig {
- return {
- ...config,
- // workaround RegExp serialization
- testNamePattern:
- config.testNamePattern
- ? config.testNamePattern.toString() as any as RegExp
- : undefined,
- }
-}
diff --git a/packages/vitest/src/api/types.ts b/packages/vitest/src/api/types.ts
index 9909a5303ac4..6dba431c24d6 100644
--- a/packages/vitest/src/api/types.ts
+++ b/packages/vitest/src/api/types.ts
@@ -11,7 +11,6 @@ export interface WebSocketHandlers {
onCollected(files?: File[]): Promise
onTaskUpdate(packs: TaskResultPack[]): void
onAfterSuiteRun(meta: AfterSuiteRunMeta): void
- onDone(name: string): void
onCancel(reason: CancelReason): void
getCountOfFailedTests(): number
sendLog(log: UserConsoleLog): void
@@ -32,6 +31,10 @@ export interface WebSocketHandlers {
updateSnapshot(file?: File): Promise
getProvidedContext(): ProvidedContext
getUnhandledErrors(): unknown[]
+
+ finishBrowserTests(): void
+ getBrowserFiles(): string[]
+ debug(...args: string[]): void
}
export interface WebSocketEvents extends Pick {
diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts
index f3874ad5d61b..178678ff7ef6 100644
--- a/packages/vitest/src/node/pools/browser.ts
+++ b/packages/vitest/src/node/pools/browser.ts
@@ -1,5 +1,4 @@
import { createDefer } from '@vitest/utils'
-import { relative } from 'pathe'
import type { Vitest } from '../core'
import type { ProcessPool } from '../pool'
import type { WorkspaceProject } from '../workspace'
@@ -8,61 +7,42 @@ import type { BrowserProvider } from '../../types/browser'
export function createBrowserPool(ctx: Vitest): ProcessPool {
const providers = new Set()
- const waitForTest = async (provider: BrowserProvider, id: string) => {
- const defer = createDefer()
- ctx.state.browserTestPromises.set(id, defer)
- const off = provider.catchError((error) => {
- if (id !== 'no-isolate') {
- Object.defineProperty(error, 'VITEST_TEST_PATH', {
- value: id,
- })
- }
- defer.reject(error)
- })
- try {
- return await defer
- }
- finally {
- off()
+ const waitForTests = async (project: WorkspaceProject, files: string[]) => {
+ const defer = createDefer()
+ project.browserState?.resolve()
+ project.browserState = {
+ files,
+ resolve: () => {
+ defer.resolve()
+ project.browserState = undefined
+ },
+ reject: defer.reject,
}
+ return await defer
}
const runTests = async (project: WorkspaceProject, files: string[]) => {
ctx.state.clearFiles(project, files)
- let isCancelled = false
- project.ctx.onCancel(() => {
- isCancelled = true
- })
+ // TODO
+ // let isCancelled = false
+ // project.ctx.onCancel(() => {
+ // isCancelled = true
+ // })
const provider = project.browserProvider!
providers.add(provider)
const resolvedUrls = project.browser?.resolvedUrls
const origin = resolvedUrls?.local[0] ?? resolvedUrls?.network[0]
- const paths = files.map(file => relative(project.config.root, file))
- if (project.config.browser.isolate) {
- for (const path of paths) {
- if (isCancelled) {
- ctx.state.cancelFiles(files.slice(paths.indexOf(path)), ctx.config.root, project.config.name)
- break
- }
+ if (!origin)
+ throw new Error(`Can't find browser origin URL for project "${project.config.name}"`)
- const url = new URL('/', origin)
- url.searchParams.append('path', path)
- url.searchParams.set('id', path)
- await provider.openPage(url.toString())
- await waitForTest(provider, path)
- }
- }
- else {
- const url = new URL('/', origin)
- url.searchParams.set('id', 'no-isolate')
- paths.forEach(path => url.searchParams.append('path', path))
- await provider.openPage(url.toString())
- await waitForTest(provider, 'no-isolate')
- }
+ const promise = waitForTests(project, files)
+
+ await provider.openPage(new URL('/', origin).toString())
+ await promise
}
const runWorkspaceTests = async (specs: [WorkspaceProject, string][]) => {
@@ -80,7 +60,6 @@ export function createBrowserPool(ctx: Vitest): ProcessPool {
return {
name: 'browser',
async close() {
- ctx.state.browserTestPromises.clear()
await Promise.all([...providers].map(provider => provider.close()))
providers.clear()
},
diff --git a/packages/vitest/src/node/state.ts b/packages/vitest/src/node/state.ts
index 3da0eed04782..128bdf66c01a 100644
--- a/packages/vitest/src/node/state.ts
+++ b/packages/vitest/src/node/state.ts
@@ -17,7 +17,6 @@ export function isAggregateError(err: unknown): err is AggregateErrorPonyfill {
export class StateManager {
filesMap = new Map()
pathsSet: Set = new Set()
- browserTestPromises = new Map void; reject: (v: unknown) => void }>()
idMap = new Map()
taskFileMap = new WeakMap()
errorsSet = new Set()
diff --git a/packages/vitest/src/node/workspace.ts b/packages/vitest/src/node/workspace.ts
index 74a18282b5c6..8e708b5e7119 100644
--- a/packages/vitest/src/node/workspace.ts
+++ b/packages/vitest/src/node/workspace.ts
@@ -70,6 +70,12 @@ export class WorkspaceProject {
closingPromise: Promise | undefined
browserProvider: BrowserProvider | undefined
+ browserState: {
+ files: string[]
+ resolve(): void
+ reject(v: unknown): void
+ } | undefined
+
testFilesList: string[] | null = null
private _globalSetups: GlobalSetupFile[] | undefined
diff --git a/packages/vitest/src/types/browser.ts b/packages/vitest/src/types/browser.ts
index 04254ebb505e..d01ad2d80003 100644
--- a/packages/vitest/src/types/browser.ts
+++ b/packages/vitest/src/types/browser.ts
@@ -12,7 +12,6 @@ export interface BrowserProvider {
getSupportedBrowsers(): readonly string[]
initialize(ctx: WorkspaceProject, options: BrowserProviderInitializationOptions): Awaitable
openPage(url: string): Awaitable
- catchError(cb: (error: Error) => Awaitable): () => Awaitable
close(): Awaitable
}
@@ -83,6 +82,13 @@ export interface BrowserConfigOptions {
* @default true
*/
isolate?: boolean
+
+ /**
+ * Run test files in parallel. Fallbacks to `test.fileParallelism`.
+ *
+ * @default test.fileParallelism
+ */
+ fileParallelism?: boolean
}
export interface ResolvedBrowserOptions extends BrowserConfigOptions {
diff --git a/packages/ws-client/src/index.ts b/packages/ws-client/src/index.ts
index 8249af8f0021..85e7e0b387bc 100644
--- a/packages/ws-client/src/index.ts
+++ b/packages/ws-client/src/index.ts
@@ -14,6 +14,7 @@ export interface VitestClientOptions {
autoReconnect?: boolean
reconnectInterval?: number
reconnectTries?: number
+ connectTimeout?: number
reactive?: (v: T) => T
ref?: (v: T) => { value: T }
WebSocketConstructor?: typeof WebSocket
@@ -33,6 +34,7 @@ export function createClient(url: string, options: VitestClientOptions = {}) {
autoReconnect = true,
reconnectInterval = 2000,
reconnectTries = 10,
+ connectTimeout = 60000,
reactive = v => v,
WebSocketConstructor = globalThis.WebSocket,
} = options
@@ -98,10 +100,17 @@ export function createClient(url: string, options: VitestClientOptions = {}) {
}
function registerWS() {
- openPromise = new Promise((resolve) => {
+ openPromise = new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error(`Cannot connect to the server in ${connectTimeout / 1000} seconds`))
+ }, connectTimeout)?.unref?.()
+ if (ctx.ws.OPEN === ctx.ws.readyState)
+ resolve()
+ // still have a listener even if it's already open to update tries
ctx.ws.addEventListener('open', () => {
tries = reconnectTries
resolve()
+ clearTimeout(timeout)
})
})
ctx.ws.addEventListener('message', (v) => {
diff --git a/test/browser/specs/filter.test.mjs b/test/browser/specs/filter.test.mjs
index d5b421395083..7c5d0815284f 100644
--- a/test/browser/specs/filter.test.mjs
+++ b/test/browser/specs/filter.test.mjs
@@ -3,7 +3,7 @@ import test from 'node:test'
import { execa } from 'execa'
test('filter', async () => {
- const result = await execa(
+ let result = execa(
'npx',
[
'vitest',
@@ -21,6 +21,15 @@ test('filter', async () => {
},
},
)
+ if (process.env.VITEST_BROWSER_DEBUG) {
+ result.stderr.on('data', (data) => {
+ process.stderr.write(data.toString())
+ })
+ result.stdout.on('data', (data) => {
+ process.stdout.write(data.toString())
+ })
+ }
+ result = await result
assert.match(result.stdout, /✓ test\/basic.test.ts > basic 2/)
assert.match(result.stdout, /Test Files {2}1 passed/)
assert.match(result.stdout, /Tests {2}1 passed | 3 skipped/)
diff --git a/test/browser/specs/run-vitest.mjs b/test/browser/specs/run-vitest.mjs
index 278f05ce69dd..7fcd2cdd5e28 100644
--- a/test/browser/specs/run-vitest.mjs
+++ b/test/browser/specs/run-vitest.mjs
@@ -9,7 +9,7 @@ export default async function runVitest(moreArgs = []) {
if (browser !== 'safari')
argv.push('--browser.headless')
- const { stderr, stdout } = await execa('npx', argv.concat(moreArgs), {
+ const result = execa('npx', argv.concat(moreArgs), {
env: {
...process.env,
CI: 'true',
@@ -17,6 +17,15 @@ export default async function runVitest(moreArgs = []) {
},
reject: false,
})
+ if (process.env.VITEST_BROWSER_DEBUG) {
+ result.stderr.on('data', (data) => {
+ process.stderr.write(data.toString())
+ })
+ result.stdout.on('data', (data) => {
+ process.stdout.write(data.toString())
+ })
+ }
+ const { stderr, stdout } = await result
const browserResult = await readFile('./browser.json', 'utf-8')
const browserResultJson = JSON.parse(browserResult)
diff --git a/test/browser/specs/runner.test.mjs b/test/browser/specs/runner.test.mjs
index dcf75b12c989..9667361b09bd 100644
--- a/test/browser/specs/runner.test.mjs
+++ b/test/browser/specs/runner.test.mjs
@@ -2,69 +2,76 @@ import assert from 'node:assert'
import test from 'node:test'
import runVitest from './run-vitest.mjs'
-const {
- stderr,
- stdout,
- browserResultJson,
- passedTests,
- failedTests,
-} = await runVitest()
+const cliArguments = [
+ ['not parallel', ['--no-browser.fileParallelism']],
+ ['parallel', []],
+]
-await test('tests are actually running', async () => {
- assert.equal(browserResultJson.testResults.length, 14, 'Not all the tests have been run')
- assert.equal(passedTests.length, 12, 'Some tests failed')
- assert.equal(failedTests.length, 2, 'Some tests have passed but should fail')
+for (const [description, args] of cliArguments) {
+ const {
+ stderr,
+ stdout,
+ browserResultJson,
+ passedTests,
+ failedTests,
+ } = await runVitest(args)
- assert.doesNotMatch(stderr, /has been externalized for browser compatibility/, 'doesn\'t have any externalized modules')
- assert.doesNotMatch(stderr, /Unhandled Error/, 'doesn\'t have any unhandled errors')
-})
+ await test(`[${description}] tests are actually running`, async () => {
+ assert.equal(browserResultJson.testResults.length, 14, 'Not all the tests have been run')
+ assert.equal(passedTests.length, 12, 'Some tests failed')
+ assert.equal(failedTests.length, 2, 'Some tests have passed but should fail')
-await test('correctly prints error', () => {
- assert.match(stderr, /expected 1 to be 2/, 'prints failing error')
- assert.match(stderr, /- 2\s+\+ 1/, 'prints failing diff')
- assert.match(stderr, /Expected to be/, 'prints \`Expected to be\`')
- assert.match(stderr, /But got/, 'prints \`But got\`')
-})
+ assert.doesNotMatch(stderr, /has been externalized for browser compatibility/, 'doesn\'t have any externalized modules')
+ assert.doesNotMatch(stderr, /Unhandled Error/, 'doesn\'t have any unhandled errors')
+ })
-await test('logs are redirected to stdout', async () => {
- assert.match(stdout, /stdout | test\/logs.test.ts > logging to stdout/)
- assert.match(stdout, /hello from console.log/, 'prints console.log')
- assert.match(stdout, /hello from console.info/, 'prints console.info')
- assert.match(stdout, /hello from console.debug/, 'prints console.debug')
- assert.match(stdout, /{ hello: 'from dir' }/, 'prints console.dir')
- assert.match(stdout, /{ hello: 'from dirxml' }/, 'prints console.dixml')
- // safari logs the stack files with @https://...
- assert.match(stdout, /hello from console.trace\s+(\w+|@)/, 'prints console.trace')
- assert.match(stdout, /dom /, 'prints dom')
- assert.match(stdout, /default: 1/, 'prints first default count')
- assert.match(stdout, /default: 2/, 'prints second default count')
- assert.match(stdout, /default: 3/, 'prints third default count')
- assert.match(stdout, /count: 1/, 'prints first custom count')
- assert.match(stdout, /count: 2/, 'prints second custom count')
- assert.match(stdout, /count: 3/, 'prints third custom count')
- assert.match(stdout, /default: [\d.]+ ms/, 'prints default time')
- assert.match(stdout, /time: [\d.]+ ms/, 'prints custom time')
-})
+ await test(`[${description}] correctly prints error`, () => {
+ assert.match(stderr, /expected 1 to be 2/, 'prints failing error')
+ assert.match(stderr, /- 2\s+\+ 1/, 'prints failing diff')
+ assert.match(stderr, /Expected to be/, 'prints \`Expected to be\`')
+ assert.match(stderr, /But got/, 'prints \`But got\`')
+ })
-await test('logs are redirected to stderr', async () => {
- assert.match(stderr, /stderr | test\/logs.test.ts > logging to stderr/)
- assert.match(stderr, /hello from console.error/, 'prints console.log')
- assert.match(stderr, /hello from console.warn/, 'prints console.info')
- assert.match(stderr, /Timer "invalid timeLog" does not exist/, 'prints errored timeLog')
- assert.match(stderr, /Timer "invalid timeEnd" does not exist/, 'prints errored timeEnd')
-})
+ await test(`[${description}] logs are redirected to stdout`, async () => {
+ assert.match(stdout, /stdout | test\/logs.test.ts > logging to stdout/)
+ assert.match(stdout, /hello from console.log/, 'prints console.log')
+ assert.match(stdout, /hello from console.info/, 'prints console.info')
+ assert.match(stdout, /hello from console.debug/, 'prints console.debug')
+ assert.match(stdout, /{ hello: 'from dir' }/, 'prints console.dir')
+ assert.match(stdout, /{ hello: 'from dirxml' }/, 'prints console.dixml')
+ // safari logs the stack files with @https://...
+ assert.match(stdout, /hello from console.trace\s+(\w+|@)/, 'prints console.trace')
+ assert.match(stdout, /dom /, 'prints dom')
+ assert.match(stdout, /default: 1/, 'prints first default count')
+ assert.match(stdout, /default: 2/, 'prints second default count')
+ assert.match(stdout, /default: 3/, 'prints third default count')
+ assert.match(stdout, /count: 1/, 'prints first custom count')
+ assert.match(stdout, /count: 2/, 'prints second custom count')
+ assert.match(stdout, /count: 3/, 'prints third custom count')
+ assert.match(stdout, /default: [\d.]+ ms/, 'prints default time')
+ assert.match(stdout, /time: [\d.]+ ms/, 'prints custom time')
+ })
-await test('stack trace points to correct file in every browser', () => {
- // dependeing on the browser it references either `.toBe()` or `expect()`
- assert.match(stderr, /test\/failing.test.ts:4:(12|17)/, 'prints stack trace')
-})
+ await test(`[${description}] logs are redirected to stderr`, async () => {
+ assert.match(stderr, /stderr | test\/logs.test.ts > logging to stderr/)
+ assert.match(stderr, /hello from console.error/, 'prints console.log')
+ assert.match(stderr, /hello from console.warn/, 'prints console.info')
+ assert.match(stderr, /Timer "invalid timeLog" does not exist/, 'prints errored timeLog')
+ assert.match(stderr, /Timer "invalid timeEnd" does not exist/, 'prints errored timeEnd')
+ })
-await test('popup apis should log a warning', () => {
- assert.ok(stderr.includes('Vitest encountered a \`alert\("test"\)\`'), 'prints warning for alert')
- assert.ok(stderr.includes('Vitest encountered a \`confirm\("test"\)\`'), 'prints warning for confirm')
- assert.ok(stderr.includes('Vitest encountered a \`prompt\("test"\)\`'), 'prints warning for prompt')
-})
+ await test(`[${description}] stack trace points to correct file in every browser`, () => {
+ // dependeing on the browser it references either `.toBe()` or `expect()`
+ assert.match(stderr, /test\/failing.test.ts:4:(12|17)/, 'prints stack trace')
+ })
-await test('snapshot inaccessible file debuggability', () => {
- assert.ok(stdout.includes('Access denied to "/inaccesible/path".'), 'file security enforcement explained')
-})
+ await test(`[${description}] popup apis should log a warning`, () => {
+ assert.ok(stderr.includes('Vitest encountered a \`alert\("test"\)\`'), 'prints warning for alert')
+ assert.ok(stderr.includes('Vitest encountered a \`confirm\("test"\)\`'), 'prints warning for confirm')
+ assert.ok(stderr.includes('Vitest encountered a \`prompt\("test"\)\`'), 'prints warning for prompt')
+ })
+
+ await test(`[${description}] snapshot inaccessible file debuggability`, () => {
+ assert.ok(stdout.includes('Access denied to "/inaccesible/path".'), 'file security enforcement explained')
+ })
+}
diff --git a/test/core/test/injector-esm.test.ts b/test/core/test/injector-esm.test.ts
index 22eabedb7ce3..e8987a48310f 100644
--- a/test/core/test/injector-esm.test.ts
+++ b/test/core/test/injector-esm.test.ts
@@ -126,9 +126,9 @@ test('export * from', async () => {
).toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: "Module" };
const { __vi_inject__: __vi_esm_0__ } = await import("vue");
- __vi_export_all__(__vi_inject__, __vi_esm_0__);
+ __vitest_browser_runner__.exportAll(__vi_inject__, __vi_esm_0__);
const { __vi_inject__: __vi_esm_1__ } = await import("react");
- __vi_export_all__(__vi_inject__, __vi_esm_1__);
+ __vitest_browser_runner__.exportAll(__vi_inject__, __vi_esm_1__);
export { __vi_inject__ }"
@@ -167,7 +167,7 @@ test('export then import minified', async () => {
"const __vi_inject__ = { [Symbol.toStringTag]: "Module" };
import { __vi_inject__ as __vi_esm_0__ } from 'vue'
const { __vi_inject__: __vi_esm_1__ } = await import("vue");
- __vi_export_all__(__vi_inject__, __vi_esm_1__);
+ __vitest_browser_runner__.exportAll(__vi_inject__, __vi_esm_1__);
export { __vi_inject__ }"
`)
@@ -198,7 +198,7 @@ test('dynamic import', async () => {
)
expect(result).toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: "Module" };
- const i = () => __vi_wrap_module__(import('./foo'))
+ const i = () => __vitest_browser_runner__.wrapModule(import('./foo'))
export { __vi_inject__ }"
`)
})