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)