Skip to content

Commit 4f40177

Browse files
authoredFeb 7, 2024
feat(browser): run test files in isolated iframes (#5036)
1 parent b607f1e commit 4f40177

29 files changed

+808
-490
lines changed
 

‎docs/config/index.md

+15-1
Original file line numberDiff line numberDiff line change
@@ -1518,7 +1518,21 @@ Run the browser in a `headless` mode. If you are running Vitest in CI, it will b
15181518
- **Default:** `true`
15191519
- **CLI:** `--browser.isolate`, `--browser.isolate=false`
15201520

1521-
Isolate test environment after each test.
1521+
Run every test in a separate iframe.
1522+
1523+
### browser.fileParallelism <Badge type="info">1.3.0+</Badge>
1524+
1525+
- **Type:** `boolean`
1526+
- **Default:** the same as [`fileParallelism`](#fileparallelism-110)
1527+
- **CLI:** `--browser.fileParallelism=false`
1528+
1529+
Create all test iframes at the same time so they are running in parallel.
1530+
1531+
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.
1532+
1533+
::: tip
1534+
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.
1535+
:::
15221536

15231537
#### browser.api
15241538

‎packages/browser/src/client/client.ts

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { CancelReason } from '@vitest/runner'
2+
import { createClient } from '@vitest/ws-client'
3+
4+
export const PORT = import.meta.hot ? '51204' : location.port
5+
export const HOST = [location.hostname, PORT].filter(Boolean).join(':')
6+
export const ENTRY_URL = `${
7+
location.protocol === 'https:' ? 'wss:' : 'ws:'
8+
}//${HOST}/__vitest_api__`
9+
10+
let setCancel = (_: CancelReason) => {}
11+
export const onCancel = new Promise<CancelReason>((resolve) => {
12+
setCancel = resolve
13+
})
14+
15+
export const client = createClient(ENTRY_URL, {
16+
handlers: {
17+
onCancel: setCancel,
18+
},
19+
})
20+
21+
export const channel = new BroadcastChannel('vitest')

‎packages/browser/src/client/index.html

+3-51
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<html lang="en">
33
<head>
44
<meta charset="UTF-8" />
5-
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
5+
<link rel="icon" href="{__VITEST_FAVICON__}" type="image/svg+xml">
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
77
<title>Vitest Browser Runner</title>
88
<style>
@@ -21,59 +21,11 @@
2121
border: none;
2222
}
2323
</style>
24+
<script>{__VITEST_INJECTOR__}</script>
2425
</head>
2526
<body>
2627
<iframe id="vitest-ui" src=""></iframe>
27-
<script>
28-
const moduleCache = new Map()
29-
30-
// this method receives a module object or "import" promise that it resolves and keeps track of
31-
// and returns a hijacked module object that can be used to mock module exports
32-
function wrapModule(module) {
33-
if (module instanceof Promise) {
34-
moduleCache.set(module, { promise: module, evaluated: false })
35-
return module
36-
// TODO: add a test
37-
.then(m => '__vi_inject__' in m ? m.__vi_inject__ : m)
38-
.finally(() => moduleCache.delete(module))
39-
}
40-
return '__vi_inject__' in module ? module.__vi_inject__ : module
41-
}
42-
43-
function exportAll(exports, sourceModule) {
44-
// #1120 when a module exports itself it causes
45-
// call stack error
46-
if (exports === sourceModule)
47-
return
48-
49-
if (Object(sourceModule) !== sourceModule || Array.isArray(sourceModule))
50-
return
51-
52-
for (const key in sourceModule) {
53-
if (key !== 'default') {
54-
try {
55-
Object.defineProperty(exports, key, {
56-
enumerable: true,
57-
configurable: true,
58-
get: () => sourceModule[key],
59-
})
60-
}
61-
catch (_err) { }
62-
}
63-
}
64-
}
65-
66-
window.__vi_export_all__ = exportAll
67-
68-
// TODO: allow easier rewriting of import.meta.env
69-
window.__vi_import_meta__ = {
70-
env: {},
71-
url: location.href,
72-
}
73-
74-
window.__vi_module_cache__ = moduleCache
75-
window.__vi_wrap_module__ = wrapModule
76-
</script>
7728
<script type="module" src="/main.ts"></script>
29+
<div id="vitest-tester"></div>
7830
</body>
7931
</html>

‎packages/browser/src/client/logger.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { importId } from './utils'
33

44
const { Date, console } = globalThis
55

6-
export async function setupConsoleLogSpy(basePath: string) {
7-
const { stringify, format, inspect } = await importId('vitest/utils', basePath) as typeof import('vitest/utils')
6+
export async function setupConsoleLogSpy() {
7+
const { stringify, format, inspect } = await importId('vitest/utils') as typeof import('vitest/utils')
88
const { log, info, error, dir, dirxml, trace, time, timeEnd, timeLog, warn, debug, count, countReset } = console
99
const formatInput = (input: unknown) => {
1010
if (input instanceof Node)

‎packages/browser/src/client/main.ts

+111-258
Original file line numberDiff line numberDiff line change
@@ -1,284 +1,137 @@
1-
import { createClient } from '@vitest/ws-client'
2-
import type { ResolvedConfig } from 'vitest'
3-
import type { CancelReason, VitestRunner } from '@vitest/runner'
4-
import { parseRegexp } from '@vitest/utils'
5-
import type { VitestExecutor } from '../../../vitest/src/runtime/execute'
6-
import { createBrowserRunner } from './runner'
7-
import { importId as _importId } from './utils'
8-
import { setupConsoleLogSpy } from './logger'
9-
import { createSafeRpc, rpc, rpcDone } from './rpc'
10-
import { setupDialogsSpy } from './dialog'
11-
import { BrowserSnapshotEnvironment } from './snapshot'
12-
import { VitestBrowserClientMocker } from './mocker'
13-
14-
export const PORT = import.meta.hot ? '51204' : location.port
15-
export const HOST = [location.hostname, PORT].filter(Boolean).join(':')
16-
export const ENTRY_URL = `${
17-
location.protocol === 'https:' ? 'wss:' : 'ws:'
18-
}//${HOST}/__vitest_api__`
19-
20-
let config: ResolvedConfig | undefined
21-
let runner: VitestRunner | undefined
22-
const browserHashMap = new Map<string, [test: boolean, timestamp: string]>()
1+
import { channel, client } from './client'
2+
import { rpcDone } from './rpc'
3+
import { getBrowserState, getConfig } from './utils'
234

245
const url = new URL(location.href)
25-
const testId = url.searchParams.get('id') || 'unknown'
26-
const reloadTries = Number(url.searchParams.get('reloadTries') || '0')
27-
28-
const basePath = () => config?.base || '/'
29-
const importId = (id: string) => _importId(id, basePath())
30-
const viteClientPath = () => `${basePath()}@vite/client`
31-
32-
function getQueryPaths() {
33-
return url.searchParams.getAll('path')
34-
}
35-
36-
let setCancel = (_: CancelReason) => {}
37-
const onCancel = new Promise<CancelReason>((resolve) => {
38-
setCancel = resolve
39-
})
406

41-
export const client = createClient(ENTRY_URL, {
42-
handlers: {
43-
onCancel: setCancel,
44-
},
45-
})
7+
const ID_ALL = '__vitest_all__'
468

47-
const ws = client.ws
9+
const iframes = new Map<string, HTMLIFrameElement>()
4810

49-
async function loadConfig() {
50-
let retries = 5
51-
do {
52-
try {
53-
await new Promise(resolve => setTimeout(resolve, 150))
54-
config = await client.rpc.getConfig()
55-
config = unwrapConfig(config)
56-
return
57-
}
58-
catch (_) {
59-
// just ignore
60-
}
61-
}
62-
while (--retries > 0)
63-
64-
throw new Error('cannot load configuration after 5 retries')
11+
function debug(...args: unknown[]) {
12+
const debug = getConfig().env.VITEST_BROWSER_DEBUG
13+
if (debug && debug !== 'false')
14+
client.rpc.debug(...args.map(String))
6515
}
6616

67-
function unwrapConfig(config: ResolvedConfig): ResolvedConfig {
68-
return {
69-
...config,
70-
// workaround RegExp serialization
71-
testNamePattern:
72-
config.testNamePattern
73-
? parseRegexp((config.testNamePattern as any as string))
74-
: undefined,
17+
function createIframe(container: HTMLDivElement, file: string) {
18+
if (iframes.has(file)) {
19+
container.removeChild(iframes.get(file)!)
20+
iframes.delete(file)
7521
}
76-
}
7722

78-
function on(event: string, listener: (...args: any[]) => void) {
79-
window.addEventListener(event, listener)
80-
return () => window.removeEventListener(event, listener)
23+
const iframe = document.createElement('iframe')
24+
iframe.setAttribute('loading', 'eager')
25+
iframe.setAttribute('src', `${url.pathname}__vitest_test__/__test__/${encodeURIComponent(file)}`)
26+
iframes.set(file, iframe)
27+
container.appendChild(iframe)
28+
return iframe
8129
}
8230

83-
function serializeError(unhandledError: any) {
84-
return {
85-
...unhandledError,
86-
name: unhandledError.name,
87-
message: unhandledError.message,
88-
stack: String(unhandledError.stack),
89-
}
31+
async function done() {
32+
await rpcDone()
33+
await client.rpc.finishBrowserTests()
9034
}
9135

92-
// we can't import "processError" yet because error might've been thrown before the module was loaded
93-
async function defaultErrorReport(type: string, unhandledError: any) {
94-
const error = serializeError(unhandledError)
95-
if (testId !== 'no-isolate')
96-
error.VITEST_TEST_PATH = testId
97-
await client.rpc.onUnhandledError(error, type)
98-
await client.rpc.onDone(testId)
36+
interface IframeDoneEvent {
37+
type: 'done'
38+
filenames: string[]
9939
}
10040

101-
function catchWindowErrors(cb: (e: ErrorEvent) => void) {
102-
let userErrorListenerCount = 0
103-
function throwUnhandlerError(e: ErrorEvent) {
104-
if (userErrorListenerCount === 0 && e.error != null)
105-
cb(e)
106-
else
107-
console.error(e.error)
108-
}
109-
const addEventListener = window.addEventListener.bind(window)
110-
const removeEventListener = window.removeEventListener.bind(window)
111-
window.addEventListener('error', throwUnhandlerError)
112-
window.addEventListener = function (...args: Parameters<typeof addEventListener>) {
113-
if (args[0] === 'error')
114-
userErrorListenerCount++
115-
return addEventListener.apply(this, args)
116-
}
117-
window.removeEventListener = function (...args: Parameters<typeof removeEventListener>) {
118-
if (args[0] === 'error' && userErrorListenerCount)
119-
userErrorListenerCount--
120-
return removeEventListener.apply(this, args)
121-
}
122-
return function clearErrorHandlers() {
123-
window.removeEventListener('error', throwUnhandlerError)
124-
}
125-
}
126-
127-
const stopErrorHandler = catchWindowErrors(e => defaultErrorReport('Error', e.error))
128-
const stopRejectionHandler = on('unhandledrejection', e => defaultErrorReport('Unhandled Rejection', e.reason))
129-
130-
let runningTests = false
131-
132-
async function reportUnexpectedError(rpc: typeof client.rpc, type: string, error: any) {
133-
const { processError } = await importId('vitest/browser') as typeof import('vitest/browser')
134-
const processedError = processError(error)
135-
if (testId !== 'no-isolate')
136-
error.VITEST_TEST_PATH = testId
137-
await rpc.onUnhandledError(processedError, type)
138-
if (!runningTests)
139-
await rpc.onDone(testId)
41+
interface IframeErrorEvent {
42+
type: 'error'
43+
error: any
44+
errorType: string
45+
files: string[]
14046
}
14147

142-
ws.addEventListener('open', async () => {
143-
await loadConfig()
144-
145-
let safeRpc: typeof client.rpc
146-
try {
147-
// if importing /@id/ failed, we reload the page waiting until Vite prebundles it
148-
const { getSafeTimers } = await importId('vitest/utils') as typeof import('vitest/utils')
149-
safeRpc = createSafeRpc(client, getSafeTimers)
150-
}
151-
catch (err: any) {
152-
if (reloadTries >= 10) {
153-
const error = serializeError(new Error('Vitest failed to load "vitest/utils" after 10 retries.'))
154-
error.cause = serializeError(err)
155-
156-
await client.rpc.onUnhandledError(error, 'Reload Error')
157-
await client.rpc.onDone(testId)
158-
return
159-
}
160-
161-
const tries = reloadTries + 1
162-
const newUrl = new URL(location.href)
163-
newUrl.searchParams.set('reloadTries', String(tries))
164-
location.href = newUrl.href
165-
return
166-
}
167-
168-
stopErrorHandler()
169-
stopRejectionHandler()
170-
171-
catchWindowErrors(event => reportUnexpectedError(safeRpc, 'Error', event.error))
172-
on('unhandledrejection', event => reportUnexpectedError(safeRpc, 'Unhandled Rejection', event.reason))
173-
174-
// @ts-expect-error untyped global for internal use
175-
globalThis.__vitest_browser__ = true
176-
// @ts-expect-error mocking vitest apis
177-
globalThis.__vitest_worker__ = {
178-
config,
179-
browserHashMap,
180-
environment: {
181-
name: 'browser',
182-
},
183-
// @ts-expect-error untyped global for internal use
184-
moduleCache: globalThis.__vi_module_cache__,
185-
rpc: client.rpc,
186-
safeRpc,
187-
durations: {
188-
environment: 0,
189-
prepare: 0,
190-
},
191-
providedContext: await client.rpc.getProvidedContext(),
192-
}
193-
// @ts-expect-error mocking vitest apis
194-
globalThis.__vitest_mocker__ = new VitestBrowserClientMocker()
195-
196-
const paths = getQueryPaths()
197-
198-
const iFrame = document.getElementById('vitest-ui') as HTMLIFrameElement
199-
iFrame.setAttribute('src', '/__vitest__/')
200-
201-
await setupConsoleLogSpy(basePath())
202-
setupDialogsSpy()
203-
await runTests(paths, config!)
204-
})
48+
type IframeChannelEvent = IframeDoneEvent | IframeErrorEvent
20549

206-
async function prepareTestEnvironment(config: ResolvedConfig) {
207-
// need to import it before any other import, otherwise Vite optimizer will hang
208-
await import(viteClientPath())
50+
client.ws.addEventListener('open', async () => {
51+
const config = getConfig()
52+
const container = document.querySelector('#vitest-tester') as HTMLDivElement
53+
const testFiles = getBrowserState().files
20954

210-
const {
211-
startTests,
212-
setupCommonEnv,
213-
loadDiffConfig,
214-
loadSnapshotSerializers,
215-
takeCoverageInsideWorker,
216-
} = await importId('vitest/browser') as typeof import('vitest/browser')
217-
218-
const executor = {
219-
executeId: (id: string) => importId(id),
220-
}
221-
222-
if (!runner) {
223-
const { VitestTestRunner } = await importId('vitest/runners') as typeof import('vitest/runners')
224-
const BrowserRunner = createBrowserRunner(VitestTestRunner, { takeCoverage: () => takeCoverageInsideWorker(config.coverage, executor) })
225-
runner = new BrowserRunner({ config, browserHashMap })
226-
}
227-
228-
return {
229-
startTests,
230-
setupCommonEnv,
231-
loadDiffConfig,
232-
loadSnapshotSerializers,
233-
executor,
234-
runner,
235-
}
236-
}
55+
debug('test files', testFiles.join(', '))
23756

238-
async function runTests(paths: string[], config: ResolvedConfig) {
239-
let preparedData: Awaited<ReturnType<typeof prepareTestEnvironment>> | undefined
240-
// if importing /@id/ failed, we reload the page waiting until Vite prebundles it
241-
try {
242-
preparedData = await prepareTestEnvironment(config)
243-
}
244-
catch (err) {
245-
location.reload()
57+
// TODO: fail tests suite because no tests found?
58+
if (!testFiles.length) {
59+
await done()
24660
return
24761
}
24862

249-
const { startTests, setupCommonEnv, loadDiffConfig, loadSnapshotSerializers, executor, runner } = preparedData!
250-
251-
onCancel.then((reason) => {
252-
runner?.onCancel?.(reason)
63+
const runningFiles = new Set<string>(testFiles)
64+
65+
channel.addEventListener('message', async (e: MessageEvent<IframeChannelEvent>): Promise<void> => {
66+
debug('channel event', JSON.stringify(e.data))
67+
switch (e.data.type) {
68+
case 'done': {
69+
const filenames = e.data.filenames
70+
filenames.forEach(filename => runningFiles.delete(filename))
71+
72+
if (!runningFiles.size)
73+
await done()
74+
break
75+
}
76+
// error happened at the top level, this should never happen in user code, but it can trigger during development
77+
case 'error': {
78+
const iframeId = e.data.files.length > 1 ? ID_ALL : e.data.files[0]
79+
iframes.delete(iframeId)
80+
await client.rpc.onUnhandledError(e.data.error, e.data.errorType)
81+
if (iframeId === ID_ALL)
82+
runningFiles.clear()
83+
else
84+
runningFiles.delete(iframeId)
85+
if (!runningFiles.size)
86+
await done()
87+
break
88+
}
89+
default: {
90+
await client.rpc.onUnhandledError({
91+
name: 'Unexpected Event',
92+
message: `Unexpected event: ${(e.data as any).type}`,
93+
}, 'Unexpected Event')
94+
await done()
95+
}
96+
}
25397
})
25498

255-
if (!config.snapshotOptions.snapshotEnvironment)
256-
config.snapshotOptions.snapshotEnvironment = new BrowserSnapshotEnvironment()
257-
258-
try {
259-
const [diffOptions] = await Promise.all([
260-
loadDiffConfig(config, executor as VitestExecutor),
261-
loadSnapshotSerializers(config, executor as VitestExecutor),
262-
])
263-
runner.config.diffOptions = diffOptions
264-
265-
await setupCommonEnv(config)
266-
const files = paths.map((path) => {
267-
return (`${config.root}/${path}`).replace(/\/+/g, '/')
268-
})
269-
270-
const now = `${new Date().getTime()}`
271-
files.forEach(i => browserHashMap.set(i, [true, now]))
272-
273-
runningTests = true
274-
275-
for (const file of files)
276-
await startTests([file], runner)
277-
}
278-
finally {
279-
runningTests = false
280-
281-
await rpcDone()
282-
await rpc().onDone(testId)
99+
const fileParallelism = config.browser.fileParallelism ?? config.fileParallelism
100+
101+
if (config.isolate === false) {
102+
createIframe(
103+
container,
104+
ID_ALL,
105+
)
106+
}
107+
else {
108+
// if fileParallelism is enabled, we can create all iframes at once
109+
if (fileParallelism) {
110+
for (const file of testFiles) {
111+
createIframe(
112+
container,
113+
file,
114+
)
115+
}
116+
}
117+
else {
118+
// otherwise, we need to wait for each iframe to finish before creating the next one
119+
// this is the most stable way to run tests in the browser
120+
for (const file of testFiles) {
121+
createIframe(
122+
container,
123+
file,
124+
)
125+
await new Promise<void>((resolve) => {
126+
channel.addEventListener('message', function handler(e: MessageEvent<IframeChannelEvent>) {
127+
// done and error can only be triggered by the previous iframe
128+
if (e.data.type === 'done' || e.data.type === 'error') {
129+
channel.removeEventListener('message', handler)
130+
resolve()
131+
}
132+
})
133+
})
134+
}
135+
}
283136
}
284-
}
137+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
const moduleCache = new Map()
2+
3+
function wrapModule(module) {
4+
if (module instanceof Promise) {
5+
moduleCache.set(module, { promise: module, evaluated: false })
6+
return module
7+
.then(m => '__vi_inject__' in m ? m.__vi_inject__ : m)
8+
.finally(() => moduleCache.delete(module))
9+
}
10+
return '__vi_inject__' in module ? module.__vi_inject__ : module
11+
}
12+
13+
function exportAll(exports, sourceModule) {
14+
if (exports === sourceModule)
15+
return
16+
17+
if (Object(sourceModule) !== sourceModule || Array.isArray(sourceModule))
18+
return
19+
20+
for (const key in sourceModule) {
21+
if (key !== 'default') {
22+
try {
23+
Object.defineProperty(exports, key, {
24+
enumerable: true,
25+
configurable: true,
26+
get: () => sourceModule[key],
27+
})
28+
}
29+
catch (_err) { }
30+
}
31+
}
32+
}
33+
34+
window.__vitest_browser_runner__ = {
35+
exportAll,
36+
wrapModule,
37+
moduleCache,
38+
config: { __VITEST_CONFIG__ },
39+
files: { __VITEST_FILES__ },
40+
}
41+
42+
const config = __vitest_browser_runner__.config
43+
44+
if (config.testNamePattern)
45+
config.testNamePattern = parseRegexp(config.testNamePattern)
46+
47+
function parseRegexp(input) {
48+
// Parse input
49+
const m = input.match(/(\/?)(.+)\1([a-z]*)/i)
50+
51+
// match nothing
52+
if (!m)
53+
return /$^/
54+
55+
// Invalid flags
56+
if (m[3] && !/^(?!.*?(.).*?\1)[gmixXsuUAJ]+$/.test(m[3]))
57+
return RegExp(input)
58+
59+
// Create the regular expression
60+
return new RegExp(m[2], m[3])
61+
}

‎packages/browser/src/client/rpc.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
getSafeTimers,
33
} from '@vitest/utils'
44
import type { VitestClient } from '@vitest/ws-client'
5+
import { importId } from './utils'
56

67
const { get } = Reflect
78

@@ -42,6 +43,8 @@ export async function rpcDone() {
4243
export function createSafeRpc(client: VitestClient, getTimers: () => any): VitestClient['rpc'] {
4344
return new Proxy(client.rpc, {
4445
get(target, p, handler) {
46+
if (p === 'then')
47+
return
4548
const sendCall = get(target, p, handler)
4649
const safeSendCall = (...args: any[]) => withSafeTimers(getTimers, async () => {
4750
const result = sendCall(...args)
@@ -59,7 +62,13 @@ export function createSafeRpc(client: VitestClient, getTimers: () => any): Vites
5962
})
6063
}
6164

65+
export async function loadSafeRpc(client: VitestClient) {
66+
// if importing /@id/ failed, we reload the page waiting until Vite prebundles it
67+
const { getSafeTimers } = await importId('vitest/utils') as typeof import('vitest/utils')
68+
return createSafeRpc(client, getSafeTimers)
69+
}
70+
6271
export function rpc(): VitestClient['rpc'] {
6372
// @ts-expect-error not typed global
64-
return globalThis.__vitest_worker__.safeRpc
73+
return globalThis.__vitest_worker__.rpc
6574
}

‎packages/browser/src/client/runner.ts

+34-3
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import type { File, TaskResultPack, Test, VitestRunner } from '@vitest/runner'
22
import type { ResolvedConfig } from 'vitest'
3+
import type { VitestExecutor } from 'vitest/execute'
34
import { rpc } from './rpc'
5+
import { getConfig, importId } from './utils'
6+
import { BrowserSnapshotEnvironment } from './snapshot'
47

58
interface BrowserRunnerOptions {
69
config: ResolvedConfig
7-
browserHashMap: Map<string, [test: boolean, timstamp: string]>
810
}
911

12+
export const browserHashMap = new Map<string, [test: boolean, timstamp: string]>()
13+
1014
interface CoverageHandler {
1115
takeCoverage: () => Promise<unknown>
1216
}
@@ -17,12 +21,11 @@ export function createBrowserRunner(
1721
): { new(options: BrowserRunnerOptions): VitestRunner } {
1822
return class BrowserTestRunner extends VitestRunner {
1923
public config: ResolvedConfig
20-
hashMap = new Map<string, [test: boolean, timstamp: string]>()
24+
hashMap = browserHashMap
2125

2226
constructor(options: BrowserRunnerOptions) {
2327
super(options.config)
2428
this.config = options.config
25-
this.hashMap = options.browserHashMap
2629
}
2730

2831
async onAfterRunTask(task: Test) {
@@ -76,3 +79,31 @@ export function createBrowserRunner(
7679
}
7780
}
7881
}
82+
83+
let cachedRunner: VitestRunner | null = null
84+
85+
export async function initiateRunner() {
86+
if (cachedRunner)
87+
return cachedRunner
88+
const config = getConfig()
89+
const [{ VitestTestRunner }, { takeCoverageInsideWorker, loadDiffConfig, loadSnapshotSerializers }] = await Promise.all([
90+
importId('vitest/runners') as Promise<typeof import('vitest/runners')>,
91+
importId('vitest/browser') as Promise<typeof import('vitest/browser')>,
92+
])
93+
const BrowserRunner = createBrowserRunner(VitestTestRunner, {
94+
takeCoverage: () => takeCoverageInsideWorker(config.coverage, { executeId: importId }),
95+
})
96+
if (!config.snapshotOptions.snapshotEnvironment)
97+
config.snapshotOptions.snapshotEnvironment = new BrowserSnapshotEnvironment()
98+
const runner = new BrowserRunner({
99+
config,
100+
})
101+
const executor = { executeId: importId } as VitestExecutor
102+
const [diffOptions] = await Promise.all([
103+
loadDiffConfig(config, executor),
104+
loadSnapshotSerializers(config, executor),
105+
])
106+
runner.config.diffOptions = diffOptions
107+
cachedRunner = runner
108+
return runner
109+
}
+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<link rel="icon" href="{__VITEST_FAVICON__}" type="image/svg+xml">
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>{__VITEST_TITLE__}</title>
8+
<style>
9+
html {
10+
padding: 0;
11+
margin: 0;
12+
}
13+
body {
14+
padding: 0;
15+
margin: 0;
16+
}
17+
</style>
18+
<script>{__VITEST_INJECTOR__}</script>
19+
</head>
20+
<body>
21+
<script type="module" src="/tester.ts"></script>
22+
{__VITEST_APPEND__}
23+
</body>
24+
</html>

‎packages/browser/src/client/tester.ts

+185
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import type { WorkerGlobalState } from 'vitest'
2+
import { channel, client, onCancel } from './client'
3+
import { setupDialogsSpy } from './dialog'
4+
import { setupConsoleLogSpy } from './logger'
5+
import { browserHashMap, initiateRunner } from './runner'
6+
import { getBrowserState, getConfig, importId } from './utils'
7+
import { loadSafeRpc } from './rpc'
8+
import { VitestBrowserClientMocker } from './mocker'
9+
import { registerUnexpectedErrors, registerUnhandledErrors, serializeError } from './unhandled'
10+
11+
const stopErrorHandler = registerUnhandledErrors()
12+
13+
const url = new URL(location.href)
14+
const reloadStart = url.searchParams.get('__reloadStart')
15+
16+
function debug(...args: unknown[]) {
17+
const debug = getConfig().env.VITEST_BROWSER_DEBUG
18+
if (debug && debug !== 'false')
19+
client.rpc.debug(...args.map(String))
20+
}
21+
22+
async function tryCall<T>(fn: () => Promise<T>): Promise<T | false | undefined> {
23+
try {
24+
return await fn()
25+
}
26+
catch (err: any) {
27+
const now = Date.now()
28+
// try for 30 seconds
29+
const canTry = !reloadStart || (now - Number(reloadStart) < 30_000)
30+
debug('failed to resolve runner', err?.message, 'trying again:', canTry, 'time is', now, 'reloadStart is', reloadStart)
31+
if (!canTry) {
32+
const error = serializeError(new Error('Vitest failed to load its runner after 30 seconds.'))
33+
error.cause = serializeError(err)
34+
35+
await client.rpc.onUnhandledError(error, 'Preload Error')
36+
return false
37+
}
38+
39+
if (!reloadStart) {
40+
const newUrl = new URL(location.href)
41+
newUrl.searchParams.set('__reloadStart', now.toString())
42+
debug('set the new url because reload start is not set to', newUrl)
43+
location.href = newUrl.toString()
44+
}
45+
else {
46+
debug('reload the iframe because reload start is set', location.href)
47+
location.reload()
48+
}
49+
}
50+
}
51+
52+
async function prepareTestEnvironment(files: string[]) {
53+
debug('trying to resolve runner', `${reloadStart}`)
54+
const config = getConfig()
55+
56+
const viteClientPath = `${config.base || '/'}@vite/client`
57+
await import(viteClientPath)
58+
59+
const rpc: any = await loadSafeRpc(client)
60+
61+
stopErrorHandler()
62+
registerUnexpectedErrors(rpc)
63+
64+
const providedContext = await client.rpc.getProvidedContext()
65+
66+
const state: WorkerGlobalState = {
67+
ctx: {
68+
pool: 'browser',
69+
worker: './browser.js',
70+
workerId: 1,
71+
config,
72+
projectName: config.name,
73+
files,
74+
environment: {
75+
name: 'browser',
76+
options: null,
77+
},
78+
providedContext,
79+
invalidates: [],
80+
},
81+
onCancel,
82+
mockMap: new Map(),
83+
config,
84+
environment: {
85+
name: 'browser',
86+
transformMode: 'web',
87+
setup() {
88+
throw new Error('Not called in the browser')
89+
},
90+
},
91+
moduleCache: getBrowserState().moduleCache,
92+
rpc,
93+
durations: {
94+
environment: 0,
95+
prepare: 0,
96+
},
97+
providedContext,
98+
}
99+
// @ts-expect-error untyped global for internal use
100+
globalThis.__vitest_browser__ = true
101+
// @ts-expect-error mocking vitest apis
102+
globalThis.__vitest_worker__ = state
103+
// @ts-expect-error mocking vitest apis
104+
globalThis.__vitest_mocker__ = new VitestBrowserClientMocker()
105+
106+
await setupConsoleLogSpy()
107+
setupDialogsSpy()
108+
109+
const { startTests, setupCommonEnv } = await importId('vitest/browser') as typeof import('vitest/browser')
110+
111+
const version = url.searchParams.get('browserv') || '0'
112+
files.forEach((filename) => {
113+
const currentVersion = browserHashMap.get(filename)
114+
if (!currentVersion || currentVersion[1] !== version)
115+
browserHashMap.set(filename, [true, version])
116+
})
117+
118+
const runner = await initiateRunner()
119+
120+
onCancel.then((reason) => {
121+
runner.onCancel?.(reason)
122+
})
123+
124+
return {
125+
runner,
126+
config,
127+
state,
128+
setupCommonEnv,
129+
startTests,
130+
}
131+
}
132+
133+
function done(files: string[]) {
134+
channel.postMessage({ type: 'done', filenames: files })
135+
}
136+
137+
async function runTests(files: string[]) {
138+
await client.waitForConnection()
139+
140+
debug('client is connected to ws server')
141+
142+
let preparedData: Awaited<ReturnType<typeof prepareTestEnvironment>> | undefined | false
143+
144+
// if importing /@id/ failed, we reload the page waiting until Vite prebundles it
145+
try {
146+
preparedData = await tryCall(() => prepareTestEnvironment(files))
147+
}
148+
catch (error) {
149+
debug('data cannot be loaded becuase it threw an error')
150+
await client.rpc.onUnhandledError(serializeError(error), 'Preload Error')
151+
done(files)
152+
return
153+
}
154+
155+
// cannot load data, finish the test
156+
if (preparedData === false) {
157+
debug('data cannot be loaded, finishing the test')
158+
done(files)
159+
return
160+
}
161+
162+
// page is reloading
163+
if (!preparedData) {
164+
debug('page is reloading, waiting for the next run')
165+
return
166+
}
167+
168+
debug('runner resolved successfully')
169+
170+
const { config, runner, state, setupCommonEnv, startTests } = preparedData
171+
172+
try {
173+
await setupCommonEnv(config)
174+
for (const file of files)
175+
await startTests([file], runner)
176+
}
177+
finally {
178+
state.environmentTeardownRun = true
179+
debug('finished running tests')
180+
done(files)
181+
}
182+
}
183+
184+
// @ts-expect-error untyped global for internal use
185+
window.__vitest_browser_runner__.runTests = runTests
+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { client } from './client'
2+
import { channel } from './client'
3+
import { getBrowserState, importId } from './utils'
4+
5+
function on(event: string, listener: (...args: any[]) => void) {
6+
window.addEventListener(event, listener)
7+
return () => window.removeEventListener(event, listener)
8+
}
9+
10+
export function serializeError(unhandledError: any) {
11+
return {
12+
...unhandledError,
13+
name: unhandledError.name,
14+
message: unhandledError.message,
15+
stack: String(unhandledError.stack),
16+
}
17+
}
18+
19+
// we can't import "processError" yet because error might've been thrown before the module was loaded
20+
async function defaultErrorReport(type: string, unhandledError: any) {
21+
const error = serializeError(unhandledError)
22+
channel.postMessage({ type: 'error', files: getBrowserState().runningFiles, error, errorType: type })
23+
}
24+
25+
function catchWindowErrors(cb: (e: ErrorEvent) => void) {
26+
let userErrorListenerCount = 0
27+
function throwUnhandlerError(e: ErrorEvent) {
28+
if (userErrorListenerCount === 0 && e.error != null)
29+
cb(e)
30+
else
31+
console.error(e.error)
32+
}
33+
const addEventListener = window.addEventListener.bind(window)
34+
const removeEventListener = window.removeEventListener.bind(window)
35+
window.addEventListener('error', throwUnhandlerError)
36+
window.addEventListener = function (...args: Parameters<typeof addEventListener>) {
37+
if (args[0] === 'error')
38+
userErrorListenerCount++
39+
return addEventListener.apply(this, args)
40+
}
41+
window.removeEventListener = function (...args: Parameters<typeof removeEventListener>) {
42+
if (args[0] === 'error' && userErrorListenerCount)
43+
userErrorListenerCount--
44+
return removeEventListener.apply(this, args)
45+
}
46+
return function clearErrorHandlers() {
47+
window.removeEventListener('error', throwUnhandlerError)
48+
}
49+
}
50+
51+
export function registerUnhandledErrors() {
52+
const stopErrorHandler = catchWindowErrors(e => defaultErrorReport('Error', e.error))
53+
const stopRejectionHandler = on('unhandledrejection', e => defaultErrorReport('Unhandled Rejection', e.reason))
54+
return () => {
55+
stopErrorHandler()
56+
stopRejectionHandler()
57+
}
58+
}
59+
60+
export function registerUnexpectedErrors(rpc: typeof client.rpc) {
61+
catchWindowErrors(event => reportUnexpectedError(rpc, 'Error', event.error))
62+
on('unhandledrejection', event => reportUnexpectedError(rpc, 'Unhandled Rejection', event.reason))
63+
}
64+
65+
async function reportUnexpectedError(rpc: typeof client.rpc, type: string, error: any) {
66+
const { processError } = await importId('vitest/browser') as typeof import('vitest/browser')
67+
const processedError = processError(error)
68+
await rpc.onUnhandledError(processedError, type)
69+
}

‎packages/browser/src/client/utils.ts

+24-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
1-
export async function importId(id: string, basePath: string) {
2-
const name = `${basePath}@id/${id}`
3-
// @ts-expect-error mocking vitest apis
4-
return __vi_wrap_module__(import(name))
1+
import type { ResolvedConfig, WorkerGlobalState } from 'vitest'
2+
3+
export async function importId(id: string) {
4+
const name = `${getConfig().base || '/'}@id/${id}`
5+
return getBrowserState().wrapModule(import(name))
6+
}
7+
8+
export function getConfig(): ResolvedConfig {
9+
return getBrowserState().config
10+
}
11+
12+
interface BrowserRunnerState {
13+
files: string[]
14+
runningFiles: string[]
15+
moduleCache: WorkerGlobalState['moduleCache']
16+
config: ResolvedConfig
17+
exportAll(): void
18+
wrapModule(module: any): any
19+
runTests(tests: string[]): Promise<void>
20+
}
21+
22+
export function getBrowserState(): BrowserRunnerState {
23+
// @ts-expect-error not typed global
24+
return window.__vitest_browser_runner__
525
}

‎packages/browser/src/client/vite.config.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ export default defineConfig({
1313
outDir: '../../dist/client',
1414
emptyOutDir: false,
1515
assetsDir: '__vitest_browser__',
16+
rollupOptions: {
17+
input: {
18+
main: resolve(__dirname, './index.html'),
19+
tester: resolve(__dirname, './tester.html'),
20+
},
21+
},
1622
},
1723
plugins: [
1824
{
@@ -33,7 +39,7 @@ export default defineConfig({
3339
if (fs.existsSync(browser))
3440
fs.rmSync(browser, { recursive: true })
3541

36-
fs.mkdirSync(browser)
42+
fs.mkdirSync(browser, { recursive: true })
3743
fs.mkdirSync(resolve(browser, 'assets'))
3844

3945
files.forEach((f) => {

‎packages/browser/src/node/esmInjector.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { Expression, ImportDeclaration, Node, Positioned } from '@vitest/ut
66

77
const viInjectedKey = '__vi_inject__'
88
// const viImportMetaKey = '__vi_import_meta__' // to allow overwrite
9-
const viExportAllHelper = '__vi_export_all__'
9+
const viExportAllHelper = '__vitest_browser_runner__.exportAll'
1010

1111
const skipHijack = [
1212
'/@vite/client',
@@ -230,7 +230,7 @@ export function injectVitestModule(code: string, id: string, parse: PluginContex
230230
// s.update(node.start, node.end, viImportMetaKey)
231231
},
232232
onDynamicImport(node) {
233-
const replace = '__vi_wrap_module__(import('
233+
const replace = '__vitest_browser_runner__.wrapModule(import('
234234
s.overwrite(node.start, (node.source as Positioned<Expression>).start, replace)
235235
s.overwrite(node.end - 1, node.end, '))')
236236
},

‎packages/browser/src/node/index.ts

+82-8
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { fileURLToPath } from 'node:url'
2-
2+
import { readFile } from 'node:fs/promises'
33
import { basename, resolve } from 'pathe'
44
import sirv from 'sirv'
55
import type { Plugin } from 'vite'
6+
import type { ResolvedConfig } from 'vitest'
67
import type { WorkspaceProject } from 'vitest/node'
78
import { coverageConfigDefaults } from 'vitest/config'
89
import { injectVitestModule } from './esmInjector'
910

11+
function replacer(code: string, values: Record<string, string>) {
12+
return code.replace(/{\s*(\w+)\s*}/g, (_, key) => values[key] ?? '')
13+
}
14+
1015
export default (project: WorkspaceProject, base = '/'): Plugin[] => {
1116
const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..')
1217
const distRoot = resolve(pkgRoot, 'dist')
@@ -23,18 +28,74 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
2328
}
2429
},
2530
async configureServer(server) {
31+
const testerHtml = readFile(resolve(distRoot, 'client/tester.html'), 'utf8')
32+
const runnerHtml = readFile(resolve(distRoot, 'client/index.html'), 'utf8')
33+
const injectorJs = readFile(resolve(distRoot, 'client/esm-client-injector.js'), 'utf8')
34+
const favicon = `${base}favicon.svg`
35+
const testerPrefix = `${base}__vitest_test__/__test__/`
36+
server.middlewares.use((_req, res, next) => {
37+
const headers = server.config.server.headers
38+
if (headers) {
39+
for (const name in headers)
40+
res.setHeader(name, headers[name]!)
41+
}
42+
next()
43+
})
44+
server.middlewares.use(async (req, res, next) => {
45+
if (!req.url)
46+
return next()
47+
const url = new URL(req.url, 'http://localhost')
48+
if (!url.pathname.startsWith(testerPrefix) && url.pathname !== base)
49+
return next()
50+
51+
res.setHeader('Cache-Control', 'no-cache, max-age=0, must-revalidate')
52+
res.setHeader('Content-Type', 'text/html; charset=utf-8')
53+
54+
const files = project.browserState?.files ?? []
55+
56+
const config = wrapConfig(project.getSerializableConfig())
57+
config.env ??= {}
58+
config.env.VITEST_BROWSER_DEBUG = process.env.VITEST_BROWSER_DEBUG || ''
59+
60+
const injector = replacer(await injectorJs, {
61+
__VITEST_CONFIG__: JSON.stringify(config),
62+
__VITEST_FILES__: JSON.stringify(files),
63+
})
64+
65+
if (url.pathname === base) {
66+
const html = replacer(await runnerHtml, {
67+
__VITEST_FAVICON__: favicon,
68+
__VITEST_TITLE__: 'Vitest Browser Runner',
69+
__VITEST_INJECTOR__: injector,
70+
})
71+
res.write(html, 'utf-8')
72+
res.end()
73+
return
74+
}
75+
76+
const decodedTestFile = decodeURIComponent(url.pathname.slice(testerPrefix.length))
77+
// if decoded test file is "__vitest_all__" or not in the list of known files, run all tests
78+
const tests = decodedTestFile === '__vitest_all__' || !files.includes(decodedTestFile) ? '__vitest_browser_runner__.files' : JSON.stringify([decodedTestFile])
79+
80+
const html = replacer(await testerHtml, {
81+
__VITEST_FAVICON__: favicon,
82+
__VITEST_TITLE__: 'Vitest Browser Tester',
83+
__VITEST_INJECTOR__: injector,
84+
__VITEST_APPEND__:
85+
// TODO: have only a single global variable to not pollute the global scope
86+
`<script type="module">
87+
__vitest_browser_runner__.runningFiles = ${tests}
88+
__vitest_browser_runner__.runTests(__vitest_browser_runner__.runningFiles)
89+
</script>`,
90+
})
91+
res.write(html, 'utf-8')
92+
res.end()
93+
})
2694
server.middlewares.use(
2795
base,
2896
sirv(resolve(distRoot, 'client'), {
2997
single: false,
3098
dev: true,
31-
setHeaders(res, _pathname, _stats) {
32-
const headers = server.config.server.headers
33-
if (headers) {
34-
for (const name in headers)
35-
res.setHeader(name, headers[name]!)
36-
}
37-
},
3899
}),
39100
)
40101

@@ -69,9 +130,11 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
69130
optimizeDeps: {
70131
entries: [
71132
...entries,
133+
'vitest',
72134
'vitest/utils',
73135
'vitest/browser',
74136
'vitest/runners',
137+
'@vitest/utils',
75138
],
76139
exclude: [
77140
'vitest',
@@ -158,3 +221,14 @@ function resolveCoverageFolder(project: WorkspaceProject) {
158221

159222
return [resolve(root, subdir), `/${basename(root)}/${subdir}/`]
160223
}
224+
225+
function wrapConfig(config: ResolvedConfig): ResolvedConfig {
226+
return {
227+
...config,
228+
// workaround RegExp serialization
229+
testNamePattern:
230+
config.testNamePattern
231+
? config.testNamePattern.toString() as any as RegExp
232+
: undefined,
233+
}
234+
}

‎packages/browser/src/node/providers/none.ts

-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import type { Awaitable } from 'vitest'
21
import type { BrowserProvider, WorkspaceProject } from 'vitest/node'
32

43
export class NoneBrowserProvider implements BrowserProvider {
@@ -22,10 +21,6 @@ export class NoneBrowserProvider implements BrowserProvider {
2221
throw new Error('You\'ve enabled headless mode for "none" provider but it doesn\'t support it.')
2322
}
2423

25-
catchError(_cb: (error: Error) => Awaitable<void>) {
26-
return () => {}
27-
}
28-
2924
async openPage(_url: string) {
3025
this.open = true
3126
if (!this.ctx.browser)

‎packages/browser/src/node/providers/playwright.ts

-13
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import type { Browser, LaunchOptions, Page } from 'playwright'
22
import type { BrowserProvider, BrowserProviderInitializationOptions, WorkspaceProject } from 'vitest/node'
33

4-
type Awaitable<T> = T | PromiseLike<T>
5-
64
export const playwrightBrowsers = ['firefox', 'webkit', 'chromium'] as const
75
export type PlaywrightBrowser = typeof playwrightBrowsers[number]
86

@@ -48,20 +46,9 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
4846
this.cachedBrowser = browser
4947
this.cachedPage = await browser.newPage(this.options?.page)
5048

51-
this.cachedPage.on('close', () => {
52-
browser.close()
53-
})
54-
5549
return this.cachedPage
5650
}
5751

58-
catchError(cb: (error: Error) => Awaitable<void>) {
59-
this.cachedPage?.on('pageerror', cb)
60-
return () => {
61-
this.cachedPage?.off('pageerror', cb)
62-
}
63-
}
64-
6552
async openPage(url: string) {
6653
const browserPage = await this.openBrowserPage()
6754
await browserPage.goto(url)

‎packages/browser/src/node/providers/webdriver.ts

-7
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import type { BrowserProvider, BrowserProviderInitializationOptions, WorkspaceProject } from 'vitest/node'
22
import type { RemoteOptions } from 'webdriverio'
33

4-
type Awaitable<T> = T | PromiseLike<T>
5-
64
const webdriverBrowsers = ['firefox', 'chrome', 'edge', 'safari'] as const
75
type WebdriverBrowser = typeof webdriverBrowsers[number]
86

@@ -81,11 +79,6 @@ export class WebdriverBrowserProvider implements BrowserProvider {
8179
await browserInstance.url(url)
8280
}
8381

84-
// TODO
85-
catchError(_cb: (error: Error) => Awaitable<void>) {
86-
return () => {}
87-
}
88-
8982
async close() {
9083
await Promise.all([
9184
this.cachedBrowser?.sessionId ? this.cachedBrowser?.deleteSession?.() : null,

‎packages/vitest/src/api/setup.ts

+20-23
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ import type { ViteDevServer } from 'vite'
1111
import type { StackTraceParserOptions } from '@vitest/utils/source-map'
1212
import { API_PATH } from '../constants'
1313
import type { Vitest } from '../node'
14-
import type { File, ModuleGraphData, Reporter, ResolvedConfig, TaskResultPack, UserConsoleLog } from '../types'
14+
import type { File, ModuleGraphData, Reporter, TaskResultPack, UserConsoleLog } from '../types'
1515
import { getModuleGraph, isPrimitive, stringifyReplace } from '../utils'
16-
import { WorkspaceProject } from '../node/workspace'
16+
import type { WorkspaceProject } from '../node/workspace'
1717
import { parseErrorStacktrace } from '../utils/source-map'
1818
import type { TransformResultWithSource, WebSocketEvents, WebSocketHandlers } from './types'
1919

@@ -51,9 +51,6 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: Vi
5151
async onUnhandledError(error, type) {
5252
ctx.state.catchError(error, type)
5353
},
54-
async onDone(testId) {
55-
return ctx.state.browserTestPromises.get(testId)?.resolve(true)
56-
},
5754
async onCollected(files) {
5855
ctx.state.collectFiles(files)
5956
await ctx.report('onCollected', files)
@@ -114,9 +111,6 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: Vi
114111
await ctx.rerunFiles(files)
115112
},
116113
getConfig() {
117-
if (vitestOrWorkspace instanceof WorkspaceProject)
118-
return wrapConfig(vitestOrWorkspace.getSerializableConfig())
119-
120114
return vitestOrWorkspace.config
121115
},
122116
async getTransformResult(id) {
@@ -140,16 +134,30 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: Vi
140134
onCancel(reason) {
141135
ctx.cancelCurrentRun(reason)
142136
},
137+
debug(...args) {
138+
ctx.logger.console.debug(...args)
139+
},
143140
getCountOfFailedTests() {
144141
return ctx.state.getCountOfFailedTests()
145142
},
146-
// browser should have a separate RPC in the future, UI doesn't care for provided context
147-
getProvidedContext() {
148-
return 'ctx' in vitestOrWorkspace ? vitestOrWorkspace.getProvidedContext() : ({} as any)
149-
},
150143
getUnhandledErrors() {
151144
return ctx.state.getUnhandledErrors()
152145
},
146+
147+
// TODO: have a separate websocket conection for private browser API
148+
getBrowserFiles() {
149+
if (!('ctx' in vitestOrWorkspace))
150+
throw new Error('`getBrowserTestFiles` is only available in the browser API')
151+
return vitestOrWorkspace.browserState?.files ?? []
152+
},
153+
finishBrowserTests() {
154+
if (!('ctx' in vitestOrWorkspace))
155+
throw new Error('`finishBrowserTests` is only available in the browser API')
156+
return vitestOrWorkspace.browserState?.resolve()
157+
},
158+
getProvidedContext() {
159+
return 'ctx' in vitestOrWorkspace ? vitestOrWorkspace.getProvidedContext() : ({} as any)
160+
},
153161
},
154162
{
155163
post: msg => ws.send(msg),
@@ -224,14 +232,3 @@ class WebSocketReporter implements Reporter {
224232
})
225233
}
226234
}
227-
228-
function wrapConfig(config: ResolvedConfig): ResolvedConfig {
229-
return {
230-
...config,
231-
// workaround RegExp serialization
232-
testNamePattern:
233-
config.testNamePattern
234-
? config.testNamePattern.toString() as any as RegExp
235-
: undefined,
236-
}
237-
}

‎packages/vitest/src/api/types.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ export interface WebSocketHandlers {
1111
onCollected(files?: File[]): Promise<void>
1212
onTaskUpdate(packs: TaskResultPack[]): void
1313
onAfterSuiteRun(meta: AfterSuiteRunMeta): void
14-
onDone(name: string): void
1514
onCancel(reason: CancelReason): void
1615
getCountOfFailedTests(): number
1716
sendLog(log: UserConsoleLog): void
@@ -32,6 +31,10 @@ export interface WebSocketHandlers {
3231
updateSnapshot(file?: File): Promise<void>
3332
getProvidedContext(): ProvidedContext
3433
getUnhandledErrors(): unknown[]
34+
35+
finishBrowserTests(): void
36+
getBrowserFiles(): string[]
37+
debug(...args: string[]): void
3538
}
3639

3740
export interface WebSocketEvents extends Pick<Reporter, 'onCollected' | 'onFinished' | 'onTaskUpdate' | 'onUserConsoleLog' | 'onPathsCollected'> {

‎packages/vitest/src/node/pools/browser.ts

+22-43
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { createDefer } from '@vitest/utils'
2-
import { relative } from 'pathe'
32
import type { Vitest } from '../core'
43
import type { ProcessPool } from '../pool'
54
import type { WorkspaceProject } from '../workspace'
@@ -8,61 +7,42 @@ import type { BrowserProvider } from '../../types/browser'
87
export function createBrowserPool(ctx: Vitest): ProcessPool {
98
const providers = new Set<BrowserProvider>()
109

11-
const waitForTest = async (provider: BrowserProvider, id: string) => {
12-
const defer = createDefer()
13-
ctx.state.browserTestPromises.set(id, defer)
14-
const off = provider.catchError((error) => {
15-
if (id !== 'no-isolate') {
16-
Object.defineProperty(error, 'VITEST_TEST_PATH', {
17-
value: id,
18-
})
19-
}
20-
defer.reject(error)
21-
})
22-
try {
23-
return await defer
24-
}
25-
finally {
26-
off()
10+
const waitForTests = async (project: WorkspaceProject, files: string[]) => {
11+
const defer = createDefer<void>()
12+
project.browserState?.resolve()
13+
project.browserState = {
14+
files,
15+
resolve: () => {
16+
defer.resolve()
17+
project.browserState = undefined
18+
},
19+
reject: defer.reject,
2720
}
21+
return await defer
2822
}
2923

3024
const runTests = async (project: WorkspaceProject, files: string[]) => {
3125
ctx.state.clearFiles(project, files)
3226

33-
let isCancelled = false
34-
project.ctx.onCancel(() => {
35-
isCancelled = true
36-
})
27+
// TODO
28+
// let isCancelled = false
29+
// project.ctx.onCancel(() => {
30+
// isCancelled = true
31+
// })
3732

3833
const provider = project.browserProvider!
3934
providers.add(provider)
4035

4136
const resolvedUrls = project.browser?.resolvedUrls
4237
const origin = resolvedUrls?.local[0] ?? resolvedUrls?.network[0]
43-
const paths = files.map(file => relative(project.config.root, file))
4438

45-
if (project.config.browser.isolate) {
46-
for (const path of paths) {
47-
if (isCancelled) {
48-
ctx.state.cancelFiles(files.slice(paths.indexOf(path)), ctx.config.root, project.config.name)
49-
break
50-
}
39+
if (!origin)
40+
throw new Error(`Can't find browser origin URL for project "${project.config.name}"`)
5141

52-
const url = new URL('/', origin)
53-
url.searchParams.append('path', path)
54-
url.searchParams.set('id', path)
55-
await provider.openPage(url.toString())
56-
await waitForTest(provider, path)
57-
}
58-
}
59-
else {
60-
const url = new URL('/', origin)
61-
url.searchParams.set('id', 'no-isolate')
62-
paths.forEach(path => url.searchParams.append('path', path))
63-
await provider.openPage(url.toString())
64-
await waitForTest(provider, 'no-isolate')
65-
}
42+
const promise = waitForTests(project, files)
43+
44+
await provider.openPage(new URL('/', origin).toString())
45+
await promise
6646
}
6747

6848
const runWorkspaceTests = async (specs: [WorkspaceProject, string][]) => {
@@ -80,7 +60,6 @@ export function createBrowserPool(ctx: Vitest): ProcessPool {
8060
return {
8161
name: 'browser',
8262
async close() {
83-
ctx.state.browserTestPromises.clear()
8463
await Promise.all([...providers].map(provider => provider.close()))
8564
providers.clear()
8665
},

‎packages/vitest/src/node/state.ts

-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ export function isAggregateError(err: unknown): err is AggregateErrorPonyfill {
1717
export class StateManager {
1818
filesMap = new Map<string, File[]>()
1919
pathsSet: Set<string> = new Set()
20-
browserTestPromises = new Map<string, { resolve: (v: unknown) => void; reject: (v: unknown) => void }>()
2120
idMap = new Map<string, Task>()
2221
taskFileMap = new WeakMap<Task, File>()
2322
errorsSet = new Set<unknown>()

‎packages/vitest/src/node/workspace.ts

+6
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ export class WorkspaceProject {
7070
closingPromise: Promise<unknown> | undefined
7171
browserProvider: BrowserProvider | undefined
7272

73+
browserState: {
74+
files: string[]
75+
resolve(): void
76+
reject(v: unknown): void
77+
} | undefined
78+
7379
testFilesList: string[] | null = null
7480

7581
private _globalSetups: GlobalSetupFile[] | undefined

‎packages/vitest/src/types/browser.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ export interface BrowserProvider {
1212
getSupportedBrowsers(): readonly string[]
1313
initialize(ctx: WorkspaceProject, options: BrowserProviderInitializationOptions): Awaitable<void>
1414
openPage(url: string): Awaitable<void>
15-
catchError(cb: (error: Error) => Awaitable<void>): () => Awaitable<void>
1615
close(): Awaitable<void>
1716
}
1817

@@ -83,6 +82,13 @@ export interface BrowserConfigOptions {
8382
* @default true
8483
*/
8584
isolate?: boolean
85+
86+
/**
87+
* Run test files in parallel. Fallbacks to `test.fileParallelism`.
88+
*
89+
* @default test.fileParallelism
90+
*/
91+
fileParallelism?: boolean
8692
}
8793

8894
export interface ResolvedBrowserOptions extends BrowserConfigOptions {

‎packages/ws-client/src/index.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface VitestClientOptions {
1414
autoReconnect?: boolean
1515
reconnectInterval?: number
1616
reconnectTries?: number
17+
connectTimeout?: number
1718
reactive?: <T>(v: T) => T
1819
ref?: <T>(v: T) => { value: T }
1920
WebSocketConstructor?: typeof WebSocket
@@ -33,6 +34,7 @@ export function createClient(url: string, options: VitestClientOptions = {}) {
3334
autoReconnect = true,
3435
reconnectInterval = 2000,
3536
reconnectTries = 10,
37+
connectTimeout = 60000,
3638
reactive = v => v,
3739
WebSocketConstructor = globalThis.WebSocket,
3840
} = options
@@ -98,10 +100,17 @@ export function createClient(url: string, options: VitestClientOptions = {}) {
98100
}
99101

100102
function registerWS() {
101-
openPromise = new Promise((resolve) => {
103+
openPromise = new Promise((resolve, reject) => {
104+
const timeout = setTimeout(() => {
105+
reject(new Error(`Cannot connect to the server in ${connectTimeout / 1000} seconds`))
106+
}, connectTimeout)?.unref?.()
107+
if (ctx.ws.OPEN === ctx.ws.readyState)
108+
resolve()
109+
// still have a listener even if it's already open to update tries
102110
ctx.ws.addEventListener('open', () => {
103111
tries = reconnectTries
104112
resolve()
113+
clearTimeout(timeout)
105114
})
106115
})
107116
ctx.ws.addEventListener('message', (v) => {

‎test/browser/specs/filter.test.mjs

+10-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import test from 'node:test'
33
import { execa } from 'execa'
44

55
test('filter', async () => {
6-
const result = await execa(
6+
let result = execa(
77
'npx',
88
[
99
'vitest',
@@ -21,6 +21,15 @@ test('filter', async () => {
2121
},
2222
},
2323
)
24+
if (process.env.VITEST_BROWSER_DEBUG) {
25+
result.stderr.on('data', (data) => {
26+
process.stderr.write(data.toString())
27+
})
28+
result.stdout.on('data', (data) => {
29+
process.stdout.write(data.toString())
30+
})
31+
}
32+
result = await result
2433
assert.match(result.stdout, / test\/basic.test.ts > basic 2/)
2534
assert.match(result.stdout, /Test Files {2}1 passed/)
2635
assert.match(result.stdout, /Tests {2}1 passed | 3 skipped/)

‎test/browser/specs/run-vitest.mjs

+10-1
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,23 @@ export default async function runVitest(moreArgs = []) {
99
if (browser !== 'safari')
1010
argv.push('--browser.headless')
1111

12-
const { stderr, stdout } = await execa('npx', argv.concat(moreArgs), {
12+
const result = execa('npx', argv.concat(moreArgs), {
1313
env: {
1414
...process.env,
1515
CI: 'true',
1616
NO_COLOR: 'true',
1717
},
1818
reject: false,
1919
})
20+
if (process.env.VITEST_BROWSER_DEBUG) {
21+
result.stderr.on('data', (data) => {
22+
process.stderr.write(data.toString())
23+
})
24+
result.stdout.on('data', (data) => {
25+
process.stdout.write(data.toString())
26+
})
27+
}
28+
const { stderr, stdout } = await result
2029
const browserResult = await readFile('./browser.json', 'utf-8')
2130
const browserResultJson = JSON.parse(browserResult)
2231

‎test/browser/specs/runner.test.mjs

+65-58
Original file line numberDiff line numberDiff line change
@@ -2,69 +2,76 @@ import assert from 'node:assert'
22
import test from 'node:test'
33
import runVitest from './run-vitest.mjs'
44

5-
const {
6-
stderr,
7-
stdout,
8-
browserResultJson,
9-
passedTests,
10-
failedTests,
11-
} = await runVitest()
5+
const cliArguments = [
6+
['not parallel', ['--no-browser.fileParallelism']],
7+
['parallel', []],
8+
]
129

13-
await test('tests are actually running', async () => {
14-
assert.equal(browserResultJson.testResults.length, 14, 'Not all the tests have been run')
15-
assert.equal(passedTests.length, 12, 'Some tests failed')
16-
assert.equal(failedTests.length, 2, 'Some tests have passed but should fail')
10+
for (const [description, args] of cliArguments) {
11+
const {
12+
stderr,
13+
stdout,
14+
browserResultJson,
15+
passedTests,
16+
failedTests,
17+
} = await runVitest(args)
1718

18-
assert.doesNotMatch(stderr, /has been externalized for browser compatibility/, 'doesn\'t have any externalized modules')
19-
assert.doesNotMatch(stderr, /Unhandled Error/, 'doesn\'t have any unhandled errors')
20-
})
19+
await test(`[${description}] tests are actually running`, async () => {
20+
assert.equal(browserResultJson.testResults.length, 14, 'Not all the tests have been run')
21+
assert.equal(passedTests.length, 12, 'Some tests failed')
22+
assert.equal(failedTests.length, 2, 'Some tests have passed but should fail')
2123

22-
await test('correctly prints error', () => {
23-
assert.match(stderr, /expected 1 to be 2/, 'prints failing error')
24-
assert.match(stderr, /- 2\s+\+ 1/, 'prints failing diff')
25-
assert.match(stderr, /Expected to be/, 'prints \`Expected to be\`')
26-
assert.match(stderr, /But got/, 'prints \`But got\`')
27-
})
24+
assert.doesNotMatch(stderr, /has been externalized for browser compatibility/, 'doesn\'t have any externalized modules')
25+
assert.doesNotMatch(stderr, /Unhandled Error/, 'doesn\'t have any unhandled errors')
26+
})
2827

29-
await test('logs are redirected to stdout', async () => {
30-
assert.match(stdout, /stdout | test\/logs.test.ts > logging to stdout/)
31-
assert.match(stdout, /hello from console.log/, 'prints console.log')
32-
assert.match(stdout, /hello from console.info/, 'prints console.info')
33-
assert.match(stdout, /hello from console.debug/, 'prints console.debug')
34-
assert.match(stdout, /{ hello: 'from dir' }/, 'prints console.dir')
35-
assert.match(stdout, /{ hello: 'from dirxml' }/, 'prints console.dixml')
36-
// safari logs the stack files with @https://...
37-
assert.match(stdout, /hello from console.trace\s+(\w+|@)/, 'prints console.trace')
38-
assert.match(stdout, /dom <div \/>/, 'prints dom')
39-
assert.match(stdout, /default: 1/, 'prints first default count')
40-
assert.match(stdout, /default: 2/, 'prints second default count')
41-
assert.match(stdout, /default: 3/, 'prints third default count')
42-
assert.match(stdout, /count: 1/, 'prints first custom count')
43-
assert.match(stdout, /count: 2/, 'prints second custom count')
44-
assert.match(stdout, /count: 3/, 'prints third custom count')
45-
assert.match(stdout, /default: [\d.]+ ms/, 'prints default time')
46-
assert.match(stdout, /time: [\d.]+ ms/, 'prints custom time')
47-
})
28+
await test(`[${description}] correctly prints error`, () => {
29+
assert.match(stderr, /expected 1 to be 2/, 'prints failing error')
30+
assert.match(stderr, /- 2\s+\+ 1/, 'prints failing diff')
31+
assert.match(stderr, /Expected to be/, 'prints \`Expected to be\`')
32+
assert.match(stderr, /But got/, 'prints \`But got\`')
33+
})
4834

49-
await test('logs are redirected to stderr', async () => {
50-
assert.match(stderr, /stderr | test\/logs.test.ts > logging to stderr/)
51-
assert.match(stderr, /hello from console.error/, 'prints console.log')
52-
assert.match(stderr, /hello from console.warn/, 'prints console.info')
53-
assert.match(stderr, /Timer "invalid timeLog" does not exist/, 'prints errored timeLog')
54-
assert.match(stderr, /Timer "invalid timeEnd" does not exist/, 'prints errored timeEnd')
55-
})
35+
await test(`[${description}] logs are redirected to stdout`, async () => {
36+
assert.match(stdout, /stdout | test\/logs.test.ts > logging to stdout/)
37+
assert.match(stdout, /hello from console.log/, 'prints console.log')
38+
assert.match(stdout, /hello from console.info/, 'prints console.info')
39+
assert.match(stdout, /hello from console.debug/, 'prints console.debug')
40+
assert.match(stdout, /{ hello: 'from dir' }/, 'prints console.dir')
41+
assert.match(stdout, /{ hello: 'from dirxml' }/, 'prints console.dixml')
42+
// safari logs the stack files with @https://...
43+
assert.match(stdout, /hello from console.trace\s+(\w+|@)/, 'prints console.trace')
44+
assert.match(stdout, /dom <div \/>/, 'prints dom')
45+
assert.match(stdout, /default: 1/, 'prints first default count')
46+
assert.match(stdout, /default: 2/, 'prints second default count')
47+
assert.match(stdout, /default: 3/, 'prints third default count')
48+
assert.match(stdout, /count: 1/, 'prints first custom count')
49+
assert.match(stdout, /count: 2/, 'prints second custom count')
50+
assert.match(stdout, /count: 3/, 'prints third custom count')
51+
assert.match(stdout, /default: [\d.]+ ms/, 'prints default time')
52+
assert.match(stdout, /time: [\d.]+ ms/, 'prints custom time')
53+
})
5654

57-
await test('stack trace points to correct file in every browser', () => {
58-
// dependeing on the browser it references either `.toBe()` or `expect()`
59-
assert.match(stderr, /test\/failing.test.ts:4:(12|17)/, 'prints stack trace')
60-
})
55+
await test(`[${description}] logs are redirected to stderr`, async () => {
56+
assert.match(stderr, /stderr | test\/logs.test.ts > logging to stderr/)
57+
assert.match(stderr, /hello from console.error/, 'prints console.log')
58+
assert.match(stderr, /hello from console.warn/, 'prints console.info')
59+
assert.match(stderr, /Timer "invalid timeLog" does not exist/, 'prints errored timeLog')
60+
assert.match(stderr, /Timer "invalid timeEnd" does not exist/, 'prints errored timeEnd')
61+
})
6162

62-
await test('popup apis should log a warning', () => {
63-
assert.ok(stderr.includes('Vitest encountered a \`alert\("test"\)\`'), 'prints warning for alert')
64-
assert.ok(stderr.includes('Vitest encountered a \`confirm\("test"\)\`'), 'prints warning for confirm')
65-
assert.ok(stderr.includes('Vitest encountered a \`prompt\("test"\)\`'), 'prints warning for prompt')
66-
})
63+
await test(`[${description}] stack trace points to correct file in every browser`, () => {
64+
// dependeing on the browser it references either `.toBe()` or `expect()`
65+
assert.match(stderr, /test\/failing.test.ts:4:(12|17)/, 'prints stack trace')
66+
})
6767

68-
await test('snapshot inaccessible file debuggability', () => {
69-
assert.ok(stdout.includes('Access denied to "/inaccesible/path".'), 'file security enforcement explained')
70-
})
68+
await test(`[${description}] popup apis should log a warning`, () => {
69+
assert.ok(stderr.includes('Vitest encountered a \`alert\("test"\)\`'), 'prints warning for alert')
70+
assert.ok(stderr.includes('Vitest encountered a \`confirm\("test"\)\`'), 'prints warning for confirm')
71+
assert.ok(stderr.includes('Vitest encountered a \`prompt\("test"\)\`'), 'prints warning for prompt')
72+
})
73+
74+
await test(`[${description}] snapshot inaccessible file debuggability`, () => {
75+
assert.ok(stdout.includes('Access denied to "/inaccesible/path".'), 'file security enforcement explained')
76+
})
77+
}

‎test/core/test/injector-esm.test.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,9 @@ test('export * from', async () => {
126126
).toMatchInlineSnapshot(`
127127
"const __vi_inject__ = { [Symbol.toStringTag]: "Module" };
128128
const { __vi_inject__: __vi_esm_0__ } = await import("vue");
129-
__vi_export_all__(__vi_inject__, __vi_esm_0__);
129+
__vitest_browser_runner__.exportAll(__vi_inject__, __vi_esm_0__);
130130
const { __vi_inject__: __vi_esm_1__ } = await import("react");
131-
__vi_export_all__(__vi_inject__, __vi_esm_1__);
131+
__vitest_browser_runner__.exportAll(__vi_inject__, __vi_esm_1__);
132132
133133
134134
export { __vi_inject__ }"
@@ -167,7 +167,7 @@ test('export then import minified', async () => {
167167
"const __vi_inject__ = { [Symbol.toStringTag]: "Module" };
168168
import { __vi_inject__ as __vi_esm_0__ } from 'vue'
169169
const { __vi_inject__: __vi_esm_1__ } = await import("vue");
170-
__vi_export_all__(__vi_inject__, __vi_esm_1__);
170+
__vitest_browser_runner__.exportAll(__vi_inject__, __vi_esm_1__);
171171
172172
export { __vi_inject__ }"
173173
`)
@@ -198,7 +198,7 @@ test('dynamic import', async () => {
198198
)
199199
expect(result).toMatchInlineSnapshot(`
200200
"const __vi_inject__ = { [Symbol.toStringTag]: "Module" };
201-
const i = () => __vi_wrap_module__(import('./foo'))
201+
const i = () => __vitest_browser_runner__.wrapModule(import('./foo'))
202202
export { __vi_inject__ }"
203203
`)
204204
})

0 commit comments

Comments
 (0)
Please sign in to comment.