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