- **Removed Files**: - Deleted outdated statistics documentation files (`statistics.md`, `statistics-flush-solution.md`, `statistics-summary.md`) to clean up the repository and avoid confusion. - **Added Standards**: - Introduced `DOCUMENTATION_STANDARDS.md` to outline naming conventions and troubleshooting practices for more consistent and maintainable project documentation. - **Tests**: - Added a new test file `edge-cases.test.ts` to verify handling of edge cases, ensuring robust behavior against boundary values and invalid inputs. **Purpose**: Cleans up deprecated documentation while introducing concrete standards for maintaining and updating documentation. Enhances test coverage for unusual or boundary inputs, improving overall system resilience.
219 lines
8.4 KiB
TypeScript
219 lines
8.4 KiB
TypeScript
import { Reporter, File, Task, TaskResult, Vitest, TaskResultPack, RunnerTaskEventPack as TaskEventPack } from 'vitest'
|
|
|
|
/**
|
|
* PrettyReporter - A visually appealing reporter for Vitest
|
|
*
|
|
* This reporter creates a visually enhanced summary of test results
|
|
* with colors, symbols, and formatted output.
|
|
*/
|
|
export default class PrettyReporter implements Reporter {
|
|
private startTime: number = 0
|
|
private testResults: Map<string, { passed: number, failed: number, skipped: number, duration: number }> = new Map()
|
|
private totalTests: { passed: number, failed: number, skipped: number } = { passed: 0, failed: 0, skipped: 0 }
|
|
private failedTests: Array<{ file: string, name: string, error: string }> = []
|
|
private ctx: Vitest | undefined
|
|
|
|
onInit(ctx: Vitest): void {
|
|
this.ctx = ctx
|
|
this.startTime = Date.now()
|
|
console.log('\n🧠 \x1b[36mBrainy Test Suite\x1b[0m - Starting tests...\n')
|
|
}
|
|
|
|
onFinished(files?: File[] | undefined): void {
|
|
const duration = Date.now() - this.startTime
|
|
this.printSummary(duration)
|
|
}
|
|
|
|
onTaskUpdate(packs: TaskResultPack[], events: TaskEventPack[]): void {
|
|
for (const [id, result, meta] of packs) {
|
|
if (result) {
|
|
// Find the task in the state manager
|
|
if (this.ctx?.state) {
|
|
try {
|
|
// We need to handle the entity type carefully
|
|
const entity = this.ctx.state.getReportedEntity(id as unknown as Task)
|
|
|
|
// Skip if entity doesn't exist or doesn't have the right properties
|
|
if (!entity || typeof entity !== 'object') continue
|
|
|
|
// Check if it has the necessary properties to be treated as a Task
|
|
if ('type' in entity &&
|
|
'name' in entity &&
|
|
'file' in entity &&
|
|
entity.file &&
|
|
typeof entity.file === 'object' &&
|
|
'filepath' in entity.file) {
|
|
// Process the task with the right type
|
|
this.processTask({
|
|
id: id,
|
|
name: entity.name as string,
|
|
type: entity.type as any,
|
|
file: entity.file as any,
|
|
mode: (entity as any).mode || 'run',
|
|
result: result
|
|
} as unknown as Task)
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Error processing task ${id}:`, error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
onCollected(files?: File[] | undefined): void {
|
|
if (files) {
|
|
for (const file of files) {
|
|
this.testResults.set(file.filepath, { passed: 0, failed: 0, skipped: 0, duration: 0 })
|
|
|
|
// Count tests in each file
|
|
if (file.tasks) {
|
|
this.countTestsInTasks(file.tasks)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private countTestsInTasks(tasks: Task[]): void {
|
|
for (const task of tasks) {
|
|
if (task.type === 'test') {
|
|
this.processTask(task)
|
|
} else if (task.tasks) {
|
|
// Recursively process nested tasks (like in describe blocks)
|
|
this.countTestsInTasks(task.tasks)
|
|
}
|
|
}
|
|
}
|
|
|
|
private processTask(task: Task): void {
|
|
if (!task || !task.file) return
|
|
|
|
// For test collection phase, we just want to count the test
|
|
if (task.type === 'test') {
|
|
// Make sure we have a valid file path
|
|
if (!task.file.filepath) return
|
|
|
|
const fileResult = this.testResults.get(task.file.filepath) || {
|
|
passed: 0, failed: 0, skipped: 0, duration: 0
|
|
}
|
|
|
|
// During collection, we don't have results yet, so just count the test
|
|
// The actual pass/fail status will be updated during task updates
|
|
if (!task.result) {
|
|
// Just count it as a test, state will be updated later
|
|
console.log(`Counting test: ${task.name} in ${task.file.name}`)
|
|
} else if (task.result.state === 'pass') {
|
|
fileResult.passed++
|
|
this.totalTests.passed++
|
|
console.log(`Passed test: ${task.name}`)
|
|
} else if (task.result.state === 'fail') {
|
|
fileResult.failed++
|
|
this.totalTests.failed++
|
|
this.failedTests.push({
|
|
file: task.file.name,
|
|
name: task.name,
|
|
error: task.result?.errors?.[0]?.message || 'Unknown error'
|
|
})
|
|
console.log(`Failed test: ${task.name}`)
|
|
} else if (task.mode === 'skip' || task.result.state === 'skip') {
|
|
fileResult.skipped++
|
|
this.totalTests.skipped++
|
|
console.log(`Skipped test: ${task.name}`)
|
|
}
|
|
|
|
if (task.result?.duration) {
|
|
fileResult.duration += task.result.duration
|
|
}
|
|
|
|
this.testResults.set(task.file.filepath, fileResult)
|
|
}
|
|
}
|
|
|
|
private printSummary(duration: number): void {
|
|
const durationStr = this.formatDuration(duration)
|
|
const totalTests = this.totalTests.passed + this.totalTests.failed + this.totalTests.skipped
|
|
|
|
console.log('\n')
|
|
console.log('━'.repeat(80))
|
|
console.log(`\n\x1b[1m\x1b[36m📊 TEST SUMMARY REPORT\x1b[0m`)
|
|
console.log('━'.repeat(80))
|
|
|
|
// Print overall stats
|
|
console.log(`\n\x1b[1mTest Run Completed in:\x1b[0m ${durationStr}`)
|
|
console.log(`\x1b[1mDate:\x1b[0m ${new Date().toLocaleString()}`)
|
|
console.log(`\x1b[1mTotal Test Files:\x1b[0m ${this.testResults.size}`)
|
|
console.log(`\x1b[1mTotal Tests:\x1b[0m ${totalTests}`)
|
|
|
|
// Print test status counts with colors and symbols
|
|
console.log(`\n\x1b[1mResults:\x1b[0m`)
|
|
console.log(` \x1b[32m✓ Passed:\x1b[0m ${this.totalTests.passed}`)
|
|
console.log(` \x1b[31m✗ Failed:\x1b[0m ${this.totalTests.failed}`)
|
|
console.log(` \x1b[33m○ Skipped:\x1b[0m ${this.totalTests.skipped}`)
|
|
|
|
// Print file results in a table format
|
|
console.log('\n\x1b[1mTest Files:\x1b[0m')
|
|
console.log('┌' + '─'.repeat(50) + '┬' + '─'.repeat(10) + '┬' + '─'.repeat(10) + '┬' + '─'.repeat(10) + '┐')
|
|
console.log('│ \x1b[1mFile\x1b[0m' + ' '.repeat(46) + '│ \x1b[1mPassed\x1b[0m' + ' '.repeat(4) + '│ \x1b[1mFailed\x1b[0m' + ' '.repeat(4) + '│ \x1b[1mSkipped\x1b[0m' + ' '.repeat(3) + '│')
|
|
console.log('├' + '─'.repeat(50) + '┼' + '─'.repeat(10) + '┼' + '─'.repeat(10) + '┼' + '─'.repeat(10) + '┤')
|
|
|
|
// Sort files by name for consistent output
|
|
const sortedFiles = Array.from(this.testResults.entries()).sort((a, b) => {
|
|
const fileNameA = a[0].split('/').pop() || ''
|
|
const fileNameB = b[0].split('/').pop() || ''
|
|
return fileNameA.localeCompare(fileNameB)
|
|
})
|
|
|
|
for (const [filepath, results] of sortedFiles) {
|
|
const fileName = filepath.split('/').pop() || filepath
|
|
const truncatedName = this.truncateString(fileName, 48)
|
|
const passedStr = `\x1b[32m${results.passed}\x1b[0m`
|
|
const failedStr = results.failed > 0 ? `\x1b[31m${results.failed}\x1b[0m` : `${results.failed}`
|
|
const skippedStr = results.skipped > 0 ? `\x1b[33m${results.skipped}\x1b[0m` : `${results.skipped}`
|
|
|
|
console.log(`│ ${truncatedName}${' '.repeat(50 - truncatedName.length)}│ ${passedStr}${' '.repeat(10 - passedStr.length + 9)}│ ${failedStr}${' '.repeat(10 - failedStr.length + 9)}│ ${skippedStr}${' '.repeat(10 - skippedStr.length + 9)}│`)
|
|
}
|
|
|
|
console.log('└' + '─'.repeat(50) + '┴' + '─'.repeat(10) + '┴' + '─'.repeat(10) + '┴' + '─'.repeat(10) + '┘')
|
|
|
|
// Print failed tests if any
|
|
if (this.failedTests.length > 0) {
|
|
console.log('\n\x1b[1m\x1b[31m❌ Failed Tests:\x1b[0m')
|
|
for (let i = 0; i < this.failedTests.length; i++) {
|
|
const { file, name, error } = this.failedTests[i]
|
|
console.log(`\n ${i + 1}. \x1b[1m${file}\x1b[0m: ${name}`)
|
|
console.log(` \x1b[31m${error}\x1b[0m`)
|
|
}
|
|
}
|
|
|
|
// Print final status
|
|
console.log('\n')
|
|
if (this.totalTests.failed > 0) {
|
|
console.log('\x1b[41m\x1b[37m FAILED \x1b[0m Some tests failed. Check the report above for details.')
|
|
} else {
|
|
console.log('\x1b[42m\x1b[30m PASSED \x1b[0m All tests passed successfully!')
|
|
}
|
|
console.log('\n')
|
|
}
|
|
|
|
private formatDuration(ms: number): string {
|
|
if (ms < 1000) {
|
|
return `${ms}ms`
|
|
}
|
|
const seconds = Math.floor(ms / 1000)
|
|
const minutes = Math.floor(seconds / 60)
|
|
const remainingSeconds = seconds % 60
|
|
|
|
if (minutes === 0) {
|
|
return `${seconds}.${Math.floor((ms % 1000) / 100)}s`
|
|
}
|
|
|
|
return `${minutes}m ${remainingSeconds}s`
|
|
}
|
|
|
|
private truncateString(str: string, maxLength: number): string {
|
|
if (str.length <= maxLength) {
|
|
return str
|
|
}
|
|
return str.substring(0, maxLength - 3) + '...'
|
|
}
|
|
}
|