[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): throw an error if iframe was reloaded (#9516)

authored by

Vladimir and committed by
GitHub
(Jan 24, 2026, 12:07 PM +0100) 73a81f88 2a8cb9dc

+176 -88
+68
test/browser/specs/errors.test.ts
··· 1 + import { expect, test } from 'vitest' 2 + import { instances, runBrowserTests, runInlineBrowserTests } from './utils' 3 + 4 + test('prints correct unhandled error stack', async () => { 5 + const { stderr } = await runBrowserTests({ 6 + root: './fixtures/unhandled', 7 + }) 8 + 9 + expect(stderr).toContain('throw-unhandled-error.test.ts:9:10') 10 + expect(stderr).toContain('This error originated in "throw-unhandled-error.test.ts" test file.') 11 + expect(stderr).toContain('The latest test that might\'ve caused the error is "unhandled exception".') 12 + 13 + if (instances.some(({ browser }) => browser === 'webkit')) { 14 + expect(stderr).toContain('throw-unhandled-error.test.ts:9:20') 15 + } 16 + }) 17 + 18 + test('ignores unhandled errors', async () => { 19 + const { stderr } = await runBrowserTests({ 20 + root: './fixtures/unhandled', 21 + onUnhandledError(error) { 22 + if (error.message.includes('custom_unhandled_error')) { 23 + return false 24 + } 25 + }, 26 + }) 27 + 28 + expect(stderr).toBe('') 29 + }) 30 + 31 + test('disables tracking', async () => { 32 + const { stderr } = await runBrowserTests({ 33 + root: './fixtures/unhandled', 34 + browser: { 35 + trackUnhandledErrors: false, 36 + }, 37 + }) 38 + expect(stderr).toBe('') 39 + }) 40 + 41 + test('print unhandled non error', async () => { 42 + const { testTree, stderr } = await runBrowserTests({ 43 + root: './fixtures/unhandled-non-error', 44 + }) 45 + expect(stderr).toContain('[Error: ResizeObserver loop completed with undelivered notifications.]') 46 + expect(testTree()).toMatchInlineSnapshot(` 47 + { 48 + "basic.test.ts": { 49 + "ResizeObserver error": "passed", 50 + }, 51 + } 52 + `) 53 + }) 54 + 55 + test('throws an error if test reloads the iframe during a test run', async () => { 56 + const { stderr, fs } = await runInlineBrowserTests({ 57 + 'iframe-reload.test.ts': ` 58 + import { test } from 'vitest'; 59 + 60 + test('reload iframe', () => { 61 + location.reload(); 62 + }); 63 + `, 64 + }) 65 + expect(stderr).toContain( 66 + `The iframe for "${fs.resolveFile('./iframe-reload.test.ts')}" was reloaded during a test.`, 67 + ) 68 + })
+1 -1
test/browser/specs/to-match-screenshot.test.ts
··· 27 27 28 28 const browser = 'chromium' 29 29 30 - export async function runInlineTests( 30 + async function runInlineTests( 31 31 structure: TestFsStructure, 32 32 config: ViteUserConfig['test'] = {}, 33 33 ) {
-53
test/browser/specs/unhandled.test.ts
··· 1 - import { expect, test } from 'vitest' 2 - import { instances, runBrowserTests } from './utils' 3 - 4 - test('prints correct unhandled error stack', async () => { 5 - const { stderr } = await runBrowserTests({ 6 - root: './fixtures/unhandled', 7 - }) 8 - 9 - expect(stderr).toContain('throw-unhandled-error.test.ts:9:10') 10 - expect(stderr).toContain('This error originated in "throw-unhandled-error.test.ts" test file.') 11 - expect(stderr).toContain('The latest test that might\'ve caused the error is "unhandled exception".') 12 - 13 - if (instances.some(({ browser }) => browser === 'webkit')) { 14 - expect(stderr).toContain('throw-unhandled-error.test.ts:9:20') 15 - } 16 - }) 17 - 18 - test('ignores unhandled errors', async () => { 19 - const { stderr } = await runBrowserTests({ 20 - root: './fixtures/unhandled', 21 - onUnhandledError(error) { 22 - if (error.message.includes('custom_unhandled_error')) { 23 - return false 24 - } 25 - }, 26 - }) 27 - 28 - expect(stderr).toBe('') 29 - }) 30 - 31 - test('disables tracking', async () => { 32 - const { stderr } = await runBrowserTests({ 33 - root: './fixtures/unhandled', 34 - browser: { 35 - trackUnhandledErrors: false, 36 - }, 37 - }) 38 - expect(stderr).toBe('') 39 - }) 40 - 41 - test('print unhandled non error', async () => { 42 - const { testTree, stderr } = await runBrowserTests({ 43 - root: './fixtures/unhandled-non-error', 44 - }) 45 - expect(stderr).toContain('[Error: ResizeObserver loop completed with undelivered notifications.]') 46 - expect(testTree()).toMatchInlineSnapshot(` 47 - { 48 - "basic.test.ts": { 49 - "ResizeObserver error": "passed", 50 - }, 51 - } 52 - `) 53 - })
+26 -3
test/browser/specs/utils.ts
··· 1 1 import type { UserConfig as ViteUserConfig } from 'vite' 2 2 import type { TestUserConfig } from 'vitest/node' 3 - import type { VitestRunnerCLIOptions } from '../../test-utils' 4 - import { runVitest } from '../../test-utils' 5 - import { browser } from '../settings' 3 + import type { RunVitestConfig, TestFsStructure, VitestRunnerCLIOptions } from '../../test-utils' 4 + import { runInlineTests, runVitest } from '../../test-utils' 5 + import { browser, instances, provider } from '../settings' 6 6 7 7 export { browser, instances, provider } from '../settings' 8 + 9 + export async function runInlineBrowserTests( 10 + structure: TestFsStructure, 11 + config?: RunVitestConfig, 12 + options?: VitestRunnerCLIOptions, 13 + ) { 14 + return runInlineTests( 15 + structure, 16 + { 17 + watch: false, 18 + reporters: 'none', 19 + ...config, 20 + browser: { 21 + enabled: true, 22 + provider, 23 + instances, 24 + headless: browser !== 'safari', 25 + ...config?.browser, 26 + } as TestUserConfig['browser'], 27 + }, 28 + options, 29 + ) 30 + } 8 31 9 32 export async function runBrowserTests( 10 33 config?: Omit<TestUserConfig, 'browser'> & { browser?: Partial<TestUserConfig['browser']> },
+81 -31
packages/browser/src/client/orchestrator.ts
··· 124 124 if (!iframe) { 125 125 return 126 126 } 127 - await sendEventToIframe({ 127 + await this.sendEventToIframe({ 128 128 event: 'cleanup', 129 129 iframeId: ID_ALL, 130 130 }) ··· 158 158 159 159 await setIframeViewport(iframe, width, height) 160 160 debug('run non-isolated tests', options.files.join(', ')) 161 - await sendEventToIframe({ 161 + await this.sendEventToIframe({ 162 162 event: 'execute', 163 163 iframeId: ID_ALL, 164 164 files: options.files, ··· 195 195 ) 196 196 await setIframeViewport(iframe, width, height) 197 197 // running tests after the "prepare" event 198 - await sendEventToIframe({ 198 + await this.sendEventToIframe({ 199 199 event: 'execute', 200 200 files: [spec], 201 201 method: options.method, ··· 203 203 context: options.providedContext, 204 204 }) 205 205 // perform "cleanup" to cleanup resources and calculate the coverage 206 - await sendEventToIframe({ 206 + await this.sendEventToIframe({ 207 207 event: 'cleanup', 208 208 iframeId: file, 209 209 }) ··· 233 233 `Cannot connect to the iframe. ` 234 234 + `Did you change the location or submitted a form? ` 235 235 + 'If so, don\'t forget to call `event.preventDefault()` to avoid reloading the page.\n\n' 236 - + `Received URL: ${href || 'unknown'}\nExpected: ${iframe.src}`, 236 + + `Received URL: ${href || 'unknown due to CORS'}\nExpected: ${iframe.src}`, 237 237 ))) 238 + } 239 + else if (this.iframes.has(iframeId)) { 240 + const events = this.iframeEvents.get(iframe) 241 + if (events?.size) { 242 + this.dispatchIframeError(new Error(this.createWarningMessage(iframeId, 'during a test'))) 243 + } 244 + else { 245 + this.warnReload(iframe, iframeId) 246 + } 238 247 } 239 248 else { 240 249 this.iframes.set(iframeId, iframe) 241 - sendEventToIframe({ 250 + this.sendEventToIframe({ 242 251 event: 'prepare', 243 252 iframeId, 244 253 startTime, ··· 259 268 } 260 269 }) 261 270 return iframe 271 + } 272 + 273 + private loggedIframe = new WeakSet<HTMLIFrameElement>() 274 + 275 + private createWarningMessage(iframeId: string, location: string) { 276 + return `The iframe${iframeId === ID_ALL ? '' : ` for "${iframeId}"`} was reloaded ${location}. ` 277 + + `This can lead to unexpected behavior during tests, duplicated test results or tests hanging.\n\n` 278 + + `Make sure that your test code does not change window's location, submit forms without preventing default behavior, or imports unoptimized dependencies.\n` 279 + + `If you are using a framework that manipulates browser history (like React Router), consider using memory-based routing for tests. ` 280 + + `If you think this is a false positive, open an issue with a reproduction: https://github.com/vitest-dev/vitest/issues/new` 281 + } 282 + 283 + private warnReload(iframe: HTMLIFrameElement, iframeId: string) { 284 + if (this.loggedIframe.has(iframe)) { 285 + return 286 + } 287 + this.loggedIframe.add(iframe) 288 + const message = `\x1B[41m WARNING \x1B[49m ${this.createWarningMessage(iframeId, 'multiple times')}` 289 + 290 + client.rpc.sendLog('run', { 291 + type: 'stderr', 292 + time: Date.now(), 293 + content: message, 294 + size: message.length, 295 + taskId: iframeId === ID_ALL ? undefined : generateFileId(iframeId), 296 + }).catch(() => { /* ignore */ }) 262 297 } 263 298 264 299 private getIframeHref(iframe: HTMLIFrameElement) { ··· 345 380 } 346 381 } 347 382 } 383 + 384 + private iframeEvents = new WeakMap<HTMLIFrameElement, Set<string>>() 385 + 386 + private async sendEventToIframe(event: IframeChannelOutgoingEvent): Promise<void> { 387 + const iframe = this.iframes.get(event.iframeId) 388 + if (!iframe) { 389 + throw new Error(`Cannot find iframe with id ${event.iframeId}`) 390 + } 391 + let events = this.iframeEvents.get(iframe) 392 + if (!events) { 393 + events = new Set() 394 + this.iframeEvents.set(iframe, events) 395 + } 396 + events.add(event.event) 397 + 398 + channel.postMessage(event) 399 + return new Promise<void>((resolve, reject) => { 400 + const cleanupEvents = () => { 401 + channel.removeEventListener('message', onReceived) 402 + this.eventTarget.removeEventListener('iframeerror', onError) 403 + } 404 + 405 + function onReceived(e: MessageEvent) { 406 + if (e.data.iframeId === event.iframeId && e.data.event === `response:${event.event}`) { 407 + resolve() 408 + cleanupEvents() 409 + events!.delete(event.event) 410 + } 411 + } 412 + 413 + function onError(e: Event) { 414 + reject((e as CustomEvent).detail) 415 + cleanupEvents() 416 + events!.delete(event.event) 417 + } 418 + 419 + this.eventTarget.addEventListener('iframeerror', onError) 420 + channel.addEventListener('message', onReceived) 421 + }) 422 + } 348 423 } 349 424 350 425 const orchestrator = new IframeOrchestrator() ··· 363 438 return element as HTMLDivElement 364 439 } 365 440 return document.querySelector('#vitest-tester') as HTMLDivElement 366 - } 367 - 368 - async function sendEventToIframe(event: IframeChannelOutgoingEvent) { 369 - channel.postMessage(event) 370 - return new Promise<void>((resolve, reject) => { 371 - function cleanupEvents() { 372 - channel.removeEventListener('message', onReceived) 373 - orchestrator.eventTarget.removeEventListener('iframeerror', onError) 374 - } 375 - 376 - function onReceived(e: MessageEvent) { 377 - if (e.data.iframeId === event.iframeId && e.data.event === `response:${event.event}`) { 378 - resolve() 379 - cleanupEvents() 380 - } 381 - } 382 - 383 - function onError(e: Event) { 384 - reject((e as CustomEvent).detail) 385 - cleanupEvents() 386 - } 387 - 388 - orchestrator.eventTarget.addEventListener('iframeerror', onError) 389 - channel.addEventListener('message', onReceived) 390 - }) 391 441 } 392 442 393 443 function generateFileId(file: string) {