UNPKG

@projectwallace/css-code-coverage

Version:

Generate useful CSS Code Coverage report from browser-reported coverage

396 lines (385 loc) 10.7 kB
import * as v from "valibot"; import { format } from "@projectwallace/format-css"; //#region src/lib/parse-coverage.ts let RangeSchema = v.object({ start: v.number(), end: v.number() }); let CoverageSchema = v.object({ text: v.string(), url: v.string(), ranges: v.array(RangeSchema) }); function is_valid_coverage(input) { return v.safeParse(v.array(CoverageSchema), input).success; } function parse_coverage(input) { try { let parse_result = JSON.parse(input); return is_valid_coverage(parse_result) ? parse_result : []; } catch { return []; } } //#endregion //#region src/lib/chunkify.ts const WHITESPACE_ONLY_REGEX = /^\s+$/; function merge(stylesheet) { let new_chunks = []; let previous_chunk; for (let i = 0; i < stylesheet.chunks.length; i++) { let chunk = stylesheet.chunks.at(i); if (WHITESPACE_ONLY_REGEX.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset))) continue; let latest_chunk = new_chunks.at(-1); if (i > 0 && previous_chunk && latest_chunk) { if (previous_chunk.is_covered === chunk.is_covered) { latest_chunk.end_offset = chunk.end_offset; previous_chunk = chunk; continue; } else if (WHITESPACE_ONLY_REGEX.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset)) || chunk.end_offset === chunk.start_offset) { latest_chunk.end_offset = chunk.end_offset; continue; } } previous_chunk = chunk; new_chunks.push(chunk); } return { ...stylesheet, chunks: new_chunks }; } function chunkify(stylesheet) { let chunks = []; let offset = 0; for (let range of stylesheet.ranges) { if (offset !== range.start) { chunks.push({ start_offset: offset, end_offset: range.start, is_covered: false }); offset = range.start; } chunks.push({ start_offset: range.start, end_offset: range.end, is_covered: true }); offset = range.end; } if (offset !== stylesheet.text.length - 1) chunks.push({ start_offset: offset, end_offset: stylesheet.text.length, is_covered: false }); return merge({ url: stylesheet.url, text: stylesheet.text, chunks }); } //#endregion //#region src/lib/prettify.ts function prettify(stylesheet) { let line = 1; let offset = 0; let pretty_chunks = stylesheet.chunks.map((chunk, index) => { let css = format(stylesheet.text.substring(chunk.start_offset, chunk.end_offset - 1)).trim(); if (chunk.is_covered) { let is_last_chunk = index === stylesheet.chunks.length - 1; if (index === 0) css = css + (is_last_chunk ? "" : "\n"); else if (index === stylesheet.chunks.length - 1) css = "\n" + css; else css = "\n" + css + "\n"; } let line_count = css.split("\n").length; let start_offset = offset; let end_offset = offset + css.length - 1; let start_line = line; let end_line = line + line_count; line = end_line; offset = end_offset; return { ...chunk, start_offset, start_line, end_line: end_line - 1, end_offset, css, total_lines: end_line - start_line }; }); return { ...stylesheet, chunks: pretty_chunks, text: pretty_chunks.map(({ css }) => css).join("\n") }; } //#endregion //#region src/lib/decuplicate.ts function merge_ranges(ranges) { if (ranges.length === 0) return []; ranges.sort((a, b) => a.start - b.start); let merged = [ranges[0]]; for (let r of ranges.slice(1)) { let last = merged.at(-1); if (last && r.start <= last.end + 1) { if (r.end > last.end) last.end = r.end; } else merged.push({ start: r.start, end: r.end }); } return merged; } function merge_entry_ranges(sheet, entry) { if (!sheet) return { url: entry.url, ranges: [...entry.ranges] }; let seen = new Set(sheet.ranges.map((r) => `${r.start}:${r.end}`)); for (let range of entry.ranges) { let id = `${range.start}:${range.end}`; if (!seen.has(id)) { seen.add(id); sheet.ranges.push({ ...range }); } } return sheet; } function deduplicate_entries(entries) { let grouped = entries.reduce((acc, entry) => { let key = entry.text; acc[key] = merge_entry_ranges(acc[key], entry); return acc; }, Object.create(null)); return Object.entries(grouped).map(([text, { url, ranges }]) => ({ text, url, ranges: merge_ranges(ranges) })); } //#endregion //#region src/lib/ext.ts function ext(url) { try { let parsed_url = new URL(url); return parsed_url.pathname.slice(parsed_url.pathname.lastIndexOf(".") + 1); } catch { let ext_index = url.lastIndexOf("."); return url.slice(ext_index, url.indexOf("/", ext_index) + 1); } } //#endregion //#region src/lib/html-parser.ts /** * @description * Very, very naive but effective DOMParser. * It can only find <style> elements and their .textContent */ var DOMParser = class { parseFromString(html, _type) { let styles = []; let lower = html.toLowerCase(); let pos = 0; while (true) { let open = lower.indexOf("<style", pos); if (open === -1) break; let start = lower.indexOf(">", open); if (start === -1) break; let close = lower.indexOf("</style>", start); if (close === -1) break; let text = html.slice(start + 1, close); styles.push({ textContent: text }); pos = close + 8; } return { querySelectorAll(selector) { return styles; } }; } }; //#endregion //#region src/lib/remap-html.ts function get_dom_parser() { if (typeof window !== "undefined" && "DOMParser" in window) return new window.DOMParser(); return new DOMParser(); } function remap_html(html, old_ranges) { let doc = get_dom_parser().parseFromString(html, "text/html"); let combined_css = ""; let new_ranges = []; let current_offset = 0; let style_elements = doc.querySelectorAll("style"); for (let style_element of Array.from(style_elements)) { let style_content = style_element.textContent; if (!style_content.trim()) continue; combined_css += style_content; let start_index = html.indexOf(style_content); let end_index = start_index + style_content.length; for (let range of old_ranges) if (range.start >= start_index && range.end <= end_index) new_ranges.push({ start: current_offset + (range.start - start_index), end: current_offset + (range.end - start_index) }); current_offset += style_content.length; } return { css: combined_css, ranges: new_ranges }; } //#endregion //#region src/lib/filter-entries.ts function is_html(text) { return /<\/?(html|body|head|div|span|script|style)/i.test(text); } const SELECTOR_REGEX = /(@[a-z-]+|\[[^\]]+\]|[a-zA-Z_#.-][a-zA-Z0-9_-]*)\s*\{/; const DECLARATION_REGEX = /^\s*[a-zA-Z-]+\s*:\s*.+;?\s*$/m; function is_css_like(text) { return SELECTOR_REGEX.test(text) || DECLARATION_REGEX.test(text); } function is_js_like(text) { try { new Function(text); return true; } catch { return false; } } function filter_coverage(acc, entry) { let extension = ext(entry.url).toLowerCase(); if (extension === "js") return acc; if (extension === "css") { acc.push(entry); return acc; } if (is_html(entry.text)) { let { css, ranges } = remap_html(entry.text, entry.ranges); acc.push({ url: entry.url, text: css, ranges }); return acc; } if (is_css_like(entry.text) && !is_js_like(entry.text)) { acc.push({ url: entry.url, text: entry.text, ranges: entry.ranges }); return acc; } return acc; } //#endregion //#region src/lib/extend-ranges.ts const AT_SIGN = 64; const LONGEST_ATRULE_NAME = 28; function extend_ranges(coverage) { let { ranges, url, text } = coverage; return { text, ranges: ranges.map((range, index) => { let prev_range = ranges[index - 1]; for (let i = range.start; i >= range.start - LONGEST_ATRULE_NAME; i--) { if (prev_range && prev_range.end > i) break; let char_position = i; if (text.charCodeAt(char_position) === AT_SIGN) { range.start = char_position; let next_offset = range.end; let next_char$1 = text.charAt(next_offset); while (/\s/.test(next_char$1)) { next_offset++; next_char$1 = text.charAt(next_offset); } if (next_char$1 === "{") range.end = range.end + 1; break; } } let offset = range.end; let next_char = text.charAt(offset); while (/\s/.test(next_char)) { offset++; next_char = text.charAt(offset); } if (next_char === "}") range.end = offset + 1; return range; }), url }; } //#endregion //#region src/lib/index.ts function ratio(fraction, total) { if (total === 0) return 0; return fraction / total; } function calculate_stylesheet_coverage({ text, url, chunks }) { let uncovered_bytes = 0; let covered_bytes = 0; let total_bytes = 0; let total_lines = 0; let covered_lines = 0; let uncovered_lines = 0; for (let chunk of chunks) { let lines = chunk.total_lines; let bytes = chunk.end_offset - chunk.start_offset; total_lines += lines; total_bytes += bytes; if (chunk.is_covered) { covered_lines += lines; covered_bytes += bytes; } else { uncovered_lines += lines; uncovered_bytes += bytes; } } return { url, text, uncovered_bytes, covered_bytes, total_bytes, line_coverage_ratio: ratio(covered_lines, total_lines), byte_coverage_ratio: ratio(covered_bytes, total_bytes), total_lines, covered_lines, uncovered_lines, chunks }; } function calculate_coverage(coverage) { let total_files_found = coverage.length; 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)); let { total_lines, total_covered_lines, total_uncovered_lines, total_bytes, total_used_bytes, total_unused_bytes } = coverage_per_stylesheet.reduce((totals, sheet) => { totals.total_lines += sheet.total_lines; totals.total_covered_lines += sheet.covered_lines; totals.total_uncovered_lines += sheet.uncovered_lines; totals.total_bytes += sheet.total_bytes; totals.total_used_bytes += sheet.covered_bytes; totals.total_unused_bytes += sheet.uncovered_bytes; return totals; }, { total_lines: 0, total_covered_lines: 0, total_uncovered_lines: 0, total_bytes: 0, total_used_bytes: 0, total_unused_bytes: 0 }); return { total_files_found, total_bytes, total_lines, covered_bytes: total_used_bytes, covered_lines: total_covered_lines, uncovered_bytes: total_unused_bytes, uncovered_lines: total_uncovered_lines, byte_coverage_ratio: ratio(total_used_bytes, total_bytes), line_coverage_ratio: ratio(total_covered_lines, total_lines), coverage_per_stylesheet, total_stylesheets: coverage_per_stylesheet.length }; } //#endregion export { calculate_coverage, parse_coverage };