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 group_body.push_str(" group('Custom', function () {\n");
474 let iters = config.chain_iterations.max(1);
475 if iters > 1 {
476 group_body
477 .push_str(&format!(" for (let __iter = 0; __iter < {}; __iter++) {{\n", iters));
478 }
479
480 let (ctx_vars, ctx_cookies) = collect_referenced_ctx_idents(config);
491 let needs_ctx = iters > 1 || !ctx_vars.is_empty() || !ctx_cookies.is_empty();
492 if needs_ctx {
493 group_body.push_str(" // Round 39 chain context — pre-declared so ${var:X}/${cookie:X} substitutions never ReferenceError\n");
494 for var in &ctx_vars {
495 group_body.push_str(&format!(" let __ctx_var_{} = '';\n", var));
496 }
497 for cookie in &ctx_cookies {
498 group_body.push_str(&format!(" let __ctx_cookie_{} = '';\n", cookie));
499 }
500 }
501
502 for (check_idx, check) in config.custom_checks.iter().enumerate() {
503 group_body.push_str(" {\n");
504
505 let mut all_headers: Vec<(String, String)> = Vec::new();
508 for (k, v) in &check.headers {
509 all_headers.push((k.clone(), v.clone()));
510 }
511 for (k, v) in custom_headers {
512 if !check.headers.contains_key(k) {
513 all_headers.push((k.clone(), v.clone()));
514 }
515 }
516 let is_upload = check.upload.is_some() || !check.uploads.is_empty();
520 if check.body.is_some()
521 && !is_upload
522 && !all_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
523 {
524 all_headers.push(("Content-Type".to_string(), "application/json".to_string()));
525 }
526
527 let headers_js = build_headers_object_js(&all_headers, needs_ctx);
528 let method = check.method.to_uppercase();
529 let url_substituted = substitute_chain_tokens(&check.path);
532 let url = format!("${{{}}}{}", base_url, url_substituted);
533 let escaped_name = check.name.replace('\'', "\\'");
534
535 let upload_specs: Vec<&UploadFile> =
542 check.upload.iter().chain(check.uploads.iter()).collect();
543 let form_var = if !upload_specs.is_empty() {
544 let mut form_entries: Vec<String> = Vec::with_capacity(upload_specs.len());
545 for spec in &upload_specs {
546 let var = format!("__file_{}", *upload_counter);
547 *upload_counter += 1;
548 let filename = spec.filename.clone().unwrap_or_else(|| {
549 std::path::Path::new(&spec.path)
550 .file_name()
551 .and_then(|n| n.to_str())
552 .unwrap_or("upload.bin")
553 .to_string()
554 });
555 init_code.push_str(&format!(
556 "// Round 39 #79 — preload upload file for `{}`\nconst {} = open('{}', 'b');\n",
557 check.name,
558 var,
559 js_escape_sq(&spec.path),
560 ));
561 form_entries.push(format!(
562 "'{}': http.file({}, '{}', '{}')",
563 js_escape_sq(&spec.field_name),
564 var,
565 js_escape_sq(&filename),
566 js_escape_sq(&spec.content_type),
567 ));
568 }
569 let form_name = format!("__form_{}", check_idx);
570 group_body.push_str(&format!(
571 " let {} = {{ {} }};\n",
572 form_name,
573 form_entries.join(", ")
574 ));
575 Some(form_name)
576 } else {
577 None
578 };
579
580 let count = check.repeat.count.max(1);
583 let is_parallel = count > 1 && matches!(check.repeat.mode, RepeatMode::Parallel);
584 let body_expr = match &check.body {
585 Some(b) => {
586 let substituted = substitute_chain_tokens(b);
587 format!("`{}`", substituted)
589 }
590 None => "null".to_string(),
591 };
592
593 if let Some(form_name) = &form_var {
594 if check.body.is_some() {
597 group_body.push_str(&format!(
598 " // warning: custom check '{}' has both `body` and `upload`/`uploads`; ignoring body\n",
599 check.name
600 ));
601 }
602 if is_parallel {
604 group_body.push_str(&format!(
605 " let __batch_{} = []; for (let __r = 0; __r < {}; __r++) {{ __batch_{}.push({{ method: 'POST', url: `{}`, body: {}, params: {{ headers: {} }} }}); }}\n",
606 check_idx, count, check_idx, url, form_name, headers_js
607 ));
608 group_body.push_str(&format!(
609 " let __responses_{} = http.batch(__batch_{});\n",
610 check_idx, check_idx
611 ));
612 group_body.push_str(&format!(" let res = __responses_{}[0];\n", check_idx));
613 emit_check_assertions(group_body, &escaped_name, check, export_requests);
614 group_body.push_str(&format!(
615 " for (let __i = 1; __i < __responses_{}.length; __i++) {{ let res = __responses_{}[__i];",
616 check_idx, check_idx
617 ));
618 emit_check_assertions(group_body, &escaped_name, check, export_requests);
619 group_body.push_str(" }\n");
620 } else if count > 1 {
621 group_body
622 .push_str(&format!(" for (let __r = 0; __r < {}; __r++) {{\n", count));
623 group_body.push_str(&format!(
624 " let res = http.post(`{}`, {}, {{ headers: {} }});\n",
625 url, form_name, headers_js
626 ));
627 emit_check_assertions(group_body, &escaped_name, check, export_requests);
628 group_body.push_str(" }\n");
629 } else {
630 group_body.push_str(&format!(
631 " let res = http.post(`{}`, {}, {{ headers: {} }});\n",
632 url, form_name, headers_js
633 ));
634 emit_check_assertions(group_body, &escaped_name, check, export_requests);
635 }
636 } else {
637 let k6_method = match method.as_str() {
639 "DELETE" => "del".to_string(),
640 other => other.to_lowercase(),
641 };
642 let body_method = !matches!(method.as_str(), "GET" | "HEAD" | "OPTIONS" | "DELETE");
643 let request_call = if body_method {
644 format!(
645 "http.{}(`{}`, {}, {{ headers: {} }})",
646 k6_method, url, body_expr, headers_js
647 )
648 } else if all_headers.is_empty() {
649 format!("http.{}(`{}`)", k6_method, url)
650 } else {
651 format!("http.{}(`{}`, {{ headers: {} }})", k6_method, url, headers_js)
652 };
653
654 if is_parallel {
655 let entry_method = match method.as_str() {
656 "DELETE" => "DELETE",
657 "GET" => "GET",
658 "HEAD" => "HEAD",
659 "OPTIONS" => "OPTIONS",
660 "PUT" => "PUT",
661 "PATCH" => "PATCH",
662 "POST" => "POST",
663 _ => "POST",
664 };
665 let body_field = if body_method {
666 format!("body: {}, ", body_expr)
667 } else {
668 String::new()
669 };
670 group_body.push_str(&format!(
671 " let __batch_{} = []; for (let __r = 0; __r < {}; __r++) {{ __batch_{}.push({{ method: '{}', url: `{}`, {}params: {{ headers: {} }} }}); }}\n",
672 check_idx, count, check_idx, entry_method, url, body_field, headers_js
673 ));
674 group_body.push_str(&format!(
675 " let __responses_{} = http.batch(__batch_{});\n",
676 check_idx, check_idx
677 ));
678 group_body.push_str(&format!(" let res = __responses_{}[0];\n", check_idx));
679 emit_check_assertions(group_body, &escaped_name, check, export_requests);
680 group_body.push_str(&format!(
681 " for (let __i = 1; __i < __responses_{}.length; __i++) {{ let res = __responses_{}[__i];",
682 check_idx, check_idx
683 ));
684 emit_check_assertions(group_body, &escaped_name, check, export_requests);
685 group_body.push_str(" }\n");
686 } else if count > 1 {
687 group_body
688 .push_str(&format!(" for (let __r = 0; __r < {}; __r++) {{\n", count));
689 group_body.push_str(&format!(" let res = {};\n", request_call));
690 emit_check_assertions(group_body, &escaped_name, check, export_requests);
691 group_body.push_str(" }\n");
692 } else {
693 group_body.push_str(&format!(" let res = {};\n", request_call));
694 emit_check_assertions(group_body, &escaped_name, check, export_requests);
695 }
696 }
697
698 if !check.extract.is_empty() {
704 emit_chain_extract(group_body, &check.extract);
705 }
706
707 group_body.push_str(" }\n");
708 }
709
710 if iters > 1 {
711 group_body.push_str(" }\n");
712 }
713
714 group_body.push_str(" });\n\n");
715}
716
717fn emit_check_assertions(
721 group_body: &mut String,
722 escaped_name: &str,
723 check: &CustomCheck,
724 export_requests: bool,
725) {
726 if export_requests {
727 group_body.push_str(&format!(
728 " if (typeof __captureExchange === 'function') __captureExchange('{}', res);\n",
729 escaped_name
730 ));
731 }
732 group_body.push_str(&format!(
733 " {{ let ok = check(res, {{ '{}': (r) => r.status === {} }}); if (!ok) __captureFailure('{}', res, 'status === {}'); }}\n",
734 escaped_name, check.expected_status, escaped_name, check.expected_status
735 ));
736 for (header_name, pattern) in &check.expected_headers {
737 let header_check_name = format!("{}:header:{}", escaped_name, header_name);
738 let escaped_pattern = js_escape_sq(pattern);
739 let header_lower = header_name.to_lowercase();
740 group_body.push_str(&format!(
741 " {{ 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",
742 header_check_name, header_lower, escaped_pattern, header_check_name, header_name, escaped_pattern
743 ));
744 }
745 for field in &check.expected_body_fields {
746 let field_check_name = format!("{}:body:{}:{}", escaped_name, field.name, field.field_type);
747 let accessor = generate_field_accessor(&field.name);
748 let type_check = match field.field_type.as_str() {
749 "string" => format!("typeof ({}) === 'string'", accessor),
750 "integer" => format!("Number.isInteger({})", accessor),
751 "number" => format!("typeof ({}) === 'number'", accessor),
752 "boolean" => format!("typeof ({}) === 'boolean'", accessor),
753 "array" => format!("Array.isArray({})", accessor),
754 "object" => {
755 format!("typeof ({}) === 'object' && !Array.isArray({})", accessor, accessor)
756 }
757 _ => format!("({}) !== undefined", accessor),
758 };
759 group_body.push_str(&format!(
760 " {{ let ok = check(res, {{ '{}': (r) => {{ try {{ return {}; }} catch(e) {{ return false; }} }} }}); if (!ok) __captureFailure('{}', res, 'body field {} is {}'); }}\n",
761 field_check_name, type_check, field_check_name, field.name, field.field_type
762 ));
763 }
764}
765
766fn emit_chain_extract(group_body: &mut String, rules: &ExtractRules) {
772 for cookie_name in &rules.cookies {
773 let var = sanitize_js_ident(cookie_name);
774 group_body.push_str(&format!(
775 " if (res.cookies && res.cookies['{}'] && res.cookies['{}'][0]) {{ __ctx_cookie_{} = res.cookies['{}'][0].value; }}\n",
776 js_escape_sq(cookie_name),
777 js_escape_sq(cookie_name),
778 var,
779 js_escape_sq(cookie_name),
780 ));
781 }
782 for (var_name, header_name) in &rules.headers {
783 let var = sanitize_js_ident(var_name);
784 let header_lower = header_name.to_lowercase();
785 group_body.push_str(&format!(
786 " {{ const _hk = Object.keys(res.headers || {{}}).find(k => k.toLowerCase() === '{}'); if (_hk) {{ __ctx_var_{} = res.headers[_hk]; }} }}\n",
787 js_escape_sq(&header_lower), var
788 ));
789 }
790 if !rules.body_fields.is_empty() {
791 group_body.push_str(" try { let __body_json = JSON.parse(res.body || 'null');\n");
792 for (var_name, dotted) in &rules.body_fields {
793 let var = sanitize_js_ident(var_name);
794 let segments: Vec<String> =
797 dotted.split('.').map(|s| format!("['{}']", js_escape_sq(s))).collect();
798 let accessor = format!("__body_json{}", segments.join(""));
799 group_body.push_str(&format!(
800 " try {{ const __v = {}; if (__v !== undefined && __v !== null) __ctx_var_{} = String(__v); }} catch(e) {{}}\n",
801 accessor, var
802 ));
803 }
804 group_body.push_str(" } catch(e) {}\n");
805 }
806 let _ = group_body;
812}
813
814fn generate_field_accessor(field_name: &str) -> String {
821 let parts: Vec<&str> = field_name.split('.').collect();
823 let mut expr = String::from("JSON.parse(r.body)");
824
825 for part in &parts {
826 if let Some(arr_name) = part.strip_suffix("[]") {
827 expr.push_str(&format!("['{}'][0]", arr_name));
829 } else {
830 expr.push_str(&format!("['{}']", part));
831 }
832 }
833
834 expr
835}
836
837#[cfg(test)]
838mod tests {
839 use super::*;
840
841 #[test]
842 fn test_parse_custom_yaml() {
843 let yaml = r#"
844custom_checks:
845 - name: "custom:pets-returns-200"
846 path: /pets
847 method: GET
848 expected_status: 200
849 - name: "custom:create-product"
850 path: /api/products
851 method: POST
852 expected_status: 201
853 body: '{"sku": "TEST-001", "name": "Test"}'
854 expected_body_fields:
855 - name: id
856 type: integer
857 expected_headers:
858 content-type: "application/json"
859"#;
860 let config: CustomConformanceConfig = serde_yaml::from_str(yaml).unwrap();
861 assert_eq!(config.custom_checks.len(), 2);
862 assert_eq!(config.custom_checks[0].name, "custom:pets-returns-200");
863 assert_eq!(config.custom_checks[0].expected_status, 200);
864 assert_eq!(config.custom_checks[1].expected_body_fields.len(), 1);
865 assert_eq!(config.custom_checks[1].expected_body_fields[0].name, "id");
866 assert_eq!(config.custom_checks[1].expected_body_fields[0].field_type, "integer");
867 }
868
869 #[test]
870 fn test_generate_k6_group_get() {
871 let config = CustomConformanceConfig {
872 custom_checks: vec![CustomCheck {
873 name: "custom:test-get".to_string(),
874 path: "/api/test".to_string(),
875 method: "GET".to_string(),
876 expected_status: 200,
877 body: None,
878 expected_headers: std::collections::HashMap::new(),
879 expected_body_fields: vec![],
880 headers: std::collections::HashMap::new(),
881 upload: None,
882 uploads: vec![],
883 extract: ExtractRules::default(),
884 repeat: Repeat::default(),
885 }],
886 chain_iterations: 1,
887 };
888
889 let script = config.generate_k6_group("BASE_URL", &[]);
890 assert!(script.contains("group('Custom'"));
891 assert!(script.contains("http.get(`${BASE_URL}/api/test`)"));
892 assert!(script.contains("'custom:test-get': (r) => r.status === 200"));
893 }
894
895 #[test]
896 fn test_generate_k6_group_post_with_body() {
897 let config = CustomConformanceConfig {
898 custom_checks: vec![CustomCheck {
899 name: "custom:create".to_string(),
900 path: "/api/items".to_string(),
901 method: "POST".to_string(),
902 expected_status: 201,
903 body: Some(r#"{"name": "test"}"#.to_string()),
904 expected_headers: std::collections::HashMap::new(),
905 expected_body_fields: vec![ExpectedBodyField {
906 name: "id".to_string(),
907 field_type: "integer".to_string(),
908 }],
909 headers: std::collections::HashMap::new(),
910 upload: None,
911 uploads: vec![],
912 extract: ExtractRules::default(),
913 repeat: Repeat::default(),
914 }],
915 chain_iterations: 1,
916 };
917
918 let script = config.generate_k6_group("BASE_URL", &[]);
919 assert!(script.contains("http.post("));
920 assert!(script.contains("'custom:create': (r) => r.status === 201"));
921 assert!(script.contains("custom:create:body:id:integer"));
922 assert!(script.contains("Number.isInteger"));
923 }
924
925 #[test]
926 fn test_generate_k6_group_with_header_checks() {
927 let mut expected_headers = std::collections::HashMap::new();
928 expected_headers.insert("content-type".to_string(), "application/json".to_string());
929
930 let config = CustomConformanceConfig {
931 custom_checks: vec![CustomCheck {
932 name: "custom:header-check".to_string(),
933 path: "/api/test".to_string(),
934 method: "GET".to_string(),
935 expected_status: 200,
936 body: None,
937 expected_headers,
938 expected_body_fields: vec![],
939 headers: std::collections::HashMap::new(),
940 upload: None,
941 uploads: vec![],
942 extract: ExtractRules::default(),
943 repeat: Repeat::default(),
944 }],
945 chain_iterations: 1,
946 };
947
948 let script = config.generate_k6_group("BASE_URL", &[]);
949 assert!(script.contains("custom:header-check:header:content-type"));
950 assert!(script.contains("new RegExp('application/json')"));
951 }
952
953 #[test]
954 fn test_generate_k6_group_with_custom_headers() {
955 let config = CustomConformanceConfig {
956 custom_checks: vec![CustomCheck {
957 name: "custom:auth-test".to_string(),
958 path: "/api/secure".to_string(),
959 method: "GET".to_string(),
960 expected_status: 200,
961 body: None,
962 expected_headers: std::collections::HashMap::new(),
963 expected_body_fields: vec![],
964 headers: std::collections::HashMap::new(),
965 upload: None,
966 uploads: vec![],
967 extract: ExtractRules::default(),
968 repeat: Repeat::default(),
969 }],
970 chain_iterations: 1,
971 };
972
973 let custom_headers = vec![("Authorization".to_string(), "Bearer token123".to_string())];
974 let script = config.generate_k6_group("BASE_URL", &custom_headers);
975 assert!(script.contains("'Authorization': 'Bearer token123'"));
976 }
977
978 #[test]
979 fn test_failure_capture_emitted() {
980 let config = CustomConformanceConfig {
981 custom_checks: vec![CustomCheck {
982 name: "custom:capture-test".to_string(),
983 path: "/api/test".to_string(),
984 method: "GET".to_string(),
985 expected_status: 200,
986 body: None,
987 expected_headers: {
988 let mut m = std::collections::HashMap::new();
989 m.insert("X-Rate-Limit".to_string(), ".*".to_string());
990 m
991 },
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!(
1008 script.contains("__captureFailure('custom:capture-test', res, 'status === 200')"),
1009 "Status check should emit __captureFailure"
1010 );
1011 assert!(
1013 script.contains("__captureFailure('custom:capture-test:header:X-Rate-Limit'"),
1014 "Header check should emit __captureFailure"
1015 );
1016 assert!(
1018 script.contains("__captureFailure('custom:capture-test:body:id:integer'"),
1019 "Body field check should emit __captureFailure"
1020 );
1021 }
1022
1023 #[test]
1024 fn test_from_file_nonexistent() {
1025 let result = CustomConformanceConfig::from_file(Path::new("/nonexistent/file.yaml"));
1026 assert!(result.is_err());
1027 let err = result.unwrap_err().to_string();
1028 assert!(err.contains("Failed to read custom conformance file"));
1029 }
1030
1031 #[test]
1032 fn test_generate_k6_group_delete() {
1033 let config = CustomConformanceConfig {
1034 custom_checks: vec![CustomCheck {
1035 name: "custom:delete-item".to_string(),
1036 path: "/api/items/1".to_string(),
1037 method: "DELETE".to_string(),
1038 expected_status: 204,
1039 body: None,
1040 expected_headers: std::collections::HashMap::new(),
1041 expected_body_fields: vec![],
1042 headers: std::collections::HashMap::new(),
1043 upload: None,
1044 uploads: vec![],
1045 extract: ExtractRules::default(),
1046 repeat: Repeat::default(),
1047 }],
1048 chain_iterations: 1,
1049 };
1050
1051 let script = config.generate_k6_group("BASE_URL", &[]);
1052 assert!(script.contains("http.del("));
1053 assert!(script.contains("r.status === 204"));
1054 }
1055
1056 #[test]
1057 fn test_field_accessor_simple() {
1058 assert_eq!(generate_field_accessor("name"), "JSON.parse(r.body)['name']");
1059 }
1060
1061 #[test]
1062 fn test_field_accessor_nested_dot() {
1063 assert_eq!(
1064 generate_field_accessor("config.enabled"),
1065 "JSON.parse(r.body)['config']['enabled']"
1066 );
1067 }
1068
1069 #[test]
1070 fn test_field_accessor_array_bracket() {
1071 assert_eq!(generate_field_accessor("items[].id"), "JSON.parse(r.body)['items'][0]['id']");
1072 }
1073
1074 #[test]
1075 fn test_field_accessor_deep_nested() {
1076 assert_eq!(generate_field_accessor("a.b.c"), "JSON.parse(r.body)['a']['b']['c']");
1077 }
1078
1079 #[test]
1080 fn test_generate_k6_nested_body_fields() {
1081 let config = CustomConformanceConfig {
1082 custom_checks: vec![CustomCheck {
1083 name: "custom:nested".to_string(),
1084 path: "/api/data".to_string(),
1085 method: "GET".to_string(),
1086 expected_status: 200,
1087 body: None,
1088 expected_headers: std::collections::HashMap::new(),
1089 expected_body_fields: vec![
1090 ExpectedBodyField {
1091 name: "count".to_string(),
1092 field_type: "integer".to_string(),
1093 },
1094 ExpectedBodyField {
1095 name: "results[].name".to_string(),
1096 field_type: "string".to_string(),
1097 },
1098 ],
1099 headers: std::collections::HashMap::new(),
1100 upload: None,
1101 uploads: vec![],
1102 extract: ExtractRules::default(),
1103 repeat: Repeat::default(),
1104 }],
1105 chain_iterations: 1,
1106 };
1107
1108 let script = config.generate_k6_group("BASE_URL", &[]);
1109 assert!(script.contains("JSON.parse(r.body)['count']"));
1111 assert!(script.contains("JSON.parse(r.body)['results'][0]['name']"));
1113 }
1114}