diff --git a/packages/ui/client/auto-imports.d.ts b/packages/ui/client/auto-imports.d.ts index 1f23330f7d12..25801881e95f 100644 --- a/packages/ui/client/auto-imports.d.ts +++ b/packages/ui/client/auto-imports.d.ts @@ -90,6 +90,7 @@ declare global { const onUpdated: typeof import('vue')['onUpdated'] const openInEditor: typeof import('./composables/error')['openInEditor'] const params: typeof import('./composables/params')['params'] + const parseError: typeof import('./composables/error')['parseError'] const pausableWatch: typeof import('@vueuse/core')['pausableWatch'] const provide: typeof import('vue')['provide'] const provideLocal: typeof import('@vueuse/core')['provideLocal'] diff --git a/packages/ui/client/components.d.ts b/packages/ui/client/components.d.ts index 5a6b0d1a4fd7..afe16661a6de 100644 --- a/packages/ui/client/components.d.ts +++ b/packages/ui/client/components.d.ts @@ -13,6 +13,7 @@ declare module 'vue' { Dashboard: typeof import('./components/Dashboard.vue')['default'] DashboardEntry: typeof import('./components/dashboard/DashboardEntry.vue')['default'] DetailsPanel: typeof import('./components/DetailsPanel.vue')['default'] + ErrorEntry: typeof import('./components/dashboard/ErrorEntry.vue')['default'] FileDetails: typeof import('./components/FileDetails.vue')['default'] IconButton: typeof import('./components/IconButton.vue')['default'] Modal: typeof import('./components/Modal.vue')['default'] diff --git a/packages/ui/client/components/DetailsPanel.vue b/packages/ui/client/components/DetailsPanel.vue index d1929e5fee13..64d116c8ffa7 100644 --- a/packages/ui/client/components/DetailsPanel.vue +++ b/packages/ui/client/components/DetailsPanel.vue @@ -7,7 +7,7 @@ const open = ref(true) + +
Time
{{ time }}
+ diff --git a/packages/ui/client/composables/client/index.ts b/packages/ui/client/composables/client/index.ts index 8bf0df3f3c6d..2f0cfedaf79a 100644 --- a/packages/ui/client/composables/client/index.ts +++ b/packages/ui/client/composables/client/index.ts @@ -1,16 +1,18 @@ import { createClient, getTasks } from '@vitest/ws-client' import type { WebSocketStatus } from '@vueuse/core' -import type { File, ResolvedConfig } from 'vitest' +import type { ErrorWithDiff, File, ResolvedConfig } from 'vitest' import type { Ref } from 'vue' import { reactive } from 'vue' import type { RunState } from '../../../types' import { ENTRY_URL, isReport } from '../../constants' +import { parseError } from '../error' import { activeFileId } from '../params' import { createStaticClient } from './static' export { ENTRY_URL, PORT, HOST, isReport } from '../../constants' export const testRunState: Ref = ref('idle') +export const unhandledErrors: Ref = ref([]) export const client = (function createVitestClient() { if (isReport) { @@ -23,8 +25,9 @@ export const client = (function createVitestClient() { onTaskUpdate() { testRunState.value = 'running' }, - onFinished() { + onFinished(_files, errors) { testRunState.value = 'idle' + unhandledErrors.value = (errors || []).map(parseError) }, }, }) @@ -70,11 +73,13 @@ watch( ws.addEventListener('open', async () => { status.value = 'OPEN' client.state.filesMap.clear() - const [files, _config] = await Promise.all([ + const [files, _config, errors] = await Promise.all([ client.rpc.getFiles(), client.rpc.getConfig(), + client.rpc.getUnhandledErrors(), ]) client.state.collectFiles(files) + unhandledErrors.value = (errors || []).map(parseError) config.value = _config }) diff --git a/packages/ui/client/composables/client/static.ts b/packages/ui/client/composables/client/static.ts index 113af2310291..333226b50213 100644 --- a/packages/ui/client/composables/client/static.ts +++ b/packages/ui/client/composables/client/static.ts @@ -1,9 +1,8 @@ import type { BirpcReturn } from 'birpc' import type { VitestClient } from '@vitest/ws-client' -import type { WebSocketHandlers } from 'vitest/src/api/types' +import type { File, ModuleGraphData, ResolvedConfig, WebSocketEvents, WebSocketHandlers } from 'vitest' import { parse } from 'flatted' import { decompressSync, strFromU8 } from 'fflate' -import type { File, ModuleGraphData, ResolvedConfig } from 'vitest/src/types' import { StateManager } from '../../../../vitest/src/node/state' interface HTMLReportMetadata { @@ -11,6 +10,7 @@ interface HTMLReportMetadata { files: File[] config: ResolvedConfig moduleGraph: Record + unhandledErrors: unknown[] } const noop: any = () => {} @@ -42,6 +42,9 @@ export function createStaticClient(): VitestClient { getModuleGraph: async (id) => { return metadata.moduleGraph[id] }, + getUnhandledErrors: () => { + return metadata.unhandledErrors + }, getTransformResult: async (id) => { return { code: id, @@ -66,9 +69,12 @@ export function createStaticClient(): VitestClient { saveSnapshotFile: asyncNoop, readTestFile: asyncNoop, removeSnapshotFile: asyncNoop, + onUnhandledError: noop, + saveTestFile: asyncNoop, + getProvidedContext: () => ({}), } as WebSocketHandlers - ctx.rpc = rpc as any as BirpcReturn + ctx.rpc = rpc as any as BirpcReturn let openPromise: Promise diff --git a/packages/ui/client/composables/error.ts b/packages/ui/client/composables/error.ts index 06b22c000df7..206ee280b6d0 100644 --- a/packages/ui/client/composables/error.ts +++ b/packages/ui/client/composables/error.ts @@ -1,4 +1,6 @@ import Filter from 'ansi-to-html' +import type { ErrorWithDiff } from 'vitest' +import { parseStacktrace } from '@vitest/utils/source-map' export function shouldOpenInEditor(name: string, fileName?: string) { return fileName && name.endsWith(fileName) @@ -15,3 +17,32 @@ export function createAnsiToHtmlFilter(dark: boolean) { bg: dark ? '#000' : '#FFF', }) } + +function isPrimitive(value: unknown) { + return value === null || (typeof value !== 'function' && typeof value !== 'object') +} + +export function parseError(e: unknown) { + let error = e as ErrorWithDiff + + if (isPrimitive(e)) { + error = { + message: String(error).split(/\n/g)[0], + stack: String(error), + name: '', + } + } + + if (!e) { + const err = new Error('unknown error') + error = { + message: err.message, + stack: err.stack, + name: '', + } + } + + error.stacks = parseStacktrace(error.stack || error.stackStr || '', { ignoreStackEntries: [] }) + + return error +} diff --git a/packages/ui/client/composables/summary.ts b/packages/ui/client/composables/summary.ts index 68d57d13c787..7193cb72902d 100644 --- a/packages/ui/client/composables/summary.ts +++ b/packages/ui/client/composables/summary.ts @@ -1,5 +1,5 @@ import { hasFailedSnapshot } from '@vitest/ws-client' -import type { Custom, Task, Test } from 'vitest/src' +import type { Custom, Task, Test } from 'vitest' import { files, testRunState } from '~/composables/client' type Nullable = T | null | undefined diff --git a/packages/ui/node/reporter.ts b/packages/ui/node/reporter.ts index 916e7b46c1c2..3196f5b18e12 100644 --- a/packages/ui/node/reporter.ts +++ b/packages/ui/node/reporter.ts @@ -28,6 +28,7 @@ interface HTMLReportData { files: File[] config: ResolvedConfig moduleGraph: Record + unhandledErrors: unknown[] } const distDir = resolve(fileURLToPath(import.meta.url), '../../dist') @@ -47,6 +48,7 @@ export default class HTMLReporter implements Reporter { paths: this.ctx.state.getPaths(), files: this.ctx.state.getFiles(), config: this.ctx.config, + unhandledErrors: this.ctx.state.getUnhandledErrors(), moduleGraph: {}, } await Promise.all( diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts index 62529c426811..a0fa664b8230 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -147,6 +147,9 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: Vi getProvidedContext() { return 'ctx' in vitestOrWorkspace ? vitestOrWorkspace.getProvidedContext() : ({} as any) }, + getUnhandledErrors() { + return ctx.state.getUnhandledErrors() + }, }, { post: msg => ws.send(msg), @@ -206,9 +209,9 @@ class WebSocketReporter implements Reporter { }) } - onFinished(files?: File[] | undefined) { + onFinished(files?: File[], errors?: unknown[]) { this.clients.forEach((client) => { - client.onFinished?.(files) + client.onFinished?.(files, errors) }) } diff --git a/packages/vitest/src/api/types.ts b/packages/vitest/src/api/types.ts index e736c8ff53fe..9909a5303ac4 100644 --- a/packages/vitest/src/api/types.ts +++ b/packages/vitest/src/api/types.ts @@ -31,6 +31,7 @@ export interface WebSocketHandlers { rerun(files: string[]): Promise updateSnapshot(file?: File): Promise getProvidedContext(): ProvidedContext + getUnhandledErrors(): unknown[] } export interface WebSocketEvents extends Pick { diff --git a/packages/vitest/src/node/error.ts b/packages/vitest/src/node/error.ts index 7a2e0ec42df1..063e9e67b49f 100644 --- a/packages/vitest/src/node/error.ts +++ b/packages/vitest/src/node/error.ts @@ -102,7 +102,7 @@ export async function printError(error: unknown, project: WorkspaceProject | und if (testName) { logger.error(c.red(`The latest test that might've caused the error is "${c.bold(testName)}". It might mean one of the following:` + '\n- The error was thrown, while Vitest was running this test.' - + '\n- This was the last recorded test before the error was thrown, if error originated after test finished its execution.')) + + '\n- If the error occurred after the test had been completed, this was the last documented test before it was thrown.')) } if (afterEnvTeardown) { logger.error(c.red('This error was caught after test environment was torn down. Make sure to cancel any running tasks before test finishes:' diff --git a/packages/ws-client/src/index.ts b/packages/ws-client/src/index.ts index 7a94d4be87b2..0fa7ee822c32 100644 --- a/packages/ws-client/src/index.ts +++ b/packages/ws-client/src/index.ts @@ -65,8 +65,8 @@ export function createClient(url: string, options: VitestClientOptions = {}) { onUserConsoleLog(log) { ctx.state.updateUserLog(log) }, - onFinished(files) { - handlers.onFinished?.(files) + onFinished(files, errors) { + handlers.onFinished?.(files, errors) }, onCancel(reason: CancelReason) { handlers.onCancel?.(reason) diff --git a/test/reporters/tests/__snapshots__/html.test.ts.snap b/test/reporters/tests/__snapshots__/html.test.ts.snap index 780d73733359..935f8ce63f72 100644 --- a/test/reporters/tests/__snapshots__/html.test.ts.snap +++ b/test/reporters/tests/__snapshots__/html.test.ts.snap @@ -104,6 +104,7 @@ exports[`html reporter > resolves to "failing" status for test file "json-fail" "paths": [ "/test/reporters/fixtures/json-fail.test.ts", ], + "unhandledErrors": [], } `; @@ -229,5 +230,6 @@ exports[`html reporter > resolves to "passing" status for test file "all-passing "paths": [ "/test/reporters/fixtures/all-passing-or-skipped.test.ts", ], + "unhandledErrors": [], } `; diff --git a/test/ui/fixtures/sample.test.ts b/test/ui/fixtures/sample.test.ts index b4d376c539e5..dae216fc04a2 100644 --- a/test/ui/fixtures/sample.test.ts +++ b/test/ui/fixtures/sample.test.ts @@ -3,5 +3,11 @@ import { expect, it } from 'vitest' it('add', () => { // eslint-disable-next-line no-console console.log('log test') + setTimeout(() => { + throw new Error('error') + }) + setTimeout(() => { + throw 1 + }) expect(1 + 1).toEqual(2) }) diff --git a/test/ui/test/html-report.spec.ts b/test/ui/test/html-report.spec.ts index 8c2e094d6525..71398fbf7d65 100644 --- a/test/ui/test/html-report.spec.ts +++ b/test/ui/test/html-report.spec.ts @@ -34,8 +34,17 @@ test.describe('html report', () => { // dashbaord await expect(page.locator('[aria-labelledby=tests]')).toContainText('5 Pass 0 Fail 5 Total') + // unhandled errors + await expect(page.getByTestId('unhandled-errors')).toContainText( + 'Vitest caught 2 errors during the test run. This might cause false positive tests. ' + + 'Resolve unhandled errors to make sure your tests are not affected.', + ) + + await expect(page.getByTestId('unhandled-errors-details')).toContainText('Error: error') + await expect(page.getByTestId('unhandled-errors-details')).toContainText('Unknown Error: 1') + // report - await page.getByText('sample.test.ts').click() + await page.getByTestId('details-panel').getByText('sample.test.ts').click() await page.getByText('All tests passed in this file').click() await expect(page.getByTestId('filenames')).toContainText('sample.test.ts') diff --git a/test/ui/test/ui.spec.ts b/test/ui/test/ui.spec.ts index 770f348da099..3245b1fc5274 100644 --- a/test/ui/test/ui.spec.ts +++ b/test/ui/test/ui.spec.ts @@ -25,8 +25,17 @@ test.describe('ui', () => { // dashbaord await expect(page.locator('[aria-labelledby=tests]')).toContainText('5 Pass 0 Fail 5 Total') + // unhandled errors + await expect(page.getByTestId('unhandled-errors')).toContainText( + 'Vitest caught 2 errors during the test run. This might cause false positive tests. ' + + 'Resolve unhandled errors to make sure your tests are not affected.', + ) + + await expect(page.getByTestId('unhandled-errors-details')).toContainText('Error: error') + await expect(page.getByTestId('unhandled-errors-details')).toContainText('Unknown Error: 1') + // report - await page.getByText('sample.test.ts').click() + await page.getByTestId('details-panel').getByText('sample.test.ts').click() await page.getByText('All tests passed in this file').click() await expect(page.getByTestId('filenames')).toContainText('sample.test.ts') @@ -60,7 +69,7 @@ test.describe('ui', () => { // match all files when no filter await page.getByPlaceholder('Search...').fill('') await page.getByText('PASS (3)').click() - await expect(page.getByText('fixtures/sample.test.ts', { exact: true })).toBeVisible() + await expect(page.getByTestId('details-panel').getByText('fixtures/sample.test.ts', { exact: true })).toBeVisible() // match nothing await page.getByPlaceholder('Search...').fill('nothing') @@ -69,6 +78,6 @@ test.describe('ui', () => { // searching "add" will match "sample.test.ts" since it includes a test case named "add" await page.getByPlaceholder('Search...').fill('add') await page.getByText('PASS (1)').click() - await expect(page.getByText('fixtures/sample.test.ts', { exact: true })).toBeVisible() + await expect(page.getByTestId('details-panel').getByText('fixtures/sample.test.ts', { exact: true })).toBeVisible() }) })