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 "&"
36 | '<' -> Buffer.add_string buf "<"
37 | '>' -> Buffer.add_string buf ">"
38 | '"' -> Buffer.add_string buf """
39 | '\'' -> Buffer.add_string buf "'"
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