From 3cbd214d0d517173b2da47bea179274df7346c25 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Wed, 15 Oct 2025 21:04:53 +0200 Subject: [PATCH 1/5] move to calculate_stylesheet_coverage --- src/index.ts | 216 ++++++++++++++++++++++++++------------------------- 1 file changed, 111 insertions(+), 105 deletions(-) diff --git a/src/index.ts b/src/index.ts index ed1a932..3a7cb90 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,116 @@ function ratio(fraction: number, total: number) { return fraction / total } +function calculate_stylesheet_coverage(text: string, ranges: Range[], url: string): StylesheetCoverage { + function is_line_covered(line: string, start_offset: number) { + let end = start_offset + line.length + let next_offset = end + 1 // account for newline character + let is_empty = /^\s*$/.test(line) + let is_closing_brace = line.endsWith('}') + + if (!is_empty && !is_closing_brace) { + for (let range of ranges) { + if (range.start > end || range.end < start_offset) { + continue + } + if (range.start <= start_offset && range.end >= end) { + return true + } else if (line.startsWith('@') && range.start > start_offset && range.start < next_offset) { + return true + } + } + } + return false + } + + let lines = text.split('\n') + let total_file_lines = lines.length + let line_coverage = new Uint8Array(total_file_lines) + let file_lines_covered = 0 + let file_total_bytes = text.length + let file_bytes_covered = 0 + let offset = 0 + + for (let index = 0; index < lines.length; index++) { + let line = lines[index] + if (line === undefined) continue + + let start = offset + let end = offset + line.length + let next_offset = end + 1 // +1 for the newline character + let is_empty = /^\s*$/.test(line) + let is_closing_brace = line.endsWith('}') + let is_in_range = is_line_covered(line, start) + let is_covered = false + + let prev_is_covered = index > 0 && line_coverage[index - 1] === 1 + + if (is_in_range && !is_closing_brace && !is_empty) { + is_covered = true + } else if ((is_empty || is_closing_brace) && prev_is_covered) { + is_covered = true + } else if (is_empty && !prev_is_covered && is_line_covered(lines[index + 1]!, next_offset)) { + // If the next line is covered, mark this empty line as covered + is_covered = true + } + + line_coverage[index] = is_covered ? 1 : 0 + + if (is_covered) { + file_lines_covered++ + file_bytes_covered += line.length + 1 + } + + offset = next_offset + } + + // Create "chunks" of covered/uncovered lines for easier rendering later on + let chunks = [ + { + start_line: 1, + is_covered: line_coverage[0] === 1, + end_line: 1, + total_lines: 1, + }, + ] + + for (let index = 1; index < line_coverage.length; index++) { + let is_covered = line_coverage[index] + if (is_covered !== line_coverage[index - 1]) { + let last_chunk = chunks.at(-1)! + last_chunk.end_line = index + last_chunk.total_lines = index - last_chunk.start_line + 1 + + chunks.push({ + start_line: index + 1, + is_covered: is_covered === 1, + end_line: index, + total_lines: 0, + }) + } + } + + let last_chunk = chunks.at(-1)! + last_chunk.total_lines = line_coverage.length + 1 - last_chunk.start_line + last_chunk.end_line = line_coverage.length + + return { + url, + text, + ranges, + unused_bytes: file_total_bytes - file_bytes_covered, + used_bytes: file_bytes_covered, + total_bytes: file_total_bytes, + line_coverage_ratio: ratio(file_lines_covered, total_file_lines), + byte_coverage_ratio: ratio(file_bytes_covered, file_total_bytes), + line_coverage, + total_lines: total_file_lines, + covered_lines: file_lines_covered, + uncovered_lines: total_file_lines - file_lines_covered, + chunks, + } +} + /** * @description * CSS Code Coverage calculation @@ -63,111 +173,7 @@ export function calculate_coverage(coverage: Coverage[], parse_html?: Parser): C // Calculate coverage for each individual stylesheet we found let coverage_per_stylesheet = Array.from(deduplicated).map(([text, { url, ranges }]) => { - function is_line_covered(line: string, start_offset: number) { - let end = start_offset + line.length - let next_offset = end + 1 // account for newline character - let is_empty = /^\s*$/.test(line) - let is_closing_brace = line.endsWith('}') - - if (!is_empty && !is_closing_brace) { - for (let range of ranges) { - if (range.start > end || range.end < start_offset) { - continue - } - if (range.start <= start_offset && range.end >= end) { - return true - } else if (line.startsWith('@') && range.start > start_offset && range.start < next_offset) { - return true - } - } - } - return false - } - - let lines = text.split('\n') - let total_file_lines = lines.length - let line_coverage = new Uint8Array(total_file_lines) - let file_lines_covered = 0 - let file_total_bytes = text.length - let file_bytes_covered = 0 - let offset = 0 - - for (let index = 0; index < lines.length; index++) { - let line = lines[index]! - let start = offset - let end = offset + line.length - let next_offset = end + 1 // +1 for the newline character - let is_empty = /^\s*$/.test(line) - let is_closing_brace = line.endsWith('}') - let is_in_range = is_line_covered(line, start) - let is_covered = false - - let prev_is_covered = index > 0 && line_coverage[index - 1] === 1 - - if (is_in_range && !is_closing_brace && !is_empty) { - is_covered = true - } else if ((is_empty || is_closing_brace) && prev_is_covered) { - is_covered = true - } else if (is_empty && !prev_is_covered && is_line_covered(lines[index + 1]!, next_offset)) { - // If the next line is covered, mark this empty line as covered - is_covered = true - } - - line_coverage[index] = is_covered ? 1 : 0 - - if (is_covered) { - file_lines_covered++ - file_bytes_covered += line.length + 1 - } - - offset = next_offset - } - - // Create "chunks" of covered/uncovered lines for easier rendering later on - let chunks = [ - { - start_line: 1, - is_covered: line_coverage[0] === 1, - end_line: 1, - total_lines: 1, - }, - ] - - for (let index = 1; index < line_coverage.length; index++) { - let is_covered = line_coverage[index] - if (is_covered !== line_coverage[index - 1]) { - let last_chunk = chunks.at(-1)! - last_chunk.end_line = index - last_chunk.total_lines = index - last_chunk.start_line + 1 - - chunks.push({ - start_line: index + 1, - is_covered: is_covered === 1, - end_line: index, - total_lines: 0, - }) - } - } - - let last_chunk = chunks.at(-1)! - last_chunk.total_lines = line_coverage.length + 1 - last_chunk.start_line - last_chunk.end_line = line_coverage.length - - return { - url, - text, - ranges, - unused_bytes: file_total_bytes - file_bytes_covered, - used_bytes: file_bytes_covered, - total_bytes: file_total_bytes, - line_coverage_ratio: ratio(file_lines_covered, total_file_lines), - byte_coverage_ratio: ratio(file_bytes_covered, file_total_bytes), - line_coverage, - total_lines: total_file_lines, - covered_lines: file_lines_covered, - uncovered_lines: total_file_lines - file_lines_covered, - chunks, - } + return calculate_stylesheet_coverage(text, ranges, url) }) // Calculate total coverage for all stylesheets combined From aabf746c686925592af8271596e15991a7925841 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Wed, 15 Oct 2025 23:54:45 +0200 Subject: [PATCH 2/5] WIP: simplify steps like prettification etc. --- src/css-tree.d.ts | 61 -------- src/decuplicate.ts | 6 +- src/deduplicate.test.ts | 38 +++-- src/filter-entries.ts | 1 - src/index.test.ts | 215 +++++++++------------------- src/index.ts | 278 ++++++++++++++++++++++--------------- src/parse-coverage.test.ts | 9 +- src/parse-coverage.ts | 4 +- src/prettify.test.ts | 56 -------- src/prettify.ts | 84 ----------- src/remap-html.test.ts | 11 ++ test/generate-coverage.ts | 3 +- 12 files changed, 270 insertions(+), 496 deletions(-) delete mode 100644 src/css-tree.d.ts delete mode 100644 src/prettify.test.ts delete mode 100644 src/prettify.ts diff --git a/src/css-tree.d.ts b/src/css-tree.d.ts deleted file mode 100644 index bb1ad45..0000000 --- a/src/css-tree.d.ts +++ /dev/null @@ -1,61 +0,0 @@ -declare module 'css-tree/tokenizer' { - export function tokenize(css: string, callback: (type: number, start: number, end: number) => void): void - - // css-tree tokens: https://github.com/csstree/csstree/blob/be5ea1257009960c04cccdb58bb327263e27e3b3/lib/tokenizer/types.js - // https://www.w3.org/TR/css-syntax-3/ - export const EOF = 0 // - export const Ident = 1 // - export const Function = 2 // - export const AtKeyword = 3 // - export const Hash = 4 // - export const String = 5 // - export const BadString = 6 // - export const Url = 7 // - export const BadUrl = 8 // - export const Delim = 9 // - export const Number = 10 // - export const Percentage = 11 // - export const Dimension = 12 // - export const WhiteSpace = 13 // - export const CDO = 14 // - export const CDC = 15 // - export const Colon = 16 // : - export const Semicolon = 17 // ; - export const Comma = 18 // , - export const LeftSquareBracket = 19 // <[-token> - export const RightSquareBracket = 20 // <]-token> - export const LeftParenthesis = 21 // <(-token> - export const RightParenthesis = 22 // <)-token> - export const LeftCurlyBracket = 23 // <{-token> - export const RightCurlyBracket = 24 // <}-token> - export const Comment = 25 - - export const tokenTypes = { - EOF, - Ident, - Function, - AtKeyword, - Hash, - String, - BadString, - Url, - BadUrl, - Delim, - Number, - Percentage, - Dimension, - WhiteSpace, - CDO, - CDC, - Colon, - Semicolon, - Comma, - LeftSquareBracket, - RightSquareBracket, - LeftParenthesis, - RightParenthesis, - LeftCurlyBracket, - RightCurlyBracket, - Comment, - } -} diff --git a/src/decuplicate.ts b/src/decuplicate.ts index d24a5aa..9a97c6f 100644 --- a/src/decuplicate.ts +++ b/src/decuplicate.ts @@ -6,11 +6,11 @@ import type { Coverage, Range } from './parse-coverage.ts' * - if a duplicate stylesheet enters the room, we add it's ranges to the existing stylesheet's ranges * - only bytes of deduplicated stylesheets are counted */ -export function deduplicate_entries(entries: Coverage[]): Map, Pick> { +export function deduplicate_entries(entries: Coverage[]): Coverage[] { let checked_stylesheets = new Map() for (let entry of entries) { - let text = entry.text || '' + let text = entry.text if (checked_stylesheets.has(text)) { let sheet = checked_stylesheets.get(text)! let ranges = sheet.ranges @@ -36,5 +36,5 @@ export function deduplicate_entries(entries: Coverage[]): Map ({ text, url, ranges })) } diff --git a/src/deduplicate.test.ts b/src/deduplicate.test.ts index 908324a..77190c6 100644 --- a/src/deduplicate.test.ts +++ b/src/deduplicate.test.ts @@ -7,16 +7,16 @@ test('handles a single entry', () => { ranges: [{ start: 0, end: 4 }], url: 'example.com', } - expect(deduplicate_entries([entry])).toEqual(new Map([[entry.text, { url: entry.url, ranges: entry.ranges }]])) + expect(deduplicate_entries([entry])).toEqual([entry]) }) -test('deduplicats a simple duplicate entry', () => { +test('deduplicates a simple duplicate entry', () => { let entry = { text: 'a {}', ranges: [{ start: 0, end: 4 }], url: 'example.com', } - expect(deduplicate_entries([entry, entry])).toEqual(new Map([[entry.text, { url: entry.url, ranges: entry.ranges }]])) + expect(deduplicate_entries([entry, entry])).toEqual([entry]) }) test('merges two identical texts with different URLs and identical ranges', () => { @@ -33,7 +33,7 @@ test('merges two identical texts with different URLs and identical ranges', () = }, ] let first = entries.at(0)! - expect(deduplicate_entries(entries)).toEqual(new Map([[first.text, { url: first.url, ranges: first.ranges }]])) + expect(deduplicate_entries(entries)).toEqual([{ text: first.text, url: first.url, ranges: first.ranges }]) }) test('merges different ranges on identical CSS, different URLs', () => { @@ -50,9 +50,7 @@ test('merges different ranges on identical CSS, different URLs', () => { }, ] let first = entries.at(0)! - expect(deduplicate_entries(entries)).toEqual( - new Map([[first.text, { url: first.url, ranges: [first.ranges[0], entries[1]!.ranges[0]] }]]), - ) + expect(deduplicate_entries(entries)).toEqual([{ text: first.text, url: first.url, ranges: [first.ranges[0], entries[1]!.ranges[0]] }]) }) test('merges different ranges on identical CSS, identical URLs', () => { @@ -68,9 +66,9 @@ test('merges different ranges on identical CSS, identical URLs', () => { url: 'example.com', }, ] - expect(deduplicate_entries(entries)).toEqual( - new Map([[entries[0]!.text, { url: entries[0]!.url, ranges: [entries[0]!.ranges[0], entries[1]!.ranges[0]] }]]), - ) + expect(deduplicate_entries(entries)).toEqual([ + { text: entries[0]!.text, url: entries[0]!.url, ranges: [entries[0]!.ranges[0], entries[1]!.ranges[0]] }, + ]) }) test('does not merge different CSS with different URLs and identical ranges', () => { @@ -86,12 +84,10 @@ test('does not merge different CSS with different URLs and identical ranges', () url: 'example.com/b', }, ] - expect(deduplicate_entries(entries)).toEqual( - new Map([ - [entries[0]!.text, { url: entries[0]!.url, ranges: entries[0]!.ranges }], - [entries[1]!.text, { url: entries[1]!.url, ranges: entries[1]!.ranges }], - ]), - ) + expect(deduplicate_entries(entries)).toEqual([ + { text: entries[0]!.text, url: entries[0]!.url, ranges: entries[0]!.ranges }, + { text: entries[1]!.text, url: entries[1]!.url, ranges: entries[1]!.ranges }, + ]) }) test('does not merge different CSS with same URLs and identical ranges', () => { @@ -107,10 +103,8 @@ test('does not merge different CSS with same URLs and identical ranges', () => { url: 'example.com', }, ] - expect(deduplicate_entries(entries)).toEqual( - new Map([ - [entries[0]!.text, { url: entries[0]!.url, ranges: entries[0]!.ranges }], - [entries[1]!.text, { url: entries[1]!.url, ranges: entries[1]!.ranges }], - ]), - ) + expect(deduplicate_entries(entries)).toEqual([ + { text: entries[0]!.text, url: entries[0]!.url, ranges: entries[0]!.ranges }, + { text: entries[1]!.text, url: entries[1]!.url, ranges: entries[1]!.ranges }, + ]) }) diff --git a/src/filter-entries.ts b/src/filter-entries.ts index 99b543e..27e4cc1 100644 --- a/src/filter-entries.ts +++ b/src/filter-entries.ts @@ -11,7 +11,6 @@ export function filter_coverage(coverage: Coverage[], parse_html?: Parser): Cove let result = [] for (let entry of coverage) { - if (!entry.text) continue let extension = ext(entry.url).toLowerCase() if (extension === 'js') continue diff --git a/src/index.test.ts b/src/index.test.ts index 232174a..a99e570 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -35,13 +35,13 @@ test.describe('from \n\n\n\n

Hello world

\n \n \n \n\n\n\n', - }, - ] - - test('counts totals', () => { - let result = calculate_coverage(coverage, html_parser) - 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_ratio).toBe(9 / 14) - expect.soft(result.total_stylesheets).toBe(1) - }) - - test('extracts and formats css', () => { - let result = calculate_coverage(coverage, html_parser) - expect(result.coverage_per_stylesheet.at(0)?.text).toEqual( - format(`h1 { - color: blue; - font-size: 24px; - } - - /* not covered */ - p { - color: red; - } - - @media (width > 30em) { - h1 { - color: green; - } - }`), - ) - }) - - test('calculates line coverage', () => { - let result = calculate_coverage(coverage, html_parser) - expect(result.coverage_per_stylesheet.at(0)?.line_coverage).toEqual( - new Uint8Array([ - // h1 {} - 1, 1, 1, 1, - // comment + p {} - 0, 0, 0, 0, - // newline - 1, - // @media - 1, - // h1 { - 0, - // color: green; } - 1, 1, 1, - ]), - ) - }) - - test('calculates chunks', () => { - let result = calculate_coverage(coverage, html_parser) - expect(result.coverage_per_stylesheet.at(0)?.chunks).toEqual([ - { start_line: 1, is_covered: true, end_line: 4, total_lines: 4 }, - { start_line: 5, is_covered: false, end_line: 8, total_lines: 4 }, - { start_line: 9, is_covered: true, end_line: 10, total_lines: 2 }, - { start_line: 11, is_covered: false, end_line: 11, total_lines: 1 }, - { start_line: 12, is_covered: true, end_line: 14, total_lines: 3 }, - ]) - }) - +test.describe('chunks', () => { test('calculates chunks for fully covered file', () => { let result = calculate_coverage( [ { - url: 'https://example.com', + url: 'https://example.com/style.css', ranges: [ { start: 0, @@ -225,13 +140,16 @@ test.describe('from coverage data downloaded directly from the browser as JSON', ], html_parser, ) - expect(result.coverage_per_stylesheet.at(0)?.text).toEqual('h1 {\n\tcolor: blue;\n}') - expect(result.coverage_per_stylesheet.at(0)?.chunks).toEqual([ + expect.soft(result.coverage_per_stylesheet.at(0)?.text).toEqual('h1 {\n\tcolor: blue;\n}\n') + expect.soft(result.coverage_per_stylesheet.at(0)?.chunks).toEqual([ { start_line: 1, is_covered: true, - end_line: 3, - total_lines: 3, + end_line: 4, + total_lines: 4, + css: 'h1 {\n\tcolor: blue;\n}\n', + start_offset: 0, + end_offset: 'h1 {\n\tcolor: blue;\n}\n'.length - 1, }, ]) }) @@ -240,19 +158,22 @@ test.describe('from coverage data downloaded directly from the browser as JSON', let result = calculate_coverage( [ { - url: 'https://example.com', + url: 'https://example.com/style.css', ranges: [], text: 'h1 { color: blue; }', }, ], html_parser, ) - expect(result.coverage_per_stylesheet.at(0)?.chunks).toEqual([ + expect.soft(result.coverage_per_stylesheet.at(0)?.chunks).toEqual([ { start_line: 1, is_covered: false, end_line: 3, total_lines: 3, + css: format('h1 { color: blue; }'), + start_offset: 0, + end_offset: 19, }, ]) }) @@ -262,8 +183,8 @@ test('handles empty input', () => { let result = calculate_coverage([], html_parser) expect(result.total_files_found).toBe(0) expect(result.total_bytes).toBe(0) - expect(result.used_bytes).toBe(0) - expect(result.unused_bytes).toBe(0) + expect(result.covered_bytes).toBe(0) + expect(result.uncovered_bytes).toBe(0) expect(result.total_lines).toBe(0) expect(result.covered_lines).toBe(0) expect(result.uncovered_lines).toBe(0) diff --git a/src/index.ts b/src/index.ts index 3a7cb90..3d9f788 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,12 @@ import { is_valid_coverage, type Coverage, type Range } from './parse-coverage.ts' -import { prettify } from './prettify.ts' import { deduplicate_entries } from './decuplicate.ts' import { filter_coverage } from './filter-entries.ts' import type { Parser } from './types.ts' +import { format } from '@projectwallace/format-css' export type CoverageData = { - unused_bytes: number - used_bytes: number + uncovered_bytes: number + covered_bytes: number total_bytes: number line_coverage_ratio: number byte_coverage_ratio: number @@ -19,13 +19,7 @@ export type StylesheetCoverage = CoverageData & { url: string text: string ranges: Range[] - line_coverage: Uint8Array - chunks: { - is_covered: boolean - start_line: number - end_line: number - total_lines: number - }[] + chunks: CoverageChunk[] } export type CoverageResult = CoverageData & { @@ -39,116 +33,176 @@ function ratio(fraction: number, total: number) { return fraction / total } -function calculate_stylesheet_coverage(text: string, ranges: Range[], url: string): StylesheetCoverage { - function is_line_covered(line: string, start_offset: number) { - let end = start_offset + line.length - let next_offset = end + 1 // account for newline character - let is_empty = /^\s*$/.test(line) - let is_closing_brace = line.endsWith('}') - - if (!is_empty && !is_closing_brace) { - for (let range of ranges) { - if (range.start > end || range.end < start_offset) { - continue - } - if (range.start <= start_offset && range.end >= end) { - return true - } else if (line.startsWith('@') && range.start > start_offset && range.start < next_offset) { - return true - } - } +function calculate_stylesheet_coverage({ text, ranges, url, chunks }: LineCoverage): StylesheetCoverage { + let uncovered_bytes = 0 + let covered_bytes = 0 + let total_bytes = 0 + let total_lines = 0 + let covered_lines = 0 + let uncovered_lines = 0 + + for (let chunk of chunks) { + let lines = chunk.total_lines + let bytes = chunk.end_offset - chunk.start_offset + + total_lines += lines + total_bytes += bytes + + if (chunk.is_covered) { + covered_lines += lines + covered_bytes += bytes + } else { + uncovered_lines += lines + uncovered_bytes += bytes } - return false } - let lines = text.split('\n') - let total_file_lines = lines.length - let line_coverage = new Uint8Array(total_file_lines) - let file_lines_covered = 0 - let file_total_bytes = text.length - let file_bytes_covered = 0 - let offset = 0 + return { + url, + text, + ranges, + uncovered_bytes, + covered_bytes, + total_bytes, + line_coverage_ratio: ratio(covered_lines, total_lines), + byte_coverage_ratio: ratio(covered_bytes, total_bytes), + total_lines, + covered_lines, + uncovered_lines, + chunks, + } +} - for (let index = 0; index < lines.length; index++) { - let line = lines[index] - if (line === undefined) continue - - let start = offset - let end = offset + line.length - let next_offset = end + 1 // +1 for the newline character - let is_empty = /^\s*$/.test(line) - let is_closing_brace = line.endsWith('}') - let is_in_range = is_line_covered(line, start) - let is_covered = false - - let prev_is_covered = index > 0 && line_coverage[index - 1] === 1 - - if (is_in_range && !is_closing_brace && !is_empty) { - is_covered = true - } else if ((is_empty || is_closing_brace) && prev_is_covered) { - is_covered = true - } else if (is_empty && !prev_is_covered && is_line_covered(lines[index + 1]!, next_offset)) { - // If the next line is covered, mark this empty line as covered - is_covered = true +/** + * WARNING: mutates the ranges array + */ +function include_atrule_name_in_ranges(coverage: Coverage[]): Coverage[] { + // Adjust ranges to include @-rule name (only preludes included) + // Note: Cannot reliably include closing } because it may not be the end of the range + const LONGEST_ATRULE_NAME = '@-webkit-font-feature-values'.length + + for (let stylesheet of coverage) { + for (let range of stylesheet.ranges) { + // Heuristic: atrule names are no longer than LONGEST_ATRULE_NAME + for (let i = 1; i >= -LONGEST_ATRULE_NAME; i--) { + let char_position = range.start + i + if (stylesheet.text.charAt(char_position) === '@') { + range.start = char_position + break + } + } } + } - line_coverage[index] = is_covered ? 1 : 0 + return coverage +} - if (is_covered) { - file_lines_covered++ - file_bytes_covered += line.length + 1 - } +type OffsetChunk = { + start_offset: number + end_offset: number + is_covered: boolean +} - offset = next_offset - } +type CoverageChunk = OffsetChunk & { + start_line: number + end_line: number + total_lines: number + css: string +} - // Create "chunks" of covered/uncovered lines for easier rendering later on - let chunks = [ - { - start_line: 1, - is_covered: line_coverage[0] === 1, - end_line: 1, - total_lines: 1, - }, - ] - - for (let index = 1; index < line_coverage.length; index++) { - let is_covered = line_coverage[index] - if (is_covered !== line_coverage[index - 1]) { - let last_chunk = chunks.at(-1)! - last_chunk.end_line = index - last_chunk.total_lines = index - last_chunk.start_line + 1 +type ChunkifiedCoverage = Coverage & { chunks: OffsetChunk[] } +type LineCoverage = Coverage & { chunks: CoverageChunk[] } + +// TODO: get rid of empty chunks, merge first/last with adjecent covered block +function chunkify_stylesheet(stylesheet: Coverage): ChunkifiedCoverage { + let chunks = [] + let offset = 0 + for (let range of stylesheet.ranges) { + // Create non-covered chunk + if (offset !== range.start) { chunks.push({ - start_line: index + 1, - is_covered: is_covered === 1, - end_line: index, - total_lines: 0, + start_offset: offset, + end_offset: range.start, + is_covered: false, }) + offset = range.start } + + chunks.push({ + start_offset: range.start, + end_offset: range.end, + is_covered: true, + }) + offset = range.end } - let last_chunk = chunks.at(-1)! - last_chunk.total_lines = line_coverage.length + 1 - last_chunk.start_line - last_chunk.end_line = line_coverage.length + // fill up last chunk if necessary: + if (offset !== stylesheet.text.length) { + chunks.push({ + start_offset: offset, + end_offset: stylesheet.text.length, + is_covered: false, + }) + } return { - url, - text, - ranges, - unused_bytes: file_total_bytes - file_bytes_covered, - used_bytes: file_bytes_covered, - total_bytes: file_total_bytes, - line_coverage_ratio: ratio(file_lines_covered, total_file_lines), - byte_coverage_ratio: ratio(file_bytes_covered, file_total_bytes), - line_coverage, - total_lines: total_file_lines, - covered_lines: file_lines_covered, - uncovered_lines: total_file_lines - file_lines_covered, + ...stylesheet, chunks, } } +function prettify(stylesheet: ChunkifiedCoverage): LineCoverage { + let line = 1 + let offset = 0 + + let pretty_chunks = stylesheet.chunks.map((offset_chunk, index) => { + let css = format(stylesheet.text.slice(offset_chunk.start_offset, offset_chunk.end_offset)) + + if (offset_chunk.is_covered) { + if (index === 0) { + // mark the line between this chunk and the next on as covered + css = css + '\n' + } else if (index === stylesheet.chunks.length - 1) { + // mark the newline after the previous uncovered block as covered + css = '\n' + css + } else { + // mark the newline after the previous uncovered block as covered + // and mark the line between this chunk and the next on as covered + css = '\n' + css + '\n' + } + } + + let line_count = css.split('\n').length + let start_offset = offset + let end_offset = Math.max(offset + css.length - 1, 0) + let start_line = line + let end_line = line + line_count + + line = end_line + offset = end_offset + + return { + ...offset_chunk, + start_offset, + start_line, + end_line: end_line - 1, + end_offset, + css, + total_lines: end_line - start_line, + } + }) + + let updated_stylesheet = { + ...stylesheet, + // TODO: update ranges as well?? Or remove them because we have chunks now + chunks: pretty_chunks, + text: pretty_chunks.map(({ css }) => css).join(''), + } + + return updated_stylesheet +} + /** * @description * CSS Code Coverage calculation @@ -168,24 +222,24 @@ export function calculate_coverage(coverage: Coverage[], parse_html?: Parser): C } let filtered_coverage = filter_coverage(coverage, parse_html) - let prettified_coverage = prettify(filtered_coverage) - let deduplicated = deduplicate_entries(prettified_coverage) + let deduplicated = deduplicate_entries(filtered_coverage) + let range_extended = include_atrule_name_in_ranges(deduplicated) + let chunkified = range_extended.map((stylesheet) => chunkify_stylesheet(stylesheet)) + let prettified = chunkified.map((stylesheet) => prettify(stylesheet)) // Calculate coverage for each individual stylesheet we found - let coverage_per_stylesheet = Array.from(deduplicated).map(([text, { url, ranges }]) => { - return calculate_stylesheet_coverage(text, ranges, url) - }) + let coverage_per_stylesheet = prettified.map((stylesheet) => calculate_stylesheet_coverage(stylesheet)) // Calculate total coverage for all stylesheets combined - let { total_lines, total_covered_lines, total_uncovered_lines, total_bytes, total_used_bytes, total_unused_bytes } = + let { total_lines, total_covered_lines, total_uncovered_lines, total_bytes, total_covered_bytes, total_uncovered_bytes } = coverage_per_stylesheet.reduce( (totals, sheet) => { totals.total_lines += sheet.total_lines totals.total_covered_lines += sheet.covered_lines totals.total_uncovered_lines += sheet.uncovered_lines totals.total_bytes += sheet.total_bytes - totals.total_used_bytes += sheet.used_bytes - totals.total_unused_bytes += sheet.unused_bytes + totals.total_covered_bytes += sheet.covered_bytes + totals.total_uncovered_bytes += sheet.uncovered_bytes return totals }, { @@ -193,8 +247,8 @@ export function calculate_coverage(coverage: Coverage[], parse_html?: Parser): C total_covered_lines: 0, total_uncovered_lines: 0, total_bytes: 0, - total_used_bytes: 0, - total_unused_bytes: 0, + total_covered_bytes: 0, + total_uncovered_bytes: 0, }, ) @@ -202,11 +256,11 @@ export function calculate_coverage(coverage: Coverage[], parse_html?: Parser): C total_files_found, total_bytes, total_lines, - used_bytes: total_used_bytes, + covered_bytes: total_covered_bytes, covered_lines: total_covered_lines, - unused_bytes: total_unused_bytes, + uncovered_bytes: total_uncovered_bytes, uncovered_lines: total_uncovered_lines, - byte_coverage_ratio: ratio(total_used_bytes, total_bytes), + byte_coverage_ratio: ratio(total_covered_bytes, total_bytes), line_coverage_ratio: ratio(total_covered_lines, total_lines), coverage_per_stylesheet, total_stylesheets: coverage_per_stylesheet.length, diff --git a/src/parse-coverage.test.ts b/src/parse-coverage.test.ts index 9c4a5fa..457953e 100644 --- a/src/parse-coverage.test.ts +++ b/src/parse-coverage.test.ts @@ -23,7 +23,7 @@ test('parses valid JSON', () => { ]) }) -test('allows entries without text', () => { +test('does not allow entries without text', () => { let input = ` [ { @@ -35,12 +35,7 @@ test('allows entries without text', () => { ] ` let result = parse_coverage(input) - expect(result).toEqual([ - { - url: 'example.com', - ranges: [{ start: 0, end: 13 }], - }, - ]) + expect(result).toEqual([]) }) test('returns empty array for invalid JSON', () => { diff --git a/src/parse-coverage.ts b/src/parse-coverage.ts index 7e27d50..989f30c 100644 --- a/src/parse-coverage.ts +++ b/src/parse-coverage.ts @@ -7,13 +7,13 @@ export type Range = { export type Coverage = { url: string - text?: string + text: string ranges: Range[] } let CoverageSchema = v.array( v.object({ - text: v.optional(v.string()), + text: v.string(), url: v.string(), ranges: v.array( v.object({ diff --git a/src/prettify.test.ts b/src/prettify.test.ts deleted file mode 100644 index d2aa237..0000000 --- a/src/prettify.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { test, expect } from '@playwright/test' -import { prettify } from './prettify.ts' - -test('simple rule prettification', () => { - let entries = [ - { - url: 'example.com', - text: 'a+b{color:red}', - ranges: [{ start: 0, end: 14 }], - }, - ] - let prettified = [ - { - url: 'example.com', - text: `a + b {\n\tcolor: red;\n}`, - ranges: [{ start: 0, end: 22 }], - }, - ] - expect(prettify(entries)).toEqual(prettified) -}) - -test('Handles new tokens added by css formatter', () => { - let entries = [ - { - url: 'example.com', - text: 'a{color:red}b{color:green}', - ranges: [{ start: 0, end: 26 }], - }, - ] - let prettified = [ - { - url: 'example.com', - text: `a {\n\tcolor: red;\n}\n\nb {\n\tcolor: green;\n}`, - ranges: [{ start: 0, end: 40 }], - }, - ] - expect(prettify(entries)).toEqual(prettified) -}) - -test('atrule prettification', () => { - let entries = [ - { - url: 'example.com', - text: '@supports (display:grid){a+b{color:red}}', - ranges: [{ start: 0, end: 40 }], - }, - ] - let prettified = [ - { - url: 'example.com', - text: `@supports (display: grid) {\n\ta + b {\n\t\tcolor: red;\n\t}\n}`, - ranges: [{ start: 0, end: 55 }], - }, - ] - expect(prettify(entries)).toEqual(prettified) -}) diff --git a/src/prettify.ts b/src/prettify.ts deleted file mode 100644 index ab988eb..0000000 --- a/src/prettify.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { format } from '@projectwallace/format-css' -import type { Range, Coverage } from './parse-coverage.ts' -// css-tree tokens: https://github.com/csstree/csstree/blob/be5ea1257009960c04cccdb58bb327263e27e3b3/lib/tokenizer/types.js -import { tokenize, tokenTypes } from 'css-tree/tokenizer' - -export function prettify(coverage: Coverage[]): Coverage[] { - return coverage.map(({ url, text, ranges }) => { - if (!text) { - return { url, text, ranges } - } - let formatted = format(text) - let irrelevant_tokens: Set = new Set([ - tokenTypes.EOF, - tokenTypes.BadString, - tokenTypes.BadUrl, - tokenTypes.WhiteSpace, - tokenTypes.Semicolon, - tokenTypes.Comment, - tokenTypes.Colon, - ]) - - // Initialize the ranges with an empty array of token indexes - let ext_ranges: (Range & { tokens: number[] })[] = ranges.map(({ start, end }) => ({ start, end, tokens: [] })) - - function is_in_range(start: number, end: number): number { - let range_index = 0 - for (let range of ext_ranges) { - if (range.start > end) return -1 - if (range.start <= start && range.end >= end) { - return range_index - } - range_index++ - } - return -1 - } - - let index = 0 - - tokenize(text, (type, start, end) => { - if (irrelevant_tokens.has(type)) return - index++ - - // format-css changes the Url token to a Function,String,RightParenthesis token sequence - if (type === tokenTypes.Url) { - index += 2 - } - - let range_index = is_in_range(start, end) - if (range_index !== -1) { - ext_ranges[range_index]!.tokens.push(index) - } - }) - - let new_tokens: Map = new Map() - index = 0 - - tokenize(formatted, (type, start, end) => { - if (irrelevant_tokens.has(type)) return - index++ - - // format-css changes the Url token to a Function,String,RightParenthesis token sequence - if (type === tokenTypes.Url) { - index += 2 - } - - new_tokens.set(index, { start, end }) - }) - - let new_ranges: Range[] = [] - - for (let range of ext_ranges) { - let start_token = new_tokens.get(range.tokens.at(0)!) - let end_token = new_tokens.get(range.tokens.at(-1)!) - if (start_token !== undefined && end_token !== undefined) { - new_ranges.push({ - start: start_token.start, - end: end_token.end, - }) - } - } - - return { url, text: formatted, ranges: new_ranges } - }) -} diff --git a/src/remap-html.test.ts b/src/remap-html.test.ts index 79896e7..58bd18c 100644 --- a/src/remap-html.test.ts +++ b/src/remap-html.test.ts @@ -64,3 +64,14 @@ test('remaps multiple style blocks', () => { ], }) }) + +test.skip('removes leading & trailing whitespace from style block', () => { + let css = `h1 { color: red; }` + let html = create_html(``, `

Hello world

`) + let range = { start: html.indexOf(css) - 2, end: html.indexOf(css) + 2 + css.length } + let result = remap_html(html_parser, html, [range]) + expect(result).toEqual({ + css, + ranges: [{ start: 0, end: css.length }], + }) +}) diff --git a/test/generate-coverage.ts b/test/generate-coverage.ts index 32f84cb..937d171 100644 --- a/test/generate-coverage.ts +++ b/test/generate-coverage.ts @@ -1,4 +1,5 @@ import { chromium } from '@playwright/test' +import type { Coverage } from '../src' export async function generate_coverage(html: string, { link_css }: { link_css?: string } = {}) { let browser = await chromium.launch({ headless: true }) @@ -22,5 +23,5 @@ export async function generate_coverage(html: string, { link_css }: { link_css?: await page.evaluate(() => getComputedStyle(document.body)) // force CSS evaluation let coverage = await page.coverage.stopCSSCoverage() await browser.close() - return coverage + return coverage.filter((e) => e.text !== undefined) as Coverage[] } From b790a528a5a0078770c0008352adcb7b134a36ad Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Wed, 15 Oct 2025 23:55:18 +0200 Subject: [PATCH 3/5] rm css-tree --- package-lock.json | 1 - package.json | 1 - vite.config.js | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ceac281..aeef712 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "EUPL-1.2", "dependencies": { "@projectwallace/format-css": "^2.1.1", - "css-tree": "^3.1.0", "valibot": "^1.1.0" }, "devDependencies": { diff --git a/package.json b/package.json index 1a4e2f4..cb4f580 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,6 @@ }, "dependencies": { "@projectwallace/format-css": "^2.1.1", - "css-tree": "^3.1.0", "valibot": "^1.1.0" } } diff --git a/vite.config.js b/vite.config.js index 9e82578..720543c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -10,7 +10,7 @@ export default defineConfig({ formats: ['es'], }, rollupOptions: { - external: Object.keys(pkg.dependencies).concat('css-tree/tokenizer'), + external: Object.keys(pkg.dependencies), }, }, plugins: [ From dfe0547da33a5bc33182796606d770a4d427010a Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Thu, 16 Oct 2025 16:58:05 +0200 Subject: [PATCH 4/5] split up in smaller files --- src/chunkify.ts | 48 +++++++++++++++ src/extend-ranges.ts | 25 ++++++++ src/index.ts | 143 +++---------------------------------------- src/prettify.ts | 65 ++++++++++++++++++++ 4 files changed, 145 insertions(+), 136 deletions(-) create mode 100644 src/chunkify.ts create mode 100644 src/extend-ranges.ts create mode 100644 src/prettify.ts diff --git a/src/chunkify.ts b/src/chunkify.ts new file mode 100644 index 0000000..cc8956b --- /dev/null +++ b/src/chunkify.ts @@ -0,0 +1,48 @@ +import type { Coverage } from './parse-coverage' + +export type ChunkedCoverage = Coverage & { + chunks: { + start_offset: number + end_offset: number + is_covered: boolean + }[] +} + +// TODO: get rid of empty chunks, merge first/last with adjecent covered block +export function chunkify_stylesheet(stylesheet: Coverage): ChunkedCoverage { + let chunks = [] + let offset = 0 + + for (let range of stylesheet.ranges) { + // Create non-covered chunk + if (offset !== range.start) { + chunks.push({ + start_offset: offset, + end_offset: range.start, + is_covered: false, + }) + offset = range.start + } + + chunks.push({ + start_offset: range.start, + end_offset: range.end, + is_covered: true, + }) + offset = range.end + } + + // fill up last chunk if necessary: + if (offset !== stylesheet.text.length) { + chunks.push({ + start_offset: offset, + end_offset: stylesheet.text.length, + is_covered: false, + }) + } + + return { + ...stylesheet, + chunks, + } +} diff --git a/src/extend-ranges.ts b/src/extend-ranges.ts new file mode 100644 index 0000000..ea55577 --- /dev/null +++ b/src/extend-ranges.ts @@ -0,0 +1,25 @@ +import type { Coverage } from './parse-coverage' + +/** + * WARNING: mutates the ranges array + */ +export function extend_ranges(coverage: Coverage[]): Coverage[] { + // Adjust ranges to include @-rule name (only preludes included) + // Note: Cannot reliably include closing } because it may not be the end of the range + const LONGEST_ATRULE_NAME = '@-webkit-font-feature-values'.length + + for (let stylesheet of coverage) { + for (let range of stylesheet.ranges) { + // Heuristic: atrule names are no longer than LONGEST_ATRULE_NAME + for (let i = 1; i >= -LONGEST_ATRULE_NAME; i--) { + let char_position = range.start + i + if (stylesheet.text.charAt(char_position) === '@') { + range.start = char_position + break + } + } + } + } + + return coverage +} diff --git a/src/index.ts b/src/index.ts index 3d9f788..6325dc6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,9 @@ import { is_valid_coverage, type Coverage, type Range } from './parse-coverage.t import { deduplicate_entries } from './decuplicate.ts' import { filter_coverage } from './filter-entries.ts' import type { Parser } from './types.ts' -import { format } from '@projectwallace/format-css' +import { chunkify_stylesheet } from './chunkify.ts' +import { extend_ranges } from './extend-ranges.ts' +import { prettify, type PrettifiedCoverage, type PrettifiedChunk } from './prettify.ts' export type CoverageData = { uncovered_bytes: number @@ -19,7 +21,7 @@ export type StylesheetCoverage = CoverageData & { url: string text: string ranges: Range[] - chunks: CoverageChunk[] + chunks: PrettifiedChunk[] } export type CoverageResult = CoverageData & { @@ -33,7 +35,8 @@ function ratio(fraction: number, total: number) { return fraction / total } -function calculate_stylesheet_coverage({ text, ranges, url, chunks }: LineCoverage): StylesheetCoverage { +function calculate_stylesheet_coverage(stylesheet: PrettifiedCoverage): StylesheetCoverage { + let { text, ranges, url, chunks } = stylesheet let uncovered_bytes = 0 let covered_bytes = 0 let total_bytes = 0 @@ -73,136 +76,6 @@ function calculate_stylesheet_coverage({ text, ranges, url, chunks }: LineCovera } } -/** - * WARNING: mutates the ranges array - */ -function include_atrule_name_in_ranges(coverage: Coverage[]): Coverage[] { - // Adjust ranges to include @-rule name (only preludes included) - // Note: Cannot reliably include closing } because it may not be the end of the range - const LONGEST_ATRULE_NAME = '@-webkit-font-feature-values'.length - - for (let stylesheet of coverage) { - for (let range of stylesheet.ranges) { - // Heuristic: atrule names are no longer than LONGEST_ATRULE_NAME - for (let i = 1; i >= -LONGEST_ATRULE_NAME; i--) { - let char_position = range.start + i - if (stylesheet.text.charAt(char_position) === '@') { - range.start = char_position - break - } - } - } - } - - return coverage -} - -type OffsetChunk = { - start_offset: number - end_offset: number - is_covered: boolean -} - -type CoverageChunk = OffsetChunk & { - start_line: number - end_line: number - total_lines: number - css: string -} - -type ChunkifiedCoverage = Coverage & { chunks: OffsetChunk[] } -type LineCoverage = Coverage & { chunks: CoverageChunk[] } - -// TODO: get rid of empty chunks, merge first/last with adjecent covered block -function chunkify_stylesheet(stylesheet: Coverage): ChunkifiedCoverage { - let chunks = [] - let offset = 0 - - for (let range of stylesheet.ranges) { - // Create non-covered chunk - if (offset !== range.start) { - chunks.push({ - start_offset: offset, - end_offset: range.start, - is_covered: false, - }) - offset = range.start - } - - chunks.push({ - start_offset: range.start, - end_offset: range.end, - is_covered: true, - }) - offset = range.end - } - - // fill up last chunk if necessary: - if (offset !== stylesheet.text.length) { - chunks.push({ - start_offset: offset, - end_offset: stylesheet.text.length, - is_covered: false, - }) - } - - return { - ...stylesheet, - chunks, - } -} - -function prettify(stylesheet: ChunkifiedCoverage): LineCoverage { - let line = 1 - let offset = 0 - - let pretty_chunks = stylesheet.chunks.map((offset_chunk, index) => { - let css = format(stylesheet.text.slice(offset_chunk.start_offset, offset_chunk.end_offset)) - - if (offset_chunk.is_covered) { - if (index === 0) { - // mark the line between this chunk and the next on as covered - css = css + '\n' - } else if (index === stylesheet.chunks.length - 1) { - // mark the newline after the previous uncovered block as covered - css = '\n' + css - } else { - // mark the newline after the previous uncovered block as covered - // and mark the line between this chunk and the next on as covered - css = '\n' + css + '\n' - } - } - - let line_count = css.split('\n').length - let start_offset = offset - let end_offset = Math.max(offset + css.length - 1, 0) - let start_line = line - let end_line = line + line_count - - line = end_line - offset = end_offset - - return { - ...offset_chunk, - start_offset, - start_line, - end_line: end_line - 1, - end_offset, - css, - total_lines: end_line - start_line, - } - }) - - let updated_stylesheet = { - ...stylesheet, - // TODO: update ranges as well?? Or remove them because we have chunks now - chunks: pretty_chunks, - text: pretty_chunks.map(({ css }) => css).join(''), - } - - return updated_stylesheet -} - /** * @description * CSS Code Coverage calculation @@ -223,11 +96,9 @@ export function calculate_coverage(coverage: Coverage[], parse_html?: Parser): C let filtered_coverage = filter_coverage(coverage, parse_html) let deduplicated = deduplicate_entries(filtered_coverage) - let range_extended = include_atrule_name_in_ranges(deduplicated) + let range_extended = extend_ranges(deduplicated) let chunkified = range_extended.map((stylesheet) => chunkify_stylesheet(stylesheet)) let prettified = chunkified.map((stylesheet) => prettify(stylesheet)) - - // Calculate coverage for each individual stylesheet we found let coverage_per_stylesheet = prettified.map((stylesheet) => calculate_stylesheet_coverage(stylesheet)) // Calculate total coverage for all stylesheets combined diff --git a/src/prettify.ts b/src/prettify.ts new file mode 100644 index 0000000..d05e6f6 --- /dev/null +++ b/src/prettify.ts @@ -0,0 +1,65 @@ +import type { Coverage } from './parse-coverage' +import { type ChunkedCoverage } from './chunkify' +import { format } from '@projectwallace/format-css' + +export type PrettifiedChunk = ChunkedCoverage['chunks'][0] & { + start_line: number + end_line: number + total_lines: number + css: string +} + +export type PrettifiedCoverage = Coverage & { + chunks: PrettifiedChunk[] +} + +export function prettify(stylesheet: ChunkedCoverage): PrettifiedCoverage { + let line = 1 + let offset = 0 + + let pretty_chunks = stylesheet.chunks.map((offset_chunk, index) => { + let css = format(stylesheet.text.slice(offset_chunk.start_offset, offset_chunk.end_offset)) + + if (offset_chunk.is_covered) { + if (index === 0) { + // mark the line between this chunk and the next on as covered + css = css + '\n' + } else if (index === stylesheet.chunks.length - 1) { + // mark the newline after the previous uncovered block as covered + css = '\n' + css + } else { + // mark the newline after the previous uncovered block as covered + // and mark the line between this chunk and the next on as covered + css = '\n' + css + '\n' + } + } + + let line_count = css.split('\n').length + let start_offset = offset + let end_offset = Math.max(offset + css.length - 1, 0) + let start_line = line + let end_line = line + line_count + + line = end_line + offset = end_offset + + return { + ...offset_chunk, + start_offset, + start_line, + end_line: end_line - 1, + end_offset, + css, + total_lines: end_line - start_line, + } + }) + + let updated_stylesheet = { + ...stylesheet, + // TODO: update ranges as well?? Or remove them because we have chunks now + chunks: pretty_chunks, + text: pretty_chunks.map(({ css }) => css).join(''), + } + + return updated_stylesheet +} From 73a873ddecbb90ba5f4cf84b006239dd2913d4b1 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Thu, 16 Oct 2025 22:35:11 +0200 Subject: [PATCH 5/5] some tests, remove ranges from output --- src/chunkify.test.ts | 101 ++++++++++++++++++++++++++++++++++++++ src/extend-ranges.test.ts | 4 ++ src/index.ts | 4 +- src/prettify.test.ts | 10 ++++ src/prettify.ts | 2 +- 5 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 src/chunkify.test.ts create mode 100644 src/extend-ranges.test.ts create mode 100644 src/prettify.test.ts diff --git a/src/chunkify.test.ts b/src/chunkify.test.ts new file mode 100644 index 0000000..ebef7ed --- /dev/null +++ b/src/chunkify.test.ts @@ -0,0 +1,101 @@ +import { test, expect } from '@playwright/test' +import { chunkify_stylesheet, type ChunkedCoverage } from './chunkify' + +test('creates chunks with outer chunks covered', () => { + let coverage = { + text: 'a { color: red; } b { color: green; } c { color: blue; }', + ranges: [ + { start: 0, end: 17 }, + { start: 38, end: 56 }, + ], + url: 'https://example.com', + } + let result = chunkify_stylesheet(coverage) + expect(result).toEqual({ + ...coverage, + chunks: [ + { + start_offset: 0, + end_offset: 17, + is_covered: true, + }, + { + start_offset: 17, + end_offset: 38, + is_covered: false, + }, + { + start_offset: 38, + end_offset: 56, + is_covered: true, + }, + ], + } satisfies ChunkedCoverage) +}) + +test('creates chunks with only middle chunk covered', () => { + let coverage = { + text: 'a { color: red; } b { color: green; } c { color: blue; }', + ranges: [{ start: 17, end: 38 }], + url: 'https://example.com', + } + let result = chunkify_stylesheet(coverage) + expect(result).toEqual({ + ...coverage, + chunks: [ + { + start_offset: 0, + end_offset: 17, + is_covered: false, + }, + { + start_offset: 17, + end_offset: 38, + is_covered: true, + }, + { + start_offset: 38, + end_offset: 56, + is_covered: false, + }, + ], + } satisfies ChunkedCoverage) +}) + +test('creates a single chunk when all is covered', () => { + let coverage = { + text: 'a { color: red; } b { color: green; } c { color: blue; }', + ranges: [{ start: 0, end: 56 }], + url: 'https://example.com', + } + let result = chunkify_stylesheet(coverage) + expect(result).toEqual({ + ...coverage, + chunks: [ + { + start_offset: 0, + end_offset: 56, + is_covered: true, + }, + ], + } satisfies ChunkedCoverage) +}) + +test('creates a single chunk when none is covered', () => { + let coverage = { + text: 'a { color: red; } b { color: green; } c { color: blue; }', + ranges: [], + url: 'https://example.com', + } + let result = chunkify_stylesheet(coverage) + expect(result).toEqual({ + ...coverage, + chunks: [ + { + start_offset: 0, + end_offset: 56, + is_covered: false, + }, + ], + } satisfies ChunkedCoverage) +}) diff --git a/src/extend-ranges.test.ts b/src/extend-ranges.test.ts new file mode 100644 index 0000000..f3aca75 --- /dev/null +++ b/src/extend-ranges.test.ts @@ -0,0 +1,4 @@ +import { test, expect } from '@playwright/test' +import { extend_ranges } from './extend-ranges' + +// TODO diff --git a/src/index.ts b/src/index.ts index 6325dc6..3243859 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,6 @@ export type CoverageData = { export type StylesheetCoverage = CoverageData & { url: string text: string - ranges: Range[] chunks: PrettifiedChunk[] } @@ -36,7 +35,7 @@ function ratio(fraction: number, total: number) { } function calculate_stylesheet_coverage(stylesheet: PrettifiedCoverage): StylesheetCoverage { - let { text, ranges, url, chunks } = stylesheet + let { text, url, chunks } = stylesheet let uncovered_bytes = 0 let covered_bytes = 0 let total_bytes = 0 @@ -63,7 +62,6 @@ function calculate_stylesheet_coverage(stylesheet: PrettifiedCoverage): Styleshe return { url, text, - ranges, uncovered_bytes, covered_bytes, total_bytes, diff --git a/src/prettify.test.ts b/src/prettify.test.ts new file mode 100644 index 0000000..5fde535 --- /dev/null +++ b/src/prettify.test.ts @@ -0,0 +1,10 @@ +import { test, expect } from '@playwright/test' +import { prettify } from './prettify' + +test('simple range at start', () => {}) +test('simple range at middle', () => {}) +test('simple range at end', () => {}) + +test('atrule at start', () => {}) +test('atrule at middle', () => {}) +test('atrule at end', () => {}) diff --git a/src/prettify.ts b/src/prettify.ts index d05e6f6..1d1c783 100644 --- a/src/prettify.ts +++ b/src/prettify.ts @@ -9,7 +9,7 @@ export type PrettifiedChunk = ChunkedCoverage['chunks'][0] & { css: string } -export type PrettifiedCoverage = Coverage & { +export type PrettifiedCoverage = Omit & { chunks: PrettifiedChunk[] }