1use 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
18struct StepRow {
20 line: usize,
21 text: String,
22 status: Status,
23 attempts: u32,
24 duration_ms: u64,
25 detail: Option<String>,
26}
27
28#[derive(Default)]
30struct ScenarioBlock {
31 file: String,
32 name: String,
33 status: Option<Status>,
34 steps: Vec<StepRow>,
35 start_ms: Option<u64>,
40 end_ms: Option<u64>,
41 worker: Option<u64>,
42}
43
44pub fn render_html(events: &[Event], artifacts_href: &str) -> String {
49 let mut run_id = String::new();
50 let mut blocks: Vec<ScenarioBlock> = Vec::new();
51 let mut index: BTreeMap<(String, String), usize> = BTreeMap::new();
52 let mut total_steps = 0usize;
53 let mut total_attempts = 0u64;
54 let mut run_finished: Option<(usize, usize, usize)> = None;
55
56 for event in events {
57 match event {
58 Event::RunStarted { run_id: id, .. } => run_id = id.to_string(),
59 Event::StepFinished {
60 scenario,
61 step,
62 status,
63 attempts,
64 duration_ms,
65 detail,
66 ..
67 } => {
68 total_steps += 1;
69 total_attempts += u64::from(*attempts);
70 let at = block_index(&mut blocks, &mut index, &step.file, scenario);
71 blocks[at].steps.push(StepRow {
72 line: step.line,
73 text: step.text.to_string(),
74 status: *status,
75 attempts: *attempts,
76 duration_ms: *duration_ms,
77 detail: detail.clone(),
78 });
79 }
80 Event::ScenarioStarted {
81 scenario,
82 file,
83 timestamp_ms,
84 worker,
85 ..
86 } => {
87 let at = block_index(&mut blocks, &mut index, file, scenario);
88 blocks[at].start_ms = *timestamp_ms;
89 blocks[at].worker = *worker;
90 }
91 Event::ScenarioFinished {
92 scenario,
93 file,
94 status,
95 timestamp_ms,
96 worker,
97 ..
98 } => {
99 let at = block_index(&mut blocks, &mut index, file, scenario);
100 blocks[at].status = Some(*status);
101 blocks[at].end_ms = *timestamp_ms;
102 if blocks[at].worker.is_none() {
103 blocks[at].worker = *worker;
104 }
105 }
106 Event::RunFinished {
107 passed,
108 failed,
109 skipped,
110 ..
111 } => {
112 run_finished = Some((*passed, *failed, *skipped));
113 }
114 _ => {}
115 }
116 }
117
118 let (passed, failed, skipped) = suite_totals(run_finished, &blocks);
119 let warned = blocks
123 .iter()
124 .filter(|block| block.status == Some(Status::Warned))
125 .count();
126
127 let mut html = String::with_capacity(2048 + blocks.len() * 256);
128 let _ = writeln!(
129 html,
130 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\
131 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
132 <title>proef report — {run}</title>\n<style>{STYLE}</style>\n</head>\n<body>\n\
133 <h1>proef run <code>{run}</code></h1>",
134 run = esc(&run_id)
135 );
136 html.push_str("<p class=\"summary\">");
137 tally(&mut html, "pass", passed, "passed");
138 tally(&mut html, "fail", failed, "failed");
139 tally(&mut html, "skip", skipped, "skipped");
140 if warned > 0 {
141 tally(&mut html, "warn", warned, "warned");
142 }
143 let _ = writeln!(
144 html,
145 "<span class=\"steps\">{total_steps} steps · {total_attempts} attempts</span></p>"
146 );
147
148 render_timeline(&mut html, &blocks);
149 for block in &blocks {
150 render_block(&mut html, block, artifacts_href, failed);
151 }
152 html.push_str("</body>\n</html>\n");
153 html
154}
155
156fn suite_totals(
163 run_finished: Option<(usize, usize, usize)>,
164 blocks: &[ScenarioBlock],
165) -> (usize, usize, usize) {
166 run_finished.unwrap_or_else(|| {
167 let (mut passed, mut failed, mut skipped) = (0usize, 0usize, 0usize);
168 for block in blocks {
169 match block.status {
170 Some(Status::Passed) => passed += 1,
171 Some(Status::Failed) => failed += 1,
172 Some(Status::Skipped) => skipped += 1,
173 Some(Status::Warned) | None => {}
174 }
175 }
176 (passed, failed, skipped)
177 })
178}
179
180fn block_index(
182 blocks: &mut Vec<ScenarioBlock>,
183 index: &mut BTreeMap<(String, String), usize>,
184 file: &str,
185 scenario: &str,
186) -> usize {
187 *index
188 .entry((file.to_string(), scenario.to_string()))
189 .or_insert_with(|| {
190 blocks.push(ScenarioBlock {
191 file: file.to_string(),
192 name: scenario.to_string(),
193 ..ScenarioBlock::default()
194 });
195 blocks.len() - 1
196 })
197}
198
199fn render_block(
208 html: &mut String,
209 block: &ScenarioBlock,
210 artifacts_href: &str,
211 headline_failed: usize,
212) {
213 let status = block.status.unwrap_or(Status::Skipped);
214 let open = if status == Status::Failed {
215 " open"
216 } else {
217 ""
218 };
219 let _ = write!(
220 html,
221 "<details class=\"scenario {cls}\"{open}>\n<summary>\
222 <span class=\"pill {cls}\">{word}</span> \
223 <span class=\"loc\">{file}</span> {name}",
224 cls = status_class(status),
225 word = status_word(status),
226 file = esc(&block.file),
227 name = esc(&block.name),
228 );
229 if status == Status::Failed && headline_failed == 0 {
230 html.push_str(
231 " <span class=\"phase-note\">setup/teardown — excluded from totals above</span>",
232 );
233 }
234 if !block.steps.is_empty() {
237 let stem = Path::new(&block.file).file_stem().map_or_else(
238 || "feature".to_owned(),
239 |stem| stem.to_string_lossy().into_owned(),
240 );
241 let slug = format!("{}--{}", slugify(&stem), slugify(&block.name));
242 let _ = write!(
243 html,
244 " <a class=\"artifact\" href=\"{href}/{slug}.hurl\">artifact</a>",
245 href = esc(artifacts_href),
246 );
247 }
248 html.push_str("</summary>\n<ol class=\"steps\">\n");
249 let total_ms: u64 = block.steps.iter().map(|step| step.duration_ms).sum();
255 let mut elapsed_ms: u64 = 0;
256 for step in &block.steps {
257 let _ = write!(
258 html,
259 "<li class=\"{cls}\"><span class=\"glyph\">{glyph}</span> {text}\
260 <span class=\"meta\">:{line} · {attempts}× · {ms}ms</span>",
261 cls = status_class(step.status),
262 glyph = status_glyph(step.status),
263 text = esc(&step.text),
264 line = step.line,
265 attempts = step.attempts,
266 ms = step.duration_ms,
267 );
268 if total_ms > 0 {
269 let _ = write!(
270 html,
271 "<span class=\"track\"><span class=\"bar {cls}\" \
272 style=\"margin-left:{offset}%;width:{width}%\"></span></span>",
273 cls = status_class(step.status),
274 offset = pct(elapsed_ms, total_ms),
275 width = pct(step.duration_ms, total_ms),
276 );
277 }
278 elapsed_ms += step.duration_ms;
279 if let Some(detail) = &step.detail {
280 let _ = write!(html, "<pre class=\"detail\">{}</pre>", esc(detail));
281 }
282 html.push_str("</li>\n");
283 }
284 html.push_str("</ol>\n</details>\n");
285}
286
287fn render_timeline(html: &mut String, blocks: &[ScenarioBlock]) {
293 let timed: Vec<&ScenarioBlock> = blocks
294 .iter()
295 .filter(|block| block.start_ms.is_some() && block.end_ms.is_some())
296 .collect();
297 let Some(max_end) = timed.iter().filter_map(|block| block.end_ms).max() else {
298 return; };
300 if max_end == 0 {
301 return; }
303 let mut workers: Vec<u64> = timed
304 .iter()
305 .map(|block| block.worker.unwrap_or(0))
306 .collect();
307 workers.sort_unstable();
308 workers.dedup();
309
310 let _ = writeln!(
311 html,
312 "<h2 class=\"timeline-h\">Timeline <span class=\"count\">{max_end}ms</span></h2>\n\
313 <div class=\"timeline\">"
314 );
315 for worker in &workers {
316 let _ = write!(
317 html,
318 "<div class=\"lane\"><span class=\"lane-label\">worker {worker}</span>\
319 <div class=\"lane-track\">"
320 );
321 for block in timed
322 .iter()
323 .filter(|block| block.worker.unwrap_or(0) == *worker)
324 {
325 let start = block.start_ms.unwrap_or(0);
326 let end = block.end_ms.unwrap_or(start).max(start);
327 let _ = write!(
328 html,
329 "<span class=\"tbar {cls}\" style=\"left:{left}%;width:{width}%\" \
330 title=\"{title} ({dur}ms)\"></span>",
331 cls = status_class(block.status.unwrap_or(Status::Skipped)),
332 left = pct(start, max_end),
333 width = pct(end - start, max_end),
334 title = esc(&block.name),
335 dur = end - start,
336 );
337 }
338 html.push_str("</div></div>\n");
339 }
340 html.push_str("</div>\n");
341}
342
343fn pct(n: u64, total: u64) -> String {
346 let permille = u128::from(n) * 1000 / u128::from(total); format!("{}.{}", permille / 10, permille % 10)
348}
349
350fn tally(html: &mut String, class: &str, count: usize, word: &str) {
353 let _ = write!(html, "<span class=\"count {class}\">{count} {word}</span> ");
354}
355
356fn status_class(status: Status) -> &'static str {
357 match status {
358 Status::Passed => "pass",
359 Status::Failed => "fail",
360 Status::Skipped => "skip",
361 Status::Warned => "warn",
362 }
363}
364
365fn status_word(status: Status) -> &'static str {
366 match status {
367 Status::Passed => "passed",
368 Status::Failed => "failed",
369 Status::Skipped => "skipped",
370 Status::Warned => "warned",
371 }
372}
373
374fn status_glyph(status: Status) -> &'static str {
375 match status {
376 Status::Passed => "✓",
377 Status::Failed => "✗",
378 Status::Skipped => "·",
379 Status::Warned => "⚠",
380 }
381}
382
383fn esc(text: &str) -> String {
385 let mut out = String::with_capacity(text.len());
386 for ch in text.chars() {
387 match ch {
388 '&' => out.push_str("&"),
389 '<' => out.push_str("<"),
390 '>' => out.push_str(">"),
391 '"' => out.push_str("""),
392 '\'' => out.push_str("'"),
393 _ => out.push(ch),
394 }
395 }
396 out
397}
398
399const STYLE: &str = "\
402:root{--bg:#fff;--fg:#1a1a1a;--muted:#666;--line:#e2e2e2;--pass:#1a7f37;--fail:#cf222e;--skip:#8a8a8a;--warn:#9a6700;--card:#f6f8fa}\
403@media(prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#9aa4af;--line:#30363d;--pass:#3fb950;--fail:#f85149;--skip:#8a8a8a;--warn:#d29922;--card:#161b22}}\
404*{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}\
405h1{font-size:1.4rem;font-weight:600}code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}\
406.incomplete-banner{color:var(--warn);font-weight:600;margin:0 0 1rem}\
407.summary{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center;margin:0 0 1.5rem}\
408.count{font-weight:600;padding:.15rem .6rem;border-radius:999px;background:var(--card)}\
409.count.pass{color:var(--pass)}.count.fail{color:var(--fail)}.count.skip{color:var(--skip)}.count.warn{color:var(--warn)}\
410.summary .steps{color:var(--muted);margin-left:auto}\
411.scenario{border:1px solid var(--line);border-radius:8px;margin:.5rem 0;background:var(--card)}\
412.scenario summary{cursor:pointer;padding:.6rem .8rem;list-style:none;display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}\
413.scenario summary::-webkit-details-marker{display:none}\
414.pill{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;padding:.1rem .5rem;border-radius:4px;color:#fff}\
415.pill.pass{background:var(--pass)}.pill.fail{background:var(--fail)}.pill.skip{background:var(--skip)}.pill.warn{background:var(--warn)}\
416.loc{color:var(--muted);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.85rem}\
417.artifact{margin-left:auto;font-size:.85rem;color:var(--muted)}\
418.phase-note{color:var(--muted);font-size:.78rem;font-style:italic}\
419.steps{margin:0;padding:.2rem .8rem .8rem 2rem;border-top:1px solid var(--line)}\
420.steps li{margin:.3rem 0}.steps .glyph{font-weight:700}\
421li.pass .glyph{color:var(--pass)}li.fail .glyph{color:var(--fail)}li.skip .glyph{color:var(--skip)}li.warn .glyph{color:var(--warn)}\
422.meta{color:var(--muted);font-size:.8rem;margin-left:.4rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}\
423.track{display:block;height:4px;margin:.25rem 0 0;background:var(--line);border-radius:2px;overflow:hidden}\
424.bar{display:block;height:100%;min-width:1px;border-radius:2px}\
425.bar.pass{background:var(--pass)}.bar.fail{background:var(--fail)}.bar.skip{background:var(--skip)}.bar.warn{background:var(--warn)}\
426.timeline-h{font-size:1.05rem;font-weight:600;margin:1.5rem 0 .5rem}\
427.timeline{margin:0 0 1.5rem}\
428.lane{display:flex;align-items:center;gap:.5rem;margin:.25rem 0}\
429.lane-label{color:var(--muted);font-size:.75rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;min-width:5rem;text-align:right}\
430.lane-track{position:relative;flex:1;height:1rem;background:var(--card);border:1px solid var(--line);border-radius:3px}\
431.tbar{position:absolute;top:1px;height:calc(100% - 2px);min-width:2px;border-radius:2px;opacity:.9}\
432.tbar.pass{background:var(--pass)}.tbar.fail{background:var(--fail)}.tbar.skip{background:var(--skip)}.tbar.warn{background:var(--warn)}\
433.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}\
434";