1use std::fmt::Write;
7use std::time::Duration;
8
9use crate::adapters::{TestRunResult, TestStatus};
10use crate::error;
11use crate::events::TestEvent;
12use crate::plugin::Plugin;
13
14#[derive(Debug, Clone)]
16pub struct HtmlConfig {
17 pub output_path: Option<String>,
19 pub title: String,
21 pub inline_styles: bool,
23 pub show_durations: bool,
25 pub show_slowest: usize,
27 pub dark_mode: bool,
29}
30
31impl Default for HtmlConfig {
32 fn default() -> Self {
33 Self {
34 output_path: None,
35 title: "Test Report".into(),
36 inline_styles: true,
37 show_durations: true,
38 show_slowest: 5,
39 dark_mode: false,
40 }
41 }
42}
43
44pub struct HtmlReporter {
46 config: HtmlConfig,
47 output: String,
48}
49
50impl HtmlReporter {
51 pub fn new(config: HtmlConfig) -> Self {
52 Self {
53 config,
54 output: String::new(),
55 }
56 }
57
58 pub fn output(&self) -> &str {
60 &self.output
61 }
62}
63
64impl Plugin for HtmlReporter {
65 fn name(&self) -> &str {
66 "html"
67 }
68
69 fn version(&self) -> &str {
70 "1.0.0"
71 }
72
73 fn on_event(&mut self, _event: &TestEvent) -> error::Result<()> {
74 Ok(())
75 }
76
77 fn on_result(&mut self, result: &TestRunResult) -> error::Result<()> {
78 self.output = generate_html(result, &self.config);
79 Ok(())
80 }
81}
82
83pub fn generate_html(result: &TestRunResult, config: &HtmlConfig) -> String {
85 let mut html = String::with_capacity(8192);
86
87 write_doctype(&mut html);
88 write_head(&mut html, config);
89 write_body_open(&mut html);
90 write_header_section(&mut html, result, config);
91 write_summary_cards(&mut html, result);
92 write_progress_bar(&mut html, result);
93
94 if result.suites.len() > 1 {
95 write_suite_table(&mut html, result);
96 }
97
98 write_suite_details(&mut html, result, config);
99
100 if result.total_failed() > 0 {
101 write_failures_section(&mut html, result);
102 }
103
104 if config.show_slowest > 0 {
105 write_slowest_section(&mut html, result, config.show_slowest);
106 }
107
108 write_footer(&mut html);
109 write_body_close(&mut html);
110
111 html
112}
113
114fn write_doctype(html: &mut String) {
115 html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n");
116}
117
118fn write_head(html: &mut String, config: &HtmlConfig) {
119 let _ = writeln!(html, "<head>");
120 let _ = writeln!(
121 html,
122 "<meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
123 );
124 let title = html_escape(&config.title);
125 let _ = writeln!(html, "<title>{title}</title>");
126
127 if config.inline_styles {
128 write_styles(html, config.dark_mode);
129 }
130
131 let _ = writeln!(html, "</head>");
132}
133
134fn write_styles(html: &mut String, dark: bool) {
135 let bg = if dark { "#1e1e2e" } else { "#f8f9fa" };
136 let fg = if dark { "#cdd6f4" } else { "#212529" };
137 let card_bg = if dark { "#313244" } else { "#ffffff" };
138 let border = if dark { "#45475a" } else { "#dee2e6" };
139
140 let _ = writeln!(html, "<style>");
141 let _ = writeln!(
142 html,
143 ":root{{--bg:{bg};--fg:{fg};--card:{card_bg};--border:{border};}}"
144 );
145 let _ = writeln!(html, "* {{margin:0;padding:0;box-sizing:border-box;}}");
146 let _ = writeln!(
147 html,
148 "body {{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;\
149 background:var(--bg);color:var(--fg);line-height:1.6;padding:2rem;max-width:1200px;margin:0 auto;}}"
150 );
151 let _ = write!(
152 html,
153 "h1 {{font-size:1.8rem;margin-bottom:0.5rem;}}\n\
154 h2 {{font-size:1.3rem;margin:1.5rem 0 0.5rem;border-bottom:2px solid var(--border);padding-bottom:0.25rem;}}\n\
155 h3 {{font-size:1.1rem;margin:1rem 0 0.5rem;}}\n"
156 );
157 let _ = write!(
158 html,
159 ".cards {{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:1rem;margin:1rem 0;}}\n\
160 .card {{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:1rem;text-align:center;}}\n\
161 .card .value {{font-size:2rem;font-weight:700;}}\n\
162 .card .label {{font-size:0.85rem;opacity:0.7;}}\n"
163 );
164 let _ = write!(
165 html,
166 ".progress {{height:24px;border-radius:12px;overflow:hidden;display:flex;margin:1rem 0;\
167 background:var(--border);}}\n\
168 .progress .pass {{background:#40a02b;}}\n\
169 .progress .fail {{background:#d20f39;}}\n\
170 .progress .skip {{background:#df8e1d;}}\n"
171 );
172 let _ = write!(
173 html,
174 "table {{width:100%;border-collapse:collapse;margin:0.5rem 0;}}\n\
175 th,td {{padding:0.5rem 0.75rem;text-align:left;border-bottom:1px solid var(--border);}}\n\
176 th {{background:var(--card);font-weight:600;}}\n"
177 );
178 let _ = write!(
179 html,
180 "details {{margin:0.5rem 0;border:1px solid var(--border);border-radius:4px;overflow:hidden;}}\n\
181 summary {{padding:0.5rem 0.75rem;cursor:pointer;background:var(--card);font-weight:500;}}\n\
182 summary:hover {{opacity:0.8;}}\n\
183 details .content {{padding:0.75rem;}}\n"
184 );
185 let _ = write!(
186 html,
187 ".pass-text {{color:#40a02b;}}\n\
188 .fail-text {{color:#d20f39;}}\n\
189 .skip-text {{color:#df8e1d;}}\n"
190 );
191 let _ = writeln!(
192 html,
193 "pre {{background:var(--card);border:1px solid var(--border);border-radius:4px;\
194 padding:0.75rem;overflow-x:auto;font-size:0.85rem;margin:0.5rem 0;}}"
195 );
196 let _ = writeln!(
197 html,
198 "footer {{margin-top:2rem;padding-top:1rem;border-top:1px solid var(--border);\
199 font-size:0.8rem;opacity:0.6;text-align:center;}}"
200 );
201 let _ = writeln!(html, "</style>");
202}
203
204fn write_body_open(html: &mut String) {
205 html.push_str("<body>\n");
206}
207
208fn write_body_close(html: &mut String) {
209 html.push_str("</body>\n</html>\n");
210}
211
212fn write_header_section(html: &mut String, result: &TestRunResult, config: &HtmlConfig) {
213 let status = if result.is_success() {
214 "<span class=\"pass-text\"> PASSED</span>"
215 } else {
216 "<span class=\"fail-text\"> FAILED</span>"
217 };
218 let title = html_escape(&config.title);
219
220 let _ = writeln!(html, "<h1>{title} — {status}</h1>");
221 let _ = writeln!(
222 html,
223 "<p>Duration: {} | Exit code: {}</p>",
224 html_escape(&format_duration(result.duration)),
225 result.raw_exit_code,
226 );
227}
228
229fn write_summary_cards(html: &mut String, result: &TestRunResult) {
230 let _ = writeln!(html, "<div class=\"cards\">");
231
232 write_card(html, &result.total_tests().to_string(), "Total", "");
233 write_card(
234 html,
235 &result.total_passed().to_string(),
236 "Passed",
237 " pass-text",
238 );
239 write_card(
240 html,
241 &result.total_failed().to_string(),
242 "Failed",
243 " fail-text",
244 );
245 write_card(
246 html,
247 &result.total_skipped().to_string(),
248 "Skipped",
249 " skip-text",
250 );
251 write_card(html, &result.suites.len().to_string(), "Suites", "");
252 write_card(
253 html,
254 &html_escape(&format_duration(result.duration)),
255 "Duration",
256 "",
257 );
258
259 let _ = writeln!(html, "</div>");
260}
261
262fn write_card(html: &mut String, value: &str, label: &str, class: &str) {
263 let _ = writeln!(
264 html,
265 "<div class=\"card\"><div class=\"value{class}\">{value}</div><div class=\"label\">{label}</div></div>"
266 );
267}
268
269fn write_progress_bar(html: &mut String, result: &TestRunResult) {
270 let total = result.total_tests();
271 if total == 0 {
272 return;
273 }
274
275 let pass_pct = result.total_passed() as f64 / total as f64 * 100.0;
276 let fail_pct = result.total_failed() as f64 / total as f64 * 100.0;
277 let skip_pct = result.total_skipped() as f64 / total as f64 * 100.0;
278
279 let _ = writeln!(html, "<div class=\"progress\">");
280 if pass_pct > 0.0 {
281 let _ = writeln!(
282 html,
283 " <div class=\"pass\" style=\"width:{pass_pct:.1}%\" title=\"{} passed\"></div>",
284 result.total_passed()
285 );
286 }
287 if fail_pct > 0.0 {
288 let _ = writeln!(
289 html,
290 " <div class=\"fail\" style=\"width:{fail_pct:.1}%\" title=\"{} failed\"></div>",
291 result.total_failed()
292 );
293 }
294 if skip_pct > 0.0 {
295 let _ = writeln!(
296 html,
297 " <div class=\"skip\" style=\"width:{skip_pct:.1}%\" title=\"{} skipped\"></div>",
298 result.total_skipped()
299 );
300 }
301 let _ = writeln!(html, "</div>");
302}
303
304fn write_suite_table(html: &mut String, result: &TestRunResult) {
305 let _ = writeln!(html, "<h2>Suites</h2>");
306 let _ = writeln!(html, "<table>");
307 let _ = writeln!(
308 html,
309 "<thead><tr><th>Suite</th><th>Tests</th><th>Passed</th><th>Failed</th><th>Skipped</th><th>Status</th></tr></thead>"
310 );
311 let _ = writeln!(html, "<tbody>");
312
313 for suite in &result.suites {
314 let status = if suite.is_passed() {
315 "<span class=\"pass-text\"></span>"
316 } else {
317 "<span class=\"fail-text\"></span>"
318 };
319 let name = html_escape(&suite.name);
320 let _ = writeln!(
321 html,
322 "<tr><td>{name}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{status}</td></tr>",
323 suite.tests.len(),
324 suite.passed(),
325 suite.failed(),
326 suite.skipped(),
327 );
328 }
329
330 let _ = writeln!(html, "</tbody></table>");
331}
332
333fn write_suite_details(html: &mut String, result: &TestRunResult, config: &HtmlConfig) {
334 let _ = writeln!(html, "<h2>Details</h2>");
335
336 for suite in &result.suites {
337 let icon = if suite.is_passed() { "✅" } else { "❌" };
338 let name = html_escape(&suite.name);
339 let open = if !suite.is_passed() { " open" } else { "" };
340
341 let _ = writeln!(html, "<details{open}>");
342 let _ = writeln!(
343 html,
344 "<summary>{icon} {name} ({} tests, {} passed, {} failed)</summary>",
345 suite.tests.len(),
346 suite.passed(),
347 suite.failed(),
348 );
349 let _ = writeln!(html, "<div class=\"content\">");
350 let _ = writeln!(html, "<table>");
351 let _ = write!(html, "<thead><tr><th>Test</th><th>Status</th>");
352 if config.show_durations {
353 html.push_str("<th>Duration</th>");
354 }
355 let _ = writeln!(html, "<th>Error</th></tr></thead>");
356 let _ = writeln!(html, "<tbody>");
357
358 for test in &suite.tests {
359 let (class, icon) = match test.status {
360 TestStatus::Passed => ("pass-text", ""),
361 TestStatus::Failed => ("fail-text", ""),
362 TestStatus::Skipped => ("skip-text", "⏭️"),
363 };
364 let test_name = html_escape(&test.name);
365 let _ = write!(
366 html,
367 "<tr><td>{test_name}</td><td class=\"{class}\">{icon} {:?}</td>",
368 test.status
369 );
370 if config.show_durations {
371 let _ = write!(
372 html,
373 "<td>{}</td>",
374 html_escape(&format_duration(test.duration))
375 );
376 }
377 let error_cell = test
378 .error
379 .as_ref()
380 .map(|e| format!("<pre>{}</pre>", html_escape(&e.message)))
381 .unwrap_or_default();
382 let _ = writeln!(html, "<td>{error_cell}</td></tr>");
383 }
384
385 let _ = writeln!(html, "</tbody></table>");
386 let _ = writeln!(html, "</div></details>");
387 }
388}
389
390fn write_failures_section(html: &mut String, result: &TestRunResult) {
391 let _ = writeln!(html, "<h2>Failures</h2>");
392
393 for suite in &result.suites {
394 for test in suite.failures() {
395 let suite_name = html_escape(&suite.name);
396 let test_name = html_escape(&test.name);
397 let _ = writeln!(html, "<h3> {suite_name}::{test_name}</h3>");
398 if let Some(ref error) = test.error {
399 let msg = html_escape(&error.message);
400 let _ = writeln!(html, "<pre>{msg}</pre>");
401 if let Some(ref loc) = error.location {
402 let loc = html_escape(loc);
403 let _ = writeln!(html, "<p>at <code>{loc}</code></p>");
404 }
405 }
406 }
407 }
408}
409
410fn write_slowest_section(html: &mut String, result: &TestRunResult, n: usize) {
411 let slowest = result.slowest_tests(n);
412 if slowest.is_empty() {
413 return;
414 }
415
416 let _ = writeln!(html, "<h2>Slowest Tests</h2>");
417 let _ = writeln!(html, "<table>");
418 let _ = writeln!(
419 html,
420 "<thead><tr><th>#</th><th>Test</th><th>Suite</th><th>Duration</th></tr></thead>"
421 );
422 let _ = writeln!(html, "<tbody>");
423
424 for (i, (suite, test)) in slowest.iter().enumerate() {
425 let suite_name = html_escape(&suite.name);
426 let test_name = html_escape(&test.name);
427 let _ = writeln!(
428 html,
429 "<tr><td>{}</td><td>{test_name}</td><td>{suite_name}</td><td>{}</td></tr>",
430 i + 1,
431 html_escape(&format_duration(test.duration)),
432 );
433 }
434
435 let _ = writeln!(html, "</tbody></table>");
436}
437
438fn write_footer(html: &mut String) {
439 let _ = writeln!(html, "<footer>Generated by testx</footer>");
440}
441
442fn html_escape(s: &str) -> String {
444 s.replace('&', "&")
445 .replace('<', "<")
446 .replace('>', ">")
447 .replace('"', """)
448 .replace('\'', "'")
449}
450
451fn format_duration(d: Duration) -> String {
452 let ms = d.as_millis();
453 if ms == 0 {
454 "<1ms".to_string()
455 } else if ms < 1000 {
456 format!("{ms}ms")
457 } else {
458 format!("{:.2}s", d.as_secs_f64())
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use crate::adapters::{TestCase, TestError, TestSuite};
466
467 fn make_test(name: &str, status: TestStatus, ms: u64) -> TestCase {
468 TestCase {
469 name: name.into(),
470 status,
471 duration: Duration::from_millis(ms),
472 error: None,
473 }
474 }
475
476 fn make_failed_test(name: &str, ms: u64, msg: &str) -> TestCase {
477 TestCase {
478 name: name.into(),
479 status: TestStatus::Failed,
480 duration: Duration::from_millis(ms),
481 error: Some(TestError {
482 message: msg.into(),
483 location: Some("test.rs:10".into()),
484 }),
485 }
486 }
487
488 fn make_result() -> TestRunResult {
489 TestRunResult {
490 suites: vec![
491 TestSuite {
492 name: "math".into(),
493 tests: vec![
494 make_test("add", TestStatus::Passed, 10),
495 make_test("sub", TestStatus::Passed, 20),
496 make_failed_test("div", 5, "division by zero"),
497 ],
498 },
499 TestSuite {
500 name: "strings".into(),
501 tests: vec![
502 make_test("concat", TestStatus::Passed, 15),
503 make_test("upper", TestStatus::Skipped, 0),
504 ],
505 },
506 ],
507 duration: Duration::from_millis(500),
508 raw_exit_code: 1,
509 }
510 }
511
512 #[test]
513 fn html_valid_document() {
514 let html = generate_html(&make_result(), &HtmlConfig::default());
515 assert!(html.starts_with("<!DOCTYPE html>"));
516 assert!(html.contains("<html lang=\"en\">"));
517 assert!(html.contains("</html>"));
518 }
519
520 #[test]
521 fn html_title() {
522 let config = HtmlConfig {
523 title: "My Tests".into(),
524 ..Default::default()
525 };
526 let html = generate_html(&make_result(), &config);
527 assert!(html.contains("<title>My Tests</title>"));
528 }
529
530 #[test]
531 fn html_summary_cards() {
532 let html = generate_html(&make_result(), &HtmlConfig::default());
533 assert!(html.contains("class=\"cards\""));
534 assert!(html.contains(">5<")); assert!(html.contains(">3<")); assert!(html.contains(">1<")); }
538
539 #[test]
540 fn html_progress_bar() {
541 let html = generate_html(&make_result(), &HtmlConfig::default());
542 assert!(html.contains("class=\"progress\""));
543 assert!(html.contains("class=\"pass\""));
544 assert!(html.contains("class=\"fail\""));
545 }
546
547 #[test]
548 fn html_suite_table() {
549 let html = generate_html(&make_result(), &HtmlConfig::default());
550 assert!(html.contains("<h2>Suites</h2>"));
551 assert!(html.contains("math"));
552 assert!(html.contains("strings"));
553 }
554
555 #[test]
556 fn html_suite_details() {
557 let html = generate_html(&make_result(), &HtmlConfig::default());
558 assert!(html.contains("<details"));
559 assert!(html.contains("<summary>"));
560 }
561
562 #[test]
563 fn html_failed_suite_open() {
564 let html = generate_html(&make_result(), &HtmlConfig::default());
565 assert!(html.contains("<details open>"));
567 }
568
569 #[test]
570 fn html_failures() {
571 let html = generate_html(&make_result(), &HtmlConfig::default());
572 assert!(html.contains("<h2>Failures</h2>"));
573 assert!(html.contains("division by zero"));
574 }
575
576 #[test]
577 fn html_error_location() {
578 let html = generate_html(&make_result(), &HtmlConfig::default());
579 assert!(html.contains("test.rs:10"));
580 }
581
582 #[test]
583 fn html_slowest() {
584 let html = generate_html(&make_result(), &HtmlConfig::default());
585 assert!(html.contains("<h2>Slowest Tests</h2>"));
586 }
587
588 #[test]
589 fn html_inline_styles() {
590 let html = generate_html(&make_result(), &HtmlConfig::default());
591 assert!(html.contains("<style>"));
592 }
593
594 #[test]
595 fn html_no_inline_styles() {
596 let config = HtmlConfig {
597 inline_styles: false,
598 ..Default::default()
599 };
600 let html = generate_html(&make_result(), &config);
601 assert!(!html.contains("<style>"));
602 }
603
604 #[test]
605 fn html_dark_mode() {
606 let config = HtmlConfig {
607 dark_mode: true,
608 ..Default::default()
609 };
610 let html = generate_html(&make_result(), &config);
611 assert!(html.contains("#1e1e2e"));
612 }
613
614 #[test]
615 fn html_no_failures_no_section() {
616 let result = TestRunResult {
617 suites: vec![TestSuite {
618 name: "t".into(),
619 tests: vec![make_test("t1", TestStatus::Passed, 1)],
620 }],
621 duration: Duration::from_millis(10),
622 raw_exit_code: 0,
623 };
624 let html = generate_html(&result, &HtmlConfig::default());
625 assert!(!html.contains("<h2>Failures</h2>"));
626 }
627
628 #[test]
629 fn html_single_suite_no_table() {
630 let result = TestRunResult {
631 suites: vec![TestSuite {
632 name: "single".into(),
633 tests: vec![make_test("t", TestStatus::Passed, 1)],
634 }],
635 duration: Duration::from_millis(10),
636 raw_exit_code: 0,
637 };
638 let html = generate_html(&result, &HtmlConfig::default());
639 assert!(!html.contains("<h2>Suites</h2>"));
640 }
641
642 #[test]
643 fn html_footer() {
644 let html = generate_html(&make_result(), &HtmlConfig::default());
645 assert!(html.contains("<footer>"));
646 assert!(html.contains("testx"));
647 }
648
649 #[test]
650 fn html_escape_xss() {
651 let result = TestRunResult {
652 suites: vec![TestSuite {
653 name: "<script>alert('xss')</script>".into(),
654 tests: vec![make_test("t", TestStatus::Passed, 1)],
655 }],
656 duration: Duration::from_millis(10),
657 raw_exit_code: 0,
658 };
659 let html = generate_html(&result, &HtmlConfig::default());
660 assert!(!html.contains("<script>"));
661 assert!(html.contains("<script>"));
662 }
663
664 #[test]
665 fn html_no_durations() {
666 let config = HtmlConfig {
667 show_durations: false,
668 ..Default::default()
669 };
670 let html = generate_html(&make_result(), &config);
671 assert!(html.contains("Details"));
673 }
674
675 #[test]
676 fn html_plugin_trait() {
677 let mut reporter = HtmlReporter::new(HtmlConfig::default());
678 assert_eq!(reporter.name(), "html");
679 assert_eq!(reporter.version(), "1.0.0");
680
681 reporter.on_result(&make_result()).unwrap();
682 assert!(reporter.output().contains("<!DOCTYPE html>"));
683 }
684
685 #[test]
686 fn html_pass_status() {
687 let result = TestRunResult {
688 suites: vec![TestSuite {
689 name: "t".into(),
690 tests: vec![make_test("t1", TestStatus::Passed, 1)],
691 }],
692 duration: Duration::from_millis(10),
693 raw_exit_code: 0,
694 };
695 let html = generate_html(&result, &HtmlConfig::default());
696 assert!(html.contains("PASSED"));
697 }
698
699 #[test]
700 fn html_escape_quotes() {
701 let escaped = html_escape("say \"hello\" & 'bye'");
702 assert_eq!(escaped, "say "hello" & 'bye'");
703 }
704
705 #[test]
706 fn html_empty_result() {
707 let result = TestRunResult {
708 suites: vec![],
709 duration: Duration::ZERO,
710 raw_exit_code: 0,
711 };
712 let html = generate_html(&result, &HtmlConfig::default());
713 assert!(html.contains("<!DOCTYPE html>"));
714 assert!(html.contains("PASSED"));
715 }
716
717 #[test]
720 fn html_all_skipped() {
721 let result = TestRunResult {
722 suites: vec![TestSuite {
723 name: "skip-suite".into(),
724 tests: vec![
725 make_test("s1", TestStatus::Skipped, 0),
726 make_test("s2", TestStatus::Skipped, 0),
727 ],
728 }],
729 duration: Duration::from_millis(5),
730 raw_exit_code: 0,
731 };
732 let html = generate_html(&result, &HtmlConfig::default());
733 assert!(html.contains(">2<")); assert!(html.contains("class=\"skip\"")); assert!(!html.contains("<h2>Failures</h2>"));
736 }
737
738 #[test]
739 fn html_zero_tests_no_progress_bar() {
740 let result = TestRunResult {
741 suites: vec![TestSuite {
742 name: "empty".into(),
743 tests: vec![],
744 }],
745 duration: Duration::ZERO,
746 raw_exit_code: 0,
747 };
748 let html = generate_html(&result, &HtmlConfig::default());
749 assert!(!html.contains("class=\"pass\" style=\"width:"));
751 }
752
753 #[test]
754 fn html_custom_title() {
755 let config = HtmlConfig {
756 title: "My Special Report".into(),
757 ..Default::default()
758 };
759 let html = generate_html(&make_result(), &config);
760 assert!(html.contains("<title>My Special Report</title>"));
761 assert!(html.contains("My Special Report"));
762 }
763
764 #[test]
765 fn html_title_xss_escaped() {
766 let config = HtmlConfig {
767 title: "<script>alert('xss')</script>".into(),
768 ..Default::default()
769 };
770 let html = generate_html(&make_result(), &config);
771 assert!(!html.contains("<script>alert"));
772 assert!(html.contains("<script>"));
773 }
774
775 #[test]
776 fn html_error_message_xss_escaped() {
777 let result = TestRunResult {
778 suites: vec![TestSuite {
779 name: "s".into(),
780 tests: vec![TestCase {
781 name: "t".into(),
782 status: TestStatus::Failed,
783 duration: Duration::ZERO,
784 error: Some(crate::adapters::TestError {
785 message: "<img onerror=alert(1)>".into(),
786 location: None,
787 }),
788 }],
789 }],
790 duration: Duration::ZERO,
791 raw_exit_code: 1,
792 };
793 let html = generate_html(&result, &HtmlConfig::default());
794 assert!(!html.contains("<img onerror"));
795 assert!(html.contains("<img"));
796 }
797
798 #[test]
799 fn html_dark_mode_colors() {
800 let config = HtmlConfig {
801 dark_mode: true,
802 ..Default::default()
803 };
804 let html = generate_html(&make_result(), &config);
805 assert!(html.contains("#1e1e2e")); assert!(html.contains("#cdd6f4")); }
808
809 #[test]
810 fn html_light_mode_colors() {
811 let config = HtmlConfig {
812 dark_mode: false,
813 ..Default::default()
814 };
815 let html = generate_html(&make_result(), &config);
816 assert!(html.contains("#f8f9fa")); assert!(html.contains("#212529")); }
819
820 #[test]
821 fn html_passing_suite_not_auto_expanded() {
822 let result = TestRunResult {
823 suites: vec![TestSuite {
824 name: "good".into(),
825 tests: vec![make_test("t1", TestStatus::Passed, 1)],
826 }],
827 duration: Duration::ZERO,
828 raw_exit_code: 0,
829 };
830 let html = generate_html(&result, &HtmlConfig::default());
831 assert!(!html.contains("<details open>"));
833 assert!(html.contains("<details>"));
834 }
835
836 #[test]
837 fn html_failed_suite_auto_expanded() {
838 let html = generate_html(&make_result(), &HtmlConfig::default());
839 assert!(html.contains("<details open>"));
840 }
841
842 #[test]
843 fn html_no_slowest_section() {
844 let config = HtmlConfig {
845 show_slowest: 0,
846 ..Default::default()
847 };
848 let html = generate_html(&make_result(), &config);
849 assert!(!html.contains("Slowest Tests"));
850 }
851
852 #[test]
853 fn html_progress_bar_percentages() {
854 let result = TestRunResult {
855 suites: vec![TestSuite {
856 name: "s".into(),
857 tests: vec![
858 make_test("p1", TestStatus::Passed, 1),
859 make_test("p2", TestStatus::Passed, 1),
860 make_failed_test("f1", 1, "err"),
861 make_test("s1", TestStatus::Skipped, 0),
862 ],
863 }],
864 duration: Duration::ZERO,
865 raw_exit_code: 1,
866 };
867 let html = generate_html(&result, &HtmlConfig::default());
868 assert!(html.contains("50.0%"));
870 assert!(html.contains("25.0%"));
871 }
872
873 #[test]
874 fn html_100_percent_pass() {
875 let result = TestRunResult {
876 suites: vec![TestSuite {
877 name: "s".into(),
878 tests: vec![make_test("t", TestStatus::Passed, 1)],
879 }],
880 duration: Duration::ZERO,
881 raw_exit_code: 0,
882 };
883 let html = generate_html(&result, &HtmlConfig::default());
884 assert!(html.contains("100.0%"));
885 assert!(!html.contains("class=\"fail\""));
886 assert!(!html.contains("class=\"skip\""));
887 }
888
889 #[test]
890 fn html_exit_code_displayed() {
891 let result = TestRunResult {
892 suites: vec![],
893 duration: Duration::ZERO,
894 raw_exit_code: 42,
895 };
896 let html = generate_html(&result, &HtmlConfig::default());
897 assert!(html.contains("Exit code: 42"));
898 }
899
900 #[test]
901 fn html_duration_format_sub_ms() {
902 assert_eq!(format_duration(Duration::ZERO), "<1ms");
903 }
904
905 #[test]
906 fn html_duration_format_ms() {
907 assert_eq!(format_duration(Duration::from_millis(42)), "42ms");
908 }
909
910 #[test]
911 fn html_duration_format_seconds() {
912 assert_eq!(format_duration(Duration::from_millis(1500)), "1.50s");
913 }
914
915 #[test]
916 fn html_plugin_on_event_is_noop() {
917 let mut r = HtmlReporter::new(HtmlConfig::default());
918 assert!(
919 r.on_event(&crate::events::TestEvent::Warning {
920 message: "x".into()
921 })
922 .is_ok()
923 );
924 assert!(r.output().is_empty());
925 }
926
927 #[test]
928 fn html_plugin_shutdown() {
929 let mut r = HtmlReporter::new(HtmlConfig::default());
930 assert!(r.shutdown().is_ok());
931 }
932
933 #[test]
934 fn html_error_location_displayed() {
935 let html = generate_html(&make_result(), &HtmlConfig::default());
936 assert!(html.contains("test.rs:10"));
937 }
938
939 #[test]
940 fn html_many_suites_all_shown() {
941 let suites: Vec<TestSuite> = (0..10)
942 .map(|i| TestSuite {
943 name: format!("suite_{i}"),
944 tests: vec![make_test(&format!("t_{i}"), TestStatus::Passed, 1)],
945 })
946 .collect();
947 let result = TestRunResult {
948 suites,
949 duration: Duration::from_millis(50),
950 raw_exit_code: 0,
951 };
952 let html = generate_html(&result, &HtmlConfig::default());
953 for i in 0..10 {
954 assert!(html.contains(&format!("suite_{i}")));
955 }
956 }
957
958 #[test]
959 fn html_ampersand_in_test_name() {
960 let result = TestRunResult {
961 suites: vec![TestSuite {
962 name: "s".into(),
963 tests: vec![make_test("a & b", TestStatus::Passed, 1)],
964 }],
965 duration: Duration::ZERO,
966 raw_exit_code: 0,
967 };
968 let html = generate_html(&result, &HtmlConfig::default());
969 assert!(html.contains("a & b"));
970 assert!(!html.contains("a & b"));
971 }
972}