1use crate::error::Result;
2use crate::types::{TestReport, TestStatus};
3use std::fs;
4use std::path::Path;
5
6pub struct HtmlReporter;
8
9impl HtmlReporter {
10 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 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 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 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 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 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 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 fn html_escape(s: &str) -> String {
347 s.replace('&', "&")
348 .replace('<', "<")
349 .replace('>', ">")
350 .replace('"', """)
351 .replace('\'', "'")
352 }
353
354 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 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 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 assert!(content.contains("<!DOCTYPE html>"));
542 assert!(content.contains("<html lang=\"en\">"));
543 assert!(content.contains("</html>"));
544
545 assert!(content.contains("<title>Test Report - test-cli</title>"));
547
548 assert!(content.contains("Test Report: test-cli"));
550 assert!(content.contains("1.0.0"));
551
552 assert!(content.contains("Summary"));
554 assert!(content.contains("Overall Status"));
555
556 assert!(content.contains("successful test"));
558 assert!(content.contains("failed test"));
559
560 assert!(content.contains("Environment"));
562 assert!(content.contains("Operating System"));
563
564 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 <script>alert('xss')</script>"
575 );
576 assert_eq!(HtmlReporter::html_escape("A & B"), "A & B");
577 assert_eq!(
578 HtmlReporter::html_escape("\"quoted\""),
579 ""quoted""
580 );
581 }
582}