1use crate::error::{BenchError, Result};
4use std::path::{Path, PathBuf};
5
6use super::custom::CustomConformanceConfig;
7
8#[derive(Default, Clone)]
10pub struct ConformanceConfig {
11 pub target_url: String,
13 pub api_key: Option<String>,
15 pub basic_auth: Option<String>,
17 pub skip_tls_verify: bool,
19 pub categories: Option<Vec<String>>,
21 pub base_path: Option<String>,
23 pub custom_headers: Vec<(String, String)>,
27 pub output_dir: Option<PathBuf>,
31 pub all_operations: bool,
34 pub custom_checks_file: Option<PathBuf>,
36 pub request_delay_ms: u64,
39 pub custom_filter: Option<String>,
42 pub export_requests: bool,
45 pub validate_requests: bool,
48}
49
50impl ConformanceConfig {
51 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 pub fn has_custom_headers(&self) -> bool {
61 !self.custom_headers.is_empty()
62 }
63
64 pub fn has_cookie_header(&self) -> bool {
68 self.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("cookie"))
69 }
70
71 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 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 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 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
149pub struct ConformanceGenerator {
151 config: ConformanceConfig,
152}
153
154impl ConformanceGenerator {
155 pub fn new(config: ConformanceConfig) -> Self {
156 Self { config }
157 }
158
159 pub fn generate(&self) -> Result<String> {
161 let mut script = String::with_capacity(16384);
162
163 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 script.push_str(
175 "http.setResponseCallback(http.expectedStatuses({ min: 100, max: 599 }));\n\n",
176 );
177
178 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 script.push_str(&format!("const BASE_URL = '{}';\n\n", self.config.effective_base_url()));
192
193 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 script.push_str("const JSON_HEADERS = { 'Content-Type': 'application/json' };\n\n");
203
204 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 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 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 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 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 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 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 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 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 let custom_only = self.config.custom_checks_file.is_some()
508 && !self.config.target_url.is_empty()
509 && 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 if let Some(emit) = custom_emit {
559 script.push_str(&emit.group_body);
560 }
561
562 script.push_str("}\n\n");
563
564 self.generate_handle_summary(&mut script);
566
567 Ok(script)
568 }
569
570 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
1131fn 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")); 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 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 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 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 #[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 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 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}