import type { FullConfig, FullResult, Reporter, Suite, TestCase, TestResult } from '@playwright/test/reporter'; import * as colors from 'ansi-colors'; import * as readline from 'readline'; interface TestProgress { total: number; passed: number; failed: number; skipped: number; started: number; startTime: number; tests: Map; } interface TestResultInfo { status: 'passed' | 'failed' | 'skipped' | 'running'; startTime: number; duration: number; file: string; title: string[]; } export class ProgressReporter implements Reporter { private progress: TestProgress; private interval: NodeJS.Timeout | null = null; private lastUpdate: number = 0; private updateInterval: number = 1000; private showProgressBar: boolean = true; private quietMode: boolean = false; constructor(options?: { showProgressBar?: boolean; quietMode?: boolean; updateInterval?: number }) { this.showProgressBar = options?.showProgressBar ?? true; this.quietMode = options?.quietMode ?? false; this.updateInterval = options?.updateInterval ?? 1000; this.progress = { total: 0, passed: 0, failed: 0, skipped: 0, started: 0, startTime: 0, tests: new Map() }; } onBegin(config: FullConfig, suite: Suite) { this.progress.startTime = Date.now(); this.progress.total = this.countTotalTests(suite); if (!this.quietMode) { console.log('\n' + colors.bold('🧪 开始执行测试')); console.log(colors.gray(`总测试数: ${this.progress.total}`)); console.log(colors.gray(`并行数: ${config.workers}`)); console.log(''); if (this.showProgressBar) { this.startProgressUpdate(); } } } onTestBegin(test: TestCase, result: TestResult) { this.progress.started++; this.progress.tests.set(test.id, { status: 'running', startTime: Date.now(), duration: 0, file: test.location.file, title: test.titlePath() }); if (!this.quietMode && this.showProgressBar) { this.updateProgress(); } } onTestEnd(test: TestCase, result: TestResult) { const testInfo = this.progress.tests.get(test.id); if (testInfo) { testInfo.status = result.status === 'passed' ? 'passed' : result.status === 'failed' ? 'failed' : 'skipped'; testInfo.duration = result.duration; if (result.status === 'passed') { this.progress.passed++; } else if (result.status === 'failed') { this.progress.failed++; } else { this.progress.skipped++; } } if (!this.quietMode && this.showProgressBar) { this.updateProgress(); } } onEnd(result: FullResult) { if (this.interval) { clearInterval(this.interval); this.interval = null; } const duration = Date.now() - this.progress.startTime; if (!this.quietMode) { if (this.showProgressBar) { this.clearProgress(); } this.printSummary(result, duration); } } private countTotalTests(suite: Suite): number { let count = 0; for (const child of suite.suites) { count += this.countTotalTests(child); } count += suite.tests.length; return count; } private startProgressUpdate(): void { this.interval = setInterval(() => { this.updateProgress(); }, this.updateInterval); } private updateProgress(): void { const now = Date.now(); if (now - this.lastUpdate < this.updateInterval) { return; } this.lastUpdate = now; this.printProgressBar(); } private printProgressBar(): void { const { total, passed, failed, skipped, started, startTime } = this.progress; const completed = passed + failed + skipped; const percentage = total > 0 ? (completed / total) * 100 : 0; const elapsed = (now() - startTime) / 1000; const avgTime = completed > 0 ? elapsed / completed : 0; const remaining = (total - completed) * avgTime; const barWidth = 40; const filledWidth = Math.round((completed / total) * barWidth); const emptyWidth = barWidth - filledWidth; const bar = colors.green('█'.repeat(filledWidth)) + colors.gray('░'.repeat(emptyWidth)); const statusLine = [ colors.bold(`[${percentage.toFixed(1)}%]`), bar, colors.green(`✓ ${passed}`), failed > 0 ? colors.red(`✗ ${failed}`) : colors.gray(`✗ ${failed}`), colors.yellow(`⊘ ${skipped}`), colors.gray(`⏱ ${elapsed.toFixed(1)}s`), remaining > 0 ? colors.gray(`⏳ ${remaining.toFixed(1)}s`) : '' ].filter(Boolean).join(' '); readline.cursorTo(process.stdout, 0); process.stdout.write(statusLine); } private clearProgress(): void { readline.cursorTo(process.stdout, 0); readline.clearLine(process.stdout, 0); } private printSummary(result: FullResult, duration: number): void { const { total, passed, failed, skipped } = this.progress; const passRate = total > 0 ? (passed / total) * 100 : 0; console.log('\n' + '='.repeat(60)); console.log(colors.bold('📊 测试执行完成')); console.log('='.repeat(60)); console.log(colors.gray(`执行时间: ${(duration / 1000).toFixed(2)}秒`)); console.log(''); console.log(colors.bold('📈 测试结果:')); console.log(` 总测试数: ${colors.bold(total)}`); console.log(` 通过测试: ${colors.green(passed)} (${(passed / total * 100).toFixed(2)}%)`); console.log(` 失败测试: ${failed > 0 ? colors.red(failed) : colors.gray(failed)} (${(failed / total * 100).toFixed(2)}%)`); console.log(` 跳过测试: ${colors.yellow(skipped)} (${(skipped / total * 100).toFixed(2)}%)`); console.log(` 通过率: ${passRate >= 80 ? colors.green : passRate >= 60 ? colors.yellow : colors.red}${passRate.toFixed(2)}%`); console.log(''); if (failed > 0) { console.log(colors.bold('❌ 失败的测试:')); this.printFailedTests(); console.log(''); } if (skipped > 0) { console.log(colors.bold('⚠️ 跳过的测试:')); this.printSkippedTests(); console.log(''); } console.log('='.repeat(60)); if (result.status === 'passed') { console.log(colors.green.bold('✅ 所有测试通过')); } else { console.log(colors.red.bold('❌ 存在失败的测试')); } console.log('='.repeat(60) + '\n'); } private printFailedTests(): void { const failedTests = Array.from(this.progress.tests.entries()) .filter(([_, info]) => info.status === 'failed') .slice(0, 10); failedTests.forEach(([testId, info]) => { const fileName = info.file.split('/').pop(); console.log(` ${colors.red('✗')} ${colors.bold(fileName)}: ${info.title.join(' > ')}`); }); if (failedTests.length >= 10) { console.log(` ${colors.gray(`... 还有 ${this.progress.failed - 10} 个失败的测试`)}`); } } private printSkippedTests(): void { const skippedTests = Array.from(this.progress.tests.entries()) .filter(([_, info]) => info.status === 'skipped') .slice(0, 5); skippedTests.forEach(([testId, info]) => { const fileName = info.file.split('/').pop(); console.log(` ${colors.yellow('⊘')} ${colors.bold(fileName)}: ${info.title.join(' > ')}`); }); if (skippedTests.length >= 5) { console.log(` ${colors.gray(`... 还有 ${this.progress.skipped - 5} 个跳过的测试`)}`); } } } function now(): number { return Date.now(); } export default ProgressReporter;