Skip to content

Commit 872626b

Browse files
committed
Add CLI 'report' command to display playback results in terminal
Reads playback-results/ and renders a formatted summary with pass/fail counts, batch grouping, per-session breakdowns, error excerpts, and a historical trend line. Supports --all (all runs vs latest only) and --json for machine-readable output.
1 parent d616df7 commit 872626b

2 files changed

Lines changed: 191 additions & 0 deletions

File tree

recorder-cli/bin/cli.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { program } from 'commander'
44
import { startRecording } from '../src/index.js'
55
import { convertToPlaywright } from '../src/playwright-converter.js'
6+
import { generateReport } from '../src/report.js'
67
import chalk from 'chalk'
78
import fs from 'fs'
89
import path from 'path'
@@ -91,4 +92,13 @@ program
9192
}
9293
})
9394

95+
program
96+
.command('report')
97+
.description('Show test results from previous playback runs')
98+
.option('-a, --all', 'Show all runs (default: latest only)')
99+
.option('--json', 'Output raw JSON')
100+
.action((options) => {
101+
generateReport({ last: !options.all, all: options.all, json: options.json })
102+
})
103+
94104
program.parse()

recorder-cli/src/report.js

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import fs from 'fs'
2+
import path from 'path'
3+
import chalk from 'chalk'
4+
5+
const RESULTS_DIR = path.resolve('playback-results')
6+
7+
export function generateReport(options = {}) {
8+
const { last, all, json } = options
9+
10+
if (!fs.existsSync(RESULTS_DIR)) {
11+
console.log(chalk.yellow('No test results found. Run a playback first.'))
12+
return
13+
}
14+
15+
const runs = getRunDirs().map(loadRun).filter(Boolean)
16+
17+
if (runs.length === 0) {
18+
console.log(chalk.yellow('No test results found. Run a playback first.'))
19+
return
20+
}
21+
22+
// Group runs by batchId (single runs get their own group)
23+
const groups = groupRuns(runs)
24+
25+
if (json) {
26+
const data = all ? groups : [groups[groups.length - 1]]
27+
console.log(JSON.stringify(data, null, 2))
28+
return
29+
}
30+
31+
const groupsToShow = all ? groups : [groups[groups.length - 1]]
32+
33+
for (const group of groupsToShow) {
34+
if (group.batchId) {
35+
printBatch(group)
36+
} else {
37+
printRun(group.runs[0])
38+
}
39+
if (groupsToShow.length > 1) console.log('')
40+
}
41+
42+
if (all && groups.length > 1) {
43+
printTrend(groups)
44+
}
45+
}
46+
47+
function getRunDirs() {
48+
return fs
49+
.readdirSync(RESULTS_DIR)
50+
.filter((entry) => {
51+
const full = path.join(RESULTS_DIR, entry)
52+
return fs.statSync(full).isDirectory() && fs.existsSync(path.join(full, 'results.json'))
53+
})
54+
.sort()
55+
}
56+
57+
function loadRun(dirName) {
58+
const resultsPath = path.join(RESULTS_DIR, dirName, 'results.json')
59+
if (!fs.existsSync(resultsPath)) return null
60+
const data = JSON.parse(fs.readFileSync(resultsPath, 'utf-8'))
61+
data._dirName = dirName
62+
return data
63+
}
64+
65+
function groupRuns(runs) {
66+
const groups = []
67+
const batchMap = new Map()
68+
69+
for (const run of runs) {
70+
if (run.batchId) {
71+
if (!batchMap.has(run.batchId)) {
72+
const group = { batchId: run.batchId, runs: [] }
73+
batchMap.set(run.batchId, group)
74+
groups.push(group)
75+
}
76+
batchMap.get(run.batchId).runs.push(run)
77+
} else {
78+
groups.push({ batchId: null, runs: [run] })
79+
}
80+
}
81+
82+
// Sort sessions within each batch
83+
for (const group of groups) {
84+
if (group.batchId) {
85+
group.runs.sort((a, b) => (a.sessionIndex || 0) - (b.sessionIndex || 0))
86+
}
87+
}
88+
89+
return groups
90+
}
91+
92+
function printBatch(group) {
93+
const { batchId, runs } = group
94+
const totalSessions = runs.length
95+
const passedSessions = runs.filter((r) => r.status === 'passed').length
96+
const failedSessions = totalSessions - passedSessions
97+
const totalDuration = Math.max(...runs.map((r) => r.duration))
98+
const overallStatus = failedSessions === 0 ? 'passed' : 'failed'
99+
const date = new Date(runs[0].timestamp).toLocaleString()
100+
101+
const statusIcon = overallStatus === 'passed' ? chalk.green('PASS') : chalk.red('FAIL')
102+
console.log(`${statusIcon} ${chalk.cyan('BULK')} ${chalk.dim(date)} ${chalk.dim(`(${formatDuration(totalDuration)})`)}`)
103+
console.log(chalk.dim(` batch: ${batchId}${totalSessions} sessions`))
104+
console.log('')
105+
106+
console.log(
107+
` ${chalk.green(`${passedSessions} passed`)}` +
108+
(failedSessions > 0 ? ` ${chalk.red(`${failedSessions} failed`)}` : '') +
109+
` ${chalk.dim(`(${totalSessions} sessions)`)}`
110+
)
111+
console.log('')
112+
113+
for (const run of runs) {
114+
const icon = run.status === 'passed' ? chalk.green('✓') : chalk.red('✗')
115+
const label = `Session ${run.sessionIndex || '?'}`
116+
const dur = chalk.dim(`(${formatDuration(run.duration)})`)
117+
console.log(` ${icon} ${label} ${dur}`)
118+
119+
if (run.status !== 'passed') {
120+
const failures = run.tests.filter((t) => t.status === 'failed' || t.status === 'timedOut')
121+
for (const test of failures) {
122+
if (test.errors?.length > 0) {
123+
const firstError = test.errors[0].message?.split('\n')[0] || 'Unknown error'
124+
console.log(chalk.dim(` ${firstError}`))
125+
}
126+
}
127+
}
128+
}
129+
}
130+
131+
function printRun(run) {
132+
const statusIcon = run.status === 'passed' ? chalk.green('PASS') : chalk.red('FAIL')
133+
const date = new Date(run.timestamp).toLocaleString()
134+
const duration = formatDuration(run.duration)
135+
136+
console.log(`${statusIcon} ${chalk.dim(date)} ${chalk.dim(`(${duration})`)}`)
137+
console.log(chalk.dim(` ${path.join('playback-results', run._dirName)}/`))
138+
console.log('')
139+
140+
console.log(
141+
` ${chalk.green(`${run.passed} passed`)}` +
142+
(run.failed > 0 ? ` ${chalk.red(`${run.failed} failed`)}` : '') +
143+
(run.skipped > 0 ? ` ${chalk.yellow(`${run.skipped} skipped`)}` : '') +
144+
(run.timedOut > 0 ? ` ${chalk.magenta(`${run.timedOut} timed out`)}` : '') +
145+
` ${chalk.dim(`(${run.total} total)`)}`)
146+
147+
if (run.failed > 0 || run.timedOut > 0) {
148+
console.log('')
149+
const failures = run.tests.filter((t) => t.status === 'failed' || t.status === 'timedOut')
150+
for (const test of failures) {
151+
const icon = test.status === 'timedOut' ? chalk.magenta('⏱') : chalk.red('✗')
152+
console.log(` ${icon} ${test.file} > ${test.title}`)
153+
if (test.errors?.length > 0) {
154+
const firstError = test.errors[0].message?.split('\n')[0] || 'Unknown error'
155+
console.log(chalk.dim(` ${firstError}`))
156+
}
157+
}
158+
}
159+
}
160+
161+
function printTrend(groups) {
162+
console.log(chalk.dim('─'.repeat(50)))
163+
console.log(chalk.bold(' Trend (last ' + groups.length + ' runs):'))
164+
const passCount = groups.filter((g) => {
165+
return g.runs.every((r) => r.status === 'passed')
166+
}).length
167+
const bar = groups.map((g) => {
168+
const allPassed = g.runs.every((r) => r.status === 'passed')
169+
return allPassed ? chalk.green('●') : chalk.red('●')
170+
}).join(' ')
171+
console.log(` ${bar}`)
172+
console.log(` ${chalk.dim(`${passCount}/${groups.length} passed (${Math.round((passCount / groups.length) * 100)}%)`)}`)
173+
}
174+
175+
function formatDuration(ms) {
176+
if (ms < 1000) return `${ms}ms`
177+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
178+
const mins = Math.floor(ms / 60000)
179+
const secs = Math.round((ms % 60000) / 1000)
180+
return `${mins}m ${secs}s`
181+
}

0 commit comments

Comments
 (0)