Skip to main content

mockforge_bench/conformance/
generator.rs

1//! k6 script generator for OpenAPI 3.0.0 conformance testing
2
3use crate::error::{BenchError, Result};
4use std::path::{Path, PathBuf};
5
6use super::custom::CustomConformanceConfig;
7
8/// Configuration for conformance test generation
9#[derive(Default, Clone)]
10pub struct ConformanceConfig {
11    /// Target base URL
12    pub target_url: String,
13    /// API key for security scheme tests
14    pub api_key: Option<String>,
15    /// Basic auth credentials (user:pass) for security scheme tests
16    pub basic_auth: Option<String>,
17    /// Skip TLS verification
18    pub skip_tls_verify: bool,
19    /// Optional category filter — None means all categories
20    pub categories: Option<Vec<String>>,
21    /// Optional base path prefix for all generated URLs (e.g., "/api")
22    pub base_path: Option<String>,
23    /// Custom headers to inject into every conformance request (e.g., auth headers).
24    /// Each entry is (header_name, header_value). When a custom header matches
25    /// a spec-derived header name, the custom value replaces the placeholder.
26    pub custom_headers: Vec<(String, String)>,
27    /// Output directory for the conformance report (absolute path).
28    /// Used to write `conformance-report.json` to a deterministic location
29    /// so the CLI can find it after k6 execution.
30    pub output_dir: Option<PathBuf>,
31    /// When true, test ALL operations for method/response/body categories
32    /// instead of just one representative per feature check name.
33    pub all_operations: bool,
34    /// Optional path to a YAML file with custom conformance checks
35    pub custom_checks_file: Option<PathBuf>,
36    /// Delay in milliseconds between consecutive conformance requests.
37    /// Useful when testing against rate-limited APIs. Default: 0 (no delay).
38    pub request_delay_ms: u64,
39    /// Optional regex to filter custom checks by name or path.
40    /// Only checks whose name or path matches the regex are included.
41    pub custom_filter: Option<String>,
42    /// When true, export all request/response pairs to a JSON file
43    /// in the output directory (`conformance-requests.json`).
44    pub export_requests: bool,
45    /// When true, validate each request against the OpenAPI spec before
46    /// sending and report violations to `conformance-request-violations.json`.
47    pub validate_requests: bool,
48}
49
50impl ConformanceConfig {
51    /// Check if a category should be included based on the filter
52    pub fn should_include_category(&self, category: &str) -> bool {
53        match &self.categories {
54            None => true,
55            Some(cats) => cats.iter().any(|c| c.eq_ignore_ascii_case(category)),
56        }
57    }
58
59    /// Returns true if custom headers are configured
60    pub fn has_custom_headers(&self) -> bool {
61        !self.custom_headers.is_empty()
62    }
63
64    /// Returns true if custom headers contain a Cookie header.
65    /// When true, k6's automatic cookie jar should be disabled to prevent
66    /// duplicate cookies on subsequent requests.
67    pub fn has_cookie_header(&self) -> bool {
68        self.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("cookie"))
69    }
70
71    /// Format custom headers as a JS object literal string
72    pub fn custom_headers_js_object(&self) -> String {
73        let entries: Vec<String> = self
74            .custom_headers
75            .iter()
76            .map(|(k, v)| format!("'{}': '{}'", k, v.replace('\'', "\\'")))
77            .collect();
78        format!("{{ {} }}", entries.join(", "))
79    }
80
81    /// Generate a k6 group block for custom checks, if configured.
82    /// Returns `Ok(None)` if no custom checks file is configured.
83    /// Respects `custom_filter` to include only matching checks.
84    ///
85    /// Round 39 (#79) — returns BOTH the init-scope code (e.g.
86    /// `const __file_0 = open('/path', 'b')` for file uploads) and
87    /// the group body. The caller splices `init_code` near the top of
88    /// the script (after `BASE_URL`, before `export default
89    /// function`) and `group_body` inside the default function. k6
90    /// requires `open()` to live at init scope.
91    pub fn generate_custom_group(
92        &self,
93    ) -> Result<Option<crate::conformance::custom::K6CustomEmit>> {
94        let path = match &self.custom_checks_file {
95            Some(p) => p,
96            None => return Ok(None),
97        };
98        let mut config = CustomConformanceConfig::from_file(path)?;
99        if config.custom_checks.is_empty() {
100            return Ok(None);
101        }
102
103        // Apply regex filter if provided
104        if let Some(ref pattern) = self.custom_filter {
105            let re = regex::Regex::new(pattern).map_err(|e| {
106                BenchError::Other(format!("Invalid --conformance-custom-filter regex: {}", e))
107            })?;
108            let total = config.custom_checks.len();
109            config.custom_checks.retain(|c| re.is_match(&c.name) || re.is_match(&c.path));
110            tracing::info!(
111                "Custom check filter: {}/{} checks matched pattern",
112                config.custom_checks.len(),
113                total
114            );
115            if config.custom_checks.is_empty() {
116                return Ok(None);
117            }
118        }
119
120        Ok(Some(config.emit_k6_with_options(
121            "BASE_URL",
122            &self.custom_headers,
123            self.export_requests,
124        )))
125    }
126
127    /// Returns the effective base URL with base_path appended.
128    /// Handles trailing/leading slash normalization to avoid double slashes.
129    /// Always trims trailing slashes from the result so that `${BASE_URL}/path`
130    /// never produces `//path`.
131    pub fn effective_base_url(&self) -> String {
132        let base = match &self.base_path {
133            None => self.target_url.trim_end_matches('/').to_string(),
134            Some(bp) if bp.is_empty() => self.target_url.trim_end_matches('/').to_string(),
135            Some(bp) => {
136                let url = self.target_url.trim_end_matches('/');
137                let path = if bp.starts_with('/') {
138                    bp.as_str()
139                } else {
140                    return format!("{}/{}", url, bp).trim_end_matches('/').to_string();
141                };
142                format!("{}{}", url, path).trim_end_matches('/').to_string()
143            }
144        };
145        base
146    }
147}
148
149/// Generates k6 scripts for OpenAPI 3.0.0 conformance testing
150pub struct ConformanceGenerator {
151    config: ConformanceConfig,
152}
153
154impl ConformanceGenerator {
155    pub fn new(config: ConformanceConfig) -> Self {
156        Self { config }
157    }
158
159    /// Generate the conformance test k6 script
160    pub fn generate(&self) -> Result<String> {
161        let mut script = String::with_capacity(16384);
162
163        // Imports
164        script.push_str("import http from 'k6/http';\n");
165        script.push_str("import { check, group } from 'k6';\n");
166        if self.config.request_delay_ms > 0 {
167            script.push_str("import { sleep } from 'k6';\n");
168        }
169        script.push('\n');
170
171        // Tell k6 that all HTTP status codes are "expected" in conformance mode.
172        // Without this, k6 counts 4xx responses (e.g. intentional 404 tests) as
173        // http_req_failed errors, producing a misleading error rate percentage.
174        script.push_str(
175            "http.setResponseCallback(http.expectedStatuses({ min: 100, max: 599 }));\n\n",
176        );
177
178        // Options: 1 VU, 1 iteration (functional test, not load test)
179        script.push_str("export const options = {\n");
180        script.push_str("  vus: 1,\n");
181        script.push_str("  iterations: 1,\n");
182        if self.config.skip_tls_verify {
183            script.push_str("  insecureSkipTLSVerify: true,\n");
184        }
185        script.push_str("  thresholds: {\n");
186        script.push_str("    checks: ['rate>0'],\n");
187        script.push_str("  },\n");
188        script.push_str("};\n\n");
189
190        // Base URL (includes base_path if configured)
191        script.push_str(&format!("const BASE_URL = '{}';\n\n", self.config.effective_base_url()));
192
193        // Delay between requests (seconds) to avoid rate limiting
194        if self.config.request_delay_ms > 0 {
195            script.push_str(&format!(
196                "const REQUEST_DELAY = {:.3};\n\n",
197                self.config.request_delay_ms as f64 / 1000.0
198            ));
199        }
200
201        // Helper: JSON headers
202        script.push_str("const JSON_HEADERS = { 'Content-Type': 'application/json' };\n\n");
203
204        // Round 39 (#79) — emit init-scope code (e.g. `open()` calls
205        // for file uploads in custom checks) here, before any
206        // function declarations. k6 requires `open()` to live at
207        // script init scope; placing it inside `export default
208        // function` is a runtime ReferenceError.
209        let custom_emit = self.config.generate_custom_group()?;
210        if let Some(emit) = &custom_emit {
211            if !emit.init_code.is_empty() {
212                script.push_str("// Round 39 (#79) — preloaded upload bytes for custom checks\n");
213                script.push_str(&emit.init_code);
214                script.push('\n');
215            }
216        }
217
218        // Failure detail collector — logs req/res info for failed checks via console.log
219        script.push_str("function __captureFailure(checkName, res, expected) {\n");
220        script.push_str("  let bodyStr = '';\n");
221        script.push_str("  try { if (res.body) { const __n = res.body.length; bodyStr = res.body.substring(0, 65536); if (__n > 65536) bodyStr = bodyStr + ' <truncated at 65536 bytes; full body was ' + __n + ' bytes>'; } else { bodyStr = ''; } } catch(e) { bodyStr = '<unreadable>'; }\n");
222        script.push_str("  let reqHeaders = {};\n");
223        script.push_str(
224            "  if (res.request && res.request.headers) { reqHeaders = res.request.headers; }\n",
225        );
226        script.push_str("  let reqBody = '';\n");
227        script.push_str("  if (res.request && res.request.body) { try { const __m = res.request.body.length; reqBody = res.request.body.substring(0, 65536); if (__m > 65536) reqBody = reqBody + ' <truncated at 65536 bytes; full body was ' + __m + ' bytes>'; } catch(e) {} }\n");
228        script.push_str("  console.log('MOCKFORGE_FAILURE:' + JSON.stringify({\n");
229        script.push_str("    check: checkName,\n");
230        script.push_str("    request: {\n");
231        script.push_str("      method: res.request ? res.request.method : 'unknown',\n");
232        script.push_str("      url: res.request ? res.request.url : res.url || 'unknown',\n");
233        script.push_str("      headers: reqHeaders,\n");
234        script.push_str("      body: reqBody,\n");
235        script.push_str("    },\n");
236        script.push_str("    response: {\n");
237        script.push_str("      status: res.status,\n");
238        script.push_str("      headers: res.headers ? Object.fromEntries(Object.entries(res.headers).slice(0, 20)) : {},\n");
239        script.push_str("      body: bodyStr,\n");
240        script.push_str("    },\n");
241        script.push_str("    expected: expected,\n");
242        script.push_str("  }));\n");
243        script.push_str("}\n\n");
244
245        // Request/response capture for --export-requests (uses console.log since
246        // k6's handleSummary runs in a separate JS context with no access to
247        // module-level variables — the CLI parses the output log after k6 exits).
248        //
249        // Round 44 (#79) — Srikanth on 0.3.188: he reported `MOCKFORGE_UPLOAD_PARTS`
250        // appearing in k6-output.log but `MOCKFORGE_EXCHANGE` for the same check
251        // missing entirely from `conformance-requests.json` / `-failure-details.json`.
252        // The most likely failure mode is an exception inside `JSON.stringify`
253        // (multipart bodies can include bytes that produce surrogate-half strings
254        // k6's stringifier chokes on; very large request URLs can also bust k6's
255        // console-line length). Wrap the entire payload build + stringify in a
256        // try/catch and ALWAYS emit a fallback `MOCKFORGE_EXCHANGE` line — even
257        // when stringify fails — so the request never silently disappears from
258        // the export. The fallback carries `check`, method, URL, status, and an
259        // `_export_error` flag so a downstream consumer can tell a degraded
260        // entry from a clean one.
261        if self.config.export_requests {
262            script.push_str("function __captureExchange(checkName, res) {\n");
263            script.push_str("  try {\n");
264            script.push_str("    let bodyStr = '';\n");
265            script.push_str("    try { if (res.body) { const __n = res.body.length; bodyStr = res.body.substring(0, 65536); if (__n > 65536) bodyStr = bodyStr + ' <truncated at 65536 bytes; full body was ' + __n + ' bytes>'; } else { bodyStr = ''; } } catch(e) { bodyStr = '<unreadable>'; }\n");
266            script.push_str("    let reqHeaders = {};\n");
267            script.push_str(
268                "    if (res.request && res.request.headers) { reqHeaders = res.request.headers; }\n",
269            );
270            // Round 41 (#79) — Srikanth on 0.3.185: "When run without
271            // Spec the export request file has blank entry". k6's
272            // `res.request.body` is empty for multipart uploads (k6
273            // serialises the form internally and the JS-side body
274            // string is null). Fall back to a content-type-derived
275            // summary so the export at least surfaces "multipart/form-data; N parts"
276            // instead of an empty string. Real bodies still surface
277            // unchanged.
278            // Round 46 (#79) — Srikanth on 0.3.190: a 13MB multipart
279            // upload landed `[]` in conformance-requests.json even
280            // though `MOCKFORGE_EXCHANGE:` was present in the k6 log.
281            // Root cause: k6's logfmt encoder doesn't fully escape
282            // binary bytes that JSON.stringify emits as raw chars
283            // (control codepoints above 0x1F, surrogate halves, etc),
284            // so the resulting line wasn't valid JSON-in-logfmt and the
285            // parser dropped it on the floor. Fix: when Content-Type is
286            // `multipart/`, NEVER include raw body bytes in the export.
287            // Walk the part boundaries server-side (in JS) and emit a
288            // structured summary list of `{name, filename, contentType,
289            // bytes}` per part, then a one-line preamble showing the
290            // boundary + total byte count. This survives JSON-stringify
291            // + logfmt cleanly and gives the user a strictly more
292            // useful view than the raw envelope ever did.
293            script.push_str("    let reqBody = '';\n");
294            script.push_str("    {\n");
295            script.push_str(
296                "      const ct = (reqHeaders['Content-Type'] || reqHeaders['content-type'] || '').toString();\n",
297            );
298            script.push_str("      const isMultipart = ct.startsWith('multipart/');\n");
299            script.push_str(
300                "      if (isMultipart && res.request && res.request.body) {\n\
301                 \x20\x20\x20\x20\x20\x20\x20\x20try {\n\
302                 \x20\x20\x20\x20\x20\x20\x20\x20  const raw = res.request.body;\n\
303                 \x20\x20\x20\x20\x20\x20\x20\x20  let totalBytes = raw.length;\n\
304                 \x20\x20\x20\x20\x20\x20\x20\x20  let envelopeBytes = 0;\n\
305                 \x20\x20\x20\x20\x20\x20\x20\x20  const boundaryMatch = ct.match(/boundary=([^;]+)/);\n\
306                 \x20\x20\x20\x20\x20\x20\x20\x20  const boundary = boundaryMatch ? boundaryMatch[1].replace(/^\"|\"$/g, '') : '';\n\
307                 \x20\x20\x20\x20\x20\x20\x20\x20  const parts = [];\n\
308                 \x20\x20\x20\x20\x20\x20\x20\x20  if (boundary) {\n\
309                 \x20\x20\x20\x20\x20\x20\x20\x20    const sep = '--' + boundary;\n\
310                 \x20\x20\x20\x20\x20\x20\x20\x20    let cursor = raw.indexOf(sep);\n\
311                 \x20\x20\x20\x20\x20\x20\x20\x20    while (cursor !== -1 && parts.length < 100) {\n\
312                 \x20\x20\x20\x20\x20\x20\x20\x20      const next = raw.indexOf(sep, cursor + sep.length);\n\
313                 \x20\x20\x20\x20\x20\x20\x20\x20      if (next === -1) break;\n\
314                 \x20\x20\x20\x20\x20\x20\x20\x20      const slice = raw.substring(cursor + sep.length, next);\n\
315                 \x20\x20\x20\x20\x20\x20\x20\x20      const headerEnd = slice.indexOf('\\r\\n\\r\\n');\n\
316                 \x20\x20\x20\x20\x20\x20\x20\x20      const partHeaders = headerEnd === -1 ? slice : slice.substring(0, headerEnd);\n\
317                 \x20\x20\x20\x20\x20\x20\x20\x20      const partBody = headerEnd === -1 ? '' : slice.substring(headerEnd + 4);\n\
318                 \x20\x20\x20\x20\x20\x20\x20\x20      // Round 50 #79 — the envelope (sep + part headers + the\n\
319                 \x20\x20\x20\x20\x20\x20\x20\x20      // header/body CRLFs + the trailing CRLF) is pure ASCII, so\n\
320                 \x20\x20\x20\x20\x20\x20\x20\x20      // its .length equals its byte count even when binary part\n\
321                 \x20\x20\x20\x20\x20\x20\x20\x20      // bodies mangle raw.length. sep=--boundary; +4 = header\n\
322                 \x20\x20\x20\x20\x20\x20\x20\x20      // separator CRLFCRLF; +2 = trailing CRLF after the body.\n\
323                 \x20\x20\x20\x20\x20\x20\x20\x20      envelopeBytes += sep.length + partHeaders.length + 6;\n\
324                 \x20\x20\x20\x20\x20\x20\x20\x20      const nameMatch = partHeaders.match(/name=\"([^\"]+)\"/);\n\
325                 \x20\x20\x20\x20\x20\x20\x20\x20      const filenameMatch = partHeaders.match(/filename=\"([^\"]+)\"/);\n\
326                 \x20\x20\x20\x20\x20\x20\x20\x20      const partCtMatch = partHeaders.match(/Content-Type:\\s*([^\\r\\n]+)/i);\n\
327                 \x20\x20\x20\x20\x20\x20\x20\x20      parts.push({\n\
328                 \x20\x20\x20\x20\x20\x20\x20\x20        name: nameMatch ? nameMatch[1] : '',\n\
329                 \x20\x20\x20\x20\x20\x20\x20\x20        filename: filenameMatch ? filenameMatch[1] : '',\n\
330                 \x20\x20\x20\x20\x20\x20\x20\x20        contentType: partCtMatch ? partCtMatch[1].trim() : '',\n\
331                 \x20\x20\x20\x20\x20\x20\x20\x20        bytes: Math.max(0, partBody.length - 2),\n\
332                 \x20\x20\x20\x20\x20\x20\x20\x20      });\n\
333                 \x20\x20\x20\x20\x20\x20\x20\x20      cursor = next;\n\
334                 \x20\x20\x20\x20\x20\x20\x20\x20    }\n\
335                 \x20\x20\x20\x20\x20\x20\x20\x20    // Closing boundary: --boundary--CRLF.\n\
336                 \x20\x20\x20\x20\x20\x20\x20\x20    if (parts.length) { envelopeBytes += sep.length + 4; }\n\
337                 \x20\x20\x20\x20\x20\x20\x20\x20  }\n\
338                 \x20\x20\x20\x20\x20\x20\x20\x20  // Round 47 #79 — overlay accurate on-disk byte counts from\n\
339                 \x20\x20\x20\x20\x20\x20\x20\x20  // the per-check size map written at init scope; falls back\n\
340                 \x20\x20\x20\x20\x20\x20\x20\x20  // to the JS-string-derived bytes when no entry exists.\n\
341                 \x20\x20\x20\x20\x20\x20\x20\x20  // Round 48 #79 — Srikanth on 0.3.192: per-file counts were\n\
342                 \x20\x20\x20\x20\x20\x20\x20\x20  // exact but the total was still off because we kept using\n\
343                 \x20\x20\x20\x20\x20\x20\x20\x20  // raw.length (UTF-16 code units). Recompute totalBytes as\n\
344                 \x20\x20\x20\x20\x20\x20\x20\x20  // the SUM of per-part bytes once they've been overlaid; only\n\
345                 \x20\x20\x20\x20\x20\x20\x20\x20  // every part's true byte count came from disk does the sum\n\
346                 \x20\x20\x20\x20\x20\x20\x20\x20  // equal the actual upload size (the multipart envelope\n\
347                 \x20\x20\x20\x20\x20\x20\x20\x20  // overhead bytes stay reported as the raw.length delta).\n\
348                 \x20\x20\x20\x20\x20\x20\x20\x20  const __mfSizes = (globalThis.__mfUploadSizes || {})[checkName] || {};\n\
349                 \x20\x20\x20\x20\x20\x20\x20\x20  let __allKnown = parts.length > 0;\n\
350                 \x20\x20\x20\x20\x20\x20\x20\x20  parts.forEach(function (p) { if (typeof __mfSizes[p.name] === 'number') { p.bytes = __mfSizes[p.name]; } else { __allKnown = false; } });\n\
351                 \x20\x20\x20\x20\x20\x20\x20\x20  const partsTotal = parts.reduce(function (acc, p) { return acc + p.bytes; }, 0);\n\
352                 \x20\x20\x20\x20\x20\x20\x20\x20  if (__allKnown) totalBytes = partsTotal;\n\
353                 \x20\x20\x20\x20\x20\x20\x20\x20  // Round 49 #79 — Srikanth on 0.3.193 asked why our proxy\n\
354                 \x20\x20\x20\x20\x20\x20\x20\x20  // counted 57998271 bytes vs mockforge's 57996316 (disk\n\
355                 \x20\x20\x20\x20\x20\x20\x20\x20  // sum). The diff is the multipart envelope (boundaries,\n\
356                 \x20\x20\x20\x20\x20\x20\x20\x20  // per-part Content-Disposition / Content-Type lines,\n\
357                 \x20\x20\x20\x20\x20\x20\x20\x20  // CRLFs, the final closing boundary). Surface both:\n\
358                 \x20\x20\x20\x20\x20\x20\x20\x20  // `total` stays the disk-sum payload (what a receiver\n\
359                 \x20\x20\x20\x20\x20\x20\x20\x20  // writes back to disk); `wire` adds the envelope so\n\
360                 \x20\x20\x20\x20\x20\x20\x20\x20  // packet captures / proxy byte counters match.\n\
361                 \x20\x20\x20\x20\x20\x20\x20\x20  // Round 50 #79 — Srikanth on 0.3.194 saw wire (56344432)\n\
362                 \x20\x20\x20\x20\x20\x20\x20\x20  // come out SMALLER than total (57996316). raw.length is a\n\
363                 \x20\x20\x20\x20\x20\x20\x20\x20  // UTF-8-decoded JS string, so binary part bytes collapse\n\
364                 \x20\x20\x20\x20\x20\x20\x20\x20  // and it UNDERcounts. The envelope is only ~2KB for 9\n\
365                 \x20\x20\x20\x20\x20\x20\x20\x20  // parts, so wire can never be less than total. Compute it\n\
366                 \x20\x20\x20\x20\x20\x20\x20\x20  // from the disk-accurate payload plus the ASCII envelope.\n\
367                 \x20\x20\x20\x20\x20\x20\x20\x20  // Round 51 #79 — Srikanth on 0.3.196: the reconstructed\n\
368                 \x20\x20\x20\x20\x20\x20\x20\x20  // envelope was 264 bytes short of his proxy's 1955. k6 sets\n\
369                 \x20\x20\x20\x20\x20\x20\x20\x20  // Content-Length to the EXACT wire body size (what the proxy\n\
370                 \x20\x20\x20\x20\x20\x20\x20\x20  // counts); prefer it, fall back to the reconstruction.\n\
371                 \x20\x20\x20\x20\x20\x20\x20\x20  const __clHdr = parseInt((reqHeaders['Content-Length'] || reqHeaders['content-length'] || ''), 10);\n\
372                 \x20\x20\x20\x20\x20\x20\x20\x20  const wireBytes = (!isNaN(__clHdr) && __clHdr > 0) ? __clHdr : (__allKnown ? (partsTotal + envelopeBytes) : ((typeof raw === 'string' && raw.length) ? raw.length : totalBytes));\n\
373                 \x20\x20\x20\x20\x20\x20\x20\x20  // Round 52 #79 — Srikanth on 0.3.198 still saw a fixed 264-byte gap\n\
374                 \x20\x20\x20\x20\x20\x20\x20\x20  // (proxy 57998271 vs our 57998007). Those 264 bytes are the top-level\n\
375                 \x20\x20\x20\x20\x20\x20\x20\x20  // HTTP request preface (request-line + Host + the script-visible\n\
376                 \x20\x20\x20\x20\x20\x20\x20\x20  // headers + the transport-managed Content-Length + the blank line):\n\
377                 \x20\x20\x20\x20\x20\x20\x20\x20  // his proxy meters the whole request, we reported only the multipart\n\
378                 \x20\x20\x20\x20\x20\x20\x20\x20  // entity body (Content-Length). Reconstruct the header block so\n\
379                 \x20\x20\x20\x20\x20\x20\x20\x20  // `request` reconciles with a full-request byte counter.\n\
380                 \x20\x20\x20\x20\x20\x20\x20\x20  let __hdrBytes = 0;\n\
381                 \x20\x20\x20\x20\x20\x20\x20\x20  try {\n\
382                 \x20\x20\x20\x20\x20\x20\x20\x20    const __method = (res.request && res.request.method) ? res.request.method : 'POST';\n\
383                 \x20\x20\x20\x20\x20\x20\x20\x20    const __url = (res.request && res.request.url) ? res.request.url : '';\n\
384                 \x20\x20\x20\x20\x20\x20\x20\x20    let __rest = __url; const __sch = __rest.indexOf('://'); if (__sch !== -1) __rest = __rest.substring(__sch + 3);\n\
385                 \x20\x20\x20\x20\x20\x20\x20\x20    const __slash = __rest.indexOf('/'); const __host = __slash === -1 ? __rest : __rest.substring(0, __slash); const __pathq = __slash === -1 ? '/' : __rest.substring(__slash);\n\
386                 \x20\x20\x20\x20\x20\x20\x20\x20    __hdrBytes += (__method + ' ' + __pathq + ' HTTP/1.1').length + 2;\n\
387                 \x20\x20\x20\x20\x20\x20\x20\x20    if (__host) __hdrBytes += ('Host: ' + __host).length + 2;\n\
388                 \x20\x20\x20\x20\x20\x20\x20\x20    for (const __hn in reqHeaders) { let __hv = reqHeaders[__hn]; if (Array.isArray(__hv)) __hv = __hv.join(', '); __hdrBytes += (__hn + ': ' + String(__hv)).length + 2; }\n\
389                 \x20\x20\x20\x20\x20\x20\x20\x20    if (!('Content-Length' in reqHeaders) && !('content-length' in reqHeaders)) { __hdrBytes += ('Content-Length: ' + wireBytes).length + 2; }\n\
390                 \x20\x20\x20\x20\x20\x20\x20\x20    __hdrBytes += 2;\n\
391                 \x20\x20\x20\x20\x20\x20\x20\x20  } catch (e) { __hdrBytes = 0; }\n\
392                 \x20\x20\x20\x20\x20\x20\x20\x20  const requestBytes = wireBytes + __hdrBytes;\n\
393                 \x20\x20\x20\x20\x20\x20\x20\x20  const summary = parts.map(function (p) { return '\\'' + p.name + '\\':\\'' + p.filename + '\\' (' + p.contentType + ', ' + p.bytes + ' bytes)'; }).join(', ');\n\
394                 \x20\x20\x20\x20\x20\x20\x20\x20  reqBody = '<multipart/form-data; boundary=' + boundary + '; ' + parts.length + ' part(s); total ' + totalBytes + ' bytes (wire ' + wireBytes + ' bytes w/ envelope' + (__hdrBytes > 0 ? ('; request ' + requestBytes + ' bytes incl ' + __hdrBytes + '-byte header block') : '') + '): ' + summary + '>';\n\
395                 \x20\x20\x20\x20\x20\x20\x20\x20} catch (e) {\n\
396                 \x20\x20\x20\x20\x20\x20\x20\x20  reqBody = '<multipart upload; summary failed: ' + (e && e.message ? e.message : 'unknown') + '>';\n\
397                 \x20\x20\x20\x20\x20\x20\x20\x20}\n\
398                 \x20\x20\x20\x20\x20\x20} else if (isMultipart) {\n\
399                 \x20\x20\x20\x20\x20\x20\x20\x20reqBody = '<multipart upload; body bytes not surfaced by k6 res.request.body>';\n\
400                 \x20\x20\x20\x20\x20\x20} else if (res.request && res.request.body) {\n\
401                 \x20\x20\x20\x20\x20\x20\x20\x20try { const __m = res.request.body.length; reqBody = res.request.body.substring(0, 65536); if (__m > 65536) reqBody = reqBody + ' <truncated at 65536 bytes; full body was ' + __m + ' bytes>'; } catch (e) {}\n\
402                 \x20\x20\x20\x20\x20\x20}\n\
403                 \x20\x20\x20\x20}\n",
404            );
405            // Round 47 (#79) — emit a separate MOCKFORGE_NETWORK_EVENT
406            // line when the request never completed (k6 returns
407            // status=0 with an error_code/error string for connect /
408            // tls / timeout failures). The CLI executor harvests these
409            // into `conformance-network-events.json` for the k6 path,
410            // matching the native + self-test paths.
411            script.push_str(
412                "    if (res && res.status === 0) {\n\
413                 \x20\x20\x20\x20\x20\x20const ec = (res.error_code != null) ? res.error_code : 0;\n\
414                 \x20\x20\x20\x20\x20\x20const em = (res.error != null) ? String(res.error) : '';\n\
415                 \x20\x20\x20\x20\x20\x20// k6 error_code ranges: 1200s = TCP/DNS, 1300s = TLS, 1400s = timeout, 1500s = HTTP/2, others. Map coarsely.\n\
416                 \x20\x20\x20\x20\x20\x20let kind = 'other';\n\
417                 \x20\x20\x20\x20\x20\x20if (ec >= 1200 && ec < 1300) kind = 'connect';\n\
418                 \x20\x20\x20\x20\x20\x20else if (ec >= 1300 && ec < 1400) kind = 'tls';\n\
419                 \x20\x20\x20\x20\x20\x20else if (ec >= 1400 && ec < 1500) kind = 'timeout';\n\
420                 \x20\x20\x20\x20\x20\x20else if (em.toLowerCase().indexOf('eof') !== -1) kind = 'connect';\n                 \x20\x20\x20\x20\x20\x20else if (em.toLowerCase().indexOf('timeout') !== -1) kind = 'timeout';\n\
421                 \x20\x20\x20\x20\x20\x20else if (em.toLowerCase().indexOf('tls') !== -1) kind = 'tls';\n\
422                 \x20\x20\x20\x20\x20\x20else if (em.toLowerCase().indexOf('connect') !== -1 || em.toLowerCase().indexOf('refused') !== -1) kind = 'connect';\n\
423                 \x20\x20\x20\x20\x20\x20// Round 63 (#79) — target-side HTTP protocol violation (e.g. more bytes than the declared Content-Length). Checked last so it only re-labels what would be 'other'.\n\
424                 \x20\x20\x20\x20\x20\x20else if (em.toLowerCase().indexOf('declared content-length') !== -1 || em.toLowerCase().indexOf('malformed') !== -1 || em.toLowerCase().indexOf('protocol error') !== -1 || em.toLowerCase().indexOf('invalid header') !== -1) kind = 'protocol';\n\
425                 \x20\x20\x20\x20\x20\x20console.log('MOCKFORGE_NETWORK_EVENT:' + JSON.stringify({\n\
426                 \x20\x20\x20\x20\x20\x20  timestamp: new Date().toISOString(),\n\
427                 \x20\x20\x20\x20\x20\x20  check: checkName,\n\
428                 \x20\x20\x20\x20\x20\x20  method: res.request ? res.request.method : 'unknown',\n\
429                 \x20\x20\x20\x20\x20\x20  url: res.request ? res.request.url : res.url || 'unknown',\n\
430                 \x20\x20\x20\x20\x20\x20  kind: kind,\n\
431                 \x20\x20\x20\x20\x20\x20  error_code: ec,\n\
432                 \x20\x20\x20\x20\x20\x20  message: em,\n\
433                 \x20\x20\x20\x20\x20\x20}));\n\
434                 \x20\x20\x20\x20}\n",
435            );
436            script.push_str("    console.log('MOCKFORGE_EXCHANGE:' + JSON.stringify({\n");
437            script.push_str("      check: checkName,\n");
438            script.push_str("      request: {\n");
439            script.push_str("        method: res.request ? res.request.method : 'unknown',\n");
440            script.push_str("        url: res.request ? res.request.url : res.url || 'unknown',\n");
441            script.push_str("        headers: reqHeaders,\n");
442            script.push_str("        body: reqBody,\n");
443            script.push_str("      },\n");
444            script.push_str("      response: {\n");
445            script.push_str("        status: res.status,\n");
446            script.push_str("        headers: res.headers ? Object.fromEntries(Object.entries(res.headers).slice(0, 30)) : {},\n");
447            script.push_str("        body: bodyStr,\n");
448            script.push_str("      },\n");
449            script.push_str("    }));\n");
450            script.push_str("  } catch (e) {\n");
451            // Fallback path: still emit SOMETHING the parser can pick up
452            // so the request doesn't vanish from the export. Stays short
453            // on purpose — bigger payload was what tripped the primary
454            // path.
455            script.push_str("    try {\n");
456            script.push_str("      console.log('MOCKFORGE_EXCHANGE:' + JSON.stringify({\n");
457            script.push_str("        check: checkName,\n");
458            script.push_str("        request: {\n");
459            script.push_str(
460                "          method: (res && res.request) ? res.request.method : 'unknown',\n",
461            );
462            script.push_str("          url: (res && res.request) ? res.request.url : (res && res.url) || 'unknown',\n");
463            script.push_str("          headers: {},\n");
464            script.push_str("          body: '<exchange capture failed: ' + (e && e.message ? e.message : 'unknown error') + '>',\n");
465            script.push_str("        },\n");
466            script.push_str("        response: {\n");
467            script.push_str("          status: (res && res.status) || 0,\n");
468            script.push_str("          headers: {},\n");
469            script.push_str("          body: '',\n");
470            script.push_str("        },\n");
471            script.push_str("        _export_error: (e && e.message) ? e.message : String(e),\n");
472            script.push_str("      }));\n");
473            script.push_str("    } catch (e2) {\n");
474            // Last-resort: a hand-rolled JSON string so even if a
475            // second stringify fails, we still flag the failure.
476            script.push_str("      console.log('MOCKFORGE_EXCHANGE:{\"check\":\"' + checkName + '\",\"request\":{\"method\":\"unknown\",\"url\":\"unknown\",\"headers\":{},\"body\":\"\"},\"response\":{\"status\":0,\"headers\":{},\"body\":\"\"},\"_export_error\":\"double-fault\"}');\n");
477            script.push_str("    }\n");
478            script.push_str("  }\n");
479            script.push_str("}\n\n");
480        }
481
482        // Default function
483        script.push_str("export default function () {\n");
484
485        if self.config.has_cookie_header() {
486            script.push_str(
487                "  // Clear cookie jar to prevent server Set-Cookie from duplicating custom Cookie header\n",
488            );
489            script.push_str("  http.cookieJar().clear(BASE_URL);\n\n");
490        }
491
492        // Helper to insert a delay between groups when --conformance-delay is set
493        let delay_between = if self.config.request_delay_ms > 0 {
494            "  sleep(REQUEST_DELAY);\n".to_string()
495        } else {
496            String::new()
497        };
498
499        // Round 39 (#79) — Srikanth on 0.3.183: "In the exported
500        // request I see it is sending request to
501        // api/conformance/params/hello and some other URLs". When the
502        // user passed `--conformance-custom` without `--spec`, the
503        // generator still emitted the 47 built-in reference checks
504        // against `/conformance/...` paths, which 404 on a real
505        // target. Skip them when custom checks are the only input —
506        // matching the native executor's `custom_only` branch.
507        let custom_only = self.config.custom_checks_file.is_some()
508            && !self.config.target_url.is_empty()
509            // Reference checks ARE the right answer when the user
510            // explicitly listed categories with --conformance-category.
511            && self.config.categories.is_none();
512        if !custom_only {
513            if self.config.should_include_category("Parameters") {
514                self.generate_parameters_group(&mut script);
515                script.push_str(&delay_between);
516            }
517            if self.config.should_include_category("Request Bodies") {
518                self.generate_request_bodies_group(&mut script);
519                script.push_str(&delay_between);
520            }
521            if self.config.should_include_category("Schema Types") {
522                self.generate_schema_types_group(&mut script);
523                script.push_str(&delay_between);
524            }
525            if self.config.should_include_category("Composition") {
526                self.generate_composition_group(&mut script);
527                script.push_str(&delay_between);
528            }
529            if self.config.should_include_category("String Formats") {
530                self.generate_string_formats_group(&mut script);
531                script.push_str(&delay_between);
532            }
533            if self.config.should_include_category("Constraints") {
534                self.generate_constraints_group(&mut script);
535                script.push_str(&delay_between);
536            }
537            if self.config.should_include_category("Response Codes") {
538                self.generate_response_codes_group(&mut script);
539                script.push_str(&delay_between);
540            }
541            if self.config.should_include_category("HTTP Methods") {
542                self.generate_http_methods_group(&mut script);
543                script.push_str(&delay_between);
544            }
545            if self.config.should_include_category("Content Types") {
546                self.generate_content_negotiation_group(&mut script);
547                script.push_str(&delay_between);
548            }
549            if self.config.should_include_category("Security") {
550                self.generate_security_group(&mut script);
551            }
552        }
553
554        // Custom checks from YAML file — round 39: we already called
555        // `generate_custom_group()` above to emit init-scope code, so
556        // here we just splice the group body inside the default
557        // function.
558        if let Some(emit) = custom_emit {
559            script.push_str(&emit.group_body);
560        }
561
562        script.push_str("}\n\n");
563
564        // handleSummary for conformance report output
565        self.generate_handle_summary(&mut script);
566
567        Ok(script)
568    }
569
570    /// Write the generated script to a file
571    pub fn write_script(&self, path: &Path) -> Result<()> {
572        let script = self.generate()?;
573        if let Some(parent) = path.parent() {
574            std::fs::create_dir_all(parent)?;
575        }
576        std::fs::write(path, script)
577            .map_err(|e| BenchError::Other(format!("Failed to write conformance script: {}", e)))
578    }
579
580    /// Returns a JS expression for merging custom headers with provided headers.
581    /// If no custom headers, returns the input as-is.
582    /// If custom headers exist, wraps with Object.assign using inline header object.
583    fn merge_with_custom_headers(&self, headers_expr: &str) -> String {
584        if self.config.has_custom_headers() {
585            format!(
586                "Object.assign({{}}, {}, {})",
587                headers_expr,
588                self.config.custom_headers_js_object()
589            )
590        } else {
591            headers_expr.to_string()
592        }
593    }
594
595    /// Emit a GET request with optional custom headers merged in.
596    fn emit_get(&self, script: &mut String, url: &str, extra_headers: Option<&str>) {
597        let has_custom = self.config.has_custom_headers();
598        let custom_obj = self.config.custom_headers_js_object();
599        match (extra_headers, has_custom) {
600            (None, false) => {
601                script.push_str(&format!("      let res = http.get(`{}`);\n", url));
602            }
603            (None, true) => {
604                script.push_str(&format!(
605                    "      let res = http.get(`{}`, {{ headers: {} }});\n",
606                    url, custom_obj
607                ));
608            }
609            (Some(hdrs), false) => {
610                script.push_str(&format!(
611                    "      let res = http.get(`{}`, {{ headers: {} }});\n",
612                    url, hdrs
613                ));
614            }
615            (Some(hdrs), true) => {
616                script.push_str(&format!(
617                    "      let res = http.get(`{}`, {{ headers: Object.assign({{}}, {}, {}) }});\n",
618                    url, hdrs, custom_obj
619                ));
620            }
621        }
622        self.maybe_clear_cookie_jar(script);
623        self.maybe_capture_exchange(script);
624    }
625
626    /// Emit a POST/PUT/PATCH request with optional custom headers merged in.
627    fn emit_post_like(
628        &self,
629        script: &mut String,
630        method: &str,
631        url: &str,
632        body: &str,
633        headers_expr: &str,
634    ) {
635        let merged = self.merge_with_custom_headers(headers_expr);
636        script.push_str(&format!(
637            "      let res = http.{}(`{}`, {}, {{ headers: {} }});\n",
638            method, url, body, merged
639        ));
640        self.maybe_clear_cookie_jar(script);
641        self.maybe_capture_exchange(script);
642    }
643
644    /// Emit a DELETE/HEAD/OPTIONS request with optional custom headers.
645    fn emit_no_body(&self, script: &mut String, method: &str, url: &str) {
646        if self.config.has_custom_headers() {
647            script.push_str(&format!(
648                "      let res = http.{}(`{}`, {{ headers: {} }});\n",
649                method,
650                url,
651                self.config.custom_headers_js_object()
652            ));
653        } else {
654            script.push_str(&format!("      let res = http.{}(`{}`);\n", method, url));
655        }
656        self.maybe_clear_cookie_jar(script);
657        self.maybe_capture_exchange(script);
658    }
659
660    /// Emit `__captureExchange` call when `--export-requests` is enabled.
661    fn maybe_capture_exchange(&self, script: &mut String) {
662        if self.config.export_requests {
663            script.push_str(
664                "      if (typeof __captureExchange === 'function') __captureExchange('', res);\n",
665            );
666        }
667    }
668
669    /// Emit cookie jar clearing after a request when custom Cookie headers are used.
670    /// Prevents k6's internal cookie jar from re-sending server Set-Cookie values
671    /// alongside the custom Cookie header on subsequent requests.
672    fn maybe_clear_cookie_jar(&self, script: &mut String) {
673        if self.config.has_cookie_header() {
674            script.push_str("      http.cookieJar().clear(BASE_URL);\n");
675        }
676    }
677
678    fn generate_parameters_group(&self, script: &mut String) {
679        script.push_str("  group('Parameters', function () {\n");
680
681        // Path param: string
682        script.push_str("    {\n");
683        self.emit_get(script, "${BASE_URL}/conformance/params/hello", None);
684        script.push_str(
685            "      check(res, { 'param:path:string': (r) => r.status >= 200 && r.status < 500 });\n",
686        );
687        script.push_str("    }\n");
688
689        // Path param: integer
690        script.push_str("    {\n");
691        self.emit_get(script, "${BASE_URL}/conformance/params/42", None);
692        script.push_str(
693            "      check(res, { 'param:path:integer': (r) => r.status >= 200 && r.status < 500 });\n",
694        );
695        script.push_str("    }\n");
696
697        // Query param: string
698        script.push_str("    {\n");
699        self.emit_get(script, "${BASE_URL}/conformance/params/query?name=test", None);
700        script.push_str(
701            "      check(res, { 'param:query:string': (r) => r.status >= 200 && r.status < 500 });\n",
702        );
703        script.push_str("    }\n");
704
705        // Query param: integer
706        script.push_str("    {\n");
707        self.emit_get(script, "${BASE_URL}/conformance/params/query?count=10", None);
708        script.push_str(
709            "      check(res, { 'param:query:integer': (r) => r.status >= 200 && r.status < 500 });\n",
710        );
711        script.push_str("    }\n");
712
713        // Query param: array
714        script.push_str("    {\n");
715        self.emit_get(script, "${BASE_URL}/conformance/params/query?tags=a&tags=b", None);
716        script.push_str(
717            "      check(res, { 'param:query:array': (r) => r.status >= 200 && r.status < 500 });\n",
718        );
719        script.push_str("    }\n");
720
721        // Header param
722        script.push_str("    {\n");
723        self.emit_get(
724            script,
725            "${BASE_URL}/conformance/params/header",
726            Some("{ 'X-Custom-Param': 'test-value' }"),
727        );
728        script.push_str(
729            "      check(res, { 'param:header': (r) => r.status >= 200 && r.status < 500 });\n",
730        );
731        script.push_str("    }\n");
732
733        // Cookie param
734        script.push_str("    {\n");
735        script.push_str("      let jar = http.cookieJar();\n");
736        script.push_str("      jar.set(BASE_URL, 'session', 'abc123');\n");
737        self.emit_get(script, "${BASE_URL}/conformance/params/cookie", None);
738        script.push_str(
739            "      check(res, { 'param:cookie': (r) => r.status >= 200 && r.status < 500 });\n",
740        );
741        script.push_str("    }\n");
742
743        script.push_str("  });\n\n");
744    }
745
746    fn generate_request_bodies_group(&self, script: &mut String) {
747        script.push_str("  group('Request Bodies', function () {\n");
748
749        // JSON body
750        script.push_str("    {\n");
751        self.emit_post_like(
752            script,
753            "post",
754            "${BASE_URL}/conformance/body/json",
755            "JSON.stringify({ name: 'test', value: 42 })",
756            "JSON_HEADERS",
757        );
758        script.push_str(
759            "      check(res, { 'body:json': (r) => r.status >= 200 && r.status < 500 });\n",
760        );
761        script.push_str("    }\n");
762
763        // Form-urlencoded body
764        script.push_str("    {\n");
765        if self.config.has_custom_headers() {
766            script.push_str(&format!(
767                "      let res = http.post(`${{BASE_URL}}/conformance/body/form`, {{ field1: 'value1', field2: 'value2' }}, {{ headers: {} }});\n",
768                self.config.custom_headers_js_object()
769            ));
770        } else {
771            script.push_str(
772                "      let res = http.post(`${BASE_URL}/conformance/body/form`, { field1: 'value1', field2: 'value2' });\n",
773            );
774        }
775        self.maybe_clear_cookie_jar(script);
776        script.push_str(
777            "      check(res, { 'body:form-urlencoded': (r) => r.status >= 200 && r.status < 500 });\n",
778        );
779        script.push_str("    }\n");
780
781        // Multipart body
782        script.push_str("    {\n");
783        script.push_str(
784            "      let data = { field: http.file('test content', 'test.txt', 'text/plain') };\n",
785        );
786        if self.config.has_custom_headers() {
787            script.push_str(&format!(
788                "      let res = http.post(`${{BASE_URL}}/conformance/body/multipart`, data, {{ headers: {} }});\n",
789                self.config.custom_headers_js_object()
790            ));
791        } else {
792            script.push_str(
793                "      let res = http.post(`${BASE_URL}/conformance/body/multipart`, data);\n",
794            );
795        }
796        self.maybe_clear_cookie_jar(script);
797        script.push_str(
798            "      check(res, { 'body:multipart': (r) => r.status >= 200 && r.status < 500 });\n",
799        );
800        script.push_str("    }\n");
801
802        script.push_str("  });\n\n");
803    }
804
805    fn generate_schema_types_group(&self, script: &mut String) {
806        script.push_str("  group('Schema Types', function () {\n");
807
808        let types = [
809            ("string", r#"{ "value": "hello" }"#, "schema:string"),
810            ("integer", r#"{ "value": 42 }"#, "schema:integer"),
811            ("number", r#"{ "value": 3.14 }"#, "schema:number"),
812            ("boolean", r#"{ "value": true }"#, "schema:boolean"),
813            ("array", r#"{ "value": [1, 2, 3] }"#, "schema:array"),
814            ("object", r#"{ "value": { "nested": "data" } }"#, "schema:object"),
815        ];
816
817        for (type_name, body, check_name) in types {
818            script.push_str("    {\n");
819            let url = format!("${{BASE_URL}}/conformance/schema/{}", type_name);
820            let body_str = format!("'{}'", body);
821            self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
822            script.push_str(&format!(
823                "      check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
824                check_name
825            ));
826            script.push_str("    }\n");
827        }
828
829        script.push_str("  });\n\n");
830    }
831
832    fn generate_composition_group(&self, script: &mut String) {
833        script.push_str("  group('Composition', function () {\n");
834
835        let compositions = [
836            ("oneOf", r#"{ "type": "string", "value": "test" }"#, "composition:oneOf"),
837            ("anyOf", r#"{ "value": "test" }"#, "composition:anyOf"),
838            ("allOf", r#"{ "name": "test", "id": 1 }"#, "composition:allOf"),
839        ];
840
841        for (kind, body, check_name) in compositions {
842            script.push_str("    {\n");
843            let url = format!("${{BASE_URL}}/conformance/composition/{}", kind);
844            let body_str = format!("'{}'", body);
845            self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
846            script.push_str(&format!(
847                "      check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
848                check_name
849            ));
850            script.push_str("    }\n");
851        }
852
853        script.push_str("  });\n\n");
854    }
855
856    fn generate_string_formats_group(&self, script: &mut String) {
857        script.push_str("  group('String Formats', function () {\n");
858
859        let formats = [
860            ("date", r#"{ "value": "2024-01-15" }"#, "format:date"),
861            ("date-time", r#"{ "value": "2024-01-15T10:30:00Z" }"#, "format:date-time"),
862            ("email", r#"{ "value": "test@example.com" }"#, "format:email"),
863            ("uuid", r#"{ "value": "550e8400-e29b-41d4-a716-446655440000" }"#, "format:uuid"),
864            ("uri", r#"{ "value": "https://example.com/path" }"#, "format:uri"),
865            ("ipv4", r#"{ "value": "192.168.1.1" }"#, "format:ipv4"),
866            ("ipv6", r#"{ "value": "::1" }"#, "format:ipv6"),
867        ];
868
869        for (fmt, body, check_name) in formats {
870            script.push_str("    {\n");
871            let url = format!("${{BASE_URL}}/conformance/formats/{}", fmt);
872            let body_str = format!("'{}'", body);
873            self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
874            script.push_str(&format!(
875                "      check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
876                check_name
877            ));
878            script.push_str("    }\n");
879        }
880
881        script.push_str("  });\n\n");
882    }
883
884    fn generate_constraints_group(&self, script: &mut String) {
885        script.push_str("  group('Constraints', function () {\n");
886
887        let constraints = [
888            (
889                "required",
890                "JSON.stringify({ required_field: 'present' })",
891                "constraint:required",
892            ),
893            ("optional", "JSON.stringify({})", "constraint:optional"),
894            ("minmax", "JSON.stringify({ value: 50 })", "constraint:minmax"),
895            ("pattern", "JSON.stringify({ value: 'ABC-123' })", "constraint:pattern"),
896            ("enum", "JSON.stringify({ status: 'active' })", "constraint:enum"),
897        ];
898
899        for (kind, body, check_name) in constraints {
900            script.push_str("    {\n");
901            let url = format!("${{BASE_URL}}/conformance/constraints/{}", kind);
902            self.emit_post_like(script, "post", &url, body, "JSON_HEADERS");
903            script.push_str(&format!(
904                "      check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
905                check_name
906            ));
907            script.push_str("    }\n");
908        }
909
910        script.push_str("  });\n\n");
911    }
912
913    fn generate_response_codes_group(&self, script: &mut String) {
914        script.push_str("  group('Response Codes', function () {\n");
915
916        let codes = [
917            ("200", "response:200"),
918            ("201", "response:201"),
919            ("204", "response:204"),
920            ("400", "response:400"),
921            ("404", "response:404"),
922        ];
923
924        for (code, check_name) in codes {
925            script.push_str("    {\n");
926            let url = format!("${{BASE_URL}}/conformance/responses/{}", code);
927            self.emit_get(script, &url, None);
928            script.push_str(&format!(
929                "      check(res, {{ '{}': (r) => r.status === {} }});\n",
930                check_name, code
931            ));
932            script.push_str("    }\n");
933        }
934
935        script.push_str("  });\n\n");
936    }
937
938    fn generate_http_methods_group(&self, script: &mut String) {
939        script.push_str("  group('HTTP Methods', function () {\n");
940
941        // GET
942        script.push_str("    {\n");
943        self.emit_get(script, "${BASE_URL}/conformance/methods", None);
944        script.push_str(
945            "      check(res, { 'method:GET': (r) => r.status >= 200 && r.status < 500 });\n",
946        );
947        script.push_str("    }\n");
948
949        // POST
950        script.push_str("    {\n");
951        self.emit_post_like(
952            script,
953            "post",
954            "${BASE_URL}/conformance/methods",
955            "JSON.stringify({ action: 'create' })",
956            "JSON_HEADERS",
957        );
958        script.push_str(
959            "      check(res, { 'method:POST': (r) => r.status >= 200 && r.status < 500 });\n",
960        );
961        script.push_str("    }\n");
962
963        // PUT
964        script.push_str("    {\n");
965        self.emit_post_like(
966            script,
967            "put",
968            "${BASE_URL}/conformance/methods",
969            "JSON.stringify({ action: 'update' })",
970            "JSON_HEADERS",
971        );
972        script.push_str(
973            "      check(res, { 'method:PUT': (r) => r.status >= 200 && r.status < 500 });\n",
974        );
975        script.push_str("    }\n");
976
977        // PATCH
978        script.push_str("    {\n");
979        self.emit_post_like(
980            script,
981            "patch",
982            "${BASE_URL}/conformance/methods",
983            "JSON.stringify({ action: 'patch' })",
984            "JSON_HEADERS",
985        );
986        script.push_str(
987            "      check(res, { 'method:PATCH': (r) => r.status >= 200 && r.status < 500 });\n",
988        );
989        script.push_str("    }\n");
990
991        // DELETE
992        script.push_str("    {\n");
993        self.emit_no_body(script, "del", "${BASE_URL}/conformance/methods");
994        script.push_str(
995            "      check(res, { 'method:DELETE': (r) => r.status >= 200 && r.status < 500 });\n",
996        );
997        script.push_str("    }\n");
998
999        // HEAD
1000        script.push_str("    {\n");
1001        self.emit_no_body(script, "head", "${BASE_URL}/conformance/methods");
1002        script.push_str(
1003            "      check(res, { 'method:HEAD': (r) => r.status >= 200 && r.status < 500 });\n",
1004        );
1005        script.push_str("    }\n");
1006
1007        // OPTIONS
1008        script.push_str("    {\n");
1009        self.emit_no_body(script, "options", "${BASE_URL}/conformance/methods");
1010        script.push_str(
1011            "      check(res, { 'method:OPTIONS': (r) => r.status >= 200 && r.status < 500 });\n",
1012        );
1013        script.push_str("    }\n");
1014
1015        script.push_str("  });\n\n");
1016    }
1017
1018    fn generate_content_negotiation_group(&self, script: &mut String) {
1019        script.push_str("  group('Content Types', function () {\n");
1020
1021        script.push_str("    {\n");
1022        self.emit_get(
1023            script,
1024            "${BASE_URL}/conformance/content-types",
1025            Some("{ 'Accept': 'application/json' }"),
1026        );
1027        script.push_str(
1028            "      check(res, { 'content:negotiation': (r) => r.status >= 200 && r.status < 500 });\n",
1029        );
1030        script.push_str("    }\n");
1031
1032        script.push_str("  });\n\n");
1033    }
1034
1035    fn generate_security_group(&self, script: &mut String) {
1036        script.push_str("  group('Security', function () {\n");
1037
1038        // Bearer token
1039        script.push_str("    {\n");
1040        self.emit_get(
1041            script,
1042            "${BASE_URL}/conformance/security/bearer",
1043            Some("{ 'Authorization': 'Bearer test-token-123' }"),
1044        );
1045        script.push_str(
1046            "      check(res, { 'security:bearer': (r) => r.status >= 200 && r.status < 500 });\n",
1047        );
1048        script.push_str("    }\n");
1049
1050        // API Key
1051        let api_key = self.config.api_key.as_deref().unwrap_or("test-api-key-123");
1052        script.push_str("    {\n");
1053        let api_key_hdrs = format!("{{ 'X-API-Key': '{}' }}", api_key);
1054        self.emit_get(script, "${BASE_URL}/conformance/security/apikey", Some(&api_key_hdrs));
1055        script.push_str(
1056            "      check(res, { 'security:apikey': (r) => r.status >= 200 && r.status < 500 });\n",
1057        );
1058        script.push_str("    }\n");
1059
1060        // Basic auth
1061        let basic_creds = self.config.basic_auth.as_deref().unwrap_or("user:pass");
1062        let encoded = base64_encode(basic_creds);
1063        script.push_str("    {\n");
1064        let basic_hdrs = format!("{{ 'Authorization': 'Basic {}' }}", encoded);
1065        self.emit_get(script, "${BASE_URL}/conformance/security/basic", Some(&basic_hdrs));
1066        script.push_str(
1067            "      check(res, { 'security:basic': (r) => r.status >= 200 && r.status < 500 });\n",
1068        );
1069        script.push_str("    }\n");
1070
1071        script.push_str("  });\n\n");
1072    }
1073
1074    fn generate_handle_summary(&self, script: &mut String) {
1075        // Determine the report output path. When output_dir is set, use an absolute
1076        // path so k6 writes the file where the CLI expects to find it regardless of CWD.
1077        let report_path = match &self.config.output_dir {
1078            Some(dir) => {
1079                let abs = std::fs::canonicalize(dir)
1080                    .unwrap_or_else(|_| dir.clone())
1081                    .join("conformance-report.json");
1082                abs.to_string_lossy().to_string()
1083            }
1084            None => "conformance-report.json".to_string(),
1085        };
1086
1087        script.push_str("export function handleSummary(data) {\n");
1088        script.push_str("  // Extract check results for conformance reporting\n");
1089        script.push_str("  let checks = {};\n");
1090        script.push_str("  if (data.metrics && data.metrics.checks) {\n");
1091        script.push_str("    // Overall check pass rate\n");
1092        script.push_str("    checks.overall_pass_rate = data.metrics.checks.values.rate;\n");
1093        script.push_str("  }\n");
1094        script.push_str("  // Collect per-check results from root_group\n");
1095        script.push_str("  let checkResults = {};\n");
1096        script.push_str("  function walkGroups(group) {\n");
1097        script.push_str("    if (group.checks) {\n");
1098        script.push_str("      for (let checkObj of group.checks) {\n");
1099        script.push_str("        checkResults[checkObj.name] = {\n");
1100        script.push_str("          passes: checkObj.passes,\n");
1101        script.push_str("          fails: checkObj.fails,\n");
1102        script.push_str("        };\n");
1103        script.push_str("      }\n");
1104        script.push_str("    }\n");
1105        script.push_str("    if (group.groups) {\n");
1106        script.push_str("      for (let subGroup of group.groups) {\n");
1107        script.push_str("        walkGroups(subGroup);\n");
1108        script.push_str("      }\n");
1109        script.push_str("    }\n");
1110        script.push_str("  }\n");
1111        script.push_str("  if (data.root_group) {\n");
1112        script.push_str("    walkGroups(data.root_group);\n");
1113        script.push_str("  }\n");
1114        script.push_str("  let result = {\n");
1115        script.push_str(&format!(
1116            "    '{}': JSON.stringify({{ checks: checkResults, overall: checks }}, null, 2),\n",
1117            report_path
1118        ));
1119        script.push_str("    'summary.json': JSON.stringify(data),\n");
1120        script.push_str("    stdout: textSummary(data, { indent: '  ', enableColors: true }),\n");
1121        script.push_str("  };\n");
1122        script.push_str("  return result;\n");
1123        script.push_str("}\n\n");
1124        script.push_str("// textSummary fallback\n");
1125        script.push_str("function textSummary(data, opts) {\n");
1126        script.push_str("  return JSON.stringify(data, null, 2);\n");
1127        script.push_str("}\n");
1128    }
1129}
1130
1131/// Simple base64 encoding for basic auth
1132fn base64_encode(input: &str) -> String {
1133    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1134    let bytes = input.as_bytes();
1135    let mut result = String::with_capacity(bytes.len().div_ceil(3) * 4);
1136    for chunk in bytes.chunks(3) {
1137        let b0 = chunk[0] as u32;
1138        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
1139        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
1140        let triple = (b0 << 16) | (b1 << 8) | b2;
1141        result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
1142        result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
1143        if chunk.len() > 1 {
1144            result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
1145        } else {
1146            result.push('=');
1147        }
1148        if chunk.len() > 2 {
1149            result.push(CHARS[(triple & 0x3F) as usize] as char);
1150        } else {
1151            result.push('=');
1152        }
1153    }
1154    result
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159    use super::*;
1160
1161    #[test]
1162    fn test_generate_conformance_script() {
1163        let config = ConformanceConfig {
1164            target_url: "http://localhost:8080".to_string(),
1165            api_key: None,
1166            basic_auth: None,
1167            skip_tls_verify: false,
1168            categories: None,
1169            base_path: None,
1170            custom_headers: vec![],
1171            output_dir: None,
1172            all_operations: false,
1173            custom_checks_file: None,
1174            request_delay_ms: 0,
1175            custom_filter: None,
1176            export_requests: false,
1177            validate_requests: false,
1178        };
1179        let generator = ConformanceGenerator::new(config);
1180        let script = generator.generate().unwrap();
1181
1182        assert!(script.contains("import http from 'k6/http'"));
1183        assert!(script.contains("vus: 1"));
1184        assert!(script.contains("iterations: 1"));
1185        assert!(script.contains("group('Parameters'"));
1186        assert!(script.contains("group('Request Bodies'"));
1187        assert!(script.contains("group('Schema Types'"));
1188        assert!(script.contains("group('Composition'"));
1189        assert!(script.contains("group('String Formats'"));
1190        assert!(script.contains("group('Constraints'"));
1191        assert!(script.contains("group('Response Codes'"));
1192        assert!(script.contains("group('HTTP Methods'"));
1193        assert!(script.contains("group('Content Types'"));
1194        assert!(script.contains("group('Security'"));
1195        assert!(script.contains("handleSummary"));
1196    }
1197
1198    #[test]
1199    fn test_base64_encode() {
1200        assert_eq!(base64_encode("user:pass"), "dXNlcjpwYXNz");
1201        assert_eq!(base64_encode("a"), "YQ==");
1202        assert_eq!(base64_encode("ab"), "YWI=");
1203        assert_eq!(base64_encode("abc"), "YWJj");
1204    }
1205
1206    #[test]
1207    fn test_conformance_script_with_custom_auth() {
1208        let config = ConformanceConfig {
1209            target_url: "https://api.example.com".to_string(),
1210            api_key: Some("my-api-key".to_string()),
1211            basic_auth: Some("admin:secret".to_string()),
1212            skip_tls_verify: true,
1213            categories: None,
1214            base_path: None,
1215            custom_headers: vec![],
1216            output_dir: None,
1217            all_operations: false,
1218            custom_checks_file: None,
1219            request_delay_ms: 0,
1220            custom_filter: None,
1221            export_requests: false,
1222            validate_requests: false,
1223        };
1224        let generator = ConformanceGenerator::new(config);
1225        let script = generator.generate().unwrap();
1226
1227        assert!(script.contains("insecureSkipTLSVerify: true"));
1228        assert!(script.contains("my-api-key"));
1229        assert!(script.contains(&base64_encode("admin:secret")));
1230    }
1231
1232    #[test]
1233    fn test_should_include_category_none_includes_all() {
1234        let config = ConformanceConfig {
1235            target_url: "http://localhost:8080".to_string(),
1236            api_key: None,
1237            basic_auth: None,
1238            skip_tls_verify: false,
1239            categories: None,
1240            base_path: None,
1241            custom_headers: vec![],
1242            output_dir: None,
1243            all_operations: false,
1244            custom_checks_file: None,
1245            request_delay_ms: 0,
1246            custom_filter: None,
1247            export_requests: false,
1248            validate_requests: false,
1249        };
1250        assert!(config.should_include_category("Parameters"));
1251        assert!(config.should_include_category("Security"));
1252        assert!(config.should_include_category("Anything"));
1253    }
1254
1255    #[test]
1256    fn test_should_include_category_filtered() {
1257        let config = ConformanceConfig {
1258            target_url: "http://localhost:8080".to_string(),
1259            api_key: None,
1260            basic_auth: None,
1261            skip_tls_verify: false,
1262            categories: Some(vec!["Parameters".to_string(), "Security".to_string()]),
1263            base_path: None,
1264            custom_headers: vec![],
1265            output_dir: None,
1266            all_operations: false,
1267            custom_checks_file: None,
1268            request_delay_ms: 0,
1269            custom_filter: None,
1270            export_requests: false,
1271            validate_requests: false,
1272        };
1273        assert!(config.should_include_category("Parameters"));
1274        assert!(config.should_include_category("Security"));
1275        assert!(config.should_include_category("parameters")); // case-insensitive
1276        assert!(!config.should_include_category("Composition"));
1277        assert!(!config.should_include_category("Schema Types"));
1278    }
1279
1280    #[test]
1281    fn test_generate_with_category_filter() {
1282        let config = ConformanceConfig {
1283            target_url: "http://localhost:8080".to_string(),
1284            api_key: None,
1285            basic_auth: None,
1286            skip_tls_verify: false,
1287            categories: Some(vec!["Parameters".to_string(), "Security".to_string()]),
1288            base_path: None,
1289            custom_headers: vec![],
1290            output_dir: None,
1291            all_operations: false,
1292            custom_checks_file: None,
1293            request_delay_ms: 0,
1294            custom_filter: None,
1295            export_requests: false,
1296            validate_requests: false,
1297        };
1298        let generator = ConformanceGenerator::new(config);
1299        let script = generator.generate().unwrap();
1300
1301        assert!(script.contains("group('Parameters'"));
1302        assert!(script.contains("group('Security'"));
1303        assert!(!script.contains("group('Request Bodies'"));
1304        assert!(!script.contains("group('Schema Types'"));
1305        assert!(!script.contains("group('Composition'"));
1306    }
1307
1308    #[test]
1309    fn test_effective_base_url_no_base_path() {
1310        let config = ConformanceConfig {
1311            target_url: "https://example.com".to_string(),
1312            api_key: None,
1313            basic_auth: None,
1314            skip_tls_verify: false,
1315            categories: None,
1316            base_path: None,
1317            custom_headers: vec![],
1318            output_dir: None,
1319            all_operations: false,
1320            custom_checks_file: None,
1321            request_delay_ms: 0,
1322            custom_filter: None,
1323            export_requests: false,
1324            validate_requests: false,
1325        };
1326        assert_eq!(config.effective_base_url(), "https://example.com");
1327    }
1328
1329    #[test]
1330    fn test_effective_base_url_with_base_path() {
1331        let config = ConformanceConfig {
1332            target_url: "https://example.com".to_string(),
1333            api_key: None,
1334            basic_auth: None,
1335            skip_tls_verify: false,
1336            categories: None,
1337            base_path: Some("/api".to_string()),
1338            custom_headers: vec![],
1339            output_dir: None,
1340            all_operations: false,
1341            custom_checks_file: None,
1342            request_delay_ms: 0,
1343            custom_filter: None,
1344            export_requests: false,
1345            validate_requests: false,
1346        };
1347        assert_eq!(config.effective_base_url(), "https://example.com/api");
1348    }
1349
1350    #[test]
1351    fn test_effective_base_url_trailing_slash_normalization() {
1352        let config = ConformanceConfig {
1353            target_url: "https://example.com/".to_string(),
1354            api_key: None,
1355            basic_auth: None,
1356            skip_tls_verify: false,
1357            categories: None,
1358            base_path: Some("/api".to_string()),
1359            custom_headers: vec![],
1360            output_dir: None,
1361            all_operations: false,
1362            custom_checks_file: None,
1363            request_delay_ms: 0,
1364            custom_filter: None,
1365            export_requests: false,
1366            validate_requests: false,
1367        };
1368        assert_eq!(config.effective_base_url(), "https://example.com/api");
1369    }
1370
1371    #[test]
1372    fn test_effective_base_url_trailing_slash_no_base_path() {
1373        // Regression: --target https://192.168.2.86/ without --base-path
1374        // must not produce double slashes when combined with /path
1375        let config = ConformanceConfig {
1376            target_url: "https://192.168.2.86/".to_string(),
1377            api_key: None,
1378            basic_auth: None,
1379            skip_tls_verify: false,
1380            categories: None,
1381            base_path: None,
1382            custom_headers: vec![],
1383            output_dir: None,
1384            all_operations: false,
1385            custom_checks_file: None,
1386            request_delay_ms: 0,
1387            custom_filter: None,
1388            export_requests: false,
1389            validate_requests: false,
1390        };
1391        assert_eq!(config.effective_base_url(), "https://192.168.2.86");
1392    }
1393
1394    #[test]
1395    fn test_generate_script_with_base_path() {
1396        let config = ConformanceConfig {
1397            target_url: "https://192.168.2.86".to_string(),
1398            api_key: None,
1399            basic_auth: None,
1400            skip_tls_verify: true,
1401            categories: None,
1402            base_path: Some("/api".to_string()),
1403            custom_headers: vec![],
1404            output_dir: None,
1405            all_operations: false,
1406            custom_checks_file: None,
1407            request_delay_ms: 0,
1408            custom_filter: None,
1409            export_requests: false,
1410            validate_requests: false,
1411        };
1412        let generator = ConformanceGenerator::new(config);
1413        let script = generator.generate().unwrap();
1414
1415        assert!(script.contains("const BASE_URL = 'https://192.168.2.86/api'"));
1416        // Verify URLs include the base path via BASE_URL
1417        assert!(script.contains("${BASE_URL}/conformance/"));
1418    }
1419
1420    #[test]
1421    fn test_generate_with_custom_headers() {
1422        let config = ConformanceConfig {
1423            target_url: "https://192.168.2.86".to_string(),
1424            api_key: None,
1425            basic_auth: None,
1426            skip_tls_verify: true,
1427            categories: Some(vec!["Parameters".to_string()]),
1428            base_path: Some("/api".to_string()),
1429            custom_headers: vec![
1430                ("X-Avi-Tenant".to_string(), "admin".to_string()),
1431                ("X-CSRFToken".to_string(), "real-token".to_string()),
1432            ],
1433            output_dir: None,
1434            all_operations: false,
1435            custom_checks_file: None,
1436            request_delay_ms: 0,
1437            custom_filter: None,
1438            export_requests: false,
1439            validate_requests: false,
1440        };
1441        let generator = ConformanceGenerator::new(config);
1442        let script = generator.generate().unwrap();
1443
1444        // Custom headers should be inlined into requests (no separate const)
1445        assert!(
1446            !script.contains("const CUSTOM_HEADERS"),
1447            "Script should NOT declare a CUSTOM_HEADERS const"
1448        );
1449        assert!(script.contains("'X-Avi-Tenant': 'admin'"));
1450        assert!(script.contains("'X-CSRFToken': 'real-token'"));
1451    }
1452
1453    #[test]
1454    fn test_custom_headers_js_object() {
1455        let config = ConformanceConfig {
1456            target_url: "http://localhost".to_string(),
1457            api_key: None,
1458            basic_auth: None,
1459            skip_tls_verify: false,
1460            categories: None,
1461            base_path: None,
1462            custom_headers: vec![
1463                ("Authorization".to_string(), "Bearer abc123".to_string()),
1464                ("X-Custom".to_string(), "value".to_string()),
1465            ],
1466            output_dir: None,
1467            all_operations: false,
1468            custom_checks_file: None,
1469            request_delay_ms: 0,
1470            custom_filter: None,
1471            export_requests: false,
1472            validate_requests: false,
1473        };
1474        let js = config.custom_headers_js_object();
1475        assert!(js.contains("'Authorization': 'Bearer abc123'"));
1476        assert!(js.contains("'X-Custom': 'value'"));
1477    }
1478
1479    /// Round 63 (#79) — the k6 network-event `kind` ladder lives in THREE
1480    /// render paths (this file, `spec_driven.rs`, and `k6_script.hbs`). The
1481    /// classic #79 failure is fixing one and silently leaving the others
1482    /// behind, so assert every path carries the `protocol` branch that labels
1483    /// target-side HTTP violations (Srikanth's WAF answering blocked probes
1484    /// with a body that disagreed with its declared Content-Length, which
1485    /// previously landed in the useless `other` bucket).
1486    #[test]
1487    fn protocol_kind_present_in_all_three_render_paths() {
1488        const PATHS: [(&str, &str); 3] = [
1489            ("generator.rs", include_str!("generator.rs")),
1490            ("spec_driven.rs", include_str!("spec_driven.rs")),
1491            ("k6_script.hbs", include_str!("../templates/k6_script.hbs")),
1492        ];
1493        // NOTE: this test's own source is one of the inputs (generator.rs
1494        // includes itself), so every needle is assembled at runtime — a
1495        // literal here would satisfy the assertion even if the real ladder
1496        // were deleted.
1497        let kind_of = |k: &str| format!("kind = '{k}'");
1498        let cl_needle = format!("declared content-{}", "length");
1499
1500        for (name, src) in PATHS {
1501            assert!(
1502                src.contains(&kind_of("protocol")),
1503                "{name} is missing the `protocol` network-event classification (#79 r63)"
1504            );
1505            assert!(
1506                src.contains(&cl_needle),
1507                "{name} is missing the declared-Content-Length match (#79 r63)"
1508            );
1509            // The protocol branch must stay LAST so it only re-labels what
1510            // would otherwise be `other`; the eof/timeout/tls/connect rules
1511            // above it keep their existing meaning.
1512            let protocol_at = src.find(&kind_of("protocol")).expect("checked above");
1513            for earlier in ["connect", "timeout", "tls"] {
1514                let needle = kind_of(earlier);
1515                assert!(
1516                    src.find(&needle).expect("ladder rule present") < protocol_at,
1517                    "{name}: `{needle}` must be classified before `protocol` (#79 r63)"
1518                );
1519            }
1520        }
1521    }
1522}