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        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
442/// Escape HTML special characters.
443fn html_escape(s: &str) -> String {
444    s.replace('&', "&amp;")
445        .replace('<', "&lt;")
446        .replace('>', "&gt;")
447        .replace('"', "&quot;")
448        .replace('\'', "&#39;")
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<")); // total
535        assert!(html.contains(">3<")); // passed
536        assert!(html.contains(">1<")); // failed
537    }
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        // Failed suite should have 'open' attribute
566        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("&lt;script&gt;"));
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        // Duration column header not present in test detail tables
672        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 &quot;hello&quot; &amp; &#39;bye&#39;");
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    // ─── Edge Case Tests ────────────────────────────────────────────────
718
719    #[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<")); // total = 2
734        assert!(html.contains("class=\"skip\"")); // skip segment in progress bar
735        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        // Progress bar should not render with 0 total
750        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("&lt;script&gt;"));
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("&lt;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")); // dark bg
806        assert!(html.contains("#cdd6f4")); // dark fg
807    }
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")); // light bg
817        assert!(html.contains("#212529")); // light fg
818    }
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        // Passing suite should NOT have 'open' attribute
832        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        // 50% pass, 25% fail, 25% skip
869        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 &amp; b"));
970        assert!(!html.contains("a & b"));
971    }
972}