@projectwallace/css-code-coverage 0.6.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/README.md CHANGED
@@ -53,7 +53,7 @@ let coverage = await page.coverage.stopCSSCoverage()
53
53
  // Now we can process it
54
54
  import { calculate_coverage } from '@projectwallace/css-code-coverage'
55
55
 
56
- let report = await calculcate_coverage(coverage)
56
+ let report = calculcate_coverage(coverage)
57
57
  ```
58
58
 
59
59
  ### Browser devtools
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
 
@@ -259,15 +241,43 @@ function ext(url) {
259
241
  }
260
242
  }
261
243
 
244
+ //#endregion
245
+ //#region src/lib/html-parser.ts
246
+ /**
247
+ * @description
248
+ * Very, very naive but effective DOMParser.
249
+ * It can only find <style> elements and their .textContent
250
+ */
251
+ var DOMParser = class {
252
+ parseFromString(html, _type) {
253
+ let styles = [];
254
+ let lower = html.toLowerCase();
255
+ let pos = 0;
256
+ while (true) {
257
+ let open = lower.indexOf("<style", pos);
258
+ if (open === -1) break;
259
+ let start = lower.indexOf(">", open);
260
+ if (start === -1) break;
261
+ let close = lower.indexOf("</style>", start);
262
+ if (close === -1) break;
263
+ let text = html.slice(start + 1, close);
264
+ styles.push({ textContent: text });
265
+ pos = close + 8;
266
+ }
267
+ return { querySelectorAll(selector) {
268
+ return styles;
269
+ } };
270
+ }
271
+ };
272
+
262
273
  //#endregion
263
274
  //#region src/lib/remap-html.ts
264
- async function get_dom_parser() {
275
+ function get_dom_parser() {
265
276
  if (typeof window !== "undefined" && "DOMParser" in window) return new window.DOMParser();
266
- let { DOMParser } = await import("./esm--VCpEgdH.js");
267
277
  return new DOMParser();
268
278
  }
269
- async function remap_html(html, old_ranges) {
270
- let doc = (await get_dom_parser()).parseFromString(html, "text/html");
279
+ function remap_html(html, old_ranges) {
280
+ let doc = get_dom_parser().parseFromString(html, "text/html");
271
281
  let combined_css = "";
272
282
  let new_ranges = [];
273
283
  let current_offset = 0;
@@ -295,31 +305,44 @@ async function remap_html(html, old_ranges) {
295
305
  function is_html(text) {
296
306
  return /<\/?(html|body|head|div|span|script|style)/i.test(text);
297
307
  }
298
- async function filter_coverage(coverage) {
299
- let result = [];
300
- for (let entry of coverage) {
301
- let extension = ext(entry.url).toLowerCase();
302
- if (extension === "js") continue;
303
- if (extension === "css") {
304
- result.push(entry);
305
- continue;
306
- }
307
- if (is_html(entry.text)) {
308
- let { css, ranges } = await remap_html(entry.text, entry.ranges);
309
- result.push({
310
- url: entry.url,
311
- text: css,
312
- ranges
313
- });
314
- continue;
315
- }
316
- result.push({
308
+ const SELECTOR_REGEX = /(@[a-z-]+|\[[^\]]+\]|[a-zA-Z_#.-][a-zA-Z0-9_-]*)\s*\{/;
309
+ const DECLARATION_REGEX = /^\s*[a-zA-Z-]+\s*:\s*.+;?\s*$/m;
310
+ function is_css_like(text) {
311
+ return SELECTOR_REGEX.test(text) || DECLARATION_REGEX.test(text);
312
+ }
313
+ function is_js_like(text) {
314
+ try {
315
+ new Function(text);
316
+ return true;
317
+ } catch {
318
+ return false;
319
+ }
320
+ }
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({
317
339
  url: entry.url,
318
340
  text: entry.text,
319
341
  ranges: entry.ranges
320
342
  });
343
+ return acc;
321
344
  }
322
- return result;
345
+ return acc;
323
346
  }
324
347
 
325
348
  //#endregion
@@ -327,38 +350,37 @@ async function filter_coverage(coverage) {
327
350
  const AT_SIGN = 64;
328
351
  const LONGEST_ATRULE_NAME = 28;
329
352
  function extend_ranges(coverage) {
330
- return coverage.map(({ text, ranges, url }) => {
331
- return {
332
- text,
333
- ranges: ranges.map((range, index) => {
334
- let prev_range = ranges[index - 1];
335
- for (let i = range.start; i >= range.start - LONGEST_ATRULE_NAME; i--) {
336
- if (prev_range && prev_range.end > i) break;
337
- let char_position = i;
338
- if (text.charCodeAt(char_position) === AT_SIGN) {
339
- range.start = char_position;
340
- let next_offset = range.end;
341
- let next_char$1 = text.charAt(next_offset);
342
- while (/\s/.test(next_char$1)) {
343
- next_offset++;
344
- next_char$1 = text.charAt(next_offset);
345
- }
346
- if (next_char$1 === "{") range.end = range.end + 1;
347
- 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);
348
368
  }
369
+ if (next_char$1 === "{") range.end = range.end + 1;
370
+ break;
349
371
  }
350
- let offset = range.end;
351
- let next_char = text.charAt(offset);
352
- while (/\s/.test(next_char)) {
353
- offset++;
354
- next_char = text.charAt(offset);
355
- }
356
- if (next_char === "}") range.end = offset + 1;
357
- return range;
358
- }),
359
- url
360
- };
361
- });
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
+ };
362
384
  }
363
385
 
364
386
  //#endregion
@@ -401,20 +423,9 @@ function calculate_stylesheet_coverage({ text, url, chunks }) {
401
423
  chunks
402
424
  };
403
425
  }
404
- /**
405
- * @description
406
- * CSS Code Coverage calculation
407
- *
408
- * These are the steps performed to calculate coverage:
409
- * 1. Filter eligible files / validate input
410
- * 2. Prettify the CSS dicovered in each Coverage and update their ranges
411
- * 3. De-duplicate Coverages: merge all ranges for CSS sources occurring multiple times
412
- * 4. Calculate used/unused CSS bytes (fastest path, no inspection of the actual CSS needed)
413
- * 5. Calculate line-coverage, byte-coverage per stylesheet
414
- */
415
- async function calculate_coverage(coverage) {
426
+ function calculate_coverage(coverage) {
416
427
  let total_files_found = coverage.length;
417
- let coverage_per_stylesheet = extend_ranges(deduplicate_entries(await 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));
418
429
  let { total_lines, total_covered_lines, total_uncovered_lines, total_bytes, total_used_bytes, total_unused_bytes } = coverage_per_stylesheet.reduce((totals, sheet) => {
419
430
  totals.total_lines += sheet.total_lines;
420
431
  totals.total_covered_lines += sheet.covered_lines;
@@ -448,11 +459,6 @@ async function calculate_coverage(coverage) {
448
459
 
449
460
  //#endregion
450
461
  //#region src/cli/program.ts
451
- var MissingDataError = class extends Error {
452
- constructor() {
453
- super("No data to analyze");
454
- }
455
- };
456
462
  function validate_min_line_coverage(actual, expected) {
457
463
  return {
458
464
  ok: actual >= expected,
@@ -472,17 +478,16 @@ function validate_min_file_line_coverage(actual, expected) {
472
478
  expected
473
479
  };
474
480
  }
475
- async function program({ min_file_coverage, min_file_line_coverage }, coverage_data) {
476
- if (coverage_data.length === 0) throw new MissingDataError();
477
- let coverage = await calculate_coverage(coverage_data);
478
- let min_line_coverage_result = validate_min_line_coverage(coverage.line_coverage_ratio, min_file_coverage);
479
- 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);
481
+ function program({ min_coverage, min_file_coverage }, coverage_data) {
482
+ let coverage = calculate_coverage(coverage_data);
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);
480
485
  return {
481
486
  context: { coverage },
482
487
  report: {
483
- ok: min_line_coverage_result.ok && min_file_line_coverage_result.ok,
484
- min_line_coverage: min_line_coverage_result,
485
- 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
486
491
  }
487
492
  };
488
493
  }
@@ -506,43 +511,61 @@ async function read(coverage_dir) {
506
511
  function indent(line) {
507
512
  return (line || "").replace(/^\t+/, (tabs) => " ".repeat(tabs.length * 4));
508
513
  }
509
- function print({ report, context }, params) {
510
- if (report.min_line_coverage.ok) console.log(`${styleText(["bold", "green"], "Success")}: total line coverage is ${(report.min_line_coverage.actual * 100).toFixed(2)}%`);
511
- else console.error(`${styleText(["bold", "red"], "Failed")}: line coverage is ${(report.min_line_coverage.actual * 100).toFixed(2)}% which is lower than the threshold of ${report.min_line_coverage.expected}`);
514
+ let line_number = (num, covered = true) => `${num.toString().padStart(5, " ")} ${covered ? "│" : "━"} `;
515
+ function percentage(ratio$1, decimals = 2) {
516
+ return `${(ratio$1 * 100).toFixed(ratio$1 === 1 ? 0 : decimals)}%`;
517
+ }
518
+ function print_lines({ report, context }, params, { styleText: styleText$1, print_width }) {
519
+ let output = [];
520
+ if (report.min_line_coverage.ok) output.push(`${styleText$1(["bold", "green"], "Success")}: total line coverage is ${percentage(report.min_line_coverage.actual)}`);
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
+ }
512
527
  if (report.min_file_line_coverage.expected !== void 0) {
513
528
  let { expected, actual, ok: ok$1 } = report.min_file_line_coverage;
514
- if (ok$1) console.log(`${styleText(["bold", "green"], "Success")}: all files pass minimum line coverage of ${expected * 100}%`);
529
+ if (ok$1) output.push(`${styleText$1(["bold", "green"], "Success")}: all files pass minimum line coverage of ${percentage(expected)}`);
515
530
  else {
516
531
  let num_files_failed = context.coverage.coverage_per_stylesheet.filter((sheet) => sheet.line_coverage_ratio < expected).length;
517
- console.error(`${styleText(["bold", "red"], "Failed")}: ${num_files_failed} files do not meet the minimum line coverage of ${expected * 100}% (minimum coverage was ${(actual * 100).toFixed(2)}%)`);
518
- if (params["show-uncovered"] === "none") console.log(` Hint: set --show-uncovered=violations to see which files didn't pass`);
532
+ output.push(`${styleText$1(["bold", "red"], "Failed")}: ${num_files_failed} ${num_files_failed === 1 ? "file does" : "files do"} not meet the minimum line coverage of ${percentage(expected)} (minimum coverage was ${percentage(actual)})`);
533
+ if (params["show-uncovered"] === "none") output.push(` Hint: set --show-uncovered=violations to see which files didn't pass`);
519
534
  }
