Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 7 additions & 1 deletion .vscodeignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,10 @@ tsconfig.base.json
.vscode-test.mjs
eslint.config.mjs
scripts/**
test-results/**
test-results/**
.pnpm-store/**
.devcontainer/**
tsconfig.tsbuildinfo
packages/**/node_modules/**
packages/**/src/**
packages/**/tsconfig.json
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -99,6 +104,10 @@
{
"command": "vitest.updateSnapshot",
"when": "controllerId == 'vitest'"
},
{
"command": "vitest.copyFailingTestsOutput",
"when": "controllerId == 'vitest'"
}
],
"testing/item/gutter": [
Expand Down
88 changes: 88 additions & 0 deletions packages/extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,94 @@ class VitestExtension {
const tokenSource = new vscode.CancellationTokenSource()
await profile.runHandler(request, tokenSource.token)
}),
vscode.commands.registerCommand('vitest.copyFailingTestsOutput', async () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am confused why you don't use the test item here to get the errors from there? With this implementation, the copy button will copy all errors whenever you call this

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
Expand Down
14 changes: 14 additions & 0 deletions packages/extension/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions packages/extension/src/testTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
17 changes: 17 additions & 0 deletions packages/extension/src/testTreeData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()}$`
}
Expand Down