Skip to main content

proef_core/
html.rs

1//! `render_html` — a self-contained HTML view of a run's event stream.
2//!
3//! A *derived* view (ADR-0008), never a second record: it replays the same
4//! `events.jsonl` the console and `JUnit` reporters consume. Pure and
5//! deterministic in `events` (sans-IO core), so it snapshot-locks like the
6//! emitter. Events reaching here are already redacted at the sink
7//! (`report::sink`), so no secret value can enter the page — the same
8//! assumption `explain` makes.
9
10use std::collections::BTreeMap;
11use std::fmt::Write as _;
12use std::path::Path;
13
14use crate::emit::slugify;
15use crate::event::Event;
16use crate::step::Status;
17
18/// One step's row in the report.
19struct StepRow {
20    line: usize,
21    text: String,
22    status: Status,
23    attempts: u32,
24    duration_ms: u64,
25    detail: Option<String>,
26    /// `file.hurl#name` when the step ran a named fragment (ADR-0018).
27    fragment: Option<String>,
28}
29
30/// One scenario's block: identity, aggregate status, and its steps in order.
31#[derive(Default)]
32struct ScenarioBlock {
33    file: String,
34    name: String,
35    status: Option<Status>,
36    steps: Vec<StepRow>,
37    /// Run-relative start/end ms and worker index — injected observability
38    /// (ADR-0015), present only when the record carries timing. When present
39    /// they drive the cross-worker run timeline; absent, the report falls back
40    /// to the per-scenario waterfalls alone.
41    start_ms: Option<u64>,
42    end_ms: Option<u64>,
43    worker: Option<u64>,
44}
45
46/// Render `events` as a standalone HTML document. `artifacts_href` is the link
47/// prefix for each scenario's `.hurl` artifact (e.g. `"artifacts"`, resolved
48/// relative to wherever the caller writes the file); the artifact filename is
49/// derived with the same slug the emitter uses, so the links match on disk.
50pub fn render_html(events: &[Event], artifacts_href: &str) -> String {
51    let mut run_id = String::new();
52    let mut blocks: Vec<ScenarioBlock> = Vec::new();
53    let mut index: BTreeMap<(String, String), usize> = BTreeMap::new();
54    let mut total_steps = 0usize;
55    let mut total_attempts = 0u64;
56    let mut run_finished: Option<(usize, usize, usize)> = None;
57
58    for event in events {
59        match event {
60            Event::RunStarted { run_id: id, .. } => run_id = id.to_string(),
61            Event::StepFinished {
62                scenario,
63                step,
64                status,
65                attempts,
66                duration_ms,
67                detail,
68                fragment,
69                ..
70            } => {
71                total_steps += 1;
72                total_attempts += u64::from(*attempts);
73                let at = block_index(&mut blocks, &mut index, &step.file, scenario);
74                blocks[at].steps.push(StepRow {
75                    line: step.line,
76                    text: step.text.to_string(),
77                    status: *status,
78                    attempts: *attempts,
79                    duration_ms: *duration_ms,
80                    detail: detail.clone(),
81                    fragment: fragment.clone(),
82                });
83            }
84            Event::ScenarioStarted {
85                scenario,
86                file,
87                timestamp_ms,
88                worker,
89                ..
90            } => {
91                let at = block_index(&mut blocks, &mut index, file, scenario);
92                blocks[at].start_ms = *timestamp_ms;
93                blocks[at].worker = *worker;
94            }
95            Event::ScenarioFinished {
96                scenario,
97                file,
98                status,
99                timestamp_ms,
100                worker,
101                ..
102            } => {
103                let at = block_index(&mut blocks, &mut index, file, scenario);
104                blocks[at].status = Some(*status);
105                blocks[at].end_ms = *timestamp_ms;
106                if blocks[at].worker.is_none() {
107                    blocks[at].worker = *worker;
108                }
109            }
110            Event::RunFinished {
111                passed,
112                failed,
113                skipped,
114                ..
115            } => {
116                run_finished = Some((*passed, *failed, *skipped));
117            }
118            _ => {}
119        }
120    }
121
122    let (passed, failed, skipped) = suite_totals(run_finished, &blocks);
123    // `warned` is informational only — never part of the aligned three
124    // numbers above (no other surface breaks it out either) — so it stays a
125    // plain count of every rendered block regardless of phase.
126    let warned = blocks
127        .iter()
128        .filter(|block| block.status == Some(Status::Warned))
129        .count();
130
131    let mut html = String::with_capacity(2048 + blocks.len() * 256);
132    let _ = writeln!(
133        html,
134        "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\
135         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
136         <title>proef report — {run}</title>\n<style>{STYLE}</style>\n</head>\n<body>\n\
137         <h1>proef run <code>{run}</code></h1>",
138        run = esc(&run_id)
139    );
140    html.push_str("<p class=\"summary\">");
141    tally(&mut html, "pass", passed, "passed");
142    tally(&mut html, "fail", failed, "failed");
143    tally(&mut html, "skip", skipped, "skipped");
144    if warned > 0 {
145        tally(&mut html, "warn", warned, "warned");
146    }
147    let _ = writeln!(
148        html,
149        "<span class=\"steps\">{total_steps} steps · {total_attempts} attempts</span></p>"
150    );
151
152    render_timeline(&mut html, &blocks);
153    for block in &blocks {
154        render_block(&mut html, block, artifacts_href, failed);
155    }
156    html.push_str("</body>\n</html>\n");
157    html
158}
159
160/// The headline `(passed, failed, skipped)` — mirrors every other surface
161/// that reads `RunFinished` (console `summary:`, `explain`, `--output json`,
162/// `JUnit`, TAP, the SLA gate, the exit code): main-suite scenarios only,
163/// `[run] setup`/`teardown` excluded (ADR-0014). A truncated record has no
164/// `RunFinished` to read, so fall back to counting every rendered block — the
165/// only totals a dead run can offer.
166fn suite_totals(
167    run_finished: Option<(usize, usize, usize)>,
168    blocks: &[ScenarioBlock],
169) -> (usize, usize, usize) {
170    run_finished.unwrap_or_else(|| {
171        let (mut passed, mut failed, mut skipped) = (0usize, 0usize, 0usize);
172        for block in blocks {
173            match block.status {
174                Some(Status::Passed) => passed += 1,
175                Some(Status::Failed) => failed += 1,
176                Some(Status::Skipped) => skipped += 1,
177                Some(Status::Warned) | None => {}
178            }
179        }
180        (passed, failed, skipped)
181    })
182}
183
184/// Find or create the block for `(file, scenario)`, preserving first-seen order.
185fn block_index(
186    blocks: &mut Vec<ScenarioBlock>,
187    index: &mut BTreeMap<(String, String), usize>,
188    file: &str,
189    scenario: &str,
190) -> usize {
191    *index
192        .entry((file.to_string(), scenario.to_string()))
193        .or_insert_with(|| {
194            blocks.push(ScenarioBlock {
195                file: file.to_string(),
196                name: scenario.to_string(),
197                ..ScenarioBlock::default()
198            });
199            blocks.len() - 1
200        })
201}
202
203/// One `<details>` per scenario — failures open by default so the report leads
204/// with what broke. `headline_failed` is the aligned failed count in the
205/// summary bar above (`RunFinished`'s, suite-only per ADR-0014, or the
206/// counted-block fallback on a truncated record): a block whose own status is
207/// `Failed` while that count is `0` cannot be one of the failures the
208/// headline counts — it is necessarily a `[run] setup`/`teardown` fault
209/// excluded from it, so it is flagged here rather than left to read as the
210/// page contradicting its own summary.
211fn render_block(
212    html: &mut String,
213    block: &ScenarioBlock,
214    artifacts_href: &str,
215    headline_failed: usize,
216) {
217    let status = block.status.unwrap_or(Status::Skipped);
218    let open = if status == Status::Failed {
219        " open"
220    } else {
221        ""
222    };
223    let _ = write!(
224        html,
225        "<details class=\"scenario {cls}\"{open}>\n<summary>\
226         <span class=\"pill {cls}\">{word}</span> \
227         <span class=\"loc\">{file}</span> {name}",
228        cls = status_class(status),
229        word = status_word(status),
230        file = esc(&block.file),
231        name = esc(&block.name),
232    );
233    if status == Status::Failed && headline_failed == 0 {
234        html.push_str(
235            " <span class=\"phase-note\">setup/teardown — excluded from totals above</span>",
236        );
237    }
238    // Link the artifact only when the scenario actually ran hurl steps (else
239    // no `.hurl` was emitted for it).
240    if !block.steps.is_empty() {
241        let stem = Path::new(&block.file).file_stem().map_or_else(
242            || "feature".to_owned(),
243            |stem| stem.to_string_lossy().into_owned(),
244        );
245        let slug = format!("{}--{}", slugify(&stem), slugify(&block.name));
246        let _ = write!(
247            html,
248            " <a class=\"artifact\" href=\"{href}/{slug}.hurl\">artifact</a>",
249            href = esc(artifacts_href),
250        );
251    }
252    html.push_str("</summary>\n<ol class=\"steps\">\n");
253    // Per-scenario timing waterfall: each step's bar is offset by the steps
254    // before it and as wide as its own duration, both as a fraction of the
255    // scenario total. Purely derived from `duration_ms` (no timestamps), so it
256    // shows the *sequential* cascade within one scenario — not cross-worker
257    // occupancy, which would need an injected clock the sans-IO core never reads.
258    let total_ms: u64 = block.steps.iter().map(|step| step.duration_ms).sum();
259    let mut elapsed_ms: u64 = 0;
260    for step in &block.steps {
261        let _ = write!(
262            html,
263            "<li class=\"{cls}\"><span class=\"glyph\">{glyph}</span> {text}\
264             <span class=\"meta\">:{line} · {attempts}× · {ms}ms</span>",
265            cls = status_class(step.status),
266            glyph = status_glyph(step.status),
267            text = esc(&step.text),
268            line = step.line,
269            attempts = step.attempts,
270            ms = step.duration_ms,
271        );
272        if total_ms > 0 {
273            let _ = write!(
274                html,
275                "<span class=\"track\"><span class=\"bar {cls}\" \
276                 style=\"margin-left:{offset}%;width:{width}%\"></span></span>",
277                cls = status_class(step.status),
278                offset = pct(elapsed_ms, total_ms),
279                width = pct(step.duration_ms, total_ms),
280            );
281        }
282        elapsed_ms += step.duration_ms;
283        if let Some(detail) = &step.detail {
284            let _ = write!(html, "<pre class=\"detail\">{}</pre>", esc(detail));
285        }
286        // Every `ref:` step, not only failing ones: this is a per-step listing
287        // rather than a failure list, so a green report answers "which file did
288        // this run" too. It sits last so a failure still reads reason-first
289        // (ADR-0018).
290        if let Some(fragment) = &step.fragment {
291            let _ = write!(html, "<p class=\"via\">via {}</p>", esc(fragment));
292        }
293        html.push_str("</li>\n");
294    }
295    html.push_str("</ol>\n</details>\n");
296}
297
298/// The cross-worker run timeline (ADR-0015): a lane per worker, each scenario a
299/// bar from its start to its finish, positioned on a shared run-relative axis so
300/// concurrency is visible at a glance. Rendered only when the record carries
301/// injected timing (`start`/`end` timestamps); absent, the report shows just the
302/// per-scenario waterfalls, so old records degrade cleanly.
303fn render_timeline(html: &mut String, blocks: &[ScenarioBlock]) {
304    let timed: Vec<&ScenarioBlock> = blocks
305        .iter()
306        .filter(|block| block.start_ms.is_some() && block.end_ms.is_some())
307        .collect();
308    let Some(max_end) = timed.iter().filter_map(|block| block.end_ms).max() else {
309        return; // no timed scenarios — old record, waterfalls only
310    };
311    if max_end == 0 {
312        return; // a zero-length run has nothing to place on the axis
313    }
314    let mut workers: Vec<u64> = timed
315        .iter()
316        .map(|block| block.worker.unwrap_or(0))
317        .collect();
318    workers.sort_unstable();
319    workers.dedup();
320
321    let _ = writeln!(
322        html,
323        "<h2 class=\"timeline-h\">Timeline <span class=\"count\">{max_end}ms</span></h2>\n\
324         <div class=\"timeline\">"
325    );
326    for worker in &workers {
327        let _ = write!(
328            html,
329            "<div class=\"lane\"><span class=\"lane-label\">worker {worker}</span>\
330             <div class=\"lane-track\">"
331        );
332        for block in timed
333            .iter()
334            .filter(|block| block.worker.unwrap_or(0) == *worker)
335        {
336            let start = block.start_ms.unwrap_or(0);
337            let end = block.end_ms.unwrap_or(start).max(start);
338            let _ = write!(
339                html,
340                "<span class=\"tbar {cls}\" style=\"left:{left}%;width:{width}%\" \
341                 title=\"{title} ({dur}ms)\"></span>",
342                cls = status_class(block.status.unwrap_or(Status::Skipped)),
343                left = pct(start, max_end),
344                width = pct(end - start, max_end),
345                title = esc(&block.name),
346                dur = end - start,
347            );
348        }
349        html.push_str("</div></div>\n");
350    }
351    html.push_str("</div>\n");
352}
353
354/// `n / total` as a percentage string with one decimal place, using integer
355/// math only (no lossy float cast). `total` must be non-zero (callers guard).
356fn pct(n: u64, total: u64) -> String {
357    let permille = u128::from(n) * 1000 / u128::from(total); // 0..=1000
358    format!("{}.{}", permille / 10, permille % 10)
359}
360
361/// Write one `<span>` count into the summary bar, omitting nothing (callers gate
362/// on zero where a bucket should hide).
363fn tally(html: &mut String, class: &str, count: usize, word: &str) {
364    let _ = write!(html, "<span class=\"count {class}\">{count} {word}</span> ");
365}
366
367fn status_class(status: Status) -> &'static str {
368    match status {
369        Status::Passed => "pass",
370        Status::Failed => "fail",
371        Status::Skipped => "skip",
372        Status::Warned => "warn",
373    }
374}
375
376fn status_word(status: Status) -> &'static str {
377    match status {
378        Status::Passed => "passed",
379        Status::Failed => "failed",
380        Status::Skipped => "skipped",
381        Status::Warned => "warned",
382    }
383}
384
385fn status_glyph(status: Status) -> &'static str {
386    match status {
387        Status::Passed => "✓",
388        Status::Failed => "✗",
389        Status::Skipped => "·",
390        Status::Warned => "⚠",
391    }
392}
393
394/// HTML-escape text destined for element content or a double-quoted attribute.
395fn esc(text: &str) -> String {
396    let mut out = String::with_capacity(text.len());
397    for ch in text.chars() {
398        match ch {
399            '&' => out.push_str("&amp;"),
400            '<' => out.push_str("&lt;"),
401            '>' => out.push_str("&gt;"),
402            '"' => out.push_str("&quot;"),
403            '\'' => out.push_str("&#39;"),
404            _ => out.push(ch),
405        }
406    }
407    out
408}
409
410/// Inlined stylesheet — the report is a single self-contained file (no external
411/// requests), light/dark aware for a local artifact.
412const STYLE: &str = "\
413:root{--bg:#fff;--fg:#1a1a1a;--muted:#666;--line:#e2e2e2;--pass:#1a7f37;--fail:#cf222e;--skip:#8a8a8a;--warn:#9a6700;--card:#f6f8fa}\
414@media(prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#9aa4af;--line:#30363d;--pass:#3fb950;--fail:#f85149;--skip:#8a8a8a;--warn:#d29922;--card:#161b22}}\
415*{box-sizing:border-box}body{margin:0;padding:2rem;max-width:60rem;margin:0 auto;background:var(--bg);color:var(--fg);font:15px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}\
416h1{font-size:1.4rem;font-weight:600}code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}\
417.incomplete-banner{color:var(--warn);font-weight:600;margin:0 0 1rem}\
418.summary{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center;margin:0 0 1.5rem}\
419.count{font-weight:600;padding:.15rem .6rem;border-radius:999px;background:var(--card)}\
420.count.pass{color:var(--pass)}.count.fail{color:var(--fail)}.count.skip{color:var(--skip)}.count.warn{color:var(--warn)}\
421.summary .steps{color:var(--muted);margin-left:auto}\
422.scenario{border:1px solid var(--line);border-radius:8px;margin:.5rem 0;background:var(--card)}\
423.scenario summary{cursor:pointer;padding:.6rem .8rem;list-style:none;display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}\
424.scenario summary::-webkit-details-marker{display:none}\
425.pill{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;padding:.1rem .5rem;border-radius:4px;color:#fff}\
426.pill.pass{background:var(--pass)}.pill.fail{background:var(--fail)}.pill.skip{background:var(--skip)}.pill.warn{background:var(--warn)}\
427.loc{color:var(--muted);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.85rem}\
428.artifact{margin-left:auto;font-size:.85rem;color:var(--muted)}\
429.phase-note{color:var(--muted);font-size:.78rem;font-style:italic}\
430.steps{margin:0;padding:.2rem .8rem .8rem 2rem;border-top:1px solid var(--line)}\
431.steps li{margin:.3rem 0}.steps .glyph{font-weight:700}\
432li.pass .glyph{color:var(--pass)}li.fail .glyph{color:var(--fail)}li.skip .glyph{color:var(--skip)}li.warn .glyph{color:var(--warn)}\
433.meta{color:var(--muted);font-size:.8rem;margin-left:.4rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}\
434.track{display:block;height:4px;margin:.25rem 0 0;background:var(--line);border-radius:2px;overflow:hidden}\
435.bar{display:block;height:100%;min-width:1px;border-radius:2px}\
436.bar.pass{background:var(--pass)}.bar.fail{background:var(--fail)}.bar.skip{background:var(--skip)}.bar.warn{background:var(--warn)}\
437.timeline-h{font-size:1.05rem;font-weight:600;margin:1.5rem 0 .5rem}\
438.timeline{margin:0 0 1.5rem}\
439.lane{display:flex;align-items:center;gap:.5rem;margin:.25rem 0}\
440.lane-label{color:var(--muted);font-size:.75rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;min-width:5rem;text-align:right}\
441.lane-track{position:relative;flex:1;height:1rem;background:var(--card);border:1px solid var(--line);border-radius:3px}\
442.tbar{position:absolute;top:1px;height:calc(100% - 2px);min-width:2px;border-radius:2px;opacity:.9}\
443.tbar.pass{background:var(--pass)}.tbar.fail{background:var(--fail)}.tbar.skip{background:var(--skip)}.tbar.warn{background:var(--warn)}\
444.detail{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:.5rem .7rem;margin:.4rem 0 0;white-space:pre-wrap;font-size:.82rem;overflow-x:auto}\
445.via{color:var(--muted);font-size:.78rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;margin:.3rem 0 0}\
446";