Skip to main content

testx/plugin/reporters/
html.rs

1//! HTML reporter plugin.
2//!
3//! Generates a self-contained HTML test report with a summary
4//! dashboard, expandable suite sections, and error display.
5
6use 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/// HTML reporter configuration.
15#[derive(Debug, Clone)]
16pub struct HtmlConfig {
17    /// Output file path (None = stdout)
18    pub output_path: Option<String>,
19    /// Custom page title
20    pub title: String,
21    /// Include inline CSS (true) or link to external stylesheet (false)
22    pub inline_styles: bool,
23    /// Show individual test durations
24    pub show_durations: bool,
25    /// Maximum number of slowest tests section
26    pub show_slowest: usize,
27    /// Enable dark mode theme
28    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
44/// HTML reporter plugin.
45pub 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    /// Get the generated HTML output.
59    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
83/// Generate a complete HTML report from test results.
84pub 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
433/// Escape HTML special characters.
434fn html_escape(s: &str) -> String {
435    s.replace('&', "&amp;")
436        .replace('<', "&lt;")
437        .replace('>', "&gt;")
438        .replace('"', "&quot;")
439        .replace('\'', "&#39;")
440}
441
442fn format_duration(d: Duration) -> String {
443    let ms = d.as_millis();
444    if ms == 0 {
445        "&lt;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<")); // total
526        assert!(html.contains(">3<")); // passed
527        assert!(html.contains(">1<")); // failed
528    }
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        // Failed suite should have 'open' attribute
557        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("&lt;script&gt;"));
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        // Duration column header not present in test detail tables
663        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 &quot;hello&quot; &amp; &#39;bye&#39;");
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}