Pure OCaml Yaml 1.2 reader and writer using Bytesrw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 SPDX-License-Identifier: ISC 4 ---------------------------------------------------------------------------*) 5 6open Yamlrw 7module TL = Test_suite_lib.Test_suite_loader 8module TF = Test_suite_lib.Tree_format 9module JF = Test_suite_lib.Json_format 10module JC = Test_suite_lib.Json_compare 11 12let test_suite_path = "yaml-test-suite" 13 14(* HTML escape function *) 15let html_escape s = 16 let buf = Buffer.create (String.length s) in 17 String.iter (function 18 | '<' -> Buffer.add_string buf "&lt;" 19 | '>' -> Buffer.add_string buf "&gt;" 20 | '&' -> Buffer.add_string buf "&amp;" 21 | '"' -> Buffer.add_string buf "&quot;" 22 | c -> Buffer.add_char buf c 23 ) s; 24 Buffer.contents buf 25 26let normalize_tree s = 27 let lines = String.split_on_char '\n' s in 28 let lines = List.filter (fun l -> String.trim l <> "") lines in 29 String.concat "\n" lines 30 31type test_result = { 32 id : string; 33 name : string; 34 yaml : string; 35 is_error_test : bool; 36 status : [`Pass | `Fail of string | `Skip]; 37 output : string; 38 json_status : [`Pass | `Fail of string | `Skip]; 39 json_expected : string; 40 json_actual : string; 41} 42 43let compare_json expected actual = 44 (* Parse both JSON strings and compare the resulting structures. 45 This handles formatting differences and object key ordering. *) 46 JC.compare_json_strings expected actual 47 48let run_json_test (test : TL.test_case) : [`Pass | `Fail of string | `Skip] * string = 49 match test.json with 50 | None -> (`Skip, "") 51 | Some expected_json -> 52 if test.fail then 53 (* Error tests shouldn't have JSON comparison *) 54 (`Skip, "") 55 else 56 try 57 (* Handle multi-document YAML by using documents_of_string *) 58 let docs = Loader.documents_of_string test.yaml in 59 let values = List.filter_map (fun doc -> 60 match Document.root doc with 61 | None -> None 62 | Some yaml -> Some (Yaml.to_value ~resolve_aliases_first:true yaml) 63 ) docs in 64 let actual_json = match values with 65 | [] -> "" (* Empty document produces empty JSON *) 66 | [v] -> JF.to_json v 67 | vs -> JF.documents_to_json vs 68 in 69 if compare_json expected_json actual_json then 70 (`Pass, actual_json) 71 else 72 (`Fail "JSON mismatch", actual_json) 73 with 74 | Yamlrw_error e -> 75 (`Fail (Format.asprintf "Parse error: %a" Error.pp e), "") 76 | exn -> 77 (`Fail (Printf.sprintf "Exception: %s" (Printexc.to_string exn)), "") 78 79let run_test (test : TL.test_case) : test_result = 80 let json_status, json_actual = run_json_test test in 81 let base = { 82 id = test.id; 83 name = test.name; 84 yaml = test.yaml; 85 is_error_test = test.fail; 86 status = `Skip; 87 output = ""; 88 json_status; 89 json_expected = Option.value ~default:"" test.json; 90 json_actual; 91 } in 92 if test.fail then begin 93 try 94 let parser = Parser.of_string test.yaml in 95 let events = Parser.to_list parser in 96 let tree = TF.of_spanned_events events in 97 { base with 98 status = `Fail "Expected parsing to fail"; 99 output = tree; 100 } 101 with 102 | Yamlrw_error e -> 103 { base with 104 status = `Pass; 105 output = Format.asprintf "%a" Error.pp e; 106 } 107 | exn -> 108 { base with 109 status = `Pass; 110 output = Printexc.to_string exn; 111 } 112 end 113 else begin 114 match test.tree with 115 | None -> 116 (* No expected tree - check if json indicates expected success *) 117 (match test.json with 118 | Some _ -> 119 (* Has json output, so should parse successfully *) 120 (try 121 let parser = Parser.of_string test.yaml in 122 let events = Parser.to_list parser in 123 let tree = TF.of_spanned_events events in 124 { base with status = `Pass; output = tree } 125 with exn -> 126 { base with 127 status = `Fail (Printf.sprintf "Should parse but got: %s" (Printexc.to_string exn)); 128 output = Printexc.to_string exn; 129 }) 130 | None -> 131 (* No tree, no json, no fail - ambiguous edge case, skip *) 132 { base with status = `Skip; output = "(no expected tree or json)" }) 133 | Some expected -> 134 try 135 let parser = Parser.of_string test.yaml in 136 let events = Parser.to_list parser in 137 let actual = TF.of_spanned_events events in 138 let expected_norm = normalize_tree expected in 139 let actual_norm = normalize_tree actual in 140 if expected_norm = actual_norm then 141 { base with status = `Pass; output = actual } 142 else 143 { base with 144 status = `Fail (Printf.sprintf "Tree mismatch"); 145 output = Printf.sprintf "Expected:\n%s\n\nActual:\n%s" expected_norm actual_norm; 146 } 147 with exn -> 148 { base with 149 status = `Fail (Printf.sprintf "Exception: %s" (Printexc.to_string exn)); 150 output = Printexc.to_string exn; 151 } 152 end 153 154let status_class = function 155 | `Pass -> "pass" 156 | `Fail _ -> "fail" 157 | `Skip -> "skip" 158 159let status_text = function 160 | `Pass -> "PASS" 161 | `Fail _ -> "FAIL" 162 | `Skip -> "SKIP" 163 164let generate_html results output_file = 165 let oc = open_out output_file in 166 let pass_count = List.length (List.filter (fun r -> r.status = `Pass) results) in 167 let fail_count = List.length (List.filter (fun r -> match r.status with `Fail _ -> true | _ -> false) results) in 168 let skip_count = List.length (List.filter (fun r -> r.status = `Skip) results) in 169 let total = List.length results in 170 let json_pass_count = List.length (List.filter (fun r -> r.json_status = `Pass) results) in 171 let json_fail_count = List.length (List.filter (fun r -> match r.json_status with `Fail _ -> true | _ -> false) results) in 172 let json_skip_count = List.length (List.filter (fun r -> r.json_status = `Skip) results) in 173 174 Printf.fprintf oc {|<!DOCTYPE html> 175<html lang="en"> 176<head> 177 <meta charset="UTF-8"> 178 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 179 <title>Yamlrw Test Results</title> 180 <style> 181 :root { 182 --pass-color: #22c55e; 183 --fail-color: #ef4444; 184 --skip-color: #f59e0b; 185 --bg-color: #1a1a2e; 186 --card-bg: #16213e; 187 --text-color: #e2e8f0; 188 --border-color: #334155; 189 } 190 * { box-sizing: border-box; margin: 0; padding: 0; } 191 body { 192 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 193 background: var(--bg-color); 194 color: var(--text-color); 195 line-height: 1.6; 196 padding: 2rem; 197 } 198 .container { max-width: 1400px; margin: 0 auto; } 199 h1 { margin-bottom: 1.5rem; font-size: 2rem; } 200 .summary { 201 display: flex; 202 gap: 1rem; 203 margin-bottom: 2rem; 204 flex-wrap: wrap; 205 } 206 .stat { 207 background: var(--card-bg); 208 padding: 1rem 1.5rem; 209 border-radius: 8px; 210 border-left: 4px solid var(--border-color); 211 } 212 .stat.pass { border-left-color: var(--pass-color); } 213 .stat.fail { border-left-color: var(--fail-color); } 214 .stat.skip { border-left-color: var(--skip-color); } 215 .stat-value { font-size: 2rem; font-weight: bold; } 216 .stat-label { font-size: 0.875rem; opacity: 0.8; } 217 .filters { 218 margin-bottom: 1.5rem; 219 display: flex; 220 gap: 0.5rem; 221 flex-wrap: wrap; 222 } 223 .filter-btn { 224 padding: 0.5rem 1rem; 225 border: 1px solid var(--border-color); 226 background: var(--card-bg); 227 color: var(--text-color); 228 border-radius: 4px; 229 cursor: pointer; 230 transition: all 0.2s; 231 } 232 .filter-btn:hover { border-color: var(--text-color); } 233 .filter-btn.active { background: var(--text-color); color: var(--bg-color); } 234 .search { 235 padding: 0.5rem 1rem; 236 border: 1px solid var(--border-color); 237 background: var(--card-bg); 238 color: var(--text-color); 239 border-radius: 4px; 240 width: 200px; 241 } 242 .tests { display: flex; flex-direction: column; gap: 1rem; } 243 .test { 244 background: var(--card-bg); 245 border-radius: 8px; 246 border: 1px solid var(--border-color); 247 overflow: hidden; 248 } 249 .test-header { 250 padding: 1rem; 251 display: flex; 252 align-items: center; 253 gap: 1rem; 254 cursor: pointer; 255 border-bottom: 1px solid var(--border-color); 256 } 257 .test-header:hover { background: rgba(255,255,255,0.05); } 258 .badge { 259 padding: 0.25rem 0.5rem; 260 border-radius: 4px; 261 font-size: 0.75rem; 262 font-weight: bold; 263 text-transform: uppercase; 264 } 265 .badge.pass { background: var(--pass-color); color: #000; } 266 .badge.fail { background: var(--fail-color); color: #fff; } 267 .badge.skip { background: var(--skip-color); color: #000; } 268 .badge.error-test { background: #8b5cf6; color: #fff; margin-left: auto; } 269 .test-id { font-family: monospace; font-weight: bold; } 270 .test-name { opacity: 0.8; flex: 1; } 271 .test-content { display: none; padding: 1rem; } 272 .test.expanded .test-content { display: block; } 273 .section { margin-bottom: 1rem; } 274 .section-title { 275 font-size: 0.875rem; 276 text-transform: uppercase; 277 opacity: 0.6; 278 margin-bottom: 0.5rem; 279 letter-spacing: 0.05em; 280 } 281 pre { 282 background: #0f172a; 283 padding: 1rem; 284 border-radius: 4px; 285 overflow-x: auto; 286 font-size: 0.875rem; 287 white-space: pre-wrap; 288 word-break: break-all; 289 } 290 .expand-icon { transition: transform 0.2s; } 291 .test.expanded .expand-icon { transform: rotate(90deg); } 292 </style> 293</head> 294<body> 295 <div class="container"> 296 <h1>Yamlrw Test Results</h1> 297 <div class="summary"> 298 <div class="stat pass"> 299 <div class="stat-value">%d</div> 300 <div class="stat-label">Passed</div> 301 </div> 302 <div class="stat fail"> 303 <div class="stat-value">%d</div> 304 <div class="stat-label">Failed</div> 305 </div> 306 <div class="stat skip"> 307 <div class="stat-value">%d</div> 308 <div class="stat-label">Skipped</div> 309 </div> 310 <div class="stat"> 311 <div class="stat-value">%d</div> 312 <div class="stat-label">Total</div> 313 </div> 314 </div> 315 <h2 style="margin: 1.5rem 0 1rem;">JSON Output Comparison</h2> 316 <div class="summary"> 317 <div class="stat pass"> 318 <div class="stat-value">%d</div> 319 <div class="stat-label">JSON Pass</div> 320 </div> 321 <div class="stat fail"> 322 <div class="stat-value">%d</div> 323 <div class="stat-label">JSON Fail</div> 324 </div> 325 <div class="stat skip"> 326 <div class="stat-value">%d</div> 327 <div class="stat-label">JSON Skip</div> 328 </div> 329 </div> 330 <div class="filters"> 331 <button class="filter-btn active" data-filter="all">All</button> 332 <button class="filter-btn" data-filter="pass">Pass</button> 333 <button class="filter-btn" data-filter="fail">Fail</button> 334 <button class="filter-btn" data-filter="skip">Skip</button> 335 <input type="text" class="search" placeholder="Search by ID or name..."> 336 </div> 337 <div class="tests"> 338|} pass_count fail_count skip_count total json_pass_count json_fail_count json_skip_count; 339 340 List.iter (fun result -> 341 let error_badge = if result.is_error_test then 342 {|<span class="badge error-test">Error Test</span>|} 343 else "" in 344 let json_badge = Printf.sprintf {|<span class="badge %s" style="margin-left: 4px;">JSON: %s</span>|} 345 (status_class result.json_status) (status_text result.json_status) in 346 let json_section = if result.json_expected <> "" || result.json_actual <> "" then 347 Printf.sprintf {| 348 <div class="section"> 349 <div class="section-title">Expected JSON</div> 350 <pre>%s</pre> 351 </div> 352 <div class="section"> 353 <div class="section-title">Actual JSON</div> 354 <pre>%s</pre> 355 </div>|} 356 (html_escape result.json_expected) 357 (html_escape result.json_actual) 358 else "" in 359 Printf.fprintf oc {| <div class="test" data-status="%s" data-json-status="%s" data-id="%s" data-name="%s"> 360 <div class="test-header" onclick="this.parentElement.classList.toggle('expanded')"> 361 <span class="expand-icon">▶</span> 362 <span class="badge %s">%s</span> 363 %s 364 <span class="test-id">%s</span> 365 <span class="test-name">%s</span> 366 %s 367 </div> 368 <div class="test-content"> 369 <div class="section"> 370 <div class="section-title">YAML Input</div> 371 <pre>%s</pre> 372 </div> 373 <div class="section"> 374 <div class="section-title">Event Tree Output</div> 375 <pre>%s</pre> 376 </div>%s 377 </div> 378 </div> 379|} 380 (status_class result.status) 381 (status_class result.json_status) 382 (html_escape result.id) 383 (html_escape (String.lowercase_ascii result.name)) 384 (status_class result.status) 385 (status_text result.status) 386 json_badge 387 (html_escape result.id) 388 (html_escape result.name) 389 error_badge 390 (html_escape result.yaml) 391 (html_escape result.output) 392 json_section 393 ) results; 394 395 Printf.fprintf oc {| </div> 396 </div> 397 <script> 398 document.querySelectorAll('.filter-btn').forEach(btn => { 399 btn.addEventListener('click', () => { 400 document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); 401 btn.classList.add('active'); 402 filterTests(); 403 }); 404 }); 405 document.querySelector('.search').addEventListener('input', filterTests); 406 function filterTests() { 407 const filter = document.querySelector('.filter-btn.active').dataset.filter; 408 const search = document.querySelector('.search').value.toLowerCase(); 409 document.querySelectorAll('.test').forEach(test => { 410 const status = test.dataset.status; 411 const id = test.dataset.id.toLowerCase(); 412 const name = test.dataset.name; 413 const matchesFilter = filter === 'all' || status === filter; 414 const matchesSearch = !search || id.includes(search) || name.includes(search); 415 test.style.display = matchesFilter && matchesSearch ? '' : 'none'; 416 }); 417 } 418 </script> 419</body> 420</html> 421|}; 422 close_out oc 423 424let () = 425 let html_output = ref None in 426 let show_skipped = ref false in 427 let test_suite_path_ref = ref test_suite_path in 428 let args = [ 429 "--html", Arg.String (fun s -> html_output := Some s), 430 "<file> Generate HTML report to file"; 431 "--show-skipped", Arg.Set show_skipped, 432 " Show details of skipped tests"; 433 "--test-suite-path", Arg.Set_string test_suite_path_ref, 434 "<path> Path to yaml-test-suite directory"; 435 ] in 436 Arg.parse args (fun _ -> ()) "Usage: run_all_tests [--html <file>] [--show-skipped] [--test-suite-path <path>]"; 437 438 let all_tests = TL.load_directory !test_suite_path_ref in 439 Printf.printf "Total tests loaded: %d\n%!" (List.length all_tests); 440 441 let results = List.map run_test all_tests in 442 443 let pass_count = List.length (List.filter (fun r -> r.status = `Pass) results) in 444 let fail_count = List.length (List.filter (fun r -> match r.status with `Fail _ -> true | _ -> false) results) in 445 let skip_count = List.length (List.filter (fun r -> r.status = `Skip) results) in 446 447 let json_pass_count = List.length (List.filter (fun r -> r.json_status = `Pass) results) in 448 let json_fail_count = List.length (List.filter (fun r -> match r.json_status with `Fail _ -> true | _ -> false) results) in 449 let json_skip_count = List.length (List.filter (fun r -> r.json_status = `Skip) results) in 450 451 Printf.printf "\nEvent Tree Results: %d pass, %d fail, %d skip (total: %d)\n%!" 452 pass_count fail_count skip_count (pass_count + fail_count + skip_count); 453 454 Printf.printf "JSON Results: %d pass, %d fail, %d skip\n%!" 455 json_pass_count json_fail_count json_skip_count; 456 457 if fail_count > 0 then begin 458 Printf.printf "\nFailing event tree tests:\n"; 459 List.iter (fun r -> 460 match r.status with 461 | `Fail msg -> Printf.printf " %s: %s - %s\n" r.id r.name msg 462 | _ -> () 463 ) results 464 end; 465 466 if json_fail_count > 0 then begin 467 Printf.printf "\nFailing JSON tests:\n"; 468 List.iter (fun r -> 469 match r.json_status with 470 | `Fail msg -> Printf.printf " %s: %s - %s\n" r.id r.name msg 471 | _ -> () 472 ) results 473 end; 474 475 if !show_skipped && skip_count > 0 then begin 476 Printf.printf "\nSkipped tests (no expected tree):\n"; 477 List.iter (fun r -> 478 if r.status = `Skip then begin 479 Printf.printf " %s: %s\n" r.id r.name; 480 Printf.printf " YAML (%d chars): %S\n" (String.length r.yaml) 481 (if String.length r.yaml <= 60 then r.yaml 482 else String.sub r.yaml 0 60 ^ "...") 483 end 484 ) results 485 end; 486 487 match !html_output with 488 | Some file -> 489 generate_html results file; 490 Printf.printf "\nHTML report generated: %s\n" file 491 | None -> ()