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 const totalBytes = raw.length;\n\
304 \x20\x20\x20\x20\x20\x20\x20\x20 const boundaryMatch = ct.match(/boundary=([^;]+)/);\n\
305 \x20\x20\x20\x20\x20\x20\x20\x20 const boundary = boundaryMatch ? boundaryMatch[1].replace(/^\"|\"$/g, '') : '';\n\
306 \x20\x20\x20\x20\x20\x20\x20\x20 const parts = [];\n\
307 \x20\x20\x20\x20\x20\x20\x20\x20 if (boundary) {\n\
308 \x20\x20\x20\x20\x20\x20\x20\x20 const sep = '--' + boundary;\n\
309 \x20\x20\x20\x20\x20\x20\x20\x20 let cursor = raw.indexOf(sep);\n\
310 \x20\x20\x20\x20\x20\x20\x20\x20 while (cursor !== -1 && parts.length < 100) {\n\
311 \x20\x20\x20\x20\x20\x20\x20\x20 const next = raw.indexOf(sep, cursor + sep.length);\n\
312 \x20\x20\x20\x20\x20\x20\x20\x20 if (next === -1) break;\n\
313 \x20\x20\x20\x20\x20\x20\x20\x20 const slice = raw.substring(cursor + sep.length, next);\n\
314 \x20\x20\x20\x20\x20\x20\x20\x20 const headerEnd = slice.indexOf('\\r\\n\\r\\n');\n\
315 \x20\x20\x20\x20\x20\x20\x20\x20 const partHeaders = headerEnd === -1 ? slice : slice.substring(0, headerEnd);\n\
316 \x20\x20\x20\x20\x20\x20\x20\x20 const partBody = headerEnd === -1 ? '' : slice.substring(headerEnd + 4);\n\
317 \x20\x20\x20\x20\x20\x20\x20\x20 const nameMatch = partHeaders.match(/name=\"([^\"]+)\"/);\n\
318 \x20\x20\x20\x20\x20\x20\x20\x20 const filenameMatch = partHeaders.match(/filename=\"([^\"]+)\"/);\n\
319 \x20\x20\x20\x20\x20\x20\x20\x20 const partCtMatch = partHeaders.match(/Content-Type:\\s*([^\\r\\n]+)/i);\n\
320 \x20\x20\x20\x20\x20\x20\x20\x20 parts.push({\n\
321 \x20\x20\x20\x20\x20\x20\x20\x20 name: nameMatch ? nameMatch[1] : '',\n\
322 \x20\x20\x20\x20\x20\x20\x20\x20 filename: filenameMatch ? filenameMatch[1] : '',\n\
323 \x20\x20\x20\x20\x20\x20\x20\x20 contentType: partCtMatch ? partCtMatch[1].trim() : '',\n\
324 \x20\x20\x20\x20\x20\x20\x20\x20 bytes: Math.max(0, partBody.length - 2),\n\
325 \x20\x20\x20\x20\x20\x20\x20\x20 });\n\
326 \x20\x20\x20\x20\x20\x20\x20\x20 cursor = next;\n\
327 \x20\x20\x20\x20\x20\x20\x20\x20 }\n\
328 \x20\x20\x20\x20\x20\x20\x20\x20 }\n\
329 \x20\x20\x20\x20\x20\x20\x20\x20 // Round 47 #79 — overlay accurate on-disk byte counts from\n\
330 \x20\x20\x20\x20\x20\x20\x20\x20 // the per-check size map written at init scope; falls back\n\
331 \x20\x20\x20\x20\x20\x20\x20\x20 // to the JS-string-derived bytes when no entry exists.\n\
332 \x20\x20\x20\x20\x20\x20\x20\x20 const __mfSizes = (globalThis.__mfUploadSizes || {})[checkName] || {};\n\
333 \x20\x20\x20\x20\x20\x20\x20\x20 parts.forEach(function (p) { if (typeof __mfSizes[p.name] === 'number') p.bytes = __mfSizes[p.name]; });\n\
334 \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\
335 \x20\x20\x20\x20\x20\x20\x20\x20 reqBody = '<multipart/form-data; boundary=' + boundary + '; ' + parts.length + ' part(s); total ' + totalBytes + ' bytes: ' + summary + '>';\n\
336 \x20\x20\x20\x20\x20\x20\x20\x20} catch (e) {\n\
337 \x20\x20\x20\x20\x20\x20\x20\x20 reqBody = '<multipart upload; summary failed: ' + (e && e.message ? e.message : 'unknown') + '>';\n\
338 \x20\x20\x20\x20\x20\x20\x20\x20}\n\
339 \x20\x20\x20\x20\x20\x20} else if (isMultipart) {\n\
340 \x20\x20\x20\x20\x20\x20\x20\x20reqBody = '<multipart upload; body bytes not surfaced by k6 res.request.body>';\n\
341 \x20\x20\x20\x20\x20\x20} else if (res.request && res.request.body) {\n\
342 \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\
343 \x20\x20\x20\x20\x20\x20}\n\
344 \x20\x20\x20\x20}\n",
345 );
346 script.push_str(
353 " if (res && res.status === 0) {\n\
354 \x20\x20\x20\x20\x20\x20const ec = (res.error_code != null) ? res.error_code : 0;\n\
355 \x20\x20\x20\x20\x20\x20const em = (res.error != null) ? String(res.error) : '';\n\
356 \x20\x20\x20\x20\x20\x20// k6 error_code ranges: 1200s = TCP/DNS, 1300s = TLS, 1400s = timeout, 1500s = HTTP/2, others. Map coarsely.\n\
357 \x20\x20\x20\x20\x20\x20let kind = 'other';\n\
358 \x20\x20\x20\x20\x20\x20if (ec >= 1200 && ec < 1300) kind = 'connect';\n\
359 \x20\x20\x20\x20\x20\x20else if (ec >= 1300 && ec < 1400) kind = 'tls';\n\
360 \x20\x20\x20\x20\x20\x20else if (ec >= 1400 && ec < 1500) kind = 'timeout';\n\
361 \x20\x20\x20\x20\x20\x20else if (em.toLowerCase().indexOf('timeout') !== -1) kind = 'timeout';\n\
362 \x20\x20\x20\x20\x20\x20else if (em.toLowerCase().indexOf('tls') !== -1) kind = 'tls';\n\
363 \x20\x20\x20\x20\x20\x20else if (em.toLowerCase().indexOf('connect') !== -1 || em.toLowerCase().indexOf('refused') !== -1) kind = 'connect';\n\
364 \x20\x20\x20\x20\x20\x20console.log('MOCKFORGE_NETWORK_EVENT:' + JSON.stringify({\n\
365 \x20\x20\x20\x20\x20\x20 timestamp: new Date().toISOString(),\n\
366 \x20\x20\x20\x20\x20\x20 check: checkName,\n\
367 \x20\x20\x20\x20\x20\x20 method: res.request ? res.request.method : 'unknown',\n\
368 \x20\x20\x20\x20\x20\x20 url: res.request ? res.request.url : res.url || 'unknown',\n\
369 \x20\x20\x20\x20\x20\x20 kind: kind,\n\
370 \x20\x20\x20\x20\x20\x20 error_code: ec,\n\
371 \x20\x20\x20\x20\x20\x20 message: em,\n\
372 \x20\x20\x20\x20\x20\x20}));\n\
373 \x20\x20\x20\x20}\n",
374 );
375 script.push_str(" console.log('MOCKFORGE_EXCHANGE:' + JSON.stringify({\n");
376 script.push_str(" check: checkName,\n");
377 script.push_str(" request: {\n");
378 script.push_str(" method: res.request ? res.request.method : 'unknown',\n");
379 script.push_str(" url: res.request ? res.request.url : res.url || 'unknown',\n");
380 script.push_str(" headers: reqHeaders,\n");
381 script.push_str(" body: reqBody,\n");
382 script.push_str(" },\n");
383 script.push_str(" response: {\n");
384 script.push_str(" status: res.status,\n");
385 script.push_str(" headers: res.headers ? Object.fromEntries(Object.entries(res.headers).slice(0, 30)) : {},\n");
386 script.push_str(" body: bodyStr,\n");
387 script.push_str(" },\n");
388 script.push_str(" }));\n");
389 script.push_str(" } catch (e) {\n");
390 script.push_str(" try {\n");
395 script.push_str(" console.log('MOCKFORGE_EXCHANGE:' + JSON.stringify({\n");
396 script.push_str(" check: checkName,\n");
397 script.push_str(" request: {\n");
398 script.push_str(
399 " method: (res && res.request) ? res.request.method : 'unknown',\n",
400 );
401 script.push_str(" url: (res && res.request) ? res.request.url : (res && res.url) || 'unknown',\n");
402 script.push_str(" headers: {},\n");
403 script.push_str(" body: '<exchange capture failed: ' + (e && e.message ? e.message : 'unknown error') + '>',\n");
404 script.push_str(" },\n");
405 script.push_str(" response: {\n");
406 script.push_str(" status: (res && res.status) || 0,\n");
407 script.push_str(" headers: {},\n");
408 script.push_str(" body: '',\n");
409 script.push_str(" },\n");
410 script.push_str(" _export_error: (e && e.message) ? e.message : String(e),\n");
411 script.push_str(" }));\n");
412 script.push_str(" } catch (e2) {\n");
413 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");
416 script.push_str(" }\n");
417 script.push_str(" }\n");
418 script.push_str("}\n\n");
419 }
420
421 script.push_str("export default function () {\n");
423
424 if self.config.has_cookie_header() {
425 script.push_str(
426 " // Clear cookie jar to prevent server Set-Cookie from duplicating custom Cookie header\n",
427 );
428 script.push_str(" http.cookieJar().clear(BASE_URL);\n\n");
429 }
430
431 let delay_between = if self.config.request_delay_ms > 0 {
433 " sleep(REQUEST_DELAY);\n".to_string()
434 } else {
435 String::new()
436 };
437
438 let custom_only = self.config.custom_checks_file.is_some()
447 && !self.config.target_url.is_empty()
448 && self.config.categories.is_none();
451 if !custom_only {
452 if self.config.should_include_category("Parameters") {
453 self.generate_parameters_group(&mut script);
454 script.push_str(&delay_between);
455 }
456 if self.config.should_include_category("Request Bodies") {
457 self.generate_request_bodies_group(&mut script);
458 script.push_str(&delay_between);
459 }
460 if self.config.should_include_category("Schema Types") {
461 self.generate_schema_types_group(&mut script);
462 script.push_str(&delay_between);
463 }
464 if self.config.should_include_category("Composition") {
465 self.generate_composition_group(&mut script);
466 script.push_str(&delay_between);
467 }
468 if self.config.should_include_category("String Formats") {
469 self.generate_string_formats_group(&mut script);
470 script.push_str(&delay_between);
471 }
472 if self.config.should_include_category("Constraints") {
473 self.generate_constraints_group(&mut script);
474 script.push_str(&delay_between);
475 }
476 if self.config.should_include_category("Response Codes") {
477 self.generate_response_codes_group(&mut script);
478 script.push_str(&delay_between);
479 }
480 if self.config.should_include_category("HTTP Methods") {
481 self.generate_http_methods_group(&mut script);
482 script.push_str(&delay_between);
483 }
484 if self.config.should_include_category("Content Types") {
485 self.generate_content_negotiation_group(&mut script);
486 script.push_str(&delay_between);
487 }
488 if self.config.should_include_category("Security") {
489 self.generate_security_group(&mut script);
490 }
491 }
492
493 if let Some(emit) = custom_emit {
498 script.push_str(&emit.group_body);
499 }
500
501 script.push_str("}\n\n");
502
503 self.generate_handle_summary(&mut script);
505
506 Ok(script)
507 }
508
509 pub fn write_script(&self, path: &Path) -> Result<()> {
511 let script = self.generate()?;
512 if let Some(parent) = path.parent() {
513 std::fs::create_dir_all(parent)?;
514 }
515 std::fs::write(path, script)
516 .map_err(|e| BenchError::Other(format!("Failed to write conformance script: {}", e)))
517 }
518
519 fn merge_with_custom_headers(&self, headers_expr: &str) -> String {
523 if self.config.has_custom_headers() {
524 format!(
525 "Object.assign({{}}, {}, {})",
526 headers_expr,
527 self.config.custom_headers_js_object()
528 )
529 } else {
530 headers_expr.to_string()
531 }
532 }
533
534 fn emit_get(&self, script: &mut String, url: &str, extra_headers: Option<&str>) {
536 let has_custom = self.config.has_custom_headers();
537 let custom_obj = self.config.custom_headers_js_object();
538 match (extra_headers, has_custom) {
539 (None, false) => {
540 script.push_str(&format!(" let res = http.get(`{}`);\n", url));
541 }
542 (None, true) => {
543 script.push_str(&format!(
544 " let res = http.get(`{}`, {{ headers: {} }});\n",
545 url, custom_obj
546 ));
547 }
548 (Some(hdrs), false) => {
549 script.push_str(&format!(
550 " let res = http.get(`{}`, {{ headers: {} }});\n",
551 url, hdrs
552 ));
553 }
554 (Some(hdrs), true) => {
555 script.push_str(&format!(
556 " let res = http.get(`{}`, {{ headers: Object.assign({{}}, {}, {}) }});\n",
557 url, hdrs, custom_obj
558 ));
559 }
560 }
561 self.maybe_clear_cookie_jar(script);
562 self.maybe_capture_exchange(script);
563 }
564
565 fn emit_post_like(
567 &self,
568 script: &mut String,
569 method: &str,
570 url: &str,
571 body: &str,
572 headers_expr: &str,
573 ) {
574 let merged = self.merge_with_custom_headers(headers_expr);
575 script.push_str(&format!(
576 " let res = http.{}(`{}`, {}, {{ headers: {} }});\n",
577 method, url, body, merged
578 ));
579 self.maybe_clear_cookie_jar(script);
580 self.maybe_capture_exchange(script);
581 }
582
583 fn emit_no_body(&self, script: &mut String, method: &str, url: &str) {
585 if self.config.has_custom_headers() {
586 script.push_str(&format!(
587 " let res = http.{}(`{}`, {{ headers: {} }});\n",
588 method,
589 url,
590 self.config.custom_headers_js_object()
591 ));
592 } else {
593 script.push_str(&format!(" let res = http.{}(`{}`);\n", method, url));
594 }
595 self.maybe_clear_cookie_jar(script);
596 self.maybe_capture_exchange(script);
597 }
598
599 fn maybe_capture_exchange(&self, script: &mut String) {
601 if self.config.export_requests {
602 script.push_str(
603 " if (typeof __captureExchange === 'function') __captureExchange('', res);\n",
604 );
605 }
606 }
607
608 fn maybe_clear_cookie_jar(&self, script: &mut String) {
612 if self.config.has_cookie_header() {
613 script.push_str(" http.cookieJar().clear(BASE_URL);\n");
614 }
615 }
616
617 fn generate_parameters_group(&self, script: &mut String) {
618 script.push_str(" group('Parameters', function () {\n");
619
620 script.push_str(" {\n");
622 self.emit_get(script, "${BASE_URL}/conformance/params/hello", None);
623 script.push_str(
624 " check(res, { 'param:path:string': (r) => r.status >= 200 && r.status < 500 });\n",
625 );
626 script.push_str(" }\n");
627
628 script.push_str(" {\n");
630 self.emit_get(script, "${BASE_URL}/conformance/params/42", None);
631 script.push_str(
632 " check(res, { 'param:path:integer': (r) => r.status >= 200 && r.status < 500 });\n",
633 );
634 script.push_str(" }\n");
635
636 script.push_str(" {\n");
638 self.emit_get(script, "${BASE_URL}/conformance/params/query?name=test", None);
639 script.push_str(
640 " check(res, { 'param:query:string': (r) => r.status >= 200 && r.status < 500 });\n",
641 );
642 script.push_str(" }\n");
643
644 script.push_str(" {\n");
646 self.emit_get(script, "${BASE_URL}/conformance/params/query?count=10", None);
647 script.push_str(
648 " check(res, { 'param:query:integer': (r) => r.status >= 200 && r.status < 500 });\n",
649 );
650 script.push_str(" }\n");
651
652 script.push_str(" {\n");
654 self.emit_get(script, "${BASE_URL}/conformance/params/query?tags=a&tags=b", None);
655 script.push_str(
656 " check(res, { 'param:query:array': (r) => r.status >= 200 && r.status < 500 });\n",
657 );
658 script.push_str(" }\n");
659
660 script.push_str(" {\n");
662 self.emit_get(
663 script,
664 "${BASE_URL}/conformance/params/header",
665 Some("{ 'X-Custom-Param': 'test-value' }"),
666 );
667 script.push_str(
668 " check(res, { 'param:header': (r) => r.status >= 200 && r.status < 500 });\n",
669 );
670 script.push_str(" }\n");
671
672 script.push_str(" {\n");
674 script.push_str(" let jar = http.cookieJar();\n");
675 script.push_str(" jar.set(BASE_URL, 'session', 'abc123');\n");
676 self.emit_get(script, "${BASE_URL}/conformance/params/cookie", None);
677 script.push_str(
678 " check(res, { 'param:cookie': (r) => r.status >= 200 && r.status < 500 });\n",
679 );
680 script.push_str(" }\n");
681
682 script.push_str(" });\n\n");
683 }
684
685 fn generate_request_bodies_group(&self, script: &mut String) {
686 script.push_str(" group('Request Bodies', function () {\n");
687
688 script.push_str(" {\n");
690 self.emit_post_like(
691 script,
692 "post",
693 "${BASE_URL}/conformance/body/json",
694 "JSON.stringify({ name: 'test', value: 42 })",
695 "JSON_HEADERS",
696 );
697 script.push_str(
698 " check(res, { 'body:json': (r) => r.status >= 200 && r.status < 500 });\n",
699 );
700 script.push_str(" }\n");
701
702 script.push_str(" {\n");
704 if self.config.has_custom_headers() {
705 script.push_str(&format!(
706 " let res = http.post(`${{BASE_URL}}/conformance/body/form`, {{ field1: 'value1', field2: 'value2' }}, {{ headers: {} }});\n",
707 self.config.custom_headers_js_object()
708 ));
709 } else {
710 script.push_str(
711 " let res = http.post(`${BASE_URL}/conformance/body/form`, { field1: 'value1', field2: 'value2' });\n",
712 );
713 }
714 self.maybe_clear_cookie_jar(script);
715 script.push_str(
716 " check(res, { 'body:form-urlencoded': (r) => r.status >= 200 && r.status < 500 });\n",
717 );
718 script.push_str(" }\n");
719
720 script.push_str(" {\n");
722 script.push_str(
723 " let data = { field: http.file('test content', 'test.txt', 'text/plain') };\n",
724 );
725 if self.config.has_custom_headers() {
726 script.push_str(&format!(
727 " let res = http.post(`${{BASE_URL}}/conformance/body/multipart`, data, {{ headers: {} }});\n",
728 self.config.custom_headers_js_object()
729 ));
730 } else {
731 script.push_str(
732 " let res = http.post(`${BASE_URL}/conformance/body/multipart`, data);\n",
733 );
734 }
735 self.maybe_clear_cookie_jar(script);
736 script.push_str(
737 " check(res, { 'body:multipart': (r) => r.status >= 200 && r.status < 500 });\n",
738 );
739 script.push_str(" }\n");
740
741 script.push_str(" });\n\n");
742 }
743
744 fn generate_schema_types_group(&self, script: &mut String) {
745 script.push_str(" group('Schema Types', function () {\n");
746
747 let types = [
748 ("string", r#"{ "value": "hello" }"#, "schema:string"),
749 ("integer", r#"{ "value": 42 }"#, "schema:integer"),
750 ("number", r#"{ "value": 3.14 }"#, "schema:number"),
751 ("boolean", r#"{ "value": true }"#, "schema:boolean"),
752 ("array", r#"{ "value": [1, 2, 3] }"#, "schema:array"),
753 ("object", r#"{ "value": { "nested": "data" } }"#, "schema:object"),
754 ];
755
756 for (type_name, body, check_name) in types {
757 script.push_str(" {\n");
758 let url = format!("${{BASE_URL}}/conformance/schema/{}", type_name);
759 let body_str = format!("'{}'", body);
760 self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
761 script.push_str(&format!(
762 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
763 check_name
764 ));
765 script.push_str(" }\n");
766 }
767
768 script.push_str(" });\n\n");
769 }
770
771 fn generate_composition_group(&self, script: &mut String) {
772 script.push_str(" group('Composition', function () {\n");
773
774 let compositions = [
775 ("oneOf", r#"{ "type": "string", "value": "test" }"#, "composition:oneOf"),
776 ("anyOf", r#"{ "value": "test" }"#, "composition:anyOf"),
777 ("allOf", r#"{ "name": "test", "id": 1 }"#, "composition:allOf"),
778 ];
779
780 for (kind, body, check_name) in compositions {
781 script.push_str(" {\n");
782 let url = format!("${{BASE_URL}}/conformance/composition/{}", kind);
783 let body_str = format!("'{}'", body);
784 self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
785 script.push_str(&format!(
786 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
787 check_name
788 ));
789 script.push_str(" }\n");
790 }
791
792 script.push_str(" });\n\n");
793 }
794
795 fn generate_string_formats_group(&self, script: &mut String) {
796 script.push_str(" group('String Formats', function () {\n");
797
798 let formats = [
799 ("date", r#"{ "value": "2024-01-15" }"#, "format:date"),
800 ("date-time", r#"{ "value": "2024-01-15T10:30:00Z" }"#, "format:date-time"),
801 ("email", r#"{ "value": "test@example.com" }"#, "format:email"),
802 ("uuid", r#"{ "value": "550e8400-e29b-41d4-a716-446655440000" }"#, "format:uuid"),
803 ("uri", r#"{ "value": "https://example.com/path" }"#, "format:uri"),
804 ("ipv4", r#"{ "value": "192.168.1.1" }"#, "format:ipv4"),
805 ("ipv6", r#"{ "value": "::1" }"#, "format:ipv6"),
806 ];
807
808 for (fmt, body, check_name) in formats {
809 script.push_str(" {\n");
810 let url = format!("${{BASE_URL}}/conformance/formats/{}", fmt);
811 let body_str = format!("'{}'", body);
812 self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
813 script.push_str(&format!(
814 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
815 check_name
816 ));
817 script.push_str(" }\n");
818 }
819
820 script.push_str(" });\n\n");
821 }
822
823 fn generate_constraints_group(&self, script: &mut String) {
824 script.push_str(" group('Constraints', function () {\n");
825
826 let constraints = [
827 (
828 "required",
829 "JSON.stringify({ required_field: 'present' })",
830 "constraint:required",
831 ),
832 ("optional", "JSON.stringify({})", "constraint:optional"),
833 ("minmax", "JSON.stringify({ value: 50 })", "constraint:minmax"),
834 ("pattern", "JSON.stringify({ value: 'ABC-123' })", "constraint:pattern"),
835 ("enum", "JSON.stringify({ status: 'active' })", "constraint:enum"),
836 ];
837
838 for (kind, body, check_name) in constraints {
839 script.push_str(" {\n");
840 let url = format!("${{BASE_URL}}/conformance/constraints/{}", kind);
841 self.emit_post_like(script, "post", &url, body, "JSON_HEADERS");
842 script.push_str(&format!(
843 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
844 check_name
845 ));
846 script.push_str(" }\n");
847 }
848
849 script.push_str(" });\n\n");
850 }
851
852 fn generate_response_codes_group(&self, script: &mut String) {
853 script.push_str(" group('Response Codes', function () {\n");
854
855 let codes = [
856 ("200", "response:200"),
857 ("201", "response:201"),
858 ("204", "response:204"),
859 ("400", "response:400"),
860 ("404", "response:404"),
861 ];
862
863 for (code, check_name) in codes {
864 script.push_str(" {\n");
865 let url = format!("${{BASE_URL}}/conformance/responses/{}", code);
866 self.emit_get(script, &url, None);
867 script.push_str(&format!(
868 " check(res, {{ '{}': (r) => r.status === {} }});\n",
869 check_name, code
870 ));
871 script.push_str(" }\n");
872 }
873
874 script.push_str(" });\n\n");
875 }
876
877 fn generate_http_methods_group(&self, script: &mut String) {
878 script.push_str(" group('HTTP Methods', function () {\n");
879
880 script.push_str(" {\n");
882 self.emit_get(script, "${BASE_URL}/conformance/methods", None);
883 script.push_str(
884 " check(res, { 'method:GET': (r) => r.status >= 200 && r.status < 500 });\n",
885 );
886 script.push_str(" }\n");
887
888 script.push_str(" {\n");
890 self.emit_post_like(
891 script,
892 "post",
893 "${BASE_URL}/conformance/methods",
894 "JSON.stringify({ action: 'create' })",
895 "JSON_HEADERS",
896 );
897 script.push_str(
898 " check(res, { 'method:POST': (r) => r.status >= 200 && r.status < 500 });\n",
899 );
900 script.push_str(" }\n");
901
902 script.push_str(" {\n");
904 self.emit_post_like(
905 script,
906 "put",
907 "${BASE_URL}/conformance/methods",
908 "JSON.stringify({ action: 'update' })",
909 "JSON_HEADERS",
910 );
911 script.push_str(
912 " check(res, { 'method:PUT': (r) => r.status >= 200 && r.status < 500 });\n",
913 );
914 script.push_str(" }\n");
915
916 script.push_str(" {\n");
918 self.emit_post_like(
919 script,
920 "patch",
921 "${BASE_URL}/conformance/methods",
922 "JSON.stringify({ action: 'patch' })",
923 "JSON_HEADERS",
924 );
925 script.push_str(
926 " check(res, { 'method:PATCH': (r) => r.status >= 200 && r.status < 500 });\n",
927 );
928 script.push_str(" }\n");
929
930 script.push_str(" {\n");
932 self.emit_no_body(script, "del", "${BASE_URL}/conformance/methods");
933 script.push_str(
934 " check(res, { 'method:DELETE': (r) => r.status >= 200 && r.status < 500 });\n",
935 );
936 script.push_str(" }\n");
937
938 script.push_str(" {\n");
940 self.emit_no_body(script, "head", "${BASE_URL}/conformance/methods");
941 script.push_str(
942 " check(res, { 'method:HEAD': (r) => r.status >= 200 && r.status < 500 });\n",
943 );
944 script.push_str(" }\n");
945
946 script.push_str(" {\n");
948 self.emit_no_body(script, "options", "${BASE_URL}/conformance/methods");
949 script.push_str(
950 " check(res, { 'method:OPTIONS': (r) => r.status >= 200 && r.status < 500 });\n",
951 );
952 script.push_str(" }\n");
953
954 script.push_str(" });\n\n");
955 }
956
957 fn generate_content_negotiation_group(&self, script: &mut String) {
958 script.push_str(" group('Content Types', function () {\n");
959
960 script.push_str(" {\n");
961 self.emit_get(
962 script,
963 "${BASE_URL}/conformance/content-types",
964 Some("{ 'Accept': 'application/json' }"),
965 );
966 script.push_str(
967 " check(res, { 'content:negotiation': (r) => r.status >= 200 && r.status < 500 });\n",
968 );
969 script.push_str(" }\n");
970
971 script.push_str(" });\n\n");
972 }
973
974 fn generate_security_group(&self, script: &mut String) {
975 script.push_str(" group('Security', function () {\n");
976
977 script.push_str(" {\n");
979 self.emit_get(
980 script,
981 "${BASE_URL}/conformance/security/bearer",
982 Some("{ 'Authorization': 'Bearer test-token-123' }"),
983 );
984 script.push_str(
985 " check(res, { 'security:bearer': (r) => r.status >= 200 && r.status < 500 });\n",
986 );
987 script.push_str(" }\n");
988
989 let api_key = self.config.api_key.as_deref().unwrap_or("test-api-key-123");
991 script.push_str(" {\n");
992 let api_key_hdrs = format!("{{ 'X-API-Key': '{}' }}", api_key);
993 self.emit_get(script, "${BASE_URL}/conformance/security/apikey", Some(&api_key_hdrs));
994 script.push_str(
995 " check(res, { 'security:apikey': (r) => r.status >= 200 && r.status < 500 });\n",
996 );
997 script.push_str(" }\n");
998
999 let basic_creds = self.config.basic_auth.as_deref().unwrap_or("user:pass");
1001 let encoded = base64_encode(basic_creds);
1002 script.push_str(" {\n");
1003 let basic_hdrs = format!("{{ 'Authorization': 'Basic {}' }}", encoded);
1004 self.emit_get(script, "${BASE_URL}/conformance/security/basic", Some(&basic_hdrs));
1005 script.push_str(
1006 " check(res, { 'security:basic': (r) => r.status >= 200 && r.status < 500 });\n",
1007 );
1008 script.push_str(" }\n");
1009
1010 script.push_str(" });\n\n");
1011 }
1012
1013 fn generate_handle_summary(&self, script: &mut String) {
1014 let report_path = match &self.config.output_dir {
1017 Some(dir) => {
1018 let abs = std::fs::canonicalize(dir)
1019 .unwrap_or_else(|_| dir.clone())
1020 .join("conformance-report.json");
1021 abs.to_string_lossy().to_string()
1022 }
1023 None => "conformance-report.json".to_string(),
1024 };
1025
1026 script.push_str("export function handleSummary(data) {\n");
1027 script.push_str(" // Extract check results for conformance reporting\n");
1028 script.push_str(" let checks = {};\n");
1029 script.push_str(" if (data.metrics && data.metrics.checks) {\n");
1030 script.push_str(" // Overall check pass rate\n");
1031 script.push_str(" checks.overall_pass_rate = data.metrics.checks.values.rate;\n");
1032 script.push_str(" }\n");
1033 script.push_str(" // Collect per-check results from root_group\n");
1034 script.push_str(" let checkResults = {};\n");
1035 script.push_str(" function walkGroups(group) {\n");
1036 script.push_str(" if (group.checks) {\n");
1037 script.push_str(" for (let checkObj of group.checks) {\n");
1038 script.push_str(" checkResults[checkObj.name] = {\n");
1039 script.push_str(" passes: checkObj.passes,\n");
1040 script.push_str(" fails: checkObj.fails,\n");
1041 script.push_str(" };\n");
1042 script.push_str(" }\n");
1043 script.push_str(" }\n");
1044 script.push_str(" if (group.groups) {\n");
1045 script.push_str(" for (let subGroup of group.groups) {\n");
1046 script.push_str(" walkGroups(subGroup);\n");
1047 script.push_str(" }\n");
1048 script.push_str(" }\n");
1049 script.push_str(" }\n");
1050 script.push_str(" if (data.root_group) {\n");
1051 script.push_str(" walkGroups(data.root_group);\n");
1052 script.push_str(" }\n");
1053 script.push_str(" let result = {\n");
1054 script.push_str(&format!(
1055 " '{}': JSON.stringify({{ checks: checkResults, overall: checks }}, null, 2),\n",
1056 report_path
1057 ));
1058 script.push_str(" 'summary.json': JSON.stringify(data),\n");
1059 script.push_str(" stdout: textSummary(data, { indent: ' ', enableColors: true }),\n");
1060 script.push_str(" };\n");
1061 script.push_str(" return result;\n");
1062 script.push_str("}\n\n");
1063 script.push_str("// textSummary fallback\n");
1064 script.push_str("function textSummary(data, opts) {\n");
1065 script.push_str(" return JSON.stringify(data, null, 2);\n");
1066 script.push_str("}\n");
1067 }
1068}
1069
1070fn base64_encode(input: &str) -> String {
1072 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1073 let bytes = input.as_bytes();
1074 let mut result = String::with_capacity(bytes.len().div_ceil(3) * 4);
1075 for chunk in bytes.chunks(3) {
1076 let b0 = chunk[0] as u32;
1077 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
1078 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
1079 let triple = (b0 << 16) | (b1 << 8) | b2;
1080 result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
1081 result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
1082 if chunk.len() > 1 {
1083 result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
1084 } else {
1085 result.push('=');
1086 }
1087 if chunk.len() > 2 {
1088 result.push(CHARS[(triple & 0x3F) as usize] as char);
1089 } else {
1090 result.push('=');
1091 }
1092 }
1093 result
1094}
1095
1096#[cfg(test)]
1097mod tests {
1098 use super::*;
1099
1100 #[test]
1101 fn test_generate_conformance_script() {
1102 let config = ConformanceConfig {
1103 target_url: "http://localhost:8080".to_string(),
1104 api_key: None,
1105 basic_auth: None,
1106 skip_tls_verify: false,
1107 categories: None,
1108 base_path: None,
1109 custom_headers: vec![],
1110 output_dir: None,
1111 all_operations: false,
1112 custom_checks_file: None,
1113 request_delay_ms: 0,
1114 custom_filter: None,
1115 export_requests: false,
1116 validate_requests: false,
1117 };
1118 let generator = ConformanceGenerator::new(config);
1119 let script = generator.generate().unwrap();
1120
1121 assert!(script.contains("import http from 'k6/http'"));
1122 assert!(script.contains("vus: 1"));
1123 assert!(script.contains("iterations: 1"));
1124 assert!(script.contains("group('Parameters'"));
1125 assert!(script.contains("group('Request Bodies'"));
1126 assert!(script.contains("group('Schema Types'"));
1127 assert!(script.contains("group('Composition'"));
1128 assert!(script.contains("group('String Formats'"));
1129 assert!(script.contains("group('Constraints'"));
1130 assert!(script.contains("group('Response Codes'"));
1131 assert!(script.contains("group('HTTP Methods'"));
1132 assert!(script.contains("group('Content Types'"));
1133 assert!(script.contains("group('Security'"));
1134 assert!(script.contains("handleSummary"));
1135 }
1136
1137 #[test]
1138 fn test_base64_encode() {
1139 assert_eq!(base64_encode("user:pass"), "dXNlcjpwYXNz");
1140 assert_eq!(base64_encode("a"), "YQ==");
1141 assert_eq!(base64_encode("ab"), "YWI=");
1142 assert_eq!(base64_encode("abc"), "YWJj");
1143 }
1144
1145 #[test]
1146 fn test_conformance_script_with_custom_auth() {
1147 let config = ConformanceConfig {
1148 target_url: "https://api.example.com".to_string(),
1149 api_key: Some("my-api-key".to_string()),
1150 basic_auth: Some("admin:secret".to_string()),
1151 skip_tls_verify: true,
1152 categories: None,
1153 base_path: None,
1154 custom_headers: vec![],
1155 output_dir: None,
1156 all_operations: false,
1157 custom_checks_file: None,
1158 request_delay_ms: 0,
1159 custom_filter: None,
1160 export_requests: false,
1161 validate_requests: false,
1162 };
1163 let generator = ConformanceGenerator::new(config);
1164 let script = generator.generate().unwrap();
1165
1166 assert!(script.contains("insecureSkipTLSVerify: true"));
1167 assert!(script.contains("my-api-key"));
1168 assert!(script.contains(&base64_encode("admin:secret")));
1169 }
1170
1171 #[test]
1172 fn test_should_include_category_none_includes_all() {
1173 let config = ConformanceConfig {
1174 target_url: "http://localhost:8080".to_string(),
1175 api_key: None,
1176 basic_auth: None,
1177 skip_tls_verify: false,
1178 categories: None,
1179 base_path: None,
1180 custom_headers: vec![],
1181 output_dir: None,
1182 all_operations: false,
1183 custom_checks_file: None,
1184 request_delay_ms: 0,
1185 custom_filter: None,
1186 export_requests: false,
1187 validate_requests: false,
1188 };
1189 assert!(config.should_include_category("Parameters"));
1190 assert!(config.should_include_category("Security"));
1191 assert!(config.should_include_category("Anything"));
1192 }
1193
1194 #[test]
1195 fn test_should_include_category_filtered() {
1196 let config = ConformanceConfig {
1197 target_url: "http://localhost:8080".to_string(),
1198 api_key: None,
1199 basic_auth: None,
1200 skip_tls_verify: false,
1201 categories: Some(vec!["Parameters".to_string(), "Security".to_string()]),
1202 base_path: None,
1203 custom_headers: vec![],
1204 output_dir: None,
1205 all_operations: false,
1206 custom_checks_file: None,
1207 request_delay_ms: 0,
1208 custom_filter: None,
1209 export_requests: false,
1210 validate_requests: false,
1211 };
1212 assert!(config.should_include_category("Parameters"));
1213 assert!(config.should_include_category("Security"));
1214 assert!(config.should_include_category("parameters")); assert!(!config.should_include_category("Composition"));
1216 assert!(!config.should_include_category("Schema Types"));
1217 }
1218
1219 #[test]
1220 fn test_generate_with_category_filter() {
1221 let config = ConformanceConfig {
1222 target_url: "http://localhost:8080".to_string(),
1223 api_key: None,
1224 basic_auth: None,
1225 skip_tls_verify: false,
1226 categories: Some(vec!["Parameters".to_string(), "Security".to_string()]),
1227 base_path: None,
1228 custom_headers: vec![],
1229 output_dir: None,
1230 all_operations: false,
1231 custom_checks_file: None,
1232 request_delay_ms: 0,
1233 custom_filter: None,
1234 export_requests: false,
1235 validate_requests: false,
1236 };
1237 let generator = ConformanceGenerator::new(config);
1238 let script = generator.generate().unwrap();
1239
1240 assert!(script.contains("group('Parameters'"));
1241 assert!(script.contains("group('Security'"));
1242 assert!(!script.contains("group('Request Bodies'"));
1243 assert!(!script.contains("group('Schema Types'"));
1244 assert!(!script.contains("group('Composition'"));
1245 }
1246
1247 #[test]
1248 fn test_effective_base_url_no_base_path() {
1249 let config = ConformanceConfig {
1250 target_url: "https://example.com".to_string(),
1251 api_key: None,
1252 basic_auth: None,
1253 skip_tls_verify: false,
1254 categories: None,
1255 base_path: None,
1256 custom_headers: vec![],
1257 output_dir: None,
1258 all_operations: false,
1259 custom_checks_file: None,
1260 request_delay_ms: 0,
1261 custom_filter: None,
1262 export_requests: false,
1263 validate_requests: false,
1264 };
1265 assert_eq!(config.effective_base_url(), "https://example.com");
1266 }
1267
1268 #[test]
1269 fn test_effective_base_url_with_base_path() {
1270 let config = ConformanceConfig {
1271 target_url: "https://example.com".to_string(),
1272 api_key: None,
1273 basic_auth: None,
1274 skip_tls_verify: false,
1275 categories: None,
1276 base_path: Some("/api".to_string()),
1277 custom_headers: vec![],
1278 output_dir: None,
1279 all_operations: false,
1280 custom_checks_file: None,
1281 request_delay_ms: 0,
1282 custom_filter: None,
1283 export_requests: false,
1284 validate_requests: false,
1285 };
1286 assert_eq!(config.effective_base_url(), "https://example.com/api");
1287 }
1288
1289 #[test]
1290 fn test_effective_base_url_trailing_slash_normalization() {
1291 let config = ConformanceConfig {
1292 target_url: "https://example.com/".to_string(),
1293 api_key: None,
1294 basic_auth: None,
1295 skip_tls_verify: false,
1296 categories: None,
1297 base_path: Some("/api".to_string()),
1298 custom_headers: vec![],
1299 output_dir: None,
1300 all_operations: false,
1301 custom_checks_file: None,
1302 request_delay_ms: 0,
1303 custom_filter: None,
1304 export_requests: false,
1305 validate_requests: false,
1306 };
1307 assert_eq!(config.effective_base_url(), "https://example.com/api");
1308 }
1309
1310 #[test]
1311 fn test_effective_base_url_trailing_slash_no_base_path() {
1312 let config = ConformanceConfig {
1315 target_url: "https://192.168.2.86/".to_string(),
1316 api_key: None,
1317 basic_auth: None,
1318 skip_tls_verify: false,
1319 categories: None,
1320 base_path: None,
1321 custom_headers: vec![],
1322 output_dir: None,
1323 all_operations: false,
1324 custom_checks_file: None,
1325 request_delay_ms: 0,
1326 custom_filter: None,
1327 export_requests: false,
1328 validate_requests: false,
1329 };
1330 assert_eq!(config.effective_base_url(), "https://192.168.2.86");
1331 }
1332
1333 #[test]
1334 fn test_generate_script_with_base_path() {
1335 let config = ConformanceConfig {
1336 target_url: "https://192.168.2.86".to_string(),
1337 api_key: None,
1338 basic_auth: None,
1339 skip_tls_verify: true,
1340 categories: None,
1341 base_path: Some("/api".to_string()),
1342 custom_headers: vec![],
1343 output_dir: None,
1344 all_operations: false,
1345 custom_checks_file: None,
1346 request_delay_ms: 0,
1347 custom_filter: None,
1348 export_requests: false,
1349 validate_requests: false,
1350 };
1351 let generator = ConformanceGenerator::new(config);
1352 let script = generator.generate().unwrap();
1353
1354 assert!(script.contains("const BASE_URL = 'https://192.168.2.86/api'"));
1355 assert!(script.contains("${BASE_URL}/conformance/"));
1357 }
1358
1359 #[test]
1360 fn test_generate_with_custom_headers() {
1361 let config = ConformanceConfig {
1362 target_url: "https://192.168.2.86".to_string(),
1363 api_key: None,
1364 basic_auth: None,
1365 skip_tls_verify: true,
1366 categories: Some(vec!["Parameters".to_string()]),
1367 base_path: Some("/api".to_string()),
1368 custom_headers: vec![
1369 ("X-Avi-Tenant".to_string(), "admin".to_string()),
1370 ("X-CSRFToken".to_string(), "real-token".to_string()),
1371 ],
1372 output_dir: None,
1373 all_operations: false,
1374 custom_checks_file: None,
1375 request_delay_ms: 0,
1376 custom_filter: None,
1377 export_requests: false,
1378 validate_requests: false,
1379 };
1380 let generator = ConformanceGenerator::new(config);
1381 let script = generator.generate().unwrap();
1382
1383 assert!(
1385 !script.contains("const CUSTOM_HEADERS"),
1386 "Script should NOT declare a CUSTOM_HEADERS const"
1387 );
1388 assert!(script.contains("'X-Avi-Tenant': 'admin'"));
1389 assert!(script.contains("'X-CSRFToken': 'real-token'"));
1390 }
1391
1392 #[test]
1393 fn test_custom_headers_js_object() {
1394 let config = ConformanceConfig {
1395 target_url: "http://localhost".to_string(),
1396 api_key: None,
1397 basic_auth: None,
1398 skip_tls_verify: false,
1399 categories: None,
1400 base_path: None,
1401 custom_headers: vec![
1402 ("Authorization".to_string(), "Bearer abc123".to_string()),
1403 ("X-Custom".to_string(), "value".to_string()),
1404 ],
1405 output_dir: None,
1406 all_operations: false,
1407 custom_checks_file: None,
1408 request_delay_ms: 0,
1409 custom_filter: None,
1410 export_requests: false,
1411 validate_requests: false,
1412 };
1413 let js = config.custom_headers_js_object();
1414 assert!(js.contains("'Authorization': 'Bearer abc123'"));
1415 assert!(js.contains("'X-Custom': 'value'"));
1416 }
1417}