Skip to main content

mockforge_bench/conformance/
capture_html.rs

1//! Browser-viewable HTML rendering of self-test request/response capture.
2//!
3//! Issue #79 round 24 (Srikanth (d) follow-up). `conformance-self-test-
4//! requests.jsonl` is great for `jq` and `grep`, but Srikanth asked for
5//! something that can be loaded in a browser without external tooling.
6//! This module renders the same `CaseCapture` records into a single
7//! self-contained HTML file (no external CSS / JS / images) with:
8//!
9//! - A toolbar showing the run's probe count and a status filter
10//!   (PASS / FAIL / all) plus a free-text search over label / URL.
11//! - One collapsible card per probe, showing label, method, URL, and
12//!   status code at a glance; expanding the card reveals request
13//!   headers, request body, response headers, response body, and any
14//!   transport error.
15//!
16//! The toolbar filtering runs inline via a tiny vanilla-JS handler;
17//! no `fetch()` calls, no module imports, no CDN dependencies.
18//! Loading the file works offline and from a `file://` URL.
19
20use super::self_test::CaseCapture;
21
22/// Round 25 perf cap — Srikanth's r24 follow-up: at 9700 probes the
23/// browser hangs and filter typing is sluggish. The fix has three
24/// parts: (1) `content-visibility: auto` on cards (CSS-only; the browser
25/// only paints what's actually scrolled into view), (2) a 200ms debounce
26/// on filter input so each keystroke doesn't trigger a full re-render,
27/// (3) a hard cap on rendered cards with a `Showing N of M` banner. The
28/// JSONL still has the full set so users can `jq` past the cap.
29const DEFAULT_RENDER_CAP: usize = 1000;
30
31/// Render the full HTML viewer for a slice of captured probes.
32/// Bodies are kept verbatim (already truncated upstream to
33/// `CAPTURE_BODY_CAP_BYTES`); the HTML escapes everything before
34/// inserting into the DOM so payload content cannot break out of
35/// the rendering.
36pub 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    // Round 25 — surface the cap explicitly when truncation happened so
60    // the user knows the JSONL has more. Removing the banner is wrong
61    // (the user can't tell something is missing); raising the cap is
62    // also wrong because 9700 cards hung the browser on Srikanth's box.
63    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    // `oninput="applyFilter()"` is debounced inline — the JS handler
73    // sets a 200ms setTimeout before re-applying the filter, so typing
74    // doesn't trigger N re-renders per second.
75    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    // The card carries data-status / data-text attributes that the
107    // toolbar's JS handler reads to decide visibility. Using data-
108    // attributes (not inline style) keeps the markup auditable.
109    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    // Round 25 — surface a schema mismatch front-and-centre. The
140    // probe's status may be 2xx (so it'd otherwise look fine), but
141    // the body doesn't match what the spec promised, which is
142    // exactly the round-21.3 / a2 / a3 case Srikanth asked about.
143    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('&', "&amp;")
180        .replace('<', "&lt;")
181        .replace('>', "&gt;")
182        .replace('"', "&quot;")
183        .replace('\'', "&#39;")
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        // Two probes → two <details> cards.
327        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        // Payload with a literal `<script>` to confirm escaping kicks in
335        // before insertion. Without escape, the browser would parse the
336        // tag.
337        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("&lt;script&gt;"), "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}