OCaml HTML5 parser/serialiser based on Python's JustHTML
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>HTML5rw Regression Test Suite</title>
7 <style>
8 :root {
9 --bg-primary: #1a1a2e;
10 --bg-secondary: #16213e;
11 --bg-tertiary: #0f3460;
12 --text-primary: #eee;
13 --text-secondary: #aaa;
14 --text-muted: #666;
15 --accent: #e94560;
16 --accent-light: #ff6b8a;
17 --success: #4ade80;
18 --success-dim: rgba(74, 222, 128, 0.2);
19 --failure: #f87171;
20 --failure-dim: rgba(248, 113, 113, 0.2);
21 --warning: #fbbf24;
22 --info: #60a5fa;
23 --border: #333;
24 --code-bg: #0d1117;
25 }
26
27 * { box-sizing: border-box; margin: 0; padding: 0; }
28
29 body {
30 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
31 background: var(--bg-primary);
32 color: var(--text-primary);
33 line-height: 1.6;
34 padding: 20px;
35 }
36
37 .container { max-width: 1400px; margin: 0 auto; }
38
39 .hero {
40 background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
41 padding: 30px 40px;
42 border-radius: 12px;
43 margin-bottom: 30px;
44 border: 1px solid var(--border);
45 }
46
47 .hero h1 {
48 font-size: 2rem;
49 margin-bottom: 10px;
50 color: var(--accent);
51 }
52
53 .hero p { color: var(--text-secondary); margin-bottom: 15px; }
54
55 .controls {
56 display: flex;
57 gap: 12px;
58 flex-wrap: wrap;
59 align-items: center;
60 }
61
62 button {
63 padding: 12px 24px;
64 border: none;
65 border-radius: 8px;
66 background: var(--accent);
67 color: white;
68 cursor: pointer;
69 font-size: 14px;
70 font-weight: 600;
71 transition: all 0.2s;
72 }
73
74 button:hover { background: var(--accent-light); transform: translateY(-1px); }
75 button:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
76 button.secondary { background: var(--bg-tertiary); border: 1px solid var(--border); }
77 button.secondary:hover { background: var(--bg-secondary); }
78
79 select {
80 padding: 12px 16px;
81 border: 1px solid var(--border);
82 border-radius: 8px;
83 background: var(--bg-secondary);
84 color: var(--text-primary);
85 font-size: 14px;
86 }
87
88 .summary-grid {
89 display: grid;
90 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
91 gap: 20px;
92 margin-bottom: 30px;
93 }
94
95 .summary-card {
96 background: var(--bg-secondary);
97 border-radius: 12px;
98 padding: 20px;
99 border: 1px solid var(--border);
100 text-align: center;
101 }
102
103 .summary-card h3 {
104 font-size: 0.8rem;
105 text-transform: uppercase;
106 letter-spacing: 1px;
107 color: var(--text-secondary);
108 margin-bottom: 8px;
109 }
110
111 .summary-card .value {
112 font-size: 2rem;
113 font-weight: 700;
114 }
115
116 .summary-card .value.success { color: var(--success); }
117 .summary-card .value.failure { color: var(--failure); }
118 .summary-card .value.neutral { color: var(--text-primary); }
119
120 .progress-container {
121 background: var(--bg-secondary);
122 border-radius: 12px;
123 padding: 20px;
124 margin-bottom: 30px;
125 border: 1px solid var(--border);
126 }
127
128 .progress-bar {
129 height: 24px;
130 background: var(--failure-dim);
131 border-radius: 12px;
132 overflow: hidden;
133 margin-top: 10px;
134 }
135
136 .progress-fill {
137 height: 100%;
138 background: var(--success);
139 border-radius: 12px;
140 transition: width 0.3s ease;
141 display: flex;
142 align-items: center;
143 justify-content: center;
144 font-size: 12px;
145 font-weight: 600;
146 }
147
148 .status-text {
149 font-size: 14px;
150 color: var(--text-secondary);
151 margin-bottom: 8px;
152 }
153
154 .results-section {
155 background: var(--bg-secondary);
156 border-radius: 12px;
157 margin-bottom: 20px;
158 border: 1px solid var(--border);
159 overflow: hidden;
160 }
161
162 .results-header {
163 padding: 16px 20px;
164 background: var(--bg-tertiary);
165 cursor: pointer;
166 display: flex;
167 justify-content: space-between;
168 align-items: center;
169 }
170
171 .results-header:hover { background: #1a4a7a; }
172
173 .results-header h2 {
174 font-size: 1rem;
175 display: flex;
176 align-items: center;
177 gap: 10px;
178 }
179
180 .results-header .toggle { color: var(--text-secondary); transition: transform 0.2s; }
181 .results-header.collapsed .toggle { transform: rotate(-90deg); }
182
183 .results-stats {
184 display: flex;
185 gap: 15px;
186 font-size: 14px;
187 }
188
189 .results-stats .passed { color: var(--success); }
190 .results-stats .failed { color: var(--failure); }
191
192 .results-content { padding: 15px; }
193 .results-content.hidden { display: none; }
194
195 .test-item {
196 margin: 6px 0;
197 border: 1px solid var(--border);
198 border-radius: 6px;
199 overflow: hidden;
200 }
201
202 .test-header {
203 padding: 10px 14px;
204 cursor: pointer;
205 display: flex;
206 justify-content: space-between;
207 align-items: center;
208 background: var(--bg-primary);
209 font-size: 13px;
210 }
211
212 .test-header:hover { background: rgba(255,255,255,0.03); }
213
214 .test-header .status {
215 width: 8px;
216 height: 8px;
217 border-radius: 50%;
218 margin-right: 10px;
219 flex-shrink: 0;
220 }
221
222 .test-header .status.passed { background: var(--success); }
223 .test-header .status.failed { background: var(--failure); }
224
225 .test-header .test-info { flex: 1; display: flex; align-items: center; min-width: 0; }
226 .test-header .test-num { font-weight: 600; margin-right: 10px; color: var(--text-muted); }
227 .test-header .test-desc { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
228 .test-header .expand-icon { color: var(--text-muted); font-size: 0.75rem; }
229
230 .test-details {
231 padding: 15px;
232 background: var(--code-bg);
233 border-top: 1px solid var(--border);
234 display: none;
235 font-size: 13px;
236 }
237
238 .test-details.visible { display: block; }
239
240 .detail-section { margin-bottom: 15px; }
241 .detail-section:last-child { margin-bottom: 0; }
242
243 .detail-section h4 {
244 font-size: 11px;
245 text-transform: uppercase;
246 letter-spacing: 1px;
247 color: var(--text-muted);
248 margin-bottom: 8px;
249 }
250
251 .detail-section pre {
252 background: var(--bg-secondary);
253 padding: 12px;
254 border-radius: 6px;
255 overflow-x: auto;
256 font-family: 'Monaco', 'Menlo', monospace;
257 font-size: 12px;
258 white-space: pre-wrap;
259 word-break: break-word;
260 max-height: 300px;
261 overflow-y: auto;
262 border: 1px solid var(--border);
263 }
264
265 .detail-row {
266 display: grid;
267 grid-template-columns: 1fr 1fr;
268 gap: 15px;
269 }
270
271 .filter-controls {
272 display: flex;
273 gap: 10px;
274 margin-bottom: 20px;
275 flex-wrap: wrap;
276 }
277
278 .filter-controls input[type="search"] {
279 padding: 10px 14px;
280 border: 1px solid var(--border);
281 border-radius: 8px;
282 background: var(--bg-secondary);
283 color: var(--text-primary);
284 font-size: 14px;
285 width: 250px;
286 }
287
288 .log-output {
289 background: var(--code-bg);
290 border: 1px solid var(--border);
291 border-radius: 8px;
292 padding: 15px;
293 font-family: 'Monaco', 'Menlo', monospace;
294 font-size: 12px;
295 max-height: 200px;
296 overflow-y: auto;
297 white-space: pre-wrap;
298 margin-bottom: 20px;
299 }
300
301 @media (max-width: 768px) {
302 .detail-row { grid-template-columns: 1fr; }
303 .summary-grid { grid-template-columns: 1fr 1fr; }
304 }
305 </style>
306</head>
307<body>
308 <div class="container">
309 <div class="hero">
310 <h1>HTML5rw Regression Test Suite</h1>
311 <p>
312 Browser-based regression testing for the HTML5rw OCaml parser.
313 Tests are loaded from the html5lib-tests conformance suite.
314 </p>
315 <div class="controls">
316 <button id="run-all" onclick="runAllTests()">Run All Tests</button>
317 <button id="run-tree" class="secondary" onclick="runTreeTests()">Tree Construction Only</button>
318 <button id="run-encoding" class="secondary" onclick="runEncodingTests()">Encoding Only</button>
319 <select id="mode-select">
320 <option value="js">JavaScript (js_of_ocaml)</option>
321 <option value="wasm">WebAssembly (wasm_of_ocaml)</option>
322 </select>
323 </div>
324 </div>
325
326 <div class="summary-grid" id="summary" style="display: none;">
327 <div class="summary-card">
328 <h3>Total Tests</h3>
329 <div class="value neutral" id="total-count">0</div>
330 </div>
331 <div class="summary-card">
332 <h3>Passed</h3>
333 <div class="value success" id="passed-count">0</div>
334 </div>
335 <div class="summary-card">
336 <h3>Failed</h3>
337 <div class="value failure" id="failed-count">0</div>
338 </div>
339 <div class="summary-card">
340 <h3>Pass Rate</h3>
341 <div class="value" id="pass-rate">0%</div>
342 </div>
343 </div>
344
345 <div class="progress-container" id="progress-container">
346 <div class="status-text" id="status-text">Ready to run tests. Click a button above to start.</div>
347 <div class="progress-bar">
348 <div class="progress-fill" id="progress-fill" style="width: 0%"></div>
349 </div>
350 </div>
351
352 <div class="log-output" id="log-output">Waiting for tests to start...</div>
353
354 <div class="filter-controls" id="filter-controls" style="display: none;">
355 <input type="search" id="search" placeholder="Search tests...">
356 <select id="filter">
357 <option value="all">All Tests</option>
358 <option value="passed">Passed Only</option>
359 <option value="failed">Failed Only</option>
360 </select>
361 <button class="secondary" onclick="expandAll()">Expand All</button>
362 <button class="secondary" onclick="collapseAll()">Collapse All</button>
363 </div>
364
365 <div id="results-container"></div>
366 </div>
367
368 <script>
369 // Test file lists
370 const TREE_CONSTRUCTION_FILES = [
371 "adoption01.dat", "adoption02.dat", "blocks.dat", "comments01.dat",
372 "doctype01.dat", "domjs-unsafe.dat", "entities01.dat", "entities02.dat",
373 "foreign-fragment.dat", "html5test-com.dat", "inbody01.dat", "isindex.dat",
374 "main-element.dat", "math.dat", "menuitem-element.dat", "namespace-sensitivity.dat",
375 "noscript01.dat", "pending-spec-changes-plain-text-unsafe.dat",
376 "pending-spec-changes.dat", "plain-text-unsafe.dat", "quirks01.dat", "ruby.dat",
377 "scriptdata01.dat", "search-element.dat", "svg.dat", "tables01.dat",
378 "template.dat", "tests_innerHTML_1.dat", "tests1.dat", "tests10.dat",
379 "tests11.dat", "tests12.dat", "tests14.dat", "tests15.dat", "tests16.dat",
380 "tests17.dat", "tests18.dat", "tests19.dat", "tests2.dat", "tests20.dat",
381 "tests21.dat", "tests22.dat", "tests23.dat", "tests24.dat", "tests25.dat",
382 "tests26.dat", "tests3.dat", "tests4.dat", "tests5.dat", "tests6.dat",
383 "tests7.dat", "tests8.dat", "tests9.dat", "tricky01.dat", "webkit01.dat",
384 "webkit02.dat"
385 ];
386
387 const ENCODING_FILES = [
388 "test-yahoo-jp.dat", "tests1.dat", "tests2.dat"
389 ];
390
391 let testRunner = null;
392 let isRunning = false;
393
394 function log(msg) {
395 const output = document.getElementById('log-output');
396 output.textContent += msg + '\n';
397 output.scrollTop = output.scrollHeight;
398 }
399
400 function clearLog() {
401 document.getElementById('log-output').textContent = '';
402 }
403
404 function updateProgress(current, total, msg) {
405 const pct = total > 0 ? (current / total * 100) : 0;
406 document.getElementById('progress-fill').style.width = pct + '%';
407 document.getElementById('status-text').textContent = msg || `Running: ${current}/${total}`;
408 }
409
410 function updateSummary(passed, failed) {
411 const total = passed + failed;
412 const rate = total > 0 ? (passed / total * 100).toFixed(1) : 0;
413 document.getElementById('total-count').textContent = total;
414 document.getElementById('passed-count').textContent = passed;
415 document.getElementById('failed-count').textContent = failed;
416 document.getElementById('pass-rate').textContent = rate + '%';
417 document.getElementById('pass-rate').className = 'value ' + (rate >= 99 ? 'success' : rate >= 90 ? 'neutral' : 'failure');
418 document.getElementById('summary').style.display = 'grid';
419 }
420
421 async function loadTestRunner() {
422 const mode = document.getElementById('mode-select').value;
423 const scriptName = mode === 'wasm' ? 'htmlrw-tests.wasm.js' : 'htmlrw-tests.js';
424
425 if (typeof html5rwTests !== 'undefined') {
426 return true;
427 }
428
429 log(`Loading ${scriptName}...`);
430 try {
431 await new Promise((resolve, reject) => {
432 const script = document.createElement('script');
433 script.src = `_build/default/lib/js/${scriptName}`;
434 script.onload = resolve;
435 script.onerror = () => reject(new Error(`Failed to load ${scriptName}`));
436 document.head.appendChild(script);
437 });
438
439 // Wait for initialization
440 await new Promise(resolve => setTimeout(resolve, 100));
441
442 if (typeof html5rwTests === 'undefined') {
443 throw new Error('Test runner not initialized');
444 }
445
446 log(`Test runner loaded (version ${html5rwTests.version})`);
447 return true;
448 } catch (e) {
449 log(`ERROR: ${e.message}`);
450 log('Make sure to run: opam exec -- dune build lib/js/htmlrw-tests.js');
451 return false;
452 }
453 }
454
455 async function fetchTestFile(type, filename) {
456 const basePath = type === 'tree-construction'
457 ? 'html5lib-tests/tree-construction/'
458 : 'html5lib-tests/encoding/';
459 const url = basePath + filename;
460 const response = await fetch(url);
461 if (!response.ok) {
462 throw new Error(`Failed to fetch ${url}: ${response.status}`);
463 }
464 return await response.text();
465 }
466
467 function renderFileResult(result) {
468 const section = document.createElement('div');
469 section.className = 'results-section';
470 section.dataset.filename = result.filename;
471
472 const collapsed = result.failedCount === 0 ? 'collapsed' : '';
473 const hidden = result.failedCount === 0 ? 'hidden' : '';
474
475 section.innerHTML = `
476 <div class="results-header ${collapsed}">
477 <h2><span class="toggle">▼</span> ${escapeHtml(result.filename)}</h2>
478 <div class="results-stats">
479 <span class="passed">✓ ${result.passedCount}</span>
480 <span class="failed">✗ ${result.failedCount}</span>
481 </div>
482 </div>
483 <div class="results-content ${hidden}">
484 ${result.tests.map(renderTestResult).join('')}
485 </div>
486 `;
487
488 // Add toggle handler
489 section.querySelector('.results-header').addEventListener('click', function() {
490 this.classList.toggle('collapsed');
491 this.nextElementSibling.classList.toggle('hidden');
492 });
493
494 // Add test detail handlers
495 section.querySelectorAll('.test-header').forEach(header => {
496 header.addEventListener('click', function(e) {
497 e.stopPropagation();
498 const details = this.nextElementSibling;
499 details.classList.toggle('visible');
500 const icon = this.querySelector('.expand-icon');
501 icon.textContent = details.classList.contains('visible') ? '▲' : '▼';
502 });
503 });
504
505 return section;
506 }
507
508 function renderTestResult(test) {
509 const statusClass = test.success ? 'passed' : 'failed';
510 return `
511 <div class="test-item" data-passed="${test.success}">
512 <div class="test-header">
513 <div class="test-info">
514 <span class="status ${statusClass}"></span>
515 <span class="test-num">#${test.testNum}</span>
516 <span class="test-desc">${escapeHtml(test.description)}</span>
517 </div>
518 <span class="expand-icon">▼</span>
519 </div>
520 <div class="test-details">
521 <div class="detail-section">
522 <h4>Input</h4>
523 <pre>${escapeHtml(test.input)}</pre>
524 </div>
525 <div class="detail-row">
526 <div class="detail-section">
527 <h4>Expected</h4>
528 <pre>${escapeHtml(test.expected)}</pre>
529 </div>
530 <div class="detail-section">
531 <h4>Actual</h4>
532 <pre>${escapeHtml(test.actual)}</pre>
533 </div>
534 </div>
535 </div>
536 </div>
537 `;
538 }
539
540 function escapeHtml(str) {
541 const div = document.createElement('div');
542 div.textContent = str;
543 return div.innerHTML;
544 }
545
546 async function runTests(testType, files, basePath) {
547 if (isRunning) return;
548 isRunning = true;
549
550 clearLog();
551 document.getElementById('results-container').innerHTML = '';
552 document.getElementById('filter-controls').style.display = 'none';
553
554 const buttons = document.querySelectorAll('button');
555 buttons.forEach(b => b.disabled = true);
556
557 try {
558 if (!await loadTestRunner()) {
559 return;
560 }
561
562 log(`Starting ${testType} tests...`);
563 let totalPassed = 0;
564 let totalFailed = 0;
565 const allResults = [];
566
567 for (let i = 0; i < files.length; i++) {
568 const filename = files[i];
569 updateProgress(i, files.length, `Loading ${filename}...`);
570
571 try {
572 const content = await fetchTestFile(basePath, filename);
573 log(`Running ${filename}...`);
574
575 let result;
576 if (basePath === 'tree-construction') {
577 result = html5rwTests.runTreeConstructionTest(filename, content);
578 } else {
579 result = html5rwTests.runEncodingTest(filename, content);
580 }
581
582 totalPassed += result.passedCount;
583 totalFailed += result.failedCount;
584 allResults.push(result);
585
586 log(` ${filename}: ${result.passedCount} passed, ${result.failedCount} failed`);
587
588 // Render result immediately
589 const section = renderFileResult(result);
590 document.getElementById('results-container').appendChild(section);
591
592 } catch (e) {
593 log(` ERROR loading ${filename}: ${e.message}`);
594 }
595
596 updateSummary(totalPassed, totalFailed);
597 updateProgress(i + 1, files.length);
598 }
599
600 updateProgress(files.length, files.length, `Complete: ${totalPassed} passed, ${totalFailed} failed`);
601 log(`\n=== SUMMARY ===`);
602 log(`Total: ${totalPassed + totalFailed} tests`);
603 log(`Passed: ${totalPassed}`);
604 log(`Failed: ${totalFailed}`);
605 log(`Pass rate: ${((totalPassed / (totalPassed + totalFailed)) * 100).toFixed(2)}%`);
606
607 document.getElementById('filter-controls').style.display = 'flex';
608 setupFilters();
609
610 } finally {
611 isRunning = false;
612 buttons.forEach(b => b.disabled = false);
613 }
614 }
615
616 function runAllTests() {
617 // Run both tree and encoding tests
618 runTests('all', TREE_CONSTRUCTION_FILES.concat(ENCODING_FILES.map(f => 'encoding/' + f)), 'tree-construction');
619 }
620
621 async function runTreeTests() {
622 await runTests('tree-construction', TREE_CONSTRUCTION_FILES, 'tree-construction');
623 }
624
625 async function runEncodingTests() {
626 await runTests('encoding', ENCODING_FILES, 'encoding');
627 }
628
629 function setupFilters() {
630 const search = document.getElementById('search');
631 const filter = document.getElementById('filter');
632
633 search.addEventListener('input', applyFilters);
634 filter.addEventListener('change', applyFilters);
635 }
636
637 function applyFilters() {
638 const query = document.getElementById('search').value.toLowerCase();
639 const filterValue = document.getElementById('filter').value;
640
641 document.querySelectorAll('.test-item').forEach(item => {
642 const text = item.textContent.toLowerCase();
643 const passed = item.dataset.passed === 'true';
644 let visible = true;
645
646 if (query && !text.includes(query)) visible = false;
647 if (filterValue === 'passed' && !passed) visible = false;
648 if (filterValue === 'failed' && passed) visible = false;
649
650 item.style.display = visible ? '' : 'none';
651 });
652 }
653
654 function expandAll() {
655 document.querySelectorAll('.results-header.collapsed').forEach(h => h.click());
656 }
657
658 function collapseAll() {
659 document.querySelectorAll('.results-header:not(.collapsed)').forEach(h => h.click());
660 }
661
662 // Quick test on load
663 window.addEventListener('load', function() {
664 log('Ready. Select a test mode and click Run to begin.');
665 });
666 </script>
667</body>
668</html>