diff --git a/packages/browser/src/client/orchestrator.ts b/packages/browser/src/client/orchestrator.ts index 465ec1091..213871de2 100644 --- a/packages/browser/src/client/orchestrator.ts +++ b/packages/browser/src/client/orchestrator.ts @@ -13,6 +13,8 @@ export class IframeOrchestrator { private recreateNonIsolatedIframe = false private iframes = new Map() + public eventTarget: EventTarget = new EventTarget() + constructor() { debug('init orchestrator', getBrowserState().sessionId) @@ -122,6 +124,7 @@ export class IframeOrchestrator { method: options.method, context: options.providedContext, }) + debug('finished running tests', options.files.join(', ')) // we don't cleanup here because in non-isolated mode // it is done after all tests finished running } @@ -156,34 +159,65 @@ export class IframeOrchestrator { }) } + private dispatchIframeError(error: Error) { + const event = new CustomEvent('iframeerror', { detail: error }) + this.eventTarget.dispatchEvent(event) + return error + } + private async prepareIframe(container: HTMLDivElement, iframeId: string, startTime: number) { const iframe = this.createTestIframe(iframeId) container.appendChild(iframe) await new Promise((resolve, reject) => { iframe.onload = () => { - this.iframes.set(iframeId, iframe) - sendEventToIframe({ - event: 'prepare', - iframeId, - startTime, - }).then(resolve, reject) + const href = this.getIframeHref(iframe) + debug('iframe loaded with href', href) + if (href !== iframe.src) { + reject(this.dispatchIframeError(new Error( + `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}`, + ))) + } + else { + this.iframes.set(iframeId, iframe) + sendEventToIframe({ + event: 'prepare', + iframeId, + startTime, + }).then(resolve, error => reject(this.dispatchIframeError(error))) + } } iframe.onerror = (e) => { if (typeof e === 'string') { - reject(new Error(e)) + reject(this.dispatchIframeError(new Error(e))) } else if (e instanceof ErrorEvent) { - reject(e.error) + reject(this.dispatchIframeError(e.error)) } else { - reject(new Error(`Cannot load the iframe ${iframeId}.`)) + reject(this.dispatchIframeError(new Error(`Cannot load the iframe ${iframeId}.`))) } } }) return iframe } + private getIframeHref(iframe: HTMLIFrameElement) { + try { + // same origin iframe has contentWindow + // same origin trusted iframe (where tests can run) + // also allows accessing "location" + return iframe.contentWindow?.location.href + } + catch { + // looks like this iframe is not a tester.html + return undefined + } + } + private createTestIframe(iframeId: string) { const iframe = document.createElement('iframe') const src = `/?sessionId=${getBrowserState().sessionId}&iframeId=${iframeId}` @@ -257,7 +291,8 @@ export class IframeOrchestrator { } } -getBrowserState().orchestrator = new IframeOrchestrator() +const orchestrator = new IframeOrchestrator() +getBrowserState().orchestrator = orchestrator async function getContainer(config: SerializedConfig): Promise { if (config.browser.ui) { @@ -276,16 +311,26 @@ async function getContainer(config: SerializedConfig): Promise { async function sendEventToIframe(event: IframeChannelOutgoingEvent) { channel.postMessage(event) - return new Promise((resolve) => { - channel.addEventListener( - 'message', - function handler(e) { - if (e.data.iframeId === event.iframeId && e.data.event === `response:${event.event}`) { - resolve() - channel.removeEventListener('message', handler) - } - }, - ) + 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) }) } diff --git a/test/browser/fixtures/broken-iframe/submit-form.test.ts b/test/browser/fixtures/broken-iframe/submit-form.test.ts new file mode 100644 index 000000000..2b8f245d3 --- /dev/null +++ b/test/browser/fixtures/broken-iframe/submit-form.test.ts @@ -0,0 +1,12 @@ +import { userEvent } from '@vitest/browser/context'; +import { test } from 'vitest'; + +test('submitting a form reloads the iframe with "?" query', async () => { + const form = document.createElement('form') + document.body.append(form) + form.id = 'form' + const button = document.createElement('button') + button.id = 'button' + form.append(button) + await userEvent.click(button) +}) diff --git a/test/browser/fixtures/broken-iframe/vitest.config.ts b/test/browser/fixtures/broken-iframe/vitest.config.ts new file mode 100644 index 000000000..f1718205a --- /dev/null +++ b/test/browser/fixtures/broken-iframe/vitest.config.ts @@ -0,0 +1,15 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' +import { provider, instances } from '../../settings' + +export default defineConfig({ + cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), + test: { + browser: { + enabled: true, + provider, + instances, + headless: true, + }, + }, +}) diff --git a/test/browser/package.json b/test/browser/package.json index ed716c6ae..114b02163 100644 --- a/test/browser/package.json +++ b/test/browser/package.json @@ -21,6 +21,7 @@ "test-different-configs": "vitest --root ./fixtures/multiple-different-configs", "test-setup-file": "vitest --root ./fixtures/setup-file", "test-snapshots": "vitest --root ./fixtures/update-snapshot", + "test-broken-iframe": "vitest --root ./fixtures/broken-iframe", "coverage": "vitest --coverage.enabled --coverage.provider=istanbul --browser.headless=yes", "test:browser:preview": "PROVIDER=preview vitest", "test:browser:playwright": "PROVIDER=playwright vitest", diff --git a/test/browser/specs/bail-out.test.ts b/test/browser/specs/bail-out.test.ts new file mode 100644 index 000000000..959435338 --- /dev/null +++ b/test/browser/specs/bail-out.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from 'vitest' +import { runBrowserTests } from './utils' + +test('fails gracefully when browser crashes', async () => { + const { stderr } = await runBrowserTests({ + root: './fixtures/browser-crash', + reporters: [['verbose', { isTTY: false }]], + }) + + expect(stderr).toContain('Browser connection was closed while running tests. Was the page closed unexpectedly?') +}) + +test('vitest bails out when the iframe is no longer accessible', async () => { + const { stderr } = await runBrowserTests({ + root: './fixtures/broken-iframe', + reporters: [['verbose', { isTTY: false }]], + }, [], {}, { fails: true }) + expect(stderr).toContain( + '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.', + ) + expect(stderr).toContain('Received URL: http://') + expect(stderr).toContain('Expected: http://') +}) diff --git a/test/browser/specs/browser-crash.test.ts b/test/browser/specs/browser-crash.test.ts deleted file mode 100644 index a7410855a..000000000 --- a/test/browser/specs/browser-crash.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { expect, test } from 'vitest' -import { runBrowserTests } from './utils' - -test('fails gracefully when browser crashes', async () => { - const { stderr } = await runBrowserTests({ - root: './fixtures/browser-crash', - reporters: [['verbose', { isTTY: false }]], - }) - - expect(stderr).toContain('Browser connection was closed while running tests. Was the page closed unexpectedly?') -})