(** Test runner for Nu HTML Validator test suite
This validates HTML5 documents against the upstream Nu HTML Validator test suite.
Tests are classified by filename suffix:
- `-isvalid.html` : Should produce no errors or warnings
- `-novalid.html` : Should produce at least one error
- `-haswarn.html` : Should produce at least one warning
*)
module Report = Test_report
type expected_outcome =
| Valid (** -isvalid.html: expect no errors *)
| Invalid (** -novalid.html: expect error matching messages.json *)
| HasWarning (** -haswarn.html: expect warning matching messages.json *)
| Unknown (** Unknown suffix *)
type test_file = {
path : string; (** Full filesystem path *)
relative_path : string; (** Path relative to tests/, used as key in messages.json *)
category : string; (** html, html-aria, etc. *)
expected : expected_outcome;
}
type test_result = {
file : test_file;
passed : bool;
actual_errors : string list;
actual_warnings : string list;
actual_infos : string list;
expected_message : string option;
match_quality : Expected_message.match_quality option; (** How well did message match? *)
details : string;
}
(** Parse expected outcome from filename suffix *)
let parse_outcome filename =
(* Check for .html suffix *)
if String.length filename > 13 && String.sub filename (String.length filename - 13) 13 = "-isvalid.html" then
Valid
else if String.length filename > 13 && String.sub filename (String.length filename - 13) 13 = "-novalid.html" then
Invalid
else if String.length filename > 13 && String.sub filename (String.length filename - 13) 13 = "-haswarn.html" then
HasWarning
(* Check for .xhtml suffix *)
else if String.length filename > 14 && String.sub filename (String.length filename - 14) 14 = "-isvalid.xhtml" then
Valid
else if String.length filename > 14 && String.sub filename (String.length filename - 14) 14 = "-novalid.xhtml" then
Invalid
else if String.length filename > 14 && String.sub filename (String.length filename - 14) 14 = "-haswarn.xhtml" then
HasWarning
else
Unknown
(** Current strictness setting - can be set via --strict flag *)
let strictness = ref Expected_message.lenient
(** Find best matching message and return (found_acceptable, best_quality) *)
let find_best_match ~expected_str ~actual_msgs =
let expected = Expected_message.parse expected_str in
let qualities = List.map (fun msg ->
Expected_message.matches ~strictness:!strictness ~expected ~actual:msg
) actual_msgs in
let best_quality =
List.fold_left (fun best q ->
(* Lower variant = better match in our type definition *)
if q < best then q else best
) Expected_message.No_match qualities
in
let acceptable = Expected_message.is_acceptable ~strictness:!strictness best_quality in
(acceptable, best_quality)
(** Recursively find all HTML test files *)
let rec discover_tests_in_dir base_dir current_dir =
let full_path = Filename.concat base_dir current_dir in
if not (Sys.file_exists full_path) then []
else if Sys.is_directory full_path then begin
let entries = Sys.readdir full_path |> Array.to_list in
List.concat_map (fun entry ->
let sub_path = if current_dir = "" then entry else Filename.concat current_dir entry in
discover_tests_in_dir base_dir sub_path
) entries
end else if Filename.check_suffix current_dir ".html" || Filename.check_suffix current_dir ".xhtml" then begin
let outcome = parse_outcome (Filename.basename current_dir) in
if outcome = Unknown then []
else
let category =
match String.split_on_char '/' current_dir with
| cat :: _ -> cat
| [] -> "unknown"
in
[{ path = full_path; relative_path = current_dir; category; expected = outcome }]
end else
[]
let discover_tests tests_dir =
discover_tests_in_dir tests_dir ""
(** Run a single test *)
let run_test messages test =
try
let ic = open_in test.path in
let content = really_input_string ic (in_channel_length ic) in
close_in ic;
let reader = Bytesrw.Bytes.Reader.of_string content in
let result = Htmlrw_check.check ~collect_parse_errors:true ~system_id:test.relative_path reader in
(* Keep full message objects for proper matching *)
let error_msgs = Htmlrw_check.errors result in
let warning_msgs = Htmlrw_check.warnings result in
let info_msgs = Htmlrw_check.infos result in
(* Extract text for reporting *)
let errors = List.map (fun m -> m.Htmlrw_check.text) error_msgs in
let warnings = List.map (fun m -> m.Htmlrw_check.text) warning_msgs in
let infos = List.map (fun m -> m.Htmlrw_check.text) info_msgs in
let expected_msg = Validator_messages.get messages test.relative_path in
let (passed, match_quality, details) = match test.expected with
| Valid ->
(* isvalid tests fail on errors or warnings, but info messages are OK *)
if errors = [] && warnings = [] then
(true, None,
if infos = [] then "OK: No messages" else Printf.sprintf "OK: No errors/warnings (%d info)" (List.length infos))
else
(false, None,
Printf.sprintf "Expected valid but got %d errors, %d warnings"
(List.length errors) (List.length warnings))
| Invalid ->
if errors = [] then
(false, None, "Expected error but got none")
else begin
(* For novalid tests, require message match when expected message is provided *)
match expected_msg with
| None ->
(* No expected message - pass if any error detected *)
(true, None,
Printf.sprintf "Got %d error(s), no expected message to match" (List.length errors))
| Some exp ->
let (matched, quality) = find_best_match ~expected_str:exp ~actual_msgs:error_msgs in
if matched then
(true, Some quality,
Printf.sprintf "Got %d error(s), match: %s" (List.length errors)
(Expected_message.match_quality_to_string quality))
else
(* FAIL if message doesn't match *)
(false, Some quality,
Printf.sprintf "Message mismatch (quality: %s).\n Expected: %s\n Got: %s"
(Expected_message.match_quality_to_string quality)
exp (String.concat "\n " errors))
end
| HasWarning ->
(* For haswarn, require message match against warnings or infos *)
let all_msgs = warning_msgs @ info_msgs in
let all_messages = warnings @ infos in
if all_messages = [] && errors = [] then
(false, None, "Expected warning but got none")
else begin
match expected_msg with
| None ->
if all_messages <> [] then
(true, None, Printf.sprintf "Got %d warning/info message(s)" (List.length all_messages))
else
(true, None, Printf.sprintf "Got %d error(s) instead of warning" (List.length errors))
| Some exp ->
let (warn_matched, warn_quality) = find_best_match ~expected_str:exp ~actual_msgs:all_msgs in
if warn_matched then
(true, Some warn_quality,
Printf.sprintf "Got %d warning/info message(s), match: %s" (List.length all_messages)
(Expected_message.match_quality_to_string warn_quality))
else begin
let (err_matched, err_quality) = find_best_match ~expected_str:exp ~actual_msgs:error_msgs in
if err_matched then
(* Accept error if message matches (severity might differ) *)
(true, Some err_quality,
Printf.sprintf "Got error instead of warning, match: %s"
(Expected_message.match_quality_to_string err_quality))
else
let best = if warn_quality < err_quality then warn_quality else err_quality in
(false, Some best,
Printf.sprintf "Message mismatch (quality: %s).\n Expected: %s\n Got warnings: %s\n Got errors: %s"
(Expected_message.match_quality_to_string best)
exp (String.concat "\n " (if all_messages = [] then ["(none)"] else all_messages))
(String.concat "\n " (if errors = [] then ["(none)"] else errors)))
end
end
| Unknown ->
(false, None, "Unknown test type")
in
{ file = test; passed; actual_errors = errors; actual_warnings = warnings;
actual_infos = infos; expected_message = expected_msg; match_quality; details }
with e ->
{ file = test; passed = false; actual_errors = []; actual_warnings = [];
actual_infos = []; expected_message = None; match_quality = None;
details = Printf.sprintf "Exception: %s" (Printexc.to_string e) }
(** Group tests by category *)
let group_by_category tests =
let tbl = Hashtbl.create 16 in
List.iter (fun test ->
let cat = test.file.category in
let existing = try Hashtbl.find tbl cat with Not_found -> [] in
Hashtbl.replace tbl cat (test :: existing)
) tests;
Hashtbl.fold (fun k v acc -> (k, List.rev v) :: acc) tbl []
|> List.sort (fun (a, _) (b, _) -> String.compare a b)
(** Print summary to console *)
let print_summary results =
let by_category = group_by_category results in
Printf.printf "\n=== Results by Category ===\n";
List.iter (fun (cat, tests) ->
let passed = List.filter (fun r -> r.passed) tests |> List.length in
let total = List.length tests in
Printf.printf "%s: %d/%d passed (%.1f%%)\n" cat passed total
(100.0 *. float_of_int passed /. float_of_int (max 1 total))
) by_category;
(* Breakdown by test type *)
let isvalid_results = List.filter (fun r -> r.file.expected = Valid) results in
let novalid_results = List.filter (fun r -> r.file.expected = Invalid) results in
let haswarn_results = List.filter (fun r -> r.file.expected = HasWarning) results in
let count_passed rs = List.filter (fun r -> r.passed) rs |> List.length in
Printf.printf "\n=== Results by Test Type ===\n";
Printf.printf "isvalid (no errors expected): %d/%d passed (%.1f%%)\n"
(count_passed isvalid_results) (List.length isvalid_results)
(100.0 *. float_of_int (count_passed isvalid_results) /. float_of_int (max 1 (List.length isvalid_results)));
Printf.printf "novalid (errors expected): %d/%d passed (%.1f%%)\n"
(count_passed novalid_results) (List.length novalid_results)
(100.0 *. float_of_int (count_passed novalid_results) /. float_of_int (max 1 (List.length novalid_results)));
Printf.printf "haswarn (warnings expected): %d/%d passed (%.1f%%)\n"
(count_passed haswarn_results) (List.length haswarn_results)
(100.0 *. float_of_int (count_passed haswarn_results) /. float_of_int (max 1 (List.length haswarn_results)));
let total_passed = List.filter (fun r -> r.passed) results |> List.length in
let total = List.length results in
Printf.printf "\n=== Overall ===\n";
Printf.printf "Total: %d/%d passed (%.1f%%)\n" total_passed total
(100.0 *. float_of_int total_passed /. float_of_int (max 1 total));
(* Match quality breakdown *)
let count_quality q = List.filter (fun r ->
match r.match_quality with Some mq -> mq = q | None -> false
) results |> List.length in
let exact = count_quality Expected_message.Exact_match in
let code_match = count_quality Expected_message.Code_match in
let msg_match = count_quality Expected_message.Message_match in
let substring = count_quality Expected_message.Substring_match in
let sev_mismatch = count_quality Expected_message.Severity_mismatch in
let no_match = count_quality Expected_message.No_match in
let no_quality = List.filter (fun r -> r.match_quality = None) results |> List.length in
Printf.printf "\n=== Match Quality ===\n";
let mode_name =
if !strictness = Expected_message.strict then "STRICT (full)"
else if !strictness = Expected_message.exact_message then "STRICT (exact message)"
else "lenient"
in
Printf.printf "Mode: %s\n" mode_name;
Printf.printf "Exact matches: %d\n" exact;
Printf.printf "Code matches: %d\n" code_match;
Printf.printf "Message matches: %d\n" msg_match;
Printf.printf "Substring matches: %d\n" substring;
Printf.printf "Severity mismatches: %d\n" sev_mismatch;
Printf.printf "No matches: %d\n" no_match;
Printf.printf "N/A (isvalid or no expected): %d\n" no_quality
(** Generate HTML report *)
let generate_html_report results output_path =
let by_category = group_by_category results in
let file_results = List.map (fun (category, tests) ->
let passed_count = List.filter (fun r -> r.passed) tests |> List.length in
let failed_count = List.length tests - passed_count in
let test_results = List.mapi (fun i r ->
let outcome_str = match r.file.expected with
| Valid -> "valid"
| Invalid -> "invalid"
| HasWarning -> "has-warning"
| Unknown -> "unknown"
in
let description = Printf.sprintf "[%s] %s" outcome_str r.file.relative_path in
let expected = match r.expected_message with
| Some m -> m
| None -> "(no expected message)"
in
let actual_str =
let errors = if r.actual_errors = [] then ""
else "Errors:\n" ^ String.concat "\n" r.actual_errors in
let warnings = if r.actual_warnings = [] then ""
else "Warnings:\n" ^ String.concat "\n" r.actual_warnings in
let infos = if r.actual_infos = [] then ""
else "Info:\n" ^ String.concat "\n" r.actual_infos in
if errors = "" && warnings = "" && infos = "" then "(no messages)"
else String.trim (errors ^ "\n" ^ warnings ^ "\n" ^ infos)
in
Report.{
test_num = i + 1;
description;
input = r.file.relative_path;
expected;
actual = actual_str;
success = r.passed;
details = [("Status", r.details)];
raw_test_data = None;
}
) tests in
Report.{
filename = category;
test_type = "HTML5 Validator";
passed_count;
failed_count;
tests = test_results;
}
) by_category in
let total_passed = List.filter (fun r -> r.passed) results |> List.length in
let total_failed = List.length results - total_passed in
let report : Report.report = {
title = "Nu HTML Validator Tests";
test_type = "validator";
description = "Tests from the Nu HTML Validator (W3C's official HTML checker). \
Tests validate HTML5 conformance including element nesting, required attributes, \
ARIA roles, obsolete elements, and more. Each test file is classified by suffix: \
-isvalid.html (should produce no errors), -novalid.html (should produce errors), \
-haswarn.html (should produce warnings).";
files = file_results;
total_passed;
total_failed;
} in
Report.generate_report report output_path
let () =
(* Parse command line arguments *)
let args = Array.to_list Sys.argv |> List.tl in
let is_strict = List.mem "--strict" args in
let non_flag_args = List.filter (fun s -> not (String.length s > 0 && s.[0] = '-')) args in
let tests_dir = match non_flag_args with x :: _ -> x | [] -> "validator/tests" in
let report_path = match non_flag_args with _ :: x :: _ -> x | _ -> "test_validator_report.html" in
(* Apply strict mode if requested - use exact_message which requires exact text but not typed codes *)
if is_strict then begin
strictness := Expected_message.exact_message;
Printf.printf "Running in STRICT mode (exact message matching required)\n%!"
end;
Printf.printf "Loading messages.json...\n%!";
let messages_path = Filename.concat tests_dir "messages.json" in
let messages = Validator_messages.load messages_path in
Printf.printf "Loaded %d expected messages\n%!" (Validator_messages.count messages);
Printf.printf "Discovering test files...\n%!";
let tests = discover_tests tests_dir in
Printf.printf "Found %d test files\n%!" (List.length tests);
Printf.printf "Running tests...\n%!";
let results = List.map (run_test messages) tests in
(* Print failing isvalid tests *)
let failing_isvalid = List.filter (fun r ->
r.file.expected = Valid && not r.passed
) results in
if failing_isvalid <> [] then begin
Printf.printf "\n=== Failing isvalid tests ===\n";
List.iter (fun r ->
Printf.printf "%s: %s\n" r.file.relative_path r.details
) failing_isvalid
end;
(* Print failing haswarn tests *)
let failing_haswarn = List.filter (fun r ->
r.file.expected = HasWarning && not r.passed
) results in
if failing_haswarn <> [] then begin
Printf.printf "\n=== Failing haswarn tests ===\n";
List.iter (fun r ->
Printf.printf "%s\n" r.file.relative_path
) failing_haswarn
end;
(* Print failing novalid tests *)
let failing_novalid = List.filter (fun r ->
r.file.expected = Invalid && not r.passed
) results in
if failing_novalid <> [] then begin
Printf.printf "\n=== Failing novalid tests (first 50) ===\n";
List.iteri (fun i r ->
if i < 50 then Printf.printf "%s\n" r.file.relative_path
) failing_novalid
end;
print_summary results;
generate_html_report results report_path;
let failed_count = List.filter (fun r -> not r.passed) results |> List.length in
exit (if failed_count > 0 then 1 else 0)