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
504 let (ctx_vars, ctx_cookies) = collect_referenced_ctx_idents(config);
527 let needs_ctx = iters > 1 || !ctx_vars.is_empty() || !ctx_cookies.is_empty();
528 if needs_ctx {
529 group_body.push_str(" // Round 44 chain context — pre-declared at GROUP scope (outside the iter loop) so captures from iteration N persist into iteration N+1\n");
530 for var in &ctx_vars {
531 group_body.push_str(&format!(" let __ctx_var_{} = '';\n", var));
532 }
533 for cookie in &ctx_cookies {
534 group_body.push_str(&format!(" let __ctx_cookie_{} = '';\n", cookie));
535 }
536 }
537
538 if iters > 1 {
539 group_body
540 .push_str(&format!(" for (let __iter = 0; __iter < {}; __iter++) {{\n", iters));
541 }
542
543 for (check_idx, check) in config.custom_checks.iter().enumerate() {
544 group_body.push_str(" {\n");
545
546 let mut all_headers: Vec<(String, String)> = Vec::new();
549 for (k, v) in &check.headers {
550 all_headers.push((k.clone(), v.clone()));
551 }
552 for (k, v) in custom_headers {
553 if !check.headers.contains_key(k) {
554 all_headers.push((k.clone(), v.clone()));
555 }
556 }
557 let is_upload = check.upload.is_some() || !check.uploads.is_empty();
561 if check.body.is_some()
562 && !is_upload
563 && !all_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
564 {
565 all_headers.push(("Content-Type".to_string(), "application/json".to_string()));
566 }
567
568 let headers_js = build_headers_object_js(&all_headers, needs_ctx);
569 let params_js = if uses_cookie_substitution {
574 format!("{{ headers: {}, jar: __custom_jar_factory() }}", headers_js)
575 } else {
576 format!("{{ headers: {} }}", headers_js)
577 };
578 let method = check.method.to_uppercase();
579 let url_substituted = substitute_chain_tokens(&check.path);
582 let url = format!("${{{}}}{}", base_url, url_substituted);
583 let escaped_name = check.name.replace('\'', "\\'");
584
585 let upload_specs: Vec<&UploadFile> =
592 check.upload.iter().chain(check.uploads.iter()).collect();
593 let form_var = if !upload_specs.is_empty() {
594 let mut form_entries: Vec<String> = Vec::with_capacity(upload_specs.len());
595 for spec in &upload_specs {
596 let var = format!("__file_{}", *upload_counter);
597 *upload_counter += 1;
598 let filename = spec.filename.clone().unwrap_or_else(|| {
599 Path::new(&spec.path)
600 .file_name()
601 .and_then(|n| n.to_str())
602 .unwrap_or("upload.bin")
603 .to_string()
604 });
605 init_code.push_str(&format!(
606 "// Round 39 #79 — preload upload file for `{}`\nconst {} = open('{}', 'b');\n",
607 check.name,
608 var,
609 js_escape_sq(&spec.path),
610 ));
611 form_entries.push(format!(
612 "'{}': http.file({}, '{}', '{}')",
613 js_escape_sq(&spec.field_name),
614 var,
615 js_escape_sq(&filename),
616 js_escape_sq(&spec.content_type),
617 ));
618 }
619 let form_name = format!("__form_{}", check_idx);
620 group_body.push_str(&format!(
621 " let {} = {{ {} }};\n",
622 form_name,
623 form_entries.join(", ")
624 ));
625 let summary_entries: Vec<String> = upload_specs
637 .iter()
638 .map(|spec| {
639 let filename = spec.filename.clone().unwrap_or_else(|| {
640 Path::new(&spec.path)
641 .file_name()
642 .and_then(|n| n.to_str())
643 .unwrap_or("upload.bin")
644 .to_string()
645 });
646 format!(
647 "'{}':'{}' ({})",
648 js_escape_sq(&spec.field_name),
649 js_escape_sq(&filename),
650 js_escape_sq(&spec.content_type)
651 )
652 })
653 .collect();
654 group_body.push_str(&format!(
655 " console.log('MOCKFORGE_UPLOAD_PARTS: {} {} files: {}');\n",
656 js_escape_sq(&check.name),
657 upload_specs.len(),
658 js_escape_sq(&summary_entries.join(", ")),
659 ));
660 Some(form_name)
661 } else {
662 None
663 };
664
665 let count = check.repeat.count.max(1);
668 let is_parallel = count > 1 && matches!(check.repeat.mode, RepeatMode::Parallel);
669 let body_expr = match &check.body {
670 Some(b) => {
671 let substituted = substitute_chain_tokens(b);
672 format!("`{}`", substituted)
674 }
675 None => "null".to_string(),
676 };
677
678 if let Some(form_name) = &form_var {
679 if check.body.is_some() {
682 group_body.push_str(&format!(
683 " // warning: custom check '{}' has both `body` and `upload`/`uploads`; ignoring body\n",
684 check.name
685 ));
686 }
687 if is_parallel {
689 group_body.push_str(&format!(
690 " let __batch_{} = []; for (let __r = 0; __r < {}; __r++) {{ __batch_{}.push({{ method: 'POST', url: `{}`, body: {}, params: {} }}); }}\n",
691 check_idx, count, check_idx, url, form_name, params_js
692 ));
693 group_body.push_str(&format!(
694 " let __responses_{} = http.batch(__batch_{});\n",
695 check_idx, check_idx
696 ));
697 group_body.push_str(&format!(" let res = __responses_{}[0];\n", check_idx));
698 emit_check_assertions(group_body, &escaped_name, check, export_requests);
699 group_body.push_str(&format!(
700 " for (let __i = 1; __i < __responses_{}.length; __i++) {{ let res = __responses_{}[__i];",
701 check_idx, check_idx
702 ));
703 emit_check_assertions(group_body, &escaped_name, check, export_requests);
704 group_body.push_str(" }\n");
705 } else if count > 1 {
706 group_body
707 .push_str(&format!(" for (let __r = 0; __r < {}; __r++) {{\n", count));
708 group_body.push_str(&format!(
709 " let res = http.post(`{}`, {}, {});\n",
710 url, form_name, params_js
711 ));
712 emit_check_assertions(group_body, &escaped_name, check, export_requests);
713 group_body.push_str(" }\n");
714 } else {
715 group_body.push_str(&format!(
716 " let res = http.post(`{}`, {}, {});\n",
717 url, form_name, params_js
718 ));
719 emit_check_assertions(group_body, &escaped_name, check, export_requests);
720 }
721 } else {
722 let k6_method = match method.as_str() {
724 "DELETE" => "del".to_string(),
725 other => other.to_lowercase(),
726 };
727 let body_method = !matches!(method.as_str(), "GET" | "HEAD" | "OPTIONS" | "DELETE");
728 let request_call = if body_method {
734 format!("http.{}(`{}`, {}, {})", k6_method, url, body_expr, params_js)
735 } else if all_headers.is_empty() && !uses_cookie_substitution {
736 format!("http.{}(`{}`)", k6_method, url)
737 } else {
738 format!("http.{}(`{}`, {})", k6_method, url, params_js)
739 };
740
741 if is_parallel {
742 let entry_method = match method.as_str() {
743 "DELETE" => "DELETE",
744 "GET" => "GET",
745 "HEAD" => "HEAD",
746 "OPTIONS" => "OPTIONS",
747 "PUT" => "PUT",
748 "PATCH" => "PATCH",
749 "POST" => "POST",
750 _ => "POST",
751 };
752 let body_field = if body_method {
753 format!("body: {}, ", body_expr)
754 } else {
755 String::new()
756 };
757 group_body.push_str(&format!(
758 " let __batch_{} = []; for (let __r = 0; __r < {}; __r++) {{ __batch_{}.push({{ method: '{}', url: `{}`, {}params: {} }}); }}\n",
759 check_idx, count, check_idx, entry_method, url, body_field, params_js
760 ));
761 group_body.push_str(&format!(
762 " let __responses_{} = http.batch(__batch_{});\n",
763 check_idx, check_idx
764 ));
765 group_body.push_str(&format!(" let res = __responses_{}[0];\n", check_idx));
766 emit_check_assertions(group_body, &escaped_name, check, export_requests);
767 group_body.push_str(&format!(
768 " for (let __i = 1; __i < __responses_{}.length; __i++) {{ let res = __responses_{}[__i];",
769 check_idx, check_idx
770 ));
771 emit_check_assertions(group_body, &escaped_name, check, export_requests);
772 group_body.push_str(" }\n");
773 } else if count > 1 {
774 group_body
775 .push_str(&format!(" for (let __r = 0; __r < {}; __r++) {{\n", count));
776 group_body.push_str(&format!(" let res = {};\n", request_call));
777 emit_check_assertions(group_body, &escaped_name, check, export_requests);
778 group_body.push_str(" }\n");
779 } else {
780 group_body.push_str(&format!(" let res = {};\n", request_call));
781 emit_check_assertions(group_body, &escaped_name, check, export_requests);
782 }
783 }
784
785 if !check.extract.is_empty() {
791 emit_chain_extract(group_body, &check.extract);
792 }
793
794 group_body.push_str(" }\n");
795 }
796
797 if iters > 1 {
798 group_body.push_str(" }\n");
799 }
800
801 group_body.push_str(" });\n\n");
802}
803
804fn emit_check_assertions(
808 group_body: &mut String,
809 escaped_name: &str,
810 check: &CustomCheck,
811 export_requests: bool,
812) {
813 if export_requests {
814 group_body.push_str(&format!(
815 " if (typeof __captureExchange === 'function') __captureExchange('{}', res);\n",
816 escaped_name
817 ));
818 }
819 group_body.push_str(&format!(
820 " {{ let ok = check(res, {{ '{}': (r) => r.status === {} }}); if (!ok) __captureFailure('{}', res, 'status === {}'); }}\n",
821 escaped_name, check.expected_status, escaped_name, check.expected_status
822 ));
823 for (header_name, pattern) in &check.expected_headers {
824 let header_check_name = format!("{}:header:{}", escaped_name, header_name);
825 let escaped_pattern = js_escape_sq(pattern);
826 let header_lower = header_name.to_lowercase();
827 group_body.push_str(&format!(
828 " {{ 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",
829 header_check_name, header_lower, escaped_pattern, header_check_name, header_name, escaped_pattern
830 ));
831 }
832 for field in &check.expected_body_fields {
833 let field_check_name = format!("{}:body:{}:{}", escaped_name, field.name, field.field_type);
834 let accessor = generate_field_accessor(&field.name);
835 let type_check = match field.field_type.as_str() {
836 "string" => format!("typeof ({}) === 'string'", accessor),
837 "integer" => format!("Number.isInteger({})", accessor),
838 "number" => format!("typeof ({}) === 'number'", accessor),
839 "boolean" => format!("typeof ({}) === 'boolean'", accessor),
840 "array" => format!("Array.isArray({})", accessor),
841 "object" => {
842 format!("typeof ({}) === 'object' && !Array.isArray({})", accessor, accessor)
843 }
844 _ => format!("({}) !== undefined", accessor),
845 };
846 group_body.push_str(&format!(
847 " {{ let ok = check(res, {{ '{}': (r) => {{ try {{ return {}; }} catch(e) {{ return false; }} }} }}); if (!ok) __captureFailure('{}', res, 'body field {} is {}'); }}\n",
848 field_check_name, type_check, field_check_name, field.name, field.field_type
849 ));
850 }
851}
852
853fn emit_chain_extract(group_body: &mut String, rules: &ExtractRules) {
859 for cookie_name in &rules.cookies {
860 let var = sanitize_js_ident(cookie_name);
861 group_body.push_str(&format!(
862 " if (res.cookies && res.cookies['{}'] && res.cookies['{}'][0]) {{ __ctx_cookie_{} = res.cookies['{}'][0].value; }}\n",
863 js_escape_sq(cookie_name),
864 js_escape_sq(cookie_name),
865 var,
866 js_escape_sq(cookie_name),
867 ));
868 }
869 for (var_name, header_name) in &rules.headers {
870 let var = sanitize_js_ident(var_name);
871 let header_lower = header_name.to_lowercase();
872 group_body.push_str(&format!(
873 " {{ const _hk = Object.keys(res.headers || {{}}).find(k => k.toLowerCase() === '{}'); if (_hk) {{ __ctx_var_{} = res.headers[_hk]; }} }}\n",
874 js_escape_sq(&header_lower), var
875 ));
876 }
877 if !rules.body_fields.is_empty() {
878 group_body.push_str(" try { let __body_json = JSON.parse(res.body || 'null');\n");
879 for (var_name, dotted) in &rules.body_fields {
880 let var = sanitize_js_ident(var_name);
881 let segments: Vec<String> =
884 dotted.split('.').map(|s| format!("['{}']", js_escape_sq(s))).collect();
885 let accessor = format!("__body_json{}", segments.join(""));
886 group_body.push_str(&format!(
887 " try {{ const __v = {}; if (__v !== undefined && __v !== null) __ctx_var_{} = String(__v); }} catch(e) {{}}\n",
888 accessor, var
889 ));
890 }
891 group_body.push_str(" } catch(e) {}\n");
892 }
893 let _ = group_body;
899}
900
901fn generate_field_accessor(field_name: &str) -> String {
908 let parts: Vec<&str> = field_name.split('.').collect();
910 let mut expr = String::from("JSON.parse(r.body)");
911
912 for part in &parts {
913 if let Some(arr_name) = part.strip_suffix("[]") {
914 expr.push_str(&format!("['{}'][0]", arr_name));
916 } else {
917 expr.push_str(&format!("['{}']", part));
918 }
919 }
920
921 expr
922}
923
924#[cfg(test)]
925mod tests {
926 use super::*;
927
928 #[test]
929 fn test_parse_custom_yaml() {
930 let yaml = r#"
931custom_checks:
932 - name: "custom:pets-returns-200"
933 path: /pets
934 method: GET
935 expected_status: 200
936 - name: "custom:create-product"
937 path: /api/products
938 method: POST
939 expected_status: 201
940 body: '{"sku": "TEST-001", "name": "Test"}'
941 expected_body_fields:
942 - name: id
943 type: integer
944 expected_headers:
945 content-type: "application/json"
946"#;
947 let config: CustomConformanceConfig = serde_yaml::from_str(yaml).unwrap();
948 assert_eq!(config.custom_checks.len(), 2);
949 assert_eq!(config.custom_checks[0].name, "custom:pets-returns-200");
950 assert_eq!(config.custom_checks[0].expected_status, 200);
951 assert_eq!(config.custom_checks[1].expected_body_fields.len(), 1);
952 assert_eq!(config.custom_checks[1].expected_body_fields[0].name, "id");
953 assert_eq!(config.custom_checks[1].expected_body_fields[0].field_type, "integer");
954 }
955
956 #[test]
957 fn test_generate_k6_group_get() {
958 let config = CustomConformanceConfig {
959 custom_checks: vec![CustomCheck {
960 name: "custom:test-get".to_string(),
961 path: "/api/test".to_string(),
962 method: "GET".to_string(),
963 expected_status: 200,
964 body: None,
965 expected_headers: std::collections::HashMap::new(),
966 expected_body_fields: vec![],
967 headers: std::collections::HashMap::new(),
968 upload: None,
969 uploads: vec![],
970 extract: ExtractRules::default(),
971 repeat: Repeat::default(),
972 }],
973 chain_iterations: 1,
974 };
975
976 let script = config.generate_k6_group("BASE_URL", &[]);
977 assert!(script.contains("group('Custom'"));
978 assert!(script.contains("http.get(`${BASE_URL}/api/test`)"));
979 assert!(script.contains("'custom:test-get': (r) => r.status === 200"));
980 }
981
982 #[test]
983 fn test_generate_k6_group_post_with_body() {
984 let config = CustomConformanceConfig {
985 custom_checks: vec![CustomCheck {
986 name: "custom:create".to_string(),
987 path: "/api/items".to_string(),
988 method: "POST".to_string(),
989 expected_status: 201,
990 body: Some(r#"{"name": "test"}"#.to_string()),
991 expected_headers: std::collections::HashMap::new(),
992 expected_body_fields: vec![ExpectedBodyField {
993 name: "id".to_string(),
994 field_type: "integer".to_string(),
995 }],
996 headers: std::collections::HashMap::new(),
997 upload: None,
998 uploads: vec![],
999 extract: ExtractRules::default(),
1000 repeat: Repeat::default(),
1001 }],
1002 chain_iterations: 1,
1003 };
1004
1005 let script = config.generate_k6_group("BASE_URL", &[]);
1006 assert!(script.contains("http.post("));
1007 assert!(script.contains("'custom:create': (r) => r.status === 201"));
1008 assert!(script.contains("custom:create:body:id:integer"));
1009 assert!(script.contains("Number.isInteger"));
1010 }
1011
1012 #[test]
1013 fn test_generate_k6_group_with_header_checks() {
1014 let mut expected_headers = std::collections::HashMap::new();
1015 expected_headers.insert("content-type".to_string(), "application/json".to_string());
1016
1017 let config = CustomConformanceConfig {
1018 custom_checks: vec![CustomCheck {
1019 name: "custom:header-check".to_string(),
1020 path: "/api/test".to_string(),
1021 method: "GET".to_string(),
1022 expected_status: 200,
1023 body: None,
1024 expected_headers,
1025 expected_body_fields: vec![],
1026 headers: std::collections::HashMap::new(),
1027 upload: None,
1028 uploads: vec![],
1029 extract: ExtractRules::default(),
1030 repeat: Repeat::default(),
1031 }],
1032 chain_iterations: 1,
1033 };
1034
1035 let script = config.generate_k6_group("BASE_URL", &[]);
1036 assert!(script.contains("custom:header-check:header:content-type"));
1037 assert!(script.contains("new RegExp('application/json')"));
1038 }
1039
1040 #[test]
1041 fn test_generate_k6_group_with_custom_headers() {
1042 let config = CustomConformanceConfig {
1043 custom_checks: vec![CustomCheck {
1044 name: "custom:auth-test".to_string(),
1045 path: "/api/secure".to_string(),
1046 method: "GET".to_string(),
1047 expected_status: 200,
1048 body: None,
1049 expected_headers: std::collections::HashMap::new(),
1050 expected_body_fields: vec![],
1051 headers: std::collections::HashMap::new(),
1052 upload: None,
1053 uploads: vec![],
1054 extract: ExtractRules::default(),
1055 repeat: Repeat::default(),
1056 }],
1057 chain_iterations: 1,
1058 };
1059
1060 let custom_headers = vec![("Authorization".to_string(), "Bearer token123".to_string())];
1061 let script = config.generate_k6_group("BASE_URL", &custom_headers);
1062 assert!(script.contains("'Authorization': 'Bearer token123'"));
1063 }
1064
1065 #[test]
1066 fn test_failure_capture_emitted() {
1067 let config = CustomConformanceConfig {
1068 custom_checks: vec![CustomCheck {
1069 name: "custom:capture-test".to_string(),
1070 path: "/api/test".to_string(),
1071 method: "GET".to_string(),
1072 expected_status: 200,
1073 body: None,
1074 expected_headers: {
1075 let mut m = std::collections::HashMap::new();
1076 m.insert("X-Rate-Limit".to_string(), ".*".to_string());
1077 m
1078 },
1079 expected_body_fields: vec![ExpectedBodyField {
1080 name: "id".to_string(),
1081 field_type: "integer".to_string(),
1082 }],
1083 headers: std::collections::HashMap::new(),
1084 upload: None,
1085 uploads: vec![],
1086 extract: ExtractRules::default(),
1087 repeat: Repeat::default(),
1088 }],
1089 chain_iterations: 1,
1090 };
1091
1092 let script = config.generate_k6_group("BASE_URL", &[]);
1093 assert!(
1095 script.contains("__captureFailure('custom:capture-test', res, 'status === 200')"),
1096 "Status check should emit __captureFailure"
1097 );
1098 assert!(
1100 script.contains("__captureFailure('custom:capture-test:header:X-Rate-Limit'"),
1101 "Header check should emit __captureFailure"
1102 );
1103 assert!(
1105 script.contains("__captureFailure('custom:capture-test:body:id:integer'"),
1106 "Body field check should emit __captureFailure"
1107 );
1108 }
1109
1110 #[test]
1111 fn test_from_file_nonexistent() {
1112 let result = CustomConformanceConfig::from_file(Path::new("/nonexistent/file.yaml"));
1113 assert!(result.is_err());
1114 let err = result.unwrap_err().to_string();
1115 assert!(err.contains("Failed to read custom conformance file"));
1116 }
1117
1118 #[test]
1119 fn test_generate_k6_group_delete() {
1120 let config = CustomConformanceConfig {
1121 custom_checks: vec![CustomCheck {
1122 name: "custom:delete-item".to_string(),
1123 path: "/api/items/1".to_string(),
1124 method: "DELETE".to_string(),
1125 expected_status: 204,
1126 body: None,
1127 expected_headers: std::collections::HashMap::new(),
1128 expected_body_fields: vec![],
1129 headers: std::collections::HashMap::new(),
1130 upload: None,
1131 uploads: vec![],
1132 extract: ExtractRules::default(),
1133 repeat: Repeat::default(),
1134 }],
1135 chain_iterations: 1,
1136 };
1137
1138 let script = config.generate_k6_group("BASE_URL", &[]);
1139 assert!(script.contains("http.del("));
1140 assert!(script.contains("r.status === 204"));
1141 }
1142
1143 #[test]
1144 fn test_field_accessor_simple() {
1145 assert_eq!(generate_field_accessor("name"), "JSON.parse(r.body)['name']");
1146 }
1147
1148 #[test]
1149 fn test_field_accessor_nested_dot() {
1150 assert_eq!(
1151 generate_field_accessor("config.enabled"),
1152 "JSON.parse(r.body)['config']['enabled']"
1153 );
1154 }
1155
1156 #[test]
1157 fn test_field_accessor_array_bracket() {
1158 assert_eq!(generate_field_accessor("items[].id"), "JSON.parse(r.body)['items'][0]['id']");
1159 }
1160
1161 #[test]
1162 fn test_field_accessor_deep_nested() {
1163 assert_eq!(generate_field_accessor("a.b.c"), "JSON.parse(r.body)['a']['b']['c']");
1164 }
1165
1166 #[test]
1167 fn test_generate_k6_nested_body_fields() {
1168 let config = CustomConformanceConfig {
1169 custom_checks: vec![CustomCheck {
1170 name: "custom:nested".to_string(),
1171 path: "/api/data".to_string(),
1172 method: "GET".to_string(),
1173 expected_status: 200,
1174 body: None,
1175 expected_headers: std::collections::HashMap::new(),
1176 expected_body_fields: vec![
1177 ExpectedBodyField {
1178 name: "count".to_string(),
1179 field_type: "integer".to_string(),
1180 },
1181 ExpectedBodyField {
1182 name: "results[].name".to_string(),
1183 field_type: "string".to_string(),
1184 },
1185 ],
1186 headers: std::collections::HashMap::new(),
1187 upload: None,
1188 uploads: vec![],
1189 extract: ExtractRules::default(),
1190 repeat: Repeat::default(),
1191 }],
1192 chain_iterations: 1,
1193 };
1194
1195 let script = config.generate_k6_group("BASE_URL", &[]);
1196 assert!(script.contains("JSON.parse(r.body)['count']"));
1198 assert!(script.contains("JSON.parse(r.body)['results'][0]['name']"));
1200 }
1201}