mockforge_bench/conformance/
capture_html.rs1use super::self_test::CaseCapture;
21
22const DEFAULT_RENDER_CAP: usize = 1000;
30
31pub fn render_capture_html(entries: &[CaseCapture]) -> String {
37 let total = entries.len();
38 let rendered: &[CaseCapture] = if total > DEFAULT_RENDER_CAP {
39 &entries[..DEFAULT_RENDER_CAP]
40 } else {
41 entries
42 };
43 let mut out = String::with_capacity(rendered.len() * 1024);
44 out.push_str(HEAD);
45 push_summary(&mut out, entries, rendered.len());
46 out.push_str("<div id=\"cards\">\n");
47 for (idx, e) in rendered.iter().enumerate() {
48 push_card(&mut out, idx, e);
49 }
50 out.push_str("</div>\n");
51 out.push_str(FOOT);
52 out
53}
54
55fn push_summary(out: &mut String, entries: &[CaseCapture], rendered: usize) {
56 let total = entries.len();
57 let pass = entries.iter().filter(|e| (200..400).contains(&e.response_status)).count();
58 let fail = entries.iter().filter(|e| !(200..400).contains(&e.response_status)).count();
59 let cap_note = if total > rendered {
64 format!(
65 "<p class=\"small\">Showing first {rendered} of {total} probe(s). \
66 The full set is in <code>conformance-self-test-requests.jsonl</code>; \
67 pipe through <code>jq</code> to inspect anything past the cap.</p>\n"
68 )
69 } else {
70 String::new()
71 };
72 out.push_str(&format!(
76 "<header>\n\
77 <h1>Self-Test Request/Response Capture</h1>\n\
78 <p class=\"meta\">{total} probe(s) — {pass} returned 2xx-3xx, {fail} returned 4xx-5xx or errored. \
79 Generated by <code>mockforge bench --conformance-self-test --conformance-self-test-capture</code>.</p>\n\
80 {cap_note}\
81 <div class=\"toolbar\">\n\
82 <input type=\"search\" id=\"q\" placeholder=\"filter by label, method, or URL\" oninput=\"scheduleFilter()\" />\n\
83 <label><input type=\"checkbox\" id=\"showPass\" checked onchange=\"applyFilter()\"/> 2xx-3xx</label>\n\
84 <label><input type=\"checkbox\" id=\"showFail\" checked onchange=\"applyFilter()\"/> 4xx-5xx</label>\n\
85 <label><input type=\"checkbox\" id=\"showErr\" checked onchange=\"applyFilter()\"/> transport error</label>\n\
86 </div>\n\
87 </header>\n"
88 ));
89}
90
91fn push_card(out: &mut String, idx: usize, e: &CaseCapture) {
92 let status_class = if e.error.is_some() {
93 "err"
94 } else if (200..400).contains(&e.response_status) {
95 "pass"
96 } else if (400..600).contains(&e.response_status) {
97 "fail"
98 } else {
99 "info"
100 };
101 let status_text = if e.error.is_some() {
102 "ERR".to_string()
103 } else {
104 e.response_status.to_string()
105 };
106 out.push_str(&format!(
110 "<details class=\"card\" data-status=\"{}\" data-text=\"{}\">\n\
111 <summary><span class=\"badge {}\">{}</span> \
112 <code class=\"method\">{}</code> \
113 <span class=\"label\">{}</span> \
114 <code class=\"url\">{}</code></summary>\n",
115 status_class,
116 html_escape(&format!("{} {} {} {}", e.label, e.method, e.url, e.response_status))
117 .to_ascii_lowercase(),
118 status_class,
119 status_text,
120 html_escape(&e.method),
121 html_escape(&e.label),
122 html_escape(&e.url),
123 ));
124 out.push_str("<div class=\"body\">\n");
125 push_kv_section(out, "Request headers", &e.request_headers);
126 if let Some(body) = &e.request_body {
127 push_body_section(out, "Request body", body, e.request_body_truncated);
128 }
129 push_kv_section(out, "Response headers", &e.response_headers);
130 if let Some(body) = &e.response_body {
131 push_body_section(out, "Response body", body, e.response_body_truncated);
132 }
133 if let Some(err) = &e.error {
134 out.push_str(&format!(
135 "<h3>Transport error</h3>\n<pre class=\"err\">{}</pre>\n",
136 html_escape(err)
137 ));
138 }
139 if let Some(schema_err) = &e.response_schema_error {
144 out.push_str(&format!(
145 "<h3>Response schema mismatch</h3>\n<pre class=\"err\">{}</pre>\n",
146 html_escape(schema_err)
147 ));
148 }
149 out.push_str("</div>\n</details>\n");
150 let _ = idx;
151}
152
153fn push_kv_section(out: &mut String, title: &str, kv: &std::collections::BTreeMap<String, String>) {
154 if kv.is_empty() {
155 out.push_str(&format!("<h3>{title}</h3>\n<p class=\"small\">(none)</p>\n"));
156 return;
157 }
158 out.push_str(&format!("<h3>{title}</h3>\n<table class=\"kv\"><tbody>\n"));
159 for (k, v) in kv {
160 out.push_str(&format!(
161 "<tr><td><code>{}</code></td><td><code>{}</code></td></tr>\n",
162 html_escape(k),
163 html_escape(v)
164 ));
165 }
166 out.push_str("</tbody></table>\n");
167}
168
169fn push_body_section(out: &mut String, title: &str, body: &str, truncated: bool) {
170 let suffix = if truncated {
171 " <span class=\"small\">(truncated at 16 KiB)</span>"
172 } else {
173 ""
174 };
175 out.push_str(&format!("<h3>{title}{suffix}</h3>\n<pre>{}</pre>\n", html_escape(body)));
176}
177
178fn html_escape(s: &str) -> String {
179 s.replace('&', "&")
180 .replace('<', "<")
181 .replace('>', ">")
182 .replace('"', """)
183 .replace('\'', "'")
184}
185
186const HEAD: &str = r#"<!doctype html>
187<html lang="en">
188<head>
189<meta charset="utf-8">
190<title>MockForge Self-Test Capture</title>
191<style>
192 body { font-family: -apple-system, system-ui, sans-serif; max-width: 1200px;
193 margin: 1rem auto; padding: 0 1rem; color: #1f2933; line-height: 1.45; }
194 h1 { font-size: 1.6rem; margin: 0; }
195 h3 { margin: 1rem 0 0.3rem; font-size: 0.95rem; color: #374151; }
196 header { border-bottom: 1px solid #e5e7eb; padding-bottom: 1rem; margin-bottom: 1rem; }
197 .meta { color: #6b7280; font-size: 0.9rem; margin: 0.25rem 0 0.75rem; }
198 .toolbar { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; }
199 .toolbar input[type=search] { flex: 1; min-width: 240px; padding: 0.4rem 0.6rem;
200 border: 1px solid #d1d5db; border-radius: 4px; font-size: 0.9rem; }
201 .toolbar label { font-size: 0.85rem; color: #4b5563; cursor: pointer; }
202 details.card { border: 1px solid #e5e7eb; border-radius: 6px; margin: 0.4rem 0;
203 background: #fff;
204 /* Round 25 perf — only paint cards that are scrolled into view.
205 The browser uses `contain-intrinsic-size` to reserve scrollbar
206 space for off-screen cards without rendering their contents.
207 Cuts the load+filter cost on a 1000-card report from seconds
208 to ms in modern browsers. Safari < 18 ignores both properties
209 and falls back to today's behaviour (no regression). */
210 content-visibility: auto;
211 contain-intrinsic-size: 0 48px;
212 }
213 details.card[open] { box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
214 details.card.hidden { display: none; }
215 summary { padding: 0.5rem 0.75rem; cursor: pointer; display: flex; gap: 0.5rem;
216 align-items: center; flex-wrap: wrap; }
217 summary::-webkit-details-marker { color: #9ca3af; }
218 .method { background: #f3f4f6; padding: 0.05rem 0.4rem; border-radius: 3px;
219 font-size: 0.8rem; }
220 .label { color: #374151; font-size: 0.85rem; }
221 .url { color: #6b7280; font-size: 0.8rem; word-break: break-all; }
222 .body { padding: 0 0.75rem 0.75rem; border-top: 1px solid #f3f4f6; }
223 .badge { display: inline-block; padding: 0.1rem 0.5rem; border-radius: 999px;
224 font-size: 0.75rem; font-weight: 600; }
225 .badge.pass { background: #d1fae5; color: #047857; }
226 .badge.fail { background: #fee2e2; color: #b91c1c; }
227 .badge.err { background: #fef3c7; color: #92400e; }
228 .badge.info { background: #dbeafe; color: #1d4ed8; }
229 table.kv { width: 100%; font-size: 0.85rem; border-collapse: collapse;
230 margin: 0.25rem 0 0.75rem; }
231 table.kv td { padding: 0.2rem 0.5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
232 table.kv td:first-child { color: #4b5563; width: 30%; max-width: 280px; }
233 pre { background: #f9fafb; border: 1px solid #f3f4f6; padding: 0.5rem;
234 border-radius: 4px; font-size: 0.8rem; overflow-x: auto; white-space: pre-wrap;
235 word-break: break-word; max-height: 320px; overflow-y: auto; }
236 pre.err { background: #fef2f2; border-color: #fecaca; color: #991b1b; }
237 .small { color: #6b7280; font-size: 0.75rem; }
238 code { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.88em; }
239</style>
240</head>
241<body>
242"#;
243
244const FOOT: &str = r#"
245<script>
246// Round 25 perf — debounce the search-box filter so typing in a large
247// capture doesn't re-walk the DOM on every keystroke. Checkboxes fire
248// applyFilter() directly because they're single toggles, not streams
249// of input events.
250var _filterTimer = null;
251function scheduleFilter() {
252 if (_filterTimer) clearTimeout(_filterTimer);
253 _filterTimer = setTimeout(applyFilter, 200);
254}
255function applyFilter() {
256 var q = document.getElementById('q').value.trim().toLowerCase();
257 var showPass = document.getElementById('showPass').checked;
258 var showFail = document.getElementById('showFail').checked;
259 var showErr = document.getElementById('showErr').checked;
260 var cards = document.querySelectorAll('details.card');
261 for (var i = 0; i < cards.length; i++) {
262 var c = cards[i];
263 var status = c.dataset.status;
264 var statusOk = (status === 'pass' && showPass) ||
265 (status === 'fail' && showFail) ||
266 (status === 'err' && showErr) ||
267 (status === 'info' && (showPass || showFail));
268 var textOk = !q || c.dataset.text.indexOf(q) !== -1;
269 if (statusOk && textOk) {
270 c.classList.remove('hidden');
271 } else {
272 c.classList.add('hidden');
273 }
274 }
275}
276</script>
277</body>
278</html>
279"#;
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use std::collections::BTreeMap;
285
286 fn sample() -> Vec<CaseCapture> {
287 let mut req_h = BTreeMap::new();
288 req_h.insert("X-Forwarded-For".to_string(), "203.0.113.0".to_string());
289 let mut resp_h = BTreeMap::new();
290 resp_h.insert("content-type".to_string(), "application/json".to_string());
291 vec![
292 CaseCapture {
293 label: "positive".to_string(),
294 method: "GET".to_string(),
295 url: "http://target/users".to_string(),
296 request_headers: req_h.clone(),
297 request_body: None,
298 request_body_truncated: false,
299 response_status: 200,
300 response_headers: resp_h.clone(),
301 response_body: Some("{\"ok\":true}".to_string()),
302 response_body_truncated: false,
303 error: None,
304 response_schema_error: None,
305 },
306 CaseCapture {
307 label: "owasp:sqli".to_string(),
308 method: "GET".to_string(),
309 url: "http://target/users?id=' OR 1=1".to_string(),
310 request_headers: BTreeMap::new(),
311 request_body: None,
312 request_body_truncated: false,
313 response_status: 200,
314 response_headers: resp_h,
315 response_body: Some("[]".to_string()),
316 response_body_truncated: false,
317 error: None,
318 response_schema_error: None,
319 },
320 ]
321 }
322
323 #[test]
324 fn renders_one_card_per_entry() {
325 let html = render_capture_html(&sample());
326 assert_eq!(html.matches("<details class=\"card\"").count(), 2);
328 assert!(html.contains("positive"));
329 assert!(html.contains("owasp:sqli"));
330 }
331
332 #[test]
333 fn escapes_payloads_in_url() {
334 let entry = CaseCapture {
338 label: "owasp:xss".into(),
339 method: "GET".into(),
340 url: "http://t/users?id=<script>alert(1)</script>".into(),
341 request_headers: BTreeMap::new(),
342 request_body: None,
343 request_body_truncated: false,
344 response_status: 200,
345 response_headers: BTreeMap::new(),
346 response_body: None,
347 response_body_truncated: false,
348 error: None,
349 response_schema_error: None,
350 };
351 let html = render_capture_html(&[entry]);
352 assert!(html.contains("<script>"), "script tag should be escaped");
353 assert!(!html.contains("<script>alert"), "raw script tag must not appear");
354 }
355
356 #[test]
357 fn empty_capture_still_produces_valid_html() {
358 let html = render_capture_html(&[]);
359 assert!(html.starts_with("<!doctype html>"));
360 assert!(html.contains("0 probe(s)"));
361 assert!(html.contains("</html>"));
362 }
363
364 #[test]
365 fn carries_request_and_response_headers() {
366 let html = render_capture_html(&sample());
367 assert!(html.contains("X-Forwarded-For"));
368 assert!(html.contains("203.0.113.0"));
369 assert!(html.contains("content-type"));
370 }
371
372 #[test]
373 fn truncated_flag_surfaces_in_section_header() {
374 let big = CaseCapture {
375 label: "x".into(),
376 method: "GET".into(),
377 url: "/x".into(),
378 request_headers: BTreeMap::new(),
379 request_body: Some("AAA".into()),
380 request_body_truncated: true,
381 response_status: 200,
382 response_headers: BTreeMap::new(),
383 response_body: Some("BBB".into()),
384 response_body_truncated: true,
385 error: None,
386 response_schema_error: None,
387 };
388 let html = render_capture_html(&[big]);
389 assert_eq!(html.matches("(truncated at 16 KiB)").count(), 2);
390 }
391}