diff --git a/packages/browser/src/client/orchestrator.ts b/packages/browser/src/client/orchestrator.ts index 675d9331401d..8b3fbb8cde72 100644 --- a/packages/browser/src/client/orchestrator.ts +++ b/packages/browser/src/client/orchestrator.ts @@ -124,7 +124,7 @@ export class IframeOrchestrator { if (!iframe) { return } - await sendEventToIframe({ + await this.sendEventToIframe({ event: 'cleanup', iframeId: ID_ALL, }) @@ -158,7 +158,7 @@ export class IframeOrchestrator { await setIframeViewport(iframe, width, height) debug('run non-isolated tests', options.files.join(', ')) - await sendEventToIframe({ + await this.sendEventToIframe({ event: 'execute', iframeId: ID_ALL, files: options.files, @@ -195,7 +195,7 @@ export class IframeOrchestrator { ) await setIframeViewport(iframe, width, height) // running tests after the "prepare" event - await sendEventToIframe({ + await this.sendEventToIframe({ event: 'execute', files: [spec], method: options.method, @@ -203,7 +203,7 @@ export class IframeOrchestrator { context: options.providedContext, }) // perform "cleanup" to cleanup resources and calculate the coverage - await sendEventToIframe({ + await this.sendEventToIframe({ event: 'cleanup', iframeId: file, }) @@ -233,12 +233,21 @@ export class IframeOrchestrator { `Cannot connect to the iframe. ` + `Did you change the location or submitted a form? ` + 'If so, don\'t forget to call `event.preventDefault()` to avoid reloading the page.\n\n' - + `Received URL: ${href || 'unknown'}\nExpected: ${iframe.src}`, + + `Received URL: ${href || 'unknown due to CORS'}\nExpected: ${iframe.src}`, ))) } + else if (this.iframes.has(iframeId)) { + const events = this.iframeEvents.get(iframe) + if (events?.size) { + this.dispatchIframeError(new Error(this.createWarningMessage(iframeId, 'during a test'))) + } + else { + this.warnReload(iframe, iframeId) + } + } else { this.iframes.set(iframeId, iframe) - sendEventToIframe({ + this.sendEventToIframe({ event: 'prepare', iframeId, startTime, @@ -261,6 +270,32 @@ export class IframeOrchestrator { return iframe } + private loggedIframe = new WeakSet() + + private createWarningMessage(iframeId: string, location: string) { + return `The iframe${iframeId === ID_ALL ? '' : ` for "${iframeId}"`} was reloaded ${location}. ` + + `This can lead to unexpected behavior during tests, duplicated test results or tests hanging.\n\n` + + `Make sure that your test code does not change window's location, submit forms without preventing default behavior, or imports unoptimized dependencies.\n` + + `If you are using a framework that manipulates browser history (like React Router), consider using memory-based routing for tests. ` + + `If you think this is a false positive, open an issue with a reproduction: https://github.com/vitest-dev/vitest/issues/new` + } + + private warnReload(iframe: HTMLIFrameElement, iframeId: string) { + if (this.loggedIframe.has(iframe)) { + return + } + this.loggedIframe.add(iframe) + const message = `\x1B[41m WARNING \x1B[49m ${this.createWarningMessage(iframeId, 'multiple times')}` + + client.rpc.sendLog('run', { + type: 'stderr', + time: Date.now(), + content: message, + size: message.length, + taskId: iframeId === ID_ALL ? undefined : generateFileId(iframeId), + }).catch(() => { /* ignore */ }) + } + private getIframeHref(iframe: HTMLIFrameElement) { try { // same origin iframe has contentWindow @@ -345,6 +380,46 @@ export class IframeOrchestrator { } } } + + private iframeEvents = new WeakMap>() + + private async sendEventToIframe(event: IframeChannelOutgoingEvent): Promise { + const iframe = this.iframes.get(event.iframeId) + if (!iframe) { + throw new Error(`Cannot find iframe with id ${event.iframeId}`) + } + let events = this.iframeEvents.get(iframe) + if (!events) { + events = new Set() + this.iframeEvents.set(iframe, events) + } + events.add(event.event) + + channel.postMessage(event) + return new Promise((resolve, reject) => { + const cleanupEvents = () => { + channel.removeEventListener('message', onReceived) + this.eventTarget.removeEventListener('iframeerror', onError) + } + + function onReceived(e: MessageEvent) { + if (e.data.iframeId === event.iframeId && e.data.event === `response:${event.event}`) { + resolve() + cleanupEvents() + events!.delete(event.event) + } + } + + function onError(e: Event) { + reject((e as CustomEvent).detail) + cleanupEvents() + events!.delete(event.event) + } + + this.eventTarget.addEventListener('iframeerror', onError) + channel.addEventListener('message', onReceived) + }) + } } const orchestrator = new IframeOrchestrator() @@ -365,31 +440,6 @@ async function getContainer(config: SerializedConfig): Promise { return document.querySelector('#vitest-tester') as HTMLDivElement } -async function sendEventToIframe(event: IframeChannelOutgoingEvent) { - channel.postMessage(event) - return new Promise((resolve, reject) => { - function cleanupEvents() { - channel.removeEventListener('message', onReceived) - orchestrator.eventTarget.removeEventListener('iframeerror', onError) - } - - function onReceived(e: MessageEvent) { - if (e.data.iframeId === event.iframeId && e.data.event === `response:${event.event}`) { - resolve() - cleanupEvents() - } - } - - function onError(e: Event) { - reject((e as CustomEvent).detail) - cleanupEvents() - } - - orchestrator.eventTarget.addEventListener('iframeerror', onError) - channel.addEventListener('message', onReceived) - }) -} - function generateFileId(file: string) { const config = getConfig() const path = relative(config.root, file) diff --git a/test/browser/specs/unhandled.test.ts b/test/browser/specs/errors.test.ts similarity index 74% rename from test/browser/specs/unhandled.test.ts rename to test/browser/specs/errors.test.ts index 3168bf524806..e43fe55a8e84 100644 --- a/test/browser/specs/unhandled.test.ts +++ b/test/browser/specs/errors.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest' -import { instances, runBrowserTests } from './utils' +import { instances, runBrowserTests, runInlineBrowserTests } from './utils' test('prints correct unhandled error stack', async () => { const { stderr } = await runBrowserTests({ @@ -51,3 +51,18 @@ test('print unhandled non error', async () => { } `) }) + +test('throws an error if test reloads the iframe during a test run', async () => { + const { stderr, fs } = await runInlineBrowserTests({ + 'iframe-reload.test.ts': ` + import { test } from 'vitest'; + + test('reload iframe', () => { + location.reload(); + }); + `, + }) + expect(stderr).toContain( + `The iframe for "${fs.resolveFile('./iframe-reload.test.ts')}" was reloaded during a test.`, + ) +}) diff --git a/test/browser/specs/to-match-screenshot.test.ts b/test/browser/specs/to-match-screenshot.test.ts index 59828a130d5b..1591bb5bbacb 100644 --- a/test/browser/specs/to-match-screenshot.test.ts +++ b/test/browser/specs/to-match-screenshot.test.ts @@ -27,7 +27,7 @@ test('${testName}', async ({ expect }) => { const browser = 'chromium' -export async function runInlineTests( +async function runInlineTests( structure: TestFsStructure, config: ViteUserConfig['test'] = {}, ) { diff --git a/test/browser/specs/utils.ts b/test/browser/specs/utils.ts index 061feea62430..e144ef104882 100644 --- a/test/browser/specs/utils.ts +++ b/test/browser/specs/utils.ts @@ -1,11 +1,34 @@ import type { UserConfig as ViteUserConfig } from 'vite' import type { TestUserConfig } from 'vitest/node' -import type { VitestRunnerCLIOptions } from '../../test-utils' -import { runVitest } from '../../test-utils' -import { browser } from '../settings' +import type { RunVitestConfig, TestFsStructure, VitestRunnerCLIOptions } from '../../test-utils' +import { runInlineTests, runVitest } from '../../test-utils' +import { browser, instances, provider } from '../settings' export { browser, instances, provider } from '../settings' +export async function runInlineBrowserTests( + structure: TestFsStructure, + config?: RunVitestConfig, + options?: VitestRunnerCLIOptions, +) { + return runInlineTests( + structure, + { + watch: false, + reporters: 'none', + ...config, + browser: { + enabled: true, + provider, + instances, + headless: browser !== 'safari', + ...config?.browser, + } as TestUserConfig['browser'], + }, + options, + ) +} + export async function runBrowserTests( config?: Omit & { browser?: Partial }, include?: string[],