520
535
  }
521
536
  if (params["show-uncovered"] !== "none") {
522
537
  const NUM_LEADING_LINES = 3;
523
538
  const NUM_TRAILING_LINES = NUM_LEADING_LINES;
524
- let terminal_width = process.stdout.columns || 80;
525
- let line_number = (num, covered = true) => `${num.toString().padStart(5, " ")} ${covered ? "│" : "━"} `;
539
+ print_width = print_width ?? 80;
526
540
  let min_file_line_coverage = report.min_file_line_coverage.expected;
541
+ output.push();
527
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") {
528
- console.log();
529
- console.log(styleText("dim", "─".repeat(terminal_width)));
530
- console.log(sheet.url);
531
- console.log(`Coverage: ${(sheet.line_coverage_ratio * 100).toFixed(2)}%, ${sheet.covered_lines}/${sheet.total_lines} lines covered`);
543
+ output.push();
544
+ output.push(styleText$1("dim", "─".repeat(print_width)));
545
+ output.push(sheet.url);
546
+ output.push(`Coverage: ${percentage(sheet.line_coverage_ratio)}, ${sheet.covered_lines}/${sheet.total_lines} lines covered`);
532
547
  if (min_file_line_coverage && min_file_line_coverage !== 0 && sheet.line_coverage_ratio < min_file_line_coverage) {
533
548
  let lines_to_cover = min_file_line_coverage * sheet.total_lines - sheet.covered_lines;
534
- console.log(`Tip: cover ${Math.ceil(lines_to_cover)} more lines to meet the file threshold of ${min_file_line_coverage * 100}%`);
549
+ output.push(`Tip: cover ${Math.ceil(lines_to_cover)} more ${lines_to_cover === 1 ? "line" : "lines"} to meet the file threshold of ${percentage(min_file_line_coverage)}`);
535
550
  }
536
- console.log(styleText("dim", "─".repeat(terminal_width)));
551
+ output.push(styleText$1("dim", "─".repeat(print_width)));
537
552
  let lines = sheet.text.split("\n");
538
553
  for (let chunk of sheet.chunks.filter((chunk$1) => !chunk$1.is_covered)) {
539
- for (let x = Math.max(chunk.start_line - NUM_LEADING_LINES, 0); x < chunk.start_line; x++) console.log(styleText("dim", line_number(x)), styleText("dim", indent(lines[x - 1])));
540
- for (let i = chunk.start_line; i <= chunk.end_line; i++) console.log(styleText("red", line_number(i, false)), indent(lines[i - 1]));
541
- for (let y = chunk.end_line; y < Math.min(chunk.end_line + NUM_TRAILING_LINES, lines.length); y++) console.log(styleText("dim", line_number(y)), styleText("dim", indent(lines[y - 1])));
542
- console.log();
554
+ for (let x = Math.max(chunk.start_line - NUM_LEADING_LINES, 1); x < chunk.start_line; x++) output.push([styleText$1("dim", line_number(x)), styleText$1("dim", indent(lines[x - 1]))].join(""));
555
+ for (let i = chunk.start_line; i <= chunk.end_line; i++) output.push([styleText$1("red", line_number(i, false)), indent(lines[i - 1])].join(""));
556
+ for (let y = chunk.end_line + 1; y < Math.min(chunk.end_line + NUM_TRAILING_LINES, lines.length); y++) output.push([styleText$1("dim", line_number(y)), styleText$1("dim", indent(lines[y - 1]))].join(""));
557
+ output.push();
543
558
  }
544
559
  }
