@projectwallace/css-code-coverage 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -13,7 +13,8 @@ const show_uncovered_options = {
13
13
  };
14
14
  const reporters = {
15
15
  pretty: "pretty",
16
- tap: "tap"
16
+ tap: "tap",
17
+ json: "json"
17
18
  };
18
19
  let CoverageDirSchema = v.pipe(v.string(), v.nonEmpty());
19
20
  let RatioPercentageSchema = v.pipe(v.string(), v.transform(Number), v.number(), v.minValue(0), v.maxValue(1));
@@ -21,8 +22,8 @@ let ShowUncoveredSchema = v.pipe(v.string(), v.enum(show_uncovered_options));
21
22
  let ReporterSchema = v.pipe(v.string(), v.enum(reporters));
22
23
  let CliArgumentsSchema = v.object({
23
24
  "coverage-dir": CoverageDirSchema,
24
- "min-line-coverage": RatioPercentageSchema,
25
- "min-file-line-coverage": v.optional(RatioPercentageSchema),
25
+ "min-coverage": RatioPercentageSchema,
26
+ "min-file-coverage": v.optional(RatioPercentageSchema),
26
27
  "show-uncovered": v.optional(ShowUncoveredSchema, show_uncovered_options.violations),
27
28
  reporter: v.optional(ReporterSchema, reporters.pretty)
28
29
  });
