1#![allow(clippy::write_with_newline)]
24
25use std::fmt::Write;
26
27use dev_report::{CheckResult, MultiReport, Report, Severity, Verdict};
28
29use crate::brand;
30
31pub fn multi_report_to_html(multi: &MultiReport) -> String {
50 let mut out = String::with_capacity(16 * 1024);
51 write_doc(&mut out, multi);
52 out
53}
54
55fn write_doc(out: &mut String, multi: &MultiReport) {
56 let overall = multi.overall_verdict();
57 let (pass, fail, warn, skip) = multi.verdict_counts();
58 let total = pass + fail + warn + skip;
59
60 out.push_str("<!DOCTYPE html>\n");
61 out.push_str("<html lang=\"en\">\n<head>\n");
62 out.push_str("<meta charset=\"UTF-8\">\n");
63 out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
64 out.push_str("<title>");
65 write_html_text(out, &multi.subject);
66 out.push_str(" v");
67 write_html_text(out, &multi.subject_version);
68 out.push_str(" — dev-tools meta-report</title>\n");
69 write_inline_css(out);
70 out.push_str("</head>\n<body>\n");
71
72 write_header(out, multi, overall, total);
73 write_summary_section(out, pass, fail, warn, skip);
74 write_duration_section(out, multi);
75 write_producers_section(out, multi);
76 write_footer(out);
77
78 out.push_str("</body>\n</html>\n");
79}
80
81fn write_inline_css(out: &mut String) {
82 out.push_str("<style>\n:root {\n");
83 write!(out, " --color-accent: {};\n", brand::COLOR_ACCENT).unwrap();
84 write!(out, " --color-pass: {};\n", brand::COLOR_PASS).unwrap();
85 write!(out, " --color-fail: {};\n", brand::COLOR_FAIL).unwrap();
86 write!(out, " --color-warn: {};\n", brand::COLOR_WARN).unwrap();
87 write!(out, " --color-lint: {};\n", brand::COLOR_LINT).unwrap();
88 write!(out, " --color-bg: {};\n", brand::COLOR_BG).unwrap();
89 write!(out, " --color-fg: {};\n", brand::COLOR_FG).unwrap();
90 out.push_str(" --color-muted: #888;\n");
91 out.push_str(" --color-surface: #1a1f26;\n");
92 out.push_str(" --color-border: #2a3038;\n");
93 out.push_str("}\n");
94 out.push_str(r#"
95* { box-sizing: border-box; }
96html, body {
97 margin: 0;
98 padding: 0;
99 background: var(--color-bg);
100 color: var(--color-fg);
101 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
102 line-height: 1.5;
103}
104main {
105 max-width: 1100px;
106 margin: 0 auto;
107 padding: 2rem 1.5rem 4rem;
108}
109header.page {
110 border-bottom: 1px solid var(--color-border);
111 padding-bottom: 1.5rem;
112 margin-bottom: 2rem;
113}
114header.page h1 {
115 margin: 0 0 .25rem;
116 font-size: 1.6rem;
117 font-weight: 600;
118}
119header.page .subtitle {
120 color: var(--color-muted);
121 font-size: .95rem;
122}
123.verdict-badge {
124 display: inline-block;
125 padding: .35rem .9rem;
126 border-radius: 4px;
127 font-size: .8rem;
128 font-weight: 700;
129 letter-spacing: .08em;
130 text-transform: uppercase;
131 margin-top: .85rem;
132 color: #fff;
133}
134.verdict-pass { background: var(--color-pass); }
135.verdict-fail { background: var(--color-fail); }
136.verdict-warn { background: var(--color-warn); color: #1a1a1a; }
137.verdict-skip { background: var(--color-muted); }
138section { margin: 2rem 0; }
139section > h2 {
140 font-size: 1.1rem;
141 font-weight: 600;
142 margin: 0 0 .9rem;
143 padding-bottom: .35rem;
144 border-bottom: 1px solid var(--color-border);
145}
146.counts {
147 display: flex;
148 flex-wrap: wrap;
149 gap: 1rem;
150 margin: .5rem 0 1.2rem;
151}
152.count {
153 flex: 1 1 6rem;
154 background: var(--color-surface);
155 border: 1px solid var(--color-border);
156 border-left: 4px solid var(--color-muted);
157 padding: .7rem 1rem;
158 border-radius: 3px;
159}
160.count.pass { border-left-color: var(--color-pass); }
161.count.fail { border-left-color: var(--color-fail); }
162.count.warn { border-left-color: var(--color-warn); }
163.count.skip { border-left-color: var(--color-muted); }
164.count .value { font-size: 1.4rem; font-weight: 700; display: block; }
165.count .label { font-size: .8rem; color: var(--color-muted); text-transform: uppercase; letter-spacing: .06em; }
166svg.bar-chart, svg.histogram {
167 width: 100%;
168 height: auto;
169 background: var(--color-surface);
170 border: 1px solid var(--color-border);
171 border-radius: 3px;
172 padding: .25rem;
173}
174details.producer {
175 background: var(--color-surface);
176 border: 1px solid var(--color-border);
177 border-radius: 3px;
178 margin: .5rem 0;
179 padding: 0;
180}
181details.producer > summary {
182 cursor: pointer;
183 padding: .75rem 1rem;
184 font-weight: 600;
185 user-select: none;
186 list-style: none;
187}
188details.producer > summary::-webkit-details-marker { display: none; }
189details.producer > summary::before {
190 content: "▸ ";
191 color: var(--color-muted);
192 font-weight: 400;
193 transition: transform .15s ease;
194 display: inline-block;
195 width: 1em;
196}
197details.producer[open] > summary::before { content: "▾ "; }
198details.producer summary .producer-meta {
199 color: var(--color-muted);
200 font-weight: 400;
201 margin-left: .5rem;
202 font-size: .9rem;
203}
204table.checks {
205 width: 100%;
206 border-collapse: collapse;
207 font-size: .9rem;
208}
209table.checks th, table.checks td {
210 text-align: left;
211 padding: .45rem .75rem;
212 border-top: 1px solid var(--color-border);
213 vertical-align: top;
214}
215table.checks th {
216 background: var(--color-bg);
217 color: var(--color-muted);
218 text-transform: uppercase;
219 font-size: .72rem;
220 letter-spacing: .06em;
221 font-weight: 600;
222}
223table.checks td.verdict {
224 font-weight: 600;
225 white-space: nowrap;
226 width: 6ch;
227}
228table.checks td.verdict.pass { color: var(--color-pass); }
229table.checks td.verdict.fail { color: var(--color-fail); }
230table.checks td.verdict.warn { color: var(--color-warn); }
231table.checks td.verdict.skip { color: var(--color-muted); }
232table.checks td.duration { color: var(--color-muted); white-space: nowrap; width: 8ch; text-align: right; }
233table.checks td.severity { color: var(--color-muted); white-space: nowrap; }
234table.checks td.detail { color: var(--color-fg); }
235table.checks td.detail .empty { color: var(--color-muted); }
236footer.page {
237 margin-top: 3rem;
238 padding-top: 1rem;
239 border-top: 1px solid var(--color-border);
240 color: var(--color-muted);
241 font-size: .85rem;
242 text-align: center;
243}
244@media print {
245 body { background: #fff; color: #000; }
246 main { max-width: none; padding: 0; }
247 details.producer { border: 1px solid #ccc; }
248 details.producer[open] > summary::before, details.producer > summary::before { content: ""; }
249 details.producer > div.producer-body { display: block !important; }
250}
251"#);
252 out.push_str("</style>\n");
253}
254
255fn write_header(out: &mut String, multi: &MultiReport, overall: Verdict, total: usize) {
256 out.push_str("<main>\n<header class=\"page\">\n");
257 out.push_str(" <h1>");
258 write_html_text(out, &multi.subject);
259 out.push_str(" <span class=\"version\">v");
260 write_html_text(out, &multi.subject_version);
261 out.push_str("</span></h1>\n");
262
263 out.push_str(" <div class=\"subtitle\">started ");
264 write_html_text(out, &multi.started_at.to_rfc3339());
265 if let Some(end) = multi.finished_at {
266 out.push_str(" · finished ");
267 write_html_text(out, &end.to_rfc3339());
268 }
269 write!(
270 out,
271 " · {} check{} across {} producer{}",
272 total,
273 if total == 1 { "" } else { "s" },
274 multi.reports.len(),
275 if multi.reports.len() == 1 { "" } else { "s" }
276 )
277 .unwrap();
278 out.push_str("</div>\n");
279
280 let verdict_class = match overall {
281 Verdict::Pass => "verdict-pass",
282 Verdict::Fail => "verdict-fail",
283 Verdict::Warn => "verdict-warn",
284 Verdict::Skip => "verdict-skip",
285 };
286 write!(out, " <div class=\"verdict-badge {}\">", verdict_class).unwrap();
287 out.push_str(verdict_label(overall));
288 out.push_str("</div>\n</header>\n");
289}
290
291fn verdict_label(v: Verdict) -> &'static str {
292 match v {
293 Verdict::Pass => "Pass",
294 Verdict::Fail => "Fail",
295 Verdict::Warn => "Warn",
296 Verdict::Skip => "Skip",
297 }
298}
299
300fn write_summary_section(out: &mut String, pass: usize, fail: usize, warn: usize, skip: usize) {
301 out.push_str("<section>\n <h2>Summary</h2>\n <div class=\"counts\">\n");
302 for (label, count, class) in [
303 ("Pass", pass, "pass"),
304 ("Fail", fail, "fail"),
305 ("Warn", warn, "warn"),
306 ("Skip", skip, "skip"),
307 ] {
308 write!(
309 out,
310 " <div class=\"count {}\"><span class=\"value\">{}</span><span class=\"label\">{}</span></div>\n",
311 class, count, label
312 )
313 .unwrap();
314 }
315 out.push_str(" </div>\n");
316 write_bar_chart(out, pass, fail, warn, skip);
317 out.push_str("</section>\n");
318}
319
320fn write_bar_chart(out: &mut String, pass: usize, fail: usize, warn: usize, skip: usize) {
321 let total = pass + fail + warn + skip;
322 if total == 0 {
323 return;
324 }
325 let width = 1000u32;
327 let height = 40u32;
328 let mut x = 0u32;
329 let mk_seg = |count: usize, color_var: &str| -> Option<(u32, String)> {
330 if count == 0 {
331 return None;
332 }
333 let w = ((count as f64 / total as f64) * width as f64).round() as u32;
334 if w == 0 {
335 return None;
336 }
337 Some((w, color_var.into()))
338 };
339 let segments: Vec<(usize, &'static str, &'static str)> = vec![
340 (pass, "var(--color-pass)", "Pass"),
341 (fail, "var(--color-fail)", "Fail"),
342 (warn, "var(--color-warn)", "Warn"),
343 (skip, "var(--color-muted)", "Skip"),
344 ];
345
346 write!(
347 out,
348 " <svg class=\"bar-chart\" viewBox=\"0 0 {} {}\" preserveAspectRatio=\"none\" aria-label=\"Verdict distribution: {} pass, {} fail, {} warn, {} skip\">\n",
349 width, height, pass, fail, warn, skip
350 )
351 .unwrap();
352 for (count, color, label) in segments {
353 if let Some((w, _)) = mk_seg(count, color) {
354 write!(
355 out,
356 " <rect x=\"{}\" y=\"0\" width=\"{}\" height=\"{}\" fill=\"{}\"><title>{} — {}</title></rect>\n",
357 x, w, height, color, label, count
358 )
359 .unwrap();
360 x = x.saturating_add(w);
361 }
362 }
363 if x < width {
365 write!(
367 out,
368 " <rect x=\"{}\" y=\"0\" width=\"{}\" height=\"{}\" fill=\"var(--color-muted)\"/>\n",
369 x,
370 width - x,
371 height
372 )
373 .unwrap();
374 }
375 out.push_str(" </svg>\n");
376}
377
378fn write_duration_section(out: &mut String, multi: &MultiReport) {
379 let mut durations: Vec<u64> = multi
380 .reports
381 .iter()
382 .flat_map(|r| r.checks.iter().filter_map(|c| c.duration_ms))
383 .collect();
384 if durations.is_empty() {
385 return;
386 }
387 durations.sort_unstable();
388 let min = *durations.first().unwrap();
389 let max = *durations.last().unwrap();
390 let count = durations.len();
391
392 out.push_str("<section>\n <h2>Duration distribution</h2>\n");
393 write!(
394 out,
395 " <div class=\"subtitle\" style=\"color:var(--color-muted);font-size:.85rem;margin-bottom:.5rem\">{} sample{}, {} ms min, {} ms max</div>\n",
396 count,
397 if count == 1 { "" } else { "s" },
398 min,
399 max
400 )
401 .unwrap();
402 write_histogram(out, &durations, min, max);
403 out.push_str("</section>\n");
404}
405
406fn write_histogram(out: &mut String, sorted_durations: &[u64], min: u64, max: u64) {
407 let buckets: usize = 10;
408 let mut bins = vec![0usize; buckets];
409 if min == max {
410 bins[0] = sorted_durations.len();
411 } else {
412 let range = max - min;
413 for &d in sorted_durations {
414 let idx = ((d - min) as f64 / range as f64 * buckets as f64) as usize;
415 let idx = idx.min(buckets - 1);
416 bins[idx] += 1;
417 }
418 }
419 let max_bin = bins.iter().copied().max().unwrap_or(1).max(1);
420 let width = 1000u32;
421 let height = 160u32;
422 let bar_w = width / buckets as u32;
423
424 write!(
425 out,
426 " <svg class=\"histogram\" viewBox=\"0 0 {} {}\" preserveAspectRatio=\"none\" aria-label=\"Histogram of check durations\">\n",
427 width, height
428 )
429 .unwrap();
430
431 for (i, &bin) in bins.iter().enumerate() {
432 let x = i as u32 * bar_w;
433 let h = ((bin as f64 / max_bin as f64) * (height - 10) as f64).round() as u32;
434 let y = height - h;
435 let bucket_lo = if max == min {
436 min
437 } else {
438 min + (max - min) * i as u64 / buckets as u64
439 };
440 let bucket_hi = if max == min {
441 max
442 } else {
443 min + (max - min) * (i as u64 + 1) / buckets as u64
444 };
445 write!(
446 out,
447 " <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"var(--color-accent)\"><title>{}\u{2013}{} ms — {} sample{}</title></rect>\n",
448 x + 1,
449 y,
450 bar_w.saturating_sub(2),
451 h,
452 bucket_lo,
453 bucket_hi,
454 bin,
455 if bin == 1 { "" } else { "s" }
456 )
457 .unwrap();
458 }
459 out.push_str(" </svg>\n");
460}
461
462fn write_producers_section(out: &mut String, multi: &MultiReport) {
463 out.push_str("<section>\n <h2>Per-producer reports</h2>\n");
464 if multi.reports.is_empty() {
465 out.push_str(" <p style=\"color:var(--color-muted)\">No reports.</p>\n");
466 out.push_str("</section>\n");
467 return;
468 }
469 for report in &multi.reports {
470 write_producer(out, report);
471 }
472 out.push_str("</section>\n");
473}
474
475fn write_producer(out: &mut String, report: &Report) {
476 let (pass, fail, warn, skip) = report.verdict_counts();
477 let total = pass + fail + warn + skip;
478 let producer_name = report.producer.as_deref().unwrap_or("(unnamed producer)");
479 let open_attr = if fail > 0 || warn > 0 { " open" } else { "" };
481 write!(out, " <details class=\"producer\"{}>\n", open_attr).unwrap();
482 out.push_str(" <summary>");
483 write_html_text(out, producer_name);
484 write!(
485 out,
486 "<span class=\"producer-meta\">— {} pass · {} fail · {} warn · {} skip · {} total</span>",
487 pass, fail, warn, skip, total
488 )
489 .unwrap();
490 out.push_str("</summary>\n");
491 out.push_str(" <div class=\"producer-body\">\n");
492 if report.checks.is_empty() {
493 out.push_str(" <p style=\"padding:0 1rem 1rem;color:var(--color-muted)\">No checks.</p>\n");
494 } else {
495 write_check_table(out, &report.checks);
496 }
497 out.push_str(" </div>\n </details>\n");
498}
499
500fn write_check_table(out: &mut String, checks: &[CheckResult]) {
501 out.push_str(" <table class=\"checks\">\n");
502 out.push_str(" <thead><tr><th>Check</th><th>Verdict</th><th>Severity</th><th>Duration</th><th>Detail</th></tr></thead>\n");
503 out.push_str(" <tbody>\n");
504 for c in checks {
505 write_check_row(out, c);
506 }
507 out.push_str(" </tbody>\n </table>\n");
508}
509
510fn write_check_row(out: &mut String, c: &CheckResult) {
511 let verdict_class = match c.verdict {
512 Verdict::Pass => "pass",
513 Verdict::Fail => "fail",
514 Verdict::Warn => "warn",
515 Verdict::Skip => "skip",
516 };
517 out.push_str(" <tr>\n");
518 out.push_str(" <td>");
519 write_html_text(out, &c.name);
520 out.push_str("</td>\n");
521 write!(
522 out,
523 " <td class=\"verdict {}\">{}</td>\n",
524 verdict_class,
525 verdict_label(c.verdict)
526 )
527 .unwrap();
528 out.push_str(" <td class=\"severity\">");
529 match c.severity {
530 Some(s) => out.push_str(severity_label(s)),
531 None => out.push('—'),
532 }
533 out.push_str("</td>\n <td class=\"duration\">");
534 match c.duration_ms {
535 Some(ms) => {
536 write!(out, "{} ms", ms).unwrap();
537 }
538 None => out.push('—'),
539 }
540 out.push_str("</td>\n <td class=\"detail\">");
541 match &c.detail {
542 Some(d) => write_html_text(out, d),
543 None => out.push_str("<span class=\"empty\">—</span>"),
544 }
545 out.push_str("</td>\n </tr>\n");
546}
547
548fn severity_label(s: Severity) -> &'static str {
549 match s {
550 Severity::Info => "Info",
551 Severity::Warning => "Warning",
552 Severity::Error => "Error",
553 Severity::Critical => "Critical",
554 }
555}
556
557fn write_footer(out: &mut String) {
558 out.push_str("<footer class=\"page\">");
559 write_html_text(out, brand::FOOTER);
560 out.push_str("</footer>\n</main>\n");
561}
562
563fn write_html_text(out: &mut String, s: &str) {
564 for ch in s.chars() {
565 match ch {
566 '&' => out.push_str("&"),
567 '<' => out.push_str("<"),
568 '>' => out.push_str(">"),
569 '"' => out.push_str("""),
570 '\'' => out.push_str("'"),
571 c => out.push(c),
572 }
573 }
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579 use chrono::TimeZone;
580 use dev_report::Evidence;
581
582 fn frozen_multi() -> MultiReport {
583 let t0 = chrono::Utc.with_ymd_and_hms(2026, 5, 11, 12, 0, 0).unwrap();
584 let mut bench = Report::new("crate", "0.1.0").with_producer("dev-bench");
585 bench.set_started_at(t0);
586 let mut c1 = CheckResult::pass("hot_path")
587 .with_duration_ms(120)
588 .with_evidence(Evidence::numeric("mean_ns", 1234.5));
589 c1.at = t0;
590 let mut c2 = CheckResult::pass("cold_path").with_duration_ms(45);
591 c2.at = t0;
592 bench.push(c1);
593 bench.push(c2);
594 bench.set_finished_at(Some(t0));
595
596 let mut chaos = Report::new("crate", "0.1.0").with_producer("dev-chaos");
597 chaos.set_started_at(t0);
598 let mut c3 = CheckResult::fail("recover", Severity::Error)
599 .with_detail("service did not recover within 5s")
600 .with_duration_ms(5_001);
601 c3.at = t0;
602 let mut c4 = CheckResult::warn("flaky::retry", Severity::Warning)
603 .with_detail("3rd retry of 5 was slow");
604 c4.at = t0;
605 let mut c5 = CheckResult::skip("network::flaky").with_detail("no net in sandbox");
606 c5.at = t0;
607 chaos.push(c3);
608 chaos.push(c4);
609 chaos.push(c5);
610 chaos.set_finished_at(Some(t0));
611
612 let mut multi = MultiReport::new("crate", "0.1.0");
613 multi.started_at = t0;
614 multi.push(bench);
615 multi.push(chaos);
616 multi.finished_at = Some(t0);
617 multi
618 }
619
620 #[test]
621 fn output_is_doctype_html() {
622 let html = multi_report_to_html(&frozen_multi());
623 assert!(html.starts_with("<!DOCTYPE html>"));
624 assert!(html.contains("<html lang=\"en\""));
625 assert!(html.contains("</html>"));
626 }
627
628 #[test]
629 fn output_contains_subject_and_version() {
630 let html = multi_report_to_html(&frozen_multi());
631 assert!(html.contains(">crate"));
632 assert!(html.contains("v0.1.0"));
633 }
634
635 #[test]
636 fn output_contains_verdict_badge_for_overall_fail() {
637 let html = multi_report_to_html(&frozen_multi());
638 assert!(html.contains("verdict-badge verdict-fail"));
639 assert!(html.contains(">Fail<"));
640 }
641
642 #[test]
643 fn output_contains_one_details_per_producer() {
644 let html = multi_report_to_html(&frozen_multi());
645 let n = html.matches("<details class=\"producer\"").count();
646 assert_eq!(n, 2);
647 assert!(html.contains("dev-bench"));
648 assert!(html.contains("dev-chaos"));
649 }
650
651 #[test]
652 fn output_is_deterministic() {
653 let multi = frozen_multi();
654 let a = multi_report_to_html(&multi);
655 let b = multi_report_to_html(&multi);
656 assert_eq!(a, b);
657 }
658
659 #[test]
660 fn output_escapes_special_chars() {
661 let mut r = Report::new("crate<&>", "0.1.0").with_producer("dev<bad>");
662 r.push(CheckResult::fail("name<&>\"'", Severity::Error).with_detail("oh <bad> & \"x\""));
663 let mut m = MultiReport::new("crate<&>", "0.1.0");
664 m.push(r);
665 let html = multi_report_to_html(&m);
666 assert!(html.contains("crate<&>"));
667 assert!(html.contains("name<&>"'"));
668 assert!(html.contains("oh <bad> & "x""));
669 assert!(!html.contains("crate<&>"));
670 }
671
672 #[test]
673 fn empty_multi_renders_without_panic() {
674 let m = MultiReport::new("empty", "0.0.0");
675 let html = multi_report_to_html(&m);
676 assert!(html.contains("No reports."));
677 assert!(html.contains("verdict-skip"));
678 }
679
680 #[test]
681 fn duration_section_present_when_any_duration() {
682 let html = multi_report_to_html(&frozen_multi());
683 assert!(html.contains("Duration distribution"));
684 assert!(html.contains("<svg class=\"histogram\""));
685 }
686
687 #[test]
688 fn duration_section_absent_when_no_durations() {
689 let mut r = Report::new("c", "0.1.0").with_producer("p");
690 r.push(CheckResult::pass("a"));
691 let mut m = MultiReport::new("c", "0.1.0");
692 m.push(r);
693 let html = multi_report_to_html(&m);
694 assert!(!html.contains("Duration distribution"));
695 assert!(!html.contains("<svg class=\"histogram\""));
696 }
697
698 #[test]
699 fn producer_with_failures_is_open_by_default() {
700 let html = multi_report_to_html(&frozen_multi());
701 assert!(html.contains("<details class=\"producer\" open>"));
703 let open_count = html.matches("<details class=\"producer\" open>").count();
704 let closed_count = html.matches("<details class=\"producer\">").count();
705 assert_eq!(open_count, 1);
706 assert_eq!(closed_count, 1);
707 }
708
709 #[test]
710 fn no_external_resources() {
711 let html = multi_report_to_html(&frozen_multi());
712 assert!(!html.contains("http://"));
713 assert!(!html.contains("<script src="));
716 assert!(!html.contains("<link rel="));
717 assert!(!html.contains("<img "));
718 }
719
720 #[test]
721 fn uses_css_custom_properties_for_brand_colors() {
722 let html = multi_report_to_html(&frozen_multi());
723 assert!(html.contains("--color-accent:"));
724 assert!(html.contains("--color-pass:"));
725 assert!(html.contains("--color-fail:"));
726 assert!(html.contains("--color-warn:"));
727 assert!(html.contains("var(--color-accent)"));
728 }
729
730 #[test]
731 fn footer_contains_brand_footer_constant() {
732 let html = multi_report_to_html(&frozen_multi());
733 assert!(html.contains(brand::FOOTER));
734 }
735
736 #[test]
737 fn output_size_is_reasonable() {
738 let html = multi_report_to_html(&frozen_multi());
740 assert!(html.len() < 64 * 1024, "got {} bytes", html.len());
741 assert!(
742 html.len() > 1_000,
743 "got {} bytes (likely truncated)",
744 html.len()
745 );
746 }
747}