From 84cdbe9b16fd1f9cb23858f4ff806aacd4640bfb Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 10 Jul 2024 14:32:54 +0200 Subject: [PATCH] feat: improve error handling in the browser mode --- packages/browser/package.json | 3 + packages/browser/rollup.config.js | 15 ++++ packages/browser/src/client/client.ts | 4 +- packages/browser/src/client/orchestrator.html | 3 +- packages/browser/src/client/orchestrator.ts | 4 +- .../src/client/public/error-catcher.js | 81 +++++++++++++++++++ packages/browser/src/client/tester/context.ts | 2 +- packages/browser/src/client/tester/mocker.ts | 4 +- packages/browser/src/client/tester/msw.ts | 4 +- packages/browser/src/client/tester/rpc.ts | 2 +- packages/browser/src/client/tester/runner.ts | 2 +- .../browser/src/client/tester/snapshot.ts | 2 +- packages/browser/src/client/tester/state.ts | 2 +- .../browser/src/client/tester/tester.html | 3 +- packages/browser/src/client/tester/tester.ts | 14 ++-- .../browser/src/client/tester/unhandled.ts | 25 +++--- packages/browser/src/client/vite.config.ts | 8 +- packages/browser/src/node/server.ts | 5 ++ .../browser/src/node/serverOrchestrator.ts | 6 +- packages/browser/src/node/serverTester.ts | 3 +- packages/ui/client/constants.ts | 4 +- packages/ui/vite.config.ts | 4 +- packages/vitest/package.json | 1 + pnpm-lock.yaml | 3 + tsconfig.base.json | 1 + 25 files changed, 166 insertions(+), 39 deletions(-) create mode 100644 packages/browser/src/client/public/error-catcher.js diff --git a/packages/browser/package.json b/packages/browser/package.json index 8930c6410d4f..0f4777c3cc82 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -28,6 +28,9 @@ "types": "./context.d.ts", "default": "./context.js" }, + "./client": { + "default": "./dist/client.js" + }, "./matchers": { "types": "./matchers.d.ts" }, diff --git a/packages/browser/rollup.config.js b/packages/browser/rollup.config.js index 28a585f18df3..64987eb9cb74 100644 --- a/packages/browser/rollup.config.js +++ b/packages/browser/rollup.config.js @@ -57,6 +57,21 @@ export default () => }), ], }, + { + input: './src/client/client.ts', + output: { + file: 'dist/client.js', + format: 'esm', + }, + plugins: [ + resolve({ + preferBuiltins: true, + }), + esbuild({ + target: 'node18', + }), + ], + }, { input: './src/client/tester/state.ts', output: { diff --git a/packages/browser/src/client/client.ts b/packages/browser/src/client/client.ts index a950bb2b52ff..5e7bb11108ed 100644 --- a/packages/browser/src/client/client.ts +++ b/packages/browser/src/client/client.ts @@ -6,7 +6,7 @@ import { getBrowserState } from './utils' const PAGE_TYPE = getBrowserState().type -export const PORT = import.meta.hot ? '51204' : location.port +export const PORT = location.port export const HOST = [location.hostname, PORT].filter(Boolean).join(':') export const SESSION_ID = PAGE_TYPE === 'orchestrator' @@ -136,4 +136,4 @@ function createClient() { export const client = createClient() -export { channel, waitForChannel } from './channel' +export * from './channel' diff --git a/packages/browser/src/client/orchestrator.html b/packages/browser/src/client/orchestrator.html index bd17271d2237..770bc5728f61 100644 --- a/packages/browser/src/client/orchestrator.html +++ b/packages/browser/src/client/orchestrator.html @@ -23,7 +23,8 @@ height: 100%; } - + {__VITEST_INJECTOR__} + {__VITEST_ERROR_CATCHER__} {__VITEST_SCRIPTS__} diff --git a/packages/browser/src/client/orchestrator.ts b/packages/browser/src/client/orchestrator.ts index 80b11efd2447..d455732401fb 100644 --- a/packages/browser/src/client/orchestrator.ts +++ b/packages/browser/src/client/orchestrator.ts @@ -1,10 +1,10 @@ import type { ResolvedConfig } from 'vitest' +import { channel, client } from '@vitest/browser/client' import { generateHash } from '@vitest/runner/utils' +import { type GlobalChannelIncomingEvent, type IframeChannelEvent, type IframeChannelIncomingEvent, globalChannel } from '@vitest/browser/client' import { relative } from 'pathe' -import { channel, client } from './client' import { getBrowserState, getConfig } from './utils' import { getUiAPI } from './ui' -import { type GlobalChannelIncomingEvent, type IframeChannelEvent, type IframeChannelIncomingEvent, globalChannel } from './channel' import { createModuleMocker } from './tester/msw' const url = new URL(location.href) diff --git a/packages/browser/src/client/public/error-catcher.js b/packages/browser/src/client/public/error-catcher.js new file mode 100644 index 000000000000..a5701f5c8e87 --- /dev/null +++ b/packages/browser/src/client/public/error-catcher.js @@ -0,0 +1,81 @@ +import { channel, client } from '/@id/@vitest/browser/client' + +function on(event, listener) { + window.addEventListener(event, listener) + return () => window.removeEventListener(event, listener) +} + +function serializeError(unhandledError) { + if (typeof unhandledError !== 'object' || !unhandledError) { + return { + message: String(unhandledError), + } + } + + return { + name: unhandledError.name, + message: unhandledError.message, + stack: String(unhandledError.stack), + } +} + +function catchWindowErrors(cb) { + let userErrorListenerCount = 0 + function throwUnhandlerError(e) { + if (userErrorListenerCount === 0 && e.error != null) { + cb(e) + } + else { + console.error(e.error) + } + } + const addEventListener = window.addEventListener.bind(window) + const removeEventListener = window.removeEventListener.bind(window) + window.addEventListener('error', throwUnhandlerError) + window.addEventListener = function (...args) { + if (args[0] === 'error') { + userErrorListenerCount++ + } + return addEventListener.apply(this, args) + } + window.removeEventListener = function (...args) { + if (args[0] === 'error' && userErrorListenerCount) { + userErrorListenerCount-- + } + return removeEventListener.apply(this, args) + } + return function clearErrorHandlers() { + window.removeEventListener('error', throwUnhandlerError) + } +} + +function registerUnexpectedErrors() { + catchWindowErrors(event => + reportUnexpectedError('Error', event.error), + ) + on('unhandledrejection', event => + reportUnexpectedError('Unhandled Rejection', event.reason)) +} + +async function reportUnexpectedError( + type, + error, +) { + const processedError = serializeError(error) + await client.rpc.onUnhandledError(processedError, type) + const state = __vitest_browser_runner__ + + if (state.type === 'orchestrator') { + return + } + + if (!state.runTests || !__vitest_worker__.current) { + channel.postMessage({ + type: 'done', + filenames: state.files, + id: state.iframeId, + }) + } +} + +registerUnexpectedErrors() diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index a4154864fb81..b23d36df426e 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -1,7 +1,7 @@ import type { Task, WorkerGlobalState } from 'vitest' +import type { BrowserRPC } from '@vitest/browser/client' import type { BrowserPage, UserEvent, UserEventClickOptions, UserEventTabOptions, UserEventTypeOptions } from '../../../context' import type { BrowserRunnerState } from '../utils' -import type { BrowserRPC } from '../client' // this file should not import anything directly, only types diff --git a/packages/browser/src/client/tester/mocker.ts b/packages/browser/src/client/tester/mocker.ts index 5aad7c1e97ea..785e0bc3292d 100644 --- a/packages/browser/src/client/tester/mocker.ts +++ b/packages/browser/src/client/tester/mocker.ts @@ -1,8 +1,8 @@ import { getType } from '@vitest/utils' import { extname, join } from 'pathe' +import type { IframeChannelOutgoingEvent } from '@vitest/browser/client' +import { channel, waitForChannel } from '@vitest/browser/client' import { getBrowserState, importId } from '../utils' -import type { IframeChannelOutgoingEvent } from '../channel' -import { channel, waitForChannel } from '../client' import { rpc } from './rpc' const now = Date.now diff --git a/packages/browser/src/client/tester/msw.ts b/packages/browser/src/client/tester/msw.ts index da7a244ec0aa..1cdc3b88ef20 100644 --- a/packages/browser/src/client/tester/msw.ts +++ b/packages/browser/src/client/tester/msw.ts @@ -1,10 +1,10 @@ +import { channel } from '@vitest/browser/client' import type { IframeChannelEvent, IframeMockEvent, IframeMockingDoneEvent, IframeUnmockEvent, -} from '../channel' -import { channel } from '../channel' +} from '@vitest/browser/client' export function createModuleMocker() { const mocks: Map = new Map() diff --git a/packages/browser/src/client/tester/rpc.ts b/packages/browser/src/client/tester/rpc.ts index 07ccbc11e5eb..ad1695c966fb 100644 --- a/packages/browser/src/client/tester/rpc.ts +++ b/packages/browser/src/client/tester/rpc.ts @@ -1,5 +1,5 @@ import { getSafeTimers } from 'vitest/utils' -import type { VitestBrowserClient } from '../client' +import type { VitestBrowserClient } from '@vitest/browser/client' const { get } = Reflect diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index b8b3d1cfe287..4ec5a4c9531a 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -5,8 +5,8 @@ import { NodeBenchmarkRunner, VitestTestRunner } from 'vitest/runners' import { loadDiffConfig, loadSnapshotSerializers, takeCoverageInsideWorker } from 'vitest/browser' import { TraceMap, originalPositionFor } from 'vitest/utils' import { page } from '@vitest/browser/context' +import { globalChannel } from '@vitest/browser/client' import { importFs, importId } from '../utils' -import { globalChannel } from '../channel' import { VitestBrowserSnapshotEnvironment } from './snapshot' import { rpc } from './rpc' import type { VitestBrowserClientMocker } from './mocker' diff --git a/packages/browser/src/client/tester/snapshot.ts b/packages/browser/src/client/tester/snapshot.ts index aa445fe4eb0d..8e1dd2f0e807 100644 --- a/packages/browser/src/client/tester/snapshot.ts +++ b/packages/browser/src/client/tester/snapshot.ts @@ -1,6 +1,6 @@ import type { SnapshotEnvironment } from 'vitest/snapshot' import { type ParsedStack, TraceMap, originalPositionFor } from 'vitest/utils' -import type { VitestBrowserClient } from '../client' +import type { VitestBrowserClient } from '@vitest/browser/client' export class VitestBrowserSnapshotEnvironment implements SnapshotEnvironment { private sourceMaps = new Map() diff --git a/packages/browser/src/client/tester/state.ts b/packages/browser/src/client/tester/state.ts index 21a2c65b6c0f..80d32bd6b2f6 100644 --- a/packages/browser/src/client/tester/state.ts +++ b/packages/browser/src/client/tester/state.ts @@ -1,7 +1,7 @@ import type { WorkerGlobalState } from 'vitest' import { parse } from 'flatted' +import type { BrowserRPC } from '@vitest/browser/client' import { getBrowserState } from '../utils' -import type { BrowserRPC } from '../client' const config = getBrowserState().config const contextId = getBrowserState().contextId diff --git a/packages/browser/src/client/tester/tester.html b/packages/browser/src/client/tester/tester.html index 60bfdd5089f1..13ae2549cf90 100644 --- a/packages/browser/src/client/tester/tester.html +++ b/packages/browser/src/client/tester/tester.html @@ -16,8 +16,9 @@ min-height: 100vh; } - + {__VITEST_INJECTOR__} + {__VITEST_ERROR_CATCHER__} {__VITEST_SCRIPTS__} void) { window.addEventListener(event, listener) return () => window.removeEventListener(event, listener) } -export function serializeError(unhandledError: any) { +function serializeError(unhandledError: any) { + if (typeof unhandledError !== 'object' || !unhandledError) { + return { + message: String(unhandledError), + } + } + return { - ...unhandledError, name: unhandledError.name, message: unhandledError.message, stack: String(unhandledError.stack), @@ -49,19 +53,20 @@ function catchWindowErrors(cb: (e: ErrorEvent) => void) { } } -export function registerUnexpectedErrors(rpc: typeof client.rpc) { +function registerUnexpectedErrors() { catchWindowErrors(event => - reportUnexpectedError(rpc, 'Error', event.error), + reportUnexpectedError('Error', event.error), ) on('unhandledrejection', event => - reportUnexpectedError(rpc, 'Unhandled Rejection', event.reason)) + reportUnexpectedError('Unhandled Rejection', event.reason)) } async function reportUnexpectedError( - rpc: typeof client.rpc, type: string, error: any, ) { - const processedError = processError(error) - await rpc.onUnhandledError(processedError, type) + const processedError = serializeError(error) + await client.rpc.onUnhandledError(processedError, type) } + +registerUnexpectedErrors() diff --git a/packages/browser/src/client/vite.config.ts b/packages/browser/src/client/vite.config.ts index 59e3ab89cde3..ab3b3dd1aa04 100644 --- a/packages/browser/src/client/vite.config.ts +++ b/packages/browser/src/client/vite.config.ts @@ -22,7 +22,13 @@ export default defineConfig({ orchestrator: resolve(__dirname, './orchestrator.html'), tester: resolve(__dirname, './tester/tester.html'), }, - external: [/^vitest\//, 'vitest', /^msw/, '@vitest/browser/context'], + external: [ + /^vitest\//, + 'vitest', + /^msw/, + '@vitest/browser/context', + '@vitest/browser/client', + ], }, }, plugins: [ diff --git a/packages/browser/src/node/server.ts b/packages/browser/src/node/server.ts index b833418e8c43..b552648b9c61 100644 --- a/packages/browser/src/node/server.ts +++ b/packages/browser/src/node/server.ts @@ -28,6 +28,7 @@ export class BrowserServer implements IBrowserServer { public testerHtml: Promise | string public orchestratorHtml: Promise | string public injectorJs: Promise | string + public errorCatcherJs: Promise | string public stateJs: Promise | string public state: BrowserServerState @@ -86,6 +87,10 @@ export class BrowserServer implements IBrowserServer { resolve(distRoot, 'client/esm-client-injector.js'), 'utf8', ).then(js => (this.injectorJs = js)) + this.errorCatcherJs = readFile( + resolve(distRoot, 'client/error-catcher.js'), + 'utf8', + ).then(js => (this.errorCatcherJs = js)) this.stateJs = readFile( resolve(distRoot, 'state.js'), 'utf-8', diff --git a/packages/browser/src/node/serverOrchestrator.ts b/packages/browser/src/node/serverOrchestrator.ts index bfa20dc15011..e6d176cc4d6c 100644 --- a/packages/browser/src/node/serverOrchestrator.ts +++ b/packages/browser/src/node/serverOrchestrator.ts @@ -59,7 +59,8 @@ export async function resolveOrchestrator( .replace( '', [ - '', + '{__VITEST_INJECTOR__}', + '{__VITEST_ERROR_CATCHER__}', '{__VITEST_SCRIPTS__}', ``, ].join('\n'), @@ -70,7 +71,8 @@ export async function resolveOrchestrator( __VITEST_FAVICON__: server.faviconUrl, __VITEST_TITLE__: 'Vitest Browser Runner', __VITEST_SCRIPTS__: server.orchestratorScripts, - __VITEST_INJECTOR__: injector, + __VITEST_INJECTOR__: ``, + __VITEST_ERROR_CATCHER__: ``, __VITEST_CONTEXT_ID__: JSON.stringify(contextId), }) } diff --git a/packages/browser/src/node/serverTester.ts b/packages/browser/src/node/serverTester.ts index d756efa0b8ad..97c187ec34aa 100644 --- a/packages/browser/src/node/serverTester.ts +++ b/packages/browser/src/node/serverTester.ts @@ -71,7 +71,8 @@ export async function resolveTester( __VITEST_FAVICON__: server.faviconUrl, __VITEST_TITLE__: 'Vitest Browser Tester', __VITEST_SCRIPTS__: server.testerScripts, - __VITEST_INJECTOR__: injector, + __VITEST_INJECTOR__: ``, + __VITEST_ERROR_CATCHER__: ``, __VITEST_APPEND__: `