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 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(html, &format_duration(result.duration), "Duration", "");
253
254 let _ = writeln!(html, "</div>");
255}
256
257fn write_card(html: &mut String, value: &str, label: &str, class: &str) {
258 let _ = writeln!(
259 html,
260 "<div class=\"card\"><div class=\"value{class}\">{value}</div><div class=\"label\">{label}</div></div>"
261 );
262}
263
264fn write_progress_bar(html: &mut String, result: &TestRunResult) {
265 let total = result.total_tests();
266 if total == 0 {
267 return;
268 }
269
270 let pass_pct = result.total_passed() as f64 / total as f64 * 100.0;
271 let fail_pct = result.total_failed() as f64 / total as f64 * 100.0;
272 let skip_pct = result.total_skipped() as f64 / total as f64 * 100.0;
273
274 let _ = writeln!(html, "<div class=\"progress\">");
275 if pass_pct > 0.0 {
276 let _ = writeln!(
277 html,
278 " <div class=\"pass\" style=\"width:{pass_pct:.1}%\" title=\"{} passed\"></div>",
279 result.total_passed()
280 );
281 }
282 if fail_pct > 0.0 {
283 let _ = writeln!(
284 html,
285 " <div class=\"fail\" style=\"width:{fail_pct:.1}%\" title=\"{} failed\"></div>",
286 result.total_failed()
287 );
288 }
289 if skip_pct > 0.0 {
290 let _ = writeln!(
291 html,
292 " <div class=\"skip\" style=\"width:{skip_pct:.1}%\" title=\"{} skipped\"></div>",
293 result.total_skipped()
294 );
295 }
296 let _ = writeln!(html, "</div>");
297}
298
299fn write_suite_table(html: &mut String, result: &TestRunResult) {
300 let _ = writeln!(html, "<h2>Suites</h2>");
301 let _ = writeln!(html, "<table>");
302 let _ = writeln!(
303 html,
304 "<thead><tr><th>Suite</th><th>Tests</th><th>Passed</th><th>Failed</th><th>Skipped</th><th>Status</th></tr></thead>"
305 );
306 let _ = writeln!(html, "<tbody>");
307
308 for suite in &result.suites {
309 let status = if suite.is_passed() {
310 "<span class=\"pass-text\"></span>"
311 } else {
312 "<span class=\"fail-text\"></span>"
313 };
314 let name = html_escape(&suite.name);
315 let _ = writeln!(
316 html,
317 "<tr><td>{name}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{status}</td></tr>",
318 suite.tests.len(),
319 suite.passed(),
320 suite.failed(),
321 suite.skipped(),
322 );
323 }
324
325 let _ = writeln!(html, "</tbody></table>");
326}
327
328fn write_suite_details(html: &mut String, result: &TestRunResult, config: &HtmlConfig) {
329 let _ = writeln!(html, "<h2>Details</h2>");
330
331 for suite in &result.suites {
332 let icon = if suite.is_passed() { "✅" } else { "❌" };
333 let name = html_escape(&suite.name);
334 let open = if !suite.is_passed() { " open" } else { "" };
335
336 let _ = writeln!(html, "<details{open}>");
337 let _ = writeln!(
338 html,
339 "<summary>{icon} {name} ({} tests, {} passed, {} failed)</summary>",
340 suite.tests.len(),
341 suite.passed(),
342 suite.failed(),
343 );
344 let _ = writeln!(html, "<div class=\"content\">");
345 let _ = writeln!(html, "<table>");
346 let _ = write!(html, "<thead><tr><th>Test</th><th>Status</th>");
347 if config.show_durations {
348 html.push_str("<th>Duration</th>");
349 }
350 let _ = writeln!(html, "<th>Error</th></tr></thead>");
351 let _ = writeln!(html, "<tbody>");
352
353 for test in &suite.tests {
354 let (class, icon) = match test.status {
355 TestStatus::Passed => ("pass-text", ""),
356 TestStatus::Failed => ("fail-text", ""),
357 TestStatus::Skipped => ("skip-text", "⏭️"),
358 };
359 let test_name = html_escape(&test.name);
360 let _ = write!(
361 html,
362 "<tr><td>{test_name}</td><td class=\"{class}\">{icon} {:?}</td>",
363 test.status
364 );
365 if config.show_durations {
366 let _ = write!(html, "<td>{}</td>", format_duration(test.duration));
367 }
368 let error_cell = test
369 .error
370 .as_ref()
371 .map(|e| format!("<pre>{}</pre>", html_escape(&e.message)))
372 .unwrap_or_default();
373 let _ = writeln!(html, "<td>{error_cell}</td></tr>");
374 }
375
376 let _ = writeln!(html, "</tbody></table>");
377 let _ = writeln!(html, "</div></details>");
378 }
379}
380
381fn write_failures_section(html: &mut String, result: &TestRunResult) {
382 let _ = writeln!(html, "<h2>Failures</h2>");
383
384 for suite in &result.suites {
385 for test in suite.failures() {
386 let suite_name = html_escape(&suite.name);
387 let test_name = html_escape(&test.name);
388 let _ = writeln!(html, "<h3> {suite_name}::{test_name}</h3>");
389 if let Some(ref error) = test.error {
390 let msg = html_escape(&error.message);
391 let _ = writeln!(html, "<pre>{msg}</pre>");
392 if let Some(ref loc) = error.location {
393 let loc = html_escape(loc);
394 let _ = writeln!(html, "<p>at <code>{loc}</code></p>");
395 }
396 }
397 }
398 }
399}
400
401fn write_slowest_section(html: &mut String, result: &TestRunResult, n: usize) {
402 let slowest = result.slowest_tests(n);
403 if slowest.is_empty() {
404 return;
405 }
406
407 let _ = writeln!(html, "<h2>Slowest Tests</h2>");
408 let _ = writeln!(html, "<table>");
409 let _ = writeln!(
410 html,
411 "<thead><tr><th>#</th><th>Test</th><th>Suite</th><th>Duration</th></tr></thead>"
412 );
413 let _ = writeln!(html, "<tbody>");
414
415 for (i, (suite, test)) in slowest.iter().enumerate() {
416 let suite_name = html_escape(&suite.name);
417 let test_name = html_escape(&test.name);
418 let _ = writeln!(
419 html,
420 "<tr><td>{}</td><td>{test_name}</td><td>{suite_name}</td><td>{}</td></tr>",
421 i + 1,
422 format_duration(test.duration),
423 );
424 }
425
426 let _ = writeln!(html, "</tbody></table>");
427}
428
429fn write_footer(html: &mut String) {
430 let _ = writeln!(html, "<footer>Generated by testx</footer>");
431}
432
433fn html_escape(s: &str) -> String {
435 s.replace('&', "&")
436 .replace('<', "<")
437 .replace('>', ">")
438 .replace('"', """)
439 .replace('\'', "'")
440}
441
442fn format_duration(d: Duration) -> String {
443 let ms = d.as_millis();
444 if ms == 0 {
445 "<1ms".to_string()
446 } else if ms < 1000 {
447 format!("{ms}ms")
448 } else {
449 format!("{:.2}s", d.as_secs_f64())
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use crate::adapters::{TestCase, TestError, TestSuite};
457
458 fn make_test(name: &str, status: TestStatus, ms: u64) -> TestCase {
459 TestCase {
460 name: name.into(),
461 status,
462 duration: Duration::from_millis(ms),
463 error: None,
464 }
465 }
466
467 fn make_failed_test(name: &str, ms: u64, msg: &str) -> TestCase {
468 TestCase {
469 name: name.into(),
470 status: TestStatus::Failed,
471 duration: Duration::from_millis(ms),
472 error: Some(TestError {
473 message: msg.into(),
474 location: Some("test.rs:10".into()),
475 }),
476 }
477 }
478
479 fn make_result() -> TestRunResult {
480 TestRunResult {
481 suites: vec![
482 TestSuite {
483 name: "math".into(),
484 tests: vec![
485 make_test("add", TestStatus::Passed, 10),
486 make_test("sub", TestStatus::Passed, 20),
487 make_failed_test("div", 5, "division by zero"),
488 ],
489 },
490 TestSuite {
491 name: "strings".into(),
492 tests: vec![
493 make_test("concat", TestStatus::Passed, 15),
494 make_test("upper", TestStatus::Skipped, 0),
495 ],
496 },
497 ],
498 duration: Duration::from_millis(500),
499 raw_exit_code: 1,
500 }
501 }
502
503 #[test]
504 fn html_valid_document() {
505 let html = generate_html(&make_result(), &HtmlConfig::default());
506 assert!(html.starts_with("<!DOCTYPE html>"));
507 assert!(html.contains("<html lang=\"en\">"));
508 assert!(html.contains("</html>"));
509 }
510
511 #[test]
512 fn html_title() {
513 let config = HtmlConfig {
514 title: "My Tests".into(),
515 ..Default::default()
516 };
517 let html = generate_html(&make_result(), &config);
518 assert!(html.contains("<title>My Tests</title>"));
519 }
520
521 #[test]
522 fn html_summary_cards() {
523 let html = generate_html(&make_result(), &HtmlConfig::default());
524 assert!(html.contains("class=\"cards\""));
525 assert!(html.contains(">5<")); assert!(html.contains(">3<")); assert!(html.contains(">1<")); }
529
530 #[test]
531 fn html_progress_bar() {
532 let html = generate_html(&make_result(), &HtmlConfig::default());
533 assert!(html.contains("class=\"progress\""));
534 assert!(html.contains("class=\"pass\""));
535 assert!(html.contains("class=\"fail\""));
536 }
537
538 #[test]
539 fn html_suite_table() {
540 let html = generate_html(&make_result(), &HtmlConfig::default());
541 assert!(html.contains("<h2>Suites</h2>"));
542 assert!(html.contains("math"));
543 assert!(html.contains("strings"));
544 }
545
546 #[test]
547 fn html_suite_details() {
548 let html = generate_html(&make_result(), &HtmlConfig::default());
549 assert!(html.contains("<details"));
550 assert!(html.contains("<summary>"));
551 }
552
553 #[test]
554 fn html_failed_suite_open() {
555 let html = generate_html(&make_result(), &HtmlConfig::default());
556 assert!(html.contains("<details open>"));
558 }
559
560 #[test]
561 fn html_failures() {
562 let html = generate_html(&make_result(), &HtmlConfig::default());
563 assert!(html.contains("<h2>Failures</h2>"));
564 assert!(html.contains("division by zero"));
565 }
566
567 #[test]
568 fn html_error_location() {
569 let html = generate_html(&make_result(), &HtmlConfig::default());
570 assert!(html.contains("test.rs:10"));
571 }
572
573 #[test]
574 fn html_slowest() {
575 let html = generate_html(&make_result(), &HtmlConfig::default());
576 assert!(html.contains("<h2>Slowest Tests</h2>"));
577 }
578
579 #[test]
580 fn html_inline_styles() {
581 let html = generate_html(&make_result(), &HtmlConfig::default());
582 assert!(html.contains("<style>"));
583 }
584
585 #[test]
586 fn html_no_inline_styles() {
587 let config = HtmlConfig {
588 inline_styles: false,
589 ..Default::default()
590 };
591 let html = generate_html(&make_result(), &config);
592 assert!(!html.contains("<style>"));
593 }
594
595 #[test]
596 fn html_dark_mode() {
597 let config = HtmlConfig {
598 dark_mode: true,
599 ..Default::default()
600 };
601 let html = generate_html(&make_result(), &config);
602 assert!(html.contains("#1e1e2e"));
603 }
604
605 #[test]
606 fn html_no_failures_no_section() {
607 let result = TestRunResult {
608 suites: vec![TestSuite {
609 name: "t".into(),
610 tests: vec![make_test("t1", TestStatus::Passed, 1)],
611 }],
612 duration: Duration::from_millis(10),
613 raw_exit_code: 0,
614 };
615 let html = generate_html(&result, &HtmlConfig::default());
616 assert!(!html.contains("<h2>Failures</h2>"));
617 }
618
619 #[test]
620 fn html_single_suite_no_table() {
621 let result = TestRunResult {
622 suites: vec![TestSuite {
623 name: "single".into(),
624 tests: vec![make_test("t", TestStatus::Passed, 1)],
625 }],
626 duration: Duration::from_millis(10),
627 raw_exit_code: 0,
628 };
629 let html = generate_html(&result, &HtmlConfig::default());
630 assert!(!html.contains("<h2>Suites</h2>"));
631 }
632
633 #[test]
634 fn html_footer() {
635 let html = generate_html(&make_result(), &HtmlConfig::default());
636 assert!(html.contains("<footer>"));
637 assert!(html.contains("testx"));
638 }
639
640 #[test]
641 fn html_escape_xss() {
642 let result = TestRunResult {
643 suites: vec![TestSuite {
644 name: "<script>alert('xss')</script>".into(),
645 tests: vec![make_test("t", TestStatus::Passed, 1)],
646 }],
647 duration: Duration::from_millis(10),
648 raw_exit_code: 0,
649 };
650 let html = generate_html(&result, &HtmlConfig::default());
651 assert!(!html.contains("<script>"));
652 assert!(html.contains("<script>"));
653 }
654
655 #[test]
656 fn html_no_durations() {
657 let config = HtmlConfig {
658 show_durations: false,
659 ..Default::default()
660 };
661 let html = generate_html(&make_result(), &config);
662 assert!(html.contains("Details"));
664 }
665
666 #[test]
667 fn html_plugin_trait() {
668 let mut reporter = HtmlReporter::new(HtmlConfig::default());
669 assert_eq!(reporter.name(), "html");
670 assert_eq!(reporter.version(), "1.0.0");
671
672 reporter.on_result(&make_result()).unwrap();
673 assert!(reporter.output().contains("<!DOCTYPE html>"));
674 }
675
676 #[test]
677 fn html_pass_status() {
678 let result = TestRunResult {
679 suites: vec![TestSuite {
680 name: "t".into(),
681 tests: vec![make_test("t1", TestStatus::Passed, 1)],
682 }],
683 duration: Duration::from_millis(10),
684 raw_exit_code: 0,
685 };
686 let html = generate_html(&result, &HtmlConfig::default());
687 assert!(html.contains("PASSED"));
688 }
689
690 #[test]
691 fn html_escape_quotes() {
692 let escaped = html_escape("say \"hello\" & 'bye'");
693 assert_eq!(escaped, "say "hello" & 'bye'");
694 }
695
696 #[test]
697 fn html_empty_result() {
698 let result = TestRunResult {
699 suites: vec![],
700 duration: Duration::ZERO,
701 raw_exit_code: 0,
702 };
703 let html = generate_html(&result, &HtmlConfig::default());
704 assert!(html.contains("<!DOCTYPE html>"));
705 assert!(html.contains("PASSED"));
706 }
707}