545
560
  }
561
+ return output;
562
+ }
563
+ function print(report, params) {
564
+ let logger = report.report.ok ? console.log : console.error;
565
+ for (let line of print_lines(report, params, {
566
+ styleText,
567
+ print_width: process.stdout.columns
568
+ })) logger(line);
546
569
  }
547
570
 
548
571
  //#endregion
@@ -557,7 +580,7 @@ function ok(n, description) {
557
580
  console.log(`ok ${n} ${description ? `- ${description}` : ""}`);
558
581
  }
559
582
  function not_ok(n, description) {
560
- console.log(`not ok ${n} ${description ? `- ${description}` : ""}`);
583
+ console.error(`not ok ${n} ${description ? `- ${description}` : ""}`);
561
584
  }
562
585
  function meta(data) {
563
586
  console.log(" ---");
@@ -601,18 +624,80 @@ function print$1({ report, context }, params) {
601
624
  }
602
625
  }
603
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
+
604
687
  //#endregion
605
688
  //#region src/cli/cli.ts
606
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());
607
691
  let params = validate_arguments(parse_arguments(cli_args));
608
692
  let coverage_data = await read(params["coverage-dir"]);
609
- let report = await program({
610
- min_file_coverage: params["min-line-coverage"],
611
- min_file_line_coverage: params["min-file-line-coverage"]
693
+ let report = program({
694
+ min_coverage: params["min-coverage"],
695
+ min_file_coverage: params["min-file-coverage"]
612
696
  }, coverage_data);
613
697
  if (report.report.ok === false) process.exitCode = 1;
614
- if (params.reporter === "pretty") print(report, params);
615
- else if (params.reporter === "tap") print$1(report, params);
698
+ if (params.reporter === "tap") return print$1(report, params);
699
+ if (params.reporter === "json") return print$2(report, params);
700
+ return print(report, params);
616
701
  }
617
702
  try {
618
703
  await cli(process.argv.slice(2));
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
- declare function calculate_coverage(coverage: Coverage[]): Promise<CoverageResult>;
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 };