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
651 .iter()
652 .map(|spec| {
653 let filename = spec.filename.clone().unwrap_or_else(|| {
654 Path::new(&spec.path)
655 .file_name()
656 .and_then(|n| n.to_str())
657 .unwrap_or("upload.bin")
658 .to_string()
659 });
660 format!(
661 "'{}':'{}' ({})",
662 js_escape_sq(&spec.field_name),
663 js_escape_sq(&filename),
664 js_escape_sq(&spec.content_type)
665 )
666 })
667 .collect();
668 let mut size_map_entries: Vec<String> = Vec::with_capacity(upload_specs.len());
673 for spec in &upload_specs {
674 let bytes = std::fs::metadata(&spec.path).map(|m| m.len()).ok();
675 let bytes_js = bytes.map(|b| b.to_string()).unwrap_or_else(|| "null".to_string());
676 size_map_entries.push(format!(
677 "'{}': {}",
678 js_escape_sq(&spec.field_name),
679 bytes_js
680 ));
681 }
682 init_code.push_str(&format!(
686 "// Round 47 #79 — on-disk byte sizes for upload check `{}`\nif (typeof globalThis.__mfUploadSizes === 'undefined') globalThis.__mfUploadSizes = {{}};\nglobalThis.__mfUploadSizes['{}'] = {{ {} }};\n",
687 check.name,
688 js_escape_sq(&check.name),
689 size_map_entries.join(", "),
690 ));
691 group_body.push_str(&format!(
692 " console.log('MOCKFORGE_UPLOAD_PARTS: {} {} files: {}');\n",
693 js_escape_sq(&check.name),
694 upload_specs.len(),
695 js_escape_sq(&summary_entries.join(", ")),
696 ));
697 Some(form_name)
698 } else {
699 None
700 };
701
702 let count = check.repeat.count.max(1);
705 let is_parallel = count > 1 && matches!(check.repeat.mode, RepeatMode::Parallel);
706 let body_expr = match &check.body {
707 Some(b) => {
708 let substituted = substitute_chain_tokens(b);
709 format!("`{}`", substituted)
711 }
712 None => "null".to_string(),
713 };
714
715 if let Some(form_name) = &form_var {
716 if check.body.is_some() {
719 group_body.push_str(&format!(
720 " // warning: custom check '{}' has both `body` and `upload`/`uploads`; ignoring body\n",
721 check.name
722 ));
723 }
724 if is_parallel {
726 group_body.push_str(&format!(
727 " let __batch_{} = []; for (let __r = 0; __r < {}; __r++) {{ __batch_{}.push({{ method: 'POST', url: `{}`, body: {}, params: {} }}); }}\n",
728 check_idx, count, check_idx, url, form_name, params_js
729 ));
730 group_body.push_str(&format!(
731 " let __responses_{} = http.batch(__batch_{});\n",
732 check_idx, check_idx
733 ));
734 group_body.push_str(&format!(" let res = __responses_{}[0];\n", check_idx));
735 emit_check_assertions(group_body, &escaped_name, check, export_requests);
736 group_body.push_str(&format!(
737 " for (let __i = 1; __i < __responses_{}.length; __i++) {{ let res = __responses_{}[__i];",
738 check_idx, check_idx
739 ));
740 emit_check_assertions(group_body, &escaped_name, check, export_requests);
741 group_body.push_str(" }\n");
742 } else if count > 1 {
743 group_body
744 .push_str(&format!(" for (let __r = 0; __r < {}; __r++) {{\n", count));
745 group_body.push_str(&format!(
746 " let res = http.post(`{}`, {}, {});\n",
747 url, form_name, params_js
748 ));
749 emit_check_assertions(group_body, &escaped_name, check, export_requests);
750 group_body.push_str(" }\n");
751 } else {
752 group_body.push_str(&format!(
753 " let res = http.post(`{}`, {}, {});\n",
754 url, form_name, params_js
755 ));
756 emit_check_assertions(group_body, &escaped_name, check, export_requests);
757 }
758 } else {
759 let k6_method = match method.as_str() {
761 "DELETE" => "del".to_string(),
762 other => other.to_lowercase(),
763 };
764 let body_method = !matches!(method.as_str(), "GET" | "HEAD" | "OPTIONS" | "DELETE");
765 let request_call = if body_method {
771 format!("http.{}(`{}`, {}, {})", k6_method, url, body_expr, params_js)
772 } else if all_headers.is_empty() && !uses_cookie_substitution {
773 format!("http.{}(`{}`)", k6_method, url)
774 } else {
775 format!("http.{}(`{}`, {})", k6_method, url, params_js)
776 };
777
778 if is_parallel {
779 let entry_method = match method.as_str() {
780 "DELETE" => "DELETE",
781 "GET" => "GET",
782 "HEAD" => "HEAD",
783 "OPTIONS" => "OPTIONS",
784 "PUT" => "PUT",
785 "PATCH" => "PATCH",
786 "POST" => "POST",
787 _ => "POST",
788 };
789 let body_field = if body_method {
790 format!("body: {}, ", body_expr)
791 } else {
792 String::new()
793 };
794 group_body.push_str(&format!(
795 " let __batch_{} = []; for (let __r = 0; __r < {}; __r++) {{ __batch_{}.push({{ method: '{}', url: `{}`, {}params: {} }}); }}\n",
796 check_idx, count, check_idx, entry_method, url, body_field, params_js
797 ));
798 group_body.push_str(&format!(
799 " let __responses_{} = http.batch(__batch_{});\n",
800 check_idx, check_idx
801 ));
802 group_body.push_str(&format!(" let res = __responses_{}[0];\n", check_idx));
803 emit_check_assertions(group_body, &escaped_name, check, export_requests);
804 group_body.push_str(&format!(
805 " for (let __i = 1; __i < __responses_{}.length; __i++) {{ let res = __responses_{}[__i];",
806 check_idx, check_idx
807 ));
808 emit_check_assertions(group_body, &escaped_name, check, export_requests);
809 group_body.push_str(" }\n");
810 } else if count > 1 {
811 group_body
812 .push_str(&format!(" for (let __r = 0; __r < {}; __r++) {{\n", count));
813 group_body.push_str(&format!(" let res = {};\n", request_call));
814 emit_check_assertions(group_body, &escaped_name, check, export_requests);
815 group_body.push_str(" }\n");
816 } else {
817 group_body.push_str(&format!(" let res = {};\n", request_call));
818 emit_check_assertions(group_body, &escaped_name, check, export_requests);
819 }
820 }
821
822 if !check.extract.is_empty() {
828 emit_chain_extract(group_body, &check.extract);
829 }
830
831 group_body.push_str(" }\n");
832 }
833
834 if iters > 1 {
835 group_body.push_str(" }\n");
836 }
837
838 group_body.push_str(" });\n\n");
839}
840
841fn emit_check_assertions(
845 group_body: &mut String,
846 escaped_name: &str,
847 check: &CustomCheck,
848 export_requests: bool,
849) {
850 if export_requests {
851 group_body.push_str(&format!(
852 " if (typeof __captureExchange === 'function') __captureExchange('{}', res);\n",
853 escaped_name
854 ));
855 }
856 group_body.push_str(&format!(
857 " {{ let ok = check(res, {{ '{}': (r) => r.status === {} }}); if (!ok) __captureFailure('{}', res, 'status === {}'); }}\n",
858 escaped_name, check.expected_status, escaped_name, check.expected_status
859 ));
860 for (header_name, pattern) in &check.expected_headers {
861 let header_check_name = format!("{}:header:{}", escaped_name, header_name);
862 let escaped_pattern = js_escape_sq(pattern);
863 let header_lower = header_name.to_lowercase();
864 group_body.push_str(&format!(
865 " {{ 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",
866 header_check_name, header_lower, escaped_pattern, header_check_name, header_name, escaped_pattern
867 ));
868 }
869 for field in &check.expected_body_fields {
870 let field_check_name = format!("{}:body:{}:{}", escaped_name, field.name, field.field_type);
871 let accessor = generate_field_accessor(&field.name);
872 let type_check = match field.field_type.as_str() {
873 "string" => format!("typeof ({}) === 'string'", accessor),
874 "integer" => format!("Number.isInteger({})", accessor),
875 "number" => format!("typeof ({}) === 'number'", accessor),
876 "boolean" => format!("typeof ({}) === 'boolean'", accessor),
877 "array" => format!("Array.isArray({})", accessor),
878 "object" => {
879 format!("typeof ({}) === 'object' && !Array.isArray({})", accessor, accessor)
880 }
881 _ => format!("({}) !== undefined", accessor),
882 };
883 group_body.push_str(&format!(
884 " {{ let ok = check(res, {{ '{}': (r) => {{ try {{ return {}; }} catch(e) {{ return false; }} }} }}); if (!ok) __captureFailure('{}', res, 'body field {} is {}'); }}\n",
885 field_check_name, type_check, field_check_name, field.name, field.field_type
886 ));
887 }
888}
889
890fn emit_chain_extract(group_body: &mut String, rules: &ExtractRules) {
896 for cookie_name in &rules.cookies {
897 let var = sanitize_js_ident(cookie_name);
898 group_body.push_str(&format!(
899 " if (res.cookies && res.cookies['{}'] && res.cookies['{}'][0]) {{ __ctx_cookie_{} = res.cookies['{}'][0].value; }}\n",
900 js_escape_sq(cookie_name),
901 js_escape_sq(cookie_name),
902 var,
903 js_escape_sq(cookie_name),
904 ));
905 }
906 for (var_name, header_name) in &rules.headers {
907 let var = sanitize_js_ident(var_name);
908 let header_lower = header_name.to_lowercase();
909 group_body.push_str(&format!(
910 " {{ const _hk = Object.keys(res.headers || {{}}).find(k => k.toLowerCase() === '{}'); if (_hk) {{ __ctx_var_{} = res.headers[_hk]; }} }}\n",
911 js_escape_sq(&header_lower), var
912 ));
913 }
914 if !rules.body_fields.is_empty() {
915 group_body.push_str(" try { let __body_json = JSON.parse(res.body || 'null');\n");
916 for (var_name, dotted) in &rules.body_fields {
917 let var = sanitize_js_ident(var_name);
918 let segments: Vec<String> =
921 dotted.split('.').map(|s| format!("['{}']", js_escape_sq(s))).collect();
922 let accessor = format!("__body_json{}", segments.join(""));
923 group_body.push_str(&format!(
924 " try {{ const __v = {}; if (__v !== undefined && __v !== null) __ctx_var_{} = String(__v); }} catch(e) {{}}\n",
925 accessor, var
926 ));
927 }
928 group_body.push_str(" } catch(e) {}\n");
929 }
930 let _ = group_body;
936}
937
938fn generate_field_accessor(field_name: &str) -> String {
945 let parts: Vec<&str> = field_name.split('.').collect();
947 let mut expr = String::from("JSON.parse(r.body)");
948
949 for part in &parts {
950 if let Some(arr_name) = part.strip_suffix("[]") {
951 expr.push_str(&format!("['{}'][0]", arr_name));
953 } else {
954 expr.push_str(&format!("['{}']", part));
955 }
956 }
957
958 expr
959}
960
961#[cfg(test)]
962mod tests {
963 use super::*;
964
965 #[test]
966 fn test_parse_custom_yaml() {
967 let yaml = r#"
968custom_checks:
969 - name: "custom:pets-returns-200"
970 path: /pets
971 method: GET
972 expected_status: 200
973 - name: "custom:create-product"
974 path: /api/products
975 method: POST
976 expected_status: 201
977 body: '{"sku": "TEST-001", "name": "Test"}'
978 expected_body_fields:
979 - name: id
980 type: integer
981 expected_headers:
982 content-type: "application/json"
983"#;
984 let config: CustomConformanceConfig = serde_yaml::from_str(yaml).unwrap();
985 assert_eq!(config.custom_checks.len(), 2);
986 assert_eq!(config.custom_checks[0].name, "custom:pets-returns-200");
987 assert_eq!(config.custom_checks[0].expected_status, 200);
988 assert_eq!(config.custom_checks[1].expected_body_fields.len(), 1);
989 assert_eq!(config.custom_checks[1].expected_body_fields[0].name, "id");
990 assert_eq!(config.custom_checks[1].expected_body_fields[0].field_type, "integer");
991 }
992
993 #[test]
994 fn test_generate_k6_group_get() {
995 let config = CustomConformanceConfig {
996 custom_checks: vec![CustomCheck {
997 name: "custom:test-get".to_string(),
998 path: "/api/test".to_string(),
999 method: "GET".to_string(),
1000 expected_status: 200,
1001 body: None,
1002 expected_headers: std::collections::HashMap::new(),
1003 expected_body_fields: vec![],
1004 headers: std::collections::HashMap::new(),
1005 upload: None,
1006 uploads: vec![],
1007 extract: ExtractRules::default(),
1008 repeat: Repeat::default(),
1009 }],
1010 chain_iterations: 1,
1011 };
1012
1013 let script = config.generate_k6_group("BASE_URL", &[]);
1014 assert!(script.contains("group('Custom'"));
1015 assert!(script.contains("http.get(`${BASE_URL}/api/test`)"));
1016 assert!(script.contains("'custom:test-get': (r) => r.status === 200"));
1017 }
1018
1019 #[test]
1020 fn test_generate_k6_group_post_with_body() {
1021 let config = CustomConformanceConfig {
1022 custom_checks: vec![CustomCheck {
1023 name: "custom:create".to_string(),
1024 path: "/api/items".to_string(),
1025 method: "POST".to_string(),
1026 expected_status: 201,
1027 body: Some(r#"{"name": "test"}"#.to_string()),
1028 expected_headers: std::collections::HashMap::new(),
1029 expected_body_fields: vec![ExpectedBodyField {
1030 name: "id".to_string(),
1031 field_type: "integer".to_string(),
1032 }],
1033 headers: std::collections::HashMap::new(),
1034 upload: None,
1035 uploads: vec![],
1036 extract: ExtractRules::default(),
1037 repeat: Repeat::default(),
1038 }],
1039 chain_iterations: 1,
1040 };
1041
1042 let script = config.generate_k6_group("BASE_URL", &[]);
1043 assert!(script.contains("http.post("));
1044 assert!(script.contains("'custom:create': (r) => r.status === 201"));
1045 assert!(script.contains("custom:create:body:id:integer"));
1046 assert!(script.contains("Number.isInteger"));
1047 }
1048
1049 #[test]
1050 fn test_generate_k6_group_with_header_checks() {
1051 let mut expected_headers = std::collections::HashMap::new();
1052 expected_headers.insert("content-type".to_string(), "application/json".to_string());
1053
1054 let config = CustomConformanceConfig {
1055 custom_checks: vec![CustomCheck {
1056 name: "custom:header-check".to_string(),
1057 path: "/api/test".to_string(),
1058 method: "GET".to_string(),
1059 expected_status: 200,
1060 body: None,
1061 expected_headers,
1062 expected_body_fields: vec![],
1063 headers: std::collections::HashMap::new(),
1064 upload: None,
1065 uploads: vec![],
1066 extract: ExtractRules::default(),
1067 repeat: Repeat::default(),
1068 }],
1069 chain_iterations: 1,
1070 };
1071
1072 let script = config.generate_k6_group("BASE_URL", &[]);
1073 assert!(script.contains("custom:header-check:header:content-type"));
1074 assert!(script.contains("new RegExp('application/json')"));
1075 }
1076
1077 #[test]
1078 fn test_generate_k6_group_with_custom_headers() {
1079 let config = CustomConformanceConfig {
1080 custom_checks: vec![CustomCheck {
1081 name: "custom:auth-test".to_string(),
1082 path: "/api/secure".to_string(),
1083 method: "GET".to_string(),
1084 expected_status: 200,
1085 body: None,
1086 expected_headers: std::collections::HashMap::new(),
1087 expected_body_fields: vec![],
1088 headers: std::collections::HashMap::new(),
1089 upload: None,
1090 uploads: vec![],
1091 extract: ExtractRules::default(),
1092 repeat: Repeat::default(),
1093 }],
1094 chain_iterations: 1,
1095 };
1096
1097 let custom_headers = vec![("Authorization".to_string(), "Bearer token123".to_string())];
1098 let script = config.generate_k6_group("BASE_URL", &custom_headers);
1099 assert!(script.contains("'Authorization': 'Bearer token123'"));
1100 }
1101
1102 #[test]
1103 fn test_failure_capture_emitted() {
1104 let config = CustomConformanceConfig {
1105 custom_checks: vec![CustomCheck {
1106 name: "custom:capture-test".to_string(),
1107 path: "/api/test".to_string(),
1108 method: "GET".to_string(),
1109 expected_status: 200,
1110 body: None,
1111 expected_headers: {
1112 let mut m = std::collections::HashMap::new();
1113 m.insert("X-Rate-Limit".to_string(), ".*".to_string());
1114 m
1115 },
1116 expected_body_fields: vec![ExpectedBodyField {
1117 name: "id".to_string(),
1118 field_type: "integer".to_string(),
1119 }],
1120 headers: std::collections::HashMap::new(),
1121 upload: None,
1122 uploads: vec![],
1123 extract: ExtractRules::default(),
1124 repeat: Repeat::default(),
1125 }],
1126 chain_iterations: 1,
1127 };
1128
1129 let script = config.generate_k6_group("BASE_URL", &[]);
1130 assert!(
1132 script.contains("__captureFailure('custom:capture-test', res, 'status === 200')"),
1133 "Status check should emit __captureFailure"
1134 );
1135 assert!(
1137 script.contains("__captureFailure('custom:capture-test:header:X-Rate-Limit'"),
1138 "Header check should emit __captureFailure"
1139 );
1140 assert!(
1142 script.contains("__captureFailure('custom:capture-test:body:id:integer'"),
1143 "Body field check should emit __captureFailure"
1144 );
1145 }
1146
1147 #[test]
1148 fn test_from_file_nonexistent() {
1149 let result = CustomConformanceConfig::from_file(Path::new("/nonexistent/file.yaml"));
1150 assert!(result.is_err());
1151 let err = result.unwrap_err().to_string();
1152 assert!(err.contains("Failed to read custom conformance file"));
1153 }
1154
1155 #[test]
1156 fn test_generate_k6_group_delete() {
1157 let config = CustomConformanceConfig {
1158 custom_checks: vec![CustomCheck {
1159 name: "custom:delete-item".to_string(),
1160 path: "/api/items/1".to_string(),
1161 method: "DELETE".to_string(),
1162 expected_status: 204,
1163 body: None,
1164 expected_headers: std::collections::HashMap::new(),
1165 expected_body_fields: vec![],
1166 headers: std::collections::HashMap::new(),
1167 upload: None,
1168 uploads: vec![],
1169 extract: ExtractRules::default(),
1170 repeat: Repeat::default(),
1171 }],
1172 chain_iterations: 1,
1173 };
1174
1175 let script = config.generate_k6_group("BASE_URL", &[]);
1176 assert!(script.contains("http.del("));
1177 assert!(script.contains("r.status === 204"));
1178 }
1179
1180 #[test]
1181 fn test_field_accessor_simple() {
1182 assert_eq!(generate_field_accessor("name"), "JSON.parse(r.body)['name']");
1183 }
1184
1185 #[test]
1186 fn test_field_accessor_nested_dot() {
1187 assert_eq!(
1188 generate_field_accessor("config.enabled"),
1189 "JSON.parse(r.body)['config']['enabled']"
1190 );
1191 }
1192
1193 #[test]
1194 fn test_field_accessor_array_bracket() {
1195 assert_eq!(generate_field_accessor("items[].id"), "JSON.parse(r.body)['items'][0]['id']");
1196 }
1197
1198 #[test]
1199 fn test_field_accessor_deep_nested() {
1200 assert_eq!(generate_field_accessor("a.b.c"), "JSON.parse(r.body)['a']['b']['c']");
1201 }
1202
1203 #[test]
1204 fn test_generate_k6_nested_body_fields() {
1205 let config = CustomConformanceConfig {
1206 custom_checks: vec![CustomCheck {
1207 name: "custom:nested".to_string(),
1208 path: "/api/data".to_string(),
1209 method: "GET".to_string(),
1210 expected_status: 200,
1211 body: None,
1212 expected_headers: std::collections::HashMap::new(),
1213 expected_body_fields: vec![
1214 ExpectedBodyField {
1215 name: "count".to_string(),
1216 field_type: "integer".to_string(),
1217 },
1218 ExpectedBodyField {
1219 name: "results[].name".to_string(),
1220 field_type: "string".to_string(),
1221 },
1222 ],
1223 headers: std::collections::HashMap::new(),
1224 upload: None,
1225 uploads: vec![],
1226 extract: ExtractRules::default(),
1227 repeat: Repeat::default(),
1228 }],
1229 chain_iterations: 1,
1230 };
1231
1232 let script = config.generate_k6_group("BASE_URL", &[]);
1233 assert!(script.contains("JSON.parse(r.body)['count']"));
1235 assert!(script.contains("JSON.parse(r.body)['results'][0]['name']"));
1237 }
1238}