cli_testing_specialist/reporter/
html.rs

1use crate::error::Result;
2use crate::types::{TestReport, TestStatus};
3use std::fs;
4use std::path::Path;
5
6/// HTML report generator with embedded Bootstrap 5
7pub struct HtmlReporter;
8
9impl HtmlReporter {
10    /// Generate HTML report from test results
11    pub fn generate(report: &TestReport, output_path: &Path) -> Result<()> {
12        let html = Self::render_html(report);
13        fs::write(output_path, html)?;
14        Ok(())
15    }
16
17    /// Render complete HTML document
18    fn render_html(report: &TestReport) -> String {
19        format!(
20            r#"<!DOCTYPE html>
21<html lang="en">
22<head>
23    <meta charset="UTF-8">
24    <meta name="viewport" content="width=device-width, initial-scale=1.0">
25    <title>Test Report - {}</title>
26    {}
27    {}
28</head>
29<body>
30    <div class="container py-5">
31        {}
32        {}
33        {}
34        {}
35        {}
36    </div>
37    {}
38</body>
39</html>"#,
40            report.binary_name,
41            Self::embedded_css(),
42            Self::embedded_bootstrap_css(),
43            Self::render_header(report),
44            Self::render_summary(report),
45            Self::render_suite_overview(report),
46            Self::render_detailed_results(report),
47            Self::render_environment(report),
48            Self::embedded_javascript(),
49        )
50    }
51
52    /// Render page header
53    fn render_header(report: &TestReport) -> String {
54        let version_badge = if let Some(version) = &report.binary_version {
55            format!(r#"<span class="badge bg-secondary">{}</span>"#, version)
56        } else {
57            String::new()
58        };
59
60        format!(
61            r#"<header class="mb-5">
62            <h1 class="display-4">
63                Test Report: {} {}
64            </h1>
65            <p class="text-muted">
66                Generated: {}
67            </p>
68        </header>"#,
69            report.binary_name,
70            version_badge,
71            report.finished_at.format("%Y-%m-%d %H:%M:%S UTC")
72        )
73    }
74
75    /// Render summary section
76    fn render_summary(report: &TestReport) -> String {
77        let success_rate = (report.success_rate() * 100.0) as u32;
78        let alert_class = if report.all_passed() {
79            "alert-success"
80        } else if success_rate >= 80 {
81            "alert-warning"
82        } else {
83            "alert-danger"
84        };
85
86        let status_icon = if report.all_passed() { "✅" } else { "❌" };
87
88        format!(
89            r#"<section class="mb-5">
90            <h2>Summary</h2>
91            <div class="alert {} d-flex align-items-center" role="alert">
92                <div class="me-3" style="font-size: 2rem;">{}</div>
93                <div>
94                    <h4 class="alert-heading mb-1">Overall Status: {}% passed</h4>
95                    <p class="mb-0">{} of {} tests passed</p>
96                </div>
97            </div>
98
99            <div class="row g-3 mb-4">
100                <div class="col-md-3">
101                    <div class="card border-success">
102                        <div class="card-body text-center">
103                            <h3 class="text-success">{}</h3>
104                            <p class="card-text text-muted">Passed</p>
105                        </div>
106                    </div>
107                </div>
108                <div class="col-md-3">
109                    <div class="card border-danger">
110                        <div class="card-body text-center">
111                            <h3 class="text-danger">{}</h3>
112                            <p class="card-text text-muted">Failed</p>
113                        </div>
114                    </div>
115                </div>
116                <div class="col-md-3">
117                    <div class="card border-secondary">
118                        <div class="card-body text-center">
119                            <h3 class="text-secondary">{}</h3>
120                            <p class="card-text text-muted">Skipped</p>
121                        </div>
122                    </div>
123                </div>
124                <div class="col-md-3">
125                    <div class="card border-info">
126                        <div class="card-body text-center">
127                            <h3 class="text-info">{:.2}s</h3>
128                            <p class="card-text text-muted">Duration</p>
129                        </div>
130                    </div>
131                </div>
132            </div>
133
134            <div class="progress" style="height: 30px;">
135                <div class="progress-bar bg-success" role="progressbar" style="width: {}%;" aria-valuenow="{}" aria-valuemin="0" aria-valuemax="100">
136                    {}%
137                </div>
138                <div class="progress-bar bg-danger" role="progressbar" style="width: {}%;" aria-valuenow="{}" aria-valuemin="0" aria-valuemax="100">
139                </div>
140            </div>
141        </section>"#,
142            alert_class,
143            status_icon,
144            success_rate,
145            report.total_passed(),
146            report.total_tests(),
147            report.total_passed(),
148            report.total_failed(),
149            report.total_skipped(),
150            report.total_duration.as_secs_f64(),
151            success_rate,
152            success_rate,
153            success_rate,
154            100 - success_rate,
155            100 - success_rate,
156        )
157    }
158
159    /// Render suite overview
160    fn render_suite_overview(report: &TestReport) -> String {
161        let mut suites_html = String::new();
162
163        for suite in &report.suites {
164            let success_rate = (suite.success_rate() * 100.0) as u32;
165            let status_badge = if suite.failed_count() == 0 {
166                String::from(r#"<span class="badge bg-success">✅ All Passed</span>"#)
167            } else {
168                format!(
169                    r#"<span class="badge bg-danger">❌ {} Failed</span>"#,
170                    suite.failed_count()
171                )
172            };
173
174            suites_html.push_str(&format!(
175                r#"
176            <div class="card mb-3">
177                <div class="card-header d-flex justify-content-between align-items-center">
178                    <h5 class="mb-0">{}</h5>
179                    {}
180                </div>
181                <div class="card-body">
182                    <div class="row">
183                        <div class="col-md-8">
184                            <p class="mb-1"><strong>File:</strong> <code>{}</code></p>
185                            <p class="mb-1"><strong>Duration:</strong> {:.2}s</p>
186                            <p class="mb-0">
187                                <span class="badge bg-success">{} passed</span>
188                                <span class="badge bg-danger">{} failed</span>
189                                <span class="badge bg-secondary">{} skipped</span>
190                            </p>
191                        </div>
192                        <div class="col-md-4">
193                            <div class="progress" style="height: 20px;">
194                                <div class="progress-bar bg-success" role="progressbar" style="width: {}%;">{}%</div>
195                            </div>
196                        </div>
197                    </div>
198                </div>
199            </div>"#,
200                suite.name,
201                status_badge,
202                suite.file_path,
203                suite.duration.as_secs_f64(),
204                suite.passed_count(),
205                suite.failed_count(),
206                suite.skipped_count(),
207                success_rate,
208                success_rate,
209            ));
210        }
211
212        format!(
213            r#"<section class="mb-5">
214            <h2>Test Suites</h2>
215            {}
216        </section>"#,
217            suites_html
218        )
219    }
220
221    /// Render detailed results
222    fn render_detailed_results(report: &TestReport) -> String {
223        let mut details_html = String::new();
224
225        for suite in &report.suites {
226            let mut tests_html = String::new();
227
228            for test in &suite.tests {
229                let (status_class, status_icon, status_text) = match test.status {
230                    TestStatus::Passed => ("table-success", "✅", "Passed"),
231                    TestStatus::Failed => ("table-danger", "❌", "Failed"),
232                    TestStatus::Skipped => ("table-secondary", "⏭️", "Skipped"),
233                    TestStatus::Timeout => ("table-warning", "⏱️", "Timeout"),
234                };
235
236                let error_row = if let Some(error) = &test.error_message {
237                    format!(
238                        r#"<tr><td colspan="4" class="bg-light"><small class="text-danger">Error: {}</small></td></tr>"#,
239                        Self::html_escape(error)
240                    )
241                } else {
242                    String::new()
243                };
244
245                tests_html.push_str(&format!(
246                    r#"<tr class="{}">
247                        <td>{} {}</td>
248                        <td>{}</td>
249                        <td>{:.0}ms</td>
250                        <td><span class="badge bg-{}">{}</span></td>
251                    </tr>{}"#,
252                    status_class,
253                    status_icon,
254                    Self::html_escape(&test.name),
255                    suite.name,
256                    test.duration.as_millis(),
257                    if test.status.is_success() {
258                        "success"
259                    } else if test.status.is_failure() {
260                        "danger"
261                    } else {
262                        "secondary"
263                    },
264                    status_text,
265                    error_row,
266                ));
267            }
268
269            details_html.push_str(&tests_html);
270        }
271
272        format!(
273            r#"<section class="mb-5">
274            <h2>Detailed Results</h2>
275            <div class="mb-3">
276                <input type="text" id="searchInput" class="form-control" placeholder="Search tests...">
277            </div>
278            <div class="btn-group mb-3" role="group">
279                <button type="button" class="btn btn-outline-primary" onclick="filterTests('all')">All</button>
280                <button type="button" class="btn btn-outline-success" onclick="filterTests('passed')">Passed</button>
281                <button type="button" class="btn btn-outline-danger" onclick="filterTests('failed')">Failed</button>
282                <button type="button" class="btn btn-outline-secondary" onclick="filterTests('skipped')">Skipped</button>
283            </div>
284            <div class="table-responsive">
285                <table class="table table-striped table-hover" id="resultsTable">
286                    <thead class="table-dark">
287                        <tr>
288                            <th>Test Name</th>
289                            <th>Suite</th>
290                            <th>Duration</th>
291                            <th>Status</th>
292                        </tr>
293                    </thead>
294                    <tbody>
295                        {}
296                    </tbody>
297                </table>
298            </div>
299        </section>"#,
300            details_html
301        )
302    }
303
304    /// Render environment information
305    fn render_environment(report: &TestReport) -> String {
306        format!(
307            r#"<section class="mb-5">
308            <h2>Environment</h2>
309            <div class="table-responsive">
310                <table class="table table-bordered">
311                    <tbody>
312                        <tr>
313                            <th style="width: 30%;">Operating System</th>
314                            <td>{} {}</td>
315                        </tr>
316                        <tr>
317                            <th>Shell</th>
318                            <td>{}</td>
319                        </tr>
320                        <tr>
321                            <th>BATS Version</th>
322                            <td>{}</td>
323                        </tr>
324                        <tr>
325                            <th>Hostname</th>
326                            <td>{}</td>
327                        </tr>
328                        <tr>
329                            <th>User</th>
330                            <td>{}</td>
331                        </tr>
332                    </tbody>
333                </table>
334            </div>
335        </section>"#,
336            report.environment.os,
337            report.environment.os_version,
338            report.environment.shell_version,
339            report.environment.bats_version,
340            report.environment.hostname,
341            report.environment.user,
342        )
343    }
344
345    /// Escape HTML special characters
346    fn html_escape(s: &str) -> String {
347        s.replace('&', "&amp;")
348            .replace('<', "&lt;")
349            .replace('>', "&gt;")
350            .replace('"', "&quot;")
351            .replace('\'', "&#39;")
352    }
353
354    /// Embedded Bootstrap 5 CSS (minimal subset)
355    fn embedded_bootstrap_css() -> &'static str {
356        r#"<style>
357        /* Bootstrap 5 minimal subset - embedded to avoid CDN dependency */
358        *,*::before,*::after{box-sizing:border-box}
359        body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff}
360        h1,h2,h3,h4,h5{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}
361        h1{font-size:calc(1.375rem + 1.5vw)}h2{font-size:calc(1.325rem + .9vw)}h3{font-size:calc(1.3rem + .6vw)}h4{font-size:calc(1.275rem + .3vw)}h5{font-size:1.25rem}
362        p{margin-top:0;margin-bottom:1rem}
363        .container{width:100%;padding-right:.75rem;padding-left:.75rem;margin-right:auto;margin-left:auto}
364        @media (min-width:1200px){.container{max-width:1140px}}
365        .row{display:flex;flex-wrap:wrap;margin-right:-.75rem;margin-left:-.75rem}
366        .col-md-3,.col-md-4,.col-md-8{position:relative;width:100%;padding-right:.75rem;padding-left:.75rem}
367        @media (min-width:768px){.col-md-3{flex:0 0 25%;max-width:25%}.col-md-4{flex:0 0 33.333%;max-width:33.333%}.col-md-8{flex:0 0 66.666%;max-width:66.666%}}
368        .g-3{margin-right:-0.75rem;margin-left:-0.75rem}.g-3>*{padding-right:0.75rem;padding-left:0.75rem;margin-bottom:1rem}
369        .d-flex{display:flex}.align-items-center{align-items:center}.justify-content-between{justify-content:space-between}
370        .mb-0{margin-bottom:0}.mb-1{margin-bottom:.25rem}.mb-3{margin-bottom:1rem}.mb-4{margin-bottom:1.5rem}.mb-5{margin-bottom:3rem}.me-3{margin-right:1rem}.py-5{padding-top:3rem;padding-bottom:3rem}
371        .card{position:relative;display:flex;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}
372        .card-body{flex:1 1 auto;padding:1rem}
373        .card-header{padding:.5rem 1rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}
374        .card-header h5{margin:0}
375        .card-text{margin-bottom:0}
376        .border-success{border-color:#198754!important}.border-danger{border-color:#dc3545!important}.border-secondary{border-color:#6c757d!important}.border-info{border-color:#0dcaf0!important}
377        .text-success{color:#198754}.text-danger{color:#dc3545}.text-secondary{color:#6c757d}.text-info{color:#0dcaf0}.text-muted{color:#6c757d}.text-center{text-align:center}
378        .badge{display:inline-block;padding:.35em .65em;font-size:.75em;font-weight:700;line-height:1;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem}
379        .bg-success{background-color:#198754!important;color:#fff}.bg-danger{background-color:#dc3545!important;color:#fff}.bg-secondary{background-color:#6c757d!important;color:#fff}.bg-info{background-color:#0dcaf0!important}.bg-light{background-color:#f8f9fa!important}
380        .alert{position:relative;padding:1rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}
381        .alert-success{color:#0f5132;background-color:#d1e7dd;border-color:#badbcc}.alert-warning{color:#664d03;background-color:#fff3cd;border-color:#ffecb5}.alert-danger{color:#842029;background-color:#f8d7da;border-color:#f5c2c7}
382        .alert-heading{color:inherit}
383        .progress{display:flex;height:1rem;overflow:hidden;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}
384        .progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:#fff;text-align:center;white-space:nowrap;background-color:#0d6efd;transition:width .6s ease}
385        .table{width:100%;margin-bottom:1rem;color:#212529;border-collapse:collapse}
386        .table th,.table td{padding:.5rem;border-bottom:1px solid #dee2e6}
387        .table-responsive{overflow-x:auto}
388        .table-striped tbody tr:nth-of-type(odd){background-color:rgba(0,0,0,.05)}
389        .table-hover tbody tr:hover{background-color:rgba(0,0,0,.075)}
390        .table-bordered{border:1px solid #dee2e6}.table-bordered th,.table-bordered td{border:1px solid #dee2e6}
391        .table-dark{color:#fff;background-color:#212529}
392        .table-success{background-color:#d1e7dd}.table-danger{background-color:#f8d7da}.table-secondary{background-color:#e2e3e5}.table-warning{background-color:#fff3cd}
393        .btn{display:inline-block;font-weight:400;line-height:1.5;text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;user-select:none;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;border-radius:.25rem;transition:color .15s ease-in-out}
394        .btn-group{position:relative;display:inline-flex;vertical-align:middle}.btn-group>.btn{position:relative;flex:1 1 auto}
395        .btn-outline-primary{color:#0d6efd;border-color:#0d6efd}.btn-outline-primary:hover{color:#fff;background-color:#0d6efd}
396        .btn-outline-success{color:#198754;border-color:#198754}.btn-outline-success:hover{color:#fff;background-color:#198754}
397        .btn-outline-danger{color:#dc3545;border-color:#dc3545}.btn-outline-danger:hover{color:#fff;background-color:#dc3545}
398        .btn-outline-secondary{color:#6c757d;border-color:#6c757d}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d}
399        .form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;line-height:1.5;color:#212529;background-color:#fff;border:1px solid #ced4da;border-radius:.25rem}
400        .display-4{font-size:3.5rem;font-weight:300;line-height:1.2}
401        code{font-family:SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:.875em;color:#d63384;word-wrap:break-word}
402        </style>"#
403    }
404
405    /// Custom CSS for additional styling
406    fn embedded_css() -> &'static str {
407        r#"<style>
408        body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
409        .card h3 { font-size: 2.5rem; margin: 0; }
410        .progress { box-shadow: inset 0 1px 2px rgba(0,0,0,.1); }
411        .table-responsive { max-height: 600px; overflow-y: auto; }
412        .filter-hidden { display: none !important; }
413        </style>"#
414    }
415
416    /// Embedded JavaScript for interactive features
417    fn embedded_javascript() -> &'static str {
418        r#"<script>
419        // Search functionality
420        document.getElementById('searchInput').addEventListener('keyup', function() {
421            const searchTerm = this.value.toLowerCase();
422            const rows = document.querySelectorAll('#resultsTable tbody tr');
423
424            rows.forEach(row => {
425                const text = row.textContent.toLowerCase();
426                row.style.display = text.includes(searchTerm) ? '' : 'none';
427            });
428        });
429
430        // Filter functionality
431        function filterTests(status) {
432            const rows = document.querySelectorAll('#resultsTable tbody tr');
433
434            rows.forEach(row => {
435                // Check if this is an error detail row (has bg-light class, no badge)
436                const isErrorRow = row.classList.contains('bg-light') || row.querySelector('td[colspan]');
437
438                if (isErrorRow) {
439                    // Error rows: hide if previous main row is hidden
440                    const prevRow = row.previousElementSibling;
441                    if (prevRow && prevRow.style.display === 'none') {
442                        row.style.display = 'none';
443                    } else if (status === 'all') {
444                        row.style.display = '';
445                    }
446                    return;
447                }
448
449                // Main test rows
450                if (status === 'all') {
451                    row.style.display = '';
452                } else {
453                    const statusBadge = row.querySelector('.badge');
454                    if (statusBadge) {
455                        const badgeText = statusBadge.textContent.toLowerCase();
456                        const shouldShow = badgeText.includes(status);
457                        row.style.display = shouldShow ? '' : 'none';
458
459                        // Hide next error row if this test is hidden
460                        const nextRow = row.nextElementSibling;
461                        if (!shouldShow && nextRow && (nextRow.classList.contains('bg-light') || nextRow.querySelector('td[colspan]'))) {
462                            nextRow.style.display = 'none';
463                        }
464                    }
465                }
466            });
467
468            // Update active button
469            document.querySelectorAll('.btn-group .btn').forEach(btn => {
470                btn.classList.remove('active');
471            });
472            event.target.classList.add('active');
473        }
474        </script>"#
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use crate::types::{EnvironmentInfo, TestResult, TestSuite};
482    use chrono::Utc;
483    use std::time::Duration;
484    use tempfile::NamedTempFile;
485
486    fn create_test_report() -> TestReport {
487        let suite = TestSuite {
488            name: "test_suite".to_string(),
489            file_path: "/path/to/test.bats".to_string(),
490            tests: vec![
491                TestResult {
492                    name: "successful test".to_string(),
493                    status: TestStatus::Passed,
494                    duration: Duration::from_millis(150),
495                    output: String::new(),
496                    error_message: None,
497                    file_path: "/path/to/test.bats".to_string(),
498                    line_number: Some(5),
499                    tags: vec![],
500                    priority: crate::types::TestPriority::Important,
501                },
502                TestResult {
503                    name: "failed test".to_string(),
504                    status: TestStatus::Failed,
505                    duration: Duration::from_millis(200),
506                    output: "error output".to_string(),
507                    error_message: Some("assertion failed".to_string()),
508                    file_path: "/path/to/test.bats".to_string(),
509                    line_number: Some(10),
510                    tags: vec![],
511                    priority: crate::types::TestPriority::Important,
512                },
513            ],
514            duration: Duration::from_millis(350),
515            started_at: Utc::now(),
516            finished_at: Utc::now(),
517        };
518
519        TestReport {
520            binary_name: "test-cli".to_string(),
521            binary_version: Some("1.0.0".to_string()),
522            suites: vec![suite],
523            total_duration: Duration::from_millis(350),
524            started_at: Utc::now(),
525            finished_at: Utc::now(),
526            environment: EnvironmentInfo::default(),
527            security_findings: vec![],
528        }
529    }
530
531    #[test]
532    fn test_html_generation() {
533        let report = create_test_report();
534        let temp_file = NamedTempFile::new().unwrap();
535
536        HtmlReporter::generate(&report, temp_file.path()).unwrap();
537
538        let content = fs::read_to_string(temp_file.path()).unwrap();
539
540        // Verify HTML structure
541        assert!(content.contains("<!DOCTYPE html>"));
542        assert!(content.contains("<html lang=\"en\">"));
543        assert!(content.contains("</html>"));
544
545        // Verify title
546        assert!(content.contains("<title>Test Report - test-cli</title>"));
547
548        // Verify header
549        assert!(content.contains("Test Report: test-cli"));
550        assert!(content.contains("1.0.0"));
551
552        // Verify summary
553        assert!(content.contains("Summary"));
554        assert!(content.contains("Overall Status"));
555
556        // Verify test results
557        assert!(content.contains("successful test"));
558        assert!(content.contains("failed test"));
559
560        // Verify environment
561        assert!(content.contains("Environment"));
562        assert!(content.contains("Operating System"));
563
564        // Verify JavaScript is embedded
565        assert!(content.contains("<script>"));
566        assert!(content.contains("searchInput"));
567        assert!(content.contains("filterTests"));
568    }
569
570    #[test]
571    fn test_html_escape() {
572        assert_eq!(
573            HtmlReporter::html_escape("Test <script>alert('xss')</script>"),
574            "Test &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"
575        );
576        assert_eq!(HtmlReporter::html_escape("A & B"), "A &amp; B");
577        assert_eq!(
578            HtmlReporter::html_escape("\"quoted\""),
579            "&quot;quoted&quot;"
580        );
581    }
582}