[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.

perf(browser): improve browser parallelisation (#7665)

authored by

Vladimir and committed by
GitHub
(Apr 17, 2025, 1:22 PM +0200) 816a5c51 c3374808

+892 -681
+11 -6
pnpm-lock.yaml
··· 55 55 specifier: ^8.3.4 56 56 version: 8.3.4 57 57 birpc: 58 - specifier: 0.2.19 59 - version: 0.2.19 58 + specifier: 2.3.0 59 + version: 2.3.0 60 60 cac: 61 61 specifier: ^6.7.14 62 62 version: 6.7.14 ··· 492 492 version: 9.12.3 493 493 birpc: 494 494 specifier: 'catalog:' 495 - version: 0.2.19 495 + version: 2.3.0 496 496 flatted: 497 497 specifier: 'catalog:' 498 498 version: 3.3.3 ··· 841 841 version: 0.7.2 842 842 birpc: 843 843 specifier: 'catalog:' 844 - version: 0.2.19 844 + version: 2.3.0 845 845 codemirror: 846 846 specifier: ^5.65.18 847 847 version: 5.65.18 ··· 1051 1051 version: 8.3.4 1052 1052 birpc: 1053 1053 specifier: 'catalog:' 1054 - version: 0.2.19 1054 + version: 2.3.0 1055 1055 cac: 1056 1056 specifier: 'catalog:' 1057 1057 version: 6.7.14(patch_hash=a8f0f3517a47ce716ed90c0cfe6ae382ab763b021a664ada2a608477d0621588) ··· 1118 1118 dependencies: 1119 1119 birpc: 1120 1120 specifier: 'catalog:' 1121 - version: 0.2.19 1121 + version: 2.3.0 1122 1122 flatted: 1123 1123 specifier: 'catalog:' 1124 1124 version: 3.3.3 ··· 4944 4944 4945 4945 birpc@0.2.19: 4946 4946 resolution: {integrity: sha512-5WeXXAvTmitV1RqJFppT5QtUiz2p1mRSYU000Jkft5ZUCLJIk4uQriYNO50HknxKwM6jd8utNc66K1qGIwwWBQ==} 4947 + 4948 + birpc@2.3.0: 4949 + resolution: {integrity: sha512-ijbtkn/F3Pvzb6jHypHRyve2QApOCZDR25D/VnkY2G/lBNcXCTsnsCxgY4k4PkVB7zfwzYbY3O9Lcqe3xufS5g==} 4947 4950 4948 4951 bl@4.1.0: 4949 4952 resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} ··· 12917 12920 binary-extensions@2.2.0: {} 12918 12921 12919 12922 birpc@0.2.19: {} 12923 + 12924 + birpc@2.3.0: {} 12920 12925 12921 12926 bl@4.1.0: 12922 12927 dependencies:
+1 -1
pnpm-workspace.yaml
··· 21 21 '@vitejs/plugin-vue': ^5.2.3 22 22 '@vueuse/core': ^12.8.2 23 23 acorn-walk: ^8.3.4 24 - birpc: 0.2.19 24 + birpc: 2.3.0 25 25 cac: ^6.7.14 26 26 chai: ^5.2.0 27 27 debug: ^4.4.0
+20 -5
test/browser/setup.unit.ts
··· 1 + import type { BrowserInstanceOption } from 'vitest/node' 1 2 import { expect } from 'vitest' 2 3 3 4 interface SummaryOptions { ··· 5 6 } 6 7 7 8 expect.extend({ 8 - toReportPassedTest(stdout: string, testName: string, testProject?: string) { 9 - const includePattern = `✓ ${testProject ? `|${testProject}| ` : ''}${testName}` 10 - const pass = stdout.includes(`✓ ${testProject ? `|${testProject}| ` : ''}${testName}`) 9 + toReportPassedTest(stdout: string, testName: string, testProject?: string | BrowserInstanceOption[]) { 10 + const checks: BrowserInstanceOption[] | undefined = Array.isArray(testProject) 11 + ? testProject 12 + : (testProject && [{ browser: testProject }]) 13 + 14 + const pass = checks?.length 15 + ? checks.every(({ browser }) => { 16 + const includePattern = `✓ |${browser}| ${testName}` 17 + return stdout.includes(includePattern) 18 + }) 19 + : stdout.includes(`✓ ${testName}`) 20 + 11 21 return { 12 22 pass, 13 - message: () => `expected ${pass ? 'not ' : ''}to have "${includePattern}" in the report.\n\nstdout:\n${stdout}`, 23 + message: () => { 24 + const includePattern = checks?.length 25 + ? checks.map(check => `✓ |${check}| ${testName}`).join('\n') 26 + : `✓ ${testName}` 27 + return `expected ${pass ? 'not ' : ''}to have "${includePattern}" in the report.\n\nstdout:\n${stdout}` 28 + }, 14 29 } 15 30 }, 16 31 toReportSummaryTestFiles(stdout: string, { passed }: SummaryOptions) { ··· 42 57 // eslint-disable-next-line unused-imports/no-unused-vars 43 58 interface Assertion<T = any> { 44 59 // eslint-disable-next-line ts/method-signature-style 45 - toReportPassedTest(testName: string, testProject?: string): void 60 + toReportPassedTest(testName: string, testProject?: string | BrowserInstanceOption[]): void 46 61 // eslint-disable-next-line ts/method-signature-style 47 62 toReportSummaryTestFiles(options: SummaryOptions): void 48 63 // eslint-disable-next-line ts/method-signature-style
+1 -1
test/browser/vitest.config.mts
··· 58 58 ? playwrightInstances 59 59 : webdriverioInstances, 60 60 provider, 61 - isolate: false, 61 + // isolate: false, 62 62 testerScripts: [ 63 63 { 64 64 content: 'globalThis.__injected = []',
+25 -23
packages/expect/src/jest-expect.ts
··· 1206 1206 } 1207 1207 1208 1208 function formatCalls(spy: MockInstance, msg: string, showActualCall?: any) { 1209 - if (spy.mock.calls) { 1209 + if (spy.mock.calls.length) { 1210 1210 msg += c.gray( 1211 1211 `\n\nReceived: \n\n${spy.mock.calls 1212 1212 .map((callArg, i) => { ··· 1243 1243 msg: string, 1244 1244 showActualReturn?: any, 1245 1245 ) { 1246 - msg += c.gray( 1247 - `\n\nReceived: \n\n${results 1248 - .map((callReturn, i) => { 1249 - let methodCall = c.bold( 1250 - ` ${ordinalOf(i + 1)} ${spy.getMockName()} call return:\n\n`, 1251 - ) 1252 - if (showActualReturn) { 1253 - methodCall += diff(showActualReturn, callReturn.value, { 1254 - omitAnnotationLines: true, 1255 - }) 1256 - } 1257 - else { 1258 - methodCall += stringify(callReturn) 1259 - .split('\n') 1260 - .map(line => ` ${line}`) 1261 - .join('\n') 1262 - } 1246 + if (results.length) { 1247 + msg += c.gray( 1248 + `\n\nReceived: \n\n${results 1249 + .map((callReturn, i) => { 1250 + let methodCall = c.bold( 1251 + ` ${ordinalOf(i + 1)} ${spy.getMockName()} call return:\n\n`, 1252 + ) 1253 + if (showActualReturn) { 1254 + methodCall += diff(showActualReturn, callReturn.value, { 1255 + omitAnnotationLines: true, 1256 + }) 1257 + } 1258 + else { 1259 + methodCall += stringify(callReturn) 1260 + .split('\n') 1261 + .map(line => ` ${line}`) 1262 + .join('\n') 1263 + } 1263 1264 1264 - methodCall += '\n' 1265 - return methodCall 1266 - }) 1267 - .join('\n')}`, 1268 - ) 1265 + methodCall += '\n' 1266 + return methodCall 1267 + }) 1268 + .join('\n')}`, 1269 + ) 1270 + } 1269 1271 msg += c.gray( 1270 1272 `\n\nNumber of calls: ${c.bold(spy.mock.calls.length)}\n`, 1271 1273 )
+3 -5
test/browser/specs/browser-crash.test.ts
··· 1 1 import { expect, test } from 'vitest' 2 - import { instances, provider, runBrowserTests } from './utils' 2 + import { instances, runBrowserTests } from './utils' 3 3 4 - // TODO handle webdriverio. Currently they 5 - // expose no trustable way to detect browser crashes. 6 - test.runIf(provider === 'playwright')('fails gracefully when browser crashes', async () => { 4 + test('fails gracefully when browser crashes', async () => { 7 5 const { stderr } = await runBrowserTests({ 8 6 root: './fixtures/browser-crash', 9 7 reporters: [['verbose', { isTTY: false }]], ··· 13 11 }, 14 12 }) 15 13 16 - expect(stderr).toContain('Page crashed when executing tests') 14 + expect(stderr).toContain('Browser connection was closed while running tests. Was the page closed unexpectedly?') 17 15 })
+7 -4
test/browser/specs/setup-file.test.ts
··· 8 8 { 9 9 root: './fixtures/setup-file', 10 10 }, 11 + undefined, 12 + {}, 13 + { 14 + // TODO 2025-03-26 remove after debugging 15 + std: 'inherit', 16 + }, 11 17 ) 12 18 13 19 expect(stderr).toReportNoErrors() 14 - 15 - instances.forEach(({ browser }) => { 16 - expect(stdout).toReportPassedTest('module-equality.test.ts', browser) 17 - }) 20 + expect(stdout).toReportPassedTest('module-equality.test.ts', instances) 18 21 })
+3 -1
test/browser/specs/utils.ts
··· 1 1 import type { UserConfig as ViteUserConfig } from 'vite' 2 2 import type { UserConfig } from 'vitest/node' 3 + import type { VitestRunnerCLIOptions } from '../../test-utils' 3 4 import { runVitest } from '../../test-utils' 4 5 import { browser } from '../settings' 5 6 ··· 9 10 config?: Omit<UserConfig, 'browser'> & { browser?: Partial<UserConfig['browser']> }, 10 11 include?: string[], 11 12 viteOverrides?: Partial<ViteUserConfig>, 13 + runnerOptions?: VitestRunnerCLIOptions, 12 14 ) { 13 15 return runVitest({ 14 16 watch: false, ··· 18 20 headless: browser !== 'safari', 19 21 ...config?.browser, 20 22 } as UserConfig['browser'], 21 - }, include, 'test', viteOverrides) 23 + }, include, 'test', viteOverrides, runnerOptions) 22 24 }
+2 -1
test/browser/test/cdp.test.ts
··· 7 7 it('cdp sends events correctly', async () => { 8 8 const messageAdded = vi.fn() 9 9 10 + await cdp().send('Console.enable') 11 + 10 12 cdp().on('Console.messageAdded', messageAdded) 11 13 12 - await cdp().send('Console.enable') 13 14 onTestFinished(async () => { 14 15 await cdp().send('Console.disable') 15 16 })
+37 -33
packages/browser/src/client/channel.ts
··· 1 1 import type { CancelReason } from '@vitest/runner' 2 2 import { getBrowserState } from './utils' 3 3 4 - export interface IframeDoneEvent { 5 - type: 'done' 6 - filenames: string[] 7 - id: string 8 - } 9 - 10 - export interface IframeErrorEvent { 11 - type: 'error' 12 - error: any 13 - errorType: string 14 - files: string[] 15 - id: string 16 - } 17 - 18 4 export interface IframeViewportEvent { 19 - type: 'viewport' 5 + event: 'viewport' 20 6 width: number 21 7 height: number 22 - id: string 8 + iframeId: string 9 + } 10 + 11 + export interface IframeViewportFailEvent { 12 + event: 'viewport:fail' 13 + iframeId: string 14 + error: string 15 + } 16 + 17 + export interface IframeViewportDoneEvent { 18 + event: 'viewport:done' 19 + iframeId: string 23 20 } 24 21 25 22 export interface GlobalChannelTestRunCanceledEvent { ··· 27 24 reason: CancelReason 28 25 } 29 26 27 + export interface IframeExecuteEvent { 28 + event: 'execute' 29 + method: 'run' | 'collect' 30 + files: string[] 31 + iframeId: string 32 + context: string 33 + } 34 + 35 + export interface IframeCleanupEvent { 36 + event: 'cleanup' 37 + iframeId: string 38 + } 39 + 40 + export interface IframePrepareEvent { 41 + event: 'prepare' 42 + iframeId: string 43 + } 44 + 30 45 export type GlobalChannelIncomingEvent = GlobalChannelTestRunCanceledEvent 31 46 32 47 export type IframeChannelIncomingEvent = 33 48 | IframeViewportEvent 34 - | IframeErrorEvent 35 - | IframeDoneEvent 36 49 37 - export type IframeChannelOutgoingEvent = never 50 + export type IframeChannelOutgoingEvent = 51 + | IframeExecuteEvent 52 + | IframeCleanupEvent 53 + | IframePrepareEvent 54 + | IframeViewportFailEvent 55 + | IframeViewportDoneEvent 38 56 39 57 export type IframeChannelEvent = 40 58 | IframeChannelIncomingEvent ··· 44 62 `vitest:${getBrowserState().sessionId}`, 45 63 ) 46 64 export const globalChannel: BroadcastChannel = new BroadcastChannel('vitest:global') 47 - 48 - export function waitForChannel(event: IframeChannelOutgoingEvent['type']): Promise<void> { 49 - return new Promise<void>((resolve) => { 50 - channel.addEventListener( 51 - 'message', 52 - (e) => { 53 - if (e.data?.type === event) { 54 - resolve() 55 - } 56 - }, 57 - { once: true }, 58 - ) 59 - }) 60 - }
+13 -4
packages/browser/src/client/client.ts
··· 53 53 ctx.rpc = createBirpc<WebSocketBrowserHandlers, WebSocketBrowserEvents>( 54 54 { 55 55 onCancel: setCancel, 56 - async createTesters(files: string[]) { 57 - if (PAGE_TYPE !== 'orchestrator') { 58 - return 56 + async createTesters(options) { 57 + const orchestrator = getBrowserState().orchestrator 58 + if (!orchestrator) { 59 + throw new TypeError('Only orchestrator can create testers.') 59 60 } 60 - getBrowserState().createTesters?.(files) 61 + return orchestrator.createTesters(options) 62 + }, 63 + async cleanupTesters() { 64 + const orchestrator = getBrowserState().orchestrator 65 + if (!orchestrator) { 66 + throw new TypeError('Only orchestrator can cleanup testers.') 67 + } 68 + return orchestrator.cleanupTesters() 61 69 }, 62 70 cdpEvent(event: string, payload: unknown) { 63 71 const cdp = getBrowserState().cdp ··· 85 93 { 86 94 post: msg => ctx.ws.send(msg), 87 95 on: fn => (onMessage = fn), 96 + timeout: -1, // createTesters can take a while 88 97 serialize: e => 89 98 stringify(e, (_, v) => { 90 99 if (v instanceof Error) {
+180 -134
packages/browser/src/client/orchestrator.ts
··· 1 - import type { GlobalChannelIncomingEvent, IframeChannelEvent, IframeChannelIncomingEvent } from '@vitest/browser/client' 2 - import type { SerializedConfig } from 'vitest' 1 + import type { GlobalChannelIncomingEvent, IframeChannelIncomingEvent, IframeChannelOutgoingEvent, IframeViewportDoneEvent, IframeViewportFailEvent } from '@vitest/browser/client' 2 + import type { BrowserTesterOptions, SerializedConfig } from 'vitest' 3 3 import { channel, client, globalChannel } from '@vitest/browser/client' 4 4 import { generateHash } from '@vitest/runner/utils' 5 5 import { relative } from 'pathe' ··· 9 9 const url = new URL(location.href) 10 10 const ID_ALL = '__vitest_all__' 11 11 12 - class IframeOrchestrator { 12 + export class IframeOrchestrator { 13 13 private cancelled = false 14 - private runningFiles = new Set<string>() 14 + private recreateNonIsolatedIframe = false 15 15 private iframes = new Map<string, HTMLIFrameElement>() 16 16 17 - public async init(testFiles: string[]) { 18 - debug('test files', testFiles.join(', ')) 19 - 20 - this.runningFiles.clear() 21 - testFiles.forEach(file => this.runningFiles.add(file)) 17 + constructor() { 18 + debug('init orchestrator', getBrowserState().sessionId) 22 19 23 20 channel.addEventListener( 24 21 'message', ··· 30 27 ) 31 28 } 32 29 33 - public async createTesters(testFiles: string[]) { 30 + public async createTesters(options: BrowserTesterOptions): Promise<void> { 34 31 this.cancelled = false 35 - this.runningFiles.clear() 36 - testFiles.forEach(file => this.runningFiles.add(file)) 37 32 38 33 const config = getConfig() 39 - debug('create testers', testFiles.join(', ')) 34 + debug('create testers', options.files.join(', ')) 40 35 const container = await getContainer(config) 41 36 42 37 if (config.browser.ui) { 43 38 container.className = 'absolute origin-top mt-[8px]' 44 39 container.parentElement!.setAttribute('data-ready', 'true') 45 - container.textContent = '' 40 + // in non-isolated mode this will also remove the iframe, 41 + // so we only do this once 42 + if (container.textContent) { 43 + container.textContent = '' 44 + } 46 45 } 47 - const { width, height } = config.browser.viewport 46 + 47 + if (config.browser.isolate === false) { 48 + await this.runNonIsolatedTests(container, options) 49 + return 50 + } 48 51 49 52 this.iframes.forEach(iframe => iframe.remove()) 50 53 this.iframes.clear() 51 54 52 - if (config.browser.isolate === false) { 53 - debug('create iframe', ID_ALL) 54 - const iframe = this.createIframe(container, ID_ALL) 55 - 56 - await setIframeViewport(iframe, width, height) 57 - return 58 - } 59 - 60 - for (const file of testFiles) { 55 + for (let i = 0; i < options.files.length; i++) { 61 56 if (this.cancelled) { 62 - done() 63 57 return 64 58 } 65 59 60 + const file = options.files[i] 66 61 debug('create iframe', file) 67 - const iframe = this.createIframe(container, file) 68 62 69 - await setIframeViewport(iframe, width, height) 70 - 71 - await new Promise<void>((resolve) => { 72 - channel.addEventListener( 73 - 'message', 74 - function handler(e: MessageEvent<IframeChannelEvent>) { 75 - // done and error can only be triggered by the previous iframe 76 - if (e.data.type === 'done' || e.data.type === 'error') { 77 - channel.removeEventListener('message', handler) 78 - resolve() 79 - } 80 - }, 81 - ) 82 - }) 63 + await this.runIsolatedTestInIframe( 64 + container, 65 + file, 66 + options, 67 + ) 83 68 } 84 69 } 85 70 86 - private createIframe(container: HTMLDivElement, file: string) { 71 + public async cleanupTesters(): Promise<void> { 72 + const config = getConfig() 73 + if (config.browser.isolate) { 74 + // isolated mode assignes filepaths as ids 75 + const files = Array.from(this.iframes.keys()) 76 + // when the run is completed, show the last file in the UI 77 + const ui = getUiAPI() 78 + if (ui && files[0]) { 79 + const id = generateFileId(files[0]) 80 + ui.setCurrentFileId(id) 81 + } 82 + return 83 + } 84 + // we only cleanup non-isolated iframe because 85 + // in isolated mode every iframe is cleaned up after the test 86 + const iframe = this.iframes.get(ID_ALL) 87 + if (!iframe) { 88 + return 89 + } 90 + await sendEventToIframe({ 91 + event: 'cleanup', 92 + iframeId: ID_ALL, 93 + }) 94 + this.recreateNonIsolatedIframe = true 95 + } 96 + 97 + private async runNonIsolatedTests(container: HTMLDivElement, options: BrowserTesterOptions) { 98 + if (this.recreateNonIsolatedIframe) { 99 + // recreate a new non-isolated iframe during watcher reruns 100 + // because we called "cleanup" in the previous run 101 + // the iframe is not removed immediately to let the user see the last test 102 + this.recreateNonIsolatedIframe = false 103 + this.iframes.get(ID_ALL)!.remove() 104 + this.iframes.delete(ID_ALL) 105 + debug('recreate non-isolated iframe') 106 + } 107 + 108 + if (!this.iframes.has(ID_ALL)) { 109 + debug('preparing non-isolated iframe') 110 + await this.prepareIframe(container, ID_ALL) 111 + } 112 + 113 + const config = getConfig() 114 + const { width, height } = config.browser.viewport 115 + const iframe = this.iframes.get(ID_ALL)! 116 + 117 + await setIframeViewport(iframe, width, height) 118 + debug('run non-isolated tests', options.files.join(', ')) 119 + await sendEventToIframe({ 120 + event: 'execute', 121 + iframeId: ID_ALL, 122 + files: options.files, 123 + method: options.method, 124 + context: options.providedContext, 125 + }) 126 + // we don't cleanup here because in non-isolated mode 127 + // it is done after all tests finished running 128 + } 129 + 130 + private async runIsolatedTestInIframe( 131 + container: HTMLDivElement, 132 + file: string, 133 + options: BrowserTesterOptions, 134 + ) { 135 + const config = getConfig() 136 + const { width, height } = config.browser.viewport 137 + 87 138 if (this.iframes.has(file)) { 88 139 this.iframes.get(file)!.remove() 89 140 this.iframes.delete(file) 90 141 } 91 142 143 + const iframe = await this.prepareIframe(container, file) 144 + await setIframeViewport(iframe, width, height) 145 + // running tests after the "prepare" event 146 + await sendEventToIframe({ 147 + event: 'execute', 148 + files: [file], 149 + method: options.method, 150 + iframeId: file, 151 + context: options.providedContext, 152 + }) 153 + // perform "cleanup" to cleanup resources and calculate the coverage 154 + await sendEventToIframe({ 155 + event: 'cleanup', 156 + iframeId: file, 157 + }) 158 + } 159 + 160 + private async prepareIframe(container: HTMLDivElement, iframeId: string) { 161 + const iframe = this.createTestIframe(iframeId) 162 + container.appendChild(iframe) 163 + 164 + await new Promise<void>((resolve, reject) => { 165 + iframe.onload = () => { 166 + this.iframes.set(iframeId, iframe) 167 + sendEventToIframe({ 168 + event: 'prepare', 169 + iframeId, 170 + }).then(resolve, reject) 171 + } 172 + iframe.onerror = (e) => { 173 + if (typeof e === 'string') { 174 + reject(new Error(e)) 175 + } 176 + else if (e instanceof ErrorEvent) { 177 + reject(e.error) 178 + } 179 + else { 180 + reject(new Error(`Cannot load the iframe ${iframeId}.`)) 181 + } 182 + } 183 + }) 184 + return iframe 185 + } 186 + 187 + private createTestIframe(iframeId: string) { 92 188 const iframe = document.createElement('iframe') 189 + const src = `${url.pathname}__vitest_test__/__test__/?sessionId=${getBrowserState().sessionId}&iframeId=${iframeId}` 93 190 iframe.setAttribute('loading', 'eager') 94 - iframe.setAttribute( 95 - 'src', 96 - `${url.pathname}__vitest_test__/__test__/${ 97 - getBrowserState().sessionId 98 - }/${encodeURIComponent(file)}`, 99 - ) 191 + iframe.setAttribute('src', src) 100 192 iframe.setAttribute('data-vitest', 'true') 101 193 102 194 iframe.style.border = 'none' ··· 105 197 iframe.setAttribute('allowfullscreen', 'true') 106 198 iframe.setAttribute('allow', 'clipboard-write;') 107 199 iframe.setAttribute('name', 'vitest-iframe') 108 - 109 - this.iframes.set(file, iframe) 110 - container.appendChild(iframe) 111 200 return iframe 112 201 } 113 202 ··· 123 212 124 213 private async onIframeEvent(e: MessageEvent<IframeChannelIncomingEvent>) { 125 214 debug('iframe event', JSON.stringify(e.data)) 126 - switch (e.data.type) { 215 + switch (e.data.event) { 127 216 case 'viewport': { 128 - const { width, height, id } = e.data 217 + const { width, height, iframeId: id } = e.data 129 218 const iframe = this.iframes.get(id) 130 219 if (!iframe) { 131 - const error = new Error(`Cannot find iframe with id ${id}`) 220 + const error = `Cannot find iframe with id ${id}` 132 221 channel.postMessage({ 133 - type: 'viewport:fail', 134 - id, 135 - error: error.message, 136 - }) 222 + event: 'viewport:fail', 223 + iframeId: id, 224 + error, 225 + } satisfies IframeViewportFailEvent) 137 226 await client.rpc.onUnhandledError( 138 227 { 139 228 name: 'Teardown Error', 140 - message: error.message, 229 + message: error, 141 230 }, 142 231 'Teardown Error', 143 232 ) 144 - return 233 + break 145 234 } 146 235 await setIframeViewport(iframe, width, height) 147 - channel.postMessage({ type: 'viewport:done', id }) 148 - break 149 - } 150 - case 'done': { 151 - const filenames = e.data.filenames 152 - filenames.forEach(filename => this.runningFiles.delete(filename)) 153 - 154 - if (!this.runningFiles.size) { 155 - const ui = getUiAPI() 156 - // in isolated mode we don't change UI because it will slow down tests, 157 - // so we only select it when the run is done 158 - if (ui && filenames.length > 1) { 159 - const id = generateFileId(filenames[filenames.length - 1]) 160 - ui.setCurrentFileId(id) 161 - } 162 - await done() 163 - } 164 - else { 165 - // keep the last iframe 166 - const iframeId = e.data.id 167 - this.iframes.get(iframeId)?.remove() 168 - this.iframes.delete(iframeId) 169 - } 170 - break 171 - } 172 - // error happened at the top level, this should never happen in user code, but it can trigger during development 173 - case 'error': { 174 - const iframeId = e.data.id 175 - this.iframes.delete(iframeId) 176 - await client.rpc.onUnhandledError(e.data.error, e.data.errorType) 177 - if (iframeId === ID_ALL) { 178 - this.runningFiles.clear() 179 - } 180 - else { 181 - this.runningFiles.delete(iframeId) 182 - } 183 - if (!this.runningFiles.size) { 184 - await done() 185 - } 236 + channel.postMessage({ event: 'viewport:done', iframeId: id } satisfies IframeViewportDoneEvent) 186 237 break 187 238 } 188 239 default: { 189 - e.data satisfies never 240 + // ignore responses 241 + if ( 242 + typeof e.data.event === 'string' 243 + && (e.data.event as string).startsWith('response:') 244 + ) { 245 + break 246 + } 190 247 191 - await client.rpc.onUnhandledError( 192 - { 193 - name: 'Unexpected Event', 194 - message: `Unexpected event: ${(e.data as any).type}`, 195 - }, 196 - 'Unexpected Event', 197 - ) 198 - await done() 248 + await client.rpc.onUnhandledError( 249 + { 250 + name: 'Unexpected Event', 251 + message: `Unexpected event: ${(e.data as any).event}`, 252 + }, 253 + 'Unexpected Event', 254 + ) 199 255 } 200 256 } 201 257 } 202 258 } 203 259 204 - const orchestrator = new IframeOrchestrator() 205 - 206 - let promiseTesters: Promise<void> | undefined 207 - getBrowserState().createTesters = async (files) => { 208 - await promiseTesters 209 - promiseTesters = orchestrator.createTesters(files).finally(() => { 210 - promiseTesters = undefined 211 - }) 212 - await promiseTesters 213 - } 214 - 215 - async function done() { 216 - await client.rpc.finishBrowserTests(getBrowserState().sessionId) 217 - } 260 + getBrowserState().orchestrator = new IframeOrchestrator() 218 261 219 262 async function getContainer(config: SerializedConfig): Promise<HTMLDivElement> { 220 263 if (config.browser.ui) { 221 264 const element = document.querySelector('#tester-ui') 222 265 if (!element) { 223 266 return new Promise<HTMLDivElement>((resolve) => { 224 - setTimeout(() => { 267 + queueMicrotask(() => { 225 268 resolve(getContainer(config)) 226 - }, 30) 269 + }) 227 270 }) 228 271 } 229 272 return element as HTMLDivElement ··· 231 274 return document.querySelector('#vitest-tester') as HTMLDivElement 232 275 } 233 276 234 - client.waitForConnection().then(async () => { 235 - const testFiles = getBrowserState().files 236 - 237 - await orchestrator.init(testFiles) 238 - 239 - // if page was refreshed, there will be no test files 240 - // createTesters will be called again when tests are running in the UI 241 - if (testFiles.length) { 242 - await orchestrator.createTesters(testFiles) 243 - } 244 - }) 277 + async function sendEventToIframe(event: IframeChannelOutgoingEvent) { 278 + channel.postMessage(event) 279 + return new Promise<void>((resolve) => { 280 + channel.addEventListener( 281 + 'message', 282 + function handler(e) { 283 + if (e.data.iframeId === event.iframeId && e.data.event === `response:${event.event}`) { 284 + resolve() 285 + channel.removeEventListener('message', handler) 286 + } 287 + }, 288 + ) 289 + }) 290 + } 245 291 246 292 function generateFileId(file: string) { 247 293 const config = getConfig()
+2 -2
packages/browser/src/client/utils.ts
··· 1 1 import type { VitestRunner } from '@vitest/runner' 2 2 import type { SerializedConfig, WorkerGlobalState } from 'vitest' 3 + import type { IframeOrchestrator } from './orchestrator' 3 4 import type { CommandsManager } from './tester/utils' 4 5 5 6 export async function importId(id: string): Promise<any> { ··· 78 79 sessionId: string 79 80 testerId: string 80 81 method: 'run' | 'collect' 81 - runTests?: (tests: string[]) => Promise<void> 82 - createTesters?: (files: string[]) => Promise<void> 82 + orchestrator?: IframeOrchestrator 83 83 commands: CommandsManager 84 84 cdp?: { 85 85 on: (event: string, listener: (payload: any) => void) => void
-9
packages/browser/src/node/plugin.ts
··· 526 526 : null, 527 527 ...parentServer.testerScripts, 528 528 ...testerTags, 529 - { 530 - tag: 'script', 531 - attrs: { 532 - 'type': 'module', 533 - 'data-vitest-append': '', 534 - }, 535 - children: '{__VITEST_APPEND__}', 536 - injectTo: 'body', 537 - } as const, 538 529 ].filter(s => s != null) 539 530 }, 540 531 },
+249 -116
packages/browser/src/node/pool.ts
··· 1 - import type { BrowserProvider, ProcessPool, TestProject, TestSpecification, Vitest } from 'vitest/node' 1 + import type { DeferPromise } from '@vitest/utils' 2 + import type { 3 + BrowserProvider, 4 + ProcessPool, 5 + TestProject, 6 + TestSpecification, 7 + Vitest, 8 + } from 'vitest/node' 2 9 import crypto from 'node:crypto' 3 10 import * as nodeos from 'node:os' 4 - import { relative } from 'pathe' 11 + import { createDefer } from '@vitest/utils' 12 + import { stringify } from 'flatted' 5 13 import { createDebugger } from 'vitest/node' 6 14 7 15 const debug = createDebugger('vitest:browser:pool') 8 16 9 - async function waitForTests( 10 - method: 'run' | 'collect', 11 - sessionId: string, 12 - project: TestProject, 13 - files: string[], 14 - ) { 15 - const context = project.vitest._browserSessions.createAsyncSession(method, sessionId, files, project) 16 - return await context 17 - } 18 - 19 17 export function createBrowserPool(vitest: Vitest): ProcessPool { 20 18 const providers = new Set<BrowserProvider>() 21 19 22 - const executeTests = async (method: 'run' | 'collect', project: TestProject, files: string[]) => { 23 - vitest.state.clearFiles(project, files) 24 - const browser = project.browser! 20 + const numCpus 21 + = typeof nodeos.availableParallelism === 'function' 22 + ? nodeos.availableParallelism() 23 + : nodeos.cpus().length 25 24 26 - const threadsCount = getThreadsCount(project) 25 + const threadsCount = vitest.config.watch 26 + ? Math.max(Math.floor(numCpus / 2), 1) 27 + : Math.max(numCpus - 1, 1) 27 28 28 - const provider = browser.provider 29 - providers.add(provider) 29 + const projectPools = new WeakMap<TestProject, BrowserPool>() 30 30 31 - const resolvedUrls = browser.vite.resolvedUrls 31 + const ensurePool = (project: TestProject) => { 32 + if (projectPools.has(project)) { 33 + return projectPools.get(project)! 34 + } 35 + 36 + debug?.('creating pool for project %s', project.name) 37 + 38 + const resolvedUrls = project.browser!.vite.resolvedUrls 32 39 const origin = resolvedUrls?.local[0] ?? resolvedUrls?.network[0] 33 40 34 41 if (!origin) { 35 42 throw new Error( 36 - `Can't find browser origin URL for project "${project.name}" when running tests for files "${files.join('", "')}"`, 43 + `Can't find browser origin URL for project "${project.name}"`, 37 44 ) 38 45 } 39 46 40 - async function setBreakpoint(sessionId: string, file: string) { 41 - if (!project.config.inspector.waitForDebugger) { 42 - return 43 - } 44 - 45 - if (!provider.getCDPSession) { 46 - throw new Error('Unable to set breakpoint, CDP not supported') 47 - } 48 - 49 - const session = await provider.getCDPSession(sessionId) 50 - await session.send('Debugger.enable', {}) 51 - await session.send('Debugger.setBreakpointByUrl', { 52 - lineNumber: 0, 53 - urlRegex: escapePathToRegexp(file), 54 - }) 55 - } 56 - 57 - const filesPerThread = Math.ceil(files.length / threadsCount) 58 - 59 - // TODO: make it smarter, 60 - // Currently if we run 4/4/4/4 tests, and one of the chunks ends, 61 - // but there are pending tests in another chunks, we can't redistribute them 62 - const chunks: string[][] = [] 63 - for (let i = 0; i < files.length; i += filesPerThread) { 64 - const chunk = files.slice(i, i + filesPerThread) 65 - chunks.push(chunk) 66 - } 67 - 68 - debug?.( 69 - `[%s] Running %s tests in %s chunks (%s threads)`, 70 - project.name || 'core', 71 - files.length, 72 - chunks.length, 73 - threadsCount, 74 - ) 75 - 76 - const orchestrators = [...browser.state.orchestrators.entries()] 77 - 78 - const promises: Promise<void>[] = [] 79 - 80 - chunks.forEach((files, index) => { 81 - if (orchestrators[index]) { 82 - const [sessionId, orchestrator] = orchestrators[index] 83 - debug?.( 84 - 'Reusing orchestrator (session %s) for files: %s', 85 - sessionId, 86 - [...files.map(f => relative(project.config.root, f))].join(', '), 87 - ) 88 - const promise = waitForTests(method, sessionId, project, files) 89 - const tester = orchestrator.createTesters(files).catch((error) => { 90 - if (error instanceof Error && error.message.startsWith('[birpc] rpc is closed')) { 91 - return 92 - } 93 - return Promise.reject(error) 94 - }) 95 - promises.push(promise, tester) 96 - } 97 - else { 98 - const sessionId = crypto.randomUUID() 99 - const waitPromise = waitForTests(method, sessionId, project, files) 100 - debug?.( 101 - 'Opening a new session %s for files: %s', 102 - sessionId, 103 - [...files.map(f => relative(project.config.root, f))].join(', '), 104 - ) 105 - const url = new URL('/', origin) 106 - url.searchParams.set('sessionId', sessionId) 107 - const page = provider 108 - .openPage(sessionId, url.toString(), () => setBreakpoint(sessionId, files[0])) 109 - promises.push(page, waitPromise) 110 - } 47 + const pool: BrowserPool = new BrowserPool(project, { 48 + maxWorkers: getThreadsCount(project), 49 + origin, 50 + }) 51 + projectPools.set(project, pool) 52 + vitest.onCancel(() => { 53 + pool.cancel() 111 54 }) 112 55 113 - await Promise.all(promises) 56 + return pool 114 57 } 115 58 116 59 const runWorkspaceTests = async (method: 'run' | 'collect', specs: TestSpecification[]) => { ··· 126 69 isCancelled = true 127 70 }) 128 71 129 - // TODO: parallelize tests instead of running them sequentially (based on CPU?) 130 - for (const [project, files] of groupedFiles.entries()) { 131 - if (isCancelled) { 132 - break 133 - } 134 - await project._initBrowserProvider() 72 + // TODO: this might now be a good idea... should we run these in chunks? 73 + await Promise.all( 74 + [...groupedFiles.entries()].map(async ([project, files]) => { 75 + await project._initBrowserProvider() 135 76 136 - if (!project.browser) { 137 - throw new TypeError(`The browser server was not initialized${project.name ? ` for the "${project.name}" project` : ''}. This is a bug in Vitest. Please, open a new issue with reproduction.`) 138 - } 139 - await executeTests(method, project, files) 140 - } 77 + if (!project.browser) { 78 + throw new TypeError(`The browser server was not initialized${project.name ? ` for the "${project.name}" project` : ''}. This is a bug in Vitest. Please, open a new issue with reproduction.`) 79 + } 80 + 81 + if (isCancelled) { 82 + return 83 + } 84 + 85 + const pool = ensurePool(project) 86 + vitest.state.clearFiles(project, files) 87 + providers.add(project.browser!.provider) 88 + 89 + await pool.runTests(method, files) 90 + }), 91 + ) 141 92 } 142 - 143 - const numCpus 144 - = typeof nodeos.availableParallelism === 'function' 145 - ? nodeos.availableParallelism() 146 - : nodeos.cpus().length 147 93 148 94 function getThreadsCount(project: TestProject) { 149 95 const config = project.config.browser 150 - if (!config.headless || !project.browser!.provider.supportsParallelism) { 151 - return 1 152 - } 153 - 154 - if (!config.fileParallelism) { 96 + if ( 97 + !config.headless 98 + || !config.fileParallelism 99 + || !project.browser!.provider.supportsParallelism 100 + ) { 155 101 return 1 156 102 } 157 103 ··· 159 105 return project.config.maxWorkers 160 106 } 161 107 162 - return vitest.config.watch 163 - ? Math.max(Math.floor(numCpus / 2), 1) 164 - : Math.max(numCpus - 1, 1) 108 + return threadsCount 165 109 } 166 110 167 111 return { ··· 182 126 183 127 function escapePathToRegexp(path: string): string { 184 128 return path.replace(/[/\\.?*()^${}|[\]+]/g, '\\$&') 129 + } 130 + 131 + class BrowserPool { 132 + private _queue: string[] = [] 133 + private _promise: DeferPromise<void> | undefined 134 + private _providedContext: string | undefined 135 + 136 + private readySessions = new Set<string>() 137 + 138 + constructor( 139 + private project: TestProject, 140 + private options: { 141 + maxWorkers: number 142 + origin: string 143 + }, 144 + ) {} 145 + 146 + public cancel(): void { 147 + this._queue = [] 148 + } 149 + 150 + public reject(error: Error): void { 151 + this._promise?.reject(error) 152 + this._promise = undefined 153 + this.cancel() 154 + } 155 + 156 + get orchestrators() { 157 + return this.project.browser!.state.orchestrators 158 + } 159 + 160 + async runTests(method: 'run' | 'collect', files: string[]): Promise<void> { 161 + this._promise ??= createDefer<void>() 162 + 163 + if (!files.length) { 164 + this._promise.resolve() 165 + return this._promise 166 + } 167 + 168 + this._providedContext = stringify(this.project.getProvidedContext()) 169 + 170 + this._queue.push(...files) 171 + 172 + this.readySessions.forEach((sessionId) => { 173 + if (this._queue.length) { 174 + this.readySessions.delete(sessionId) 175 + this.runNextTest(method, sessionId) 176 + } 177 + }) 178 + 179 + if (this.orchestrators.size >= this.options.maxWorkers) { 180 + return this._promise 181 + } 182 + 183 + // open the minimum amount of tabs 184 + // if there is only 1 file running, we don't need 8 tabs running 185 + const workerCount = Math.min( 186 + this.options.maxWorkers - this.orchestrators.size, 187 + files.length, 188 + ) 189 + 190 + const promises: Promise<void>[] = [] 191 + for (let i = 0; i < workerCount; i++) { 192 + const sessionId = crypto.randomUUID() 193 + const page = this.openPage(sessionId).then(() => { 194 + // start running tests on the page when it's ready 195 + this.runNextTest(method, sessionId) 196 + }) 197 + promises.push(page) 198 + } 199 + await Promise.all(promises) 200 + return this._promise 201 + } 202 + 203 + private async openPage(sessionId: string) { 204 + const sessionPromise = this.project.vitest._browserSessions.createSession( 205 + sessionId, 206 + this.project, 207 + this, 208 + ) 209 + const url = new URL('/', this.options.origin) 210 + url.searchParams.set('sessionId', sessionId) 211 + const pagePromise = this.project.browser!.provider.openPage( 212 + sessionId, 213 + url.toString(), 214 + ) 215 + await Promise.all([sessionPromise, pagePromise]) 216 + } 217 + 218 + private getOrchestrator(sessionId: string) { 219 + const orchestrator = this.orchestrators.get(sessionId) 220 + if (!orchestrator) { 221 + throw new Error(`Orchestrator not found for session ${sessionId}. This is a bug in Vitest. Please, open a new issue with reproduction.`) 222 + } 223 + return orchestrator 224 + } 225 + 226 + private finishSession(sessionId: string): void { 227 + this.readySessions.add(sessionId) 228 + 229 + // the last worker finished running tests 230 + if (this.readySessions.size === this.orchestrators.size) { 231 + this._promise?.resolve() 232 + this._promise = undefined 233 + debug?.('all tests finished running') 234 + } 235 + } 236 + 237 + private runNextTest(method: 'run' | 'collect', sessionId: string): void { 238 + const file = this._queue.shift() 239 + 240 + if (!file) { 241 + debug?.('[%s] no more tests to run', sessionId) 242 + const isolate = this.project.config.browser.isolate 243 + // we don't need to cleanup testers if isolation is enabled, 244 + // because cleanup is done at the end of every test 245 + if (isolate) { 246 + this.finishSession(sessionId) 247 + return 248 + } 249 + 250 + // we need to cleanup testers first because there is only 251 + // one iframe and it does the cleanup only after everything is completed 252 + const orchestrator = this.getOrchestrator(sessionId) 253 + orchestrator.cleanupTesters() 254 + .catch(error => this.reject(error)) 255 + .finally(() => this.finishSession(sessionId)) 256 + return 257 + } 258 + 259 + if (!this._promise) { 260 + throw new Error(`Unexpected empty queue`) 261 + } 262 + 263 + const orchestrator = this.getOrchestrator(sessionId) 264 + debug?.('[%s] run test %s', sessionId, file) 265 + 266 + this.setBreakpoint(sessionId, file).then(() => { 267 + // this starts running tests inside the orchestrator 268 + orchestrator.createTesters( 269 + { 270 + method, 271 + files: [file], 272 + // this will be parsed by the test iframe, not the orchestrator 273 + // so we need to stringify it first to avoid double serialization 274 + providedContext: this._providedContext || '[{}]', 275 + }, 276 + ) 277 + .then(() => { 278 + debug?.('[%s] test %s finished running', sessionId, file) 279 + this.runNextTest(method, sessionId) 280 + }) 281 + .catch((error) => { 282 + // if user cancells the test run manually, ignore the error and exit gracefully 283 + if ( 284 + this.project.vitest.isCancelling 285 + && error instanceof Error 286 + && error.message.startsWith('Browser connection was closed while running tests') 287 + ) { 288 + this.cancel() 289 + this._promise?.resolve() 290 + this._promise = undefined 291 + return 292 + } 293 + debug?.('[%s] error during %s test run: %s', sessionId, file, error) 294 + this.reject(error) 295 + }) 296 + }).catch(err => this.reject(err)) 297 + } 298 + 299 + async setBreakpoint(sessionId: string, file: string) { 300 + if (!this.project.config.inspector.waitForDebugger) { 301 + return 302 + } 303 + 304 + const provider = this.project.browser!.provider 305 + 306 + if (!provider.getCDPSession) { 307 + throw new Error('Unable to set breakpoint, CDP not supported') 308 + } 309 + 310 + debug?.('[%s] set breakpoint for %s', sessionId, file) 311 + const session = await provider.getCDPSession(sessionId) 312 + await session.send('Debugger.enable', {}) 313 + await session.send('Debugger.setBreakpointByUrl', { 314 + lineNumber: 0, 315 + urlRegex: escapePathToRegexp(file), 316 + }) 317 + } 185 318 }
+1 -2
packages/browser/src/node/projectParent.ts
··· 198 198 throw new Error(`CDP is not supported by the provider "${provider.name}".`) 199 199 } 200 200 201 - const promise = this.cdpSessionsPromises.get(rpcId) ?? await (async () => { 201 + const session = await this.cdpSessionsPromises.get(rpcId) ?? await (async () => { 202 202 const promise = provider.getCDPSession!(sessionId).finally(() => { 203 203 this.cdpSessionsPromises.delete(rpcId) 204 204 }) ··· 206 206 return promise 207 207 })() 208 208 209 - const session = await promise 210 209 const rpc = (browser.state as BrowserServerState).testers.get(rpcId) 211 210 if (!rpc) { 212 211 throw new Error(`Tester RPC "${rpcId}" was not established.`)
+14 -17
packages/browser/src/node/rpc.ts
··· 58 58 ) 59 59 } 60 60 61 - const method = searchParams.get('method') as 'run' | 'collect' 62 - if (method !== 'run' && method !== 'collect') { 63 - return error( 64 - new Error(`[vitest] Method query in ${request.url} is invalid. Method should be either "run" or "collect".`), 65 - ) 66 - } 67 - 68 61 if (type === 'orchestrator') { 69 62 const session = vitest._browserSessions.getSession(sessionId) 70 63 // it's possible the session was already resolved by the preview provider ··· 82 75 wss.handleUpgrade(request, socket, head, (ws) => { 83 76 wss.emit('connection', ws, request) 84 77 85 - const rpc = setupClient(project, rpcId, ws, method) 78 + const rpc = setupClient(project, rpcId, ws) 86 79 const state = project.browser!.state as BrowserServerState 87 80 const clients = type === 'tester' ? state.testers : state.orchestrators 88 81 clients.set(rpcId, rpc) ··· 93 86 debug?.('[%s] Browser API disconnected from %s', rpcId, type) 94 87 clients.delete(rpcId) 95 88 globalServer.removeCDPHandler(rpcId) 89 + if (type === 'orchestrator') { 90 + vitest._browserSessions.destroySession(sessionId) 91 + } 92 + // this will reject any hanging methods if there are any 93 + rpc.$close( 94 + new Error(`[vitest] Browser connection was closed while running tests. Was the page closed unexpectedly?`), 95 + ) 96 96 }) 97 97 }) 98 98 }) ··· 111 111 } 112 112 } 113 113 114 - function setupClient(project: TestProject, rpcId: string, ws: WebSocket, method: 'run' | 'collect') { 114 + function setupClient(project: TestProject, rpcId: string, ws: WebSocket) { 115 115 const mockResolver = new ServerMockResolver(globalServer.vite, { 116 116 moduleDirectories: project.config.server?.deps?.moduleDirectories, 117 117 }) ··· 126 126 } 127 127 vitest.state.catchError(error, type) 128 128 }, 129 - async onQueued(file) { 129 + async onQueued(method, file) { 130 130 if (method === 'collect') { 131 131 vitest.state.collectFiles(project, [file]) 132 132 } ··· 134 134 await vitest._testRun.enqueued(project, file) 135 135 } 136 136 }, 137 - async onCollected(files) { 137 + async onCollected(method, files) { 138 138 if (method === 'collect') { 139 139 vitest.state.collectFiles(project, files) 140 140 } ··· 142 142 await vitest._testRun.collected(project, files) 143 143 } 144 144 }, 145 - async onTaskUpdate(packs, events) { 145 + async onTaskUpdate(method, packs, events) { 146 146 if (method === 'collect') { 147 147 vitest.state.updateTasks(packs) 148 148 } ··· 153 153 onAfterSuiteRun(meta) { 154 154 vitest.coverageProvider?.onAfterSuiteRun(meta) 155 155 }, 156 - async sendLog(log) { 156 + async sendLog(method, log) { 157 157 if (method === 'collect') { 158 158 vitest.state.updateUserLog(log) 159 159 } ··· 244 244 ) as any as BrowserCommandContext 245 245 return await commands[command](context, ...payload) 246 246 }, 247 - finishBrowserTests(sessionId: string) { 248 - debug?.('[%s] Finishing browser tests for session', sessionId) 249 - return vitest._browserSessions.getSession(sessionId)?.resolve() 250 - }, 251 247 resolveMock(rawId, importer, options) { 252 248 return mockResolver.resolveMock(rawId, importer, options) 253 249 }, ··· 329 325 on: fn => ws.on('message', fn), 330 326 eventNames: ['onCancel', 'cdpEvent'], 331 327 serialize: (data: any) => stringify(data, stringifyReplace), 328 + timeout: -1, // createTesters can take a long time 332 329 deserialize: parse, 333 330 onTimeoutError(functionName) { 334 331 throw new Error(`[vitest-api]: Timeout calling "${functionName}"`)
+3 -4
packages/browser/src/node/serverOrchestrator.ts
··· 1 1 import type { IncomingMessage, ServerResponse } from 'node:http' 2 2 import type { ProjectBrowser } from './project' 3 3 import type { ParentBrowserProject } from './projectParent' 4 + import { stringify } from 'flatted' 4 5 import { replacer } from './utils' 5 6 6 7 export async function resolveOrchestrator( ··· 19 20 // because the user could refresh the page which would remove the session id from the url 20 21 21 22 const session = globalServer.vitest._browserSessions.getSession(sessionId!) 22 - const files = session?.files ?? [] 23 23 const browserProject = (session?.project.browser as ProjectBrowser | undefined) || [...globalServer.children][0] 24 24 25 25 if (!browserProject) { ··· 36 36 __VITEST_VITE_CONFIG__: JSON.stringify({ 37 37 root: browserProject.vite.config.root, 38 38 }), 39 - __VITEST_METHOD__: JSON.stringify(session?.method || 'run'), 40 - __VITEST_FILES__: JSON.stringify(files), 39 + __VITEST_METHOD__: JSON.stringify('orchestrate'), 41 40 __VITEST_TYPE__: '"orchestrator"', 42 41 __VITEST_SESSION_ID__: JSON.stringify(sessionId), 43 42 __VITEST_TESTER_ID__: '"none"', 44 - __VITEST_PROVIDED_CONTEXT__: '{}', 43 + __VITEST_PROVIDED_CONTEXT__: JSON.stringify(stringify(browserProject.project.getProvidedContext())), 45 44 __VITEST_API_TOKEN__: JSON.stringify(globalServer.vitest.config.api.token), 46 45 }) 47 46
+5 -24
packages/browser/src/node/serverTester.ts
··· 3 3 import type { ProjectBrowser } from './project' 4 4 import type { ParentBrowserProject } from './projectParent' 5 5 import crypto from 'node:crypto' 6 - import { stringify } from 'flatted' 7 6 import { join } from 'pathe' 8 7 import { replacer } from './utils' 9 8 ··· 23 22 ) 24 23 } 25 24 26 - const { sessionId, testFile } = globalServer.resolveTesterUrl(url.pathname) 25 + const sessionId = url.searchParams.get('sessionId') || 'none' 27 26 const session = globalServer.vitest._browserSessions.getSession(sessionId) 28 27 29 28 if (!session) { ··· 33 32 } 34 33 35 34 const project = globalServer.vitest.getProjectByName(session.project.name || '') 36 - const { testFiles } = await project.globTestFiles() 37 - // if decoded test file is "__vitest_all__" or not in the list of known files, run all tests 38 - const tests 39 - = testFile === '__vitest_all__' 40 - || !testFiles.includes(testFile) 41 - ? '__vitest_browser_runner__.files' 42 - : JSON.stringify([testFile]) 43 - const iframeId = JSON.stringify(testFile) 44 - const files = session.files ?? [] 45 - const method = session.method ?? 'run' 46 - 47 35 const browserProject = (project.browser as ProjectBrowser | undefined) || [...globalServer.children][0] 48 36 49 37 if (!browserProject) { ··· 59 47 const injector = replacer(injectorJs, { 60 48 __VITEST_PROVIDER__: JSON.stringify(project.browser!.provider.name), 61 49 __VITEST_CONFIG__: JSON.stringify(browserProject.wrapSerializedConfig()), 62 - __VITEST_FILES__: JSON.stringify(files), 63 50 __VITEST_VITE_CONFIG__: JSON.stringify({ 64 51 root: browserProject.vite.config.root, 65 52 }), 66 53 __VITEST_TYPE__: '"tester"', 67 - __VITEST_METHOD__: JSON.stringify(method), 54 + __VITEST_METHOD__: JSON.stringify('none'), 68 55 __VITEST_SESSION_ID__: JSON.stringify(sessionId), 69 56 __VITEST_TESTER_ID__: JSON.stringify(crypto.randomUUID()), 70 - __VITEST_PROVIDED_CONTEXT__: JSON.stringify(stringify(project.getProvidedContext())), 57 + __VITEST_PROVIDED_CONTEXT__: '{}', 71 58 __VITEST_API_TOKEN__: JSON.stringify(globalServer.vitest.config.api.token), 72 59 }) 73 60 ··· 81 68 const html = replacer(indexhtml, { 82 69 __VITEST_FAVICON__: globalServer.faviconUrl, 83 70 __VITEST_INJECTOR__: injector, 84 - __VITEST_APPEND__: ` 85 - __vitest_browser_runner__.runningFiles = ${tests} 86 - __vitest_browser_runner__.iframeId = ${iframeId} 87 - __vitest_browser_runner__.${method === 'run' ? 'runTests' : 'collectTests'}(__vitest_browser_runner__.runningFiles) 88 - document.querySelector('script[data-vitest-append]').remove() 89 - `, 90 71 }) 91 72 return html 92 73 } 93 - catch (err) { 94 - session.reject(err) 74 + catch (err: any) { 75 + session.fail(err) 95 76 next(err) 96 77 } 97 78 }
+16 -7
packages/browser/src/node/types.ts
··· 2 2 import type { ServerIdResolution, ServerMockResolution } from '@vitest/mocker/node' 3 3 import type { TaskEventPack, TaskResultPack } from '@vitest/runner' 4 4 import type { BirpcReturn } from 'birpc' 5 - import type { AfterSuiteRunMeta, CancelReason, Reporter, RunnerTestFile, SnapshotResult, UserConsoleLog } from 'vitest' 5 + import type { 6 + AfterSuiteRunMeta, 7 + BrowserTesterOptions, 8 + CancelReason, 9 + Reporter, 10 + RunnerTestFile, 11 + SnapshotResult, 12 + TestExecutionMethod, 13 + UserConsoleLog, 14 + } from 'vitest' 6 15 7 16 export interface WebSocketBrowserHandlers { 8 17 resolveSnapshotPath: (testPath: string) => string 9 18 resolveSnapshotRawPath: (testPath: string, rawPath: string) => string 10 19 onUnhandledError: (error: unknown, type: string) => Promise<void> 11 - onQueued: (file: RunnerTestFile) => void 12 - onCollected: (files: RunnerTestFile[]) => Promise<void> 13 - onTaskUpdate: (packs: TaskResultPack[], events: TaskEventPack[]) => void 20 + onQueued: (method: TestExecutionMethod, file: RunnerTestFile) => void 21 + onCollected: (method: TestExecutionMethod, files: RunnerTestFile[]) => Promise<void> 22 + onTaskUpdate: (method: TestExecutionMethod, packs: TaskResultPack[], events: TaskEventPack[]) => void 14 23 onAfterSuiteRun: (meta: AfterSuiteRunMeta) => void 15 24 onCancel: (reason: CancelReason) => void 16 25 getCountOfFailedTests: () => number 17 26 readSnapshotFile: (id: string) => Promise<string | null> 18 27 saveSnapshotFile: (id: string, content: string) => Promise<void> 19 28 removeSnapshotFile: (id: string) => Promise<void> 20 - sendLog: (log: UserConsoleLog) => void 21 - finishBrowserTests: (sessionId: string) => void 29 + sendLog: (method: TestExecutionMethod, log: UserConsoleLog) => void 22 30 snapshotSaved: (snapshot: SnapshotResult) => void 23 31 debug: (...args: string[]) => void 24 32 resolveId: ( ··· 66 74 67 75 export interface WebSocketBrowserEvents { 68 76 onCancel: (reason: CancelReason) => void 69 - createTesters: (files: string[]) => Promise<void> 77 + createTesters: (options: BrowserTesterOptions) => Promise<void> 78 + cleanupTesters: () => Promise<void> 70 79 cdpEvent: (event: string, payload: unknown) => void 71 80 resolveManualMock: (url: string) => Promise<{ 72 81 url: string
+2
packages/vitest/src/public/index.ts
··· 179 179 /** @deprecated import from `vitest/node` instead */ 180 180 export type WorkerRPC = WorkerRPC_ 181 181 182 + export type { BrowserTesterOptions } from '../types/browser' 182 183 export type { 183 184 AfterSuiteRunMeta, 184 185 ErrorWithDiff, ··· 243 244 ContextRPC, 244 245 ContextTestEnvironment, 245 246 ResolveIdFunction, 247 + TestExecutionMethod, 246 248 WorkerGlobalState, 247 249 } from '../types/worker' 248 250 export type {
+3 -2
packages/vitest/src/public/node.ts
··· 130 130 export const TestFile: typeof _TestFile = _TestFile 131 131 export type { WorkerContext } from '../node/types/worker' 132 132 export { createViteLogger } from '../node/viteLogger' 133 + export { distDir, rootDir } from '../paths' 133 134 134 135 /** 135 136 * @deprecated Use `ModuleDiagnostic` instead 136 137 */ 137 138 export type FileDiagnostic = _FileDiagnostic 138 - 139 - export { distDir, rootDir } from '../paths' 140 139 141 140 export type { 142 141 CollectLineNumbers as TypeCheckCollectLineNumbers, ··· 146 145 RawErrsMap as TypeCheckRawErrorsMap, 147 146 RootAndTarget as TypeCheckRootAndTarget, 148 147 } from '../typecheck/types' 148 + 149 + export type { TestExecutionMethod as TestExecutionType } from '../types/worker' 149 150 150 151 export { createDebugger } from '../utils/debugger' 151 152
+2 -2
packages/vitest/src/runtime/config.ts
··· 134 134 standalone: boolean 135 135 logHeapUsage: boolean | undefined 136 136 coverage: SerializedCoverageConfig 137 - benchmark?: { 137 + benchmark: { 138 138 includeSamples: boolean 139 - } 139 + } | undefined 140 140 } 141 141 142 142 export interface SerializedCoverageConfig {
+7
packages/vitest/src/types/browser.ts
··· 1 + import type { TestExecutionMethod } from './worker' 2 + 3 + export interface BrowserTesterOptions { 4 + method: TestExecutionMethod 5 + files: string[] 6 + providedContext: string 7 + }
+2
packages/vitest/src/types/worker.ts
··· 20 20 options: Record<string, any> | null 21 21 } 22 22 23 + export type TestExecutionMethod = 'run' | 'collect' 24 + 23 25 export interface ContextRPC { 24 26 pool: string 25 27 worker: string
+22 -8
test/browser/fixtures/browser-crash/vitest.config.ts
··· 4 4 import { BrowserCommand } from 'vitest/node' 5 5 6 6 const forceCrash: BrowserCommand<[]> = async (context) => { 7 - const browser = context.context.browser().browserType().name() 8 - if (browser === 'chromium') { 9 - await context.page.goto('chrome://crash') 10 - } 7 + if (context.provider.name === 'playwright') { 8 + const browser = context.context.browser().browserType().name() 9 + if (browser === 'chromium') { 10 + await context.page.goto('chrome://crash') 11 + } 11 12 12 - if (browser === 'firefox') { 13 - await context.page.goto('about:crashcontent') 14 - } 13 + if (browser === 'firefox') { 14 + await context.page.goto('about:crashcontent') 15 + } 15 16 16 - throw new Error(`Browser crash not supported for ${browser}`) 17 + throw new Error(`Browser crash not supported for ${browser}`) 18 + } 19 + if (context.provider.name === 'webdriverio') { 20 + // @ts-expect-error not typed 21 + const browser = context.browser as any 22 + const name = context.project.config.browser.name 23 + if (name === 'chrome') { 24 + await browser.url('chrome://crash') 25 + } 26 + if (name === 'firefox') { 27 + await browser.url('about:crashcontent') 28 + } 29 + throw new Error(`Browser crash not supported for ${name}`) 30 + } 17 31 } 18 32 19 33 export default defineConfig({
-4
test/core/test/__snapshots__/mocked.test.ts.snap
··· 74 74 exports[`mocked function which fails on toReturnWith > zero call 1`] = ` 75 75 "expected "spy" to return with: 2 at least once 76 76 77 - Received: 78 - 79 - 80 - 81 77 Number of calls: 0 82 78 " 83 79 `;
+3 -3
packages/browser/src/client/public/error-catcher.js
··· 77 77 78 78 if (!state.runTests || !__vitest_worker__.current) { 79 79 channel.postMessage({ 80 - type: 'done', 81 - filenames: state.files, 82 - id: state.iframeId, 80 + // TODO: what to do in this case now? 81 + event: 'response:???', 82 + iframeId: state.iframeId, 83 83 }) 84 84 } 85 85 }
-1
packages/browser/src/client/public/esm-client-injector.js
··· 22 22 moduleCache, 23 23 config: { __VITEST_CONFIG__ }, 24 24 viteConfig: { __VITEST_VITE_CONFIG__ }, 25 - files: { __VITEST_FILES__ }, 26 25 type: { __VITEST_TYPE__ }, 27 26 sessionId: { __VITEST_SESSION_ID__ }, 28 27 testerId: { __VITEST_TESTER_ID__ },
+10 -4
packages/browser/src/client/tester/context.ts
··· 5 5 Locator, 6 6 UserEvent, 7 7 } from '../../../context' 8 + import type { IframeViewportEvent } from '../client' 8 9 import type { BrowserRunnerState } from '../utils' 9 10 import { ensureAwaited, getBrowserState, getWorkerState } from '../utils' 10 11 import { convertElementToCssSelector, processTimeoutOptions } from './utils' ··· 241 242 const screenshotIds: Record<string, Record<string, string>> = {} 242 243 export const page: BrowserPage = { 243 244 viewport(width, height) { 244 - const id = getBrowserState().iframeId 245 - channel.postMessage({ type: 'viewport', width, height, id }) 245 + const id = getBrowserState().iframeId! 246 + channel.postMessage({ 247 + event: 'viewport', 248 + width, 249 + height, 250 + iframeId: id, 251 + } satisfies IframeViewportEvent) 246 252 return new Promise((resolve, reject) => { 247 253 channel.addEventListener('message', function handler(e) { 248 - if (e.data.type === 'viewport:done' && e.data.id === id) { 254 + if (e.data.event === 'viewport:done' && e.data.iframeId === id) { 249 255 channel.removeEventListener('message', handler) 250 256 resolve() 251 257 } 252 - if (e.data.type === 'viewport:fail' && e.data.id === id) { 258 + if (e.data.event === 'viewport:fail' && e.data.iframeId === id) { 253 259 channel.removeEventListener('message', handler) 254 260 reject(new Error(e.data.error)) 255 261 }
+3 -1
packages/browser/src/client/tester/logger.ts
··· 1 1 import { format, stringify } from 'vitest/utils' 2 2 import { getConfig } from '../utils' 3 3 import { rpc } from './rpc' 4 + import { getBrowserRunner } from './runner' 4 5 5 6 const { Date, console, performance } = globalThis 6 7 ··· 141 142 = getConfig().printConsoleTrace && !disableStack 142 143 ? new Error('STACK_TRACE').stack?.split('\n').slice(1).join('\n') 143 144 : undefined 144 - rpc().sendLog({ 145 + const runner = getBrowserRunner() 146 + rpc().sendLog(runner?.method || 'run', { 145 147 origin, 146 148 content, 147 149 browser: true,
+38 -12
packages/browser/src/client/tester/runner.ts
··· 1 1 import type { CancelReason, File, Suite, Task, TaskEventPack, TaskResultPack, VitestRunner } from '@vitest/runner' 2 - import type { SerializedConfig, WorkerGlobalState } from 'vitest' 2 + import type { SerializedConfig, TestExecutionMethod, WorkerGlobalState } from 'vitest' 3 3 import type { VitestExecutor } from 'vitest/execute' 4 4 import type { VitestBrowserClientMocker } from './mocker' 5 - import { globalChannel } from '@vitest/browser/client' 5 + import { globalChannel, onCancel } from '@vitest/browser/client' 6 6 import { page, userEvent } from '@vitest/browser/context' 7 7 import { loadDiffConfig, loadSnapshotSerializers, takeCoverageInsideWorker } from 'vitest/browser' 8 8 import { NodeBenchmarkRunner, VitestTestRunner } from 'vitest/runners' ··· 22 22 takeCoverage: () => Promise<unknown> 23 23 } 24 24 25 + interface BrowserVitestRunner extends VitestRunner { 26 + sourceMapCache: Map<string, any> 27 + method: TestExecutionMethod 28 + setMethod: (method: TestExecutionMethod) => void 29 + } 30 + 25 31 export function createBrowserRunner( 26 32 runnerClass: { new (config: SerializedConfig): VitestRunner }, 27 33 mocker: VitestBrowserClientMocker, 28 34 state: WorkerGlobalState, 29 35 coverageModule: CoverageHandler | null, 30 - ): { new (options: BrowserRunnerOptions): VitestRunner & { sourceMapCache: Map<string, any> } } { 36 + ): { new (options: BrowserRunnerOptions): BrowserVitestRunner } { 31 37 return class BrowserTestRunner extends runnerClass implements VitestRunner { 32 38 public config: SerializedConfig 33 39 hashMap = browserHashMap 34 40 public sourceMapCache = new Map<string, any>() 41 + public method = 'run' as TestExecutionMethod 35 42 36 43 constructor(options: BrowserRunnerOptions) { 37 44 super(options.config) 38 45 this.config = options.config 46 + } 47 + 48 + setMethod(method: TestExecutionMethod) { 49 + this.method = method 39 50 } 40 51 41 52 onBeforeTryTask: VitestRunner['onBeforeTryTask'] = async (...args) => { ··· 59 70 60 71 onTaskFinished = async (task: Task) => { 61 72 if (this.config.browser.screenshotFailures && document.body.clientHeight > 0 && task.result?.state === 'fail') { 62 - const screenshot = await page.screenshot().catch((err) => { 73 + const screenshot = await page.screenshot({ 74 + timeout: this.config.browser.providerOptions?.actionTimeout ?? 5_000, 75 + }).catch((err) => { 63 76 console.error('[vitest] Failed to take a screenshot', err) 64 77 }) 65 78 if (screenshot) { ··· 107 120 } 108 121 109 122 onCollectStart = (file: File) => { 110 - return rpc().onQueued(file) 123 + return rpc().onQueued(this.method, file) 111 124 } 112 125 113 126 onCollected = async (files: File[]): Promise<unknown> => { ··· 121 134 122 135 if (this.config.includeTaskLocation) { 123 136 try { 124 - await updateFilesLocations(files, this.sourceMapCache) 137 + await updateTestFilesLocations(files, this.sourceMapCache) 125 138 } 126 139 catch {} 127 140 } 128 - return rpc().onCollected(files) 141 + return rpc().onCollected(this.method, files) 129 142 } 130 143 131 144 onTaskUpdate = (task: TaskResultPack[], events: TaskEventPack[]): Promise<void> => { 132 - return rpc().onTaskUpdate(task, events) 145 + return rpc().onTaskUpdate(this.method, task, events) 133 146 } 134 147 135 148 importFile = async (filepath: string) => { ··· 143 156 const prefix = `/${/^\w:/.test(filepath) ? '@fs/' : ''}` 144 157 const query = `browserv=${hash}` 145 158 const importpath = `${prefix}${filepath}?${query}`.replace(/\/+/g, '/') 146 - await import(/* @vite-ignore */ importpath) 159 + try { 160 + await import(/* @vite-ignore */ importpath) 161 + } 162 + catch (err) { 163 + throw new Error(`Failed to import test file ${filepath}`, { cause: err }) 164 + } 147 165 } 148 166 } 149 167 } 150 168 151 - let cachedRunner: VitestRunner | null = null 169 + let cachedRunner: BrowserVitestRunner | null = null 170 + 171 + export function getBrowserRunner(): BrowserVitestRunner | null { 172 + return cachedRunner 173 + } 152 174 153 175 export async function initiateRunner( 154 176 state: WorkerGlobalState, 155 177 mocker: VitestBrowserClientMocker, 156 178 config: SerializedConfig, 157 - ): Promise<VitestRunner> { 179 + ): Promise<BrowserVitestRunner> { 158 180 if (cachedRunner) { 159 181 return cachedRunner 160 182 } ··· 173 195 }) 174 196 cachedRunner = runner 175 197 198 + onCancel.then((reason) => { 199 + runner.onCancel?.(reason) 200 + }) 201 + 176 202 const [diffOptions] = await Promise.all([ 177 203 loadDiffConfig(config, executor as unknown as VitestExecutor), 178 204 loadSnapshotSerializers(config, executor as unknown as VitestExecutor), ··· 189 215 return runner 190 216 } 191 217 192 - async function updateFilesLocations(files: File[], sourceMaps: Map<string, any>) { 218 + async function updateTestFilesLocations(files: File[], sourceMaps: Map<string, any>) { 193 219 const promises = files.map(async (file) => { 194 220 const result = sourceMaps.get(file.filepath) || await rpc().getBrowserFileSourceMap(file.filepath) 195 221 if (!result) {
+3 -5
packages/browser/src/client/tester/state.ts
··· 1 1 import type { BrowserRPC } from '@vitest/browser/client' 2 2 import type { WorkerGlobalState } from 'vitest' 3 - import { parse } from 'flatted' 4 3 import { getBrowserState } from '../utils' 5 4 6 5 const config = getBrowserState().config 7 6 const sessionId = getBrowserState().sessionId 8 - 9 - const providedContext = parse(getBrowserState().providedContext) 10 7 11 8 const state: WorkerGlobalState = { 12 9 ctx: { ··· 20 17 name: 'browser', 21 18 options: null, 22 19 }, 23 - providedContext, 20 + // this is populated before tests run 21 + providedContext: {}, 24 22 invalidates: [], 25 23 }, 26 24 onCancel: null as any, ··· 38 36 environment: 0, 39 37 prepare: performance.now(), 40 38 }, 41 - providedContext, 39 + providedContext: {}, 42 40 } 43 41 44 42 // @ts-expect-error not typed global
+183 -139
packages/browser/src/client/tester/tester.ts
··· 1 + import type { BrowserRPC, IframeChannelEvent } from '@vitest/browser/client' 1 2 import { channel, client, onCancel } from '@vitest/browser/client' 2 3 import { page, server, userEvent } from '@vitest/browser/context' 4 + import { parse } from 'flatted' 3 5 import { 4 6 collectTests, 5 7 setupCommonEnv, ··· 17 19 import { browserHashMap, initiateRunner } from './runner' 18 20 import { CommandsManager } from './utils' 19 21 20 - const cleanupSymbol = Symbol.for('vitest:component-cleanup') 22 + const debugVar = getConfig().env.VITEST_BROWSER_DEBUG 23 + const debug = debugVar && debugVar !== 'false' 24 + ? (...args: unknown[]) => client.rpc.debug?.(...args.map(String)) 25 + : undefined 26 + 27 + channel.addEventListener('message', async (e) => { 28 + await client.waitForConnection() 29 + 30 + const data = e.data 31 + debug?.('event from orchestrator', JSON.stringify(e.data)) 32 + 33 + if (!isEvent(data)) { 34 + const error = new Error(`Unknown message: ${JSON.stringify(e.data)}`) 35 + unhandledError(error, 'Uknown Iframe Message') 36 + return 37 + } 38 + 39 + // ignore events to other iframes 40 + if (!('iframeId' in data) || data.iframeId !== getBrowserState().iframeId) { 41 + return 42 + } 43 + 44 + switch (data.event) { 45 + case 'execute': { 46 + const { method, files, context } = data 47 + const state = getWorkerState() 48 + const parsedContext = parse(context) 49 + 50 + state.ctx.providedContext = parsedContext 51 + state.providedContext = parsedContext 52 + 53 + if (method === 'collect') { 54 + await executeTests('collect', files).catch(err => unhandledError(err, 'Collect Error')) 55 + } 56 + else { 57 + await executeTests('run', files).catch(err => unhandledError(err, 'Run Error')) 58 + } 59 + break 60 + } 61 + case 'cleanup': { 62 + await cleanup().catch(err => unhandledError(err, 'Cleanup Error')) 63 + break 64 + } 65 + case 'prepare': { 66 + await prepare().catch(err => unhandledError(err, 'Prepare Error')) 67 + break 68 + } 69 + case 'viewport:done': 70 + case 'viewport:fail': 71 + case 'viewport': { 72 + break 73 + } 74 + default: { 75 + const error = new Error(`Unknown event: ${(data as any).event}`) 76 + unhandledError(error, 'Uknown Event') 77 + } 78 + } 79 + 80 + channel.postMessage({ 81 + event: `response:${data.event}`, 82 + iframeId: getBrowserState().iframeId!, 83 + }) 84 + }) 21 85 22 86 const url = new URL(location.href) 23 87 const reloadStart = url.searchParams.get('__reloadStart') 88 + const iframeId = url.searchParams.get('iframeId')! 24 89 25 - function debug(...args: unknown[]) { 26 - const debug = getConfig().env.VITEST_BROWSER_DEBUG 27 - if (debug && debug !== 'false') { 28 - client.rpc.debug(...args.map(String)) 29 - } 30 - } 90 + const commands = new CommandsManager() 91 + getBrowserState().commands = commands 92 + getBrowserState().iframeId = iframeId 31 93 32 - async function prepareTestEnvironment(files: string[]) { 33 - debug('trying to resolve runner', `${reloadStart}`) 94 + let contextSwitched = false 95 + 96 + async function prepareTestEnvironment() { 97 + debug?.('trying to resolve runner', `${reloadStart}`) 34 98 const config = getConfig() 35 99 36 100 const rpc = createSafeRpc(client) 37 101 38 102 const state = getWorkerState() 39 103 40 - state.ctx.files = files 41 104 state.onCancel = onCancel 42 105 state.rpc = rpc as any 43 - 44 - getBrowserState().commands = new CommandsManager() 45 106 46 107 const interceptor = createModuleMockerInterceptor() 47 108 const mocker = new VitestBrowserClientMocker( ··· 60 121 61 122 const runner = await initiateRunner(state, mocker, config) 62 123 getBrowserState().runner = runner 63 - 64 - const version = url.searchParams.get('browserv') || '' 65 - files.forEach((filename) => { 66 - const currentVersion = browserHashMap.get(filename) 67 - if (!currentVersion || currentVersion[1] !== version) { 68 - browserHashMap.set(filename, version) 69 - } 70 - }) 71 - 72 - onCancel.then((reason) => { 73 - runner.onCancel?.(reason) 74 - }) 75 - 76 - return { 77 - runner, 78 - config, 79 - state, 80 - rpc, 81 - commands: getBrowserState().commands, 82 - } 83 - } 84 - 85 - function done(files: string[]) { 86 - channel.postMessage({ 87 - type: 'done', 88 - filenames: files, 89 - id: getBrowserState().iframeId!, 90 - }) 91 - } 92 - 93 - async function executeTests(method: 'run' | 'collect', files: string[]) { 94 - await client.waitForConnection() 95 - 96 - debug('client is connected to ws server') 97 - 98 - let preparedData: 99 - | Awaited<ReturnType<typeof prepareTestEnvironment>> 100 - | undefined 101 - | false 102 - 103 - // if importing /@id/ failed, we reload the page waiting until Vite prebundles it 104 - try { 105 - preparedData = await prepareTestEnvironment(files) 106 - } 107 - catch (error: any) { 108 - debug('runner cannot be loaded because it threw an error', error.stack || error.message) 109 - await client.rpc.onUnhandledError({ 110 - name: error.name, 111 - message: error.message, 112 - stack: String(error.stack), 113 - }, 'Preload Error') 114 - done(files) 115 - return 116 - } 117 - 118 - // page is reloading 119 - if (!preparedData) { 120 - debug('page is reloading, waiting for the next run') 121 - return 122 - } 123 - 124 - debug('runner resolved successfully') 125 - 126 - const { config, runner, state, commands, rpc } = preparedData 127 - 128 - state.durations.prepare = performance.now() - state.durations.prepare 129 - 130 - debug('prepare time', state.durations.prepare, 'ms') 131 - 132 - let contextSwitched = false 133 124 134 125 // webdiverio context depends on the iframe state, so we need to switch the context, 135 126 // we delay this in case the user doesn't use any userEvent commands to avoid the overhead ··· 151 142 }) 152 143 } 153 144 154 - try { 155 - await Promise.all([ 156 - setupCommonEnv(config), 157 - startCoverageInsideWorker(config.coverage, executor, { isolate: config.browser.isolate }), 158 - (async () => { 159 - const VitestIndex = await import('vitest') 160 - Object.defineProperty(window, '__vitest_index__', { 161 - value: VitestIndex, 162 - enumerable: false, 163 - }) 164 - })(), 165 - ]) 145 + state.durations.prepare = performance.now() - state.durations.prepare 166 146 167 - for (const file of files) { 168 - state.filepath = file 169 - 170 - if (method === 'run') { 171 - await startTests([file], runner) 172 - } 173 - else { 174 - await collectTests([file], runner) 175 - } 176 - } 177 - } 178 - finally { 179 - try { 180 - if (cleanupSymbol in page) { 181 - (page[cleanupSymbol] as any)() 182 - } 183 - // need to cleanup for each tester 184 - // since playwright keyboard API is stateful on page instance level 185 - await userEvent.cleanup() 186 - if (contextSwitched) { 187 - await rpc.wdioSwitchContext('parent') 188 - } 189 - } 190 - catch (error: any) { 191 - await client.rpc.onUnhandledError({ 192 - name: error.name, 193 - message: error.message, 194 - stack: String(error.stack), 195 - }, 'Cleanup Error') 196 - } 197 - state.environmentTeardownRun = true 198 - await stopCoverageInsideWorker(config.coverage, executor, { isolate: config.browser.isolate }).catch((error) => { 199 - client.rpc.onUnhandledError({ 200 - name: error.name, 201 - message: error.message, 202 - stack: String(error.stack), 203 - }, 'Coverage Error').catch(() => {}) 204 - }) 205 - 206 - debug('finished running tests') 207 - done(files) 147 + return { 148 + runner, 149 + config, 150 + state, 208 151 } 209 152 } 210 153 211 - // @ts-expect-error untyped global for internal use 212 - window.__vitest_browser_runner__.runTests = files => executeTests('run', files) 213 - // @ts-expect-error untyped global for internal use 214 - window.__vitest_browser_runner__.collectTests = files => executeTests('collect', files) 154 + let preparedData: 155 + | Awaited<ReturnType<typeof prepareTestEnvironment>> 156 + | undefined 157 + 158 + async function executeTests(method: 'run' | 'collect', files: string[]) { 159 + if (!preparedData) { 160 + throw new Error(`Data was not properly initialized. This is a bug in Vitest. Please, open a new issue with reproduction.`) 161 + } 162 + 163 + debug?.('runner resolved successfully') 164 + 165 + const { runner, state } = preparedData 166 + 167 + state.ctx.files = files 168 + runner.setMethod(method) 169 + 170 + const version = url.searchParams.get('browserv') || '' 171 + files.forEach((filename) => { 172 + const currentVersion = browserHashMap.get(filename) 173 + if (!currentVersion || currentVersion[1] !== version) { 174 + browserHashMap.set(filename, version) 175 + } 176 + }) 177 + 178 + debug?.('prepare time', state.durations.prepare, 'ms') 179 + 180 + for (const file of files) { 181 + state.filepath = file 182 + 183 + if (method === 'run') { 184 + await startTests([file], runner) 185 + } 186 + else { 187 + await collectTests([file], runner) 188 + } 189 + } 190 + } 191 + 192 + async function prepare() { 193 + preparedData = await prepareTestEnvironment() 194 + 195 + // page is reloading 196 + debug?.('runner resolved successfully') 197 + 198 + const { config, state } = preparedData 199 + 200 + state.durations.prepare = performance.now() - state.durations.prepare 201 + 202 + debug?.('prepare time', state.durations.prepare, 'ms') 203 + 204 + await Promise.all([ 205 + setupCommonEnv(config), 206 + startCoverageInsideWorker(config.coverage, executor, { isolate: config.browser.isolate }), 207 + (async () => { 208 + const VitestIndex = await import('vitest') 209 + Object.defineProperty(window, '__vitest_index__', { 210 + value: VitestIndex, 211 + enumerable: false, 212 + }) 213 + })(), 214 + ]) 215 + } 216 + 217 + async function cleanup() { 218 + const state = getWorkerState() 219 + const config = getConfig() 220 + const rpc = state.rpc as any as BrowserRPC 221 + 222 + const cleanupSymbol = Symbol.for('vitest:component-cleanup') 223 + 224 + if (cleanupSymbol in page) { 225 + try { 226 + await (page[cleanupSymbol] as any)() 227 + } 228 + catch (error: any) { 229 + await unhandledError(error, 'Cleanup Error') 230 + } 231 + } 232 + // need to cleanup for each tester 233 + // since playwright keyboard API is stateful on page instance level 234 + await userEvent.cleanup() 235 + .catch(error => unhandledError(error, 'Cleanup Error')) 236 + 237 + // if isolation is disabled, Vitest reuses the same iframe and we 238 + // don't need to switch the context back at all 239 + if (contextSwitched) { 240 + await rpc.wdioSwitchContext('parent') 241 + .catch(error => unhandledError(error, 'Cleanup Error')) 242 + } 243 + state.environmentTeardownRun = true 244 + await stopCoverageInsideWorker(config.coverage, executor, { isolate: config.browser.isolate }).catch((error) => { 245 + return unhandledError(error, 'Coverage Error') 246 + }) 247 + } 248 + 249 + function unhandledError(e: Error, type: string) { 250 + return client.rpc.onUnhandledError({ 251 + name: e.name, 252 + message: e.message, 253 + stack: e.stack, 254 + }, type).catch(() => {}) 255 + } 256 + function isEvent(data: unknown): data is IframeChannelEvent { 257 + return typeof data === 'object' && !!data && 'event' in data 258 + }
-72
packages/browser/src/client/tester/unhandled.ts
··· 1 - import { client } from '@vitest/browser/client' 2 - 3 - function on(event: string, listener: (...args: any[]) => void) { 4 - window.addEventListener(event, listener) 5 - return () => window.removeEventListener(event, listener) 6 - } 7 - 8 - function serializeError(unhandledError: any) { 9 - if (typeof unhandledError !== 'object' || !unhandledError) { 10 - return { 11 - message: String(unhandledError), 12 - } 13 - } 14 - 15 - return { 16 - name: unhandledError.name, 17 - message: unhandledError.message, 18 - stack: String(unhandledError.stack), 19 - } 20 - } 21 - 22 - function catchWindowErrors(cb: (e: ErrorEvent) => void) { 23 - let userErrorListenerCount = 0 24 - function throwUnhandlerError(e: ErrorEvent) { 25 - if (userErrorListenerCount === 0 && e.error != null) { 26 - cb(e) 27 - } 28 - else { 29 - console.error(e.error) 30 - } 31 - } 32 - const addEventListener = window.addEventListener.bind(window) 33 - const removeEventListener = window.removeEventListener.bind(window) 34 - window.addEventListener('error', throwUnhandlerError) 35 - window.addEventListener = function ( 36 - ...args: [any, any, any] 37 - ) { 38 - if (args[0] === 'error') { 39 - userErrorListenerCount++ 40 - } 41 - return addEventListener.apply(this, args) 42 - } 43 - window.removeEventListener = function ( 44 - ...args: [any, any, any] 45 - ) { 46 - if (args[0] === 'error' && userErrorListenerCount) { 47 - userErrorListenerCount-- 48 - } 49 - return removeEventListener.apply(this, args) 50 - } 51 - return function clearErrorHandlers() { 52 - window.removeEventListener('error', throwUnhandlerError) 53 - } 54 - } 55 - 56 - function registerUnexpectedErrors() { 57 - catchWindowErrors(event => 58 - reportUnexpectedError('Error', event.error), 59 - ) 60 - on('unhandledrejection', event => 61 - reportUnexpectedError('Unhandled Rejection', event.reason)) 62 - } 63 - 64 - async function reportUnexpectedError( 65 - type: string, 66 - error: any, 67 - ) { 68 - const processedError = serializeError(error) 69 - await client.rpc.onUnhandledError(processedError, type) 70 - } 71 - 72 - registerUnexpectedErrors()
+1 -1
packages/browser/src/node/middlewares/testerMiddleware.ts
··· 9 9 return next() 10 10 } 11 11 const url = new URL(req.url, 'http://localhost') 12 - if (!url.pathname.startsWith(browserServer.prefixTesterUrl)) { 12 + if (!url.pathname.startsWith(browserServer.prefixTesterUrl) || !url.searchParams.has('sessionId')) { 13 13 return next() 14 14 } 15 15
+2 -12
packages/browser/src/node/providers/playwright.ts
··· 14 14 BrowserModuleMocker, 15 15 BrowserProvider, 16 16 BrowserProviderInitializationOptions, 17 + CDPSession, 17 18 TestProject, 18 19 } from 'vitest/node' 19 20 import { createManualModuleSource } from '@vitest/mocker/node' ··· 328 329 }) 329 330 } 330 331 331 - // unhandled page crashes will hang vitest process 332 - page.on('crash', () => { 333 - const session = this.project.vitest._browserSessions.getSession(sessionId) 334 - session?.reject(new Error('Page crashed when executing tests')) 335 - }) 336 - 337 332 return page 338 333 } 339 334 ··· 343 338 await browserPage.goto(url, { timeout: 0 }) 344 339 } 345 340 346 - async getCDPSession(sessionid: string): Promise<{ 347 - send: (method: string, params: any) => Promise<unknown> 348 - on: (event: string, listener: (...args: any[]) => void) => void 349 - off: (event: string, listener: (...args: any[]) => void) => void 350 - once: (event: string, listener: (...args: any[]) => void) => void 351 - }> { 341 + async getCDPSession(sessionid: string): Promise<CDPSession> { 352 342 const page = this.getPage(sessionid) 353 343 const cdp = await page.context().newCDPSession(page) 354 344 return {
+13 -11
packages/vitest/src/node/browser/sessions.ts
··· 1 1 import type { TestProject } from '../project' 2 2 import type { BrowserServerStateSession } from '../types/browser' 3 3 import { createDefer } from '@vitest/utils' 4 - import { relative } from 'pathe' 5 4 6 5 export class BrowserSessions { 7 6 private sessions = new Map<string, BrowserServerStateSession>() ··· 10 9 return this.sessions.get(sessionId) 11 10 } 12 11 13 - createAsyncSession(method: 'run' | 'collect', sessionId: string, files: string[], project: TestProject): Promise<void> { 12 + destroySession(sessionId: string): void { 13 + this.sessions.delete(sessionId) 14 + } 15 + 16 + createSession(sessionId: string, project: TestProject, pool: { reject: (error: Error) => void }): Promise<void> { 17 + // this promise only waits for the WS connection with the orhcestrator to be established 14 18 const defer = createDefer<void>() 15 19 16 20 const timeout = setTimeout(() => { 17 - const tests = files.map(file => relative(project.config.root, file)).join('", "') 18 - defer.reject(new Error(`Failed to connect to the browser session "${sessionId}" [${project.name}] for "${tests}" within the timeout.`)) 21 + defer.reject(new Error(`Failed to connect to the browser session "${sessionId}" [${project.name}] within the timeout.`)) 19 22 }, project.vitest.config.browser.connectTimeout ?? 60_000).unref() 20 23 21 24 this.sessions.set(sessionId, { 22 - files, 23 - method, 24 25 project, 25 26 connected: () => { 26 - clearTimeout(timeout) 27 - }, 28 - resolve: () => { 29 27 defer.resolve() 30 28 clearTimeout(timeout) 31 - this.sessions.delete(sessionId) 32 29 }, 33 - reject: defer.reject, 30 + // this fails the whole test run and cancels the pool 31 + fail: (error: Error) => { 32 + defer.resolve() 33 + clearTimeout(timeout) 34 + pool.reject(error) 35 + }, 34 36 }) 35 37 return defer 36 38 }
+4 -5
packages/vitest/src/node/types/browser.ts
··· 3 3 import type { Awaitable, ErrorWithDiff, ParsedStack } from '@vitest/utils' 4 4 import type { StackTraceParserOptions } from '@vitest/utils/source-map' 5 5 import type { ViteDevServer } from 'vite' 6 + import type { BrowserTesterOptions } from '../../types/browser' 6 7 import type { TestProject } from '../project' 7 8 import type { ApiConfig, ProjectConfig } from './config' 8 9 ··· 244 245 } 245 246 246 247 export interface BrowserServerStateSession { 247 - files: string[] 248 - method: 'run' | 'collect' 249 248 project: TestProject 250 249 connected: () => void 251 - resolve: () => void 252 - reject: (v: unknown) => void 250 + fail: (v: Error) => void 253 251 } 254 252 255 253 export interface BrowserOrchestrator { 256 - createTesters: (files: string[]) => Promise<void> 254 + cleanupTesters: () => Promise<void> 255 + createTesters: (options: BrowserTesterOptions) => Promise<void> 257 256 onCancel: (reason: CancelReason) => Promise<void> 258 257 $close: () => void 259 258 }
+1
test/browser/fixtures/mocking-out-of-root/project1/vitest.config.ts
··· 12 12 enabled: true, 13 13 provider: provider, 14 14 screenshotFailures: false, 15 + headless: true, 15 16 instances, 16 17 headless: true, 17 18 },