mockforge_bench/conformance/
capture_html.rs1use super::self_test::CaseCapture;
19
20#[allow(dead_code)] const PAGE_SIZE: usize = 50;
29
30pub fn render_capture_html(entries: &[CaseCapture]) -> String {
37 let total = entries.len();
38 let mut out = String::with_capacity(total.max(1) * 1024);
39 out.push_str(HEAD);
40 push_summary(&mut out, entries);
41 out.push_str("<div id=\"cards\"></div>\n");
42 push_pagination_controls(&mut out);
43 push_data_script(&mut out, entries);
44 out.push_str(FOOT);
45 out
46}
47
48fn push_summary(out: &mut String, entries: &[CaseCapture]) {
49 let total = entries.len();
50 let pass = entries.iter().filter(|e| (200..400).contains(&e.response_status)).count();
51 let fail = entries.iter().filter(|e| !(200..400).contains(&e.response_status)).count();
52 out.push_str(&format!(
53 "<header>\n\
54 <h1>Self-Test Request/Response Capture</h1>\n\
55 <p class=\"meta\">{total} probe(s); {pass} returned 2xx-3xx, {fail} returned 4xx-5xx or errored. \
56 Generated by <code>mockforge bench --conformance-self-test --conformance-self-test-capture</code>.</p>\n\
57 <div class=\"toolbar\">\n\
58 <input type=\"search\" id=\"q\" placeholder=\"filter by label, method, URL, or status\" oninput=\"scheduleFilter()\" />\n\
59 <label><input type=\"checkbox\" id=\"showPass\" checked onchange=\"applyFilter()\"/> 2xx-3xx</label>\n\
60 <label><input type=\"checkbox\" id=\"showFail\" checked onchange=\"applyFilter()\"/> 4xx-5xx</label>\n\
61 <label><input type=\"checkbox\" id=\"showErr\" checked onchange=\"applyFilter()\"/> transport error</label>\n\
62 <label><input type=\"checkbox\" id=\"onlyMismatches\" onchange=\"applyFilter()\"/> only show mismatches</label>\n\
63 <span id=\"filterStatus\" class=\"small\"></span>\n\
64 </div>\n\
65 </header>\n"
66 ));
67}
68
69fn push_pagination_controls(out: &mut String) {
70 out.push_str(
71 "<div class=\"pager\">\n\
72 <button id=\"firstPage\" onclick=\"gotoPage(0)\">First</button>\n\
73 <button id=\"prevPage\" onclick=\"gotoPage(currentPage - 1)\">Prev</button>\n\
74 <span id=\"pageNum\" class=\"pageNum\"></span>\n\
75 <button id=\"nextPage\" onclick=\"gotoPage(currentPage + 1)\">Next</button>\n\
76 <button id=\"lastPage\" onclick=\"gotoPage(totalPages - 1)\">Last</button>\n\
77 <label class=\"small\">Jump to page: <input type=\"number\" id=\"jumpPage\" min=\"1\" style=\"width: 5em\" onchange=\"jumpToPage()\" /></label>\n\
78 </div>\n",
79 );
80}
81
82fn push_data_script(out: &mut String, entries: &[CaseCapture]) {
90 out.push_str("<script id=\"captureData\" type=\"application/json\">\n");
91 let json = serde_json::to_string(entries).unwrap_or_else(|_| "[]".to_string());
95 let safe = json.replace("</", r"<\/");
99 out.push_str(&safe);
100 out.push_str("\n</script>\n");
101}
102
103const HEAD: &str = r#"<!doctype html>
104<html lang="en">
105<head>
106<meta charset="utf-8">
107<title>MockForge Self-Test Capture</title>
108<style>
109 body { font-family: -apple-system, system-ui, sans-serif; max-width: 1200px;
110 margin: 1rem auto; padding: 0 1rem; color: #1f2933; line-height: 1.45; }
111 h1 { font-size: 1.6rem; margin: 0; }
112 h3 { margin: 1rem 0 0.3rem; font-size: 0.95rem; color: #374151; }
113 header { border-bottom: 1px solid #e5e7eb; padding-bottom: 1rem; margin-bottom: 1rem; }
114 .meta { color: #6b7280; font-size: 0.9rem; margin: 0.25rem 0 0.75rem; }
115 .toolbar { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; }
116 .toolbar input[type=search] { flex: 1; min-width: 240px; padding: 0.4rem 0.6rem;
117 border: 1px solid #d1d5db; border-radius: 4px; font-size: 0.9rem; }
118 .toolbar label { font-size: 0.85rem; color: #4b5563; cursor: pointer; }
119 .pager { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;
120 margin: 0.75rem 0; padding: 0.5rem 0; border-top: 1px solid #e5e7eb;
121 border-bottom: 1px solid #e5e7eb; }
122 .pager button { padding: 0.25rem 0.75rem; font-size: 0.85rem;
123 border: 1px solid #d1d5db; background: #fff; border-radius: 4px; cursor: pointer; }
124 .pager button:hover:not(:disabled) { background: #f3f4f6; }
125 .pager button:disabled { opacity: 0.4; cursor: not-allowed; }
126 .pager .pageNum { font-size: 0.85rem; color: #4b5563; min-width: 6em;
127 text-align: center; font-variant-numeric: tabular-nums; }
128 details.card { border: 1px solid #e5e7eb; border-radius: 6px; margin: 0.4rem 0;
129 background: #fff; }
130 details.card[open] { box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
131 summary { padding: 0.5rem 0.75rem; cursor: pointer; display: flex; gap: 0.5rem;
132 align-items: center; flex-wrap: wrap; }
133 summary::-webkit-details-marker { color: #9ca3af; }
134 .method { background: #f3f4f6; padding: 0.05rem 0.4rem; border-radius: 3px;
135 font-size: 0.8rem; }
136 .label { color: #374151; font-size: 0.85rem; }
137 .url { color: #6b7280; font-size: 0.8rem; word-break: break-all; }
138 .body { padding: 0 0.75rem 0.75rem; border-top: 1px solid #f3f4f6; }
139 .badge { display: inline-block; padding: 0.1rem 0.5rem; border-radius: 999px;
140 font-size: 0.75rem; font-weight: 600; }
141 .badge.pass { background: #d1fae5; color: #047857; }
142 .badge.fail { background: #fee2e2; color: #b91c1c; }
143 .badge.err { background: #fef3c7; color: #92400e; }
144 .badge.info { background: #dbeafe; color: #1d4ed8; }
145 table.kv { width: 100%; font-size: 0.85rem; border-collapse: collapse;
146 margin: 0.25rem 0 0.75rem; }
147 table.kv td { padding: 0.2rem 0.5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
148 table.kv td:first-child { color: #4b5563; width: 30%; max-width: 280px; }
149 pre { background: #f9fafb; border: 1px solid #f3f4f6; padding: 0.5rem;
150 border-radius: 4px; font-size: 0.8rem; overflow-x: auto; white-space: pre-wrap;
151 word-break: break-word; max-height: 320px; overflow-y: auto; }
152 pre.err { background: #fef2f2; border-color: #fecaca; color: #991b1b; }
153 .small { color: #6b7280; font-size: 0.75rem; }
154 code { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.88em; }
155</style>
156</head>
157<body>
158"#;
159
160const FOOT: &str = r#"
161<script>
162// Round 27 — pagination + cross-page filter over the JSON-embedded
163// full capture. The previous CSS-only show/hide approach silently
164// dropped 4xx/5xx probes past the 1000-card cap (Srikanth flagged
165// this on 0.3.169). Now the JS holds the full capture in memory,
166// filters across the whole array on every input, and only renders
167// the current page (PAGE_SIZE entries) of the filtered subset.
168
169const PAGE_SIZE = 50;
170let captures = [];
171try {
172 const raw = document.getElementById('captureData').textContent.trim();
173 captures = raw ? JSON.parse(raw) : [];
174} catch (e) {
175 document.getElementById('cards').innerHTML =
176 '<p class="small">Failed to load capture data: ' + e.message + '</p>';
177}
178let filtered = captures.slice();
179let currentPage = 0;
180let totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
181
182function escapeHtml(s) {
183 if (s == null) return '';
184 return String(s)
185 .replace(/&/g, '&')
186 .replace(/</g, '<')
187 .replace(/>/g, '>')
188 .replace(/"/g, '"')
189 .replace(/'/g, ''');
190}
191
192function statusClass(c) {
193 if (c.error) return 'err';
194 if (c.response_status >= 200 && c.response_status < 400) return 'pass';
195 if (c.response_status >= 400 && c.response_status < 600) return 'fail';
196 return 'info';
197}
198
199function renderKv(title, kv) {
200 const keys = kv ? Object.keys(kv).sort() : [];
201 if (keys.length === 0) {
202 return '<h3>' + escapeHtml(title) + '</h3><p class="small">(none)</p>';
203 }
204 let html = '<h3>' + escapeHtml(title) + '</h3><table class="kv"><tbody>';
205 for (const k of keys) {
206 html += '<tr><td><code>' + escapeHtml(k) + '</code></td><td><code>' +
207 escapeHtml(kv[k]) + '</code></td></tr>';
208 }
209 html += '</tbody></table>';
210 return html;
211}
212
213function renderBody(title, body, truncated) {
214 if (body == null) return '';
215 const suffix = truncated ? ' <span class="small">(truncated at 16 KiB)</span>' : '';
216 return '<h3>' + escapeHtml(title) + suffix + '</h3><pre>' + escapeHtml(body) + '</pre>';
217}
218
219// Round 28 (Srikanth) — true when the probe's actual status didn't
220// match its expected range. Used by the "only show mismatches" filter
221// AND by the summary badge so users can spot misses at a glance.
222function isMismatch(c) {
223 const expected = c.expected_status_range || '';
224 const s = c.response_status;
225 if (c.error) return true;
226 if (expected === '4xx') return !(s >= 400 && s < 500);
227 if (expected === '2xx-3xx') return !(s >= 200 && s < 400);
228 return false;
229}
230
231function renderCard(c) {
232 const cls = statusClass(c);
233 const statusText = c.error ? 'ERR' : String(c.response_status);
234 let html = '<details class="card">';
235 html += '<summary>';
236 html += '<span class="badge ' + cls + '">' + escapeHtml(statusText) + '</span> ';
237 // Round 28 — show the expected range alongside the actual status so
238 // a reader knows what the probe wanted to see without expanding the
239 // card.
240 if (c.expected_status_range) {
241 const matchCls = isMismatch(c) ? 'fail' : 'pass';
242 html += '<span class="badge ' + matchCls + '" title="expected status range">exp ' +
243 escapeHtml(c.expected_status_range) + '</span> ';
244 }
245 html += '<code class="method">' + escapeHtml(c.method) + '</code> ';
246 html += '<span class="label">' + escapeHtml(c.label) + '</span> ';
247 html += '<code class="url">' + escapeHtml(c.url) + '</code>';
248 html += '</summary><div class="body">';
249 html += renderKv('Request headers', c.request_headers);
250 html += renderBody('Request body', c.request_body, c.request_body_truncated);
251 html += renderKv('Response headers', c.response_headers);
252 html += renderBody('Response body', c.response_body, c.response_body_truncated);
253 if (c.error) {
254 html += '<h3>Transport error</h3><pre class="err">' + escapeHtml(c.error) + '</pre>';
255 }
256 if (c.response_schema_error) {
257 html += '<h3>Response schema mismatch</h3><pre class="err">' +
258 escapeHtml(c.response_schema_error) + '</pre>';
259 }
260 html += '</div></details>';
261 return html;
262}
263
264function renderPage() {
265 totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
266 if (currentPage >= totalPages) currentPage = totalPages - 1;
267 if (currentPage < 0) currentPage = 0;
268 const start = currentPage * PAGE_SIZE;
269 const end = Math.min(start + PAGE_SIZE, filtered.length);
270 const slice = filtered.slice(start, end);
271 document.getElementById('cards').innerHTML = slice.map(renderCard).join('');
272 document.getElementById('pageNum').textContent =
273 'Page ' + (currentPage + 1) + ' / ' + totalPages +
274 ' (' + (filtered.length === 0 ? 0 : (start + 1)) + '-' + end +
275 ' of ' + filtered.length + ' filtered)';
276 document.getElementById('firstPage').disabled = currentPage === 0;
277 document.getElementById('prevPage').disabled = currentPage === 0;
278 document.getElementById('nextPage').disabled = currentPage >= totalPages - 1;
279 document.getElementById('lastPage').disabled = currentPage >= totalPages - 1;
280 document.getElementById('filterStatus').textContent =
281 filtered.length === captures.length ? '' :
282 '(' + filtered.length + ' of ' + captures.length + ' shown)';
283 document.getElementById('jumpPage').max = totalPages;
284 document.getElementById('jumpPage').value = currentPage + 1;
285 window.scrollTo({ top: 0, behavior: 'auto' });
286}
287
288function gotoPage(p) {
289 if (p < 0 || p >= totalPages) return;
290 currentPage = p;
291 renderPage();
292}
293
294function jumpToPage() {
295 const v = parseInt(document.getElementById('jumpPage').value, 10);
296 if (!isNaN(v)) gotoPage(v - 1);
297}
298
299let _filterTimer = null;
300function scheduleFilter() {
301 if (_filterTimer) clearTimeout(_filterTimer);
302 _filterTimer = setTimeout(applyFilter, 200);
303}
304
305function applyFilter() {
306 const q = document.getElementById('q').value.trim().toLowerCase();
307 const showPass = document.getElementById('showPass').checked;
308 const showFail = document.getElementById('showFail').checked;
309 const showErr = document.getElementById('showErr').checked;
310 const onlyMismatches = document.getElementById('onlyMismatches').checked;
311 filtered = captures.filter(function(c) {
312 // Round 28 — the mismatch filter runs FIRST so it composes
313 // naturally with the status checkboxes (e.g. "mismatches that
314 // are 4xx-5xx").
315 if (onlyMismatches && !isMismatch(c)) return false;
316 const cls = statusClass(c);
317 const statusOk = (cls === 'pass' && showPass) ||
318 (cls === 'fail' && showFail) ||
319 (cls === 'err' && showErr) ||
320 (cls === 'info' && (showPass || showFail));
321 if (!statusOk) return false;
322 if (!q) return true;
323 const hay = ((c.label || '') + ' ' + (c.method || '') + ' ' +
324 (c.url || '') + ' ' + (c.response_status || '')).toLowerCase();
325 return hay.indexOf(q) !== -1;
326 });
327 currentPage = 0;
328 renderPage();
329}
330
331// Initial render once the page loads.
332renderPage();
333</script>
334</body>
335</html>
336"#;
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use std::collections::BTreeMap;
342
343 fn sample() -> Vec<CaseCapture> {
344 let mut req_h = BTreeMap::new();
345 req_h.insert("X-Forwarded-For".to_string(), "203.0.113.0".to_string());
346 let mut resp_h = BTreeMap::new();
347 resp_h.insert("content-type".to_string(), "application/json".to_string());
348 vec![
349 CaseCapture {
350 label: "positive".to_string(),
351 method: "GET".to_string(),
352 url: "http://target/users".to_string(),
353 request_headers: req_h.clone(),
354 request_body: None,
355 request_body_truncated: false,
356 response_status: 200,
357 response_headers: resp_h.clone(),
358 response_body: Some("{\"ok\":true}".to_string()),
359 response_body_truncated: false,
360 error: None,
361 response_schema_error: None,
362 expected_status_range: "2xx-3xx".to_string(),
363 path_template: String::new(),
364 spec_label: None,
365 },
366 CaseCapture {
367 label: "owasp:sqli".to_string(),
368 method: "GET".to_string(),
369 url: "http://target/users?id=' OR 1=1".to_string(),
370 request_headers: BTreeMap::new(),
371 request_body: None,
372 request_body_truncated: false,
373 response_status: 500,
374 response_headers: resp_h,
375 response_body: Some("[]".to_string()),
376 response_body_truncated: false,
377 error: None,
378 response_schema_error: None,
379 expected_status_range: "2xx-3xx".to_string(),
380 path_template: String::new(),
381 spec_label: None,
382 },
383 ]
384 }
385
386 #[test]
387 fn embeds_all_probes_as_json() {
388 let html = render_capture_html(&sample());
389 assert!(html.contains("id=\"captureData\""));
391 assert!(html.contains("\"positive\""));
393 assert!(html.contains("\"owasp:sqli\""));
394 }
395
396 #[test]
401 fn no_silent_cap_past_one_thousand() {
402 let mut entries = Vec::with_capacity(1500);
403 for i in 0..1500 {
404 entries.push(CaseCapture {
405 label: format!("probe-{i}"),
406 method: "GET".into(),
407 url: format!("/path/{i}"),
408 request_headers: BTreeMap::new(),
409 request_body: None,
410 request_body_truncated: false,
411 response_status: if i % 4 == 0 { 500 } else { 200 },
412 response_headers: BTreeMap::new(),
413 response_body: None,
414 response_body_truncated: false,
415 error: None,
416 response_schema_error: None,
417 expected_status_range: "2xx-3xx".to_string(),
418 path_template: String::new(),
419 spec_label: None,
420 });
421 }
422 let html = render_capture_html(&entries);
423 assert!(html.contains("\"probe-1234\""));
425 assert!(html.contains("\"probe-1499\""));
426 assert!(!html.contains("Showing first 1000 of"));
428 }
429
430 #[test]
431 fn pagination_controls_present() {
432 let html = render_capture_html(&sample());
433 for id in [
434 "firstPage",
435 "prevPage",
436 "nextPage",
437 "lastPage",
438 "jumpPage",
439 "pageNum",
440 ] {
441 assert!(html.contains(id), "missing pagination control: {id}");
442 }
443 assert!(html.contains("PAGE_SIZE = 50"));
444 }
445
446 #[test]
447 fn empty_capture_still_produces_valid_html() {
448 let html = render_capture_html(&[]);
449 assert!(html.starts_with("<!doctype html>"));
450 assert!(html.contains("0 probe(s)"));
451 assert!(html.contains("</html>"));
452 }
453
454 #[test]
455 fn embedded_json_breaks_inline_script_terminators() {
456 let entry = CaseCapture {
461 label: "label".into(),
462 method: "GET".into(),
463 url: "/x".into(),
464 request_headers: BTreeMap::new(),
465 request_body: None,
466 request_body_truncated: false,
467 response_status: 200,
468 response_headers: BTreeMap::new(),
469 response_body: Some("<script>alert(1)</script>".into()),
470 response_body_truncated: false,
471 error: None,
472 response_schema_error: None,
473 expected_status_range: "2xx-3xx".to_string(),
474 path_template: String::new(),
475 spec_label: None,
476 };
477 let html = render_capture_html(&[entry]);
478 assert!(
479 !html.contains("</script>alert(1)</script>"),
480 "raw `</script>` snuck into the embedded data"
481 );
482 assert!(html.contains("<\\/script>"), "missing escaped form");
483 }
484}