OCaml HTML5 parser/serialiser based on Python's JustHTML
1(** Test runner for Nu HTML Validator test suite 2 3 This validates HTML5 documents against the upstream Nu HTML Validator test suite. 4 Tests are classified by filename suffix: 5 - `-isvalid.html` : Should produce no errors or warnings 6 - `-novalid.html` : Should produce at least one error 7 - `-haswarn.html` : Should produce at least one warning 8*) 9 10module Report = Test_report 11 12type expected_outcome = 13 | Valid (** -isvalid.html: expect no errors *) 14 | Invalid (** -novalid.html: expect error matching messages.json *) 15 | HasWarning (** -haswarn.html: expect warning matching messages.json *) 16 | Unknown (** Unknown suffix *) 17 18type test_file = { 19 path : string; (** Full filesystem path *) 20 relative_path : string; (** Path relative to tests/, used as key in messages.json *) 21 category : string; (** html, html-aria, etc. *) 22 expected : expected_outcome; 23} 24 25type test_result = { 26 file : test_file; 27 passed : bool; 28 actual_errors : string list; 29 actual_warnings : string list; 30 actual_infos : string list; 31 expected_message : string option; 32 match_quality : Expected_message.match_quality option; (** How well did message match? *) 33 details : string; 34} 35 36(** Parse expected outcome from filename suffix *) 37let parse_outcome filename = 38 (* Check for .html suffix *) 39 if String.length filename > 13 && String.sub filename (String.length filename - 13) 13 = "-isvalid.html" then 40 Valid 41 else if String.length filename > 13 && String.sub filename (String.length filename - 13) 13 = "-novalid.html" then 42 Invalid 43 else if String.length filename > 13 && String.sub filename (String.length filename - 13) 13 = "-haswarn.html" then 44 HasWarning 45 (* Check for .xhtml suffix *) 46 else if String.length filename > 14 && String.sub filename (String.length filename - 14) 14 = "-isvalid.xhtml" then 47 Valid 48 else if String.length filename > 14 && String.sub filename (String.length filename - 14) 14 = "-novalid.xhtml" then 49 Invalid 50 else if String.length filename > 14 && String.sub filename (String.length filename - 14) 14 = "-haswarn.xhtml" then 51 HasWarning 52 else 53 Unknown 54 55(** Current strictness setting - can be set via --strict flag *) 56let strictness = ref Expected_message.lenient 57 58(** Find best matching message and return (found_acceptable, best_quality) *) 59let find_best_match ~expected_str ~actual_msgs = 60 let expected = Expected_message.parse expected_str in 61 let qualities = List.map (fun msg -> 62 Expected_message.matches ~strictness:!strictness ~expected ~actual:msg 63 ) actual_msgs in 64 65 let best_quality = 66 List.fold_left (fun best q -> 67 (* Lower variant = better match in our type definition *) 68 if q < best then q else best 69 ) Expected_message.No_match qualities 70 in 71 let acceptable = Expected_message.is_acceptable ~strictness:!strictness best_quality in 72 (acceptable, best_quality) 73 74(** Recursively find all HTML test files *) 75let rec discover_tests_in_dir base_dir current_dir = 76 let full_path = Filename.concat base_dir current_dir in 77 if not (Sys.file_exists full_path) then [] 78 else if Sys.is_directory full_path then begin 79 let entries = Sys.readdir full_path |> Array.to_list in 80 List.concat_map (fun entry -> 81 let sub_path = if current_dir = "" then entry else Filename.concat current_dir entry in 82 discover_tests_in_dir base_dir sub_path 83 ) entries 84 end else if Filename.check_suffix current_dir ".html" || Filename.check_suffix current_dir ".xhtml" then begin 85 let outcome = parse_outcome (Filename.basename current_dir) in 86 if outcome = Unknown then [] 87 else 88 let category = 89 match String.split_on_char '/' current_dir with 90 | cat :: _ -> cat 91 | [] -> "unknown" 92 in 93 [{ path = full_path; relative_path = current_dir; category; expected = outcome }] 94 end else 95 [] 96 97let discover_tests tests_dir = 98 discover_tests_in_dir tests_dir "" 99 100(** Run a single test *) 101let run_test messages test = 102 try 103 let ic = open_in test.path in 104 let content = really_input_string ic (in_channel_length ic) in 105 close_in ic; 106 107 let reader = Bytesrw.Bytes.Reader.of_string content in 108 let result = Html5_checker.check ~collect_parse_errors:true ~system_id:test.relative_path reader in 109 110 (* Keep full message objects for proper matching *) 111 let error_msgs = Html5_checker.errors result in 112 let warning_msgs = Html5_checker.warnings result in 113 let info_msgs = Html5_checker.infos result in 114 115 (* Extract text for reporting *) 116 let errors = List.map (fun m -> m.Html5_checker.Message.message) error_msgs in 117 let warnings = List.map (fun m -> m.Html5_checker.Message.message) warning_msgs in 118 let infos = List.map (fun m -> m.Html5_checker.Message.message) info_msgs in 119 let expected_msg = Validator_messages.get messages test.relative_path in 120 121 let (passed, match_quality, details) = match test.expected with 122 | Valid -> 123 (* isvalid tests fail on errors or warnings, but info messages are OK *) 124 if errors = [] && warnings = [] then 125 (true, None, 126 if infos = [] then "OK: No messages" else Printf.sprintf "OK: No errors/warnings (%d info)" (List.length infos)) 127 else 128 (false, None, 129 Printf.sprintf "Expected valid but got %d errors, %d warnings" 130 (List.length errors) (List.length warnings)) 131 | Invalid -> 132 if errors = [] then 133 (false, None, "Expected error but got none") 134 else begin 135 (* For novalid tests, require message match when expected message is provided *) 136 match expected_msg with 137 | None -> 138 (* No expected message - pass if any error detected *) 139 (true, None, 140 Printf.sprintf "Got %d error(s), no expected message to match" (List.length errors)) 141 | Some exp -> 142 let (matched, quality) = find_best_match ~expected_str:exp ~actual_msgs:error_msgs in 143 if matched then 144 (true, Some quality, 145 Printf.sprintf "Got %d error(s), match: %s" (List.length errors) 146 (Expected_message.match_quality_to_string quality)) 147 else 148 (* FAIL if message doesn't match *) 149 (false, Some quality, 150 Printf.sprintf "Message mismatch (quality: %s).\n Expected: %s\n Got: %s" 151 (Expected_message.match_quality_to_string quality) 152 exp (String.concat "\n " errors)) 153 end 154 | HasWarning -> 155 (* For haswarn, require message match against warnings or infos *) 156 let all_msgs = warning_msgs @ info_msgs in 157 let all_messages = warnings @ infos in 158 if all_messages = [] && errors = [] then 159 (false, None, "Expected warning but got none") 160 else begin 161 match expected_msg with 162 | None -> 163 if all_messages <> [] then 164 (true, None, Printf.sprintf "Got %d warning/info message(s)" (List.length all_messages)) 165 else 166 (true, None, Printf.sprintf "Got %d error(s) instead of warning" (List.length errors)) 167 | Some exp -> 168 let (warn_matched, warn_quality) = find_best_match ~expected_str:exp ~actual_msgs:all_msgs in 169 if warn_matched then 170 (true, Some warn_quality, 171 Printf.sprintf "Got %d warning/info message(s), match: %s" (List.length all_messages) 172 (Expected_message.match_quality_to_string warn_quality)) 173 else begin 174 let (err_matched, err_quality) = find_best_match ~expected_str:exp ~actual_msgs:error_msgs in 175 if err_matched then 176 (* Accept error if message matches (severity might differ) *) 177 (true, Some err_quality, 178 Printf.sprintf "Got error instead of warning, match: %s" 179 (Expected_message.match_quality_to_string err_quality)) 180 else 181 let best = if warn_quality < err_quality then warn_quality else err_quality in 182 (false, Some best, 183 Printf.sprintf "Message mismatch (quality: %s).\n Expected: %s\n Got warnings: %s\n Got errors: %s" 184 (Expected_message.match_quality_to_string best) 185 exp (String.concat "\n " (if all_messages = [] then ["(none)"] else all_messages)) 186 (String.concat "\n " (if errors = [] then ["(none)"] else errors))) 187 end 188 end 189 | Unknown -> 190 (false, None, "Unknown test type") 191 in 192 { file = test; passed; actual_errors = errors; actual_warnings = warnings; 193 actual_infos = infos; expected_message = expected_msg; match_quality; details } 194 with e -> 195 { file = test; passed = false; actual_errors = []; actual_warnings = []; 196 actual_infos = []; expected_message = None; match_quality = None; 197 details = Printf.sprintf "Exception: %s" (Printexc.to_string e) } 198 199(** Group tests by category *) 200let group_by_category tests = 201 let tbl = Hashtbl.create 16 in 202 List.iter (fun test -> 203 let cat = test.file.category in 204 let existing = try Hashtbl.find tbl cat with Not_found -> [] in 205 Hashtbl.replace tbl cat (test :: existing) 206 ) tests; 207 Hashtbl.fold (fun k v acc -> (k, List.rev v) :: acc) tbl [] 208 |> List.sort (fun (a, _) (b, _) -> String.compare a b) 209 210(** Print summary to console *) 211let print_summary results = 212 let by_category = group_by_category results in 213 Printf.printf "\n=== Results by Category ===\n"; 214 List.iter (fun (cat, tests) -> 215 let passed = List.filter (fun r -> r.passed) tests |> List.length in 216 let total = List.length tests in 217 Printf.printf "%s: %d/%d passed (%.1f%%)\n" cat passed total 218 (100.0 *. float_of_int passed /. float_of_int (max 1 total)) 219 ) by_category; 220 221 (* Breakdown by test type *) 222 let isvalid_results = List.filter (fun r -> r.file.expected = Valid) results in 223 let novalid_results = List.filter (fun r -> r.file.expected = Invalid) results in 224 let haswarn_results = List.filter (fun r -> r.file.expected = HasWarning) results in 225 226 let count_passed rs = List.filter (fun r -> r.passed) rs |> List.length in 227 228 Printf.printf "\n=== Results by Test Type ===\n"; 229 Printf.printf "isvalid (no errors expected): %d/%d passed (%.1f%%)\n" 230 (count_passed isvalid_results) (List.length isvalid_results) 231 (100.0 *. float_of_int (count_passed isvalid_results) /. float_of_int (max 1 (List.length isvalid_results))); 232 Printf.printf "novalid (errors expected): %d/%d passed (%.1f%%)\n" 233 (count_passed novalid_results) (List.length novalid_results) 234 (100.0 *. float_of_int (count_passed novalid_results) /. float_of_int (max 1 (List.length novalid_results))); 235 Printf.printf "haswarn (warnings expected): %d/%d passed (%.1f%%)\n" 236 (count_passed haswarn_results) (List.length haswarn_results) 237 (100.0 *. float_of_int (count_passed haswarn_results) /. float_of_int (max 1 (List.length haswarn_results))); 238 239 let total_passed = List.filter (fun r -> r.passed) results |> List.length in 240 let total = List.length results in 241 Printf.printf "\n=== Overall ===\n"; 242 Printf.printf "Total: %d/%d passed (%.1f%%)\n" total_passed total 243 (100.0 *. float_of_int total_passed /. float_of_int (max 1 total)); 244 245 (* Match quality breakdown *) 246 let count_quality q = List.filter (fun r -> 247 match r.match_quality with Some mq -> mq = q | None -> false 248 ) results |> List.length in 249 let exact = count_quality Expected_message.Exact_match in 250 let code_match = count_quality Expected_message.Code_match in 251 let msg_match = count_quality Expected_message.Message_match in 252 let substring = count_quality Expected_message.Substring_match in 253 let sev_mismatch = count_quality Expected_message.Severity_mismatch in 254 let no_match = count_quality Expected_message.No_match in 255 let no_quality = List.filter (fun r -> r.match_quality = None) results |> List.length in 256 257 Printf.printf "\n=== Match Quality ===\n"; 258 let mode_name = 259 if !strictness = Expected_message.strict then "STRICT (full)" 260 else if !strictness = Expected_message.exact_message then "STRICT (exact message)" 261 else "lenient" 262 in 263 Printf.printf "Mode: %s\n" mode_name; 264 Printf.printf "Exact matches: %d\n" exact; 265 Printf.printf "Code matches: %d\n" code_match; 266 Printf.printf "Message matches: %d\n" msg_match; 267 Printf.printf "Substring matches: %d\n" substring; 268 Printf.printf "Severity mismatches: %d\n" sev_mismatch; 269 Printf.printf "No matches: %d\n" no_match; 270 Printf.printf "N/A (isvalid or no expected): %d\n" no_quality 271 272(** Generate HTML report *) 273let generate_html_report results output_path = 274 let by_category = group_by_category results in 275 276 let file_results = List.map (fun (category, tests) -> 277 let passed_count = List.filter (fun r -> r.passed) tests |> List.length in 278 let failed_count = List.length tests - passed_count in 279 let test_results = List.mapi (fun i r -> 280 let outcome_str = match r.file.expected with 281 | Valid -> "valid" 282 | Invalid -> "invalid" 283 | HasWarning -> "has-warning" 284 | Unknown -> "unknown" 285 in 286 let description = Printf.sprintf "[%s] %s" outcome_str r.file.relative_path in 287 let expected = match r.expected_message with 288 | Some m -> m 289 | None -> "(no expected message)" 290 in 291 let actual_str = 292 let errors = if r.actual_errors = [] then "" 293 else "Errors:\n" ^ String.concat "\n" r.actual_errors in 294 let warnings = if r.actual_warnings = [] then "" 295 else "Warnings:\n" ^ String.concat "\n" r.actual_warnings in 296 let infos = if r.actual_infos = [] then "" 297 else "Info:\n" ^ String.concat "\n" r.actual_infos in 298 if errors = "" && warnings = "" && infos = "" then "(no messages)" 299 else String.trim (errors ^ "\n" ^ warnings ^ "\n" ^ infos) 300 in 301 Report.{ 302 test_num = i + 1; 303 description; 304 input = r.file.relative_path; 305 expected; 306 actual = actual_str; 307 success = r.passed; 308 details = [("Status", r.details)]; 309 raw_test_data = None; 310 } 311 ) tests in 312 Report.{ 313 filename = category; 314 test_type = "HTML5 Validator"; 315 passed_count; 316 failed_count; 317 tests = test_results; 318 } 319 ) by_category in 320 321 let total_passed = List.filter (fun r -> r.passed) results |> List.length in 322 let total_failed = List.length results - total_passed in 323 324 let report : Report.report = { 325 title = "Nu HTML Validator Tests"; 326 test_type = "validator"; 327 description = "Tests from the Nu HTML Validator (W3C's official HTML checker). \ 328 Tests validate HTML5 conformance including element nesting, required attributes, \ 329 ARIA roles, obsolete elements, and more. Each test file is classified by suffix: \ 330 -isvalid.html (should produce no errors), -novalid.html (should produce errors), \ 331 -haswarn.html (should produce warnings)."; 332 files = file_results; 333 total_passed; 334 total_failed; 335 } in 336 Report.generate_report report output_path 337 338let () = 339 (* Parse command line arguments *) 340 let args = Array.to_list Sys.argv |> List.tl in 341 let is_strict = List.mem "--strict" args in 342 let non_flag_args = List.filter (fun s -> not (String.length s > 0 && s.[0] = '-')) args in 343 let tests_dir = match non_flag_args with x :: _ -> x | [] -> "validator/tests" in 344 let report_path = match non_flag_args with _ :: x :: _ -> x | _ -> "test_validator_report.html" in 345 346 (* Apply strict mode if requested - use exact_message which requires exact text but not typed codes *) 347 if is_strict then begin 348 strictness := Expected_message.exact_message; 349 Printf.printf "Running in STRICT mode (exact message matching required)\n%!" 350 end; 351 352 Printf.printf "Loading messages.json...\n%!"; 353 let messages_path = Filename.concat tests_dir "messages.json" in 354 let messages = Validator_messages.load messages_path in 355 Printf.printf "Loaded %d expected messages\n%!" (Validator_messages.count messages); 356 357 Printf.printf "Discovering test files...\n%!"; 358 let tests = discover_tests tests_dir in 359 Printf.printf "Found %d test files\n%!" (List.length tests); 360 361 Printf.printf "Running tests...\n%!"; 362 let results = List.map (run_test messages) tests in 363 364 (* Print failing isvalid tests *) 365 let failing_isvalid = List.filter (fun r -> 366 r.file.expected = Valid && not r.passed 367 ) results in 368 if failing_isvalid <> [] then begin 369 Printf.printf "\n=== Failing isvalid tests ===\n"; 370 List.iter (fun r -> 371 Printf.printf "%s: %s\n" r.file.relative_path r.details 372 ) failing_isvalid 373 end; 374 375 (* Print failing haswarn tests *) 376 let failing_haswarn = List.filter (fun r -> 377 r.file.expected = HasWarning && not r.passed 378 ) results in 379 if failing_haswarn <> [] then begin 380 Printf.printf "\n=== Failing haswarn tests ===\n"; 381 List.iter (fun r -> 382 Printf.printf "%s\n" r.file.relative_path 383 ) failing_haswarn 384 end; 385 386 (* Print failing novalid tests *) 387 let failing_novalid = List.filter (fun r -> 388 r.file.expected = Invalid && not r.passed 389 ) results in 390 if failing_novalid <> [] then begin 391 Printf.printf "\n=== Failing novalid tests (first 50) ===\n"; 392 List.iteri (fun i r -> 393 if i < 50 then Printf.printf "%s\n" r.file.relative_path 394 ) failing_novalid 395 end; 396 397 print_summary results; 398 generate_html_report results report_path; 399 400 let failed_count = List.filter (fun r -> not r.passed) results |> List.length in 401 exit (if failed_count > 0 then 1 else 0)