Skip to main content

supercov_engine/
coverage_html.rs

1//! A static HTML report: one file, no network, no service.
2//!
3//! The point of this report is not a percentage -- the CLI already prints
4//! that. It is to let a person trace a missing obligation to the line that
5//! carries it, offline, from a CI artifact they downloaded.
6//!
7//! It is a single self-contained document on purpose. A copied artifact works
8//! with no server and no network; there is nothing to fetch from a CDN; and
9//! because a source file's path is never used as an output path, a filename
10//! carrying `../` cannot write anywhere.
11//!
12//! Four states are kept distinct, because collapsing them into one red/green
13//! score is how a report starts lying: *uncovered* (measured, nothing reached
14//! it), *not applicable* (nothing eligible), *unmeasured* (Supercov declined
15//! the obligation) and *stale* (the run no longer matches the checkout).
16
17use std::collections::BTreeMap;
18use std::fmt::Write as _;
19
20use crate::run_view::{Applicability, Metric, RunView};
21
22/// Escape text for HTML character data and quoted attribute values.
23fn 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("&lt;"),
29            '>' => out.push_str("&gt;"),
30            '"' => out.push_str("&quot;"),
31            '\'' => out.push_str("&#39;"),
32            c => out.push(c),
33        }
34    }
35    out
36}
37
38/// Escape a JSON document for embedding inside a `<script>` element.
39///
40/// A source file containing `</script>` would otherwise close the element and
41/// let the rest of that file be parsed as markup -- the project's own code
42/// becoming the report's HTML.
43fn 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  ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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) || '&nbsp;'}</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
208/// Render the report. `sources` carries file text only when it was verified to
209/// match the run; a file absent from it is shown by line number instead of
210/// annotated against source that may have moved.
211pub 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\">{} &middot; {}/{} &middot; {}</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} &middot; 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&hellip;">
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        // A file containing `</script>` would otherwise close the data island
348        // and have the rest of the project's code parsed as HTML.
349        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        // The filename reaches the visible document only escaped.
370        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        // It has to open from a copied CI artifact with no network at all.
380        // The check is on the document, not the whole file: a project's own
381        // source legitimately contains URLs, and embedding it must not make
382        // this test pass or fail for the wrong reason.
383        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        // "nothing eligible" must not read as complete, and a failed or stale
413        // run must say so beside its numbers rather than presenting them as a
414        // verdict on the project.
415        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        // Colour cannot be the only carrier: the source view labels each line
434        // covered or not covered in text, with a tick or cross beside it.
435        assert!(SCRIPT.contains("'covered'") && SCRIPT.contains("'not covered'"));
436        assert!(SCRIPT.contains("\\u2713") && SCRIPT.contains("\\u2717"));
437        // Sortable headers are reachable and announce their state.
438        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}