[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

feat(browser): run test files in isolated iframes (#5036)

authored by

Vladimir and committed by
GitHub
(Feb 7, 2024, 12:18 PM +0100) 4f40177e b607f1ea

+805 -487
+15 -1
docs/config/index.md
··· 1518 1518 - **Default:** `true` 1519 1519 - **CLI:** `--browser.isolate`, `--browser.isolate=false` 1520 1520 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 + ::: 1522 1536 1523 1537 #### browser.api 1524 1538
+10 -1
packages/ws-client/src/index.ts
··· 14 14 autoReconnect?: boolean 15 15 reconnectInterval?: number 16 16 reconnectTries?: number 17 + connectTimeout?: number 17 18 reactive?: <T>(v: T) => T 18 19 ref?: <T>(v: T) => { value: T } 19 20 WebSocketConstructor?: typeof WebSocket ··· 33 34 autoReconnect = true, 34 35 reconnectInterval = 2000, 35 36 reconnectTries = 10, 37 + connectTimeout = 60000, 36 38 reactive = v => v, 37 39 WebSocketConstructor = globalThis.WebSocket, 38 40 } = options ··· 98 100 } 99 101 100 102 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 102 110 ctx.ws.addEventListener('open', () => { 103 111 tries = reconnectTries 104 112 resolve() 113 + clearTimeout(timeout) 105 114 }) 106 115 }) 107 116 ctx.ws.addEventListener('message', (v) => {
+10 -1
test/browser/specs/filter.test.mjs
··· 3 3 import { execa } from 'execa' 4 4 5 5 test('filter', async () => { 6 - const result = await execa( 6 + let result = execa( 7 7 'npx', 8 8 [ 9 9 'vitest', ··· 21 21 }, 22 22 }, 23 23 ) 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 24 33 assert.match(result.stdout, /✓ test\/basic.test.ts > basic 2/) 25 34 assert.match(result.stdout, /Test Files {2}1 passed/) 26 35 assert.match(result.stdout, /Tests {2}1 passed | 3 skipped/)
+10 -1
test/browser/specs/run-vitest.mjs
··· 9 9 if (browser !== 'safari') 10 10 argv.push('--browser.headless') 11 11 12 - const { stderr, stdout } = await execa('npx', argv.concat(moreArgs), { 12 + const result = execa('npx', argv.concat(moreArgs), { 13 13 env: { 14 14 ...process.env, 15 15 CI: 'true', ··· 17 17 }, 18 18 reject: false, 19 19 }) 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 20 29 const browserResult = await readFile('./browser.json', 'utf-8') 21 30 const browserResultJson = JSON.parse(browserResult) 22 31
+65 -58
test/browser/specs/runner.test.mjs
··· 2 2 import test from 'node:test' 3 3 import runVitest from './run-vitest.mjs' 4 4 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 + ] 12 9 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) 17 18 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') 21 23 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 + }) 28 27 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 + }) 48 34 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 + }) 56 54 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 + }) 61 62 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 + }) 67 67 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 + }
+4 -4
test/core/test/injector-esm.test.ts
··· 126 126 ).toMatchInlineSnapshot(` 127 127 "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; 128 128 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__); 130 130 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__); 132 132 133 133 134 134 export { __vi_inject__ }" ··· 167 167 "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; 168 168 import { __vi_inject__ as __vi_esm_0__ } from 'vue' 169 169 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__); 171 171 172 172 export { __vi_inject__ }" 173 173 `) ··· 198 198 ) 199 199 expect(result).toMatchInlineSnapshot(` 200 200 "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; 201 - const i = () => __vi_wrap_module__(import('./foo')) 201 + const i = () => __vitest_browser_runner__.wrapModule(import('./foo')) 202 202 export { __vi_inject__ }" 203 203 `) 204 204 })
+21
packages/browser/src/client/client.ts
··· 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')
+3 -51
packages/browser/src/client/index.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <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"> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 7 <title>Vitest Browser Runner</title> 8 8 <style> ··· 21 21 border: none; 22 22 } 23 23 </style> 24 + <script>{__VITEST_INJECTOR__}</script> 24 25 </head> 25 26 <body> 26 27 <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> 77 28 <script type="module" src="/main.ts"></script> 29 + <div id="vitest-tester"></div> 78 30 </body> 79 31 </html>
+2 -2
packages/browser/src/client/logger.ts
··· 3 3 4 4 const { Date, console } = globalThis 5 5 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') 8 8 const { log, info, error, dir, dirxml, trace, time, timeEnd, timeLog, warn, debug, count, countReset } = console 9 9 const formatInput = (input: unknown) => { 10 10 if (input instanceof Node)
+108 -255
packages/browser/src/client/main.ts
··· 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' 23 4 24 5 const url = new URL(location.href) 25 - const testId = url.searchParams.get('id') || 'unknown' 26 - const reloadTries = Number(url.searchParams.get('reloadTries') || '0') 27 6 28 - const basePath = () => config?.base || '/' 29 - const importId = (id: string) => _importId(id, basePath()) 30 - const viteClientPath = () => `${basePath()}@vite/client` 7 + const ID_ALL = '__vitest_all__' 31 8 32 - function getQueryPaths() { 33 - return url.searchParams.getAll('path') 9 + const iframes = new Map<string, HTMLIFrameElement>() 10 + 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)) 34 15 } 35 16 36 - let setCancel = (_: CancelReason) => {} 37 - const onCancel = new Promise<CancelReason>((resolve) => { 38 - setCancel = resolve 39 - }) 40 - 41 - export const client = createClient(ENTRY_URL, { 42 - handlers: { 43 - onCancel: setCancel, 44 - }, 45 - }) 46 - 47 - const ws = client.ws 48 - 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 - } 17 + function createIframe(container: HTMLDivElement, file: string) { 18 + if (iframes.has(file)) { 19 + container.removeChild(iframes.get(file)!) 20 + iframes.delete(file) 61 21 } 62 - while (--retries > 0) 63 22 64 - throw new Error('cannot load configuration after 5 retries') 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 65 29 } 66 30 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, 75 - } 31 + async function done() { 32 + await rpcDone() 33 + await client.rpc.finishBrowserTests() 76 34 } 77 35 78 - function on(event: string, listener: (...args: any[]) => void) { 79 - window.addEventListener(event, listener) 80 - return () => window.removeEventListener(event, listener) 36 + interface IframeDoneEvent { 37 + type: 'done' 38 + filenames: string[] 81 39 } 82 40 83 - function serializeError(unhandledError: any) { 84 - return { 85 - ...unhandledError, 86 - name: unhandledError.name, 87 - message: unhandledError.message, 88 - stack: String(unhandledError.stack), 89 - } 41 + interface IframeErrorEvent { 42 + type: 'error' 43 + error: any 44 + errorType: string 45 + files: string[] 90 46 } 91 47 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) 99 - } 48 + type IframeChannelEvent = IframeDoneEvent | IframeErrorEvent 100 49 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 - } 50 + client.ws.addEventListener('open', async () => { 51 + const config = getConfig() 52 + const container = document.querySelector('#vitest-tester') as HTMLDivElement 53 + const testFiles = getBrowserState().files 126 54 127 - const stopErrorHandler = catchWindowErrors(e => defaultErrorReport('Error', e.error)) 128 - const stopRejectionHandler = on('unhandledrejection', e => defaultErrorReport('Unhandled Rejection', e.reason)) 55 + debug('test files', testFiles.join(', ')) 129 56 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) 140 - } 141 - 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 57 + // TODO: fail tests suite because no tests found? 58 + if (!testFiles.length) { 59 + await done() 165 60 return 166 61 } 167 62 168 - stopErrorHandler() 169 - stopRejectionHandler() 63 + const runningFiles = new Set<string>(testFiles) 170 64 171 - catchWindowErrors(event => reportUnexpectedError(safeRpc, 'Error', event.error)) 172 - on('unhandledrejection', event => reportUnexpectedError(safeRpc, 'Unhandled Rejection', event.reason)) 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)) 173 71 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 - }) 205 - 206 - async function prepareTestEnvironment(config: ResolvedConfig) { 207 - // need to import it before any other import, otherwise Vite optimizer will hang 208 - await import(viteClientPath()) 209 - 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 - } 237 - 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() 246 - return 247 - } 248 - 249 - const { startTests, setupCommonEnv, loadDiffConfig, loadSnapshotSerializers, executor, runner } = preparedData! 250 - 251 - onCancel.then((reason) => { 252 - runner?.onCancel?.(reason) 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 + } 253 97 }) 254 98 255 - if (!config.snapshotOptions.snapshotEnvironment) 256 - config.snapshotOptions.snapshotEnvironment = new BrowserSnapshotEnvironment() 99 + const fileParallelism = config.browser.fileParallelism ?? config.fileParallelism 257 100 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) 101 + if (config.isolate === false) { 102 + createIframe( 103 + container, 104 + ID_ALL, 105 + ) 277 106 } 278 - finally { 279 - runningTests = false 280 - 281 - await rpcDone() 282 - await rpc().onDone(testId) 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 + } 283 136 } 284 - } 137 + })
+10 -1
packages/browser/src/client/rpc.ts
··· 2 2 getSafeTimers, 3 3 } from '@vitest/utils' 4 4 import type { VitestClient } from '@vitest/ws-client' 5 + import { importId } from './utils' 5 6 6 7 const { get } = Reflect 7 8 ··· 42 43 export function createSafeRpc(client: VitestClient, getTimers: () => any): VitestClient['rpc'] { 43 44 return new Proxy(client.rpc, { 44 45 get(target, p, handler) { 46 + if (p === 'then') 47 + return 45 48 const sendCall = get(target, p, handler) 46 49 const safeSendCall = (...args: any[]) => withSafeTimers(getTimers, async () => { 47 50 const result = sendCall(...args) ··· 59 62 }) 60 63 } 61 64 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 + 62 71 export function rpc(): VitestClient['rpc'] { 63 72 // @ts-expect-error not typed global 64 - return globalThis.__vitest_worker__.safeRpc 73 + return globalThis.__vitest_worker__.rpc 65 74 }
+34 -3
packages/browser/src/client/runner.ts
··· 1 1 import type { File, TaskResultPack, Test, VitestRunner } from '@vitest/runner' 2 2 import type { ResolvedConfig } from 'vitest' 3 + import type { VitestExecutor } from 'vitest/execute' 3 4 import { rpc } from './rpc' 5 + import { getConfig, importId } from './utils' 6 + import { BrowserSnapshotEnvironment } from './snapshot' 4 7 5 8 interface BrowserRunnerOptions { 6 9 config: ResolvedConfig 7 - browserHashMap: Map<string, [test: boolean, timstamp: string]> 8 10 } 11 + 12 + export const browserHashMap = new Map<string, [test: boolean, timstamp: string]>() 9 13 10 14 interface CoverageHandler { 11 15 takeCoverage: () => Promise<unknown> ··· 17 21 ): { new(options: BrowserRunnerOptions): VitestRunner } { 18 22 return class BrowserTestRunner extends VitestRunner { 19 23 public config: ResolvedConfig 20 - hashMap = new Map<string, [test: boolean, timstamp: string]>() 24 + hashMap = browserHashMap 21 25 22 26 constructor(options: BrowserRunnerOptions) { 23 27 super(options.config) 24 28 this.config = options.config 25 - this.hashMap = options.browserHashMap 26 29 } 27 30 28 31 async onAfterRunTask(task: Test) { ··· 75 78 await import(importpath) 76 79 } 77 80 } 81 + } 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 78 109 }
+24
packages/browser/src/client/tester.html
··· 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>
+185
packages/browser/src/client/tester.ts
··· 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
packages/browser/src/client/unhandled.ts
··· 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 + }
+24 -4
packages/browser/src/client/utils.ts
··· 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__ 5 25 }
+7 -1
packages/browser/src/client/vite.config.ts
··· 13 13 outDir: '../../dist/client', 14 14 emptyOutDir: false, 15 15 assetsDir: '__vitest_browser__', 16 + rollupOptions: { 17 + input: { 18 + main: resolve(__dirname, './index.html'), 19 + tester: resolve(__dirname, './tester.html'), 20 + }, 21 + }, 16 22 }, 17 23 plugins: [ 18 24 { ··· 33 39 if (fs.existsSync(browser)) 34 40 fs.rmSync(browser, { recursive: true }) 35 41 36 - fs.mkdirSync(browser) 42 + fs.mkdirSync(browser, { recursive: true }) 37 43 fs.mkdirSync(resolve(browser, 'assets')) 38 44 39 45 files.forEach((f) => {
+2 -2
packages/browser/src/node/esmInjector.ts
··· 6 6 7 7 const viInjectedKey = '__vi_inject__' 8 8 // const viImportMetaKey = '__vi_import_meta__' // to allow overwrite 9 - const viExportAllHelper = '__vi_export_all__' 9 + const viExportAllHelper = '__vitest_browser_runner__.exportAll' 10 10 11 11 const skipHijack = [ 12 12 '/@vite/client', ··· 230 230 // s.update(node.start, node.end, viImportMetaKey) 231 231 }, 232 232 onDynamicImport(node) { 233 - const replace = '__vi_wrap_module__(import(' 233 + const replace = '__vitest_browser_runner__.wrapModule(import(' 234 234 s.overwrite(node.start, (node.source as Positioned<Expression>).start, replace) 235 235 s.overwrite(node.end - 1, node.end, '))') 236 236 },
+82 -8
packages/browser/src/node/index.ts
··· 1 1 import { fileURLToPath } from 'node:url' 2 - 2 + import { readFile } from 'node:fs/promises' 3 3 import { basename, resolve } from 'pathe' 4 4 import sirv from 'sirv' 5 5 import type { Plugin } from 'vite' 6 + import type { ResolvedConfig } from 'vitest' 6 7 import type { WorkspaceProject } from 'vitest/node' 7 8 import { coverageConfigDefaults } from 'vitest/config' 8 9 import { injectVitestModule } from './esmInjector' 10 + 11 + function replacer(code: string, values: Record<string, string>) { 12 + return code.replace(/{\s*(\w+)\s*}/g, (_, key) => values[key] ?? '') 13 + } 9 14 10 15 export default (project: WorkspaceProject, base = '/'): Plugin[] => { 11 16 const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..') ··· 23 28 } 24 29 }, 25 30 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 + }) 26 94 server.middlewares.use( 27 95 base, 28 96 sirv(resolve(distRoot, 'client'), { 29 97 single: false, 30 98 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 - }, 38 99 }), 39 100 ) 40 101 ··· 69 130 optimizeDeps: { 70 131 entries: [ 71 132 ...entries, 133 + 'vitest', 72 134 'vitest/utils', 73 135 'vitest/browser', 74 136 'vitest/runners', 137 + '@vitest/utils', 75 138 ], 76 139 exclude: [ 77 140 'vitest', ··· 157 220 return [root, `/${basename(root)}/`] 158 221 159 222 return [resolve(root, subdir), `/${basename(root)}/${subdir}/`] 223 + } 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 + } 160 234 }
+20 -23
packages/vitest/src/api/setup.ts
··· 11 11 import type { StackTraceParserOptions } from '@vitest/utils/source-map' 12 12 import { API_PATH } from '../constants' 13 13 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' 15 15 import { getModuleGraph, isPrimitive, stringifyReplace } from '../utils' 16 - import { WorkspaceProject } from '../node/workspace' 16 + import type { WorkspaceProject } from '../node/workspace' 17 17 import { parseErrorStacktrace } from '../utils/source-map' 18 18 import type { TransformResultWithSource, WebSocketEvents, WebSocketHandlers } from './types' 19 19 ··· 50 50 { 51 51 async onUnhandledError(error, type) { 52 52 ctx.state.catchError(error, type) 53 - }, 54 - async onDone(testId) { 55 - return ctx.state.browserTestPromises.get(testId)?.resolve(true) 56 53 }, 57 54 async onCollected(files) { 58 55 ctx.state.collectFiles(files) ··· 114 111 await ctx.rerunFiles(files) 115 112 }, 116 113 getConfig() { 117 - if (vitestOrWorkspace instanceof WorkspaceProject) 118 - return wrapConfig(vitestOrWorkspace.getSerializableConfig()) 119 - 120 114 return vitestOrWorkspace.config 121 115 }, 122 116 async getTransformResult(id) { ··· 140 134 onCancel(reason) { 141 135 ctx.cancelCurrentRun(reason) 142 136 }, 137 + debug(...args) { 138 + ctx.logger.console.debug(...args) 139 + }, 143 140 getCountOfFailedTests() { 144 141 return ctx.state.getCountOfFailedTests() 145 142 }, 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 - }, 150 143 getUnhandledErrors() { 151 144 return ctx.state.getUnhandledErrors() 145 + }, 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) 152 160 }, 153 161 }, 154 162 { ··· 222 230 this.clients.forEach((client) => { 223 231 client.onUserConsoleLog?.(log) 224 232 }) 225 - } 226 - } 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 233 } 237 234 }
+4 -1
packages/vitest/src/api/types.ts
··· 11 11 onCollected(files?: File[]): Promise<void> 12 12 onTaskUpdate(packs: TaskResultPack[]): void 13 13 onAfterSuiteRun(meta: AfterSuiteRunMeta): void 14 - onDone(name: string): void 15 14 onCancel(reason: CancelReason): void 16 15 getCountOfFailedTests(): number 17 16 sendLog(log: UserConsoleLog): void ··· 32 31 updateSnapshot(file?: File): Promise<void> 33 32 getProvidedContext(): ProvidedContext 34 33 getUnhandledErrors(): unknown[] 34 + 35 + finishBrowserTests(): void 36 + getBrowserFiles(): string[] 37 + debug(...args: string[]): void 35 38 } 36 39 37 40 export interface WebSocketEvents extends Pick<Reporter, 'onCollected' | 'onFinished' | 'onTaskUpdate' | 'onUserConsoleLog' | 'onPathsCollected'> {
-1
packages/vitest/src/node/state.ts
··· 17 17 export class StateManager { 18 18 filesMap = new Map<string, File[]>() 19 19 pathsSet: Set<string> = new Set() 20 - browserTestPromises = new Map<string, { resolve: (v: unknown) => void; reject: (v: unknown) => void }>() 21 20 idMap = new Map<string, Task>() 22 21 taskFileMap = new WeakMap<Task, File>() 23 22 errorsSet = new Set<unknown>()
+6
packages/vitest/src/node/workspace.ts
··· 70 70 closingPromise: Promise<unknown> | undefined 71 71 browserProvider: BrowserProvider | undefined 72 72 73 + browserState: { 74 + files: string[] 75 + resolve(): void 76 + reject(v: unknown): void 77 + } | undefined 78 + 73 79 testFilesList: string[] | null = null 74 80 75 81 private _globalSetups: GlobalSetupFile[] | undefined
+7 -1
packages/vitest/src/types/browser.ts
··· 12 12 getSupportedBrowsers(): readonly string[] 13 13 initialize(ctx: WorkspaceProject, options: BrowserProviderInitializationOptions): Awaitable<void> 14 14 openPage(url: string): Awaitable<void> 15 - catchError(cb: (error: Error) => Awaitable<void>): () => Awaitable<void> 16 15 close(): Awaitable<void> 17 16 } 18 17 ··· 83 82 * @default true 84 83 */ 85 84 isolate?: boolean 85 + 86 + /** 87 + * Run test files in parallel. Fallbacks to `test.fileParallelism`. 88 + * 89 + * @default test.fileParallelism 90 + */ 91 + fileParallelism?: boolean 86 92 } 87 93 88 94 export interface ResolvedBrowserOptions extends BrowserConfigOptions {
+61
packages/browser/src/client/public/esm-client-injector.js
··· 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 + }
-5
packages/browser/src/node/providers/none.ts
··· 1 - import type { Awaitable } from 'vitest' 2 1 import type { BrowserProvider, WorkspaceProject } from 'vitest/node' 3 2 4 3 export class NoneBrowserProvider implements BrowserProvider { ··· 20 19 this.open = false 21 20 if (ctx.config.browser.headless) 22 21 throw new Error('You\'ve enabled headless mode for "none" provider but it doesn\'t support it.') 23 - } 24 - 25 - catchError(_cb: (error: Error) => Awaitable<void>) { 26 - return () => {} 27 22 } 28 23 29 24 async openPage(_url: string) {
-13
packages/browser/src/node/providers/playwright.ts
··· 1 1 import type { Browser, LaunchOptions, Page } from 'playwright' 2 2 import type { BrowserProvider, BrowserProviderInitializationOptions, WorkspaceProject } from 'vitest/node' 3 3 4 - type Awaitable<T> = T | PromiseLike<T> 5 - 6 4 export const playwrightBrowsers = ['firefox', 'webkit', 'chromium'] as const 7 5 export type PlaywrightBrowser = typeof playwrightBrowsers[number] 8 6 ··· 48 46 this.cachedBrowser = browser 49 47 this.cachedPage = await browser.newPage(this.options?.page) 50 48 51 - this.cachedPage.on('close', () => { 52 - browser.close() 53 - }) 54 - 55 49 return this.cachedPage 56 - } 57 - 58 - catchError(cb: (error: Error) => Awaitable<void>) { 59 - this.cachedPage?.on('pageerror', cb) 60 - return () => { 61 - this.cachedPage?.off('pageerror', cb) 62 - } 63 50 } 64 51 65 52 async openPage(url: string) {
-7
packages/browser/src/node/providers/webdriver.ts
··· 1 1 import type { BrowserProvider, BrowserProviderInitializationOptions, WorkspaceProject } from 'vitest/node' 2 2 import type { RemoteOptions } from 'webdriverio' 3 3 4 - type Awaitable<T> = T | PromiseLike<T> 5 - 6 4 const webdriverBrowsers = ['firefox', 'chrome', 'edge', 'safari'] as const 7 5 type WebdriverBrowser = typeof webdriverBrowsers[number] 8 6 ··· 79 77 async openPage(url: string) { 80 78 const browserInstance = await this.openBrowser() 81 79 await browserInstance.url(url) 82 - } 83 - 84 - // TODO 85 - catchError(_cb: (error: Error) => Awaitable<void>) { 86 - return () => {} 87 80 } 88 81 89 82 async close() {
+22 -43
packages/vitest/src/node/pools/browser.ts
··· 1 1 import { createDefer } from '@vitest/utils' 2 - import { relative } from 'pathe' 3 2 import type { Vitest } from '../core' 4 3 import type { ProcessPool } from '../pool' 5 4 import type { WorkspaceProject } from '../workspace' ··· 8 7 export function createBrowserPool(ctx: Vitest): ProcessPool { 9 8 const providers = new Set<BrowserProvider>() 10 9 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 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, 24 20 } 25 - finally { 26 - off() 27 - } 21 + return await defer 28 22 } 29 23 30 24 const runTests = async (project: WorkspaceProject, files: string[]) => { 31 25 ctx.state.clearFiles(project, files) 32 26 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 + // }) 37 32 38 33 const provider = project.browserProvider! 39 34 providers.add(provider) 40 35 41 36 const resolvedUrls = project.browser?.resolvedUrls 42 37 const origin = resolvedUrls?.local[0] ?? resolvedUrls?.network[0] 43 - const paths = files.map(file => relative(project.config.root, file)) 44 38 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}"`) 51 41 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 66 46 } 67 47 68 48 const runWorkspaceTests = async (specs: [WorkspaceProject, string][]) => { ··· 80 60 return { 81 61 name: 'browser', 82 62 async close() { 83 - ctx.state.browserTestPromises.clear() 84 63 await Promise.all([...providers].map(provider => provider.close())) 85 64 providers.clear() 86 65 },