@@ -44,11 +45,10 @@ function validate_arguments(args) {
44
45
  function parse_arguments(args) {
45
46
  let { values } = parseArgs({
46
47
  args,
47
- allowPositionals: true,
48
48
  options: {
49
49
  "coverage-dir": { type: "string" },
50
- "min-line-coverage": { type: "string" },
51
- "min-file-line-coverage": {
50
+ "min-coverage": { type: "string" },
51
+ "min-file-coverage": {
52
52
  type: "string",
53
53
  default: "0"
54
54
  },
@@ -90,19 +90,20 @@ function parse_coverage(input) {
90
90
 
91
91
  //#endregion
92
92
  //#region src/lib/chunkify.ts
93
+ const WHITESPACE_ONLY_REGEX = /^\s+$/;
93
94
  function merge(stylesheet) {
94
95
  let new_chunks = [];
95
96
  let previous_chunk;
96
97
  for (let i = 0; i < stylesheet.chunks.length; i++) {
97
98
  let chunk = stylesheet.chunks.at(i);
98
- if (/^\s+$/.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset))) continue;
99
+ if (WHITESPACE_ONLY_REGEX.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset))) continue;
99
100
  let latest_chunk = new_chunks.at(-1);
100
101
  if (i > 0 && previous_chunk && latest_chunk) {
101
102
  if (previous_chunk.is_covered === chunk.is_covered) {
102
103
  latest_chunk.end_offset = chunk.end_offset;
103
104
  previous_chunk = chunk;
104
105
  continue;
105
- } else if (/^\s+$/.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset)) || chunk.end_offset === chunk.start_offset) {
106
+ } else if (WHITESPACE_ONLY_REGEX.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset)) || chunk.end_offset === chunk.start_offset) {
106
107
  latest_chunk.end_offset = chunk.end_offset;
107
108
  continue;
108
109
  }
@@ -152,10 +153,10 @@ function prettify(stylesheet) {
152
153
  let line = 1;
153
154
  let offset = 0;
154
155
  let pretty_chunks = stylesheet.chunks.map((chunk, index) => {
155
- let css = format(stylesheet.text.slice(chunk.start_offset, chunk.end_offset - 1)).trim();
156
+ let css = format(stylesheet.text.substring(chunk.start_offset, chunk.end_offset - 1)).trim();
156
157
  if (chunk.is_covered) {
157
- let is_last = index === stylesheet.chunks.length - 1;
158
- if (index === 0) css = css + (is_last ? "" : "\n");
158
+ let is_last_chunk = index === stylesheet.chunks.length - 1;
159
+ if (index === 0) css = css + (is_last_chunk ? "" : "\n");
159
160
  else if (index === stylesheet.chunks.length - 1) css = "\n" + css;
160
161
  else css = "\n" + css + "\n";
161
162
  }
@@ -185,65 +186,46 @@ function prettify(stylesheet) {
185
186
 
186
187
  //#endregion
187
188
  //#region src/lib/decuplicate.ts
188
- function concatenate(ranges) {
189
- let result = [];
190
- for (let range of ranges) if (result.length > 0 && (result.at(-1).end === range.start - 1 || result.at(-1).end === range.start)) result.at(-1).end = range.end;
191
- else result.push(range);
192
- return result;
193
- }
194
- function dedupe_list(ranges) {
195
- let new_ranges = /* @__PURE__ */ new Set();
196
- outer: for (let range of ranges) {
197
- for (let processed_range of new_ranges) {
198
- if (range.start <= processed_range.start && range.end >= processed_range.end) {
199
- new_ranges.delete(processed_range);
200
- new_ranges.add(range);
201
- continue outer;
202
- }
203
- if (range.start >= processed_range.start && range.end <= processed_range.end) continue outer;
204
- if (range.start < processed_range.end && range.start > processed_range.start && range.end > processed_range.end) {
205
- new_ranges.delete(processed_range);
206
- new_ranges.add({
207
- start: processed_range.start,
208
- end: range.end
209
- });
210
- continue outer;
211
- }
189
+ function merge_ranges(ranges) {
190
+ if (ranges.length === 0) return [];
191
+ ranges.sort((a, b) => a.start - b.start);
192
+ let merged = [ranges[0]];
193
+ for (let r of ranges.slice(1)) {
194
+ let last = merged.at(-1);
195
+ if (last && r.start <= last.end + 1) {
196
+ if (r.end > last.end) last.end = r.end;
197
+ } else merged.push({
198
+ start: r.start,
199
+ end: r.end
200
+ });
201
+ }
202
+ return merged;
203
+ }
204
+ function merge_entry_ranges(sheet, entry) {
205
+ if (!sheet) return {
206
+ url: entry.url,
207
+ ranges: [...entry.ranges]
208
+ };
209
+ let seen = new Set(sheet.ranges.map((r) => `${r.start}:${r.end}`));
210
+ for (let range of entry.ranges) {
211
+ let id = `${range.start}:${range.end}`;
212
+ if (!seen.has(id)) {
213
+ seen.add(id);
214
+ sheet.ranges.push({ ...range });
212
215
  }
213
- new_ranges.add(range);
214
216
  }
215
- return new_ranges;
217
+ return sheet;
216
218
  }
217
- /**
218
- * @description
219
- * prerequisites
220
- * - we check each stylesheet content only once (to avoid counting the same content multiple times)
221
- * - if a duplicate stylesheet enters the room, we add it's ranges to the existing stylesheet's ranges
222
- * - only bytes of deduplicated stylesheets are counted
223
- */
224
219
  function deduplicate_entries(entries) {
225
- let checked_stylesheets = /* @__PURE__ */ new Map();
226
- for (let entry of entries) {
227
- let text = entry.text;
228
- if (checked_stylesheets.has(text)) {
229
- let ranges = checked_stylesheets.get(text).ranges;
230
- for (let range of entry.ranges) {
231
- let found = false;
232
- for (let checked_range of ranges) if (checked_range.start === range.start && checked_range.end === range.end) {
233
- found = true;
234
- break;
235
- }
236
- if (!found) ranges.push(range);
237
- }
238
- } else checked_stylesheets.set(text, {
239
- url: entry.url,
240
- ranges: entry.ranges
241
- });
242
- }
243
- return Array.from(checked_stylesheets, ([text, { url, ranges }]) => ({
220
+ let grouped = entries.reduce((acc, entry) => {
221
+ let key = entry.text;
222
+ acc[key] = merge_entry_ranges(acc[key], entry);
223
+ return acc;
224
+ }, Object.create(null));
225
+ return Object.entries(grouped).map(([text, { url, ranges }]) => ({
244
226
  text,
245
227
  url,
246
- ranges: concatenate(dedupe_list(ranges.sort((a, b) => a.start - b.start))).sort((a, b) => a.start - b.start)
228
+ ranges: merge_ranges(ranges)
247
229
  }));
248
230
  }
249
231
 
@@ -336,31 +318,31 @@ function is_js_like(text) {
336
318
  return false;
337
319
  }
338
320
  }
339
- function filter_coverage(coverage) {
340
- let result = [];
341
- for (let entry of coverage) {
342
- let extension = ext(entry.url).toLowerCase();
343
- if (extension === "js") continue;
344
- if (extension === "css") {
345
- result.push(entry);
346
- continue;
347
- }
348
- if (is_html(entry.text)) {
349
- let { css, ranges } = remap_html(entry.text, entry.ranges);
350
- result.push({
351
- url: entry.url,
352
- text: css,
353
- ranges
354
- });
355
- continue;
356
- }
357
- if (is_css_like(entry.text) && !is_js_like(entry.text)) result.push({
321
+ function filter_coverage(acc, entry) {
322
+ let extension = ext(entry.url).toLowerCase();
323
+ if (extension === "js") return acc;
324
+ if (extension === "css") {
325
+ acc.push(entry);
326
+ return acc;
327
+ }
328
+ if (is_html(entry.text)) {
329
+ let { css, ranges } = remap_html(entry.text, entry.ranges);
330
+ acc.push({
331
+ url: entry.url,
332
+ text: css,
333
+ ranges
334
+ });
335
+ return acc;
336
+ }
337
+ if (is_css_like(entry.text) && !is_js_like(entry.text)) {
338
+ acc.push({
358
339
  url: entry.url,
359
340
  text: entry.text,
360
341
  ranges: entry.ranges
361
342
  });
343
+ return acc;
362
344
  }
363
- return result;
345
+ return acc;
364
346
  }
365
347
 
366
348
  //#endregion
@@ -368,38 +350,37 @@ function filter_coverage(coverage) {
368
350
  const AT_SIGN = 64;
369
351
  const LONGEST_ATRULE_NAME = 28;
370
352
  function extend_ranges(coverage) {
371
- return coverage.map(({ text, ranges, url }) => {
372
- return {
373
- text,
374
- ranges: ranges.map((range, index) => {
375
- let prev_range = ranges[index - 1];
376
- for (let i = range.start; i >= range.start - LONGEST_ATRULE_NAME; i--) {
377
- if (prev_range && prev_range.end > i) break;
378
- let char_position = i;
379
- if (text.charCodeAt(char_position) === AT_SIGN) {
380
- range.start = char_position;
381
- let next_offset = range.end;
382
- let next_char$1 = text.charAt(next_offset);
383
- while (/\s/.test(next_char$1)) {
384
- next_offset++;
385
- next_char$1 = text.charAt(next_offset);
386
- }
387
- if (next_char$1 === "{") range.end = range.end + 1;
388
- break;
353
+ let { ranges, url, text } = coverage;
354
+ return {
355
+ text,
356
+ ranges: ranges.map((range, index) => {
357
+ let prev_range = ranges[index - 1];
358
+ for (let i = range.start; i >= range.start - LONGEST_ATRULE_NAME; i--) {
359
+ if (prev_range && prev_range.end > i) break;
360
+ let char_position = i;
361
+ if (text.charCodeAt(char_position) === AT_SIGN) {
362
+ range.start = char_position;
363
+ let next_offset = range.end;
364
+ let next_char$1 = text.charAt(next_offset);
365
+ while (/\s/.test(next_char$1)) {
366
+ next_offset++;
367
+ next_char$1 = text.charAt(next_offset);
389
368
  }
369
+ if (next_char$1 === "{") range.end = range.end + 1;
370
+ break;
390
371
  }
391
- let offset = range.end;
392
- let next_char = text.charAt(offset);
393
- while (/\s/.test(next_char)) {
394
- offset++;
395
- next_char = text.charAt(offset);
396
- }
397
- if (next_char === "}") range.end = offset + 1;
398
- return range;
399
- }),
400
- url
401
- };
402
- });
372
+ }
373
+ let offset = range.end;
374
+ let next_char = text.charAt(offset);
375
+ while (/\s/.test(next_char)) {
376
+ offset++;
377
+ next_char = text.charAt(offset);
378
+ }
379
+ if (next_char === "}") range.end = offset + 1;
380
+ return range;
381
+ }),
382
+ url
383
+ };
403
384
  }
404
385
 
405
386
  //#endregion
@@ -442,20 +423,9 @@ function calculate_stylesheet_coverage({ text, url, chunks }) {
442
423
  chunks
443
424
  };
444
425
  }
445
- /**
446
- * @description
447
- * CSS Code Coverage calculation
448
- *
449
- * These are the steps performed to calculate coverage:
450
- * 1. Filter eligible files / validate input
451
- * 2. Prettify the CSS dicovered in each Coverage and update their ranges
452
- * 3. De-duplicate Coverages: merge all ranges for CSS sources occurring multiple times
453
- * 4. Calculate used/unused CSS bytes (fastest path, no inspection of the actual CSS needed)
454
- * 5. Calculate line-coverage, byte-coverage per stylesheet
455
- */
456
426
  function calculate_coverage(coverage) {
457
427
  let total_files_found = coverage.length;
458
- let coverage_per_stylesheet = extend_ranges(deduplicate_entries(filter_coverage(coverage))).map((sheet) => chunkify(sheet)).map((sheet) => prettify(sheet)).map((stylesheet) => calculate_stylesheet_coverage(stylesheet));
428
+ let coverage_per_stylesheet = coverage.reduce((acc, entry) => filter_coverage(acc, entry), []).reduce((entries, entry) => deduplicate_entries(entries.concat(entry)), []).map((coverage$1) => extend_ranges(coverage$1)).map((sheet) => chunkify(sheet)).map((sheet) => prettify(sheet)).map((stylesheet) => calculate_stylesheet_coverage(stylesheet));
459
429
  let { total_lines, total_covered_lines, total_uncovered_lines, total_bytes, total_used_bytes, total_unused_bytes } = coverage_per_stylesheet.reduce((totals, sheet) => {
460
430
  totals.total_lines += sheet.total_lines;
461
431
  totals.total_covered_lines += sheet.covered_lines;
@@ -489,11 +459,6 @@ function calculate_coverage(coverage) {
489
459
 
490
460
  //#endregion
491
461
  //#region src/cli/program.ts
492
- var MissingDataError = class extends Error {
493
- constructor() {
494
- super("No data to analyze");
495
- }
496
- };
497
462
  function validate_min_line_coverage(actual, expected) {
498
463
  return {
499
464
  ok: actual >= expected,
@@ -513,17 +478,16 @@ function validate_min_file_line_coverage(actual, expected) {
513
478
  expected
514
479
  };
515
480
  }
516
- function program({ min_file_coverage, min_file_line_coverage }, coverage_data) {
517
- if (coverage_data.length === 0) throw new MissingDataError();
481
+ function program({ min_coverage, min_file_coverage }, coverage_data) {
518
482
  let coverage = calculate_coverage(coverage_data);
519
- let min_line_coverage_result = validate_min_line_coverage(coverage.line_coverage_ratio, min_file_coverage);
520
- let min_file_line_coverage_result = validate_min_file_line_coverage(Math.min(...coverage.coverage_per_stylesheet.map((sheet) => sheet.line_coverage_ratio)), min_file_line_coverage);
483
+ let min_coverage_result = validate_min_line_coverage(coverage.line_coverage_ratio, min_coverage);
484
+ let min_file_coverage_result = validate_min_file_line_coverage(Math.min(...coverage.coverage_per_stylesheet.map((sheet) => sheet.line_coverage_ratio)), min_file_coverage);
521
485
  return {
522
486
  context: { coverage },
523
487
  report: {
524
- ok: min_line_coverage_result.ok && min_file_line_coverage_result.ok,
525
- min_line_coverage: min_line_coverage_result,
526
- min_file_line_coverage: min_file_line_coverage_result
488
+ ok: min_coverage_result.ok && min_file_coverage_result.ok,
489
+ min_line_coverage: min_coverage_result,
490
+ min_file_line_coverage: min_file_coverage_result
527
491
  }
528
492
  };
529
493
  }
@@ -554,7 +518,12 @@ function percentage(ratio$1, decimals = 2) {
554
518
  function print_lines({ report, context }, params, { styleText: styleText$1, print_width }) {
555
519
  let output = [];
556
520
  if (report.min_line_coverage.ok) output.push(`${styleText$1(["bold", "green"], "Success")}: total line coverage is ${percentage(report.min_line_coverage.actual)}`);
557
- else output.push(`${styleText$1(["bold", "red"], "Failed")}: line coverage is ${percentage(report.min_line_coverage.actual)}% which is lower than the threshold of ${report.min_line_coverage.expected}`);
521
+ else {
522
+ let { actual, expected } = report.min_line_coverage;
523
+ output.push(`${styleText$1(["bold", "red"], "Failed")}: line coverage is ${percentage(actual)}% which is lower than the threshold of ${expected}`);
524
+ let lines_to_cover = expected * context.coverage.total_lines - context.coverage.covered_lines;
525
+ output.push(`Tip: cover ${Math.ceil(lines_to_cover)} more ${lines_to_cover === 1 ? "line" : "lines"} to meet the threshold of ${percentage(expected)}`);
526
+ }
558
527
  if (report.min_file_line_coverage.expected !== void 0) {
559
528
  let { expected, actual, ok: ok$1 } = report.min_file_line_coverage;
560
529
  if (ok$1) output.push(`${styleText$1(["bold", "green"], "Success")}: all files pass minimum line coverage of ${percentage(expected)}`);
@@ -569,6 +538,7 @@ function print_lines({ report, context }, params, { styleText: styleText$1, prin
569
538
  const NUM_TRAILING_LINES = NUM_LEADING_LINES;
570
539
  print_width = print_width ?? 80;
571
540
  let min_file_line_coverage = report.min_file_line_coverage.expected;
541
+ output.push();
572
542
  for (let sheet of context.coverage.coverage_per_stylesheet.sort((a, b) => a.line_coverage_ratio - b.line_coverage_ratio)) if (sheet.line_coverage_ratio !== 1 && params["show-uncovered"] === "all" || min_file_line_coverage !== void 0 && min_file_line_coverage !== 0 && sheet.line_coverage_ratio < min_file_line_coverage && params["show-uncovered"] === "violations") {
573
543
  output.push();
574
544
  output.push(styleText$1("dim", "─".repeat(print_width)));
@@ -591,10 +561,11 @@ function print_lines({ report, context }, params, { styleText: styleText$1, prin
591
561
  return output;
592
562
  }
593
563
  function print(report, params) {
564
+ let logger = report.report.ok ? console.log : console.error;
594
565
  for (let line of print_lines(report, params, {
595
566
  styleText,
596
567
  print_width: process.stdout.columns
597
- })) console.log(line);
568
+ })) logger(line);
598
569
  }
599
570
 
600
571
  //#endregion
@@ -609,7 +580,7 @@ function ok(n, description) {
609
580
  console.log(`ok ${n} ${description ? `- ${description}` : ""}`);
610
581
  }
611
582
  function not_ok(n, description) {
612
- console.log(`not ok ${n} ${description ? `- ${description}` : ""}`);
583
+ console.error(`not ok ${n} ${description ? `- ${description}` : ""}`);
613
584
  }
614
585
  function meta(data) {
615
586
  console.log(" ---");
@@ -653,17 +624,79 @@ function print$1({ report, context }, params) {
653
624
  }
654
625
  }
655
626
 
627
+ //#endregion
628
+ //#region src/cli/reporters/json.ts
629
+ function prepare({ report, context }, params) {
630
+ context.coverage.coverage_per_stylesheet = context.coverage.coverage_per_stylesheet.filter((sheet) => {
631
+ if (params["show-uncovered"] === "violations" && report.min_file_line_coverage.expected !== void 0 && sheet.line_coverage_ratio < report.min_file_line_coverage.expected) return true;
632
+ if (params["show-uncovered"] === "all" && sheet.line_coverage_ratio < 1) return true;
633
+ return false;
634
+ });
635
+ return {
636
+ report,
637
+ context
638
+ };
639
+ }
640
+ function print$2({ report, context }, params) {
641
+ let logger = report.ok ? console.log : console.error;
642
+ let data = prepare({
643
+ context,
644
+ report
645
+ }, params);
646
+ logger(JSON.stringify(data));
647
+ }
648
+
649
+ //#endregion
650
+ //#region src/cli/help.ts
651
+ function help() {
652
+ return `
653
+ ${styleText(["bold"], "USAGE")}
654
+ $ css-coverage --coverage-dir=<dir> --min-coverage=<number> [options]
655
+
656
+ ${styleText("bold", "OPTIONS")}
657
+ Required:
658
+ --coverage-dir Where your Coverage JSON files are
659
+ --min-coverage Minimum overall CSS coverage [0-1]
660
+
661
+ Optional:
662
+ --min-file-coverage Minimal coverage per file [0-1]
663
+
664
+ --show-uncovered Which files to show when not meeting
665
+ the --min-file-line-coverage threshold
666
+ • violations [default] ${styleText("dim", "show under-threshold files")}
667
+ • all ${styleText("dim", "show partially covered files")}
668
+ • none ${styleText("dim", "do not show files")}
669
+
670
+ --reporter How to show the results
671
+ • pretty [default]
672
+ • tap
673
+ • json
674
+
675
+ ${styleText("bold", "EXAMPLES")}
676
+ ${styleText("dim", "# analyze all .json files in ./coverage; require 80% overall coverage")}
677
+ $ css-coverage --coverage-dir=./coverage --min-coverage=0.8
678
+
679
+ ${styleText("dim", "# Require 50% coverage per file")}
680
+ $ css-coverage --coverage-dir=./coverage --min-coverage=0.8 --min-file-coverage=0.5
681
+
682
+ ${styleText("dim", "Report JSON")}
683
+ $ css-coverage --coverage-dir=./coverage --min-coverage=0.8 --reporter=json
684
+ `.trim();
685
+ }
686
+
656
687
  //#endregion
657
688
  //#region src/cli/cli.ts
658
689
  async function cli(cli_args) {
690
+ if (!cli_args || cli_args.length === 0 || cli_args.includes("--help") || cli_args.includes("-h")) return console.log(help());
659
691
  let params = validate_arguments(parse_arguments(cli_args));
660
692
  let coverage_data = await read(params["coverage-dir"]);
661
693
  let report = program({
662
- min_file_coverage: params["min-line-coverage"],
663
- min_file_line_coverage: params["min-file-line-coverage"]
694
+ min_coverage: params["min-coverage"],
695
+ min_file_coverage: params["min-file-coverage"]
664
696
  }, coverage_data);
665
697
  if (report.report.ok === false) process.exitCode = 1;
666
698
  if (params.reporter === "tap") return print$1(report, params);
699
+ if (params.reporter === "json") return print$2(report, params);
667
700
  return print(report, params);
668
701
  }
669
702
  try {
package/dist/index.d.ts CHANGED
@@ -63,17 +63,6 @@ type CoverageResult = CoverageData & {
63
63
  total_stylesheets: number;
64
64
  coverage_per_stylesheet: StylesheetCoverage[];
65
65
  };
66
- /**
67
- * @description
68
- * CSS Code Coverage calculation
69
- *
70
- * These are the steps performed to calculate coverage:
71
- * 1. Filter eligible files / validate input
72
- * 2. Prettify the CSS dicovered in each Coverage and update their ranges
73
- * 3. De-duplicate Coverages: merge all ranges for CSS sources occurring multiple times
74
- * 4. Calculate used/unused CSS bytes (fastest path, no inspection of the actual CSS needed)
75
- * 5. Calculate line-coverage, byte-coverage per stylesheet
76
- */
77
66
  declare function calculate_coverage(coverage: Coverage[]): CoverageResult;
78
67
  //#endregion
79
68
  export { type Coverage, CoverageData, CoverageResult, type Range, StylesheetCoverage, calculate_coverage, parse_coverage };
package/dist/index.js CHANGED
@@ -25,19 +25,20 @@ function parse_coverage(input) {
25
25
 
26
26
  //#endregion
27
27
  //#region src/lib/chunkify.ts
28
+ const WHITESPACE_ONLY_REGEX = /^\s+$/;
28
29
  function merge(stylesheet) {
29
30
  let new_chunks = [];
30
31
  let previous_chunk;
31
32
  for (let i = 0; i < stylesheet.chunks.length; i++) {
32
33
  let chunk = stylesheet.chunks.at(i);
33
- if (/^\s+$/.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset))) continue;
34
+ if (WHITESPACE_ONLY_REGEX.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset))) continue;
34
35
  let latest_chunk = new_chunks.at(-1);
35
36
  if (i > 0 && previous_chunk && latest_chunk) {
36
37
  if (previous_chunk.is_covered === chunk.is_covered) {
37
38
  latest_chunk.end_offset = chunk.end_offset;
38
39
  previous_chunk = chunk;
39
40
  continue;
40
- } else if (/^\s+$/.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset)) || chunk.end_offset === chunk.start_offset) {
41
+ } else if (WHITESPACE_ONLY_REGEX.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset)) || chunk.end_offset === chunk.start_offset) {
41
42
  latest_chunk.end_offset = chunk.end_offset;
42
43
  continue;
43
44
  }
@@ -87,10 +88,10 @@ function prettify(stylesheet) {
87
88
  let line = 1;
88
89
  let offset = 0;
89
90
  let pretty_chunks = stylesheet.chunks.map((chunk, index) => {
90
- let css = format(stylesheet.text.slice(chunk.start_offset, chunk.end_offset - 1)).trim();
91
+ let css = format(stylesheet.text.substring(chunk.start_offset, chunk.end_offset - 1)).trim();
91
92
  if (chunk.is_covered) {
92
- let is_last = index === stylesheet.chunks.length - 1;
93
- if (index === 0) css = css + (is_last ? "" : "\n");
93
+ let is_last_chunk = index === stylesheet.chunks.length - 1;
94
+ if (index === 0) css = css + (is_last_chunk ? "" : "\n");
94
95
  else if (index === stylesheet.chunks.length - 1) css = "\n" + css;
95
96
  else css = "\n" + css + "\n";
96
97
  }
@@ -120,65 +121,46 @@ function prettify(stylesheet) {
120
121
 
121
122
  //#endregion
122
123
  //#region src/lib/decuplicate.ts
123
- function concatenate(ranges) {
124
- let result = [];
125
- for (let range of ranges) if (result.length > 0 && (result.at(-1).end === range.start - 1 || result.at(-1).end === range.start)) result.at(-1).end = range.end;
126
- else result.push(range);
127
- return result;
124
+ function merge_ranges(ranges) {
125
+ if (ranges.length === 0) return [];
126
+ ranges.sort((a, b) => a.start - b.start);
127
+ let merged = [ranges[0]];
128
+ for (let r of ranges.slice(1)) {
129
+ let last = merged.at(-1);
130
+ if (last && r.start <= last.end + 1) {
131
+ if (r.end > last.end) last.end = r.end;
132
+ } else merged.push({
133
+ start: r.start,
134
+ end: r.end
135
+ });
136
+ }
137
+ return merged;
128
138
  }
129
- function dedupe_list(ranges) {
130
- let new_ranges = /* @__PURE__ */ new Set();
131
- outer: for (let range of ranges) {
132
- for (let processed_range of new_ranges) {
133
- if (range.start <= processed_range.start && range.end >= processed_range.end) {
134
- new_ranges.delete(processed_range);
135
- new_ranges.add(range);
136
- continue outer;
137
- }
138
- if (range.start >= processed_range.start && range.end <= processed_range.end) continue outer;
139
- if (range.start < processed_range.end && range.start > processed_range.start && range.end > processed_range.end) {
140
- new_ranges.delete(processed_range);
141
- new_ranges.add({
142
- start: processed_range.start,
143
- end: range.end
144
- });
145
- continue outer;
146
- }
139
+ function merge_entry_ranges(sheet, entry) {
140
+ if (!sheet) return {
141
+ url: entry.url,
142
+ ranges: [...entry.ranges]
143
+ };
144
+ let seen = new Set(sheet.ranges.map((r) => `${r.start}:${r.end}`));
145
+ for (let range of entry.ranges) {
146
+ let id = `${range.start}:${range.end}`;
147
+ if (!seen.has(id)) {
148
+ seen.add(id);
149
+ sheet.ranges.push({ ...range });
147
150
  }
148
- new_ranges.add(range);
149
151
  }
150
- return new_ranges;
152
+ return sheet;
151
153
  }
152
- /**
153
- * @description
154
- * prerequisites
155
- * - we check each stylesheet content only once (to avoid counting the same content multiple times)
156
- * - if a duplicate stylesheet enters the room, we add it's ranges to the existing stylesheet's ranges
157
- * - only bytes of deduplicated stylesheets are counted
158
- */
159
154
  function deduplicate_entries(entries) {
160
- let checked_stylesheets = /* @__PURE__ */ new Map();
161
- for (let entry of entries) {
162
- let text = entry.text;
163
- if (checked_stylesheets.has(text)) {
164
- let ranges = checked_stylesheets.get(text).ranges;
165
- for (let range of entry.ranges) {
166
- let found = false;
167
- for (let checked_range of ranges) if (checked_range.start === range.start && checked_range.end === range.end) {
168
- found = true;
169
- break;
170
- }
171
- if (!found) ranges.push(range);
172
- }
173
- } else checked_stylesheets.set(text, {
174
- url: entry.url,
175
- ranges: entry.ranges
176
- });
177
- }
178
- return Array.from(checked_stylesheets, ([text, { url, ranges }]) => ({
155
+ let grouped = entries.reduce((acc, entry) => {
156
+ let key = entry.text;
157
+ acc[key] = merge_entry_ranges(acc[key], entry);
158
+ return acc;
159
+ }, Object.create(null));
160
+ return Object.entries(grouped).map(([text, { url, ranges }]) => ({
179
161
  text,
180
162
  url,
181
- ranges: concatenate(dedupe_list(ranges.sort((a, b) => a.start - b.start))).sort((a, b) => a.start - b.start)
163
+ ranges: merge_ranges(ranges)
182
164
  }));
183
165
  }
184
166
 
@@ -271,31 +253,31 @@ function is_js_like(text) {
271
253
  return false;
272
254
  }
273
255
  }
274
- function filter_coverage(coverage) {
275
- let result = [];
276
- for (let entry of coverage) {
277
- let extension = ext(entry.url).toLowerCase();
278
- if (extension === "js") continue;
279
- if (extension === "css") {
280
- result.push(entry);
281
- continue;
282
- }
283
- if (is_html(entry.text)) {
284
- let { css, ranges } = remap_html(entry.text, entry.ranges);
285
- result.push({
286
- url: entry.url,
287
- text: css,
288
- ranges
289
- });
290
- continue;
291
- }
292
- if (is_css_like(entry.text) && !is_js_like(entry.text)) result.push({
256
+ function filter_coverage(acc, entry) {
257
+ let extension = ext(entry.url).toLowerCase();
258
+ if (extension === "js") return acc;
259
+ if (extension === "css") {
260
+ acc.push(entry);
261
+ return acc;
262
+ }
263
+ if (is_html(entry.text)) {
264
+ let { css, ranges } = remap_html(entry.text, entry.ranges);
265
+ acc.push({
266
+ url: entry.url,
267
+ text: css,
268
+ ranges
269
+ });
270
+ return acc;
271
+ }
272
+ if (is_css_like(entry.text) && !is_js_like(entry.text)) {
273
+ acc.push({
293
274
  url: entry.url,
294
275
  text: entry.text,
295
276
  ranges: entry.ranges
296
277
  });
278
+ return acc;
297
279
  }
298
- return result;
280
+ return acc;
299
281
  }
300
282
 
301
283
  //#endregion
@@ -303,38 +285,37 @@ function filter_coverage(coverage) {
303
285
  const AT_SIGN = 64;
304
286
  const LONGEST_ATRULE_NAME = 28;
305
287
  function extend_ranges(coverage) {
306
- return coverage.map(({ text, ranges, url }) => {
307
- return {
308
- text,
309
- ranges: ranges.map((range, index) => {
310
- let prev_range = ranges[index - 1];
311
- for (let i = range.start; i >= range.start - LONGEST_ATRULE_NAME; i--) {
312
- if (prev_range && prev_range.end > i) break;
313
- let char_position = i;
314
- if (text.charCodeAt(char_position) === AT_SIGN) {
315
- range.start = char_position;
316
- let next_offset = range.end;
317
- let next_char$1 = text.charAt(next_offset);
318
- while (/\s/.test(next_char$1)) {
319
- next_offset++;
320
- next_char$1 = text.charAt(next_offset);
321
- }
322
- if (next_char$1 === "{") range.end = range.end + 1;
323
- break;
288
+ let { ranges, url, text } = coverage;
289
+ return {
290
+ text,
291
+ ranges: ranges.map((range, index) => {
292
+ let prev_range = ranges[index - 1];
293
+ for (let i = range.start; i >= range.start - LONGEST_ATRULE_NAME; i--) {
294
+ if (prev_range && prev_range.end > i) break;
295
+ let char_position = i;
296
+ if (text.charCodeAt(char_position) === AT_SIGN) {
297
+ range.start = char_position;
298
+ let next_offset = range.end;
299
+ let next_char$1 = text.charAt(next_offset);
300
+ while (/\s/.test(next_char$1)) {
301
+ next_offset++;
302
+ next_char$1 = text.charAt(next_offset);
324
303
  }
304
+ if (next_char$1 === "{") range.end = range.end + 1;
305
+ break;
325
306
  }
326
- let offset = range.end;
327
- let next_char = text.charAt(offset);
328
- while (/\s/.test(next_char)) {
329
- offset++;
330
- next_char = text.charAt(offset);
331
- }
332
- if (next_char === "}") range.end = offset + 1;
333
- return range;
334
- }),
335
- url
336
- };
337
- });
307
+ }
308
+ let offset = range.end;
309
+ let next_char = text.charAt(offset);
310
+ while (/\s/.test(next_char)) {
311
+ offset++;
312
+ next_char = text.charAt(offset);
313
+ }
314
+ if (next_char === "}") range.end = offset + 1;
315
+ return range;
316
+ }),
317
+ url
318
+ };
338
319
  }
339
320
 
340
321
  //#endregion
@@ -377,20 +358,9 @@ function calculate_stylesheet_coverage({ text, url, chunks }) {
377
358
  chunks
378
359
  };
379
360
  }
380
- /**
381
- * @description
382
- * CSS Code Coverage calculation
383
- *
384
- * These are the steps performed to calculate coverage:
385
- * 1. Filter eligible files / validate input
386
- * 2. Prettify the CSS dicovered in each Coverage and update their ranges
387
- * 3. De-duplicate Coverages: merge all ranges for CSS sources occurring multiple times
388
- * 4. Calculate used/unused CSS bytes (fastest path, no inspection of the actual CSS needed)
389
- * 5. Calculate line-coverage, byte-coverage per stylesheet
390
- */
391
361
  function calculate_coverage(coverage) {
392
362
  let total_files_found = coverage.length;
393
- let coverage_per_stylesheet = extend_ranges(deduplicate_entries(filter_coverage(coverage))).map((sheet) => chunkify(sheet)).map((sheet) => prettify(sheet)).map((stylesheet) => calculate_stylesheet_coverage(stylesheet));
363
+ let coverage_per_stylesheet = coverage.reduce((acc, entry) => filter_coverage(acc, entry), []).reduce((entries, entry) => deduplicate_entries(entries.concat(entry)), []).map((coverage$1) => extend_ranges(coverage$1)).map((sheet) => chunkify(sheet)).map((sheet) => prettify(sheet)).map((stylesheet) => calculate_stylesheet_coverage(stylesheet));
394
364
  let { total_lines, total_covered_lines, total_uncovered_lines, total_bytes, total_used_bytes, total_unused_bytes } = coverage_per_stylesheet.reduce((totals, sheet) => {
395
365
  totals.total_lines += sheet.total_lines;
396
366
  totals.total_covered_lines += sheet.covered_lines;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@projectwallace/css-code-coverage",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Generate useful CSS Code Coverage report from browser-reported coverage",
5
5
  "author": "Bart Veneman <bart@projectwallace.com>",
6
6
  "repository": {
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "types": "dist/index.d.ts",
39
39
  "scripts": {
40
- "test": "c8 --reporter=text playwright test",
40
+ "test": "c8 --reporter=text --reporter=lcov playwright test",
41
41
  "build": "tsdown",
42
42
  "check": "tsc --noEmit",
43
43
  "lint": "oxlint --config .oxlintrc.json",
@@ -45,6 +45,7 @@
45
45
  "knip": "knip"
46
46
  },
47
47
  "devDependencies": {
48
+ "@codecov/vite-plugin": "^1.9.1",
48
49
  "@playwright/test": "^1.56.0",
49
50
  "@types/node": "^24.9.2",
50
51
  "c8": "^10.1.3",