@projectwallace/css-code-coverage
Version:
Generate useful CSS Code Coverage report from browser-reported coverage
396 lines (385 loc) • 10.7 kB
JavaScript
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 };