Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,377 changes: 644 additions & 1,733 deletions package-lock.json

Large diffs are not rendered by default.

17 changes: 10 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,34 +21,37 @@
],
"license": "EUPL-1.2",
"engines": {
"node": ">=18.0.0"
"node": ">=22.18.0"
},
"type": "module",
"files": [
"dist"
],
"main": "dist/css-code-coverage.js",
"bin": {
"css-coverage": "dist/cli.js"
},
"main": "dist/index.js",
"exports": {
"types": "./dist/index.d.ts",
"default": "./dist/css-code-coverage.js"
"default": "./dist/index.js"
},
"types": "dist/index.d.ts",
"scripts": {
"test": "c8 --reporter=text playwright test",
"build": "vite build",
"build": "tsdown",
"check": "tsc --noEmit",
"lint": "oxlint --config .oxlintrc.json",
"lint-package": "publint"
},
"devDependencies": {
"@playwright/test": "^1.56.0",
"@types/node": "^24.8.1",
"c8": "^10.1.3",
"linkedom": "^0.18.12",
"oxlint": "^1.22.0",
"publint": "^0.3.14",
"typescript": "^5.9.3",
"vite": "^7.1.9",
"vite-plugin-dts": "^4.5.4"
"tsdown": "^0.15.8",
"typescript": "^5.9.3"
},
"dependencies": {
"@projectwallace/format-css": "^2.1.1",
Expand Down
89 changes: 89 additions & 0 deletions src/cli/arguments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { parseArgs } from 'node:util'
import * as v from 'valibot'

const show_uncovered_options = {
none: 'none',
all: 'all',
violations: 'violations',
} as const

const reporters = {
pretty: 'pretty',
tap: 'tap',
} as const

let CoverageDirSchema = v.pipe(v.string(), v.nonEmpty())
// Coerce args string to number and validate that it's between 0 and 1
let RatioPercentageSchema = v.pipe(v.string(), v.transform(Number), v.number(), v.minValue(0), v.maxValue(1))
let ShowUncoveredSchema = v.pipe(v.string(), v.enum(show_uncovered_options))
let ReporterSchema = v.pipe(v.string(), v.enum(reporters))

let CliArgumentsSchema = v.object({
'coverage-dir': CoverageDirSchema,
'min-line-coverage': RatioPercentageSchema,
'min-file-line-coverage': v.optional(RatioPercentageSchema),
'show-uncovered': v.optional(ShowUncoveredSchema, show_uncovered_options.violations),
reporter: v.optional(ReporterSchema, reporters.pretty),
})

export type CliArguments = {
'coverage-dir': string
'min-line-coverage': number
'min-file-line-coverage'?: number
'show-uncovered': keyof typeof show_uncovered_options
reporter: keyof typeof reporters
}

type ArgumentIssue = { path?: string; message: string }

export class InvalidArgumentsError extends Error {
readonly issues: ArgumentIssue[]

constructor(issues: ArgumentIssue[]) {
super()
this.issues = issues
}
}

export function validate_arguments(args: ReturnType<typeof parse_arguments>): CliArguments {
let parse_result = v.safeParse(CliArgumentsSchema, args)

if (!parse_result.success) {
throw new InvalidArgumentsError(
parse_result.issues.map((issue) => ({
path: issue.path?.map((path) => path.key).join('.'),
message: issue.message,
})),
)
}

return parse_result.output
}

export function parse_arguments(args: string[]) {
let { values } = parseArgs({
args,
allowPositionals: true,
options: {
'coverage-dir': {
type: 'string',
},
'min-line-coverage': {
type: 'string',
},
'min-file-line-coverage': {
type: 'string',
default: '0',
},
'show-uncovered': {
type: 'string',
default: 'violations',
},
reporter: {
type: 'string',
default: 'pretty',
},
},
})
return values
}
37 changes: 37 additions & 0 deletions src/cli/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env node

import { validate_arguments, parse_arguments, InvalidArgumentsError } from './arguments.js'
import { program, MissingDataError } from './program.js'
import { read } from './file-reader.js'
import { print as pretty } from './reporters/pretty.js'
import { print as tap } from './reporters/tap.js'

async function cli(cli_args: string[]) {
const args = parse_arguments(cli_args)
let params = validate_arguments(args)
let coverage_data = await read(params['coverage-dir'])
let report = program(
{
min_file_coverage: params['min-line-coverage'],
min_file_line_coverage: params['min-file-line-coverage'],
},
coverage_data,
)

if (report.report.ok === false) {
process.exitCode = 1
}

if (params.reporter === 'pretty') {
pretty(report, params)
} else if (params.reporter === 'tap') {
tap(report, params)
}
}

try {
await cli(process.argv.slice(2))
} catch (error) {
console.error(error)
process.exit(1)
}
19 changes: 19 additions & 0 deletions src/cli/file-reader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { readFile, stat, readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { parse_coverage, type Coverage } from '../lib/parse-coverage.js'

export async function read(coverage_dir: string): Promise<Coverage[]> {
let s = await stat(coverage_dir)
if (!s.isDirectory()) throw new TypeError('InvalidDirectory')

let file_paths = await readdir(coverage_dir)
let parsed_files: Coverage[] = []

for (let file_path of file_paths) {
if (!file_path.endsWith('.json')) continue
let contents = await readFile(join(coverage_dir, file_path), 'utf-8')
let parsed = parse_coverage(contents)
parsed_files.push(...parsed)
}
return parsed_files
}
89 changes: 89 additions & 0 deletions src/cli/program.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { calculate_coverage, type Coverage, type CoverageResult } from '../lib/index.js'
import { DOMParser } from 'linkedom'

function parse_html(html: string) {
return new DOMParser().parseFromString(html, 'text/html')
}

export class MissingDataError extends Error {
constructor() {
super('No data to analyze')
}
}

export type Report = {
context: {
coverage: CoverageResult
}
report: {
ok: boolean
min_line_coverage: {
expected: number
actual: number
ok: boolean
}
min_file_line_coverage: {
expected?: number
actual: number
ok: boolean
}
}
}

function validate_min_line_coverage(actual: number, expected: number) {
return {
ok: actual >= expected,
actual,
expected,
}
}

function validate_min_file_line_coverage(actual: number, expected: number | undefined) {
if (expected === undefined) {
return {
ok: true,
actual,
expected,
}
}

return {
ok: actual >= expected,
actual,
expected,
}
}

export function program(
{
min_file_coverage,
min_file_line_coverage,
}: {
min_file_coverage: number
min_file_line_coverage?: number
},
coverage_data: Coverage[],
) {
if (coverage_data.length === 0) {
throw new MissingDataError()
}
let coverage = calculate_coverage(coverage_data, parse_html)
let min_line_coverage_result = validate_min_line_coverage(coverage.line_coverage_ratio, min_file_coverage)
let min_file_line_coverage_result = validate_min_file_line_coverage(
Math.min(...coverage.coverage_per_stylesheet.map((sheet) => sheet.line_coverage_ratio)),
min_file_line_coverage,
)

let result: Report = {
context: {
coverage,
},
report: {
ok: min_line_coverage_result.ok && min_file_line_coverage_result.ok,
min_line_coverage: min_line_coverage_result,
min_file_line_coverage: min_file_line_coverage_result,
},
}

return result
}
97 changes: 97 additions & 0 deletions src/cli/reporters/pretty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// oxlint-disable max-depth
import { styleText } from 'node:util'
import type { Report } from '../program.js'
import type { CliArguments } from '../arguments.js'

// Re-indent because tabs in the terminal tend to be bigger than usual
function indent(line?: string): string {
return (line || '').replace(/^\t+/, (tabs) => ' '.repeat(tabs.length * 4))
}

export function print({ report, context }: Report, params: CliArguments) {
if (report.min_line_coverage.ok) {
console.log(`${styleText(['bold', 'green'], 'Success')}: total line coverage is ${(report.min_line_coverage.actual * 100).toFixed(2)}%`)
} else {
console.error(
`${styleText(['bold', 'red'], 'Failed')}: line coverage is ${(report.min_line_coverage.actual * 100).toFixed(
2,
)}% which is lower than the threshold of ${report.min_line_coverage.expected}`,
)
}

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}%`)
} 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)}%)`,
)
if (params['show-uncovered'] === 'none') {
console.log(` Hint: set --show-uncovered=violations to see which files didn't pass`)
}
}
}

