let add = Buffer.add_string
let hex bytes =
let digits = "0123456789abcdef" in
let result = Bytes.create (String.length bytes * 2) in
String.iteri
(fun index character ->
let value = Char.code character in
Bytes.set result (index * 2) digits.[value lsr 4];
Bytes.set result ((index * 2) + 1) digits.[value land 0x0f])
bytes;
Bytes.unsafe_to_string result
let text bytes =
if String.is_valid_utf_8 bytes then bytes else "hex:" ^ hex bytes
let escape buffer value =
String.iter
(function
| '&' -> add buffer "&"
| '<' -> add buffer "<"
| '>' -> add buffer ">"
| '"' -> add buffer """
| '\'' -> add buffer "'"
| character -> Buffer.add_char buffer character)
(text value)
let process_status = function
| Report.Exited code -> Printf.sprintf "exited %d" code
| Report.Signaled signal -> Printf.sprintf "signal %d" signal
| Report.Timed_out -> "timed out"
| Report.Spawn_error message -> "spawn error: " ^ message
let server_process_status = function
| Server_profile.Stopped_by_harness -> "stopped by harness"
| Server_profile.Exited code -> Printf.sprintf "exited %d" code
| Server_profile.Signaled signal -> Printf.sprintf "signal %d" signal
| Server_profile.Timed_out -> "stop timed out"
| Server_profile.Spawn_error message -> "spawn error: " ^ message
| Server_profile.Readiness_error message -> "readiness error: " ^ message
let duration milliseconds =
if milliseconds < 1000. then Printf.sprintf "%.0f ms" milliseconds
else Printf.sprintf "%.2f s" (milliseconds /. 1000.)
let command = function [] -> "โ" | arguments -> String.concat " " arguments
let requirement requirement = Model.string_of_requirement requirement
let references buffer references =
List.iteri
(fun index reference ->
if index > 0 then add buffer " ";
add buffer "";
escape buffer (Provenance.label reference);
add buffer "")
references
let stylesheet =
{|
:root{color-scheme:light dark;--bg:#f6f7f8;--panel:#fff;--text:#202124;--muted:#687078;--line:#dfe3e6;--good:#177245;--bad:#b3261e;--accent:#295ea7;--code:#f0f2f4}
@media(prefers-color-scheme:dark){:root{--bg:#141618;--panel:#1d2023;--text:#e8eaed;--muted:#a9b0b7;--line:#363b40;--good:#70d6a3;--bad:#ff8983;--accent:#8ab4f8;--code:#272b2f}}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{max-width:1120px;margin:auto;padding:28px 20px 48px}header{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;margin-bottom:18px}h1{font-size:22px;line-height:1.2;margin:0 0 5px}h2{font-size:15px;margin:24px 0 8px}h3{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:0}.sub,.muted,small{color:var(--muted)}.state{border:1px solid currentColor;border-radius:999px;padding:4px 10px;font-weight:700;white-space:nowrap}.clean{color:var(--good)}.dirty{color:var(--bad)}.meta{display:flex;flex-wrap:wrap;gap:5px 16px;color:var(--muted);margin:0 0 16px}.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(105px,1fr));gap:8px}.stat{background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:9px 11px}.stat b{display:block;font-size:20px;line-height:1.1}.stat span{color:var(--muted);font-size:12px}table{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--line);font-size:13px}th,td{text-align:left;vertical-align:top;padding:8px 9px;border-bottom:1px solid var(--line)}th{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.04em}tr:last-child td{border-bottom:0}td:first-child{width:37%}code,pre{font:12px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace}code{background:var(--code);border-radius:3px;padding:1px 4px;overflow-wrap:anywhere}.case-title{display:block;margin-top:3px}.req{display:inline-block;text-transform:uppercase;font-size:10px;font-weight:700;color:var(--muted)}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}.refs{white-space:nowrap}.observed{display:block;color:var(--muted);margin-top:3px}details{background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:8px 11px;margin-top:8px}summary{cursor:pointer;font-weight:600}dl{display:grid;grid-template-columns:max-content 1fr;gap:4px 12px;margin:10px 0}dt{color:var(--muted)}dd{margin:0;overflow-wrap:anywhere}pre{background:var(--code);padding:9px;border-radius:5px;white-space:pre-wrap;overflow-wrap:anywhere;max-height:300px;overflow:auto}.detail-row>td{width:auto;padding:0 9px 9px}.case-detail{margin:0}.description{margin:8px 0 3px}.rationale{color:var(--muted);margin:0 0 9px}.trace-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.trace-grid pre{margin:4px 0 0;max-height:360px}.empty{padding:15px;background:var(--panel);border:1px solid var(--line);border-radius:7px;color:var(--muted)}footer{color:var(--muted);font-size:11px;margin-top:22px}@media(max-width:680px){main{padding:18px 10px 36px}header{display:block}.state{display:inline-block;margin-top:10px}th:nth-child(2),td:nth-child(2){display:none}td:first-child{width:46%}.trace-grid{grid-template-columns:1fr}}
|}
let page_start buffer ~title ~kind ~label ~clean =
add buffer "
";
add buffer
"";
add buffer "";
escape buffer title;
add buffer ""
let page_end buffer =
add buffer
"";
Buffer.contents buffer
let meta buffer values =
add buffer "";
List.iter
(fun (name, value) ->
add buffer "";
escape buffer name;
add buffer ": ";
escape buffer value;
add buffer "")
values;
add buffer "
"
let stats buffer values =
add buffer "";
List.iter
(fun (label, value) ->
add buffer "
";
escape buffer value;
add buffer "";
escape buffer label;
add buffer "
")
values;
add buffer "
"
let section_start buffer title =
add buffer "";
escape buffer title;
add buffer "
"
let table_start buffer =
add buffer
"| Case | Requirement | Result | References |
"
let table_end buffer = add buffer "
"
let case_row buffer ~id ~title ~requirement:requirement_ ~reason ~observed
~reference_list =
add buffer "";
escape buffer id;
add buffer "";
escape buffer title;
add buffer " | ";
escape buffer (requirement requirement_);
add buffer " | ";
escape buffer reason;
if observed <> "" then begin
add buffer "";
escape buffer observed;
add buffer ""
end;
add buffer " | ";
references buffer reference_list;
add buffer " |
"
let diagnostic_panel buffer title contents =
add buffer "";
escape buffer title;
add buffer "
";
escape buffer contents;
add buffer "
"
let failure_detail buffer (detail : Html_detail.t) =
add buffer
"Show stimulus, expectation, and \
observation";
escape buffer detail.description;
add buffer " ";
escape buffer detail.rationale;
add buffer " ";
diagnostic_panel buffer "Stimulus" detail.stimulus;
diagnostic_panel buffer "Expected" detail.expected;
diagnostic_panel buffer "Observed" detail.observed;
add buffer " |
"
let case_info_row buffer (case : Report.case_info) reason =
case_row buffer ~id:case.case_id ~title:case.title
~requirement:case.requirement ~reason ~observed:""
~reference_list:case.references
let conformance_case_row buffer (case : Conformance.case_info) reason =
case_row buffer ~id:case.id ~title:case.title ~requirement:case.rule.strength
~reason ~observed:"" ~reference_list:case.references
let raw_observation (observation : Model.observation) =
match observation.outcome with
| Model.Observed_responses responses ->
let statuses =
List.map
(fun (response : Model.observed_response) ->
string_of_int response.status)
responses
in
"response " ^ String.concat ", " statuses
| Model.Observed_error message -> "error: " ^ message
| Model.Observed_timeout None -> "timeout"
| Model.Observed_timeout (Some message) -> "timeout: " ^ message
| Model.Observed_crash message -> "crash: " ^ message
let conformance_observation (observation : Conformance.observation) =
match observation.outcome with
| Conformance.Observed_values values ->
Printf.sprintf "%d value(s)" (List.length values)
| Conformance.Observed_parsed _ -> "parsed"
| Conformance.Observed_responses responses ->
Printf.sprintf "%d structured response(s)" (List.length responses)
| Conformance.Observed_rejected None -> "rejected"
| Conformance.Observed_rejected (Some message) -> "rejected: " ^ message
| Conformance.Observed_unsupported None -> "unsupported"
| Conformance.Observed_unsupported (Some message) -> "unsupported: " ^ message
| Conformance.Observed_error message -> "error: " ^ message
| Conformance.Observed_timeout None -> "timeout"
| Conformance.Observed_timeout (Some message) -> "timeout: " ^ message
| Conformance.Observed_crash message -> "crash: " ^ message
let server_wire = function
| Server_profile.Responses responses ->
"response "
^ String.concat ", "
(List.map
(fun (response : Server_profile.response) ->
string_of_int response.status)
responses)
| Server_profile.Closed -> "connection closed"
| Server_profile.Reset message -> "connection reset: " ^ message
| Server_profile.Timeout -> "timeout"
| Server_profile.Protocol_error message -> "protocol error: " ^ message
| Server_profile.Connect_error message -> "connect error: " ^ message
let server_observation (observation : Server_profile.observation) =
let effects =
observation.effects
|> List.map (fun (token, count) ->
token ^ "="
^ match count with None -> "?" | Some count -> string_of_int count)
|> String.concat ", "
in
server_wire observation.wire ^ if effects = "" then "" else "; " ^ effects
let process_details buffer ~command:arguments ~endpoint ~stderr ?(stdout = "")
() =
add buffer
"Process details
- Command
";
escape buffer (command arguments);
add buffer "- Endpoint
- ";
escape buffer endpoint;
add buffer "
";
if stdout <> "" then begin
add buffer "stdout";
escape buffer stdout;
add buffer ""
end;
if stderr <> "" then begin
add buffer "stderr";
escape buffer stderr;
add buffer ""
end;
add buffer " "
let empty buffer message =
add buffer "";
escape buffer message;
add buffer "
"
let invalid_observations buffer observations =
if observations <> [] then begin
section_start buffer
(Printf.sprintf "Invalid observations (%d)" (List.length observations));
add buffer
"| Input | Line | Case | Error |
";
List.iter
(fun (line, case_id, input, error) ->
add buffer "";
escape buffer input;
add buffer " | ";
escape buffer
(match line with None -> "โ" | Some line -> string_of_int line);
add buffer " | ";
escape buffer (Option.value ~default:"โ" case_id);
add buffer " | ";
escape buffer error;
add buffer " |
")
observations;
table_end buffer
end
let report (report : Report.t) =
let buffer = Buffer.create 8192 in
page_start buffer
~title:(report.client ^ " HTTP/1 report")
~kind:"response-client" ~label:report.corpus_version
~clean:(Report.is_clean report);
meta buffer
([
("process", process_status report.process_status);
("duration", duration report.duration_ms);
("origin", report.base_url);
]
@
match report.selected_tags with
| [] -> []
| tags -> [ ("selected tags", String.concat ", " tags) ]);
let summary = report.summary in
stats buffer
[
("cases", string_of_int summary.case_count);
("observed", string_of_int summary.observed);
("passed", string_of_int summary.passed);
("failed", string_of_int summary.failed);
("missing", string_of_int summary.missing);
("invalid", string_of_int summary.invalid);
("crashes", string_of_int summary.crashes);
];
process_details buffer ~command:report.command ~endpoint:report.base_url
~stderr:report.stderr ();
section_start buffer (Printf.sprintf "Failures (%d)" summary.failed);
if report.failures = [] then empty buffer "No conformance failures."
else begin
table_start buffer;
List.iter
(fun (failure : Report.failure) ->
let case = failure.case in
case_row buffer ~id:case.case_id ~title:case.title
~requirement:case.requirement ~reason:failure.reason
~observed:(raw_observation failure.observation)
~reference_list:case.references;
failure_detail buffer
(Html_detail.response_client ~corpus_version:report.corpus_version
failure))
report.failures;
table_end buffer
end;
if report.missing_cases <> [] then begin
section_start buffer
(Printf.sprintf "Missing (%d)" (List.length report.missing_cases));
table_start buffer;
List.iter
(fun case -> case_info_row buffer case "not observed")
report.missing_cases;
table_end buffer
end;
invalid_observations buffer
(List.map
(fun (invalid : Report.invalid_observation) ->
(invalid.line, invalid.case_id, invalid.input, invalid.error))
report.invalid_observations);
page_end buffer
let conformance_report (report : Conformance.report) =
let buffer = Buffer.create 8192 in
let profile = Conformance.string_of_profile report.profile in
page_start buffer
~title:(report.client ^ " " ^ profile)
~kind:"independent profile" ~label:report.corpus_version
~clean:(Conformance.report_is_clean report);
meta buffer
([
("process", process_status report.process_status);
("duration", duration report.duration_ms);
("origin", report.base_url);
]
@
match report.selected_tags with
| [] -> []
| tags -> [ ("selected tags", String.concat ", " tags) ]);
let summary = report.summary in
stats buffer
[
("cases", string_of_int summary.case_count);
("observed", string_of_int summary.observed);
("supported", string_of_int summary.supported);
("passed", string_of_int summary.passed);
("failed", string_of_int summary.failed);
("unsupported", string_of_int summary.unsupported);
("missing", string_of_int summary.missing);
("invalid", string_of_int summary.invalid);
("crashes", string_of_int summary.crashes);
];
process_details buffer ~command:report.command ~endpoint:report.base_url
~stderr:report.stderr ();
section_start buffer (Printf.sprintf "Failures (%d)" summary.failed);
if report.failures = [] then empty buffer "No conformance failures."
else begin
table_start buffer;
List.iter
(fun (failure : Conformance.failure) ->
let case = failure.case in
case_row buffer ~id:case.id ~title:case.title
~requirement:case.rule.strength ~reason:failure.reason
~observed:(conformance_observation failure.observation)
~reference_list:case.references;
failure_detail buffer
(Html_detail.conformance ~corpus_version:report.corpus_version
report.profile failure))
report.failures;
table_end buffer
end;
if report.unsupported_cases <> [] then begin
section_start buffer
(Printf.sprintf "Unsupported (%d)" (List.length report.unsupported_cases));
table_start buffer;
List.iter
(fun case -> conformance_case_row buffer case "unsupported")
report.unsupported_cases;
table_end buffer
end;
if report.missing_cases <> [] then begin
section_start buffer
(Printf.sprintf "Missing (%d)" (List.length report.missing_cases));
table_start buffer;
List.iter
(fun case -> conformance_case_row buffer case "not observed")
report.missing_cases;
table_end buffer
end;
invalid_observations buffer
(List.map
(fun (invalid : Conformance.invalid_observation) ->
(invalid.line, invalid.case_id, invalid.input, invalid.error))
report.invalid_observations);
page_end buffer
let server_report (report : Server_profile.report) =
let buffer = Buffer.create 8192 in
page_start buffer
~title:(report.server ^ " HTTP/1 server report")
~kind:"request-server" ~label:report.corpus_version
~clean:(Server_profile.report_is_clean report);
meta buffer
([
("process", server_process_status report.process_status);
("duration", duration report.duration_ms);
("origin", report.origin);
]
@
match report.selected_tags with
| [] -> []
| tags -> [ ("selected tags", String.concat ", " tags) ]);
let summary = report.summary in
stats buffer
[
("cases", string_of_int summary.case_count);
("observed", string_of_int summary.observed);
("passed", string_of_int summary.passed);
("failed", string_of_int summary.failed);
("probe errors", string_of_int summary.probe_errors);
];
process_details buffer ~command:report.command ~endpoint:report.origin
~stdout:report.stdout ~stderr:report.stderr ();
section_start buffer (Printf.sprintf "Failures (%d)" summary.failed);
if report.failures = [] then empty buffer "No conformance failures."
else begin
table_start buffer;
List.iter
(fun (failure : Server_profile.failure) ->
let case = failure.case in
case_row buffer ~id:case.id ~title:case.title
~requirement:case.rule.strength ~reason:failure.reason
~observed:(server_observation failure.observation)
~reference_list:case.references;
failure_detail buffer (Html_detail.request_server failure))
report.failures;
table_end buffer
end;
page_end buffer
let of_json ?file json =
match Json.decode ?file Server_profile_json.report json with
| Ok report -> Ok (server_report report)
| Error server_error -> (
match Json.decode ?file Conformance_json.report json with
| Ok report -> Ok (conformance_report report)
| Error conformance_error -> (
match Json.decode ?file Json.report json with
| Ok raw -> Ok (report raw)
| Error raw_error ->
Error
(String.concat "\n"
[
"not a recognized httnope v1 execution report";
"request-server: " ^ server_error;
"profile: " ^ conformance_error;
"response-client: " ^ raw_error;
])))