diff --git a/packages/ui/client/components/views/ViewEditor.vue b/packages/ui/client/components/views/ViewEditor.vue index 963d00c865ad..369393ec22f8 100644 --- a/packages/ui/client/components/views/ViewEditor.vue +++ b/packages/ui/client/components/views/ViewEditor.vue @@ -4,7 +4,7 @@ import type CodeMirror from 'codemirror' import { createTooltip, destroyTooltip } from 'floating-vue' import { openInEditor } from '../../composables/error' import { client } from '~/composables/client' -import type { File } from '#types' +import type { ErrorWithDiff, File, ParsedStack } from '#types' const props = defineProps<{ file?: File @@ -60,6 +60,34 @@ watch(draft, (d) => { emit('draft', d) }, { immediate: true }) +function createErrorElement(e: ErrorWithDiff) { + const stacks = (e?.stacks || []).filter(i => i.file && i.file === props.file?.filepath) + const stack = stacks?.[0] + if (!stack) + return + const div = document.createElement('div') + div.className = 'op80 flex gap-x-2 items-center' + const pre = document.createElement('pre') + pre.className = 'c-red-600 dark:c-red-400' + pre.textContent = `${' '.repeat(stack.column)}^ ${e?.nameStr}: ${e?.message}` + div.appendChild(pre) + const span = document.createElement('span') + span.className = 'i-carbon-launch c-red-600 dark:c-red-400 hover:cursor-pointer min-w-1em min-h-1em' + span.tabIndex = 0 + span.ariaLabel = 'Open in Editor' + const tooltip = createTooltip(span, { + content: 'Open in Editor', + placement: 'bottom', + }, false) + const el: EventListener = async () => { + await openInEditor(stack.file, stack.line, stack.column) + } + div.appendChild(span) + listeners.push([span, el, () => destroyTooltip(span)]) + handles.push(cm.value!.addLineClass(stack.line - 1, 'wrap', 'bg-red-500/10')) + widgets.push(cm.value!.addLineWidget(stack.line - 1, div)) +} + watch([cm, failed], ([cmValue]) => { if (!cmValue) { clearListeners() @@ -76,32 +104,7 @@ watch([cm, failed], ([cmValue]) => { cmValue.on('changes', codemirrorChanges) failed.value.forEach((i) => { - const e = i.result?.error - const stacks = (e?.stacks || []).filter(i => i.file && i.file === props.file?.filepath) - if (stacks.length) { - const stack = stacks[0] - const div = document.createElement('div') - div.className = 'op80 flex gap-x-2 items-center' - const pre = document.createElement('pre') - pre.className = 'c-red-600 dark:c-red-400' - pre.textContent = `${' '.repeat(stack.column)}^ ${e?.nameStr}: ${e?.message}` - div.appendChild(pre) - const span = document.createElement('span') - span.className = 'i-carbon-launch c-red-600 dark:c-red-400 hover:cursor-pointer min-w-1em min-h-1em' - span.tabIndex = 0 - span.ariaLabel = 'Open in Editor' - const tooltip = createTooltip(span, { - content: 'Open in Editor', - placement: 'bottom', - }, false) - const el: EventListener = async () => { - await openInEditor(stacks[0].file, stack.line, stack.column) - } - div.appendChild(span) - listeners.push([span, el, () => destroyTooltip(span)]) - handles.push(cm.value!.addLineClass(stack.line - 1, 'wrap', 'bg-red-500/10')) - widgets.push(cm.value!.addLineWidget(stack.line - 1, div)) - } + i.result?.errors?.forEach(createErrorElement) }) if (!hasBeenEdited.value) cmValue.clearHistory() // Prevent getting access to initial state diff --git a/packages/ui/client/components/views/ViewReport.cy.tsx b/packages/ui/client/components/views/ViewReport.cy.tsx index 7e87687ea8e5..25c967c5c2af 100644 --- a/packages/ui/client/components/views/ViewReport.cy.tsx +++ b/packages/ui/client/components/views/ViewReport.cy.tsx @@ -17,6 +17,12 @@ const makeTextStack = () => ({ // 5 Stacks const textStacks = Array.from(new Array(5)).map(makeTextStack) +const error = { + name: 'Do some test', + stacks: textStacks, + message: 'Error: Transform failed with 1 error:', +} + const fileWithTextStacks = { id: 'f-1', name: 'test/plain-stack-trace.ts', @@ -25,11 +31,8 @@ const fileWithTextStacks = { filepath: 'test/plain-stack-trace.ts', result: { state: 'fail', - error: { - name: 'Do some test', - stacks: textStacks, - message: 'Error: Transform failed with 1 error:', - }, + error, + errors: [error], }, tasks: [], } @@ -67,11 +70,11 @@ describe('ViewReport', () => { filepath: 'test/plain-stack-trace.ts', result: { state: 'fail', - error: { + errors: [{ name: 'Do some test', stack: '\x1B[33mtest/plain-stack-trace.ts\x1B[0m', message: 'Error: Transform failed with 1 error:', - }, + }], }, tasks: [], } @@ -104,11 +107,11 @@ describe('ViewReport', () => { filepath: 'test/plain-stack-trace.ts', result: { state: 'fail', - error: { + errors: [{ name: 'Do some test', stack: '\x1B[33mtest/plain-stack-trace.ts\x1B[0m', message: '\x1B[44mError: Transform failed with 1 error:\x1B[0m', - }, + }], }, tasks: [], } diff --git a/packages/ui/client/components/views/ViewReport.vue b/packages/ui/client/components/views/ViewReport.vue index f7169ed37fc1..ebd8a97cc470 100644 --- a/packages/ui/client/components/views/ViewReport.vue +++ b/packages/ui/client/components/views/ViewReport.vue @@ -1,10 +1,10 @@ diff --git a/packages/ui/client/components/views/ViewReportError.vue b/packages/ui/client/components/views/ViewReportError.vue new file mode 100644 index 000000000000..9ba393572392 --- /dev/null +++ b/packages/ui/client/components/views/ViewReportError.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts index a385164b6ce2..aed3de53b9ed 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -124,8 +124,12 @@ class WebSocketReporter implements Reporter { return packs.forEach(([, result]) => { + // TODO remove after "error" deprecation is removed if (result?.error) result.error.stacks = parseStacktrace(result.error) + result?.errors?.forEach((error) => { + error.stacks = parseStacktrace(error) + }) }) this.clients.forEach((client) => { diff --git a/packages/vitest/src/node/reporters/base.ts b/packages/vitest/src/node/reporters/base.ts index dc0147da89d6..b951de4c2b62 100644 --- a/packages/vitest/src/node/reporters/base.ts +++ b/packages/vitest/src/node/reporters/base.ts @@ -85,7 +85,9 @@ export abstract class BaseReporter implements Reporter { // print short errors, full errors will be at the end in summary for (const test of failed) { logger.log(c.red(` ${pointer} ${getFullName(test)}`)) - logger.log(c.red(` ${F_RIGHT} ${(test.result!.error as any)?.message}`)) + test.result?.errors?.forEach((e) => { + logger.log(c.red(` ${F_RIGHT} ${(e as any)?.message}`)) + }) } } } @@ -258,7 +260,7 @@ export abstract class BaseReporter implements Reporter { const suites = getSuites(files) const tests = getTests(files) - const failedSuites = suites.filter(i => i.result?.error) + const failedSuites = suites.filter(i => i.result?.errors) const failedTests = tests.filter(i => i.result?.state === 'fail') const failedTotal = failedSuites.length + failedTests.length @@ -310,12 +312,13 @@ export abstract class BaseReporter implements Reporter { const errorsQueue: [error: ErrorWithDiff | undefined, tests: Task[]][] = [] for (const task of tasks) { // merge identical errors - const error = task.result?.error - const errorItem = error?.stackStr && errorsQueue.find(i => i[0]?.stackStr === error.stackStr) - if (errorItem) - errorItem[1].push(task) - else - errorsQueue.push([error, [task]]) + task.result?.errors?.forEach((error) => { + const errorItem = error?.stackStr && errorsQueue.find(i => i[0]?.stackStr === error.stackStr) + if (errorItem) + errorItem[1].push(task) + else + errorsQueue.push([error, [task]]) + }) } for (const [error, tasks] of errorsQueue) { for (const task of tasks) { diff --git a/packages/vitest/src/node/reporters/json.ts b/packages/vitest/src/node/reporters/json.ts index 98e9b50f155f..31aa979f37db 100644 --- a/packages/vitest/src/node/reporters/json.ts +++ b/packages/vitest/src/node/reporters/json.ts @@ -76,7 +76,7 @@ export class JsonReporter implements Reporter { const numTotalTestSuites = suites.length const tests = getTests(files) const numTotalTests = tests.length - const numFailedTestSuites = suites.filter(s => s.result?.error).length + const numFailedTestSuites = suites.filter(s => s.result?.errors).length const numPassedTestSuites = numTotalTestSuites - numFailedTestSuites const numPendingTestSuites = suites.filter(s => s.result?.state === 'run').length const numFailedTests = tests.filter(t => t.result?.state === 'fail').length @@ -109,7 +109,7 @@ export class JsonReporter implements Reporter { status: StatusMap[t.result?.state || t.mode] || 'skipped', title: t.name, duration: t.result?.duration, - failureMessages: t.result?.error?.message == null ? [] : [t.result.error.message], + failureMessages: t.result?.errors?.map(e => e.message) || [], location: await this.getFailureLocation(t), } as FormattedAssertionResult })) @@ -128,7 +128,7 @@ export class JsonReporter implements Reporter { t.result?.state === 'fail') ? 'failed' : 'passed', - message: file.result?.error?.message ?? '', + message: file.result?.errors?.[0]?.message ?? '', name: file.filepath, }) } @@ -179,7 +179,7 @@ export class JsonReporter implements Reporter { } protected async getFailureLocation(test: Task): Promise { - const error = test.result?.error + const error = test.result?.errors?.[0] if (!error) return diff --git a/packages/vitest/src/node/reporters/junit.ts b/packages/vitest/src/node/reporters/junit.ts index e66a7625dcc8..79649db18b54 100644 --- a/packages/vitest/src/node/reporters/junit.ts +++ b/packages/vitest/src/node/reporters/junit.ts @@ -167,17 +167,18 @@ export class JUnitReporter implements Reporter { await this.logger.log('') if (task.result?.state === 'fail') { - const error = task.result.error - - await this.writeElement('failure', { - message: error?.message, - type: error?.name ?? error?.nameStr, - }, async () => { - if (!error) - return - - await this.writeErrorDetails(error) - }) + const promises = task.result.errors?.map(async (error) => { + await this.writeElement('failure', { + message: error?.message, + type: error?.name ?? error?.nameStr, + }, async () => { + if (!error) + return + + await this.writeErrorDetails(error) + }) + }) || [] + await Promise.all(promises) } }) } diff --git a/packages/vitest/src/node/reporters/tap.ts b/packages/vitest/src/node/reporters/tap.ts index ea0c27d2bc95..8bf12cdd0ac9 100644 --- a/packages/vitest/src/node/reporters/tap.ts +++ b/packages/vitest/src/node/reporters/tap.ts @@ -66,27 +66,28 @@ export class TapReporter implements Reporter { else { this.logger.log(`${ok} ${id} - ${tapString(task.name)}${comment}`) - if (task.result?.state === 'fail' && task.result.error) { + if (task.result?.state === 'fail' && task.result.errors) { this.logger.indent() - const error = task.result.error - const stacks = parseStacktrace(error) - const stack = stacks[0] + task.result.errors.forEach((error) => { + const stacks = parseStacktrace(error) + const stack = stacks[0] - this.logger.log('---') - this.logger.log('error:') + this.logger.log('---') + this.logger.log('error:') - this.logger.indent() - this.logErrorDetails(error) - this.logger.unindent() + this.logger.indent() + this.logErrorDetails(error) + this.logger.unindent() - if (stack) - this.logger.log(`at: ${yamlString(`${stack.file}:${stack.line}:${stack.column}`)}`) + if (stack) + this.logger.log(`at: ${yamlString(`${stack.file}:${stack.line}:${stack.column}`)}`) - if (error.showDiff) { - this.logger.log(`actual: ${yamlString(error.actual)}`) - this.logger.log(`expected: ${yamlString(error.expected)}`) - } + if (error.showDiff) { + this.logger.log(`actual: ${yamlString(error.actual)}`) + this.logger.log(`expected: ${yamlString(error.expected)}`) + } + }) this.logger.log('...') this.logger.unindent() diff --git a/packages/vitest/src/node/reporters/verbose.ts b/packages/vitest/src/node/reporters/verbose.ts index cd89aefe53ba..4fbd637ea7c4 100644 --- a/packages/vitest/src/node/reporters/verbose.ts +++ b/packages/vitest/src/node/reporters/verbose.ts @@ -21,8 +21,11 @@ export class VerboseReporter extends DefaultReporter { if (this.ctx.config.logHeapUsage && task.result.heap != null) title += c.magenta(` ${Math.floor(task.result.heap / 1024 / 1024)} MB heap used`) this.ctx.logger.log(title) - if (task.result.state === 'fail') - this.ctx.logger.log(c.red(` ${F_RIGHT} ${(task.result.error as any)?.message}`)) + if (task.result.state === 'fail') { + task.result.errors?.forEach((error) => { + this.ctx.logger.log(c.red(` ${F_RIGHT} ${error?.message}`)) + }) + } } } } diff --git a/packages/vitest/src/runtime/collect.ts b/packages/vitest/src/runtime/collect.ts index 1170c898ccd2..c1930e468fb9 100644 --- a/packages/vitest/src/runtime/collect.ts +++ b/packages/vitest/src/runtime/collect.ts @@ -82,9 +82,11 @@ export async function collectTests(paths: string[], config: ResolvedConfig): Pro file.collectDuration = now() - collectStart } catch (e) { + const error = processError(e) file.result = { state: 'fail', - error: processError(e), + error, + errors: [error], } if (config.browser) console.error(e) diff --git a/packages/vitest/src/runtime/run.ts b/packages/vitest/src/runtime/run.ts index abe8d47c6d98..18490bb19a99 100644 --- a/packages/vitest/src/runtime/run.ts +++ b/packages/vitest/src/runtime/run.ts @@ -181,8 +181,10 @@ export async function runTest(test: Test) { test.result.state = 'pass' } catch (e) { + const error = processError(e) test.result.state = 'fail' - test.result.error = processError(e) + test.result.error = error + test.result.errors = [error] } try { @@ -190,8 +192,10 @@ export async function runTest(test: Test) { await callCleanupHooks(beforeEachCleanups) } catch (e) { + const error = processError(e) test.result.state = 'fail' - test.result.error = processError(e) + test.result.error = error + test.result.errors = [error] } if (test.result.state === 'pass') @@ -207,12 +211,15 @@ export async function runTest(test: Test) { // if test is marked to be failed, flip the result if (test.fails) { if (test.result.state === 'pass') { + const error = processError(new Error('Expect test to fail')) test.result.state = 'fail' - test.result.error = processError(new Error('Expect test to fail')) + test.result.error = error + test.result.errors = [error] } else { test.result.state = 'pass' test.result.error = undefined + test.result.errors = undefined } } @@ -302,8 +309,10 @@ export async function runSuite(suite: Suite) { await callCleanupHooks(beforeAllCleanups) } catch (e) { + const error = processError(e) suite.result.state = 'fail' - suite.result.error = processError(e) + suite.result.error = error + suite.result.errors = [error] } } suite.result.duration = now() - start @@ -314,8 +323,11 @@ export async function runSuite(suite: Suite) { if (suite.mode === 'run') { if (!hasTests(suite)) { suite.result.state = 'fail' - if (!suite.result.error) - suite.result.error = new Error(`No test found in suite ${suite.name}`) + if (!suite.result.error) { + const error = processError(new Error(`No test found in suite ${suite.name}`)) + suite.result.error = error + suite.result.errors = [error] + } } else if (hasFailed(suite)) { suite.result.state = 'fail' @@ -446,10 +458,12 @@ async function runSuites(suites: Suite[]) { export async function runFiles(files: File[], config: ResolvedConfig) { for (const file of files) { if (!file.tasks.length && !config.passWithNoTests) { - if (!file.result?.error) { + if (!file.result?.errors?.length) { + const error = processError(new Error(`No test suite found in file ${file.filepath}`)) file.result = { state: 'fail', - error: new Error(`No test suite found in file ${file.filepath}`), + error, + errors: [error], } } } diff --git a/packages/vitest/src/types/tasks.ts b/packages/vitest/src/types/tasks.ts index 15eba941d8f9..18918866565a 100644 --- a/packages/vitest/src/types/tasks.ts +++ b/packages/vitest/src/types/tasks.ts @@ -23,7 +23,11 @@ export interface TaskResult { duration?: number startTime?: number heap?: number + /** + * @deprecated Use "errors" instead + */ error?: ErrorWithDiff + errors?: ErrorWithDiff[] htmlError?: string hooks?: Partial> benchmark?: BenchmarkResult diff --git a/packages/vitest/src/utils/tasks.ts b/packages/vitest/src/utils/tasks.ts index 7384f65acb41..efc1b892caa0 100644 --- a/packages/vitest/src/utils/tasks.ts +++ b/packages/vitest/src/utils/tasks.ts @@ -44,8 +44,7 @@ export function hasFailed(suite: Arrayable): boolean { export function hasFailedSnapshot(suite: Arrayable): boolean { return getTests(suite).some((s) => { - const message = s.result?.error?.message - return message?.match(/Snapshot .* mismatched/) + return s.result?.errors?.some(e => e.message.match(/Snapshot .* mismatched/)) }) } diff --git a/test/reporters/src/data-for-junit.ts b/test/reporters/src/data-for-junit.ts index 2de698824f14..0d6e667c8839 100644 --- a/test/reporters/src/data-for-junit.ts +++ b/test/reporters/src/data-for-junit.ts @@ -42,6 +42,7 @@ function createSuiteHavingFailedTestWithXmlInError(): File[] { result: { state: 'fail', error: errorWithXml, + errors: [errorWithXml], duration: 2.123123123, }, context: null as any, diff --git a/test/reporters/src/data.ts b/test/reporters/src/data.ts index 25210a66a455..fcde6ed4cd05 100644 --- a/test/reporters/src/data.ts +++ b/test/reporters/src/data.ts @@ -63,6 +63,7 @@ const innerTasks: Task[] = [ result: { state: 'fail', error, + errors: [error], duration: 1.4422860145568848, }, context: null as any, diff --git a/test/reporters/tests/__snapshots__/html.test.ts.snap b/test/reporters/tests/__snapshots__/html.test.ts.snap index b243bdff112b..e5772658ef16 100644 --- a/test/reporters/tests/__snapshots__/html.test.ts.snap +++ b/test/reporters/tests/__snapshots__/html.test.ts.snap @@ -52,6 +52,22 @@ exports[`html reporter > resolves to "failing" status for test file "json-fail" "toJSON": "Function", "toString": "Function", }, + "errors": [ + { + "actual": "2", + "constructor": "Function", + "expected": "1", + "message": "expected 2 to deeply equal 1", + "name": "AssertionError", + "nameStr": "AssertionError", + "operator": "strictEqual", + "showDiff": true, + "stack": "AssertionError: expected 2 to deeply equal 1", + "stackStr": "AssertionError: expected 2 to deeply equal 1", + "toJSON": "Function", + "toString": "Function", + }, + ], "hooks": { "afterEach": "pass", "beforeEach": "pass", diff --git a/test/reporters/tests/html.test.ts b/test/reporters/tests/html.test.ts index dbc98ccaa115..bab25eb8f1c0 100644 --- a/test/reporters/tests/html.test.ts +++ b/test/reporters/tests/html.test.ts @@ -67,8 +67,11 @@ describe.skipIf(skip)('html reporter', async () => { task.result.duration = 0 task.result.startTime = 0 expect(task.result.error).toBeDefined() + expect(task.result.errors).toBeDefined() task.result.error.stack = task.result.error.stack.split('\n')[0] + task.result.errors[0].stack = task.result.errors[0].stack.split('\n')[0] task.result.error.stackStr = task.result.error.stackStr.split('\n')[0] + task.result.errors[0].stackStr = task.result.errors[0].stackStr.split('\n')[0] expect(task.logs).toBeDefined() expect(task.logs).toHaveLength(1) task.logs[0].taskId = 0