Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
449439a
PoC: verify css-coverage in CI
bartveneman Oct 6, 2025
34d821e
fix exit codes
bartveneman Oct 6, 2025
7249bff
tweaks
bartveneman Oct 6, 2025
0d63bb5
more ideas
bartveneman Oct 6, 2025
8578146
reaching levels of sillyness: render uncovered lines in CI
bartveneman Oct 7, 2025
e76bb5d
fix %
bartveneman Oct 7, 2025
96580d7
force colors
bartveneman Oct 7, 2025
968a6fc
fixes
bartveneman Oct 7, 2025
9ad28ec
minFileLineCoverage
bartveneman Oct 7, 2025
2ad0725
do not force color, it doesnt work
bartveneman Oct 7, 2025
a473b0d
replace picocolors with node:utils styleText
bartveneman Oct 7, 2025
fed90e5
fix imports
bartveneman Oct 7, 2025
24674db
make --showUncovered an enum to show violations
bartveneman Oct 7, 2025
c5b4382
fix ci
bartveneman Oct 7, 2025
023b79f
fixes and code reuse
bartveneman Oct 7, 2025
690ce37
typo
bartveneman Oct 7, 2025
44621a7
more precentages instead of fractions
bartveneman Oct 7, 2025
a537124
bring back force color
bartveneman Oct 7, 2025
e1b7f57
do not rely on color alone to indicate uncovered lines
bartveneman Oct 7, 2025
4e3c858
better NO_COLOR support
bartveneman Oct 8, 2025
3ba44c6
continue process if overall coverage failed
bartveneman Oct 8, 2025
c0e13f4
fix threshold 😂
bartveneman Oct 8, 2025
3b51d54
some refactoring for better calculations of trailing non-covered lines
bartveneman Oct 8, 2025
0fd16f0
move some totals calculations out of sheet loop
bartveneman Oct 8, 2025
1267744
merge types
bartveneman Oct 8, 2025
a801b43
perf
bartveneman Oct 8, 2025
98d0719
perf: avoid array lookups, use Map instead
bartveneman Oct 8, 2025
dbd609d
write some todo [skip ci]
bartveneman Oct 8, 2025
3d6ac96
fix tests [skip ci]
bartveneman Oct 8, 2025
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
28 changes: 27 additions & 1 deletion .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,30 @@ jobs:
with:
name: css-coverage
path: css-coverage/*.json
retention-days: 30
retention-days: 30

css-coverage:
name: Verify CSS Code Coverage
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v5
with:
cache: 'npm'
node-version: '>=22.18.0'

- name: Install dependencies
run: npm install --no-fund --no-audit --ignore-scripts

- name: Download artifact
uses: actions/download-artifact@v4
with:
name: css-coverage
path: ./css-coverage

- name: Analyze CSS Code Coverage
run: node scripts/analyze-css-coverage.ts --coverageDir=./css-coverage --minLineCoverage=.8 --minFileLineCoverage=.62 --showUncovered=violations
180 changes: 180 additions & 0 deletions scripts/analyze-css-coverage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import * as fs from 'node:fs'
import * as path from 'node:path'
import { calculate_coverage } from '../src/lib/components/coverage/calculate-coverage.ts'
import { DOMParser } from 'linkedom'
import type { Coverage } from '../src/lib/components/coverage/types.ts'
import { parseArgs, styleText } from 'node:util'
import * as v from 'valibot'
import { parse_json } from '../src/lib/components/coverage/parse-coverage.ts'

// TODO: architecture
// Create CLI that
// - creates cli config
// - validates cli input against schema
// Create Program() that
// - takes input (parsed and validated args)
// - runs and returns calculations
// - compares it with arguments passed
// - returns a Result (passed: boolean, violations, etc.)
// Create a printer that accepts a formatter
// - formats can be tap, pretty (default), minimal etc.

let args = process.argv.slice(2)

let { values } = parseArgs({
args,
allowPositionals: true,
options: {
// TODO: allow glob?
// TODO: convert to coveragedir, min-line-coverage, etc.
coverageDir: {
type: 'string'
},
minLineCoverage: {
type: 'string'
},
minFileLineCoverage: {
type: 'string',
default: '0'
},
showUncovered: {
type: 'string',
default: 'none'
}
}
})

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

let valuesSchema = v.object({
coverageDir: v.pipe(v.string(), v.nonEmpty()),
// Coerce args string to number and validate that it's between 0 and 1
minLineCoverage: v.pipe(v.string(), v.transform(Number), v.number(), v.minValue(0), v.maxValue(1)),
// Coerce args string to number and validate that it's between 0 and 1
minFileLineCoverage: v.optional(v.pipe(v.string(), v.transform(Number), v.number(), v.minValue(0), v.maxValue(1))),
showUncovered: v.optional(v.pipe(v.string(), v.enum(showUncoveredOptions)), 'none')
})

let parse_result = v.safeParse(valuesSchema, values)
if (!parse_result.success) {
console.error(styleText(['red', 'bold'], 'Failure'), ': invalid arguments')
for (let issue of parse_result.issues) {
console.error(`- ${issue.path?.map((p) => p.key).join('.')}: ${issue.message}`)
}
process.exit(1)
}
let { coverageDir, minLineCoverage, minFileLineCoverage, showUncovered } = parse_result.output

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

let files = fs.readdirSync(coverageDir)

if (files.length === 0) {
console.error(styleText(['red', 'bold'], 'Failure'), `: no JSON files found in ${coverageDir}`)
process.exit(1)
}

console.log(`Checking ${files.length} files...`)

let data = files.reduce((all_files, file_path) => {
if (!file_path.endsWith('.json')) return all_files
try {
let content = fs.readFileSync(path.resolve(coverageDir, file_path), 'utf-8')
let parsed = parse_json(content)
all_files.push(...parsed)
return all_files
} catch {
return all_files
}
}, [] as Coverage[])

let result = calculate_coverage(data, parse_html)

console.log(`Analyzed ${result.files_found} coverage entries`)

// Verify minLineCoverage
if (result.line_coverage_ratio >= minLineCoverage) {
console.log(
`${styleText(['bold', 'green'], 'Success')}: total line coverage is ${(result.line_coverage_ratio * 100).toFixed(2)}%`
)
} else {
console.error(
`${styleText(['bold', 'red'], 'Failed')}: line coverage is ${(result.line_coverage_ratio * 100).toFixed(2)}% which is lower than the threshold of ${minLineCoverage}`
)
process.exitCode = 1
}

// Verify minFileLineCoverage
if (minFileLineCoverage !== undefined && minFileLineCoverage !== 0) {
if (result.coverage_per_stylesheet.some((sheet) => sheet.line_coverage_ratio < minFileLineCoverage)) {
console.error(
`${styleText(['bold', 'red'], 'Failed')}: Not all files meet the minimum line coverage of ${minFileLineCoverage * 100}%:`
)
process.exitCode = 1
} else {
console.log(
`${styleText(['bold', 'green'], 'Success')}: all files pass minFileLineCoverage of ${minFileLineCoverage * 100}%`
)
}
}

if (showUncovered !== '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 ? '│' : '☓'} `

for (let sheet of result.coverage_per_stylesheet) {
if (
(sheet.line_coverage_ratio !== 1 && showUncovered === 'all') ||
(minFileLineCoverage !== undefined &&
minFileLineCoverage !== 0 &&
sheet.line_coverage_ratio < minFileLineCoverage &&
showUncovered === '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 (minFileLineCoverage) {
let lines_to_cover = minFileLineCoverage * sheet.total_lines - sheet.covered_lines
console.log(
`💡 Cover ${Math.ceil(lines_to_cover)} more lines to meet the file threshold of ${minFileLineCoverage * 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] === 0) {
// Rewind cursor N lines to render N previous lines
for (let j = i - NUM_LEADING_LINES; j < i; j++) {
console.log(styleText('dim', line_number(j)), styleText('dim', 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)), 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', lines[i]))
}
// Show empty line between blocks
console.log()
}
}
}
}
}
10 changes: 5 additions & 5 deletions src/lib/components/coverage/Coverage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ test.describe('loading example file', () => {
.toHaveAccessibleName('https://www.projectwallace.com/_app/immutable/assets/0.BBE7cspC.css')
await expect.soft(first_row.getByRole('cell').nth(1)).toHaveAccessibleName('38 kB')
await expect.soft(first_row.getByRole('cell').nth(2)).toHaveAccessibleName('2,619')
await expect.soft(first_row.getByRole('cell').nth(3)).toHaveAccessibleName('72.16%')
await expect.soft(first_row.getByRole('cell').nth(3)).toHaveAccessibleName('74.95%')

// Elements in correct state:
await expect.soft(table.getByRole('row').nth(1)).toHaveAttribute('aria-selected', 'true')
Expand All @@ -73,8 +73,8 @@ test.describe('loading example file', () => {
await expect.soft(ranges.nth(3)).toHaveClass(/uncovered/)

await expect.soft(ranges.nth(0)).toHaveText('1 2 3 4 5 6 7 8 9 10 11 12 13 14')
await expect.soft(ranges.nth(1)).toHaveText('15 16 17 18')
await expect.soft(ranges.nth(3)).toHaveText('51 52 53 54 55 56')
await expect.soft(ranges.nth(1)).toHaveText('15 16 17')
await expect.soft(ranges.nth(3)).toHaveText('51 52 53 54 55')
})

test('selecting a row', async ({ page }) => {
Expand Down Expand Up @@ -109,7 +109,7 @@ test.describe('loading example file', () => {
.soft(first_row.getByRole('cell').nth(0))
.toHaveAccessibleName('https://www.projectwallace.com/_app/immutable/assets/0.BBE7cspC.css')
await expect.soft(first_row.getByRole('cell').nth(1)).toHaveAccessibleName('38 kB')
await expect.soft(first_row.getByRole('cell').nth(3)).toHaveAccessibleName('72.16%')
await expect.soft(first_row.getByRole('cell').nth(3)).toHaveAccessibleName('74.95%')
})

test('sorting by total size', async ({ page }) => {
Expand All @@ -123,7 +123,7 @@ test.describe('loading example file', () => {
.soft(first_row.getByRole('cell').nth(0))
.toHaveAccessibleName('https://www.projectwallace.com/_app/immutable/assets/0.BBE7cspC.css')
await expect.soft(first_row.getByRole('cell').nth(1)).toHaveAccessibleName('38 kB')
await expect.soft(first_row.getByRole('cell').nth(3)).toHaveAccessibleName('72.16%')
await expect.soft(first_row.getByRole('cell').nth(3)).toHaveAccessibleName('74.95%')
let css_content = await page.getByTestId('pre-css').textContent()
expect.soft(css_content).toContain('.logo.svelte-1jiwtxp {')

Expand Down
16 changes: 9 additions & 7 deletions src/lib/components/coverage/Coverage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@
return sort_direction === 'asc' ? a.total_bytes - b.total_bytes : b.total_bytes - a.total_bytes
}
if (sort_by === 'coverage') {
return sort_direction === 'asc' ? a.coverage_ratio - b.coverage_ratio : b.coverage_ratio - a.coverage_ratio
return sort_direction === 'asc'
? a.line_coverage_ratio - b.line_coverage_ratio
: b.line_coverage_ratio - a.line_coverage_ratio
}
if (sort_by === 'name') {
return sort_direction === 'asc' ? string_sort(a.url, b.url) : string_sort(b.url, a.url)
Expand Down Expand Up @@ -110,12 +112,12 @@
<div class="coverage-summary">
<div>
<dt>Coverage</dt>
<dd>{format_percentage(calculated.coverage_ratio)}</dd>
<dd>{format_percentage(calculated.line_coverage)} of lines</dd>
<dd>{format_percentage(calculated.byte_coverage_ratio)}</dd>
<dd>{format_percentage(calculated.line_coverage_ratio)} of lines</dd>
</div>
<div>
<dt>Total</dt>
<dd>{format_filesize(calculated.used_bytes + calculated.unused_bytes)}</dd>
<dd>{format_filesize(calculated.total_bytes)}</dd>
<dd>{format_number(calculated.total_lines)} lines</dd>
</div>
<div>
Expand Down Expand Up @@ -152,17 +154,17 @@
<tbody use:root={{ onchange }} style:--meter-height="0.5rem">
{#each sorted_items as item_index, index}
{@const stylesheet = calculated.coverage_per_stylesheet[item_index]}
{@const { url, total_bytes, total_lines, coverage_ratio, covered_lines } = stylesheet}
{@const { url, total_bytes, total_lines, line_coverage_ratio, covered_lines } = stylesheet}
<tr use:item={{ value: index.toString() }} aria-selected={selected_index === index ? 'true' : 'false'}>
<td class="url">
{url}
</td>
<td class="numeric">{format_filesize(total_bytes)}</td>
<td class="numeric">{format_number(total_lines)}</td>
<td class="numeric">{format_percentage(coverage_ratio)}</td>
<td class="numeric">{format_percentage(line_coverage_ratio)}</td>
<td>
<div style:width={(stylesheet.total_lines / max_lines) * 100 + '%'}>
<Meter max={1} value={coverage_ratio} />
<Meter max={1} value={line_coverage_ratio} />
</div>
</td>
</tr>
Expand Down
26 changes: 13 additions & 13 deletions src/lib/components/coverage/calculate-coverage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,12 @@ test.describe('calculates coverage', () => {
let result = calculate_coverage(coverage, html_parser)
expect.soft(result.files_found).toBe(1)
expect.soft(result.total_bytes).toBe(80)
expect.soft(result.used_bytes).toBe(37)
expect.soft(result.unused_bytes).toBe(41)
expect.soft(result.used_bytes).toBe(42)
expect.soft(result.unused_bytes).toBe(38)
expect.soft(result.total_lines).toBe(11)
expect.soft(result.covered_lines).toBe(7)
expect.soft(result.uncovered_lines).toBe(11 - 7)
expect.soft(result.line_coverage).toBe(7 / 11)
expect.soft(result.line_coverage_ratio).toBe(7 / 11)
})

test('calculates stats per stylesheet', () => {
Expand All @@ -189,7 +189,7 @@ test.describe('calculates coverage', () => {
expect.soft(sheet.total_lines).toBe(11)
expect.soft(sheet.covered_lines).toBe(7)
expect.soft(sheet.uncovered_lines).toBe(4)
expect.soft(sheet.coverage_ratio).toBe(7 / 11)
expect.soft(sheet.line_coverage_ratio).toBe(7 / 11)
expect.soft(sheet.line_coverage).toEqual(new Uint8Array([1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1]))
})
})
Expand Down Expand Up @@ -226,12 +226,12 @@ test.describe('calculates coverage', () => {
let result = calculate_coverage(coverage, html_parser)
expect.soft(result.files_found).toBe(1)
expect.soft(result.total_bytes).toBe(174)
expect.soft(result.used_bytes).toBe(75)
expect.soft(result.unused_bytes).toBe(93)
expect.soft(result.used_bytes).toBe(91)
expect.soft(result.unused_bytes).toBe(83)
expect.soft(result.total_lines).toBe(21)
expect.soft(result.covered_lines).toBe(12)
expect.soft(result.uncovered_lines).toBe(21 - 12)
expect.soft(result.line_coverage).toBe(12 / 21)
expect.soft(result.line_coverage_ratio).toBe(12 / 21)
})

test('calculates stats per stylesheet', () => {
Expand All @@ -247,7 +247,7 @@ test.describe('calculates coverage', () => {
expect.soft(sheet.total_lines).toBe(21)
expect.soft(sheet.covered_lines).toBe(12)
expect.soft(sheet.uncovered_lines).toBe(21 - 12)
expect.soft(sheet.coverage_ratio).toBe(12 / 21)
expect.soft(sheet.line_coverage_ratio).toBe(12 / 21)
expect
.soft(sheet.line_coverage)
.toEqual(new Uint8Array([1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 1, 1, 1]))
Expand Down Expand Up @@ -284,10 +284,10 @@ test.describe('calculates coverage', () => {

test('counts totals', () => {
let result = calculate_coverage(coverage, html_parser)
expect.soft(result.covered_lines).toBe(8)
expect.soft(result.uncovered_lines).toBe(6)
expect.soft(result.covered_lines).toBe(9)
expect.soft(result.uncovered_lines).toBe(5)
expect.soft(result.total_lines).toBe(14)
expect.soft(result.line_coverage).toBe(8 / 14)
expect.soft(result.line_coverage_ratio).toBe(9 / 14)
})

test('extracts and formats css', () => {
Expand Down Expand Up @@ -318,11 +318,11 @@ test.describe('calculates coverage', () => {
// h1 {}
1, 1, 1, 1,
// comment + p {}
0, 0, 0, 0, 0,
0, 0, 0, 0,
// @media
1,
// h1 {
0,
1, 0,
// color: green; }
1, 1, 1
])
Expand Down
Loading