Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui): show unhandled errors on the ui #4380

Merged
merged 26 commits into from
Jan 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
edfc76b
feat(ui): show unhandled errors on the ui
spiroka Oct 26, 2023
ed06d00
chore: show unhandled errors in html report
spiroka Oct 28, 2023
3a71dc1
chore: added tests
spiroka Nov 2, 2023
753885e
Merge branch 'main' into unhandled-errors-in-ui
spiroka Nov 2, 2023
2f103fc
chore: fix typo
spiroka Nov 2, 2023
e80dd91
Merge branch 'unhandled-errors-in-ui' of https://github.com/spiroka/v…
spiroka Nov 2, 2023
d0d0863
Merge branch 'main' into unhandled-errors-in-ui
spiroka Nov 2, 2023
88ddb3d
Merge branch 'main' into unhandled-errors-in-ui
spiroka Nov 7, 2023
a75c1f2
chore: update snapshots
spiroka Nov 7, 2023
2e4a8a4
Merge branch 'main' into unhandled-errors-in-ui
spiroka Nov 9, 2023
8afb14e
Merge branch 'main' into unhandled-errors-in-ui
spiroka Nov 13, 2023
b5e42a5
chore: get unhandled errors in Promise.all
spiroka Nov 13, 2023
eafe0de
chore: show error details
spiroka Nov 19, 2023
311c2fb
chore: inline isPrimitive
spiroka Nov 20, 2023
1c7607a
chore: don't export inlined function
spiroka Nov 20, 2023
0557eb9
Merge branch 'main' into unhandled-errors-in-ui
spiroka Nov 20, 2023
7dd446f
chore: added tests for error details
spiroka Nov 21, 2023
a491c46
Merge branch 'unhandled-errors-in-ui' of https://github.com/spiroka/v…
spiroka Nov 21, 2023
ca3a82e
chore:added stack traces, did some rewording
spiroka Dec 15, 2023
5311c62
Merge branch 'main' into unhandled-errors-in-ui
spiroka Dec 15, 2023
db4f07d
Merge remote-tracking branch 'upstream/main' into unhandled-errors-in-ui
spiroka Dec 20, 2023
b6de84a
Merge branch 'main' into unhandled-errors-in-ui
spiroka Dec 26, 2023
65f01f3
Merge branch 'main' into unhandled-errors-in-ui
spiroka Jan 2, 2024
0e59875
Merge branch 'main' into unhandled-errors-in-ui
spiroka Jan 5, 2024
68f9407
Merge branch 'main' into unhandled-errors-in-ui
sheremet-va Jan 12, 2024
a8da5cd
chore: cleanup
sheremet-va Jan 12, 2024
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
1 change: 1 addition & 0 deletions packages/ui/client/auto-imports.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
1 change: 1 addition & 0 deletions packages/ui/client/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/client/components/DetailsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const open = ref(true)
</script>

<template>
<div :open="open" class="details-panel" @toggle="open = $event.target.open">
<div :open="open" class="details-panel" data-testid="details-panel" @toggle="open = $event.target.open">
<div p="y1" text-sm bg-base items-center z-5 gap-2 :class="color" w-full flex select-none sticky top="-1">
<div flex-1 h-1px border="base b" op80 />
<slot name="summary" :open="open" />
Expand Down
42 changes: 42 additions & 0 deletions packages/ui/client/components/dashboard/ErrorEntry.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<script setup lang="ts">
const props = defineProps<{
error: ErrorWithDiff
}>()
</script>

<template>
<h4 bg="red500/10" p-1 mb-1 mt-2 rounded>
<span font-bold>
{{ error.name || error.nameStr || 'Unknown Error' }}<template v-if="error.message">:</template>
</span>
{{ error.message }}
</h4>
<p v-if="error.stacks?.length" class="scrolls" text="xs" font-mono mx-1 my-2 pb-2 overflow-auto>
<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>
</p>
<p v-if="error.VITEST_TEST_PATH" text="sm" mb-2>
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.
</p>
<p v-if="error.VITEST_TEST_NAME" text="sm" mb-2>
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>
<ul>
<li>
The error was thrown, while Vitest was running this test.
</li>
<li>
If the error occurred after the test had been completed, this was the last documented test before it was thrown.
</li>
</ul>
</p>
<p v-if="error.VITEST_AFTER_ENV_TEARDOWN" text="sm" font-thin>
This error was caught after test environment was torn down. Make sure to cancel any running tasks before test finishes:<br>
<ul>
<li>
Cancel timeouts using clearTimeout and clearInterval.
</li>
<li>
Wait for promises to resolve using the await keyword.
</li>
</ul>
</p>
</template>
39 changes: 38 additions & 1 deletion packages/ui/client/components/dashboard/TestFilesEntry.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { files } from '../../composables/client'
import { files, unhandledErrors } from '../../composables/client'
import { filesFailed, filesSnapshotFailed, filesSuccess, time } from '../../composables/summary'
</script>

