Skip to content

Commit 73a81f8

Browse files
authored
fix(browser): throw an error if iframe was reloaded (#9516)
1 parent 2a8cb9d commit 73a81f8

File tree

4 files changed

+124
-36
lines changed

4 files changed

+124
-36
lines changed

packages/browser/src/client/orchestrator.ts

Lines changed: 81 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export class IframeOrchestrator {
124124
if (!iframe) {
125125
return
126126
}
127-
await sendEventToIframe({
127+
await this.sendEventToIframe({
128128
event: 'cleanup',
129129
iframeId: ID_ALL,
130130
})
@@ -158,7 +158,7 @@ export class IframeOrchestrator {
158158

159159
await setIframeViewport(iframe, width, height)
160160
debug('run non-isolated tests', options.files.join(', '))
161-
await sendEventToIframe({
161+
await this.sendEventToIframe({
162162
event: 'execute',
163163
iframeId: ID_ALL,
164164
files: options.files,
@@ -195,15 +195,15 @@ export class IframeOrchestrator {
195195
)
196196
await setIframeViewport(iframe, width, height)
197197
// running tests after the "prepare" event
198-
await sendEventToIframe({
198+
await this.sendEventToIframe({
199199
event: 'execute',
200200
files: [spec],
201201
method: options.method,
202202
iframeId: file,
203203
context: options.providedContext,
204204
})
205205
// perform "cleanup" to cleanup resources and calculate the coverage
206-
await sendEventToIframe({
206+
await this.sendEventToIframe({
207207
event: 'cleanup',
208208
iframeId: file,
209209
})
@@ -233,12 +233,21 @@ export class IframeOrchestrator {
233233
`Cannot connect to the iframe. `
234234
+ `Did you change the location or submitted a form? `
235235
+ '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}`,
237237
)))
238238
}
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+
}
247+
}
239248
else {
240249
this.iframes.set(iframeId, iframe)
241-
sendEventToIframe({
250+
this.sendEventToIframe({
242251
event: 'prepare',
243252
iframeId,
244253
startTime,
@@ -261,6 +270,32 @@ export class IframeOrchestrator {
261270
return iframe
262271
}
263272

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 */ })
297+
}
298+
264299
private getIframeHref(iframe: HTMLIFrameElement) {
265300
try {
266301
// same origin iframe has contentWindow
@@ -345,6 +380,46 @@ export class IframeOrchestrator {
345380
}
346381
}
347382
}
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+
}
348423
}
349424

350425
const orchestrator = new IframeOrchestrator()
@@ -365,31 +440,6 @@ async function getContainer(config: SerializedConfig): Promise<HTMLDivElement> {
365440
return document.querySelector('#vitest-tester') as HTMLDivElement
366441
}
367442

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-
}
392-
393443
function generateFileId(file: string) {
394444
const config = getConfig()
395445
const path = relative(config.root, file)
Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from 'vitest'
2-
import { instances, runBrowserTests } from './utils'
2+
import { instances, runBrowserTests, runInlineBrowserTests } from './utils'
33

44
test('prints correct unhandled error stack', async () => {
55
const { stderr } = await runBrowserTests({
@@ -51,3 +51,18 @@ test('print unhandled non error', async () => {
5151
}
5252
`)
5353
})
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+
})

test/browser/specs/to-match-screenshot.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ test('${testName}', async ({ expect }) => {
2727

2828
const browser = 'chromium'
2929

30-
export async function runInlineTests(
30+
async function runInlineTests(
3131
structure: TestFsStructure,
3232
config: ViteUserConfig['test'] = {},
3333
) {

test/browser/specs/utils.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,34 @@
11
import type { UserConfig as ViteUserConfig } from 'vite'
22
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'
66

77
export { browser, instances, provider } from '../settings'
88

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+
}
31+
932
export async function runBrowserTests(
1033
config?: Omit<TestUserConfig, 'browser'> & { browser?: Partial<TestUserConfig['browser']> },
1134
include?: string[],

0 commit comments

Comments
 (0)