Skip to content

Commit 7f59a1b

Browse files
spirokasheremet-va
andauthoredJan 12, 2024
feat(ui): show unhandled errors on the ui (#4380)
Co-authored-by: Vladimir <sleuths.slews0s@icloud.com>
1 parent 3ca3174 commit 7f59a1b

File tree

18 files changed

+173
-18
lines changed

18 files changed

+173
-18
lines changed
 

‎packages/ui/client/auto-imports.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ declare global {
9090
const onUpdated: typeof import('vue')['onUpdated']
9191
const openInEditor: typeof import('./composables/error')['openInEditor']
9292
const params: typeof import('./composables/params')['params']
93+
const parseError: typeof import('./composables/error')['parseError']
9394
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
9495
const provide: typeof import('vue')['provide']
9596
const provideLocal: typeof import('@vueuse/core')['provideLocal']

‎packages/ui/client/components.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ declare module 'vue' {
1313
Dashboard: typeof import('./components/Dashboard.vue')['default']
1414
DashboardEntry: typeof import('./components/dashboard/DashboardEntry.vue')['default']
1515
DetailsPanel: typeof import('./components/DetailsPanel.vue')['default']
16+
ErrorEntry: typeof import('./components/dashboard/ErrorEntry.vue')['default']
1617
FileDetails: typeof import('./components/FileDetails.vue')['default']
1718
IconButton: typeof import('./components/IconButton.vue')['default']
1819
Modal: typeof import('./components/Modal.vue')['default']

‎packages/ui/client/components/DetailsPanel.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const open = ref(true)
77
</script>
88

99
<template>
10-
<div :open="open" class="details-panel" @toggle="open = $event.target.open">
10+
<div :open="open" class="details-panel" data-testid="details-panel" @toggle="open = $event.target.open">
1111
<div p="y1" text-sm bg-base items-center z-5 gap-2 :class="color" w-full flex select-none sticky top="-1">
1212
<div flex-1 h-1px border="base b" op80 />
1313
<slot name="summary" :open="open" />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
error: ErrorWithDiff
4+
}>()
5+
</script>
6+
7+
<template>
8+
<h4 bg="red500/10" p-1 mb-1 mt-2 rounded>
9+
<span font-bold>
10+
{{ error.name || error.nameStr || 'Unknown Error' }}<template v-if="error.message">:</template>
11+
</span>
12+
{{ error.message }}
13+
</h4>
14+
<p v-if="error.stacks?.length" class="scrolls" text="xs" font-mono mx-1 my-2 pb-2 overflow-auto>
15+
<span v-for="(frame, i) in error.stacks" whitespace-pre :font-bold="i === 0 ? '' : null">❯ {{ frame.method}} {{ frame.file }}:<span text="red500/70">{{ frame.line }}:{{ frame.column }}</span><br></span>
16+
</p>
17+
<p v-if="error.VITEST_TEST_PATH" text="sm" mb-2>
18+
This error originated in <span font-bold>{{ error.VITEST_TEST_PATH }}</span> test file. It doesn't mean the error was thrown inside the file itself, but while it was running.
19+
</p>
20+
<p v-if="error.VITEST_TEST_NAME" text="sm" mb-2>
21+
The latest test that might've caused the error is <span font-bold>{{ error.VITEST_TEST_NAME }}</span>. It might mean one of the following:<br>
22+
<ul>
23+
<li>
24+
The error was thrown, while Vitest was running this test.
25+
</li>
26+
<li>
27+
If the error occurred after the test had been completed, this was the last documented test before it was thrown.
28+
</li>
29+
</ul>
30+
</p>
31+
<p v-if="error.VITEST_AFTER_ENV_TEARDOWN" text="sm" font-thin>
32+
This error was caught after test environment was torn down. Make sure to cancel any running tasks before test finishes:<br>
33+
<ul>
34+
<li>
35+
Cancel timeouts using clearTimeout and clearInterval.
36+
</li>
37+
<li>
38+
Wait for promises to resolve using the await keyword.
39+
</li>
40+
</ul>
41+
</p>
42+
</template>

‎packages/ui/client/components/dashboard/TestFilesEntry.vue

+38-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { files } from '../../composables/client'
2+
import { files, unhandledErrors } from '../../composables/client'
33
import { filesFailed, filesSnapshotFailed, filesSuccess, time } from '../../composables/summary'
44
</script>
55

@@ -44,17 +44,54 @@ import { filesFailed, filesSnapshotFailed, filesSuccess, time } from '../../comp
4444
</div>
4545
</template>
4646

47+
<template v-if="unhandledErrors.length">
48+
<div i-carbon-checkmark-outline-error />
49+
<div>
50+
Errors
51+
</div>
52+
<div class="number" text-red5>
53+
{{ unhandledErrors.length }}
54+
</div>
55+
</template>
56+
4757
<div i-carbon-timer />
4858
<div>Time</div>
4959
<div class="number" data-testid="run-time">
5060
{{ time }}
5161
</div>
5262
</div>
63+
<template v-if="unhandledErrors.length">
64+
<div bg="red500/10" text="red500" p="x3 y2" max-w-xl m-2 rounded>
65+
<h3 text-center mb-2>
66+
Unhandled Errors
67+
</h3>
68+
<p text="sm" font-thin mb-2 data-testid="unhandled-errors">
69+
Vitest caught {{ unhandledErrors.length }} error{{ unhandledErrors.length > 1 ? 's' : '' }} during the test run.<br>
70+
This might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected.
71+
</p>
72+
<details
73+
data-testid="unhandled-errors-details"
74+
class="scrolls unhandled-errors"
75+
text="sm" font-thin pe-2.5 open:max-h-52 overflow-auto
76+
>
77+
<summary font-bold cursor-pointer>Errors</summary>
78+
<ErrorEntry v-for="e in unhandledErrors" :error="e" />
79+
</details>
80+
</div>
81+
</template>
5382
</template>
5483

5584
<style scoped>
5685
.number {
5786
font-weight: 400;
5887
text-align: right;
5988
}
89+
90+
.unhandled-errors {
91+
--cm-ttc-c-thumb: #CCC;
92+
}
93+
94+
html.dark .unhandled-errors {
95+
--cm-ttc-c-thumb: #444;
96+
}
6097
</style>

‎packages/ui/client/composables/client/index.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import { createClient, getTasks } from '@vitest/ws-client'
22
import type { WebSocketStatus } from '@vueuse/core'
3-
import type { File, ResolvedConfig } from 'vitest'
3+
import type { ErrorWithDiff, File, ResolvedConfig } from 'vitest'
44
import type { Ref } from 'vue'
55
import { reactive } from 'vue'
66
import type { RunState } from '../../../types'
77
import { ENTRY_URL, isReport } from '../../constants'
8+
import { parseError } from '../error'
89
import { activeFileId } from '../params'
910
import { createStaticClient } from './static'
1011

1112
export { ENTRY_URL, PORT, HOST, isReport } from '../../constants'
1213

1314
export const testRunState: Ref<RunState> = ref('idle')
15+
export const unhandledErrors: Ref<ErrorWithDiff[]> = ref([])
1416

1517
export const client = (function createVitestClient() {
1618
if (isReport) {
@@ -23,8 +25,9 @@ export const client = (function createVitestClient() {
2325
onTaskUpdate() {
2426
testRunState.value = 'running'
2527
},
26-
onFinished() {
28+
onFinished(_files, errors) {
2729
testRunState.value = 'idle'
30+
unhandledErrors.value = (errors || []).map(parseError)
2831
},
2932
},
3033
})
@@ -70,11 +73,13 @@ watch(
7073
ws.addEventListener('open', async () => {
7174
status.value = 'OPEN'
7275
client.state.filesMap.clear()
73-
const [files, _config] = await Promise.all([
76+
const [files, _config, errors] = await Promise.all([
7477
client.rpc.getFiles(),
7578
client.rpc.getConfig(),
79+
client.rpc.getUnhandledErrors(),
7680
])
7781
client.state.collectFiles(files)
82+
unhandledErrors.value = (errors || []).map(parseError)
7883
config.value = _config
7984
})
8085

‎packages/ui/client/composables/client/static.ts

+9-3
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import type { BirpcReturn } from 'birpc'
22
import type { VitestClient } from '@vitest/ws-client'
3-
import type { WebSocketHandlers } from 'vitest/src/api/types'
3+
import type { File, ModuleGraphData, ResolvedConfig, WebSocketEvents, WebSocketHandlers } from 'vitest'
44
import { parse } from 'flatted'
55
import { decompressSync, strFromU8 } from 'fflate'
6-
import type { File, ModuleGraphData, ResolvedConfig } from 'vitest/src/types'
76
import { StateManager } from '../../../../vitest/src/node/state'
87

98
interface HTMLReportMetadata {
109
paths: string[]
1110
files: File[]
1211
config: ResolvedConfig
1312
moduleGraph: Record<string, ModuleGraphData>
13+
unhandledErrors: unknown[]
1414
}
1515

1616
const noop: any = () => {}
@@ -42,6 +42,9 @@ export function createStaticClient(): VitestClient {
4242
getModuleGraph: async (id) => {
4343
return metadata.moduleGraph[id]
4444
},
45+
getUnhandledErrors: () => {
46+
return metadata.unhandledErrors
47+
},
4548
getTransformResult: async (id) => {
4649
return {
4750
code: id,
@@ -66,9 +69,12 @@ export function createStaticClient(): VitestClient {
6669
saveSnapshotFile: asyncNoop,
6770
readTestFile: asyncNoop,
6871
removeSnapshotFile: asyncNoop,
72+
onUnhandledError: noop,
73+
saveTestFile: asyncNoop,
74+
getProvidedContext: () => ({}),
6975
} as WebSocketHandlers
7076

71-
ctx.rpc = rpc as any as BirpcReturn<WebSocketHandlers>
77+
ctx.rpc = rpc as any as BirpcReturn<WebSocketHandlers, WebSocketEvents>
7278

7379
let openPromise: Promise<void>
7480

‎packages/ui/client/composables/error.ts

+31
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import Filter from 'ansi-to-html'
2+
import type { ErrorWithDiff } from 'vitest'
3+
import { parseStacktrace } from '@vitest/utils/source-map'
24

35
export function shouldOpenInEditor(name: string, fileName?: string) {
46
return fileName && name.endsWith(fileName)
@@ -15,3 +17,32 @@ export function createAnsiToHtmlFilter(dark: boolean) {
1517
bg: dark ? '#000' : '#FFF',
1618
})
1719
}
20+
21+
function isPrimitive(value: unknown) {
22+
return value === null || (typeof value !== 'function' && typeof value !== 'object')
23+
}
24+
25+
export function parseError(e: unknown) {
26+
let error = e as ErrorWithDiff
27+
28+
if (isPrimitive(e)) {
29+
error = {
30+
message: String(error).split(/\n/g)[0],
31+
stack: String(error),
32+
name: '',
33+
}
34+
}
35+
36+
if (!e) {
37+
const err = new Error('unknown error')
38+
error = {
39+
message: err.message,
40+
stack: err.stack,
41+
name: '',
42+
}
43+
}
44+
45+
error.stacks = parseStacktrace(error.stack || error.stackStr || '', { ignoreStackEntries: [] })
46+
47+
return error
48+
}

‎packages/ui/client/composables/summary.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { hasFailedSnapshot } from '@vitest/ws-client'
2-
import type { Custom, Task, Test } from 'vitest/src'
2+
import type { Custom, Task, Test } from 'vitest'
33
import { files, testRunState } from '~/composables/client'
44

55
type Nullable<T> = T | null | undefined

‎packages/ui/node/reporter.ts

+2
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ interface HTMLReportData {
2828
files: File[]
2929
config: ResolvedConfig
3030
moduleGraph: Record<string, ModuleGraphData>
31+
unhandledErrors: unknown[]
3132
}
3233

3334
const distDir = resolve(fileURLToPath(import.meta.url), '../../dist')
@@ -47,6 +48,7 @@ export default class HTMLReporter implements Reporter {
4748
paths: this.ctx.state.getPaths(),
4849
files: this.ctx.state.getFiles(),
4950
config: this.ctx.config,
51+
unhandledErrors: this.ctx.state.getUnhandledErrors(),
5052
moduleGraph: {},
5153
}
5254
await Promise.all(

‎packages/vitest/src/api/setup.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: Vi
147147
getProvidedContext() {
148148
return 'ctx' in vitestOrWorkspace ? vitestOrWorkspace.getProvidedContext() : ({} as any)
149149
},
150+
getUnhandledErrors() {
151+
return ctx.state.getUnhandledErrors()
152+
},
150153
},
151154
{
152155
post: msg => ws.send(msg),
@@ -206,9 +209,9 @@ class WebSocketReporter implements Reporter {
206209
})
207210
}
208211

209-
onFinished(files?: File[] | undefined) {
212+
onFinished(files?: File[], errors?: unknown[]) {
210213
this.clients.forEach((client) => {
211-
client.onFinished?.(files)
214+
client.onFinished?.(files, errors)
212215
})
213216
}
214217

‎packages/vitest/src/api/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export interface WebSocketHandlers {
3131
rerun(files: string[]): Promise<void>
3232
updateSnapshot(file?: File): Promise<void>
3333
getProvidedContext(): ProvidedContext
34+
getUnhandledErrors(): unknown[]
3435
}
3536

3637
export interface WebSocketEvents extends Pick<Reporter, 'onCollected' | 'onFinished' | 'onTaskUpdate' | 'onUserConsoleLog' | 'onPathsCollected'> {

‎packages/vitest/src/node/error.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export async function printError(error: unknown, project: WorkspaceProject | und
102102
if (testName) {
103103
logger.error(c.red(`The latest test that might've caused the error is "${c.bold(testName)}". It might mean one of the following:`
104104
+ '\n- The error was thrown, while Vitest was running this test.'
105-
+ '\n- This was the last recorded test before the error was thrown, if error originated after test finished its execution.'))
105+
+ '\n- If the error occurred after the test had been completed, this was the last documented test before it was thrown.'))
106106
}
107107
if (afterEnvTeardown) {
108108
logger.error(c.red('This error was caught after test environment was torn down. Make sure to cancel any running tasks before test finishes:'

‎packages/ws-client/src/index.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ export function createClient(url: string, options: VitestClientOptions = {}) {
6565
onUserConsoleLog(log) {
6666
ctx.state.updateUserLog(log)
6767
},
68-
onFinished(files) {
69-
handlers.onFinished?.(files)
68+
onFinished(files, errors) {
69+
handlers.onFinished?.(files, errors)
7070
},
7171
onCancel(reason: CancelReason) {
7272
handlers.onCancel?.(reason)

‎test/reporters/tests/__snapshots__/html.test.ts.snap

+2
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ exports[`html reporter > resolves to "failing" status for test file "json-fail"
104104
"paths": [
105105
"<rootDir>/test/reporters/fixtures/json-fail.test.ts",
106106
],
107+
"unhandledErrors": [],
107108
}
108109
`;
109110

@@ -229,5 +230,6 @@ exports[`html reporter > resolves to "passing" status for test file "all-passing
229230
"paths": [
230231
"<rootDir>/test/reporters/fixtures/all-passing-or-skipped.test.ts",
231232
],
233+
"unhandledErrors": [],
232234
}
233235
`;

‎test/ui/fixtures/sample.test.ts

+6
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,11 @@ import { expect, it } from 'vitest'
33
it('add', () => {
44
// eslint-disable-next-line no-console
55
console.log('log test')
6+
setTimeout(() => {
7+
throw new Error('error')
8+
})
9+
setTimeout(() => {
10+
throw 1
11+
})
612
expect(1 + 1).toEqual(2)
713
})

‎test/ui/test/html-report.spec.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,17 @@ test.describe('html report', () => {
3434
// dashbaord
3535
await expect(page.locator('[aria-labelledby=tests]')).toContainText('5 Pass 0 Fail 5 Total')
3636

37+
// unhandled errors
38+
await expect(page.getByTestId('unhandled-errors')).toContainText(
39+
'Vitest caught 2 errors during the test run. This might cause false positive tests. '
40+
+ 'Resolve unhandled errors to make sure your tests are not affected.',
41+
)
42+
43+
await expect(page.getByTestId('unhandled-errors-details')).toContainText('Error: error')
44+
await expect(page.getByTestId('unhandled-errors-details')).toContainText('Unknown Error: 1')
45+
3746
// report
38-
await page.getByText('sample.test.ts').click()
47+
await page.getByTestId('details-panel').getByText('sample.test.ts').click()
3948
await page.getByText('All tests passed in this file').click()
4049
await expect(page.getByTestId('filenames')).toContainText('sample.test.ts')
4150

‎test/ui/test/ui.spec.ts

+12-3
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,17 @@ test.describe('ui', () => {
2525
// dashbaord
2626
await expect(page.locator('[aria-labelledby=tests]')).toContainText('5 Pass 0 Fail 5 Total')
2727

28+
// unhandled errors
29+
await expect(page.getByTestId('unhandled-errors')).toContainText(
30+
'Vitest caught 2 errors during the test run. This might cause false positive tests. '
31+
+ 'Resolve unhandled errors to make sure your tests are not affected.',
32+
)
33+
34+
await expect(page.getByTestId('unhandled-errors-details')).toContainText('Error: error')
35+
await expect(page.getByTestId('unhandled-errors-details')).toContainText('Unknown Error: 1')
36+
2837
// report
29-
await page.getByText('sample.test.ts').click()
38+
await page.getByTestId('details-panel').getByText('sample.test.ts').click()
3039
await page.getByText('All tests passed in this file').click()
3140
await expect(page.getByTestId('filenames')).toContainText('sample.test.ts')
3241

@@ -60,7 +69,7 @@ test.describe('ui', () => {
6069
// match all files when no filter
6170
await page.getByPlaceholder('Search...').fill('')
6271
await page.getByText('PASS (3)').click()
63-
await expect(page.getByText('fixtures/sample.test.ts', { exact: true })).toBeVisible()
72+
await expect(page.getByTestId('details-panel').getByText('fixtures/sample.test.ts', { exact: true })).toBeVisible()
6473

6574
// match nothing
6675
await page.getByPlaceholder('Search...').fill('nothing')
@@ -69,6 +78,6 @@ test.describe('ui', () => {
6978
// searching "add" will match "sample.test.ts" since it includes a test case named "add"
7079
await page.getByPlaceholder('Search...').fill('add')
7180
await page.getByText('PASS (1)').click()
72-
await expect(page.getByText('fixtures/sample.test.ts', { exact: true })).toBeVisible()
81+
await expect(page.getByTestId('details-panel').getByText('fixtures/sample.test.ts', { exact: true })).toBeVisible()
7382
})
7483
})

0 commit comments

Comments
 (0)
Please sign in to comment.