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 = Htmlrw_check.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 = Htmlrw_check.errors result in
112 let warning_msgs = Htmlrw_check.warnings result in
113 let info_msgs = Htmlrw_check.infos result in
114
115 (* Extract text for reporting *)
116 let errors = List.map (fun m -> m.Htmlrw_check.text) error_msgs in
117 let warnings = List.map (fun m -> m.Htmlrw_check.text) warning_msgs in
118 let infos = List.map (fun m -> m.Htmlrw_check.text) 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(** Read HTML source file for display in report *)
273let read_html_source path =
274 try
275 let ic = open_in path in
276 let content = really_input_string ic (in_channel_length ic) in
277 close_in ic;
278 Some content
279 with _ -> None
280
281(** Generate HTML report *)
282let generate_html_report results output_path =
283 let by_category = group_by_category results in
284
285 let file_results = List.map (fun (category, tests) ->
286 let passed_count = List.filter (fun r -> r.passed) tests |> List.length in
287 let failed_count = List.length tests - passed_count in
288 let test_results = List.mapi (fun i r ->
289 let outcome_str = match r.file.expected with
290 | Valid -> "isvalid"
291 | Invalid -> "novalid"
292 | HasWarning -> "haswarn"
293 | Unknown -> "unknown"
294 in
295 let description = Printf.sprintf "[%s] %s" outcome_str (Filename.basename r.file.relative_path) in
296 let expected = match r.expected_message with
297 | Some m -> m
298 | None -> match r.file.expected with
299 | Valid -> "(should produce no errors or warnings)"
300 | Invalid -> "(should produce at least one error)"
301 | HasWarning -> "(should produce at least one warning)"
302 | Unknown -> "(unknown test type)"
303 in
304 let actual_str =
305 let errors = if r.actual_errors = [] then ""
306 else "Errors:\n • " ^ String.concat "\n • " r.actual_errors in
307 let warnings = if r.actual_warnings = [] then ""
308 else "Warnings:\n • " ^ String.concat "\n • " r.actual_warnings in
309 let infos = if r.actual_infos = [] then ""
310 else "Info:\n • " ^ String.concat "\n • " r.actual_infos in
311 if errors = "" && warnings = "" && infos = "" then "(no messages produced)"
312 else String.trim (errors ^ (if errors <> "" && warnings <> "" then "\n\n" else "") ^
313 warnings ^ (if (errors <> "" || warnings <> "") && infos <> "" then "\n\n" else "") ^
314 infos)
315 in
316 let match_quality_str = match r.match_quality with
317 | Some q -> Expected_message.match_quality_to_string q
318 | None -> "N/A"
319 in
320 Report.{
321 test_num = i + 1;
322 description;
323 input = r.file.relative_path;
324 expected;
325 actual = actual_str;
326 success = r.passed;
327 details = [
328 ("Result", r.details);
329 ("Match Quality", match_quality_str);
330 ];
331 raw_test_data = read_html_source r.file.path;
332 }
333 ) tests in
334 Report.{
335 filename = category;
336 test_type = "HTML5 Validator";
337 passed_count;
338 failed_count;
339 tests = test_results;
340 }
341 ) by_category in
342
343 let total_passed = List.filter (fun r -> r.passed) results |> List.length in
344 let total_failed = List.length results - total_passed in
345
346 (* Compute match quality stats *)
347 let count_quality q = List.filter (fun r ->
348 match r.match_quality with Some mq -> mq = q | None -> false
349 ) results |> List.length in
350 let match_quality_stats : Report.match_quality_stats = {
351 exact_matches = count_quality Expected_message.Exact_match;
352 code_matches = count_quality Expected_message.Code_match;
353 message_matches = count_quality Expected_message.Message_match;
354 substring_matches = count_quality Expected_message.Substring_match;
355 severity_mismatches = count_quality Expected_message.Severity_mismatch;
356 no_matches = count_quality Expected_message.No_match;
357 not_applicable = List.filter (fun r -> r.match_quality = None) results |> List.length;
358 } in
359
360 (* Compute test type stats *)
361 let isvalid_results = List.filter (fun r -> r.file.expected = Valid) results in
362 let novalid_results = List.filter (fun r -> r.file.expected = Invalid) results in
363 let haswarn_results = List.filter (fun r -> r.file.expected = HasWarning) results in
364 let count_passed rs = List.filter (fun r -> r.passed) rs |> List.length in
365 let test_type_stats : Report.test_type_stats = {
366 isvalid_passed = count_passed isvalid_results;
367 isvalid_total = List.length isvalid_results;
368 novalid_passed = count_passed novalid_results;
369 novalid_total = List.length novalid_results;
370 haswarn_passed = count_passed haswarn_results;
371 haswarn_total = List.length haswarn_results;
372 } in
373
374 let mode_name =
375 if !strictness = Expected_message.strict then "STRICT (full)"
376 else if !strictness = Expected_message.exact_message then "STRICT (exact message)"
377 else "lenient"
378 in
379
380 (* Get current timestamp *)
381 let now = Unix.gettimeofday () in
382 let tm = Unix.localtime now in
383 let timestamp = Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d"
384 (tm.Unix.tm_year + 1900) (tm.Unix.tm_mon + 1) tm.Unix.tm_mday
385 tm.Unix.tm_hour tm.Unix.tm_min tm.Unix.tm_sec in
386
387 let report : Report.report = {
388 title = "Nu HTML Validator Tests";
389 test_type = "validator";
390 description = "Tests from the Nu HTML Validator (W3C's official HTML checker). \
391 Tests validate HTML5 conformance including element nesting, required attributes, \
392 ARIA roles, obsolete elements, and more. Each test file is classified by suffix: \
393 -isvalid.html (should produce no errors), -novalid.html (should produce errors), \
394 -haswarn.html (should produce warnings).";
395 files = file_results;
396 total_passed;
397 total_failed;
398 match_quality = Some match_quality_stats;
399 test_type_breakdown = Some test_type_stats;
400 strictness_mode = Some mode_name;
401 run_timestamp = Some timestamp;
402 } in
403 Report.generate_report report output_path
404
405(** Run tests with a given strictness and return results *)
406let run_all_tests ~mode_name ~strictness_setting messages tests =
407 strictness := strictness_setting;
408 Printf.printf "\n=== Running in %s mode ===\n%!" mode_name;
409 let total = List.length tests in
410 let results = List.mapi (fun i test ->
411 Printf.printf "\r[%d/%d] %s%!" (i + 1) total test.relative_path;
412 run_test messages test
413 ) tests in
414 Printf.printf "\n%!";
415 results
416
417(** Print failures for a test run *)
418let print_failures mode_name results =
419 Printf.printf "\n--- %s mode results ---\n" mode_name;
420
421 let failing_isvalid = List.filter (fun r ->
422 r.file.expected = Valid && not r.passed
423 ) results in
424 if failing_isvalid <> [] then begin
425 Printf.printf "Failing isvalid tests:\n";
426 List.iter (fun r ->
427 Printf.printf " %s: %s\n" r.file.relative_path r.details
428 ) failing_isvalid
429 end;
430
431 let failing_haswarn = List.filter (fun r ->
432 r.file.expected = HasWarning && not r.passed
433 ) results in
434 if failing_haswarn <> [] then begin
435 Printf.printf "Failing haswarn tests:\n";
436 List.iter (fun r ->
437 Printf.printf " %s\n" r.file.relative_path
438 ) failing_haswarn
439 end;
440
441 let failing_novalid = List.filter (fun r ->
442 r.file.expected = Invalid && not r.passed
443 ) results in
444 if failing_novalid <> [] then begin
445 Printf.printf "Failing novalid tests (first 20):\n";
446 List.iteri (fun i r ->
447 if i < 20 then Printf.printf " %s\n" r.file.relative_path
448 ) failing_novalid
449 end;
450
451 let passed = List.filter (fun r -> r.passed) results |> List.length in
452 let total = List.length results in
453 Printf.printf "%s: %d/%d passed (%.1f%%)\n%!" mode_name passed total
454 (100.0 *. float_of_int passed /. float_of_int total)
455
456(** Generate combined HTML report for both modes *)
457let generate_combined_html_report ~lenient_results ~strict_results output_path =
458 (* Helper to build file results from a set of results *)
459 let build_file_results results =
460 let by_category = group_by_category results in
461 List.map (fun (category, tests) ->
462 let passed_count = List.filter (fun r -> r.passed) tests |> List.length in
463 let failed_count = List.length tests - passed_count in
464 let test_results = List.mapi (fun i r ->
465 let outcome_str = match r.file.expected with
466 | Valid -> "isvalid"
467 | Invalid -> "novalid"
468 | HasWarning -> "haswarn"
469 | Unknown -> "unknown"
470 in
471 let description = Printf.sprintf "[%s] %s" outcome_str (Filename.basename r.file.relative_path) in
472 let expected = match r.expected_message with
473 | Some m -> m
474 | None -> match r.file.expected with
475 | Valid -> "(should produce no errors or warnings)"
476 | Invalid -> "(should produce at least one error)"
477 | HasWarning -> "(should produce at least one warning)"
478 | Unknown -> "(unknown test type)"
479 in
480 let actual_str =
481 let errors = if r.actual_errors = [] then ""
482 else "Errors:\n • " ^ String.concat "\n • " r.actual_errors in
483 let warnings = if r.actual_warnings = [] then ""
484 else "Warnings:\n • " ^ String.concat "\n • " r.actual_warnings in
485 let infos = if r.actual_infos = [] then ""
486 else "Info:\n • " ^ String.concat "\n • " r.actual_infos in
487 if errors = "" && warnings = "" && infos = "" then "(no messages produced)"
488 else String.trim (errors ^ (if errors <> "" && warnings <> "" then "\n\n" else "") ^
489 warnings ^ (if (errors <> "" || warnings <> "") && infos <> "" then "\n\n" else "") ^
490 infos)
491 in
492 let match_quality_str = match r.match_quality with
493 | Some q -> Expected_message.match_quality_to_string q
494 | None -> "N/A"
495 in
496 Report.{
497 test_num = i + 1;
498 description;
499 input = r.file.relative_path;
500 expected;
501 actual = actual_str;
502 success = r.passed;
503 details = [
504 ("Result", r.details);
505 ("Match Quality", match_quality_str);
506 ];
507 raw_test_data = read_html_source r.file.path;
508 }
509 ) tests in
510 Report.{
511 filename = category;
512 test_type = "HTML5 Validator";
513 passed_count;
514 failed_count;
515 tests = test_results;
516 }
517 ) by_category
518 in
519
520 let compute_stats results mode_name =
521 let total_passed = List.filter (fun r -> r.passed) results |> List.length in
522 let total_failed = List.length results - total_passed in
523 let count_quality q = List.filter (fun r ->
524 match r.match_quality with Some mq -> mq = q | None -> false
525 ) results |> List.length in
526 let match_quality_stats : Report.match_quality_stats = {
527 exact_matches = count_quality Expected_message.Exact_match;
528 code_matches = count_quality Expected_message.Code_match;
529 message_matches = count_quality Expected_message.Message_match;
530 substring_matches = count_quality Expected_message.Substring_match;
531 severity_mismatches = count_quality Expected_message.Severity_mismatch;
532 no_matches = count_quality Expected_message.No_match;
533 not_applicable = List.filter (fun r -> r.match_quality = None) results |> List.length;
534 } in
535 let isvalid_results = List.filter (fun r -> r.file.expected = Valid) results in
536 let novalid_results = List.filter (fun r -> r.file.expected = Invalid) results in
537 let haswarn_results = List.filter (fun r -> r.file.expected = HasWarning) results in
538 let count_passed rs = List.filter (fun r -> r.passed) rs |> List.length in
539 let test_type_stats : Report.test_type_stats = {
540 isvalid_passed = count_passed isvalid_results;
541 isvalid_total = List.length isvalid_results;
542 novalid_passed = count_passed novalid_results;
543 novalid_total = List.length novalid_results;
544 haswarn_passed = count_passed haswarn_results;
545 haswarn_total = List.length haswarn_results;
546 } in
547 (total_passed, total_failed, match_quality_stats, test_type_stats, mode_name)
548 in
549
550 let lenient_stats = compute_stats lenient_results "lenient" in
551 let strict_stats = compute_stats strict_results "strict" in
552
553 (* Use strict results for the main report, but include both in description *)
554 let (strict_passed, strict_failed, strict_mq, strict_tt, _) = strict_stats in
555 let (lenient_passed, _lenient_failed, _, _, _) = lenient_stats in
556
557 let now = Unix.gettimeofday () in
558 let tm = Unix.localtime now in
559 let timestamp = Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d"
560 (tm.Unix.tm_year + 1900) (tm.Unix.tm_mon + 1) tm.Unix.tm_mday
561 tm.Unix.tm_hour tm.Unix.tm_min tm.Unix.tm_sec in
562
563 let total = List.length strict_results in
564 let description = Printf.sprintf
565 "Tests from the Nu HTML Validator (W3C's official HTML checker). \
566 Tests validate HTML5 conformance including element nesting, required attributes, \
567 ARIA roles, obsolete elements, and more.\n\n\
568 LENIENT mode: %d/%d passed (%.1f%%) - allows substring matching\n\
569 STRICT mode: %d/%d passed (%.1f%%) - requires exact message matching"
570 lenient_passed total (100.0 *. float_of_int lenient_passed /. float_of_int total)
571 strict_passed total (100.0 *. float_of_int strict_passed /. float_of_int total)
572 in
573
574 let report : Report.report = {
575 title = "Nu HTML Validator Tests (Lenient + Strict)";
576 test_type = "validator";
577 description;
578 files = build_file_results strict_results; (* Show strict results in detail *)
579 total_passed = strict_passed;
580 total_failed = strict_failed;
581 match_quality = Some strict_mq;
582 test_type_breakdown = Some strict_tt;
583 strictness_mode = Some (Printf.sprintf "BOTH (Lenient: %d/%d, Strict: %d/%d)"
584 lenient_passed total strict_passed total);
585 run_timestamp = Some timestamp;
586 } in
587 Report.generate_report report output_path
588
589let () =
590 (* Parse command line arguments *)
591 let args = Array.to_list Sys.argv |> List.tl in
592 let is_strict = List.mem "--strict" args in
593 let is_both = List.mem "--both" args in
594 let non_flag_args = List.filter (fun s -> not (String.length s > 0 && s.[0] = '-')) args in
595 let tests_dir = match non_flag_args with x :: _ -> x | [] -> "validator/tests" in
596 let report_path = match non_flag_args with _ :: x :: _ -> x | _ -> "test_validator_report.html" in
597
598 Printf.printf "Loading messages.json...\n%!";
599 let messages_path = Filename.concat tests_dir "messages.json" in
600 let messages = Validator_messages.load messages_path in
601 Printf.printf "Loaded %d expected messages\n%!" (Validator_messages.count messages);
602
603 Printf.printf "Discovering test files...\n%!";
604 let tests = discover_tests tests_dir in
605 Printf.printf "Found %d test files\n%!" (List.length tests);
606
607 if is_both then begin
608 (* Run both modes *)
609 let lenient_results = run_all_tests ~mode_name:"LENIENT"
610 ~strictness_setting:Expected_message.lenient messages tests in
611 let strict_results = run_all_tests ~mode_name:"STRICT"
612 ~strictness_setting:Expected_message.exact_message messages tests in
613
614 print_failures "LENIENT" lenient_results;
615 print_failures "STRICT" strict_results;
616
617 Printf.printf "\n=== Summary ===\n";
618 let lenient_passed = List.filter (fun r -> r.passed) lenient_results |> List.length in
619 let strict_passed = List.filter (fun r -> r.passed) strict_results |> List.length in
620 let total = List.length tests in
621 Printf.printf "LENIENT: %d/%d (%.1f%%)\n" lenient_passed total
622 (100.0 *. float_of_int lenient_passed /. float_of_int total);
623 Printf.printf "STRICT: %d/%d (%.1f%%)\n" strict_passed total
624 (100.0 *. float_of_int strict_passed /. float_of_int total);
625
626 generate_combined_html_report ~lenient_results ~strict_results report_path;
627
628 (* Exit with error if strict mode has failures *)
629 let strict_failed = List.filter (fun r -> not r.passed) strict_results |> List.length in
630 exit (if strict_failed > 0 then 1 else 0)
631 end else begin
632 (* Single mode (original behavior) *)
633 if is_strict then begin
634 strictness := Expected_message.exact_message;
635 Printf.printf "Running in STRICT mode (exact message matching required)\n%!"
636 end;
637
638 Printf.printf "Running tests...\n%!";
639 let total = List.length tests in
640 let results = List.mapi (fun i test ->
641 Printf.printf "\r[%d/%d] %s%!" (i + 1) total test.relative_path;
642 run_test messages test
643 ) tests in
644 Printf.printf "\n%!";
645
646 (* Print failing isvalid tests *)
647 let failing_isvalid = List.filter (fun r ->
648 r.file.expected = Valid && not r.passed
649 ) results in
650 if failing_isvalid <> [] then begin
651 Printf.printf "\n=== Failing isvalid tests ===\n";
652 List.iter (fun r ->
653 Printf.printf "%s: %s\n" r.file.relative_path r.details
654 ) failing_isvalid
655 end;
656
657 (* Print failing haswarn tests *)
658 let failing_haswarn = List.filter (fun r ->
659 r.file.expected = HasWarning && not r.passed
660 ) results in
661 if failing_haswarn <> [] then begin
662 Printf.printf "\n=== Failing haswarn tests ===\n";
663 List.iter (fun r ->
664 Printf.printf "%s\n" r.file.relative_path
665 ) failing_haswarn
666 end;
667
668 (* Print failing novalid tests *)
669 let failing_novalid = List.filter (fun r ->
670 r.file.expected = Invalid && not r.passed
671 ) results in
672 if failing_novalid <> [] then begin
673 Printf.printf "\n=== Failing novalid tests (first 50) ===\n";
674 List.iteri (fun i r ->
675 if i < 50 then Printf.printf "%s\n" r.file.relative_path
676 ) failing_novalid
677 end;
678
679 print_summary results;
680 generate_html_report results report_path;
681
682 let failed_count = List.filter (fun r -> not r.passed) results |> List.length in
683 exit (if failed_count > 0 then 1 else 0)
684 end