1use crate::error::{BenchError, Result};
8use serde::Deserialize;
9use std::path::Path;
10
11#[derive(Debug, Deserialize)]
13pub struct CustomConformanceConfig {
14 pub custom_checks: Vec<CustomCheck>,
16 #[serde(default = "default_iterations")]
23 pub chain_iterations: u32,
24}
25
26fn default_iterations() -> u32 {
27 1
28}
29
30#[derive(Debug, Deserialize)]
32pub struct CustomCheck {
33 pub name: String,
35 pub path: String,
37 pub method: String,
39 pub expected_status: u16,
41 #[serde(default)]
43 pub body: Option<String>,
44 #[serde(default)]
46 pub expected_headers: std::collections::HashMap<String, String>,
47 #[serde(default)]
49 pub expected_body_fields: Vec<ExpectedBodyField>,
50 #[serde(default)]
52 pub headers: std::collections::HashMap<String, String>,
53
54 #[serde(default)]
60 pub upload: Option<UploadFile>,
61 #[serde(default)]
62 pub uploads: Vec<UploadFile>,
63
64 #[serde(default)]
69 pub extract: ExtractRules,
70
71 #[serde(default)]
77 pub repeat: Repeat,
78}
79
80#[derive(Debug, Deserialize)]
82pub struct ExpectedBodyField {
83 pub name: String,
85 #[serde(rename = "type")]
87 pub field_type: String,
88}
89
90#[derive(Debug, Clone, Deserialize)]
92pub struct UploadFile {
93 pub path: String,
95 #[serde(default = "default_upload_content_type")]
99 pub content_type: String,
100 #[serde(default = "default_upload_field_name")]
102 pub field_name: String,
103 #[serde(default)]
106 pub filename: Option<String>,
107}
108
109fn default_upload_content_type() -> String {
110 "application/octet-stream".to_string()
111}
112fn default_upload_field_name() -> String {
113 "file".to_string()
114}
115
116#[derive(Debug, Clone, Default, Deserialize)]
118pub struct ExtractRules {
119 #[serde(default)]
122 pub cookies: Vec<String>,
123 #[serde(default)]
126 pub headers: std::collections::HashMap<String, String>,
127 #[serde(default)]
130 pub body_fields: std::collections::HashMap<String, String>,
131}
132
133impl ExtractRules {
134 pub fn is_empty(&self) -> bool {
135 self.cookies.is_empty() && self.headers.is_empty() && self.body_fields.is_empty()
136 }
137}
138
139#[derive(Debug, Clone, Deserialize)]
141pub struct Repeat {
142 #[serde(default = "default_repeat_count")]
143 pub count: u32,
144 #[serde(default)]
145 pub mode: RepeatMode,
146}
147
148impl Default for Repeat {
149 fn default() -> Self {
150 Self {
151 count: 1,
152 mode: RepeatMode::default(),
153 }
154 }
155}
156
157impl Repeat {
158 pub fn is_default(&self) -> bool {
159 self.count == 1 && matches!(self.mode, RepeatMode::Sequential)
160 }
161}
162
163fn default_repeat_count() -> u32 {
164 1
165}
166
167#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
169#[serde(rename_all = "lowercase")]
170pub enum RepeatMode {
171 #[default]
172 Sequential,
173 Parallel,
174}
175
176impl CustomConformanceConfig {
177 pub fn from_file(path: &Path) -> Result<Self> {
179 let content = std::fs::read_to_string(path).map_err(|e| {
180 BenchError::Other(format!(
181 "Failed to read custom conformance file '{}': {}",
182 path.display(),
183 e
184 ))
185 })?;
186 serde_yaml::from_str(&content).map_err(|e| {
187 BenchError::Other(format!(
188 "Failed to parse custom conformance YAML '{}': {}",
189 path.display(),
190 e
191 ))
192 })
193 }
194
195 pub fn generate_k6_group(&self, base_url: &str, custom_headers: &[(String, String)]) -> String {
200 self.generate_k6_group_with_options(base_url, custom_headers, false)
201 }
202
203 pub fn emit_k6_with_options(
215 &self,
216 base_url: &str,
217 custom_headers: &[(String, String)],
218 export_requests: bool,
219 ) -> K6CustomEmit {
220 let mut init_code = String::new();
221 let mut group_body = String::new();
222 let mut upload_counter: usize = 0;
223 write_k6_group_body(
224 self,
225 base_url,
226 custom_headers,
227 export_requests,
228 &mut group_body,
229 &mut init_code,
230 &mut upload_counter,
231 );
232 K6CustomEmit {
233 init_code,
234 group_body,
235 }
236 }
237
238 pub fn generate_k6_group_with_options(
248 &self,
249 base_url: &str,
250 custom_headers: &[(String, String)],
251 export_requests: bool,
252 ) -> String {
253 let emit = self.emit_k6_with_options(base_url, custom_headers, export_requests);
254 let mut combined = String::with_capacity(emit.init_code.len() + emit.group_body.len());
259 combined.push_str(&emit.init_code);
260 combined.push_str(&emit.group_body);
261 combined
262 }
263}
264
265#[derive(Debug, Default, Clone)]
269pub struct K6CustomEmit {
270 pub init_code: String,
271 pub group_body: String,
272}
273
274fn js_escape_sq(s: &str) -> String {
279 s.replace('\\', "\\\\")
280 .replace('\'', "\\'")
281 .replace('\n', "\\n")
282 .replace('\r', "\\r")
283 .replace('\t', "\\t")
284}
285
286#[allow(dead_code)]
294fn js_escape_tpl(s: &str) -> String {
295 s.replace('\\', "\\\\").replace('`', "\\`").replace("${", "\\${")
296}
297
298fn substitute_chain_tokens(text: &str) -> String {
309 let mut out = String::with_capacity(text.len());
310 let mut rest = text;
311 while let Some(start) = rest.find("${") {
312 for c in rest[..start].chars() {
314 match c {
315 '\\' => out.push_str("\\\\"),
316 '`' => out.push_str("\\`"),
317 other => out.push(other),
318 }
319 }
320 let after = &rest[start + 2..];
321 if let Some(end) = after.find('}') {
322 let token = &after[..end];
323 let replacement = if let Some(name) = token.strip_prefix("var:") {
324 Some(format!("${{__ctx_var_{}}}", sanitize_js_ident(name)))
325 } else if let Some(name) = token.strip_prefix("cookie:") {
326 Some(format!("${{__ctx_cookie_{}}}", sanitize_js_ident(name)))
327 } else {
328 token
329 .strip_prefix("header:")
330 .map(|name| format!("${{__ctx_var_{}}}", sanitize_js_ident(name)))
331 };
332 if let Some(replacement) = replacement {
333 out.push_str(&replacement);
336 } else {
337 out.push_str("\\${");
341 out.push_str(token);
342 out.push('}');
343 }
344 rest = &after[end + 1..];
345 } else {
346 out.push_str("\\${");
347 rest = after;
348 break;
349 }
350 }
351 for c in rest.chars() {
353 match c {
354 '\\' => out.push_str("\\\\"),
355 '`' => out.push_str("\\`"),
356 other => out.push(other),
357 }
358 }
359 out
360}
361
362fn sanitize_js_ident(name: &str) -> String {
366 name.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect()
367}
368
369fn build_headers_object_js(all_headers: &[(String, String)], dynamic: bool) -> String {
376 if all_headers.is_empty() {
377 return "{}".to_string();
378 }
379 let entries: Vec<String> = all_headers
380 .iter()
381 .map(|(k, v)| {
382 if dynamic {
383 let substituted = substitute_chain_tokens(v);
384 format!("'{}': `{}`", js_escape_sq(k), substituted)
390 } else {
391 format!("'{}': '{}'", js_escape_sq(k), js_escape_sq(v))
392 }
393 })
394 .collect();
395 format!("{{ {} }}", entries.join(", "))
396}
397
398fn collect_referenced_ctx_idents(
406 config: &CustomConformanceConfig,
407) -> (std::collections::BTreeSet<String>, std::collections::BTreeSet<String>) {
408 let mut vars = std::collections::BTreeSet::new();
409 let mut cookies = std::collections::BTreeSet::new();
410 let walk = |s: &str,
411 vars: &mut std::collections::BTreeSet<String>,
412 cookies: &mut std::collections::BTreeSet<String>| {
413 let mut rest = s;
414 while let Some(start) = rest.find("${") {
415 let after = &rest[start + 2..];
416 if let Some(end) = after.find('}') {
417 let token = &after[..end];
418 if let Some(name) =
419 token.strip_prefix("var:").or_else(|| token.strip_prefix("header:"))
420 {
421 vars.insert(sanitize_js_ident(name));
422 } else if let Some(name) = token.strip_prefix("cookie:") {
423 cookies.insert(sanitize_js_ident(name));
424 }
425 rest = &after[end + 1..];
426 } else {
427 break;
428 }
429 }
430 };
431 for check in &config.custom_checks {
432 walk(&check.path, &mut vars, &mut cookies);
433 for v in check.headers.values() {
434 walk(v, &mut vars, &mut cookies);
435 }
436 if let Some(b) = &check.body {
437 walk(b, &mut vars, &mut cookies);
438 }
439 for var_name in check.extract.headers.keys() {
445 vars.insert(sanitize_js_ident(var_name));
446 }
447 for var_name in check.extract.body_fields.keys() {
448 vars.insert(sanitize_js_ident(var_name));
449 }
450 for cookie_name in &check.extract.cookies {
451 cookies.insert(sanitize_js_ident(cookie_name));
452 }
453 }
454 (vars, cookies)
455}
456
457#[allow(clippy::too_many_arguments)]
464fn write_k6_group_body(
465 config: &CustomConformanceConfig,
466 base_url: &str,
467 custom_headers: &[(String, String)],
468 export_requests: bool,
469 group_body: &mut String,
470 init_code: &mut String,
471 upload_counter: &mut usize,
472) {
473 let uses_cookie_substitution = config.custom_checks.iter().any(|c| {
490 !c.extract.cookies.is_empty()
491 || c.headers.values().any(|v| v.contains("${cookie:") || v.contains("${var:"))
492 });
493 if uses_cookie_substitution {
494 init_code.push_str(
495 "// Round 41 (#79) — declared once so every chain request can reuse it;\n\
496 // a fresh empty jar suppresses k6's auto-injected Set-Cookie that would\n\
497 // otherwise duplicate the explicit `${cookie:NAME}` substitution.\n\
498 const __custom_jar_factory = () => new http.CookieJar();\n",
499 );
500 }
501 group_body.push_str(" group('Custom', function () {\n");
502 let iters = config.chain_iterations.max(1);
503 if iters > 1 {
504 group_body
505 .push_str(&format!(" for (let __iter = 0; __iter < {}; __iter++) {{\n", iters));
506 }
507
508 let (ctx_vars, ctx_cookies) = collect_referenced_ctx_idents(config);
519 let needs_ctx = iters > 1 || !ctx_vars.is_empty() || !ctx_cookies.is_empty();
520 if needs_ctx {
521 group_body.push_str(" // Round 39 chain context — pre-declared so ${var:X}/${cookie:X} substitutions never ReferenceError\n");
522 for var in &ctx_vars {
523 group_body.push_str(&format!(" let __ctx_var_{} = '';\n", var));
524 }
525 for cookie in &ctx_cookies {
526 group_body.push_str(&format!(" let __ctx_cookie_{} = '';\n", cookie));
527 }
528 }
529
530 for (check_idx, check) in config.custom_checks.iter().enumerate() {
531 group_body.push_str(" {\n");
532
533 let mut all_headers: Vec<(String, String)> = Vec::new();
536 for (k, v) in &check.headers {
537 all_headers.push((k.clone(), v.clone()));
538 }
539 for (k, v) in custom_headers {
540 if !check.headers.contains_key(k) {
541 all_headers.push((k.clone(), v.clone()));
542 }
543 }
544 let is_upload = check.upload.is_some() || !check.uploads.is_empty();
548 if check.body.is_some()
549 && !is_upload
550 && !all_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
551 {
552 all_headers.push(("Content-Type".to_string(), "application/json".to_string()));
553 }
554
555 let headers_js = build_headers_object_js(&all_headers, needs_ctx);
556 let params_js = if uses_cookie_substitution {
561 format!("{{ headers: {}, jar: __custom_jar_factory() }}", headers_js)
562 } else {
563 format!("{{ headers: {} }}", headers_js)
564 };
565 let method = check.method.to_uppercase();
566 let url_substituted = substitute_chain_tokens(&check.path);
569 let url = format!("${{{}}}{}", base_url, url_substituted);
570 let escaped_name = check.name.replace('\'', "\\'");
571
572 let upload_specs: Vec<&UploadFile> =
579 check.upload.iter().chain(check.uploads.iter()).collect();
580 let form_var = if !upload_specs.is_empty() {
581 let mut form_entries: Vec<String> = Vec::with_capacity(upload_specs.len());
582 for spec in &upload_specs {
583 let var = format!("__file_{}", *upload_counter);
584 *upload_counter += 1;
585 let filename = spec.filename.clone().unwrap_or_else(|| {
586 Path::new(&spec.path)
587 .file_name()
588 .and_then(|n| n.to_str())
589 .unwrap_or("upload.bin")
590 .to_string()
591 });
592 init_code.push_str(&format!(
593 "// Round 39 #79 — preload upload file for `{}`\nconst {} = open('{}', 'b');\n",
594 check.name,
595 var,
596 js_escape_sq(&spec.path),
597 ));
598 form_entries.push(format!(
599 "'{}': http.file({}, '{}', '{}')",
600 js_escape_sq(&spec.field_name),
601 var,
602 js_escape_sq(&filename),
603 js_escape_sq(&spec.content_type),
604 ));
605 }
606 let form_name = format!("__form_{}", check_idx);
607 group_body.push_str(&format!(
608 " let {} = {{ {} }};\n",
609 form_name,
610 form_entries.join(", ")
611 ));
612 let summary_entries: Vec<String> = upload_specs
624 .iter()
625 .map(|spec| {
626 let filename = spec.filename.clone().unwrap_or_else(|| {
627 Path::new(&spec.path)
628 .file_name()
629 .and_then(|n| n.to_str())
630 .unwrap_or("upload.bin")
631 .to_string()
632 });
633 format!(
634 "'{}':'{}' ({})",
635 js_escape_sq(&spec.field_name),
636 js_escape_sq(&filename),
637 js_escape_sq(&spec.content_type)
638 )
639 })
640 .collect();
641 group_body.push_str(&format!(
642 " console.log('MOCKFORGE_UPLOAD_PARTS: {} {} files: {}');\n",
643 js_escape_sq(&check.name),
644 upload_specs.len(),
645 js_escape_sq(&summary_entries.join(", ")),
646 ));
647 Some(form_name)
648 } else {
649 None
650 };
651
652 let count = check.repeat.count.max(1);
655 let is_parallel = count > 1 && matches!(check.repeat.mode, RepeatMode::Parallel);
656 let body_expr = match &check.body {
657 Some(b) => {
658 let substituted = substitute_chain_tokens(b);
659 format!("`{}`", substituted)
661 }
662 None => "null".to_string(),
663 };
664
665 if let Some(form_name) = &form_var {
666 if check.body.is_some() {
669 group_body.push_str(&format!(
670 " // warning: custom check '{}' has both `body` and `upload`/`uploads`; ignoring body\n",
671 check.name
672 ));
673 }
674 if is_parallel {
676 group_body.push_str(&format!(
677 " let __batch_{} = []; for (let __r = 0; __r < {}; __r++) {{ __batch_{}.push({{ method: 'POST', url: `{}`, body: {}, params: {} }}); }}\n",
678 check_idx, count, check_idx, url, form_name, params_js
679 ));
680 group_body.push_str(&format!(
681 " let __responses_{} = http.batch(__batch_{});\n",
682 check_idx, check_idx
683 ));
684 group_body.push_str(&format!(" let res = __responses_{}[0];\n", check_idx));
685 emit_check_assertions(group_body, &escaped_name, check, export_requests);
686 group_body.push_str(&format!(
687 " for (let __i = 1; __i < __responses_{}.length; __i++) {{ let res = __responses_{}[__i];",
688 check_idx, check_idx
689 ));
690 emit_check_assertions(group_body, &escaped_name, check, export_requests);
691 group_body.push_str(" }\n");
692 } else if count > 1 {
693 group_body
694 .push_str(&format!(" for (let __r = 0; __r < {}; __r++) {{\n", count));
695 group_body.push_str(&format!(
696 " let res = http.post(`{}`, {}, {});\n",
697 url, form_name, params_js
698 ));
699 emit_check_assertions(group_body, &escaped_name, check, export_requests);
700 group_body.push_str(" }\n");
701 } else {
702 group_body.push_str(&format!(
703 " let res = http.post(`{}`, {}, {});\n",
704 url, form_name, params_js
705 ));
706 emit_check_assertions(group_body, &escaped_name, check, export_requests);
707 }
708 } else {
709 let k6_method = match method.as_str() {
711 "DELETE" => "del".to_string(),
712 other => other.to_lowercase(),
713 };
714 let body_method = !matches!(method.as_str(), "GET" | "HEAD" | "OPTIONS" | "DELETE");
715 let request_call = if body_method {
721 format!("http.{}(`{}`, {}, {})", k6_method, url, body_expr, params_js)
722 } else if all_headers.is_empty() && !uses_cookie_substitution {
723 format!("http.{}(`{}`)", k6_method, url)
724 } else {
725 format!("http.{}(`{}`, {})", k6_method, url, params_js)
726 };
727
728 if is_parallel {
729 let entry_method = match method.as_str() {
730 "DELETE" => "DELETE",
731 "GET" => "GET",
732 "HEAD" => "HEAD",
733 "OPTIONS" => "OPTIONS",
734 "PUT" => "PUT",
735 "PATCH" => "PATCH",
736 "POST" => "POST",
737 _ => "POST",
738 };
739 let body_field = if body_method {
740 format!("body: {}, ", body_expr)
741 } else {
742 String::new()
743 };
744 group_body.push_str(&format!(
745 " let __batch_{} = []; for (let __r = 0; __r < {}; __r++) {{ __batch_{}.push({{ method: '{}', url: `{}`, {}params: {} }}); }}\n",
746 check_idx, count, check_idx, entry_method, url, body_field, params_js
747 ));
748 group_body.push_str(&format!(
749 " let __responses_{} = http.batch(__batch_{});\n",
750 check_idx, check_idx
751 ));
752 group_body.push_str(&format!(" let res = __responses_{}[0];\n", check_idx));
753 emit_check_assertions(group_body, &escaped_name, check, export_requests);
754 group_body.push_str(&format!(
755 " for (let __i = 1; __i < __responses_{}.length; __i++) {{ let res = __responses_{}[__i];",
756 check_idx, check_idx
757 ));
758 emit_check_assertions(group_body, &escaped_name, check, export_requests);
759 group_body.push_str(" }\n");
760 } else if count > 1 {
761 group_body
762 .push_str(&format!(" for (let __r = 0; __r < {}; __r++) {{\n", count));
763 group_body.push_str(&format!(" let res = {};\n", request_call));
764 emit_check_assertions(group_body, &escaped_name, check, export_requests);
765 group_body.push_str(" }\n");
766 } else {
767 group_body.push_str(&format!(" let res = {};\n", request_call));
768 emit_check_assertions(group_body, &escaped_name, check, export_requests);
769 }
770 }
771
772 if !check.extract.is_empty() {
778 emit_chain_extract(group_body, &check.extract);
779 }
780
781 group_body.push_str(" }\n");
782 }
783
784 if iters > 1 {
785 group_body.push_str(" }\n");
786 }
787
788 group_body.push_str(" });\n\n");
789}
790
791fn emit_check_assertions(
795 group_body: &mut String,
796 escaped_name: &str,
797 check: &CustomCheck,
798 export_requests: bool,
799) {
800 if export_requests {
801 group_body.push_str(&format!(
802 " if (typeof __captureExchange === 'function') __captureExchange('{}', res);\n",
803 escaped_name
804 ));
805 }
806 group_body.push_str(&format!(
807 " {{ let ok = check(res, {{ '{}': (r) => r.status === {} }}); if (!ok) __captureFailure('{}', res, 'status === {}'); }}\n",
808 escaped_name, check.expected_status, escaped_name, check.expected_status
809 ));
810 for (header_name, pattern) in &check.expected_headers {
811 let header_check_name = format!("{}:header:{}", escaped_name, header_name);
812 let escaped_pattern = js_escape_sq(pattern);
813 let header_lower = header_name.to_lowercase();
814 group_body.push_str(&format!(
815 " {{ let ok = check(res, {{ '{}': (r) => {{ const _hk = Object.keys(r.headers || {{}}).find(k => k.toLowerCase() === '{}'); return new RegExp('{}').test(_hk ? r.headers[_hk] : ''); }} }}); if (!ok) __captureFailure('{}', res, 'header {} matches /{}/'); }}\n",
816 header_check_name, header_lower, escaped_pattern, header_check_name, header_name, escaped_pattern
817 ));
818 }
819 for field in &check.expected_body_fields {
820 let field_check_name = format!("{}:body:{}:{}", escaped_name, field.name, field.field_type);
821 let accessor = generate_field_accessor(&field.name);
822 let type_check = match field.field_type.as_str() {
823 "string" => format!("typeof ({}) === 'string'", accessor),
824 "integer" => format!("Number.isInteger({})", accessor),
825 "number" => format!("typeof ({}) === 'number'", accessor),
826 "boolean" => format!("typeof ({}) === 'boolean'", accessor),
827 "array" => format!("Array.isArray({})", accessor),
828 "object" => {
829 format!("typeof ({}) === 'object' && !Array.isArray({})", accessor, accessor)
830 }
831 _ => format!("({}) !== undefined", accessor),
832 };
833 group_body.push_str(&format!(
834 " {{ let ok = check(res, {{ '{}': (r) => {{ try {{ return {}; }} catch(e) {{ return false; }} }} }}); if (!ok) __captureFailure('{}', res, 'body field {} is {}'); }}\n",
835 field_check_name, type_check, field_check_name, field.name, field.field_type
836 ));
837 }
838}
839
840fn emit_chain_extract(group_body: &mut String, rules: &ExtractRules) {
846 for cookie_name in &rules.cookies {
847 let var = sanitize_js_ident(cookie_name);
848 group_body.push_str(&format!(
849 " if (res.cookies && res.cookies['{}'] && res.cookies['{}'][0]) {{ __ctx_cookie_{} = res.cookies['{}'][0].value; }}\n",
850 js_escape_sq(cookie_name),
851 js_escape_sq(cookie_name),
852 var,
853 js_escape_sq(cookie_name),
854 ));
855 }
856 for (var_name, header_name) in &rules.headers {
857 let var = sanitize_js_ident(var_name);
858 let header_lower = header_name.to_lowercase();
859 group_body.push_str(&format!(
860 " {{ const _hk = Object.keys(res.headers || {{}}).find(k => k.toLowerCase() === '{}'); if (_hk) {{ __ctx_var_{} = res.headers[_hk]; }} }}\n",
861 js_escape_sq(&header_lower), var
862 ));
863 }
864 if !rules.body_fields.is_empty() {
865 group_body.push_str(" try { let __body_json = JSON.parse(res.body || 'null');\n");
866 for (var_name, dotted) in &rules.body_fields {
867 let var = sanitize_js_ident(var_name);
868 let segments: Vec<String> =
871 dotted.split('.').map(|s| format!("['{}']", js_escape_sq(s))).collect();
872 let accessor = format!("__body_json{}", segments.join(""));
873 group_body.push_str(&format!(
874 " try {{ const __v = {}; if (__v !== undefined && __v !== null) __ctx_var_{} = String(__v); }} catch(e) {{}}\n",
875 accessor, var
876 ));
877 }
878 group_body.push_str(" } catch(e) {}\n");
879 }
880 let _ = group_body;
886}
887
888fn generate_field_accessor(field_name: &str) -> String {
895 let parts: Vec<&str> = field_name.split('.').collect();
897 let mut expr = String::from("JSON.parse(r.body)");
898
899 for part in &parts {
900 if let Some(arr_name) = part.strip_suffix("[]") {
901 expr.push_str(&format!("['{}'][0]", arr_name));
903 } else {
904 expr.push_str(&format!("['{}']", part));
905 }
906 }
907
908 expr
909}
910
911#[cfg(test)]
912mod tests {
913 use super::*;
914
915 #[test]
916 fn test_parse_custom_yaml() {
917 let yaml = r#"
918custom_checks:
919 - name: "custom:pets-returns-200"
920 path: /pets
921 method: GET
922 expected_status: 200
923 - name: "custom:create-product"
924 path: /api/products
925 method: POST
926 expected_status: 201
927 body: '{"sku": "TEST-001", "name": "Test"}'
928 expected_body_fields:
929 - name: id
930 type: integer
931 expected_headers:
932 content-type: "application/json"
933"#;
934 let config: CustomConformanceConfig = serde_yaml::from_str(yaml).unwrap();
935 assert_eq!(config.custom_checks.len(), 2);
936 assert_eq!(config.custom_checks[0].name, "custom:pets-returns-200");
937 assert_eq!(config.custom_checks[0].expected_status, 200);
938 assert_eq!(config.custom_checks[1].expected_body_fields.len(), 1);
939 assert_eq!(config.custom_checks[1].expected_body_fields[0].name, "id");
940 assert_eq!(config.custom_checks[1].expected_body_fields[0].field_type, "integer");
941 }
942
943 #[test]
944 fn test_generate_k6_group_get() {
945 let config = CustomConformanceConfig {
946 custom_checks: vec![CustomCheck {
947 name: "custom:test-get".to_string(),
948 path: "/api/test".to_string(),
949 method: "GET".to_string(),
950 expected_status: 200,
951 body: None,
952 expected_headers: std::collections::HashMap::new(),
953 expected_body_fields: vec![],
954 headers: std::collections::HashMap::new(),
955 upload: None,
956 uploads: vec![],
957 extract: ExtractRules::default(),
958 repeat: Repeat::default(),
959 }],
960 chain_iterations: 1,
961 };
962
963 let script = config.generate_k6_group("BASE_URL", &[]);
964 assert!(script.contains("group('Custom'"));
965 assert!(script.contains("http.get(`${BASE_URL}/api/test`)"));
966 assert!(script.contains("'custom:test-get': (r) => r.status === 200"));
967 }
968
969 #[test]
970 fn test_generate_k6_group_post_with_body() {
971 let config = CustomConformanceConfig {
972 custom_checks: vec![CustomCheck {
973 name: "custom:create".to_string(),
974 path: "/api/items".to_string(),
975 method: "POST".to_string(),
976 expected_status: 201,
977 body: Some(r#"{"name": "test"}"#.to_string()),
978 expected_headers: std::collections::HashMap::new(),
979 expected_body_fields: vec![ExpectedBodyField {
980 name: "id".to_string(),
981 field_type: "integer".to_string(),
982 }],
983 headers: std::collections::HashMap::new(),
984 upload: None,
985 uploads: vec![],
986 extract: ExtractRules::default(),
987 repeat: Repeat::default(),
988 }],
989 chain_iterations: 1,
990 };
991
992 let script = config.generate_k6_group("BASE_URL", &[]);
993 assert!(script.contains("http.post("));
994 assert!(script.contains("'custom:create': (r) => r.status === 201"));
995 assert!(script.contains("custom:create:body:id:integer"));
996 assert!(script.contains("Number.isInteger"));
997 }
998
999 #[test]
1000 fn test_generate_k6_group_with_header_checks() {
1001 let mut expected_headers = std::collections::HashMap::new();
1002 expected_headers.insert("content-type".to_string(), "application/json".to_string());
1003
1004 let config = CustomConformanceConfig {
1005 custom_checks: vec![CustomCheck {
1006 name: "custom:header-check".to_string(),
1007 path: "/api/test".to_string(),
1008 method: "GET".to_string(),
1009 expected_status: 200,
1010 body: None,
1011 expected_headers,
1012 expected_body_fields: vec![],
1013 headers: std::collections::HashMap::new(),
1014 upload: None,
1015 uploads: vec![],
1016 extract: ExtractRules::default(),
1017 repeat: Repeat::default(),
1018 }],
1019 chain_iterations: 1,
1020 };
1021
1022 let script = config.generate_k6_group("BASE_URL", &[]);
1023 assert!(script.contains("custom:header-check:header:content-type"));
1024 assert!(script.contains("new RegExp('application/json')"));
1025 }
1026
1027 #[test]
1028 fn test_generate_k6_group_with_custom_headers() {
1029 let config = CustomConformanceConfig {
1030 custom_checks: vec![CustomCheck {
1031 name: "custom:auth-test".to_string(),
1032 path: "/api/secure".to_string(),
1033 method: "GET".to_string(),
1034 expected_status: 200,
1035 body: None,
1036 expected_headers: std::collections::HashMap::new(),
1037 expected_body_fields: vec![],
1038 headers: std::collections::HashMap::new(),
1039 upload: None,
1040 uploads: vec![],
1041 extract: ExtractRules::default(),
1042 repeat: Repeat::default(),
1043 }],
1044 chain_iterations: 1,
1045 };
1046
1047 let custom_headers = vec![("Authorization".to_string(), "Bearer token123".to_string())];
1048 let script = config.generate_k6_group("BASE_URL", &custom_headers);
1049 assert!(script.contains("'Authorization': 'Bearer token123'"));
1050 }
1051
1052 #[test]
1053 fn test_failure_capture_emitted() {
1054 let config = CustomConformanceConfig {
1055 custom_checks: vec![CustomCheck {
1056 name: "custom:capture-test".to_string(),
1057 path: "/api/test".to_string(),
1058 method: "GET".to_string(),
1059 expected_status: 200,
1060 body: None,
1061 expected_headers: {
1062 let mut m = std::collections::HashMap::new();
1063 m.insert("X-Rate-Limit".to_string(), ".*".to_string());
1064 m
1065 },
1066 expected_body_fields: vec![ExpectedBodyField {
1067 name: "id".to_string(),
1068 field_type: "integer".to_string(),
1069 }],
1070 headers: std::collections::HashMap::new(),
1071 upload: None,
1072 uploads: vec![],
1073 extract: ExtractRules::default(),
1074 repeat: Repeat::default(),
1075 }],
1076 chain_iterations: 1,
1077 };
1078
1079 let script = config.generate_k6_group("BASE_URL", &[]);
1080 assert!(
1082 script.contains("__captureFailure('custom:capture-test', res, 'status === 200')"),
1083 "Status check should emit __captureFailure"
1084 );
1085 assert!(
1087 script.contains("__captureFailure('custom:capture-test:header:X-Rate-Limit'"),
1088 "Header check should emit __captureFailure"
1089 );
1090 assert!(
1092 script.contains("__captureFailure('custom:capture-test:body:id:integer'"),
1093 "Body field check should emit __captureFailure"
1094 );
1095 }
1096
1097 #[test]
1098 fn test_from_file_nonexistent() {
1099 let result = CustomConformanceConfig::from_file(Path::new("/nonexistent/file.yaml"));
1100 assert!(result.is_err());
1101 let err = result.unwrap_err().to_string();
1102 assert!(err.contains("Failed to read custom conformance file"));
1103 }
1104
1105 #[test]
1106 fn test_generate_k6_group_delete() {
1107 let config = CustomConformanceConfig {
1108 custom_checks: vec![CustomCheck {
1109 name: "custom:delete-item".to_string(),
1110 path: "/api/items/1".to_string(),
1111 method: "DELETE".to_string(),
1112 expected_status: 204,
1113 body: None,
1114 expected_headers: std::collections::HashMap::new(),
1115 expected_body_fields: vec![],
1116 headers: std::collections::HashMap::new(),
1117 upload: None,
1118 uploads: vec![],
1119 extract: ExtractRules::default(),
1120 repeat: Repeat::default(),
1121 }],
1122 chain_iterations: 1,
1123 };
1124
1125 let script = config.generate_k6_group("BASE_URL", &[]);
1126 assert!(script.contains("http.del("));
1127 assert!(script.contains("r.status === 204"));
1128 }
1129
1130 #[test]
1131 fn test_field_accessor_simple() {
1132 assert_eq!(generate_field_accessor("name"), "JSON.parse(r.body)['name']");
1133 }
1134
1135 #[test]
1136 fn test_field_accessor_nested_dot() {
1137 assert_eq!(
1138 generate_field_accessor("config.enabled"),
1139 "JSON.parse(r.body)['config']['enabled']"
1140 );
1141 }
1142
1143 #[test]
1144 fn test_field_accessor_array_bracket() {
1145 assert_eq!(generate_field_accessor("items[].id"), "JSON.parse(r.body)['items'][0]['id']");
1146 }
1147
1148 #[test]
1149 fn test_field_accessor_deep_nested() {
1150 assert_eq!(generate_field_accessor("a.b.c"), "JSON.parse(r.body)['a']['b']['c']");
1151 }
1152
1153 #[test]
1154 fn test_generate_k6_nested_body_fields() {
1155 let config = CustomConformanceConfig {
1156 custom_checks: vec![CustomCheck {
1157 name: "custom:nested".to_string(),
1158 path: "/api/data".to_string(),
1159 method: "GET".to_string(),
1160 expected_status: 200,
1161 body: None,
1162 expected_headers: std::collections::HashMap::new(),
1163 expected_body_fields: vec![
1164 ExpectedBodyField {
1165 name: "count".to_string(),
1166 field_type: "integer".to_string(),
1167 },
1168 ExpectedBodyField {
1169 name: "results[].name".to_string(),
1170 field_type: "string".to_string(),
1171 },
1172 ],
1173 headers: std::collections::HashMap::new(),
1174 upload: None,
1175 uploads: vec![],
1176 extract: ExtractRules::default(),
1177 repeat: Repeat::default(),
1178 }],
1179 chain_iterations: 1,
1180 };
1181
1182 let script = config.generate_k6_group("BASE_URL", &[]);
1183 assert!(script.contains("JSON.parse(r.body)['count']"));
1185 assert!(script.contains("JSON.parse(r.body)['results'][0]['name']"));
1187 }
1188}