// Show un-covered chunks
if (params['show-uncovered'] !== 'none') {
const NUM_LEADING_LINES = 3
const NUM_TRAILING_LINES = NUM_LEADING_LINES
let terminal_width = process.stdout.columns || 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)) {
if (
(sheet.line_coverage_ratio !== 1 && params['show-uncovered'] === 'all') ||
(min_file_line_coverage !== undefined &&
min_file_line_coverage !== 0 &&
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`)

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}%`)
}
console.log(styleText('dim', '─'.repeat(terminal_width)))

let lines = sheet.text.split('\n')
let line_coverage = sheet.line_coverage

for (let i = 0; i < lines.length; i++) {
if (line_coverage[i] === 1) continue

// Rewind cursor N lines to render N previous lines
for (let j = i - NUM_LEADING_LINES; j < i; j++) {
// Make sure that we don't try to start before line 0
if (j >= 0) {
console.log(styleText('dim', line_number(j)), styleText('dim', indent(lines[j])))
}
}

// Render uncovered lines while increasing cursor until reaching next covered block
while (line_coverage[i] === 0) {
console.log(styleText('red', line_number(i, false)), indent(lines[i]))
i++
}

// Forward cursor N lines to render N trailing lines
for (let end = i + NUM_TRAILING_LINES; i < end && i < lines.length; i++) {
console.log(styleText('dim', line_number(i)), styleText('dim', indent(lines[i])))
}

// Show empty line between blocks
console.log()
}
}
}
}
}
Loading