Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 81 additions & 31 deletions packages/browser/src/client/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export class IframeOrchestrator {
if (!iframe) {
return
}
await sendEventToIframe({
await this.sendEventToIframe({
event: 'cleanup',
iframeId: ID_ALL,
})
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -195,15 +195,15 @@ 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,
iframeId: file,
context: options.providedContext,
})
// perform "cleanup" to cleanup resources and calculate the coverage
await sendEventToIframe({
await this.sendEventToIframe({
event: 'cleanup',
iframeId: file,
})
Expand Down Expand Up @@ -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,
Expand All @@ -261,6 +270,32 @@ export class IframeOrchestrator {
return iframe
}

private loggedIframe = new WeakSet<HTMLIFrameElement>()

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
Expand Down Expand Up @@ -345,6 +380,46 @@ export class IframeOrchestrator {
}
}
}

private iframeEvents = new WeakMap<HTMLIFrameElement, Set<string>>()

private async sendEventToIframe(event: IframeChannelOutgoingEvent): Promise<void> {
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<void>((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()
Expand All @@ -365,31 +440,6 @@ async function getContainer(config: SerializedConfig): Promise<HTMLDivElement> {
return document.querySelector('#vitest-tester') as HTMLDivElement
}

async function sendEventToIframe(event: IframeChannelOutgoingEvent) {
channel.postMessage(event)
return new Promise<void>((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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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.`,
)
})
2 changes: 1 addition & 1 deletion test/browser/specs/to-match-screenshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ test('${testName}', async ({ expect }) => {

const browser = 'chromium'

export async function runInlineTests(
async function runInlineTests(
structure: TestFsStructure,
config: ViteUserConfig['test'] = {},
) {
Expand Down
29 changes: 26 additions & 3 deletions test/browser/specs/utils.ts
Original file line number Diff line number Diff line change
@@ -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<TestUserConfig, 'browser'> & { browser?: Partial<TestUserConfig['browser']> },
include?: string[],
Expand Down
Loading