diff --git a/.vscodeignore b/.vscodeignore index cdd767f9..f8b2afc9 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -24,4 +24,10 @@ tsconfig.base.json .vscode-test.mjs eslint.config.mjs scripts/** -test-results/** \ No newline at end of file +test-results/** +.pnpm-store/** +.devcontainer/** +tsconfig.tsbuildinfo +packages/**/node_modules/** +packages/**/src/** +packages/**/tsconfig.json \ No newline at end of file diff --git a/package.json b/package.json index 96d3b91a..29e8fab2 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,11 @@ "title": "Reveal in Test Explorer", "command": "vitest.revealInTestExplorer", "category": "Vitest" + }, + { + "title": "Copy Failing Tests Output", + "command": "vitest.copyFailingTestsOutput", + "category": "Vitest" } ], "menus": { @@ -99,6 +104,10 @@ { "command": "vitest.updateSnapshot", "when": "controllerId == 'vitest'" + }, + { + "command": "vitest.copyFailingTestsOutput", + "when": "controllerId == 'vitest'" } ], "testing/item/gutter": [ diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 1401a3d6..783f581a 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -344,6 +344,94 @@ class VitestExtension { const tokenSource = new vscode.CancellationTokenSource() await profile.runHandler(request, tokenSource.token) }), + vscode.commands.registerCommand('vitest.copyFailingTestsOutput', async () => { + const failingTests = this.testTree.getFailingTests() + + if (failingTests.length === 0) { + vscode.window.showInformationMessage('No failing tests found in the last run') + return + } + + const outputParts: string[] = [] + + for (const { testItem, testData } of failingTests) { + const testPath = testItem.uri?.fsPath || 'Unknown file' + const testName = testItem.label + const result = testData.lastResult + const duration = result?.duration ? ` (${result.duration}ms)` : '' + + const parts: string[] = [`Test: ${testName}${duration}`, `File: ${testPath}`, ''] + + if (result?.messages) { + for (const msg of result.messages) { + // Add the error message + const messageText = typeof msg.message === 'string' ? msg.message : msg.message.value + parts.push('Error:') + parts.push(messageText) + parts.push('') + + // Add stack trace if available + if (msg.stackTrace && msg.stackTrace.length > 0) { + parts.push('Stack trace:') + + for (const frame of msg.stackTrace) { + if (frame.uri && frame.position) { + try { + const document = await vscode.workspace.openTextDocument(frame.uri) + const lineNumber = frame.position.line + const columnNumber = frame.position.character + + // Get context lines (2 before, the error line, 2 after) + const startLine = Math.max(0, lineNumber - 2) + const endLine = Math.min(document.lineCount - 1, lineNumber + 2) + + parts.push(` at ${frame.label} (${frame.uri.fsPath}:${lineNumber + 1}:${columnNumber + 1})`) + parts.push('') + + for (let i = startLine; i <= endLine; i++) { + const line = document.lineAt(i) + const lineNum = (i + 1).toString().padStart(4, ' ') + + if (i === lineNumber) { + // This is the error line + parts.push(`>${lineNum} | ${line.text}`) + + // Add pointer to the exact column + const pointer = `${' '.repeat(11 + columnNumber)}^` + const errorSuffix = messageText ? ` ${messageText}` : '' + parts.push(` ${pointer}${errorSuffix}`) + } + else { + parts.push(` ${lineNum} | ${line.text}`) + } + } + + parts.push('') + } + catch { + // Fallback if we can't read the file + const location = `${frame.uri.fsPath}:${frame.position.line + 1}:${frame.position.character + 1}` + parts.push(` at ${frame.label} (${location})`) + } + } + else { + parts.push(` at ${frame.label} (unknown location)`) + } + } + + parts.push('') + } + } + } + + outputParts.push(parts.join('\n')) + } + + const output = outputParts.join(`${'='.repeat(80)}\n\n`) + + await vscode.env.clipboard.writeText(output) + vscode.window.showInformationMessage(`Copied ${failingTests.length} failing test(s) to clipboard`) + }), ] // if the config changes, re-define all test profiles diff --git a/packages/extension/src/runner.ts b/packages/extension/src/runner.ts index 37405f2b..ffe4b0c9 100644 --- a/packages/extension/src/runner.ts +++ b/packages/extension/src/runner.ts @@ -517,6 +517,8 @@ export class TestRunner extends vscode.Disposable { test: vscode.TestItem, result: RunnerTaskResult, ) { + const testData = getTestData(test) + switch (result.state) { case 'fail': { const errors = result.errors?.map(err => @@ -529,16 +531,28 @@ export class TestRunner extends vscode.Disposable { if (test.uri) { this.diagnostic?.addDiagnostic(test.uri, errors) } + // Store result in TestCase + if (testData instanceof TestCase) { + testData.setResult('failed', errors, result.duration) + } log.verbose?.(`Marking "${test.label}" as failed with ${errors.length} errors`) testRun.failed(test, errors, result.duration) break } case 'pass': + // Store result in TestCase + if (testData instanceof TestCase) { + testData.setResult('passed', undefined, result.duration) + } log.verbose?.(`Marking "${test.label}" as passed`) testRun.passed(test, result.duration) break case 'todo': case 'skip': + // Store result in TestCase + if (testData instanceof TestCase) { + testData.setResult('skipped') + } log.verbose?.(`Marking "${test.label}" as skipped`) testRun.skipped(test) break diff --git a/packages/extension/src/testTree.ts b/packages/extension/src/testTree.ts index d725f430..5641598c 100644 --- a/packages/extension/src/testTree.ts +++ b/packages/extension/src/testTree.ts @@ -453,6 +453,21 @@ export class TestTree extends vscode.Disposable { parent.children.delete(child.id) }) } + + public getFailingTests(): Array<{ testItem: vscode.TestItem; testData: TestCase }> { + const failingTests: Array<{ testItem: vscode.TestItem; testData: TestCase }> = [] + + for (const [, testItem] of this.flatTestItems) { + const data = getTestData(testItem) + if (data instanceof TestCase && data.lastResult) { + if (data.lastResult.status === 'failed' || data.lastResult.status === 'errored') { + failingTests.push({ testItem, testData: data }) + } + } + } + + return failingTests + } } function isTest(task: RunnerTask) { diff --git a/packages/extension/src/testTreeData.ts b/packages/extension/src/testTreeData.ts index f718f494..30ce504b 100644 --- a/packages/extension/src/testTreeData.ts +++ b/packages/extension/src/testTreeData.ts @@ -108,9 +108,17 @@ class TaskName { } } +export interface TestResult { + status: 'passed' | 'failed' | 'skipped' | 'errored' + messages?: vscode.TestMessage[] + duration?: number + timestamp: Date +} + export class TestCase extends BaseTestData { public name: TaskName public readonly type = 'test' + public lastResult?: TestResult private constructor( item: vscode.TestItem, @@ -126,6 +134,15 @@ export class TestCase extends BaseTestData { return addTestData(item, new TestCase(item, parent, file, dynamic)) } + public setResult(status: TestResult['status'], messages?: vscode.TestMessage[], duration?: number) { + this.lastResult = { + status, + messages, + duration, + timestamp: new Date(), + } + } + getTestNamePattern() { return `^${this.name.getTestNamePattern()}$` }