OCaml HTML5 parser/serialiser based on Python's JustHTML
1(* HTML Test Report Generator *) 2 3type test_result = { 4 test_num : int; 5 description : string; 6 input : string; 7 expected : string; 8 actual : string; 9 success : bool; 10 details : (string * string) list; (* Additional key-value pairs *) 11 raw_test_data : string option; (* Original test file content for context *) 12} 13 14type file_result = { 15 filename : string; 16 test_type : string; 17 passed_count : int; 18 failed_count : int; 19 tests : test_result list; 20} 21 22type report = { 23 title : string; 24 test_type : string; 25 description : string; (* Explanation of what this test suite validates *) 26 files : file_result list; 27 total_passed : int; 28 total_failed : int; 29} 30 31let html_escape s = 32 let buf = Buffer.create (String.length s * 2) in 33 String.iter (fun c -> 34 match c with 35 | '&' -> Buffer.add_string buf "&amp;" 36 | '<' -> Buffer.add_string buf "&lt;" 37 | '>' -> Buffer.add_string buf "&gt;" 38 | '"' -> Buffer.add_string buf "&quot;" 39 | '\'' -> Buffer.add_string buf "&#x27;" 40 | c -> Buffer.add_char buf c 41 ) s; 42 Buffer.contents buf 43 44(* No truncation - show full content for standalone reports *) 45let truncate_string ?(max_len=10000) s = 46 if String.length s <= max_len then s 47 else String.sub s 0 max_len ^ "\n... (truncated at " ^ string_of_int max_len ^ " chars)" 48 49let css = {| 50:root { 51 --bg-primary: #1a1a2e; 52 --bg-secondary: #16213e; 53 --bg-tertiary: #0f3460; 54 --text-primary: #eee; 55 --text-secondary: #aaa; 56 --accent: #e94560; 57 --success: #4ade80; 58 --failure: #f87171; 59 --border: #333; 60} 61 62* { box-sizing: border-box; margin: 0; padding: 0; } 63 64body { 65 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 66 background: var(--bg-primary); 67 color: var(--text-primary); 68 line-height: 1.6; 69} 70 71.container { 72 max-width: 1400px; 73 margin: 0 auto; 74 padding: 20px; 75} 76 77header { 78 background: var(--bg-secondary); 79 padding: 20px; 80 border-radius: 8px; 81 margin-bottom: 20px; 82} 83 84header h1 { 85 font-size: 1.5rem; 86 margin-bottom: 10px; 87 color: var(--accent); 88} 89 90.summary { 91 display: flex; 92 gap: 20px; 93 flex-wrap: wrap; 94 align-items: center; 95} 96 97.stat { 98 padding: 8px 16px; 99 border-radius: 6px; 100 font-weight: 600; 101} 102 103.stat.total { background: var(--bg-tertiary); } 104.stat.passed { background: rgba(74, 222, 128, 0.2); color: var(--success); } 105.stat.failed { background: rgba(248, 113, 113, 0.2); color: var(--failure); } 106 107.controls { 108 display: flex; 109 gap: 10px; 110 margin-top: 10px; 111 flex-wrap: wrap; 112} 113 114input[type="search"], select { 115 padding: 8px 12px; 116 border: 1px solid var(--border); 117 border-radius: 6px; 118 background: var(--bg-primary); 119 color: var(--text-primary); 120 font-size: 14px; 121} 122 123input[type="search"] { width: 300px; } 124 125button { 126 padding: 8px 16px; 127 border: none; 128 border-radius: 6px; 129 background: var(--accent); 130 color: white; 131 cursor: pointer; 132 font-size: 14px; 133} 134 135button:hover { opacity: 0.9; } 136 137.sidebar { 138 position: fixed; 139 left: 0; 140 top: 0; 141 bottom: 0; 142 width: 280px; 143 background: var(--bg-secondary); 144 border-right: 1px solid var(--border); 145 overflow-y: auto; 146 padding: 10px; 147 padding-top: 20px; 148} 149 150.sidebar-item { 151 padding: 8px 12px; 152 border-radius: 6px; 153 cursor: pointer; 154 display: flex; 155 justify-content: space-between; 156 align-items: center; 157 margin-bottom: 4px; 158 font-size: 14px; 159} 160 161.sidebar-item:hover { background: var(--bg-tertiary); } 162.sidebar-item.active { background: var(--accent); } 163 164.sidebar-item .count { 165 font-size: 12px; 166 padding: 2px 8px; 167 border-radius: 10px; 168 background: var(--bg-primary); 169} 170 171.sidebar-item .count.all-passed { color: var(--success); } 172.sidebar-item .count.has-failed { color: var(--failure); } 173 174main { 175 margin-left: 300px; 176 padding: 20px; 177 padding-top: 30px; 178} 179 180.intro { 181 background: var(--bg-secondary); 182 padding: 20px; 183 border-radius: 8px; 184 margin-bottom: 20px; 185} 186 187.file-section { 188 margin-bottom: 30px; 189 background: var(--bg-secondary); 190 border-radius: 8px; 191 overflow: hidden; 192} 193 194.file-header { 195 padding: 15px 20px; 196 background: var(--bg-tertiary); 197 cursor: pointer; 198 display: flex; 199 justify-content: space-between; 200 align-items: center; 201} 202 203.file-header h2 { 204 font-size: 1.1rem; 205 display: flex; 206 align-items: center; 207 gap: 10px; 208} 209 210.file-header .toggle { 211 font-size: 1.2rem; 212 transition: transform 0.2s; 213} 214 215.file-header.collapsed .toggle { transform: rotate(-90deg); } 216 217.file-stats { 218 display: flex; 219 gap: 15px; 220 font-size: 14px; 221} 222 223.file-stats .passed { color: var(--success); } 224.file-stats .failed { color: var(--failure); } 225 226.tests-container { 227 padding: 10px; 228} 229 230.tests-container.hidden { display: none; } 231 232.test-item { 233 margin: 8px 0; 234 border: 1px solid var(--border); 235 border-radius: 6px; 236 overflow: hidden; 237} 238 239.test-header { 240 padding: 10px 15px; 241 cursor: pointer; 242 display: flex; 243 justify-content: space-between; 244 align-items: center; 245 background: var(--bg-primary); 246} 247 248.test-header:hover { background: var(--bg-tertiary); } 249 250.test-header .status { 251 width: 10px; 252 height: 10px; 253 border-radius: 50%; 254 margin-right: 10px; 255} 256 257.test-header .status.passed { background: var(--success); } 258.test-header .status.failed { background: var(--failure); } 259 260.test-header .test-info { 261 flex: 1; 262 display: flex; 263 align-items: center; 264} 265 266.test-header .test-num { 267 font-weight: 600; 268 margin-right: 10px; 269 color: var(--text-secondary); 270} 271 272.test-header .test-desc { 273 font-size: 14px; 274 color: var(--text-primary); 275 white-space: nowrap; 276 overflow: hidden; 277 text-overflow: ellipsis; 278 max-width: 600px; 279} 280 281.test-details { 282 padding: 15px; 283 background: var(--bg-primary); 284 border-top: 1px solid var(--border); 285 display: none; 286} 287 288.test-details.visible { display: block; } 289 290.detail-section { 291 margin-bottom: 15px; 292} 293 294.detail-section h4 { 295 font-size: 12px; 296 text-transform: uppercase; 297 color: var(--text-secondary); 298 margin-bottom: 8px; 299 letter-spacing: 0.5px; 300} 301 302.detail-section pre { 303 background: var(--bg-secondary); 304 padding: 12px; 305 border-radius: 6px; 306 overflow-x: auto; 307 font-family: 'Monaco', 'Menlo', monospace; 308 font-size: 13px; 309 white-space: pre-wrap; 310 word-break: break-all; 311 max-height: 300px; 312 overflow-y: auto; 313} 314 315.detail-row { 316 display: grid; 317 grid-template-columns: 1fr 1fr; 318 gap: 15px; 319} 320 321.detail-row.single { grid-template-columns: 1fr; } 322 323.meta-info { 324 display: flex; 325 gap: 20px; 326 flex-wrap: wrap; 327 font-size: 13px; 328 color: var(--text-secondary); 329 margin-bottom: 15px; 330} 331 332.meta-info span { 333 background: var(--bg-secondary); 334 padding: 4px 10px; 335 border-radius: 4px; 336} 337 338.diff-indicator { 339 color: var(--failure); 340 font-weight: bold; 341 margin-left: 5px; 342} 343 344@media (max-width: 900px) { 345 .sidebar { display: none; } 346 main { margin-left: 0; } 347 .detail-row { grid-template-columns: 1fr; } 348} 349|} 350 351let js = {| 352document.addEventListener('DOMContentLoaded', function() { 353 // File section toggling 354 document.querySelectorAll('.file-header').forEach(header => { 355 header.addEventListener('click', function() { 356 this.classList.toggle('collapsed'); 357 const container = this.nextElementSibling; 358 container.classList.toggle('hidden'); 359 }); 360 }); 361 362 // Test details toggling 363 document.querySelectorAll('.test-header').forEach(header => { 364 header.addEventListener('click', function(e) { 365 e.stopPropagation(); 366 const details = this.nextElementSibling; 367 details.classList.toggle('visible'); 368 }); 369 }); 370 371 // Sidebar navigation 372 document.querySelectorAll('.sidebar-item').forEach(item => { 373 item.addEventListener('click', function() { 374 const fileId = this.dataset.file; 375 const section = document.getElementById(fileId); 376 if (section) { 377 section.scrollIntoView({ behavior: 'smooth' }); 378 // Expand if collapsed 379 const header = section.querySelector('.file-header'); 380 if (header.classList.contains('collapsed')) { 381 header.click(); 382 } 383 } 384 // Update active state 385 document.querySelectorAll('.sidebar-item').forEach(i => i.classList.remove('active')); 386 this.classList.add('active'); 387 }); 388 }); 389 390 // Search functionality 391 const searchInput = document.getElementById('search'); 392 if (searchInput) { 393 searchInput.addEventListener('input', function() { 394 const query = this.value.toLowerCase(); 395 document.querySelectorAll('.test-item').forEach(item => { 396 const text = item.textContent.toLowerCase(); 397 item.style.display = text.includes(query) ? '' : 'none'; 398 }); 399 }); 400 } 401 402 // Filter functionality 403 const filterSelect = document.getElementById('filter'); 404 if (filterSelect) { 405 filterSelect.addEventListener('change', function() { 406 const filter = this.value; 407 document.querySelectorAll('.test-item').forEach(item => { 408 const passed = item.querySelector('.status.passed') !== null; 409 if (filter === 'all') { 410 item.style.display = ''; 411 } else if (filter === 'passed') { 412 item.style.display = passed ? '' : 'none'; 413 } else if (filter === 'failed') { 414 item.style.display = passed ? 'none' : ''; 415 } 416 }); 417 }); 418 } 419 420 // Expand/Collapse all 421 document.getElementById('expand-all')?.addEventListener('click', function() { 422 document.querySelectorAll('.file-header.collapsed').forEach(h => h.click()); 423 }); 424 425 document.getElementById('collapse-all')?.addEventListener('click', function() { 426 document.querySelectorAll('.file-header:not(.collapsed)').forEach(h => h.click()); 427 }); 428}); 429|} 430 431let generate_test_html test = 432 let status_class = if test.success then "passed" else "failed" in 433 let desc_escaped = html_escape test.description in 434 let input_escaped = html_escape (truncate_string test.input) in 435 let expected_escaped = html_escape (truncate_string test.expected) in 436 let actual_escaped = html_escape (truncate_string test.actual) in 437 438 let details_html = String.concat "" (List.map (fun (key, value) -> 439 Printf.sprintf {| 440 <div class="detail-section"> 441 <h4>%s</h4> 442 <pre>%s</pre> 443 </div> 444 |} (html_escape key) (html_escape value) 445 ) test.details) in 446 447 let raw_data_html = match test.raw_test_data with 448 | Some data -> 449 Printf.sprintf {| 450 <div class="detail-section"> 451 <h4>Original Test Data (from .dat/.test file)</h4> 452 <pre>%s</pre> 453 </div> 454 |} (html_escape (truncate_string data)) 455 | None -> "" 456 in 457 458 let diff_indicator = if test.success then "" else {|<span class="diff-indicator">✗</span>|} in 459 460 Printf.sprintf {| 461 <div class="test-item" data-passed="%b"> 462 <div class="test-header"> 463 <div class="test-info"> 464 <span class="status %s"></span> 465 <span class="test-num">#%d</span> 466 <span class="test-desc">%s</span> 467 </div> 468 <span>▼</span> 469 </div> 470 <div class="test-details"> 471 %s 472 <div class="detail-section"> 473 <h4>Input (HTML to parse)</h4> 474 <pre>%s</pre> 475 </div> 476 <div class="detail-row"> 477 <div class="detail-section"> 478 <h4>Expected Output%s</h4> 479 <pre>%s</pre> 480 </div> 481 <div class="detail-section"> 482 <h4>Actual Output%s</h4> 483 <pre>%s</pre> 484 </div> 485 </div> 486 %s 487 </div> 488 </div> 489 |} test.success status_class test.test_num desc_escaped 490 raw_data_html input_escaped diff_indicator expected_escaped diff_indicator actual_escaped details_html 491 492let generate_file_html file = 493 let file_id = String.map (fun c -> if c = '.' then '-' else c) file.filename in 494 let tests_html = String.concat "\n" (List.map generate_test_html file.tests) in 495 let collapsed = if file.failed_count = 0 then "collapsed" else "" in 496 let hidden = if file.failed_count = 0 then "hidden" else "" in 497 498 Printf.sprintf {| 499 <div class="file-section" id="file-%s"> 500 <div class="file-header %s"> 501 <h2> 502 <span class="toggle">▼</span> 503 %s 504 <span style="font-weight: normal; font-size: 0.9em; color: var(--text-secondary)">(%s)</span> 505 </h2> 506 <div class="file-stats"> 507 <span class="passed">✓ %d passed</span> 508 <span class="failed">✗ %d failed</span> 509 </div> 510 </div> 511 <div class="tests-container %s"> 512 %s 513 </div> 514 </div> 515 |} file_id collapsed file.filename file.test_type file.passed_count file.failed_count hidden tests_html 516 517let generate_sidebar_html files = 518 String.concat "\n" (List.map (fun file -> 519 let file_id = String.map (fun c -> if c = '.' then '-' else c) file.filename in 520 let count_class = if file.failed_count = 0 then "all-passed" else "has-failed" in 521 Printf.sprintf {| 522 <div class="sidebar-item" data-file="file-%s"> 523 <span>%s</span> 524 <span class="count %s">%d/%d</span> 525 </div> 526 |} file_id file.filename count_class file.passed_count (file.passed_count + file.failed_count) 527 ) files) 528 529let generate_report report output_path = 530 let files_html = String.concat "\n" (List.map generate_file_html report.files) in 531 let sidebar_html = generate_sidebar_html report.files in 532 533 let html = Printf.sprintf {|<!DOCTYPE html> 534<html lang="en"> 535<head> 536 <meta charset="UTF-8"> 537 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 538 <title>%s - Test Report</title> 539 <style>%s</style> 540</head> 541<body> 542 <div class="sidebar"> 543 <h3 style="padding: 10px; color: var(--text-secondary); font-size: 12px; text-transform: uppercase;">Files</h3> 544 %s 545 </div> 546 547 <main> 548 <header> 549 <h1>%s</h1> 550 <p style="color: var(--text-secondary); margin: 10px 0; max-width: 900px;">%s</p> 551 <div class="summary"> 552 <span class="stat total">%d tests</span> 553 <span class="stat passed">✓ %d passed</span> 554 <span class="stat failed">✗ %d failed</span> 555 <span class="stat total">%.1f%% pass rate</span> 556 </div> 557 <div class="controls"> 558 <input type="search" id="search" placeholder="Search tests..."> 559 <select id="filter"> 560 <option value="all">All tests</option> 561 <option value="passed">Passed only</option> 562 <option value="failed">Failed only</option> 563 </select> 564 <button id="expand-all">Expand All</button> 565 <button id="collapse-all">Collapse All</button> 566 </div> 567 </header> 568 %s 569 </main> 570 571 <script>%s</script> 572</body> 573</html> 574|} report.title css 575 sidebar_html 576 report.title (html_escape report.description) 577 (report.total_passed + report.total_failed) 578 report.total_passed 579 report.total_failed 580 (100.0 *. float_of_int report.total_passed /. float_of_int (max 1 (report.total_passed + report.total_failed))) 581 files_html js 582 in 583 584 let oc = open_out output_path in 585 output_string oc html; 586 close_out oc; 587 Printf.printf "HTML report written to: %s\n" output_path