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

fix(browser): wait for iframe tester readiness before preparing (#10497) [backport to v4] (#10556)

Co-authored-by: Séamus O'Connor <seamus.oconnor@seeq.com>

authored by

Vladimir
Séamus O'Connor
and committed by
GitHub
(Jun 10, 2026, 8:52 AM +0200) fbc626c4 7fb29651

+119 -12
+60
test/browser/specs/readiness.test.ts
··· 1 + import { expect, test } from 'vitest' 2 + import { instances, runInlineBrowserTests } from './utils' 3 + 4 + test('prepare waits until the tester can receive browser channel events', { timeout: 5000 }, async () => { 5 + const { stderr, testTree } = await runInlineBrowserTests( 6 + { 7 + 'basic.test.ts': ` 8 + import { expect, test } from 'vitest' 9 + 10 + test('runs in the browser', () => { 11 + expect(1).toBe(1) 12 + }) 13 + `, 14 + 'delayed-tester.html': ` 15 + <!DOCTYPE html> 16 + <html lang="en"> 17 + <head> 18 + <meta charset="UTF-8" /> 19 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 20 + <title>Delayed Tester</title> 21 + <script> 22 + const addEventListener = BroadcastChannel.prototype.addEventListener 23 + const postMessage = BroadcastChannel.prototype.postMessage 24 + BroadcastChannel.prototype.addEventListener = function(type, listener, options) { 25 + if (type === 'message') { 26 + setTimeout(() => addEventListener.call(this, type, listener, options), 100) 27 + return 28 + } 29 + return addEventListener.call(this, type, listener, options) 30 + } 31 + BroadcastChannel.prototype.postMessage = function(message) { 32 + if (message && message.event === 'ready') { 33 + setTimeout(() => postMessage.call(this, message), 150) 34 + return 35 + } 36 + return postMessage.call(this, message) 37 + } 38 + </script> 39 + </head> 40 + <body></body> 41 + </html> 42 + `, 43 + }, 44 + { 45 + browser: { 46 + instances: [instances[0]], 47 + testerHtmlPath: './delayed-tester.html', 48 + }, 49 + }, 50 + ) 51 + 52 + expect(stderr).toBe('') 53 + expect(testTree()).toMatchInlineSnapshot(` 54 + { 55 + "basic.test.ts": { 56 + "runs in the browser": "passed", 57 + }, 58 + } 59 + `) 60 + })
+6
packages/browser/src/client/channel.ts
··· 20 20 iframeId: string 21 21 } 22 22 23 + export interface IframeReadyEvent { 24 + event: 'ready' 25 + iframeId: string 26 + } 27 + 23 28 export interface GlobalChannelTestRunCanceledEvent { 24 29 type: 'cancel' 25 30 reason: CancelReason ··· 49 54 50 55 export type IframeChannelIncomingEvent 51 56 = | IframeViewportEvent 57 + | IframeReadyEvent 52 58 53 59 export type IframeChannelOutgoingEvent 54 60 = | IframeExecuteEvent
+48 -12
packages/browser/src/client/orchestrator.ts
··· 1 1 import type { Context as OTELContext } from '@opentelemetry/api' 2 - import type { GlobalChannelIncomingEvent, IframeChannelIncomingEvent, IframeChannelOutgoingEvent, IframeViewportDoneEvent, IframeViewportFailEvent } from '@vitest/browser/client' 2 + import type { GlobalChannelIncomingEvent, IframeChannelEvent, IframeChannelOutgoingEvent, IframeViewportDoneEvent, IframeViewportFailEvent } from '@vitest/browser/client' 3 3 import type { FileSpecification } from '@vitest/runner' 4 4 import type { BrowserTesterOptions, SerializedConfig } from 'vitest' 5 5 import { channel, client, globalChannel } from '@vitest/browser/client' ··· 15 15 private cancelled = false 16 16 private recreateNonIsolatedIframe = false 17 17 private iframes = new Map<string, HTMLIFrameElement>() 18 + private readyIframes = new Set<string>() 19 + private readyWaiters = new Map<string, () => void>() 18 20 19 21 public eventTarget: EventTarget = new EventTarget() 20 22 ··· 91 93 92 94 this.iframes.forEach(iframe => iframe.remove()) 93 95 this.iframes.clear() 96 + this.readyIframes.clear() 97 + this.readyWaiters.clear() 94 98 95 99 for (let i = 0; i < options.files.length; i++) { 96 100 if (this.cancelled) { ··· 149 153 // because we called "cleanup" in the previous run 150 154 // the iframe is not removed immediately to let the user see the last test 151 155 this.recreateNonIsolatedIframe = false 152 - this.iframes.get(ID_ALL)!.remove() 153 - this.iframes.delete(ID_ALL) 156 + this.removeIframe(ID_ALL) 154 157 debug('recreate non-isolated iframe') 155 158 } 156 159 ··· 190 193 const file = spec.filepath 191 194 192 195 if (this.iframes.has(file)) { 193 - this.iframes.get(file)!.remove() 194 - this.iframes.delete(file) 196 + this.removeIframe(file) 195 197 } 196 198 197 199 const iframe = await this.prepareIframe( ··· 254 256 } 255 257 else { 256 258 this.iframes.set(iframeId, iframe) 257 - this.sendEventToIframe({ 258 - event: 'prepare', 259 - iframeId, 260 - startTime, 261 - otelCarrier: this.traces.getContextCarrier(otelContext), 262 - }).then(resolve, error => reject(this.dispatchIframeError(error))) 259 + this.waitForReady(iframeId) 260 + .then(() => this.sendEventToIframe({ 261 + event: 'prepare', 262 + iframeId, 263 + startTime, 264 + otelCarrier: this.traces.getContextCarrier(otelContext), 265 + })) 266 + .then(resolve, error => reject(this.dispatchIframeError(error))) 263 267 } 264 268 } 265 269 iframe.onerror = (e) => { ··· 275 279 } 276 280 }) 277 281 return iframe 282 + } 283 + 284 + private markReady(iframeId: string) { 285 + this.readyIframes.add(iframeId) 286 + 287 + const waiter = this.readyWaiters.get(iframeId) 288 + if (waiter) { 289 + this.readyWaiters.delete(iframeId) 290 + waiter() 291 + } 292 + } 293 + 294 + private waitForReady(iframeId: string): Promise<void> { 295 + if (this.readyIframes.has(iframeId)) { 296 + return Promise.resolve() 297 + } 298 + 299 + return new Promise((resolve) => { 300 + this.readyWaiters.set(iframeId, resolve) 301 + }) 302 + } 303 + 304 + private removeIframe(iframeId: string) { 305 + const iframe = this.iframes.get(iframeId) 306 + this.iframes.delete(iframeId) 307 + this.readyIframes.delete(iframeId) 308 + this.readyWaiters.delete(iframeId) 309 + iframe?.remove() 278 310 } 279 311 280 312 private loggedIframe = new WeakSet<HTMLIFrameElement>() ··· 342 374 } 343 375 } 344 376 345 - private async onIframeEvent(e: MessageEvent<IframeChannelIncomingEvent>) { 377 + private async onIframeEvent(e: MessageEvent<IframeChannelEvent>) { 346 378 debug('iframe event', JSON.stringify(e.data)) 347 379 switch (e.data.event) { 380 + case 'ready': { 381 + this.markReady(e.data.iframeId) 382 + break 383 + } 348 384 case 'viewport': { 349 385 const { width, height, iframeId: id } = e.data 350 386 const iframe = this.iframes.get(id)
+5
packages/browser/src/client/tester/tester.ts
··· 111 111 getBrowserState().activeTraceTaskIds = new Set() 112 112 getBrowserState().iframeId = iframeId 113 113 114 + channel.postMessage({ 115 + event: 'ready', 116 + iframeId, 117 + }) 118 + 114 119 let contextSwitched = false 115 120 116 121 async function prepareTestEnvironment(options: PrepareOptions) {