@projectwallace/css-code-coverage 0.7.0 → 0.8.2

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/README.md CHANGED
@@ -87,3 +87,13 @@ for (let file of files) {
87
87
  coverage_data.push(...parse_coverage(json_content))
88
88
  }
89
89
  ```
90
+
91
+ ## CLI
92
+
93
+ Use the CLI tool (`css-coverage`) to check if coverage meets minimum requirements, globally and/or per file.
94
+
95
+ ```sh
96
+ css-coverage --coverage-dir=<dir> --min-coverage=<number> [options]
97
+ ```
98
+
99
+ [CLI docs](src/cli/README.md)
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs, styleText } from "node:util";
3
3
  import * as v from "valibot";
4
- import { format } from "@projectwallace/format-css";
4
+ import { calculate_coverage } from "@projectwallace/css-code-coverage";
5
5
  import { readFile, readdir, stat } from "node:fs/promises";
6
6
  import { join } from "node:path";
7
7
 
@@ -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
  },
@@ -65,435 +65,8 @@ function parse_arguments(args) {
65
65
  return values;
66
66
  }
67
67
 
68
- //#endregion
69
- //#region src/lib/parse-coverage.ts
70
- let RangeSchema = v.object({
71
- start: v.number(),
72
- end: v.number()
73
- });
74
- let CoverageSchema = v.object({
75
- text: v.string(),
76
- url: v.string(),
77
- ranges: v.array(RangeSchema)
78
- });
79
- function is_valid_coverage(input) {
80
- return v.safeParse(v.array(CoverageSchema), input).success;
81
- }
82
- function parse_coverage(input) {
83
- try {
84
- let parse_result = JSON.parse(input);
85
- return is_valid_coverage(parse_result) ? parse_result : [];
86
- } catch {
87
- return [];
88
- }
89
- }
90
-
91
- //#endregion
92
- //#region src/lib/chunkify.ts
93
- function merge(stylesheet) {
94
- let new_chunks = [];
95
- let previous_chunk;
96
- for (let i = 0; i < stylesheet.chunks.length; i++) {
97
- let chunk = stylesheet.chunks.at(i);
98
- if (/^\s+$/.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset))) continue;
99
- let latest_chunk = new_chunks.at(-1);
100
- if (i > 0 && previous_chunk && latest_chunk) {
101
- if (previous_chunk.is_covered === chunk.is_covered) {
102
- latest_chunk.end_offset = chunk.end_offset;
103
- previous_chunk = chunk;
104
- continue;
105
- } else if (/^\s+$/.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset)) || chunk.end_offset === chunk.start_offset) {
106
- latest_chunk.end_offset = chunk.end_offset;
107
- continue;
108
- }
109
- }
110
- previous_chunk = chunk;
111
- new_chunks.push(chunk);
112
- }
113
- return {
114
- ...stylesheet,
115
- chunks: new_chunks
116
- };
117
- }
118
- function chunkify(stylesheet) {
119
- let chunks = [];
120
- let offset = 0;
121
- for (let range of stylesheet.ranges) {
122
- if (offset !== range.start) {
123
- chunks.push({
124
- start_offset: offset,
125
- end_offset: range.start,
126
- is_covered: false
127
- });
128
- offset = range.start;
129
- }
130
- chunks.push({
131
- start_offset: range.start,
132
- end_offset: range.end,
133
- is_covered: true
134
- });
135
- offset = range.end;
136
- }
137
- if (offset !== stylesheet.text.length - 1) chunks.push({
138
- start_offset: offset,
139
- end_offset: stylesheet.text.length,
140
- is_covered: false
141
- });
142
- return merge({
143
- url: stylesheet.url,
144
- text: stylesheet.text,
145
- chunks
146
- });
147
- }
148
-
149
- //#endregion
150
- //#region src/lib/prettify.ts
151
- function prettify(stylesheet) {
152
- let line = 1;
153
- let offset = 0;
154
- let pretty_chunks = stylesheet.chunks.map((chunk, index) => {
155
- let css = format(stylesheet.text.slice(chunk.start_offset, chunk.end_offset - 1)).trim();
156
- if (chunk.is_covered) {
157
- let is_last = index === stylesheet.chunks.length - 1;
158
- if (index === 0) css = css + (is_last ? "" : "\n");
159
- else if (index === stylesheet.chunks.length - 1) css = "\n" + css;
160
- else css = "\n" + css + "\n";
161
- }
162
- let line_count = css.split("\n").length;
163
- let start_offset = offset;
164
- let end_offset = offset + css.length - 1;
165
- let start_line = line;
166
- let end_line = line + line_count;
167
- line = end_line;
168
- offset = end_offset;
169
- return {
170
- ...chunk,
171
- start_offset,
172
- start_line,
173
- end_line: end_line - 1,
174
- end_offset,
175
- css,
176
- total_lines: end_line - start_line
177
- };
178
- });
179
- return {
180
- ...stylesheet,
181
- chunks: pretty_chunks,
182
- text: pretty_chunks.map(({ css }) => css).join("\n")
183
- };
184
- }
185
-
186
- //#endregion
187
- //#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
- }
212
- }
213
- new_ranges.add(range);
214
- }
215
- return new_ranges;
216
- }
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
- 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 }]) => ({
244
- text,
245
- url,
246
- ranges: concatenate(dedupe_list(ranges.sort((a, b) => a.start - b.start))).sort((a, b) => a.start - b.start)
247
- }));
248
- }
249
-
250
- //#endregion
251
- //#region src/lib/ext.ts
252
- function ext(url) {
253
- try {
254
- let parsed_url = new URL(url);
255
- return parsed_url.pathname.slice(parsed_url.pathname.lastIndexOf(".") + 1);
256
- } catch {
257
- let ext_index = url.lastIndexOf(".");
258
- return url.slice(ext_index, url.indexOf("/", ext_index) + 1);
259
- }
260
- }
261
-
262
- //#endregion
263
- //#region src/lib/html-parser.ts
264
- /**
265
- * @description
266
- * Very, very naive but effective DOMParser.
267
- * It can only find <style> elements and their .textContent
268
- */
269
- var DOMParser = class {
270
- parseFromString(html, _type) {
271
- let styles = [];
272
- let lower = html.toLowerCase();
273
- let pos = 0;
274
- while (true) {
275
- let open = lower.indexOf("<style", pos);
276
- if (open === -1) break;
277
- let start = lower.indexOf(">", open);
278
- if (start === -1) break;
279
- let close = lower.indexOf("</style>", start);
280
- if (close === -1) break;
281
- let text = html.slice(start + 1, close);
282
- styles.push({ textContent: text });
283
- pos = close + 8;
284
- }
285
- return { querySelectorAll(selector) {
286
- return styles;
287
- } };
288
- }
289
- };
290
-
291
- //#endregion
292
- //#region src/lib/remap-html.ts
293
- function get_dom_parser() {
294
- if (typeof window !== "undefined" && "DOMParser" in window) return new window.DOMParser();
295
- return new DOMParser();
296
- }
297
- function remap_html(html, old_ranges) {
298
- let doc = get_dom_parser().parseFromString(html, "text/html");
299
- let combined_css = "";
300
- let new_ranges = [];
301
- let current_offset = 0;
302
- let style_elements = doc.querySelectorAll("style");
303
- for (let style_element of Array.from(style_elements)) {
304
- let style_content = style_element.textContent;
305
- if (!style_content.trim()) continue;
306
- combined_css += style_content;
307
- let start_index = html.indexOf(style_content);
308
- let end_index = start_index + style_content.length;
309
- for (let range of old_ranges) if (range.start >= start_index && range.end <= end_index) new_ranges.push({
310
- start: current_offset + (range.start - start_index),
311
- end: current_offset + (range.end - start_index)
312
- });
313
- current_offset += style_content.length;
314
- }
315
- return {
316
- css: combined_css,
317
- ranges: new_ranges
318
- };
319
- }
320
-
321
- //#endregion
322
- //#region src/lib/filter-entries.ts
323
- function is_html(text) {
324
- return /<\/?(html|body|head|div|span|script|style)/i.test(text);
325
- }
326
- const SELECTOR_REGEX = /(@[a-z-]+|\[[^\]]+\]|[a-zA-Z_#.-][a-zA-Z0-9_-]*)\s*\{/;
327
- const DECLARATION_REGEX = /^\s*[a-zA-Z-]+\s*:\s*.+;?\s*$/m;
328
- function is_css_like(text) {
329
- return SELECTOR_REGEX.test(text) || DECLARATION_REGEX.test(text);
330
- }
331
- function is_js_like(text) {
332
- try {
333
- new Function(text);
334
- return true;
335
- } catch {
336
- return false;
337
- }
338
- }
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({
358
- url: entry.url,
359
- text: entry.text,
360
- ranges: entry.ranges
361
- });
362
- }
363
- return result;
364
- }
365
-
366
- //#endregion
367
- //#region src/lib/extend-ranges.ts
368
- const AT_SIGN = 64;
369
- const LONGEST_ATRULE_NAME = 28;
370
- 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;
389
- }
390
- }
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
- });
403
- }
404
-
405
- //#endregion
406
- //#region src/lib/index.ts
407
- function ratio(fraction, total) {
408
- if (total === 0) return 0;
409
- return fraction / total;
410
- }
411
- function calculate_stylesheet_coverage({ text, url, chunks }) {
412
- let uncovered_bytes = 0;
413
- let covered_bytes = 0;
414
- let total_bytes = 0;
415
- let total_lines = 0;
416
- let covered_lines = 0;
417
- let uncovered_lines = 0;
418
- for (let chunk of chunks) {
419
- let lines = chunk.total_lines;
420
- let bytes = chunk.end_offset - chunk.start_offset;
421
- total_lines += lines;
422
- total_bytes += bytes;
423
- if (chunk.is_covered) {
424
- covered_lines += lines;
425
- covered_bytes += bytes;
426
- } else {
427
- uncovered_lines += lines;
428
- uncovered_bytes += bytes;
429
- }
430
- }
431
- return {
432
- url,
433
- text,
434
- uncovered_bytes,
435
- covered_bytes,
436
- total_bytes,
437
- line_coverage_ratio: ratio(covered_lines, total_lines),
438
- byte_coverage_ratio: ratio(covered_bytes, total_bytes),
439
- total_lines,
440
- covered_lines,
441
- uncovered_lines,
442
- chunks
443
- };
444
- }
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
- function calculate_coverage(coverage) {
457
- 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));
459
- let { total_lines, total_covered_lines, total_uncovered_lines, total_bytes, total_used_bytes, total_unused_bytes } = coverage_per_stylesheet.reduce((totals, sheet) => {
460
- totals.total_lines += sheet.total_lines;
461
- totals.total_covered_lines += sheet.covered_lines;
462
- totals.total_uncovered_lines += sheet.uncovered_lines;
463
- totals.total_bytes += sheet.total_bytes;
464
- totals.total_used_bytes += sheet.covered_bytes;
465
- totals.total_unused_bytes += sheet.uncovered_bytes;
466
- return totals;
467
- }, {
468
- total_lines: 0,
469
- total_covered_lines: 0,
470
- total_uncovered_lines: 0,
471
- total_bytes: 0,
472
- total_used_bytes: 0,
473
- total_unused_bytes: 0
474
- });
475
- return {
476
- total_files_found,
477
- total_bytes,
478
- total_lines,
479
- covered_bytes: total_used_bytes,
480
- covered_lines: total_covered_lines,
481
- uncovered_bytes: total_unused_bytes,
482
- uncovered_lines: total_uncovered_lines,
483
- byte_coverage_ratio: ratio(total_used_bytes, total_bytes),
484
- line_coverage_ratio: ratio(total_covered_lines, total_lines),
485
- coverage_per_stylesheet,
486
- total_stylesheets: coverage_per_stylesheet.length
487
- };
488
- }
489
-
490
68
  //#endregion
491
69
  //#region src/cli/program.ts
492
- var MissingDataError = class extends Error {
493
- constructor() {
494
- super("No data to analyze");
495
- }
496
- };
497
70
  function validate_min_line_coverage(actual, expected) {
498
71
  return {
499
72
  ok: actual >= expected,
@@ -513,21 +86,43 @@ function validate_min_file_line_coverage(actual, expected) {
513
86
  expected
514
87
  };
515
88
  }
516
- function program({ min_file_coverage, min_file_line_coverage }, coverage_data) {
517
- if (coverage_data.length === 0) throw new MissingDataError();
89
+ function program({ min_coverage, min_file_coverage }, coverage_data) {
518
90
  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);
91
+ let min_coverage_result = validate_min_line_coverage(coverage.line_coverage_ratio, min_coverage);
92
+ 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
93
  return {
522
94
  context: { coverage },
523
95
  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
96
+ ok: min_coverage_result.ok && min_file_coverage_result.ok,
97
+ min_line_coverage: min_coverage_result,
98
+ min_file_line_coverage: min_file_coverage_result
527
99
  }
528
100
  };
529
101
  }
530
102
 
103
+ //#endregion
104
+ //#region src/lib/parse-coverage.ts
105
+ let RangeSchema = v.object({
106
+ start: v.number(),
107
+ end: v.number()
108
+ });
109
+ let CoverageSchema = v.object({
110
+ text: v.string(),
111
+ url: v.string(),
112
+ ranges: v.array(RangeSchema)
113
+ });
114
+ function is_valid_coverage(input) {
115
+ return v.safeParse(v.array(CoverageSchema), input).success;
116
+ }
117
+ function parse_coverage(input) {
118
+ try {
119
+ let parse_result = JSON.parse(input);
120
+ return is_valid_coverage(parse_result) ? parse_result : [];
121
+ } catch {
122
+ return [];
123
+ }
124
+ }
125
+
531
126
  //#endregion
532
127
  //#region src/cli/file-reader.ts
533
128
  async function read(coverage_dir) {
@@ -548,13 +143,18 @@ function indent(line) {
548
143
  return (line || "").replace(/^\t+/, (tabs) => " ".repeat(tabs.length * 4));
549
144
  }
550
145
  let line_number = (num, covered = true) => `${num.toString().padStart(5, " ")} ${covered ? "│" : "━"} `;
551
- function percentage(ratio$1, decimals = 2) {
552
- return `${(ratio$1 * 100).toFixed(ratio$1 === 1 ? 0 : decimals)}%`;
146
+ function percentage(ratio, decimals = 2) {
147
+ return `${(ratio * 100).toFixed(ratio === 1 ? 0 : decimals)}%`;
553
148
  }
554
149
  function print_lines({ report, context }, params, { styleText: styleText$1, print_width }) {
555
150
  let output = [];
556
151
  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}`);
