use super::self_test::CaseCapture;
const DEFAULT_RENDER_CAP: usize = 1000;
pub fn render_capture_html(entries: &[CaseCapture]) -> String {
let total = entries.len();
let rendered: &[CaseCapture] = if total > DEFAULT_RENDER_CAP {
&entries[..DEFAULT_RENDER_CAP]
} else {
entries
};
let mut out = String::with_capacity(rendered.len() * 1024);
out.push_str(HEAD);
push_summary(&mut out, entries, rendered.len());
out.push_str("<div id=\"cards\">\n");
for (idx, e) in rendered.iter().enumerate() {
push_card(&mut out, idx, e);
}
out.push_str("</div>\n");
out.push_str(FOOT);
out
}
fn push_summary(out: &mut String, entries: &[CaseCapture], rendered: usize) {
let total = entries.len();
let pass = entries.iter().filter(|e| (200..400).contains(&e.response_status)).count();
let fail = entries.iter().filter(|e| !(200..400).contains(&e.response_status)).count();
let cap_note = if total > rendered {
format!(
"<p class=\"small\">Showing first {rendered} of {total} probe(s). \
The full set is in <code>conformance-self-test-requests.jsonl</code>; \
pipe through <code>jq</code> to inspect anything past the cap.</p>\n"
)
} else {
String::new()
};
out.push_str(&format!(
"<header>\n\
<h1>Self-Test Request/Response Capture</h1>\n\
<p class=\"meta\">{total} probe(s) — {pass} returned 2xx-3xx, {fail} returned 4xx-5xx or errored. \
Generated by <code>mockforge bench --conformance-self-test --conformance-self-test-capture</code>.</p>\n\
{cap_note}\
<div class=\"toolbar\">\n\
<input type=\"search\" id=\"q\" placeholder=\"filter by label, method, or URL\" oninput=\"scheduleFilter()\" />\n\
<label><input type=\"checkbox\" id=\"showPass\" checked onchange=\"applyFilter()\"/> 2xx-3xx</label>\n\
<label><input type=\"checkbox\" id=\"showFail\" checked onchange=\"applyFilter()\"/> 4xx-5xx</label>\n\
<label><input type=\"checkbox\" id=\"showErr\" checked onchange=\"applyFilter()\"/> transport error</label>\n\
</div>\n\
</header>\n"
));
}
fn push_card(out: &mut String, idx: usize, e: &CaseCapture) {
let status_class = if e.error.is_some() {
"err"
} else if (200..400).contains(&e.response_status) {
"pass"
} else if (400..600).contains(&e.response_status) {
"fail"
} else {
"info"
};
let status_text = if e.error.is_some() {
"ERR".to_string()
} else {
e.response_status.to_string()
};
out.push_str(&format!(
"<details class=\"card\" data-status=\"{}\" data-text=\"{}\">\n\
<summary><span class=\"badge {}\">{}</span> \
<code class=\"method\">{}</code> \
<span class=\"label\">{}</span> \
<code class=\"url\">{}</code></summary>\n",
status_class,
html_escape(&format!("{} {} {} {}", e.label, e.method, e.url, e.response_status))
.to_ascii_lowercase(),
status_class,
status_text,
html_escape(&e.method),
html_escape(&e.label),
html_escape(&e.url),
));
out.push_str("<div class=\"body\">\n");
push_kv_section(out, "Request headers", &e.request_headers);
if let Some(body) = &e.request_body {
push_body_section(out, "Request body", body, e.request_body_truncated);
}
push_kv_section(out, "Response headers", &e.response_headers);
if let Some(body) = &e.response_body {
push_body_section(out, "Response body", body, e.response_body_truncated);
}
if let Some(err) = &e.error {
out.push_str(&format!(
"<h3>Transport error</h3>\n<pre class=\"err\">{}</pre>\n",
html_escape(err)
));
}
if let Some(schema_err) = &e.response_schema_error {
out.push_str(&format!(
"<h3>Response schema mismatch</h3>\n<pre class=\"err\">{}</pre>\n",
html_escape(schema_err)
));
}
out.push_str("</div>\n</details>\n");
let _ = idx;
}
fn push_kv_section(out: &mut String, title: &str, kv: &std::collections::BTreeMap<String, String>) {
if kv.is_empty() {
out.push_str(&format!("<h3>{title}</h3>\n<p class=\"small\">(none)</p>\n"));
return;
}
out.push_str(&format!("<h3>{title}</h3>\n<table class=\"kv\"><tbody>\n"));
for (k, v) in kv {
out.push_str(&format!(
"<tr><td><code>{}</code></td><td><code>{}</code></td></tr>\n",
html_escape(k),
html_escape(v)
));
}
out.push_str("</tbody></table>\n");
}
fn push_body_section(out: &mut String, title: &str, body: &str, truncated: bool) {
let suffix = if truncated {
" <span class=\"small\">(truncated at 16 KiB)</span>"
} else {
""
};
out.push_str(&format!("<h3>{title}{suffix}</h3>\n<pre>{}</pre>\n", html_escape(body)));
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
const HEAD: &str = r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>MockForge Self-Test Capture</title>
<style>
body { font-family: -apple-system, system-ui, sans-serif; max-width: 1200px;
margin: 1rem auto; padding: 0 1rem; color: #1f2933; line-height: 1.45; }
h1 { font-size: 1.6rem; margin: 0; }
h3 { margin: 1rem 0 0.3rem; font-size: 0.95rem; color: #374151; }
header { border-bottom: 1px solid #e5e7eb; padding-bottom: 1rem; margin-bottom: 1rem; }
.meta { color: #6b7280; font-size: 0.9rem; margin: 0.25rem 0 0.75rem; }
.toolbar { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; }
.toolbar input[type=search] { flex: 1; min-width: 240px; padding: 0.4rem 0.6rem;
border: 1px solid #d1d5db; border-radius: 4px; font-size: 0.9rem; }
.toolbar label { font-size: 0.85rem; color: #4b5563; cursor: pointer; }
details.card { border: 1px solid #e5e7eb; border-radius: 6px; margin: 0.4rem 0;
background: #fff;
/* Round 25 perf — only paint cards that are scrolled into view.
The browser uses `contain-intrinsic-size` to reserve scrollbar
space for off-screen cards without rendering their contents.
Cuts the load+filter cost on a 1000-card report from seconds
to ms in modern browsers. Safari < 18 ignores both properties
and falls back to today's behaviour (no regression). */
content-visibility: auto;
contain-intrinsic-size: 0 48px;
}
details.card[open] { box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
details.card.hidden { display: none; }
summary { padding: 0.5rem 0.75rem; cursor: pointer; display: flex; gap: 0.5rem;
align-items: center; flex-wrap: wrap; }
summary::-webkit-details-marker { color: #9ca3af; }
.method { background: #f3f4f6; padding: 0.05rem 0.4rem; border-radius: 3px;
font-size: 0.8rem; }
.label { color: #374151; font-size: 0.85rem; }
.url { color: #6b7280; font-size: 0.8rem; word-break: break-all; }
.body { padding: 0 0.75rem 0.75rem; border-top: 1px solid #f3f4f6; }
.badge { display: inline-block; padding: 0.1rem 0.5rem; border-radius: 999px;
font-size: 0.75rem; font-weight: 600; }
.badge.pass { background: #d1fae5; color: #047857; }
.badge.fail { background: #fee2e2; color: #b91c1c; }
.badge.err { background: #fef3c7; color: #92400e; }
.badge.info { background: #dbeafe; color: #1d4ed8; }
table.kv { width: 100%; font-size: 0.85rem; border-collapse: collapse;
margin: 0.25rem 0 0.75rem; }
table.kv td { padding: 0.2rem 0.5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
table.kv td:first-child { color: #4b5563; width: 30%; max-width: 280px; }
pre { background: #f9fafb; border: 1px solid #f3f4f6; padding: 0.5rem;
border-radius: 4px; font-size: 0.8rem; overflow-x: auto; white-space: pre-wrap;
word-break: break-word; max-height: 320px; overflow-y: auto; }
pre.err { background: #fef2f2; border-color: #fecaca; color: #991b1b; }
.small { color: #6b7280; font-size: 0.75rem; }
code { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.88em; }
</style>
</head>
<body>
"#;
const FOOT: &str = r#"
<script>
// Round 25 perf — debounce the search-box filter so typing in a large
// capture doesn't re-walk the DOM on every keystroke. Checkboxes fire
// applyFilter() directly because they're single toggles, not streams
// of input events.
var _filterTimer = null;
function scheduleFilter() {
if (_filterTimer) clearTimeout(_filterTimer);
_filterTimer = setTimeout(applyFilter, 200);
}
function applyFilter() {
var q = document.getElementById('q').value.trim().toLowerCase();
var showPass = document.getElementById('showPass').checked;
var showFail = document.getElementById('showFail').checked;
var showErr = document.getElementById('showErr').checked;
var cards = document.querySelectorAll('details.card');
for (var i = 0; i < cards.length; i++) {
var c = cards[i];
var status = c.dataset.status;
var statusOk = (status === 'pass' && showPass) ||
(status === 'fail' && showFail) ||
(status === 'err' && showErr) ||
(status === 'info' && (showPass || showFail));
var textOk = !q || c.dataset.text.indexOf(q) !== -1;
if (statusOk && textOk) {
c.classList.remove('hidden');
} else {
c.classList.add('hidden');
}
}
}
</script>
</body>
</html>
"#;
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn sample() -> Vec<CaseCapture> {
let mut req_h = BTreeMap::new();
req_h.insert("X-Forwarded-For".to_string(), "203.0.113.0".to_string());
let mut resp_h = BTreeMap::new();
resp_h.insert("content-type".to_string(), "application/json".to_string());
vec![
CaseCapture {
label: "positive".to_string(),
method: "GET".to_string(),
url: "http://target/users".to_string(),
request_headers: req_h.clone(),
request_body: None,
request_body_truncated: false,
response_status: 200,
response_headers: resp_h.clone(),
response_body: Some("{\"ok\":true}".to_string()),
response_body_truncated: false,
error: None,
response_schema_error: None,
},
CaseCapture {
label: "owasp:sqli".to_string(),
method: "GET".to_string(),
url: "http://target/users?id=' OR 1=1".to_string(),
request_headers: BTreeMap::new(),
request_body: None,
request_body_truncated: false,
response_status: 200,
response_headers: resp_h,
response_body: Some("[]".to_string()),
response_body_truncated: false,
error: None,
response_schema_error: None,
},
]
}
#[test]
fn renders_one_card_per_entry() {
let html = render_capture_html(&sample());
assert_eq!(html.matches("<details class=\"card\"").count(), 2);
assert!(html.contains("positive"));
assert!(html.contains("owasp:sqli"));
}
#[test]
fn escapes_payloads_in_url() {
let entry = CaseCapture {
label: "owasp:xss".into(),
method: "GET".into(),
url: "http://t/users?id=<script>alert(1)</script>".into(),
request_headers: BTreeMap::new(),
request_body: None,
request_body_truncated: false,
response_status: 200,
response_headers: BTreeMap::new(),
response_body: None,
response_body_truncated: false,
error: None,
response_schema_error: None,
};
let html = render_capture_html(&[entry]);
assert!(html.contains("<script>"), "script tag should be escaped");
assert!(!html.contains("<script>alert"), "raw script tag must not appear");
}
#[test]
fn empty_capture_still_produces_valid_html() {
let html = render_capture_html(&[]);
assert!(html.starts_with("<!doctype html>"));
assert!(html.contains("0 probe(s)"));
assert!(html.contains("</html>"));
}
#[test]
fn carries_request_and_response_headers() {
let html = render_capture_html(&sample());
assert!(html.contains("X-Forwarded-For"));
assert!(html.contains("203.0.113.0"));
assert!(html.contains("content-type"));
}
#[test]
fn truncated_flag_surfaces_in_section_header() {
let big = CaseCapture {
label: "x".into(),
method: "GET".into(),
url: "/x".into(),
request_headers: BTreeMap::new(),
request_body: Some("AAA".into()),
request_body_truncated: true,
response_status: 200,
response_headers: BTreeMap::new(),
response_body: Some("BBB".into()),
response_body_truncated: true,
error: None,
response_schema_error: None,
};
let html = render_capture_html(&[big]);
assert_eq!(html.matches("(truncated at 16 KiB)").count(), 2);
}
}