mockforge-bench 0.3.169

Load and performance testing for MockForge
Documentation
//! Browser-viewable HTML rendering of self-test request/response capture.
//!
//! Issue #79 round 24 (Srikanth (d) follow-up). `conformance-self-test-
//! requests.jsonl` is great for `jq` and `grep`, but Srikanth asked for
//! something that can be loaded in a browser without external tooling.
//! This module renders the same `CaseCapture` records into a single
//! self-contained HTML file (no external CSS / JS / images) with:
//!
//! - A toolbar showing the run's probe count and a status filter
//!   (PASS / FAIL / all) plus a free-text search over label / URL.
//! - One collapsible card per probe, showing label, method, URL, and
//!   status code at a glance; expanding the card reveals request
//!   headers, request body, response headers, response body, and any
//!   transport error.
//!
//! The toolbar filtering runs inline via a tiny vanilla-JS handler;
//! no `fetch()` calls, no module imports, no CDN dependencies.
//! Loading the file works offline and from a `file://` URL.

use super::self_test::CaseCapture;

/// Round 25 perf cap — Srikanth's r24 follow-up: at 9700 probes the
/// browser hangs and filter typing is sluggish. The fix has three
/// parts: (1) `content-visibility: auto` on cards (CSS-only; the browser
/// only paints what's actually scrolled into view), (2) a 200ms debounce
/// on filter input so each keystroke doesn't trigger a full re-render,
/// (3) a hard cap on rendered cards with a `Showing N of M` banner. The
/// JSONL still has the full set so users can `jq` past the cap.
const DEFAULT_RENDER_CAP: usize = 1000;

/// Render the full HTML viewer for a slice of captured probes.
/// Bodies are kept verbatim (already truncated upstream to
/// `CAPTURE_BODY_CAP_BYTES`); the HTML escapes everything before
/// inserting into the DOM so payload content cannot break out of
/// the rendering.
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();
    // Round 25 — surface the cap explicitly when truncation happened so
    // the user knows the JSONL has more. Removing the banner is wrong
    // (the user can't tell something is missing); raising the cap is
    // also wrong because 9700 cards hung the browser on Srikanth's box.
    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()
    };
    // `oninput="applyFilter()"` is debounced inline — the JS handler
    // sets a 200ms setTimeout before re-applying the filter, so typing
    // doesn't trigger N re-renders per second.
    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()
    };
    // The card carries data-status / data-text attributes that the
    // toolbar's JS handler reads to decide visibility. Using data-
    // attributes (not inline style) keeps the markup auditable.
    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)
        ));
    }
    // Round 25 — surface a schema mismatch front-and-centre. The
    // probe's status may be 2xx (so it'd otherwise look fine), but
    // the body doesn't match what the spec promised, which is
    // exactly the round-21.3 / a2 / a3 case Srikanth asked about.
    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('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}

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());
        // Two probes → two <details> cards.
        assert_eq!(html.matches("<details class=\"card\"").count(), 2);
        assert!(html.contains("positive"));
        assert!(html.contains("owasp:sqli"));
    }

    #[test]
    fn escapes_payloads_in_url() {
        // Payload with a literal `<script>` to confirm escaping kicks in
        // before insertion. Without escape, the browser would parse the
        // tag.
        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("&lt;script&gt;"), "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);
    }
}