152
+ else {
153
+ let { actual, expected } = report.min_line_coverage;
154
+ output.push(`${styleText$1(["bold", "red"], "Failed")}: line coverage is ${percentage(actual)}% which is lower than the threshold of ${expected}`);
155
+ let lines_to_cover = expected * context.coverage.total_lines - context.coverage.covered_lines;
156
+ output.push(`Tip: cover ${Math.ceil(lines_to_cover)} more ${lines_to_cover === 1 ? "line" : "lines"} to meet the threshold of ${percentage(expected)}`);
157
+ }
558
158
  if (report.min_file_line_coverage.expected !== void 0) {
559
159
  let { expected, actual, ok: ok$1 } = report.min_file_line_coverage;
560
160
  if (ok$1) output.push(`${styleText$1(["bold", "green"], "Success")}: all files pass minimum line coverage of ${percentage(expected)}`);
@@ -569,6 +169,7 @@ function print_lines({ report, context }, params, { styleText: styleText$1, prin
569
169
  const NUM_TRAILING_LINES = NUM_LEADING_LINES;
570
170
  print_width = print_width ?? 80;
571
171
  let min_file_line_coverage = report.min_file_line_coverage.expected;
172
+ output.push();
572
173
  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
174
  output.push();
574
175
  output.push(styleText$1("dim", "─".repeat(print_width)));
@@ -591,10 +192,11 @@ function print_lines({ report, context }, params, { styleText: styleText$1, prin
591
192
  return output;
592
193
  }
593
194
  function print(report, params) {
195
+ let logger = report.report.ok ? console.log : console.error;
594
196
  for (let line of print_lines(report, params, {
595
197
  styleText,
596
198
  print_width: process.stdout.columns
597
- })) console.log(line);
199
+ })) logger(line);
598
200
  }
599
201
 
600
202
  //#endregion
@@ -609,7 +211,7 @@ function ok(n, description) {
609
211
  console.log(`ok ${n} ${description ? `- ${description}` : ""}`);
610
212
  }
611
213
  function not_ok(n, description) {
612
- console.log(`not ok ${n} ${description ? `- ${description}` : ""}`);
214
+ console.error(`not ok ${n} ${description ? `- ${description}` : ""}`);
613
215
  }
614
216
  function meta(data) {
615
217
  console.log(" ---");
@@ -653,17 +255,79 @@ function print$1({ report, context }, params) {
653
255
  }
654
256
  }
655
257
 
258
+ //#endregion
259
+ //#region src/cli/reporters/json.ts
260
+ function prepare({ report, context }, params) {
261
+ context.coverage.coverage_per_stylesheet = context.coverage.coverage_per_stylesheet.filter((sheet) => {
262
+ 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;
263
+ if (params["show-uncovered"] === "all" && sheet.line_coverage_ratio < 1) return true;
264
+ return false;
265
+ });
266
+ return {
267
+ report,
268
+ context
269
+ };
270
+ }
271
+ function print$2({ report, context }, params) {
272
+ let logger = report.ok ? console.log : console.error;
273
+ let data = prepare({
274
+ context,
275
+ report
276
+ }, params);
277
+ logger(JSON.stringify(data));
278
+ }
279
+
280
+ //#endregion
281
+ //#region src/cli/help.ts
282
+ function help() {
283
+ return `
284
+ ${styleText(["bold"], "USAGE")}
285
+ $ css-coverage --coverage-dir=<dir> --min-coverage=<number> [options]
286
+
287
+ ${styleText("bold", "OPTIONS")}
288
+ Required:
289
+ --coverage-dir Where your Coverage JSON files are
290
+ --min-coverage Minimum overall CSS coverage [0-1]
291
+
292
+ Optional:
293
+ --min-file-coverage Minimal coverage per file [0-1]
294
+
295
+ --show-uncovered Which files to show when not meeting
296
+ the --min-file-line-coverage threshold
297
+ • violations [default] ${styleText("dim", "show under-threshold files")}
298
+ • all ${styleText("dim", "show partially covered files")}
299
+ • none ${styleText("dim", "do not show files")}
300
+
301
+ --reporter How to show the results
302
+ • pretty [default]
303
+ • tap
304
+ • json
305
+
306
+ ${styleText("bold", "EXAMPLES")}
307
+ ${styleText("dim", "# analyze all .json files in ./coverage; require 80% overall coverage")}
308
+ $ css-coverage --coverage-dir=./coverage --min-coverage=0.8
309
+
310
+ ${styleText("dim", "# Require 50% coverage per file")}
311
+ $ css-coverage --coverage-dir=./coverage --min-coverage=0.8 --min-file-coverage=0.5
312
+
313
+ ${styleText("dim", "Report JSON")}
314
+ $ css-coverage --coverage-dir=./coverage --min-coverage=0.8 --reporter=json
315
+ `.trim();
316
+ }
317
+
656
318
  //#endregion
657
319
  //#region src/cli/cli.ts
658
320
  async function cli(cli_args) {
321
+ if (!cli_args || cli_args.length === 0 || cli_args.includes("--help") || cli_args.includes("-h")) return console.log(help());
659
322
  let params = validate_arguments(parse_arguments(cli_args));
660
323
  let coverage_data = await read(params["coverage-dir"]);
661
324
  let report = program({
662
- min_file_coverage: params["min-line-coverage"],
663
- min_file_line_coverage: params["min-file-line-coverage"]
325
+ min_coverage: params["min-coverage"],
326
+ min_file_coverage: params["min-file-coverage"]
664
327
  }, coverage_data);
665
328
  if (report.report.ok === false) process.exitCode = 1;
666
329
  if (params.reporter === "tap") return print$1(report, params);
330
+ if (params.reporter === "json") return print$2(report, params);
667
331
  return print(report, params);
668
332
  }
669
333
  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.2",
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",