From 96765e36bcd154702184ab88a4c5891911d54985 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Wed, 29 Oct 2025 19:16:33 +0100 Subject: [PATCH 1/3] chore: refactor pretty printer --- src/cli/cli.ts | 11 +++---- src/cli/reporters/pretty.ts | 65 ++++++++++++++++++++++++------------- src/cli/reporters/tap.ts | 2 +- 3 files changed, 49 insertions(+), 29 deletions(-) diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 22a1e6f..2a15ef6 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -1,5 +1,7 @@ #!/usr/bin/env node +import { styleText } from 'node:util' +import { console } from 'node:inspector' import { validate_arguments, parse_arguments } from './arguments.js' import { program } from './program.js' import { read } from './file-reader.js' @@ -7,9 +9,7 @@ import { print as pretty } from './reporters/pretty.js' import { print as tap } from './reporters/tap.js' async function cli(cli_args: string[]) { - console.log(cli_args) let params = validate_arguments(parse_arguments(cli_args)) - console.log(params) let coverage_data = await read(params['coverage-dir']) let report = program( { @@ -23,11 +23,10 @@ async function cli(cli_args: string[]) { process.exitCode = 1 } - if (params.reporter === 'pretty') { - pretty(report, params) - } else if (params.reporter === 'tap') { - tap(report, params) + if (params.reporter === 'tap') { + return tap(report, params) } + return pretty(report, params) } try { diff --git a/src/cli/reporters/pretty.ts b/src/cli/reporters/pretty.ts index ecaeced..ee8f8a6 100644 --- a/src/cli/reporters/pretty.ts +++ b/src/cli/reporters/pretty.ts @@ -8,13 +8,26 @@ function indent(line?: string): string { return (line || '').replace(/^\t+/, (tabs) => ' '.repeat(tabs.length * 4)) } -export function print({ report, context }: Report, params: CliArguments) { +function percentage(ratio: number): string { + return `${(ratio * 100).toFixed(2)}%` +} + +type TextStyle = 'bold' | 'red' | 'dim' | 'green' +type StyleTextFn = (style: TextStyle | TextStyle[], input: string) => string + +export function print_lines( + { report, context }: Report, + params: CliArguments, + { styleText, print_width }: { styleText: StyleTextFn; print_width?: number }, +) { + let output: (string | undefined)[] = [] + if (report.min_line_coverage.ok) { - console.log(`${styleText(['bold', 'green'], 'Success')}: total line coverage is ${(report.min_line_coverage.actual * 100).toFixed(2)}%`) + output.push(`${styleText(['bold', 'green'], 'Success')}: total line coverage is ${percentage(report.min_line_coverage.actual)}%`) } else { - console.error( - `${styleText(['bold', 'red'], 'Failed')}: line coverage is ${(report.min_line_coverage.actual * 100).toFixed( - 2, + output.push( + `${styleText(['bold', 'red'], 'Failed')}: line coverage is ${percentage( + report.min_line_coverage.actual, )}% which is lower than the threshold of ${report.min_line_coverage.expected}`, ) } @@ -22,16 +35,16 @@ export function print({ report, context }: Report, params: CliArguments) { if (report.min_file_line_coverage.expected !== undefined) { let { expected, actual, ok } = report.min_file_line_coverage if (ok) { - console.log(`${styleText(['bold', 'green'], 'Success')}: all files pass minimum line coverage of ${expected * 100}%`) + output.push(`${styleText(['bold', 'green'], 'Success')}: all files pass minimum line coverage of ${percentage(expected)}%`) } else { let num_files_failed = context.coverage.coverage_per_stylesheet.filter((sheet) => sheet.line_coverage_ratio < expected!).length - console.error( - `${styleText(['bold', 'red'], 'Failed')}: ${num_files_failed} files do not meet the minimum line coverage of ${ - expected * 100 - }% (minimum coverage was ${(actual * 100).toFixed(2)}%)`, + output.push( + `${styleText(['bold', 'red'], 'Failed')}: ${num_files_failed} ${ + num_files_failed === 1 ? 'file does' : 'files do' + } not meet the minimum line coverage of ${percentage(expected)} (minimum coverage was ${percentage(actual)})`, ) if (params['show-uncovered'] === 'none') { - console.log(` Hint: set --show-uncovered=violations to see which files didn't pass`) + output.push(` Hint: set --show-uncovered=violations to see which files didn't pass`) } } } @@ -40,7 +53,7 @@ export function print({ report, context }: Report, params: CliArguments) { if (params['show-uncovered'] !== 'none') { const NUM_LEADING_LINES = 3 const NUM_TRAILING_LINES = NUM_LEADING_LINES - let terminal_width = process.stdout.columns || 80 + print_width = print_width ?? 80 let line_number = (num: number, covered: boolean = true) => `${num.toString().padStart(5, ' ')} ${covered ? '│' : '━'} ` let min_file_line_coverage = report.min_file_line_coverage.expected @@ -52,36 +65,44 @@ export function print({ report, context }: Report, params: CliArguments) { sheet.line_coverage_ratio < min_file_line_coverage && params['show-uncovered'] === 'violations') ) { - console.log() - console.log(styleText('dim', '─'.repeat(terminal_width))) - console.log(sheet.url) - console.log(`Coverage: ${(sheet.line_coverage_ratio * 100).toFixed(2)}%, ${sheet.covered_lines}/${sheet.total_lines} lines covered`) + output.push() + output.push(styleText('dim', '─'.repeat(print_width))) + output.push(sheet.url) + output.push(`Coverage: ${percentage(sheet.line_coverage_ratio)}, ${sheet.covered_lines}/${sheet.total_lines} lines covered`) if (min_file_line_coverage && min_file_line_coverage !== 0 && sheet.line_coverage_ratio < min_file_line_coverage) { let lines_to_cover = min_file_line_coverage * sheet.total_lines - sheet.covered_lines - console.log(`Tip: cover ${Math.ceil(lines_to_cover)} more lines to meet the file threshold of ${min_file_line_coverage * 100}%`) + output.push( + `Tip: cover ${Math.ceil(lines_to_cover)} more lines to meet the file threshold of ${percentage(min_file_line_coverage)}`, + ) } - console.log(styleText('dim', '─'.repeat(terminal_width))) + output.push(styleText('dim', '─'.repeat(print_width))) let lines = sheet.text.split('\n') for (let chunk of sheet.chunks.filter((chunk) => !chunk.is_covered)) { // Render N leading lines for (let x = Math.max(chunk.start_line - NUM_LEADING_LINES, 0); x < chunk.start_line; x++) { - console.log(styleText('dim', line_number(x)), styleText('dim', indent(lines[x - 1]))) + output.push([styleText('dim', line_number(x)), styleText('dim', indent(lines[x - 1]))].join('')) } // Render the uncovered chunk for (let i = chunk.start_line; i <= chunk.end_line; i++) { - console.log(styleText('red', line_number(i, false)), indent(lines[i - 1])) + output.push([styleText('red', line_number(i, false)), indent(lines[i - 1])].join('')) } // Render N trailing lines for (let y = chunk.end_line; y < Math.min(chunk.end_line + NUM_TRAILING_LINES, lines.length); y++) { - console.log(styleText('dim', line_number(y)), styleText('dim', indent(lines[y - 1]))) + output.push([styleText('dim', line_number(y)), styleText('dim', indent(lines[y - 1]))].join('')) } // Show empty line between blocks - console.log() + output.push() } } } } + + return output +} + +export function print(report: Report, params: CliArguments): void { + print_lines(report, params, { styleText, print_width: process.stdout.columns }).map((line) => console.log(line)) } diff --git a/src/cli/reporters/tap.ts b/src/cli/reporters/tap.ts index 6bfe135..1b6288b 100644 --- a/src/cli/reporters/tap.ts +++ b/src/cli/reporters/tap.ts @@ -25,7 +25,7 @@ function meta(data: Record) { console.log(' ...') } -export function print({ report, context }: Report, params: CliArguments) { +export function print({ report, context }: Report, params: CliArguments): void { let total_files = context.coverage.coverage_per_stylesheet.length let total_checks = total_files + 1 let checks_added = 1 From 30a4120445d5f1568091c14f2c94a012d90bfcb5 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Thu, 30 Oct 2025 20:15:16 +0100 Subject: [PATCH 2/3] add tests for pretty printer --- .vscode/settings.json | 3 + src/cli/reporters/pretty.test.ts | 286 +++++++++++++++++++++++++++++++ src/cli/reporters/pretty.ts | 35 ++-- 3 files changed, 309 insertions(+), 15 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 src/cli/reporters/pretty.test.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..39c96d4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "files.trimTrailingWhitespaceInRegexAndStrings": false +} \ No newline at end of file diff --git a/src/cli/reporters/pretty.test.ts b/src/cli/reporters/pretty.test.ts new file mode 100644 index 0000000..00db910 --- /dev/null +++ b/src/cli/reporters/pretty.test.ts @@ -0,0 +1,286 @@ +import { test, expect } from '@playwright/test' +import { print_lines as print, PrintLinesDependencies, type StyleTextFn, TextStyle } from './pretty' +import { Report } from '../program' +import { CoverageResult } from '../../lib' +import { CliArguments } from '../arguments' +import { PrettifiedChunk } from '../../lib/prettify' + +// test matrix +// ------------------------------ +// overall line coverage success +// overall line coverage fail +// per-file line coverage success +// per-file line coverage fail +// show all +// show violations +// show none + +function style_text_fn(_style: TextStyle | TextStyle[], text: string): string { + return text +} + +const min_line_coverage_failure = { + min_line_coverage: { + ok: false, + expected: 1, + actual: 0.502222222222, + } satisfies Report['report']['min_line_coverage'], +} + +const min_line_coverage_success = { + min_line_coverage: { + ok: true, + expected: 0, + actual: 0.5022222222, + } satisfies Report['report']['min_line_coverage'], +} + +const min_file_line_coverage_unset = { + min_file_line_coverage: { + ok: true, + actual: 0.5, + } satisfies Report['report']['min_file_line_coverage'], +} + +const min_file_line_coverage_success = { + min_file_line_coverage: { + ok: true, + actual: 1, + expected: 0.5, + } satisfies Report['report']['min_file_line_coverage'], +} + +const min_file_line_coverage_failure = { + min_file_line_coverage: { + ok: false, + actual: 0.5, + expected: 1, + } satisfies Report['report']['min_file_line_coverage'], +} + +const show_none = { 'show-uncovered': 'none' } as CliArguments +const show_all = { 'show-uncovered': 'all' } as CliArguments +const show_violations = { 'show-uncovered': 'violations' } as CliArguments + +const context_empty = { + context: { + coverage: {} as CoverageResult, + }, +} + +const context_with_failures = { + context: { + coverage: { + line_coverage_ratio: 0.4022222, + covered_lines: 10, + total_lines: 11, + coverage_per_stylesheet: [ + { + url: 'example.com', + line_coverage_ratio: 1, + text: `z { + color: yellow + }`, + chunks: [ + { + start_line: 1, + end_line: 3, + is_covered: true, + }, + ], + }, + { + url: 'example.com', + line_coverage_ratio: 8 / 19, + covered_lines: 8, + total_lines: 19, + text: `a { + color: red; +} + +a1 { + color: blue; +} + +b { + color: red; +} + +b1 { + color: blue; +} + +c { + color: red; +}`, + chunks: [ + { + start_line: 1, + end_line: 3, + is_covered: false, + }, + { + start_line: 4, + end_line: 8, + is_covered: true, + }, + { + start_line: 9, + end_line: 11, + is_covered: false, + }, + { + start_line: 12, + end_line: 16, + is_covered: true, + }, + { + start_line: 17, + end_line: 19, + is_covered: false, + }, + ], + }, + ], + } as CoverageResult, + }, +} + +const dependencies = { styleText: style_text_fn, print_width: 60 } satisfies PrintLinesDependencies + +test.describe('only --min-line-coverage', () => { + test('success', () => { + const report = { + ...context_empty, + report: { + ok: true, + ...min_line_coverage_success, + ...min_file_line_coverage_unset, + }, + } satisfies Report + let result = print(report, show_none, dependencies) + expect(result).toEqual(['Success: total line coverage is 50.22%']) + }) + + test('failure', () => { + const report = { + ...context_empty, + report: { + ok: false, + ...min_line_coverage_failure, + ...min_file_line_coverage_unset, + }, + } satisfies Report + let result = print(report, show_none, dependencies) + expect(result).toEqual(['Failed: line coverage is 50.22%% which is lower than the threshold of 1']) + }) +}) + +test.describe('with --min-file-line-coverage', () => { + test('--min-line-coverage: success; --min-file-line-coverage: success; --show: none', () => { + let report = { + ...context_empty, + report: { + ok: true, + ...min_line_coverage_success, + ...min_file_line_coverage_success, + }, + } satisfies Report + let result = print(report, show_none, dependencies) + expect(result).toEqual(['Success: total line coverage is 50.22%', 'Success: all files pass minimum line coverage of 50.00%']) + }) + + test.describe('--min-line-coverage: success; --min-file-line-coverage: failure; --show: none', () => { + let report = { + ...context_with_failures, + report: { + ok: false, + ...min_line_coverage_success, + ...min_file_line_coverage_failure, + }, + } satisfies Report + let result = print(report, show_none, dependencies) + + test('coverage: pass', () => { + expect(result[0]).toEqual('Success: total line coverage is 50.22%') + }) + test('file-coverage: fail', () => { + expect(result[1]).toEqual('Failed: 1 file does not meet the minimum line coverage of 100% (minimum coverage was 50.00%)') + }) + test('shows hint to --show=violations', () => { + expect(result[2]).toEqual(" Hint: set --show-uncovered=violations to see which files didn't pass") + }) + test('no files shown', () => { + expect(result).toHaveLength(3) + }) + }) + + test.describe('--min-line-coverage: success; --min-file-line-coverage: failure; --show: violations', () => { + let report = { + ...context_with_failures, + report: { + ok: false, + ...min_line_coverage_success, + ...min_file_line_coverage_failure, + }, + } satisfies Report + let result = print(report, show_violations, dependencies) + + test('coverage: pass', () => { + expect(result[0]).toEqual('Success: total line coverage is 50.22%') + }) + test('file-coverage: fail', () => { + expect(result[1]).toEqual('Failed: 1 file does not meet the minimum line coverage of 100% (minimum coverage was 50.00%)') + }) + test('does not show hint to --show=violations', () => { + expect(result[2]).not.toEqual(" Hint: set --show-uncovered=violations to see which files didn't pass") + }) + test.describe('shows file details', () => { + let lines = result.slice(2) + + test('shows header block', () => { + expect(lines[0]).toEqual('─'.repeat(60)) + expect(lines[4]).toEqual(lines[0]) + }) + test('shows file name', () => { + expect(lines[1]).toEqual('example.com') + }) + test('shows coverage info', () => { + expect(lines[2]).toEqual('Coverage: 42.11%, 8/19 lines covered') + }) + test('shows hint with how many lines to cover to reach threshold', () => { + expect(lines[3]).toEqual('Tip: cover 11 more lines to meet the file threshold of 100%') + }) + test('result snapshot', () => { + let snapshot = lines.join('\n') + expect(snapshot).toEqual( + ` +──────────────────────────────────────────────────────────── +example.com +Coverage: 42.11%, 8/19 lines covered +Tip: cover 11 more lines to meet the file threshold of 100% +──────────────────────────────────────────────────────────── + 1 ━ a { + 2 ━ color: red; + 3 ━ } + 4 │ + 5 │ a1 { + 6 │ color: blue; + 7 │ } + 8 │ + 9 ━ b { + 10 ━ color: red; + 11 ━ } + 12 │ + 13 │ b1 { + 14 │ color: blue; + 15 │ } + 16 │ + 17 ━ c { + 18 ━ color: red; + 19 ━ }`.trim(), + ) + }) + }) + }) +}) diff --git a/src/cli/reporters/pretty.ts b/src/cli/reporters/pretty.ts index ee8f8a6..2a28f12 100644 --- a/src/cli/reporters/pretty.ts +++ b/src/cli/reporters/pretty.ts @@ -8,22 +8,26 @@ function indent(line?: string): string { return (line || '').replace(/^\t+/, (tabs) => ' '.repeat(tabs.length * 4)) } -function percentage(ratio: number): string { - return `${(ratio * 100).toFixed(2)}%` +let line_number = (num: number, covered: boolean = true) => `${num.toString().padStart(5, ' ')} ${covered ? '│' : '━'} ` + +function percentage(ratio: number, decimals: number = 2): string { + return `${(ratio * 100).toFixed(ratio === 1 ? 0 : decimals)}%` } -type TextStyle = 'bold' | 'red' | 'dim' | 'green' -type StyleTextFn = (style: TextStyle | TextStyle[], input: string) => string +export type TextStyle = 'bold' | 'red' | 'dim' | 'green' + +export type StyleTextFn = (style: TextStyle | TextStyle[], input: string) => string -export function print_lines( - { report, context }: Report, - params: CliArguments, - { styleText, print_width }: { styleText: StyleTextFn; print_width?: number }, -) { +export type PrintLinesDependencies = { + styleText: StyleTextFn + print_width?: number +} + +export function print_lines({ report, context }: Report, params: CliArguments, { styleText, print_width }: PrintLinesDependencies) { let output: (string | undefined)[] = [] if (report.min_line_coverage.ok) { - output.push(`${styleText(['bold', 'green'], 'Success')}: total line coverage is ${percentage(report.min_line_coverage.actual)}%`) + output.push(`${styleText(['bold', 'green'], 'Success')}: total line coverage is ${percentage(report.min_line_coverage.actual)}`) } else { output.push( `${styleText(['bold', 'red'], 'Failed')}: line coverage is ${percentage( @@ -35,7 +39,7 @@ export function print_lines( if (report.min_file_line_coverage.expected !== undefined) { let { expected, actual, ok } = report.min_file_line_coverage if (ok) { - output.push(`${styleText(['bold', 'green'], 'Success')}: all files pass minimum line coverage of ${percentage(expected)}%`) + output.push(`${styleText(['bold', 'green'], 'Success')}: all files pass minimum line coverage of ${percentage(expected)}`) } else { let num_files_failed = context.coverage.coverage_per_stylesheet.filter((sheet) => sheet.line_coverage_ratio < expected!).length output.push( @@ -54,7 +58,6 @@ export function print_lines( const NUM_LEADING_LINES = 3 const NUM_TRAILING_LINES = NUM_LEADING_LINES print_width = print_width ?? 80 - let line_number = (num: number, covered: boolean = true) => `${num.toString().padStart(5, ' ')} ${covered ? '│' : '━'} ` let min_file_line_coverage = report.min_file_line_coverage.expected for (let sheet of context.coverage.coverage_per_stylesheet.sort((a, b) => a.line_coverage_ratio - b.line_coverage_ratio)) { @@ -82,7 +85,7 @@ export function print_lines( for (let chunk of sheet.chunks.filter((chunk) => !chunk.is_covered)) { // Render N leading lines - for (let x = Math.max(chunk.start_line - NUM_LEADING_LINES, 0); x < chunk.start_line; x++) { + for (let x = Math.max(chunk.start_line - NUM_LEADING_LINES, 1); x < chunk.start_line; x++) { output.push([styleText('dim', line_number(x)), styleText('dim', indent(lines[x - 1]))].join('')) } // Render the uncovered chunk @@ -90,7 +93,7 @@ export function print_lines( output.push([styleText('red', line_number(i, false)), indent(lines[i - 1])].join('')) } // Render N trailing lines - for (let y = chunk.end_line; y < Math.min(chunk.end_line + NUM_TRAILING_LINES, lines.length); y++) { + for (let y = chunk.end_line + 1; y < Math.min(chunk.end_line + NUM_TRAILING_LINES, lines.length); y++) { output.push([styleText('dim', line_number(y)), styleText('dim', indent(lines[y - 1]))].join('')) } // Show empty line between blocks @@ -104,5 +107,7 @@ export function print_lines( } export function print(report: Report, params: CliArguments): void { - print_lines(report, params, { styleText, print_width: process.stdout.columns }).map((line) => console.log(line)) + for (let line of print_lines(report, params, { styleText, print_width: process.stdout.columns })) { + console.log(line) + } } From a071280469f6b8de34b33709c3184a4eab3744a9 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Thu, 30 Oct 2025 20:17:56 +0100 Subject: [PATCH 3/3] singualr/plural fix --- src/cli/reporters/pretty.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cli/reporters/pretty.ts b/src/cli/reporters/pretty.ts index 2a28f12..85cac63 100644 --- a/src/cli/reporters/pretty.ts +++ b/src/cli/reporters/pretty.ts @@ -76,7 +76,9 @@ export function print_lines({ report, context }: Report, params: CliArguments, { if (min_file_line_coverage && min_file_line_coverage !== 0 && sheet.line_coverage_ratio < min_file_line_coverage) { let lines_to_cover = min_file_line_coverage * sheet.total_lines - sheet.covered_lines output.push( - `Tip: cover ${Math.ceil(lines_to_cover)} more lines to meet the file threshold of ${percentage(min_file_line_coverage)}`, + `Tip: cover ${Math.ceil(lines_to_cover)} more ${ + lines_to_cover === 1 ? 'line' : 'lines' + } to meet the file threshold of ${percentage(min_file_line_coverage)}`, ) } output.push(styleText('dim', '─'.repeat(print_width)))