diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 8a40dca..d6342dc 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -63,4 +63,4 @@ jobs: path: ./css-coverage - name: Analyze CSS Code Coverage - run: node scripts/analyze-css-coverage.ts --coverageDir=./css-coverage --minLineCoverage=.8 --minFileLineCoverage=.62 --showUncovered=violations + run: npm run css-coverage diff --git a/package-lock.json b/package-lock.json index c9040be..b17b38a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@melt-ui/svelte": "^0.86.6", "@oddbird/popover-polyfill": "^0.6.1", "@projectwallace/css-analyzer": "^7.6.0", - "@projectwallace/css-code-coverage": "^0.2.2", + "@projectwallace/css-code-coverage": "^0.4.1", "@projectwallace/css-code-quality": "^3.0.2", "@projectwallace/css-design-tokens": "^0.6.0", "@projectwallace/css-layer-tree": "^2.0.0", @@ -1364,17 +1364,20 @@ } }, "node_modules/@projectwallace/css-code-coverage": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@projectwallace/css-code-coverage/-/css-code-coverage-0.2.2.tgz", - "integrity": "sha512-NQS0OEEqka4xHM7sCEvEUKnZFfmf7AzVuFGpdi4XIqRbPbAkyZw+ctXvPHh2CWWiJfxiYoBM7BDUTYDYgIf6vA==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@projectwallace/css-code-coverage/-/css-code-coverage-0.4.1.tgz", + "integrity": "sha512-vnPx+mdu1njoGNfvV5hxKxTsqC84R5bicr+RAQoyPj/8JeAx8kNvNTGP9OFG2TE4q85yPA8ao0dfY+XPxY79bw==", "license": "EUPL-1.2", "dependencies": { "@projectwallace/format-css": "^2.1.1", "css-tree": "^3.1.0", "valibot": "^1.1.0" }, + "bin": { + "css-coverage": "dist/cli.js" + }, "engines": { - "node": ">=18.0.0" + "node": ">=20" } }, "node_modules/@projectwallace/css-code-quality": { diff --git a/package.json b/package.json index e193ac6..e8ac260 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "lint": "oxlint --config oxlintrc.json", "stylelint": "stylelint \"src/**/*.{css,svelte}\" --ignore-path .gitignore", - "build-css": "cat .svelte-kit/output/client/_app/immutable/assets/*.css > static/all.css" + "css-coverage": "css-coverage --coverage-dir=./css-coverage --min-line-coverage=.8 --min-file-line-coverage=.62 --show-uncovered=all" }, "dependencies": { "@bramus/specificity": "^2.4.2", @@ -23,7 +23,7 @@ "@melt-ui/svelte": "^0.86.6", "@oddbird/popover-polyfill": "^0.6.1", "@projectwallace/css-analyzer": "^7.6.0", - "@projectwallace/css-code-coverage": "^0.2.2", + "@projectwallace/css-code-coverage": "^0.4.1", "@projectwallace/css-code-quality": "^3.0.2", "@projectwallace/css-design-tokens": "^0.6.0", "@projectwallace/css-layer-tree": "^2.0.0", diff --git a/scripts/analyze-css-coverage.ts b/scripts/analyze-css-coverage.ts deleted file mode 100644 index 96e9b76..0000000 --- a/scripts/analyze-css-coverage.ts +++ /dev/null @@ -1,180 +0,0 @@ -// oxlint-disable max-depth - -import * as fs from 'node:fs' -import * as path from 'node:path' -import { DOMParser } from 'linkedom' -import { parseArgs, styleText } from 'node:util' -import * as v from 'valibot' -import { calculate_coverage, parse_coverage, type Coverage } from '@projectwallace/css-code-coverage' - -// 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_coverage(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.total_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.sort((a, b) => a.line_coverage_ratio - b.line_coverage_ratio)) { - 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 && minFileLineCoverage !== 0 && sheet.line_coverage_ratio < 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() - } - } - } - } -} diff --git a/src/lib/components/coverage/Coverage.spec.ts b/src/lib/components/coverage/Coverage.spec.ts index 04ad5bc..f94d7a0 100644 --- a/src/lib/components/coverage/Coverage.spec.ts +++ b/src/lib/components/coverage/Coverage.spec.ts @@ -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('74.95%') + await expect.soft(first_row.getByRole('cell').nth(3)).toHaveAccessibleName('72.28%') // Elements in correct state: await expect.soft(table.getByRole('row').nth(1)).toHaveAttribute('aria-selected', 'true') @@ -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('74.95%') + await expect.soft(first_row.getByRole('cell').nth(3)).toHaveAccessibleName('72.28%') }) test('sorting by total size', async ({ page }) => { @@ -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('74.95%') + await expect.soft(first_row.getByRole('cell').nth(3)).toHaveAccessibleName('72.28%') let css_content = await page.getByTestId('pre-css').textContent() expect.soft(css_content).toContain('.logo.svelte-1jiwtxp {')