1use std::collections::BTreeMap;
18use std::fmt::Write as _;
19
20use crate::run_view::{Applicability, Metric, RunView};
21
22fn escape(value: &str) -> String {
24 let mut out = String::with_capacity(value.len());
25 for character in value.chars() {
26 match character {
27 '&' => out.push_str("&"),
28 '<' => out.push_str("<"),
29 '>' => out.push_str(">"),
30 '"' => out.push_str("""),
31 '\'' => out.push_str("'"),
32 c => out.push(c),
33 }
34 }
35 out
36}
37
38fn embed_json(value: &str) -> String {
44 value
45 .replace('<', "\\u003c")
46 .replace('>', "\\u003e")
47 .replace('&', "\\u0026")
48 .replace('\u{2028}', "\\u2028")
49 .replace('\u{2029}', "\\u2029")
50}
51
52fn percentage(covered: usize, eligible: usize) -> String {
53 if eligible == 0 {
54 return "n/a".into();
55 }
56 format!("{:.2}%", covered as f64 * 100.0 / eligible as f64)
57}
58
59fn state_label(applicability: &Applicability) -> &'static str {
60 match applicability {
61 Applicability::Measured => "measured",
62 Applicability::NotApplicable => "not applicable",
63 Applicability::Incomplete { .. } => "partly measured",
64 }
65}
66
67const STYLE: &str = r##"
68:root{--bg:#fff;--fg:#1b1b1b;--muted:#5a5a5a;--line:#d8d8d8;--hit:#e6f4ea;--miss:#fdecea;--panel:#f7f7f7;--accent:#0b5fff}
69@media (prefers-color-scheme:dark){:root{--bg:#15171a;--fg:#e8e8e8;--muted:#a6a6a6;--line:#33373d;--hit:#16301f;--miss:#3a1d1b;--panel:#1c1f23;--accent:#7aa2ff}}
70*{box-sizing:border-box}
71body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
72main{max-width:72rem;margin:0 auto;padding:1.5rem}
73h1{font-size:1.3rem;margin:0 0 .25rem}
74.sub{color:var(--muted);margin:0 0 1.25rem}
75.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));gap:.75rem;margin-bottom:1.5rem}
76.card{border:1px solid var(--line);border-radius:.5rem;padding:.75rem;background:var(--panel)}
77.card .n{font-size:1.5rem;font-weight:600}
78.card .m{color:var(--muted);font-size:.85rem}
79table{border-collapse:collapse;width:100%}
80th,td{text-align:left;padding:.4rem .6rem;border-bottom:1px solid var(--line)}
81th{cursor:pointer;user-select:none;background:var(--panel)}
82th[aria-sort=ascending]::after{content:" \25B2"}
83th[aria-sort=descending]::after{content:" \25BC"}
84td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
85a{color:var(--accent)}
86.flag{font-size:.75rem;border:1px solid var(--line);border-radius:.25rem;padding:0 .3rem;color:var(--muted)}
87.controls{display:flex;gap:.75rem;align-items:center;margin-bottom:.75rem;flex-wrap:wrap}
88input[type=search]{padding:.35rem .5rem;border:1px solid var(--line);border-radius:.35rem;background:var(--bg);color:var(--fg);min-width:16rem}
89pre{margin:0;overflow-x:auto}
90.src{border:1px solid var(--line);border-radius:.5rem;overflow:hidden}
91.row{display:grid;grid-template-columns:4rem 5.5rem 1fr;gap:0;border-bottom:1px solid var(--line)}
92.row:last-child{border-bottom:0}
93.row.hit{background:var(--hit)}
94.row.miss{background:var(--miss)}
95.row .ln{color:var(--muted);text-align:right;padding:.1rem .5rem;font-variant-numeric:tabular-nums}
96.row .mk{color:var(--muted);padding:.1rem .5rem;white-space:nowrap;font-size:.8rem}
97.row code{padding:.1rem .5rem;white-space:pre;font:12.5px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}
98.note{border-left:3px solid var(--accent);background:var(--panel);padding:.6rem .8rem;margin:1rem 0;border-radius:0 .35rem .35rem 0}
99.warn{border-left-color:#c9791a}
100:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
101"##;
102
103const SCRIPT: &str = r##"
104const data = JSON.parse(document.getElementById('supercov-data').textContent);
105const files = data.files;
106const escape = (value) => String(value).replace(/[&<>"']/g, (c) =>
107 ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
108const pct = (c, e) => e === 0 ? 'n/a' : (c * 100 / e).toFixed(2) + '%';
109const metric = (file, name) => (file.metrics || []).find((m) => m.metric === name);
110let sort = { key: 'file', dir: 1 };
111
112function rows() {
113 const term = document.getElementById('filter').value.trim().toLowerCase();
114 const onlyGaps = document.getElementById('gaps').checked;
115 let shown = files.filter((f) => f.file.toLowerCase().includes(term));
116 if (onlyGaps) shown = shown.filter((f) => f.uncoveredLines.length || f.missingBranches.length || f.missingConditions.length);
117 shown.sort((a, b) => {
118 const pick = (f) => {
119 if (sort.key === 'file') return f.file;
120 const m = metric(f, sort.key);
121 return m && m.eligible ? m.covered / m.eligible : -1;
122 };
123 const x = pick(a), y = pick(b);
124 return (x > y ? 1 : x < y ? -1 : 0) * sort.dir;
125 });
126 document.getElementById('rows').innerHTML = shown.map((f) => {
127 const cell = (name) => {
128 const m = metric(f, name);
129 if (!m || m.eligible === 0) return '<td class="num"><span class="flag">n/a</span></td>';
130 return `<td class="num">${pct(m.covered, m.eligible)} <span class="flag">${m.covered}/${m.eligible}</span></td>`;
131 };
132 return `<tr><td><a href="#${encodeURIComponent(f.file)}" data-file="${escape(f.file)}">${escape(f.file)}</a></td>`
133 + cell('lines') + cell('branches') + cell('mcdc') + '</tr>';
134 }).join('') || '<tr><td colspan="4">No files match.</td></tr>';
135 document.getElementById('count').textContent = `${shown.length} of ${files.length} file(s)`;
136}
137
138function showFile(path) {
139 const file = files.find((f) => f.file === path);
140 const panel = document.getElementById('source');
141 if (!file) { panel.innerHTML = ''; return; }
142 const uncovered = new Set(file.uncoveredLines);
143 const measured = new Set(file.measuredLines);
144 const branchAt = {}, conditionAt = {};
145 for (const at of file.missingBranches) branchAt[at.line] = (branchAt[at.line] || 0) + 1;
146 for (const at of file.missingConditions) conditionAt[at.line] = (conditionAt[at.line] || 0) + 1;
147 const text = data.sources[path];
148 let body;
149 if (typeof text !== 'string') {
150 body = `<p class="note warn">Source is not embedded for this file, so only line numbers are shown. ${escape(data.sourceReason || '')}</p>`
151 + '<ul>' + file.uncoveredLines.map((l) => `<li>line ${l} not covered</li>`).join('') + '</ul>';
152 } else {
153 body = '<div class="src">' + text.split('\n').map((line, index) => {
154 const number = index + 1;
155 const marks = [];
156 if (measured.has(number)) marks.push(uncovered.has(number) ? 'not covered' : 'covered');
157 if (branchAt[number]) marks.push(`${branchAt[number]} branch gap`);
158 if (conditionAt[number]) marks.push(`${conditionAt[number]} MC/DC gap`);
159 const cls = !measured.has(number) ? '' : uncovered.has(number) ? 'miss' : 'hit';
160 const mark = !measured.has(number) ? '' : uncovered.has(number) ? '\u2717' : '\u2713';
161 return `<div class="row ${cls}" id="${encodeURIComponent(path)}:${number}">`
162 + `<span class="ln">${number}</span>`
163 + `<span class="mk">${mark ? mark + ' ' : ''}${escape(marks.join(', '))}</span>`
164 + `<code>${escape(line) || ' '}</code></div>`;
165 }).join('') + '</div>';
166 }
167 panel.innerHTML = `<h2>${escape(path)}</h2>` + body;
168 panel.scrollIntoView({ block: 'start' });
169}
170
171function route() {
172 const hash = decodeURIComponent(location.hash.replace(/^#/, ''));
173 const [path] = hash.split(/:(?=\d+$)/);
174 if (path) showFile(path);
175}
176
177document.getElementById('filter').addEventListener('input', rows);
178document.getElementById('gaps').addEventListener('change', rows);
179for (const th of document.querySelectorAll('th[data-key]')) {
180 const apply = () => {
181 const key = th.dataset.key;
182 sort = { key, dir: sort.key === key ? -sort.dir : 1 };
183 for (const other of document.querySelectorAll('th[data-key]')) other.removeAttribute('aria-sort');
184 th.setAttribute('aria-sort', sort.dir === 1 ? 'ascending' : 'descending');
185 rows();
186 };
187 th.addEventListener('click', apply);
188 th.addEventListener('keydown', (event) => {
189 if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); apply(); }
190 });
191}
192// Open the file directly as well as through the hash. A deep link still
193// works, but the report must not depend on hash navigation behaving the same
194// way in every context an artifact gets opened from.
195document.getElementById('rows').addEventListener('click', (event) => {
196 const link = event.target.closest('a[data-file]');
197 if (!link) return;
198 event.preventDefault();
199 const path = link.dataset.file;
200 history.replaceState(null, '', '#' + encodeURIComponent(path));
201 showFile(path);
202});
203window.addEventListener('hashchange', route);
204rows();
205route();
206"##;
207
208pub fn html(view: &RunView, sources: &BTreeMap<String, String>, source_reason: &str) -> String {
212 let payload = serde_json::json!({
213 "files": view.files,
214 "sources": sources,
215 "sourceReason": source_reason,
216 });
217 let payload = embed_json(&serde_json::to_string(&payload).unwrap_or_else(|_| "{}".into()));
218
219 let mut cards = String::new();
220 for metric in Metric::ALL {
221 let Some(counts) = view.metric(metric) else {
222 continue;
223 };
224 let _ = write!(
225 cards,
226 "<div class=\"card\"><div class=\"n\">{}</div><div class=\"m\">{} · {}/{} · {}</div></div>",
227 escape(&percentage(counts.covered, counts.eligible)),
228 escape(metric.name()),
229 counts.covered,
230 counts.eligible,
231 escape(state_label(&counts.applicability)),
232 );
233 }
234
235 let mut notes = String::new();
236 if !view.suite_passed {
237 notes.push_str("<p class=\"note warn\"><strong>The test command did not pass.</strong> These numbers describe the run that failed; they are not a statement that the project is covered.</p>");
238 }
239 if view.stale {
240 let _ = write!(
241 notes,
242 "<p class=\"note warn\"><strong>This run no longer matches the checkout.</strong> {}</p>",
243 escape(&view.stale_reasons.join(", "))
244 );
245 }
246 if !view.complete {
247 notes.push_str("<p class=\"note\">Some obligations were not measured exactly. They are excluded from every count here, because a measurement gap is not a coverage gap.</p>");
248 }
249 for limitation in &view.limitations {
250 let _ = write!(notes, "<p class=\"note\">{}</p>", escape(limitation));
251 }
252
253 format!(
254 r##"<!doctype html>
255<html lang="en">
256<head>
257<meta charset="utf-8">
258<meta name="viewport" content="width=device-width,initial-scale=1">
259<title>Coverage {run}</title>
260<style>{STYLE}</style>
261</head>
262<body>
263<main>
264<h1>Coverage report</h1>
265<p class="sub">Run {run} · recorded {generated}</p>
266{notes}
267<div class="cards">{cards}</div>
268<h2>Files</h2>
269<div class="controls">
270 <label for="filter">Filter files</label>
271 <input id="filter" type="search" placeholder="path contains…">
272 <label><input id="gaps" type="checkbox"> Only files with gaps</label>
273 <span id="count" class="flag" aria-live="polite"></span>
274</div>
275<table>
276<thead><tr>
277<th tabindex="0" data-key="file" aria-sort="ascending" scope="col">File</th>
278<th tabindex="0" data-key="lines" class="num" scope="col">Lines</th>
279<th tabindex="0" data-key="branches" class="num" scope="col">Branches</th>
280<th tabindex="0" data-key="mcdc" class="num" scope="col">MC/DC</th>
281</tr></thead>
282<tbody id="rows"></tbody>
283</table>
284<section id="source" aria-live="polite"></section>
285</main>
286<script id="supercov-data" type="application/json">{payload}</script>
287<script>{SCRIPT}</script>
288</body>
289</html>
290"##,
291 run = escape(&view.run),
292 generated = escape(&view.generated_at),
293 )
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use crate::run_view::{FileView, MetricView, RUN_VIEW_SCHEMA_VERSION, RunView};
300 use std::collections::BTreeSet;
301
302 fn view() -> RunView {
303 RunView {
304 schema_version: RUN_VIEW_SCHEMA_VERSION,
305 run: "run_1".into(),
306 generated_at: "now".into(),
307 suite_passed: true,
308 stale: false,
309 stale_reasons: Vec::new(),
310 complete: true,
311 limitations: Vec::new(),
312 totals: vec![
313 MetricView {
314 metric: Metric::Lines,
315 covered: 1,
316 eligible: 2,
317 applicability: Applicability::Measured,
318 },
319 MetricView {
320 metric: Metric::Mcdc,
321 covered: 0,
322 eligible: 0,
323 applicability: Applicability::NotApplicable,
324 },
325 ],
326 files: vec![FileView {
327 file: "src/<script>.ts".into(),
328 metrics: vec![MetricView {
329 metric: Metric::Lines,
330 covered: 1,
331 eligible: 2,
332 applicability: Applicability::Measured,
333 }],
334 measured_lines: vec![1, 2],
335 uncovered_lines: vec![2],
336 missing_branches: Vec::new(),
337 missing_conditions: Vec::new(),
338 functions: Vec::new(),
339 branches: Vec::new(),
340 }],
341 source_neighbourhoods: BTreeSet::new(),
342 }
343 }
344
345 #[test]
346 fn project_source_can_never_become_the_report_s_own_markup() {
347 let mut sources = BTreeMap::new();
350 sources.insert(
351 "src/<script>.ts".to_owned(),
352 "const a = '</script><img src=x onerror=alert(1)>'\n".to_owned(),
353 );
354 let text = html(&view(), &sources, "");
355 let island = text
356 .split("<script id=\"supercov-data\" type=\"application/json\">")
357 .nth(1)
358 .and_then(|rest| rest.split("</script>").next())
359 .expect("data island");
360 assert!(
361 !island.contains("</script"),
362 "the island can be closed early"
363 );
364 assert!(
365 !island.contains('<') && !island.contains('>'),
366 "raw markup survived"
367 );
368 assert!(island.contains("\\u003c/script\\u003e"), "{island}");
369 let body = text.split("<script id=").next().expect("body");
371 assert!(
372 !body.contains("src/<script>"),
373 "an unescaped filename reached the page"
374 );
375 }
376
377 #[test]
378 fn the_report_is_self_contained_and_fetches_nothing() {
379 let mut sources = BTreeMap::new();
384 sources.insert(
385 "src/<script>.ts".to_owned(),
386 "const endpoint = 'https://example.test/api'\n".to_owned(),
387 );
388 let text = html(&view(), &sources, "");
389 let island = "<script id=\"supercov-data\" type=\"application/json\">";
390 let start = text.find(island).expect("data island");
391 let end = text[start..].find("</script>").expect("island end") + start;
392 let document = format!("{}{}", &text[..start], &text[end..]);
393 for remote in [
394 "src=\"http",
395 "href=\"http",
396 "<link",
397 "@import",
398 "//cdn",
399 "fetch(",
400 "XMLHttpRequest",
401 ] {
402 assert!(
403 !document.contains(remote),
404 "{remote} appears in the document"
405 );
406 }
407 assert!(text.contains("<style>") && text.contains("<script>"));
408 }
409
410 #[test]
411 fn distinct_states_are_never_collapsed_into_one_score() {
412 let text = html(&view(), &BTreeMap::new(), "");
416 assert!(text.contains("not applicable"), "{text}");
417 assert!(text.contains("measured"));
418
419 let mut broken = view();
420 broken.suite_passed = false;
421 broken.stale = true;
422 broken.stale_reasons = vec!["instrumented source changed".into()];
423 broken.complete = false;
424 let text = html(&broken, &BTreeMap::new(), "");
425 assert!(text.contains("The test command did not pass"));
426 assert!(text.contains("no longer matches the checkout"));
427 assert!(text.contains("instrumented source changed"));
428 assert!(text.contains("a measurement gap is not a coverage gap"));
429 }
430
431 #[test]
432 fn coverage_is_marked_by_glyph_and_words_not_colour_alone() {
433 assert!(SCRIPT.contains("'covered'") && SCRIPT.contains("'not covered'"));
436 assert!(SCRIPT.contains("\\u2713") && SCRIPT.contains("\\u2717"));
437 assert!(STYLE.contains(":focus-visible"));
439 let text = html(&view(), &BTreeMap::new(), "");
440 assert!(text.contains("tabindex=\"0\"") && text.contains("aria-sort"));
441 assert!(STYLE.contains("prefers-color-scheme:dark"));
442 }
443}