From 5ba3c641356727185182cad6026905830621fee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9amus=20O=27Connor?= Date: Thu, 28 May 2026 10:19:15 -0700 Subject: [PATCH] fix(browser): wait for iframe tester readiness before preparing --- packages/browser/src/client/channel.ts | 6 ++ packages/browser/src/client/orchestrator.ts | 60 ++++++++++++++++---- packages/browser/src/client/tester/tester.ts | 5 ++ test/browser/specs/readiness.test.ts | 60 ++++++++++++++++++++ 4 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 test/browser/specs/readiness.test.ts diff --git a/packages/browser/src/client/channel.ts b/packages/browser/src/client/channel.ts index 9829a42b39d4..9f3f7dd26a20 100644 --- a/packages/browser/src/client/channel.ts +++ b/packages/browser/src/client/channel.ts @@ -21,6 +21,11 @@ export interface IframeViewportDoneEvent { iframeId: string } +export interface IframeReadyEvent { + event: 'ready' + iframeId: string +} + export interface GlobalChannelTestRunCanceledEvent { type: 'cancel' reason: CancelReason @@ -50,6 +55,7 @@ export type GlobalChannelIncomingEvent = GlobalChannelTestRunCanceledEvent export type IframeChannelIncomingEvent = | IframeViewportEvent + | IframeReadyEvent export type IframeChannelOutgoingEvent = | IframeExecuteEvent diff --git a/packages/browser/src/client/orchestrator.ts b/packages/browser/src/client/orchestrator.ts index 73449c10890a..40d49ad6a06f 100644 --- a/packages/browser/src/client/orchestrator.ts +++ b/packages/browser/src/client/orchestrator.ts @@ -1,5 +1,5 @@ import type { Context as OTELContext } from '@opentelemetry/api' -import type { GlobalChannelIncomingEvent, IframeChannelIncomingEvent, IframeChannelOutgoingEvent, IframeViewportDoneEvent, IframeViewportFailEvent } from '@vitest/browser/client' +import type { GlobalChannelIncomingEvent, IframeChannelEvent, IframeChannelOutgoingEvent, IframeViewportDoneEvent, IframeViewportFailEvent } from '@vitest/browser/client' import type { BrowserTesterOptions, SerializedConfig } from 'vitest' import type { FileSpecification } from 'vitest/internal/browser' import { channel, client, globalChannel } from '@vitest/browser/client' @@ -16,6 +16,8 @@ export class IframeOrchestrator { private cancelled = false private recreateNonIsolatedIframe = false private iframes = new Map() + private readyIframes = new Set() + private readyWaiters = new Map void>() public eventTarget: EventTarget = new EventTarget() @@ -91,6 +93,8 @@ export class IframeOrchestrator { this.iframes.forEach(iframe => iframe.remove()) this.iframes.clear() + this.readyIframes.clear() + this.readyWaiters.clear() for (let i = 0; i < options.files.length; i++) { if (this.cancelled) { @@ -149,8 +153,7 @@ export class IframeOrchestrator { // because we called "cleanup" in the previous run // the iframe is not removed immediately to let the user see the last test this.recreateNonIsolatedIframe = false - this.iframes.get(ID_ALL)!.remove() - this.iframes.delete(ID_ALL) + this.removeIframe(ID_ALL) debug('recreate non-isolated iframe') } @@ -189,8 +192,7 @@ export class IframeOrchestrator { const file = spec.filepath if (this.iframes.has(file)) { - this.iframes.get(file)!.remove() - this.iframes.delete(file) + this.removeIframe(file) } await this.prepareIframe( @@ -253,12 +255,14 @@ export class IframeOrchestrator { } else { this.iframes.set(iframeId, iframe) - this.sendEventToIframe({ - event: 'prepare', - iframeId, - startTime, - otelCarrier: this.traces.getContextCarrier(otelContext), - }).then(resolve, error => reject(this.dispatchIframeError(error))) + this.waitForReady(iframeId) + .then(() => this.sendEventToIframe({ + event: 'prepare', + iframeId, + startTime, + otelCarrier: this.traces.getContextCarrier(otelContext), + })) + .then(resolve, error => reject(this.dispatchIframeError(error))) } } iframe.onerror = (e) => { @@ -276,6 +280,34 @@ export class IframeOrchestrator { return iframe } + private markReady(iframeId: string) { + this.readyIframes.add(iframeId) + + const waiter = this.readyWaiters.get(iframeId) + if (waiter) { + this.readyWaiters.delete(iframeId) + waiter() + } + } + + private waitForReady(iframeId: string): Promise { + if (this.readyIframes.has(iframeId)) { + return Promise.resolve() + } + + return new Promise((resolve) => { + this.readyWaiters.set(iframeId, resolve) + }) + } + + private removeIframe(iframeId: string) { + const iframe = this.iframes.get(iframeId) + this.iframes.delete(iframeId) + this.readyIframes.delete(iframeId) + this.readyWaiters.delete(iframeId) + iframe?.remove() + } + private loggedIframe = new WeakSet() private createWarningMessage(iframeId: string, location: string) { @@ -365,9 +397,13 @@ export class IframeOrchestrator { } } - private async onIframeEvent(e: MessageEvent) { + private async onIframeEvent(e: MessageEvent) { debug('iframe event', JSON.stringify(e.data)) switch (e.data.event) { + case 'ready': { + this.markReady(e.data.iframeId) + break + } case 'viewport': { const { width, height, iframeId: id } = e.data const iframe = this.iframes.get(id) diff --git a/packages/browser/src/client/tester/tester.ts b/packages/browser/src/client/tester/tester.ts index 1bb118591083..e932bc5d4979 100644 --- a/packages/browser/src/client/tester/tester.ts +++ b/packages/browser/src/client/tester/tester.ts @@ -114,6 +114,11 @@ getBrowserState().iframeId = iframeId registerPageMarkHandler((name, options) => page.mark(name, options)) +channel.postMessage({ + event: 'ready', + iframeId, +}) + let contextSwitched = false async function prepareTestEnvironment(options: PrepareOptions) { diff --git a/test/browser/specs/readiness.test.ts b/test/browser/specs/readiness.test.ts new file mode 100644 index 000000000000..511466b4ec55 --- /dev/null +++ b/test/browser/specs/readiness.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from 'vitest' +import { instances, runInlineBrowserTests } from './utils' + +test('prepare waits until the tester can receive browser channel events', { timeout: 5000 }, async () => { + const { stderr, testTree } = await runInlineBrowserTests( + { + 'basic.test.ts': ` + import { expect, test } from 'vitest' + + test('runs in the browser', () => { + expect(1).toBe(1) + }) + `, + 'delayed-tester.html': ` + + + + + + Delayed Tester + + + + + `, + }, + { + browser: { + instances: [instances[0]], + testerHtmlPath: './delayed-tester.html', + }, + }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "basic.test.ts": { + "runs in the browser": "passed", + }, + } + `) +})