|
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 |
| - |
28 |
| -const basePath = () => config?.base || '/' |
29 |
| -const importId = (id: string) => _importId(id, basePath()) |
30 |
| -const viteClientPath = () => `${basePath()}@vite/client` |
31 |
| - |
32 |
| -function getQueryPaths() { |
33 |
| - return url.searchParams.getAll('path') |
34 |
| -} |
35 |
| - |
36 |
| -let setCancel = (_: CancelReason) => {} |
37 |
| -const onCancel = new Promise<CancelReason>((resolve) => { |
38 |
| - setCancel = resolve |
39 |
| -}) |
40 | 6 |
|
41 |
| -export const client = createClient(ENTRY_URL, { |
42 |
| - handlers: { |
43 |
| - onCancel: setCancel, |
44 |
| - }, |
45 |
| -}) |
| 7 | +const ID_ALL = '__vitest_all__' |
46 | 8 |
|
47 |
| -const ws = client.ws |
| 9 | +const iframes = new Map<string, HTMLIFrameElement>() |
48 | 10 |
|
49 |
| -async function loadConfig() { |
50 |
| - let retries = 5 |
51 |
| - do { |
52 |
| - try { |
53 |
| - await new Promise(resolve => setTimeout(resolve, 150)) |
54 |
| - config = await client.rpc.getConfig() |
55 |
| - config = unwrapConfig(config) |
56 |
| - return |
57 |
| - } |
58 |
| - catch (_) { |
59 |
| - // just ignore |
60 |
| - } |
61 |
| - } |
62 |
| - while (--retries > 0) |
63 |
| - |
64 |
| - throw new Error('cannot load configuration after 5 retries') |
| 11 | +function debug(...args: unknown[]) { |
| 12 | + const debug = getConfig().env.VITEST_BROWSER_DEBUG |
| 13 | + if (debug && debug !== 'false') |
| 14 | + client.rpc.debug(...args.map(String)) |
65 | 15 | }
|
66 | 16 |
|
67 |
| -function unwrapConfig(config: ResolvedConfig): ResolvedConfig { |
68 |
| - return { |
69 |
| - ...config, |
70 |
| - // workaround RegExp serialization |
71 |
| - testNamePattern: |
72 |
| - config.testNamePattern |
73 |
| - ? parseRegexp((config.testNamePattern as any as string)) |
74 |
| - : undefined, |
| 17 | +function createIframe(container: HTMLDivElement, file: string) { |
| 18 | + if (iframes.has(file)) { |
| 19 | + container.removeChild(iframes.get(file)!) |
| 20 | + iframes.delete(file) |
75 | 21 | }
|
76 |
| -} |
77 | 22 |
|
78 |
| -function on(event: string, listener: (...args: any[]) => void) { |
79 |
| - window.addEventListener(event, listener) |
80 |
| - return () => window.removeEventListener(event, listener) |
| 23 | + const iframe = document.createElement('iframe') |
| 24 | + iframe.setAttribute('loading', 'eager') |
| 25 | + iframe.setAttribute('src', `${url.pathname}__vitest_test__/__test__/${encodeURIComponent(file)}`) |
| 26 | + iframes.set(file, iframe) |
| 27 | + container.appendChild(iframe) |
| 28 | + return iframe |
81 | 29 | }
|
82 | 30 |
|
83 |
| -function serializeError(unhandledError: any) { |
84 |
| - return { |
85 |
| - ...unhandledError, |
86 |
| - name: unhandledError.name, |
87 |
| - message: unhandledError.message, |
88 |
| - stack: String(unhandledError.stack), |
89 |
| - } |
| 31 | +async function done() { |
| 32 | + await rpcDone() |
| 33 | + await client.rpc.finishBrowserTests() |
90 | 34 | }
|
91 | 35 |
|
92 |
| -// we can't import "processError" yet because error might've been thrown before the module was loaded |
93 |
| -async function defaultErrorReport(type: string, unhandledError: any) { |
94 |
| - const error = serializeError(unhandledError) |
95 |
| - if (testId !== 'no-isolate') |
96 |
| - error.VITEST_TEST_PATH = testId |
97 |
| - await client.rpc.onUnhandledError(error, type) |
98 |
| - await client.rpc.onDone(testId) |
| 36 | +interface IframeDoneEvent { |
| 37 | + type: 'done' |
| 38 | + filenames: string[] |
99 | 39 | }
|
100 | 40 |
|
101 |
| -function catchWindowErrors(cb: (e: ErrorEvent) => void) { |
102 |
| - let userErrorListenerCount = 0 |
103 |
| - function throwUnhandlerError(e: ErrorEvent) { |
104 |
| - if (userErrorListenerCount === 0 && e.error != null) |
105 |
| - cb(e) |
106 |
| - else |
107 |
| - console.error(e.error) |
108 |
| - } |
109 |
| - const addEventListener = window.addEventListener.bind(window) |
110 |
| - const removeEventListener = window.removeEventListener.bind(window) |
111 |
| - window.addEventListener('error', throwUnhandlerError) |
112 |
| - window.addEventListener = function (...args: Parameters<typeof addEventListener>) { |
113 |
| - if (args[0] === 'error') |
114 |
| - userErrorListenerCount++ |
115 |
| - return addEventListener.apply(this, args) |
116 |
| - } |
117 |
| - window.removeEventListener = function (...args: Parameters<typeof removeEventListener>) { |
118 |
| - if (args[0] === 'error' && userErrorListenerCount) |
119 |
| - userErrorListenerCount-- |
120 |
| - return removeEventListener.apply(this, args) |
121 |
| - } |
122 |
| - return function clearErrorHandlers() { |
123 |
| - window.removeEventListener('error', throwUnhandlerError) |
124 |
| - } |
125 |
| -} |
126 |
| - |
127 |
| -const stopErrorHandler = catchWindowErrors(e => defaultErrorReport('Error', e.error)) |
128 |
| -const stopRejectionHandler = on('unhandledrejection', e => defaultErrorReport('Unhandled Rejection', e.reason)) |
129 |
| - |
130 |
| -let runningTests = false |
131 |
| - |
132 |
| -async function reportUnexpectedError(rpc: typeof client.rpc, type: string, error: any) { |
133 |
| - const { processError } = await importId('vitest/browser') as typeof import('vitest/browser') |
134 |
| - const processedError = processError(error) |
135 |
| - if (testId !== 'no-isolate') |
136 |
| - error.VITEST_TEST_PATH = testId |
137 |
| - await rpc.onUnhandledError(processedError, type) |
138 |
| - if (!runningTests) |
139 |
| - await rpc.onDone(testId) |
| 41 | +interface IframeErrorEvent { |
| 42 | + type: 'error' |
| 43 | + error: any |
| 44 | + errorType: string |
| 45 | + files: string[] |
140 | 46 | }
|
141 | 47 |
|
142 |
| -ws.addEventListener('open', async () => { |
143 |
| - await loadConfig() |
144 |
| - |
145 |
| - let safeRpc: typeof client.rpc |
146 |
| - try { |
147 |
| - // if importing /@id/ failed, we reload the page waiting until Vite prebundles it |
148 |
| - const { getSafeTimers } = await importId('vitest/utils') as typeof import('vitest/utils') |
149 |
| - safeRpc = createSafeRpc(client, getSafeTimers) |
150 |
| - } |
151 |
| - catch (err: any) { |
152 |
| - if (reloadTries >= 10) { |
153 |
| - const error = serializeError(new Error('Vitest failed to load "vitest/utils" after 10 retries.')) |
154 |
| - error.cause = serializeError(err) |
155 |
| - |
156 |
| - await client.rpc.onUnhandledError(error, 'Reload Error') |
157 |
| - await client.rpc.onDone(testId) |
158 |
| - return |
159 |
| - } |
160 |
| - |
161 |
| - const tries = reloadTries + 1 |
162 |
| - const newUrl = new URL(location.href) |
163 |
| - newUrl.searchParams.set('reloadTries', String(tries)) |
164 |
| - location.href = newUrl.href |
165 |
| - return |
166 |
| - } |
167 |
| - |
168 |
| - stopErrorHandler() |
169 |
| - stopRejectionHandler() |
170 |
| - |
171 |
| - catchWindowErrors(event => reportUnexpectedError(safeRpc, 'Error', event.error)) |
172 |
| - on('unhandledrejection', event => reportUnexpectedError(safeRpc, 'Unhandled Rejection', event.reason)) |
173 |
| - |
174 |
| - // @ts-expect-error untyped global for internal use |
175 |
| - globalThis.__vitest_browser__ = true |
176 |
| - // @ts-expect-error mocking vitest apis |
177 |
| - globalThis.__vitest_worker__ = { |
178 |
| - config, |
179 |
| - browserHashMap, |
180 |
| - environment: { |
181 |
| - name: 'browser', |
182 |
| - }, |
183 |
| - // @ts-expect-error untyped global for internal use |
184 |
| - moduleCache: globalThis.__vi_module_cache__, |
185 |
| - rpc: client.rpc, |
186 |
| - safeRpc, |
187 |
| - durations: { |
188 |
| - environment: 0, |
189 |
| - prepare: 0, |
190 |
| - }, |
191 |
| - providedContext: await client.rpc.getProvidedContext(), |
192 |
| - } |
193 |
| - // @ts-expect-error mocking vitest apis |
194 |
| - globalThis.__vitest_mocker__ = new VitestBrowserClientMocker() |
195 |
| - |
196 |
| - const paths = getQueryPaths() |
197 |
| - |
198 |
| - const iFrame = document.getElementById('vitest-ui') as HTMLIFrameElement |
199 |
| - iFrame.setAttribute('src', '/__vitest__/') |
200 |
| - |
201 |
| - await setupConsoleLogSpy(basePath()) |
202 |
| - setupDialogsSpy() |
203 |
| - await runTests(paths, config!) |
204 |
| -}) |
| 48 | +type IframeChannelEvent = IframeDoneEvent | IframeErrorEvent |
205 | 49 |
|
206 |
| -async function prepareTestEnvironment(config: ResolvedConfig) { |
207 |
| - // need to import it before any other import, otherwise Vite optimizer will hang |
208 |
| - await import(viteClientPath()) |
| 50 | +client.ws.addEventListener('open', async () => { |
| 51 | + const config = getConfig() |
| 52 | + const container = document.querySelector('#vitest-tester') as HTMLDivElement |
| 53 | + const testFiles = getBrowserState().files |
209 | 54 |
|
210 |
| - const { |
211 |
| - startTests, |
212 |
| - setupCommonEnv, |
213 |
| - loadDiffConfig, |
214 |
| - loadSnapshotSerializers, |
215 |
| - takeCoverageInsideWorker, |
216 |
| - } = await importId('vitest/browser') as typeof import('vitest/browser') |
217 |
| - |
218 |
| - const executor = { |
219 |
| - executeId: (id: string) => importId(id), |
220 |
| - } |
221 |
| - |
222 |
| - if (!runner) { |
223 |
| - const { VitestTestRunner } = await importId('vitest/runners') as typeof import('vitest/runners') |
224 |
| - const BrowserRunner = createBrowserRunner(VitestTestRunner, { takeCoverage: () => takeCoverageInsideWorker(config.coverage, executor) }) |
225 |
| - runner = new BrowserRunner({ config, browserHashMap }) |
226 |
| - } |
227 |
| - |
228 |
| - return { |
229 |
| - startTests, |
230 |
| - setupCommonEnv, |
231 |
| - loadDiffConfig, |
232 |
| - loadSnapshotSerializers, |
233 |
| - executor, |
234 |
| - runner, |
235 |
| - } |
236 |
| -} |
| 55 | + debug('test files', testFiles.join(', ')) |
237 | 56 |
|
238 |
| -async function runTests(paths: string[], config: ResolvedConfig) { |
239 |
| - let preparedData: Awaited<ReturnType<typeof prepareTestEnvironment>> | undefined |
240 |
| - // if importing /@id/ failed, we reload the page waiting until Vite prebundles it |
241 |
| - try { |
242 |
| - preparedData = await prepareTestEnvironment(config) |
243 |
| - } |
244 |
| - catch (err) { |
245 |
| - location.reload() |
| 57 | + // TODO: fail tests suite because no tests found? |
| 58 | + if (!testFiles.length) { |
| 59 | + await done() |
246 | 60 | return
|
247 | 61 | }
|
248 | 62 |
|
249 |
| - const { startTests, setupCommonEnv, loadDiffConfig, loadSnapshotSerializers, executor, runner } = preparedData! |
250 |
| - |
251 |
| - onCancel.then((reason) => { |
252 |
| - runner?.onCancel?.(reason) |
| 63 | + const runningFiles = new Set<string>(testFiles) |
| 64 | + |
| 65 | + channel.addEventListener('message', async (e: MessageEvent<IframeChannelEvent>): Promise<void> => { |
| 66 | + debug('channel event', JSON.stringify(e.data)) |
| 67 | + switch (e.data.type) { |
| 68 | + case 'done': { |
| 69 | + const filenames = e.data.filenames |
| 70 | + filenames.forEach(filename => runningFiles.delete(filename)) |
| 71 | + |
| 72 | + if (!runningFiles.size) |
| 73 | + await done() |
| 74 | + break |
| 75 | + } |
| 76 | + // error happened at the top level, this should never happen in user code, but it can trigger during development |
| 77 | + case 'error': { |
| 78 | + const iframeId = e.data.files.length > 1 ? ID_ALL : e.data.files[0] |
| 79 | + iframes.delete(iframeId) |
| 80 | + await client.rpc.onUnhandledError(e.data.error, e.data.errorType) |
| 81 | + if (iframeId === ID_ALL) |
| 82 | + runningFiles.clear() |
| 83 | + else |
| 84 | + runningFiles.delete(iframeId) |
| 85 | + if (!runningFiles.size) |
| 86 | + await done() |
| 87 | + break |
| 88 | + } |
| 89 | + default: { |
| 90 | + await client.rpc.onUnhandledError({ |
| 91 | + name: 'Unexpected Event', |
| 92 | + message: `Unexpected event: ${(e.data as any).type}`, |
| 93 | + }, 'Unexpected Event') |
| 94 | + await done() |
| 95 | + } |
| 96 | + } |
253 | 97 | })
|
254 | 98 |
|
255 |
| - if (!config.snapshotOptions.snapshotEnvironment) |
256 |
| - config.snapshotOptions.snapshotEnvironment = new BrowserSnapshotEnvironment() |
257 |
| - |
258 |
| - try { |
259 |
| - const [diffOptions] = await Promise.all([ |
260 |
| - loadDiffConfig(config, executor as VitestExecutor), |
261 |
| - loadSnapshotSerializers(config, executor as VitestExecutor), |
262 |
| - ]) |
263 |
| - runner.config.diffOptions = diffOptions |
264 |
| - |
265 |
| - await setupCommonEnv(config) |
266 |
| - const files = paths.map((path) => { |
267 |
| - return (`${config.root}/${path}`).replace(/\/+/g, '/') |
268 |
| - }) |
269 |
| - |
270 |
| - const now = `${new Date().getTime()}` |
271 |
| - files.forEach(i => browserHashMap.set(i, [true, now])) |
272 |
| - |
273 |
| - runningTests = true |
274 |
| - |
275 |
| - for (const file of files) |
276 |
| - await startTests([file], runner) |
277 |
| - } |
278 |
| - finally { |
279 |
| - runningTests = false |
280 |
| - |
281 |
| - await rpcDone() |
282 |
| - await rpc().onDone(testId) |
| 99 | + const fileParallelism = config.browser.fileParallelism ?? config.fileParallelism |
| 100 | + |
| 101 | + if (config.isolate === false) { |
| 102 | + createIframe( |
| 103 | + container, |
| 104 | + ID_ALL, |
| 105 | + ) |
| 106 | + } |
| 107 | + else { |
| 108 | + // if fileParallelism is enabled, we can create all iframes at once |
| 109 | + if (fileParallelism) { |
| 110 | + for (const file of testFiles) { |
| 111 | + createIframe( |
| 112 | + container, |
| 113 | + file, |
| 114 | + ) |
| 115 | + } |
| 116 | + } |
| 117 | + else { |
| 118 | + // otherwise, we need to wait for each iframe to finish before creating the next one |
| 119 | + // this is the most stable way to run tests in the browser |
| 120 | + for (const file of testFiles) { |
| 121 | + createIframe( |
| 122 | + container, |
| 123 | + file, |
| 124 | + ) |
| 125 | + await new Promise<void>((resolve) => { |
| 126 | + channel.addEventListener('message', function handler(e: MessageEvent<IframeChannelEvent>) { |
| 127 | + // done and error can only be triggered by the previous iframe |
| 128 | + if (e.data.type === 'done' || e.data.type === 'error') { |
| 129 | + channel.removeEventListener('message', handler) |
| 130 | + resolve() |
| 131 | + } |
| 132 | + }) |
| 133 | + }) |
| 134 | + } |
| 135 | + } |
283 | 136 | }
|
284 |
| -} |
| 137 | +}) |
0 commit comments