Skip to content

Commit 8e23482

Browse files
authored
Introduce CLI tool (#11)
closes #9
1 parent 9d6e522 commit 8e23482

30 files changed

+1145
-1835
lines changed

package-lock.json

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

package.json

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,34 +21,37 @@
2121
],
2222
"license": "EUPL-1.2",
2323
"engines": {
24-
"node": ">=18.0.0"
24+
"node": ">=22.18.0"
2525
},
2626
"type": "module",
2727
"files": [
2828
"dist"
2929
],
30-
"main": "dist/css-code-coverage.js",
30+
"bin": {
31+
"css-coverage": "dist/cli.js"
32+
},
33+
"main": "dist/index.js",
3134
"exports": {
3235
"types": "./dist/index.d.ts",
33-
"default": "./dist/css-code-coverage.js"
36+
"default": "./dist/index.js"
3437
},
3538
"types": "dist/index.d.ts",
3639
"scripts": {
3740
"test": "c8 --reporter=text playwright test",
38-
"build": "vite build",
41+
"build": "tsdown",
3942
"check": "tsc --noEmit",
4043
"lint": "oxlint --config .oxlintrc.json",
4144
"lint-package": "publint"
4245
},
4346
"devDependencies": {
4447
"@playwright/test": "^1.56.0",
48+
"@types/node": "^24.8.1",
4549
"c8": "^10.1.3",
4650
"linkedom": "^0.18.12",
4751
"oxlint": "^1.22.0",
4852
"publint": "^0.3.14",
49-
"typescript": "^5.9.3",
50-
"vite": "^7.1.9",
51-
"vite-plugin-dts": "^4.5.4"
53+
"tsdown": "^0.15.8",
54+
"typescript": "^5.9.3"
5255
},
5356
"dependencies": {
5457
"@projectwallace/format-css": "^2.1.1",

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.violations),
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: 'violations',
81+
},
82+
reporter: {
83+
type: 'string',
84+
default: 'pretty',
85+
},
86+
},
87+
})
88+
return values
89+
}

src/cli/cli.ts

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

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

src/cli/reporters/pretty.ts

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

0 commit comments

Comments
 (0)