Expand Down Expand Up @@ -44,17 +44,54 @@ import { filesFailed, filesSnapshotFailed, filesSuccess, time } from '../../comp
</div>
</template>

<template v-if="unhandledErrors.length">
<div i-carbon-checkmark-outline-error />
<div>
Errors
</div>
<div class="number" text-red5>
{{ unhandledErrors.length }}
</div>
</template>

<div i-carbon-timer />
<div>Time</div>
<div class="number" data-testid="run-time">
{{ time }}
</div>
</div>
<template v-if="unhandledErrors.length">
<div bg="red500/10" text="red500" p="x3 y2" max-w-xl m-2 rounded>
<h3 text-center mb-2>
Unhandled Errors
</h3>
<p text="sm" font-thin mb-2 data-testid="unhandled-errors">
Vitest caught {{ unhandledErrors.length }} error{{ unhandledErrors.length > 1 ? 's' : '' }} during the test run.<br>
This might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected.
sheremet-va marked this conversation as resolved.
Show resolved Hide resolved
</p>
<details
data-testid="unhandled-errors-details"
class="scrolls unhandled-errors"
text="sm" font-thin pe-2.5 open:max-h-52 overflow-auto
>
<summary font-bold cursor-pointer>Errors</summary>
<ErrorEntry v-for="e in unhandledErrors" :error="e" />
</details>
</div>
</template>
</template>

<style scoped>
.number {
font-weight: 400;
text-align: right;
}

.unhandled-errors {
--cm-ttc-c-thumb: #CCC;
}

html.dark .unhandled-errors {
--cm-ttc-c-thumb: #444;
}
</style>
11 changes: 8 additions & 3 deletions packages/ui/client/composables/client/index.ts
Original file line number Diff line number Diff line change
@@ -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<RunState> = ref('idle')
export const unhandledErrors: Ref<ErrorWithDiff[]> = ref([])

export const client = (function createVitestClient() {
if (isReport) {
Expand All @@ -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)
},
},
})
Expand Down Expand Up @@ -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
})

Expand Down
12 changes: 9 additions & 3 deletions packages/ui/client/composables/client/static.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
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 {
paths: string[]
files: File[]
config: ResolvedConfig
moduleGraph: Record<string, ModuleGraphData>
unhandledErrors: unknown[]
}

const noop: any = () => {}
Expand Down Expand Up @@ -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,
Expand All @@ -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<WebSocketHandlers>
ctx.rpc = rpc as any as BirpcReturn<WebSocketHandlers, WebSocketEvents>

let openPromise: Promise<void>

Expand Down
31 changes: 31 additions & 0 deletions packages/ui/client/composables/error.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
}
2 changes: 1 addition & 1 deletion packages/ui/client/composables/summary.ts
Original file line number Diff line number Diff line change
@@ -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> = T | null | undefined
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/node/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface HTMLReportData {
files: File[]
config: ResolvedConfig
moduleGraph: Record<string, ModuleGraphData>
unhandledErrors: unknown[]
}

const distDir = resolve(fileURLToPath(import.meta.url), '../../dist')
Expand All @@ -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(
Expand Down
7 changes: 5 additions & 2 deletions packages/vitest/src/api/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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)
})
}

Expand Down
1 change: 1 addition & 0 deletions packages/vitest/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface WebSocketHandlers {
rerun(files: string[]): Promise<void>
updateSnapshot(file?: File): Promise<void>
getProvidedContext(): ProvidedContext
getUnhandledErrors(): unknown[]
}

export interface WebSocketEvents extends Pick<Reporter, 'onCollected' | 'onFinished' | 'onTaskUpdate' | 'onUserConsoleLog' | 'onPathsCollected'> {
Expand Down
2 changes: 1 addition & 1 deletion packages/vitest/src/node/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:'
Expand Down
4 changes: 2 additions & 2 deletions packages/ws-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions test/reporters/tests/__snapshots__/html.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ exports[`html reporter > resolves to "failing" status for test file "json-fail"
"paths": [
"<rootDir>/test/reporters/fixtures/json-fail.test.ts",
],
"unhandledErrors": [],
}
`;

Expand Down Expand Up @@ -229,5 +230,6 @@ exports[`html reporter > resolves to "passing" status for test file "all-passing
"paths": [
"<rootDir>/test/reporters/fixtures/all-passing-or-skipped.test.ts",
],
"unhandledErrors": [],
}
`;
6 changes: 6 additions & 0 deletions test/ui/fixtures/sample.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
11 changes: 10 additions & 1 deletion test/ui/test/html-report.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down
Loading