Skip to content

Commit 216de72

Browse files
committed
Add CLI app
1 parent 9d6e522 commit 216de72

File tree

8 files changed

+384
-0
lines changed

8 files changed

+384
-0
lines changed

package-lock.json

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
},
4343
"devDependencies": {
4444
"@playwright/test": "^1.56.0",
45+
"@types/node": "^24.8.1",
4546
"c8": "^10.1.3",
4647
"linkedom": "^0.18.12",
4748
"oxlint": "^1.22.0",

src/cli/arguments.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { parseArgs } from 'node:util'
2+
import * as v from 'valibot'
3+
4+
const show_uncovered_options = {
5+
none: 'none',
6+
all: 'all',
7+
violations: 'violations',
8+
} as const
9+
10+
const reporters = {
11+
pretty: 'pretty',
12+
tap: 'tap',
13+
} as const
14+
15+
let CoverageDirSchema = v.pipe(v.string(), v.nonEmpty())
16+
// Coerce args string to number and validate that it's between 0 and 1
17+
let RatioPercentageSchema = v.pipe(v.string(), v.transform(Number), v.number(), v.minValue(0), v.maxValue(1))
18+
let ShowUncoveredSchema = v.pipe(v.string(), v.enum(show_uncovered_options))
19+
let ReporterSchema = v.pipe(v.string(), v.enum(reporters))
20+
21+
let CliArgumentsSchema = v.object({
22+
'coverage-dir': CoverageDirSchema,
23+
'min-line-coverage': RatioPercentageSchema,
24+
'min-file-line-coverage': v.optional(RatioPercentageSchema),
25+
'show-uncovered': v.optional(ShowUncoveredSchema, show_uncovered_options.none),
26+
reporter: v.optional(ReporterSchema, reporters.pretty),
27+
})
28+
29+
export type CliArguments = {
30+
'coverage-dir': string
31+
'min-line-coverage': number
32+
'min-file-line-coverage'?: number
33+
'show-uncovered': keyof typeof show_uncovered_options
34+
reporter: keyof typeof reporters
35+
}
36+
37+
type ArgumentIssue = { path?: string; message: string }
38+
39+
export class InvalidArgumentsError extends Error {
40+
readonly issues: ArgumentIssue[]
41+
42+
constructor(issues: ArgumentIssue[]) {
43+
super()
44+
this.issues = issues
45+
}
46+
}
47+
48+
export function validate_arguments(args: ReturnType<typeof parse_arguments>): CliArguments {
49+
let parse_result = v.safeParse(CliArgumentsSchema, args)
50+
51+
if (!parse_result.success) {
52+
throw new InvalidArgumentsError(
53+
parse_result.issues.map((issue) => ({
54+
path: issue.path?.map((path) => path.key).join('.'),
55+
message: issue.message,
56+
})),
57+
)
58+
}
59+
60+
return parse_result.output
61+
}
62+
63+
export function parse_arguments(args: string[]) {
64+
let { values } = parseArgs({
65+
args,
66+
allowPositionals: true,
67+
options: {
68+
'coverage-dir': {
69+
type: 'string',
70+
},
71+
'min-line-coverage': {
72+
type: 'string',
73+
},
74+
'min-file-line-coverage': {
75+
type: 'string',
76+
default: '0',
77+
},
78+
'show-uncovered': {
79+
type: 'string',
80+
default: 'none',
81+
},
82+
reporter: {
83+
type: 'string',
84+
default: 'pretty',
85+
},
86+
},
87+
})
88+
return values
89+
}

src/cli/cli.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { validate_arguments, parse_arguments, InvalidArgumentsError } from './arguments.ts'
2+
import { program, MissingDataError } from './program.ts'
3+
import { read } from './file-reader.ts'
4+
import { print as pretty } from './reporters/pretty.ts'
5+
import { print as tap } from './reporters/tap.ts'
6+
7+
async function cli(cli_args: string[]) {
8+
const args = parse_arguments(cli_args)
9+
let params = validate_arguments(args)
10+
let coverage_data = await read(params['coverage-dir'])
11+
let report = program(
12+
{
13+
min_file_coverage: params['min-line-coverage'],
14+
min_file_line_coverage: params['min-file-line-coverage'],
15+
},
16+
coverage_data,
17+
)
18+
19+
if (report.report.ok === false) {
20+
process.exitCode = 1
21+
}
22+
23+
if (params.reporter === 'pretty') {
24+
pretty(report, params)
25+
} else if (params.reporter === 'tap') {
26+
tap(report, params)
27+
}
28+
}
29+
30+
try {
31+
await cli(process.argv.slice(2))
32+
} catch (error) {
33+
console.error(error)
34+
process.exit(1)
35+
}

src/cli/file-reader.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { readFile, stat, readdir } from 'node:fs/promises'
2+
import { join } from 'node:path'
3+
import { parse_coverage, type Coverage } from '../parse-coverage.ts'
4+
5+
export async function read(coverage_dir: string): Promise<Coverage[]> {
6+
let s = await stat(coverage_dir)
7+
if (!s.isDirectory()) throw new TypeError('InvalidDirectory')
8+
9+
let file_paths = await readdir(coverage_dir)
10+
let parsed_files: Coverage[] = []
11+
12+
for (let file_path of file_paths) {
13+
if (!file_path.endsWith('.json')) continue
14+
let contents = await readFile(join(coverage_dir, file_path), 'utf-8')
15+
let parsed = parse_coverage(contents)
16+
parsed_files.push(...parsed)
17+
}
18+
return parsed_files
19+
}

src/cli/program.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { CliArguments } from './arguments'
2+
import { calculate_coverage, type Coverage, type CoverageResult } from '../index.ts'
3+
import { read } from './file-reader.ts'
4+
import { DOMParser } from 'linkedom'
5+
6+
function parse_html(html: string) {
7+
return new DOMParser().parseFromString(html, 'text/html')
8+
}
9+
10+
export class MissingDataError extends Error {
11+
constructor() {
12+
super('Missing data to analyze')
13+
}
14+
}
15+
16+
export type Report = {
17+
context: {
18+
coverage: CoverageResult
19+
}
20+
report: {
21+
ok: boolean
22+
min_line_coverage: {
23+
expected: number
24+
actual: number
25+
ok: boolean
26+
}
27+
min_file_line_coverage: {
28+
expected?: number
29+
actual: number
30+
ok: boolean
31+
}
32+
}
33+
}
34+
35+
function validate_min_line_coverage(actual: number, expected: number) {
36+
return {
37+
ok: actual >= expected,
38+
actual,
39+
expected,
40+
}
41+
}
42+
43+
function validate_min_file_line_coverage(actual: number, expected: number | undefined) {
44+
if (expected === undefined) {
45+
return {
46+
ok: true,
47+
actual,
48+
expected,
49+
}
50+
}
51+
52+
return {
53+
ok: actual >= expected,
54+
actual,
55+
expected,
56+
}
57+
}
58+
59+
export function program(
60+
{
61+
min_file_coverage,
62+
min_file_line_coverage,
63+
}: {
64+
min_file_coverage: number
65+
min_file_line_coverage?: number
66+
},
67+
coverage_data: Coverage[],
68+
) {
69+
if (coverage_data.length === 0) {
70+
throw new MissingDataError()
71+
}
72+
let coverage = calculate_coverage(coverage_data, parse_html)
73+
let min_line_coverage_result = validate_min_line_coverage(coverage.line_coverage_ratio, min_file_coverage)
74+
let min_file_line_coverage_result = validate_min_file_line_coverage(
75+
Math.min(...coverage.coverage_per_stylesheet.map((sheet) => sheet.line_coverage_ratio)),
76+
min_file_line_coverage,
77+
)
78+
79+
let result: Report = {
80+
context: {
81+
coverage,
82+
},
83+
report: {
84+
ok: min_line_coverage_result.ok && min_file_line_coverage_result.ok,
85+
min_line_coverage: min_line_coverage_result,
86+
min_file_line_coverage: min_file_line_coverage_result,
87+
},
88+
}
89+
90+
return result
91+
}

src/cli/reporters/pretty.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { styleText } from 'node:util'
2+
import type { Report } from '../program'
3+
import type { CliArguments } from '../arguments'
4+
5+
export function print({ report, context }: Report, params: CliArguments) {
6+
if (report.min_line_coverage.ok) {
7+
console.log(`${styleText(['bold', 'green'], 'Success')}: total line coverage is ${(report.min_line_coverage.actual * 100).toFixed(2)}%`)
8+
} else {
9+
console.error(
10+
`${styleText(['bold', 'red'], 'Failed')}: line coverage is ${(report.min_line_coverage.actual * 100).toFixed(
11+
2,
12+
)}% which is lower than the threshold of ${report.min_line_coverage.expected}`,
13+
)
14+
}
15+
16+
if (report.min_file_line_coverage.expected !== undefined) {
17+
let { expected, ok } = report.min_file_line_coverage
18+
if (ok) {
19+
console.log(`${styleText(['bold', 'green'], 'Success')}: all files pass minimum line coverage of ${expected * 100}%`)
20+
} else {
21+
let num_files_failed = context.coverage.coverage_per_stylesheet.filter((sheet) => sheet.line_coverage_ratio < expected!).length
22+
console.error(
23+
`${styleText(['bold', 'red'], 'Failed')}: ${num_files_failed} files do not meet the minimum line coverage of ${expected * 100}%`,
24+
)
25+
if (params['show-uncovered'] === 'none') {
26+
console.log(` Hint: set --show-uncovered=violations to see which files didn't pass`)
27+
}
28+
}
29+
}
30+
31+
// Show un-covered chunks
32+
if (params['show-uncovered'] !== 'none') {
33+
const NUM_LEADING_LINES = 3
34+
const NUM_TRAILING_LINES = NUM_LEADING_LINES
35+
let terminal_width = process.stdout.columns || 80
36+
let line_number = (num: number, covered: boolean = true) => `${num.toString().padStart(5, ' ')} ${covered ? '│' : '━'} `
37+
let min_file_line_coverage = report.min_file_line_coverage.expected
38+
39+
for (let sheet of context.coverage.coverage_per_stylesheet.sort((a, b) => a.line_coverage_ratio - b.line_coverage_ratio)) {
40+
if (
41+
(sheet.line_coverage_ratio !== 1 && params['show-uncovered'] === 'all') ||
42+
(min_file_line_coverage !== undefined &&
43+
min_file_line_coverage !== 0 &&
44+
sheet.line_coverage_ratio < min_file_line_coverage &&
45+
params['show-uncovered'] === 'violations')
46+
) {
47+
console.log()
48+
console.log(styleText('dim', '─'.repeat(terminal_width)))
49+
console.log(sheet.url)
50+
console.log(`Coverage: ${(sheet.line_coverage_ratio * 100).toFixed(2)}%, ${sheet.covered_lines}/${sheet.total_lines} lines covered`)
51+
if (min_file_line_coverage && min_file_line_coverage !== 0 && sheet.line_coverage_ratio < min_file_line_coverage) {
52+
let lines_to_cover = min_file_line_coverage * sheet.total_lines - sheet.covered_lines
53+
console.log(`💡 Cover ${Math.ceil(lines_to_cover)} more lines to meet the file threshold of ${min_file_line_coverage * 100}%`)
54+
}
55+
console.log(styleText('dim', '─'.repeat(terminal_width)))
56+
57+
let lines = sheet.text.split('\n')
58+
let line_coverage = sheet.line_coverage
59+
60+
for (let i = 0; i < lines.length; i++) {
61+
if (line_coverage[i] === 0) {
62+
// Rewind cursor N lines to render N previous lines
63+
for (let j = i - NUM_LEADING_LINES; j < i; j++) {
64+
console.log(styleText('dim', line_number(j)), styleText('dim', lines[j]!))
65+
}
66+
// Render uncovered lines while increasing cursor until reaching next covered block
67+
while (line_coverage[i] === 0) {
68+
console.log(styleText('red', line_number(i, false)), lines[i])
69+
i++
70+
}
71+
// Forward cursor N lines to render N trailing lines
72+
for (let end = i + NUM_TRAILING_LINES; i < end && i < lines.length; i++) {
73+
console.log(styleText('dim', line_number(i)), styleText('dim', lines[i]!))
74+
}
75+
// Show empty line between blocks
76+
console.log()
77+
}
78+
}
79+
}
80+
}
81+
}
82+
}

0 commit comments

Comments
 (0)