From e27f04b8bdd1f67398e1a97540b1ed8311959557 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Mon, 20 Oct 2025 22:35:06 +0200 Subject: [PATCH 01/11] perf: do prettification after deduping --- src/lib/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/index.ts b/src/lib/index.ts index 139e912..c664eb0 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -161,9 +161,9 @@ export async function calculate_coverage(coverage: Coverage[]): Promise calculate_stylesheet_coverage(stylesheet)) + let deduplicated: Coverage[] = deduplicate_entries(filtered_coverage) + let prettified_coverage: Coverage[] = prettify(deduplicated) + let coverage_per_stylesheet = prettified_coverage.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 } = From 328afb407f9cb513dd7d58982a0948de9299442b Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Mon, 20 Oct 2025 23:03:59 +0200 Subject: [PATCH 02/11] sort ranges --- src/lib/decuplicate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/decuplicate.ts b/src/lib/decuplicate.ts index 42260c1..b9201d4 100644 --- a/src/lib/decuplicate.ts +++ b/src/lib/decuplicate.ts @@ -36,5 +36,5 @@ export function deduplicate_entries(entries: Coverage[]): Coverage[] { } } - return Array.from(checked_stylesheets, ([text, { url, ranges }]) => ({ text, url, ranges })) + return Array.from(checked_stylesheets, ([text, { url, ranges }]) => ({ text, url, ranges: ranges.sort((a, b) => a.start - b.start) })) } From 47bb65d7d64c55b53578614e72d3797be31feed4 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Mon, 20 Oct 2025 23:34:27 +0200 Subject: [PATCH 03/11] closing brace can be covered, np --- src/lib/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/index.ts b/src/lib/index.ts index c664eb0..f9e2c86 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -80,7 +80,7 @@ function calculate_stylesheet_coverage({ text, url, ranges }: Coverage) { let prev_is_covered = index > 0 && line_coverage[index - 1] === 1 - if (is_in_range && !is_closing_brace && !is_empty) { + if (is_in_range) { is_covered = true } else if ((is_empty || is_closing_brace) && prev_is_covered) { is_covered = true From f56875c275961b646db9f8ba863ae6cf072d77b7 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Wed, 22 Oct 2025 23:14:35 +0200 Subject: [PATCH 04/11] it works now!!! --- src/lib/chunkify.test.ts | 105 +++++++++++++++ src/lib/chunkify.ts | 104 +++++++++++++++ src/lib/extend-ranges.test.ts | 127 ++++++++++++++++++ src/lib/extend-ranges.ts | 66 ++++++++++ src/lib/index.test.ts | 104 +++++++-------- src/lib/index.ts | 170 ++++++++----------------- src/lib/prettify.test.ts | 78 +----------- src/lib/prettify.ts | 114 ++++++++--------- src/lib/test/generate-coverage.test.ts | 1 + src/lib/test/kitchen-sink.test.ts | 84 +++++++++--- 10 files changed, 631 insertions(+), 322 deletions(-) create mode 100644 src/lib/chunkify.test.ts create mode 100644 src/lib/chunkify.ts create mode 100644 src/lib/extend-ranges.test.ts create mode 100644 src/lib/extend-ranges.ts diff --git a/src/lib/chunkify.test.ts b/src/lib/chunkify.test.ts new file mode 100644 index 0000000..3de5bb4 --- /dev/null +++ b/src/lib/chunkify.test.ts @@ -0,0 +1,105 @@ +import { test, expect } from '@playwright/test' +import { chunkify, 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(coverage) + delete coverage.ranges + 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(coverage) + delete coverage.ranges + 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(coverage) + delete coverage.ranges + 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(coverage) + delete coverage.ranges + expect(result).toEqual({ + ...coverage, + chunks: [ + { + start_offset: 0, + end_offset: 56, + is_covered: false, + }, + ], + } satisfies ChunkedCoverage) +}) diff --git a/src/lib/chunkify.ts b/src/lib/chunkify.ts new file mode 100644 index 0000000..4d930e5 --- /dev/null +++ b/src/lib/chunkify.ts @@ -0,0 +1,104 @@ +import type { Coverage } from './parse-coverage' + +type Chunk = { + start_offset: number + end_offset: number + is_covered: boolean +} + +export type ChunkedCoverage = Omit & { + chunks: Chunk[] +} + +function merge(stylesheet: ChunkedCoverage): ChunkedCoverage { + let new_chunks: Chunk[] = [] + let previous_chunk: Chunk | undefined + + for (let i = 0; i < stylesheet.chunks.length; i++) { + let chunk = stylesheet.chunks.at(i)! + + // If the current chunk is only whitespace or empty, ignore it + if (/^\s+$/.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset))) { + continue + } + + // let previous_chunk = stylesheet.chunks.at(i - 1) + let latest_chunk = new_chunks.at(-1) + + // merge current and previous if they are both covered or uncovered + if (i > 0 && previous_chunk && latest_chunk) { + if (previous_chunk.is_covered === chunk.is_covered) { + latest_chunk.end_offset = chunk.end_offset + // latest_chunk.css = stylesheet.text.slice(latest_chunk.start_offset, chunk.end_offset) + previous_chunk = chunk + continue + } + // If the current chunk is only whitespace or empty, add it to the previous + else if (/^\s+$/.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset)) || chunk.end_offset === chunk.start_offset) { + latest_chunk.end_offset = chunk.end_offset + // latest_chunk.css = stylesheet.text.slice(latest_chunk.start_offset, chunk.end_offset) + // do not update previous_chunk + continue + } + } + + previous_chunk = chunk + new_chunks.push(chunk) + } + + return { + ...stylesheet, + chunks: new_chunks, + } +} + +// TODO: get rid of empty chunks, merge first/last with adjecent covered block + merge chunks +export function chunkify(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, + // css: stylesheet.text.substring(offset, range.start), + }) + offset = range.start + } + + chunks.push({ + start_offset: range.start, + end_offset: range.end, + is_covered: true, + // css: stylesheet.text.substring(range.start, range.end), + }) + offset = range.end + } + + // fill up last chunk if necessary: + if (offset !== stylesheet.text.length - 1) { + chunks.push({ + start_offset: offset, + end_offset: stylesheet.text.length, + is_covered: false, + // css: stylesheet.text.substring(offset, stylesheet.text.length), + }) + } + + // console.log('before merge') + // console.log(chunks) + + let merged = merge({ + url: stylesheet.url, + text: stylesheet.text, + chunks, + }) + + // console.log('after merge') + // console.log(merged.chunks) + + return merged +} diff --git a/src/lib/extend-ranges.test.ts b/src/lib/extend-ranges.test.ts new file mode 100644 index 0000000..d25dd6d --- /dev/null +++ b/src/lib/extend-ranges.test.ts @@ -0,0 +1,127 @@ +import { test, expect } from '@playwright/test' +import { extend_ranges } from './extend-ranges' +import { generate_coverage } from './test/generate-coverage' +import type { Coverage } from './parse-coverage' + +let html = ` + + + + test document + + + +

Hello world

+

Text

+ +` + +test.describe('leaves ranges intact when nothing to change', () => { + test('lonely rule', async () => { + let css = `body{color:green}` + let coverage = (await generate_coverage(html, { link_css: css })) as Coverage[] + + // Expect the incomplete coverage reported by the browser + expect(coverage.at(0)!.ranges).toEqual([{ start: 0, end: 17 }]) + + let result = extend_ranges(coverage) + expect(result.at(0)!.ranges).toEqual([{ start: 0, end: 17 }]) + }) +}) + +test.describe('@rules', () => { + test('lonely @media', async () => { + let css = `@media (min-width:100px){body{color:green}}` + let coverage = (await generate_coverage(html, { link_css: css })) as Coverage[] + + // Expect the incomplete coverage reported by the browser + expect(coverage.at(0)!.ranges).toEqual([{ start: 7, end: 42 }]) // (min-width:100px){body{color:green} + + let result = extend_ranges(coverage) + expect(result.at(0)!.ranges).toEqual([{ start: 0, end: 43 }]) // @media (min-width:100px){body{color:green}} + }) + + test.describe('adjecent to uncovered code', () => { + test('@media at end', async () => { + let css = `a{}@media (min-width:100px){body{color:green}}` + let coverage = (await generate_coverage(html, { link_css: css })) as Coverage[] + + // Expect the incomplete coverage reported by the browser + expect(coverage.at(0)!.ranges).toEqual([{ start: 10, end: 45 }]) // (min-width:100px){body{color:green} + + let result = extend_ranges(coverage) + expect(result.at(0)!.ranges).toEqual([{ start: 3, end: 46 }]) // @media (min-width:100px){body{color:green}} + }) + + test('@media at start', async () => { + let css = `@media (min-width:100px){body{color:green}}a{}` + let coverage = (await generate_coverage(html, { link_css: css })) as Coverage[] + + // Expect the incomplete coverage reported by the browser + expect(coverage.at(0)!.ranges).toEqual([{ start: 7, end: 42 }]) // (min-width:100px){body{color:green} + + let result = extend_ranges(coverage) + expect(result.at(0)!.ranges).toEqual([{ start: 0, end: 43 }]) // @media (min-width:100px){body{color:green}} + }) + }) + + test.describe('adjecent to covered code', () => { + test('@media at end', async () => { + let css = `p{color:red}@media (min-width:100px){body{color:green}}` + let coverage = (await generate_coverage(html, { link_css: css })) as Coverage[] + + // Expect the incomplete coverage reported by the browser + expect(coverage).toEqual([ + { + url: 'http://localhost/style.css', + text: css, + ranges: [ + { start: 0, end: 12 }, // p{color:red} + { start: 19, end: 54 }, // (min-width:100px){body{color:green} + ], + }, + ]) + + let result = extend_ranges(coverage) + expect(result).toEqual([ + { + url: 'http://localhost/style.css', + text: css, + ranges: [ + { start: 0, end: 12 }, // p{color:red} + { start: 12, end: 55 }, // @media (min-width:100px){body{color:green}} + ], + }, + ]) + }) + + test('@media at start', async () => { + let css = `@media (min-width:100px){body{color:green}}p{color:red}` + let coverage = (await generate_coverage(html, { link_css: css })) as Coverage[] + + // Expect the incomplete coverage reported by the browser + expect(coverage).toEqual([ + { + url: 'http://localhost/style.css', + text: css, + ranges: [ + { start: 7, end: 42 }, // (min-width:100px){body{color:green} + { start: 43, end: 55 }, // p{color:red} + ], + }, + ]) + + let result = extend_ranges(coverage) + expect(result).toEqual([ + { + url: 'http://localhost/style.css', + text: css, + ranges: [ + { start: 0, end: 43 }, // @media (min-width:100px){body{color:green}} + { start: 43, end: 55 }, // p{color:red} + ], + }, + ]) + }) + }) +}) diff --git a/src/lib/extend-ranges.ts b/src/lib/extend-ranges.ts new file mode 100644 index 0000000..9dd5174 --- /dev/null +++ b/src/lib/extend-ranges.ts @@ -0,0 +1,66 @@ +import type { Coverage } from './parse-coverage' + +const AT_SIGN = 64 +const LONGEST_ATRULE_NAME = '@-webkit-font-feature-values'.length + +export function extend_ranges(coverage: Coverage[]): Coverage[] { + return coverage.map(({ text, ranges, url }) => { + // Adjust ranges to include @-rule name (only preludes included) + // Cannot reliably include closing } because it may not be the end of the range + + console.log('Before extending') + console.log({ + ranges: ranges.map((r) => ({ + ...r, + text: text.slice(r.start, r.end), + })), + }) + console.log() + + for (let range of ranges) { + // Add @atrule-name to the front of the range + // Heuristic: atrule names are no longer than 20 characters ('@font-palette-values'.length === 20) + for (let i = 1; i >= -LONGEST_ATRULE_NAME; i--) { + let char_position = range.start + i + if (text.charCodeAt(char_position) === AT_SIGN) { + // Move the start cursor back to the start of the @-sign + range.start = char_position + + // Look if the next character might be the opening { of the atrule's block + // First eat all the whitespace that might be in-between + let next_offset = range.end + let next_char = text.charAt(next_offset) + + while (/\s/.test(next_char)) { + next_offset++ + next_char = text.charAt(next_offset) + } + + if (next_char === '{') { + range.end = range.end + 1 + } + break + } + } + + let offset = range.end + let next_char = text.charAt(offset) + while (/\s/.test(next_char)) { + offset++ + next_char = text.charAt(offset) + } + if (next_char === '}') { + range.end = offset + 1 + } + } + + console.log('EXTENDED RANGES') + console.log({ + ranges: ranges.map((r) => ({ + ...r, + text: text.slice(r.start, r.end), + })), + }) + return { text, ranges, url } + }) +} diff --git a/src/lib/index.test.ts b/src/lib/index.test.ts index 83e634d..6dd2d7a 100644 --- a/src/lib/index.test.ts +++ b/src/lib/index.test.ts @@ -30,13 +30,13 @@ test.describe('from