use crate::error::{BenchError, Result};
use serde::Deserialize;
use std::path::Path;
#[derive(Debug, Deserialize)]
pub struct CustomConformanceConfig {
pub custom_checks: Vec<CustomCheck>,
#[serde(default = "default_iterations")]
pub chain_iterations: u32,
}
fn default_iterations() -> u32 {
1
}
#[derive(Debug, Deserialize)]
pub struct CustomCheck {
pub name: String,
pub path: String,
pub method: String,
pub expected_status: u16,
#[serde(default)]
pub body: Option<String>,
#[serde(default)]
pub expected_headers: std::collections::HashMap<String, String>,
#[serde(default)]
pub expected_body_fields: Vec<ExpectedBodyField>,
#[serde(default)]
pub headers: std::collections::HashMap<String, String>,
#[serde(default)]
pub upload: Option<UploadFile>,
#[serde(default)]
pub uploads: Vec<UploadFile>,
#[serde(default)]
pub extract: ExtractRules,
#[serde(default)]
pub repeat: Repeat,
}
#[derive(Debug, Deserialize)]
pub struct ExpectedBodyField {
pub name: String,
#[serde(rename = "type")]
pub field_type: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UploadFile {
pub path: String,
#[serde(default = "default_upload_content_type")]
pub content_type: String,
#[serde(default = "default_upload_field_name")]
pub field_name: String,
#[serde(default)]
pub filename: Option<String>,
}
fn default_upload_content_type() -> String {
"application/octet-stream".to_string()
}
fn default_upload_field_name() -> String {
"file".to_string()
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ExtractRules {
#[serde(default)]
pub cookies: Vec<String>,
#[serde(default)]
pub headers: std::collections::HashMap<String, String>,
#[serde(default)]
pub body_fields: std::collections::HashMap<String, String>,
}
impl ExtractRules {
pub fn is_empty(&self) -> bool {
self.cookies.is_empty() && self.headers.is_empty() && self.body_fields.is_empty()
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Repeat {
#[serde(default = "default_repeat_count")]
pub count: u32,
#[serde(default)]
pub mode: RepeatMode,
}
impl Default for Repeat {
fn default() -> Self {
Self {
count: 1,
mode: RepeatMode::default(),
}
}
}
impl Repeat {
pub fn is_default(&self) -> bool {
self.count == 1 && matches!(self.mode, RepeatMode::Sequential)
}
}
fn default_repeat_count() -> u32 {
1
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum RepeatMode {
#[default]
Sequential,
Parallel,
}
impl CustomConformanceConfig {
pub fn from_file(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path).map_err(|e| {
BenchError::Other(format!(
"Failed to read custom conformance file '{}': {}",
path.display(),
e
))
})?;
serde_yaml::from_str(&content).map_err(|e| {
BenchError::Other(format!(
"Failed to parse custom conformance YAML '{}': {}",
path.display(),
e
))
})
}
pub fn generate_k6_group(&self, base_url: &str, custom_headers: &[(String, String)]) -> String {
self.generate_k6_group_with_options(base_url, custom_headers, false)
}
pub fn emit_k6_with_options(
&self,
base_url: &str,
custom_headers: &[(String, String)],
export_requests: bool,
) -> K6CustomEmit {
let mut init_code = String::new();
let mut group_body = String::new();
let mut upload_counter: usize = 0;
write_k6_group_body(
self,
base_url,
custom_headers,
export_requests,
&mut group_body,
&mut init_code,
&mut upload_counter,
);
K6CustomEmit {
init_code,
group_body,
}
}
pub fn generate_k6_group_with_options(
&self,
base_url: &str,
custom_headers: &[(String, String)],
export_requests: bool,
) -> String {
let emit = self.emit_k6_with_options(base_url, custom_headers, export_requests);
let mut combined = String::with_capacity(emit.init_code.len() + emit.group_body.len());
combined.push_str(&emit.init_code);
combined.push_str(&emit.group_body);
combined
}
}
#[derive(Debug, Default, Clone)]
pub struct K6CustomEmit {
pub init_code: String,
pub group_body: String,
}
fn js_escape_sq(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('\'', "\\'")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
#[allow(dead_code)]
fn js_escape_tpl(s: &str) -> String {
s.replace('\\', "\\\\").replace('`', "\\`").replace("${", "\\${")
}
fn substitute_chain_tokens(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find("${") {
for c in rest[..start].chars() {
match c {
'\\' => out.push_str("\\\\"),
'`' => out.push_str("\\`"),
other => out.push(other),
}
}
let after = &rest[start + 2..];
if let Some(end) = after.find('}') {
let token = &after[..end];
let replacement = if let Some(name) = token.strip_prefix("var:") {
Some(format!("${{__ctx_var_{}}}", sanitize_js_ident(name)))
} else if let Some(name) = token.strip_prefix("cookie:") {
Some(format!("${{__ctx_cookie_{}}}", sanitize_js_ident(name)))
} else {
token
.strip_prefix("header:")
.map(|name| format!("${{__ctx_var_{}}}", sanitize_js_ident(name)))
};
if let Some(replacement) = replacement {
out.push_str(&replacement);
} else {
out.push_str("\\${");
out.push_str(token);
out.push('}');
}
rest = &after[end + 1..];
} else {
out.push_str("\\${");
rest = after;
break;
}
}
for c in rest.chars() {
match c {
'\\' => out.push_str("\\\\"),
'`' => out.push_str("\\`"),
other => out.push(other),
}
}
out
}
fn sanitize_js_ident(name: &str) -> String {
name.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect()
}
fn build_headers_object_js(all_headers: &[(String, String)], dynamic: bool) -> String {
if all_headers.is_empty() {
return "{}".to_string();
}
let entries: Vec<String> = all_headers
.iter()
.map(|(k, v)| {
if dynamic {
let substituted = substitute_chain_tokens(v);
format!("'{}': `{}`", js_escape_sq(k), substituted)
} else {
format!("'{}': '{}'", js_escape_sq(k), js_escape_sq(v))
}
})
.collect();
format!("{{ {} }}", entries.join(", "))
}
fn collect_referenced_ctx_idents(
config: &CustomConformanceConfig,
) -> (std::collections::BTreeSet<String>, std::collections::BTreeSet<String>) {
let mut vars = std::collections::BTreeSet::new();
let mut cookies = std::collections::BTreeSet::new();
let walk = |s: &str,
vars: &mut std::collections::BTreeSet<String>,
cookies: &mut std::collections::BTreeSet<String>| {
let mut rest = s;
while let Some(start) = rest.find("${") {
let after = &rest[start + 2..];
if let Some(end) = after.find('}') {
let token = &after[..end];
if let Some(name) =
token.strip_prefix("var:").or_else(|| token.strip_prefix("header:"))
{
vars.insert(sanitize_js_ident(name));
} else if let Some(name) = token.strip_prefix("cookie:") {
cookies.insert(sanitize_js_ident(name));
}
rest = &after[end + 1..];
} else {
break;
}
}
};
for check in &config.custom_checks {
walk(&check.path, &mut vars, &mut cookies);
for v in check.headers.values() {
walk(v, &mut vars, &mut cookies);
}
if let Some(b) = &check.body {
walk(b, &mut vars, &mut cookies);
}
for var_name in check.extract.headers.keys() {
vars.insert(sanitize_js_ident(var_name));
}
for var_name in check.extract.body_fields.keys() {
vars.insert(sanitize_js_ident(var_name));
}
for cookie_name in &check.extract.cookies {
cookies.insert(sanitize_js_ident(cookie_name));
}
}
(vars, cookies)
}
#[allow(clippy::too_many_arguments)]
fn write_k6_group_body(
config: &CustomConformanceConfig,
base_url: &str,
custom_headers: &[(String, String)],
export_requests: bool,
group_body: &mut String,
init_code: &mut String,
upload_counter: &mut usize,
) {
let uses_cookie_substitution = config.custom_checks.iter().any(|c| {
!c.extract.cookies.is_empty()
|| c.headers.values().any(|v| v.contains("${cookie:") || v.contains("${var:"))
});
if uses_cookie_substitution {
init_code.push_str(
"// Round 41 (#79) — declared once so every chain request can reuse it;\n\
// a fresh empty jar suppresses k6's auto-injected Set-Cookie that would\n\
// otherwise duplicate the explicit `${cookie:NAME}` substitution.\n\
const __custom_jar_factory = () => new http.CookieJar();\n",
);
}
group_body.push_str(" group('Custom', function () {\n");
let iters = config.chain_iterations.max(1);
if iters > 1 {
group_body
.push_str(&format!(" for (let __iter = 0; __iter < {}; __iter++) {{\n", iters));
}
let (ctx_vars, ctx_cookies) = collect_referenced_ctx_idents(config);
let needs_ctx = iters > 1 || !ctx_vars.is_empty() || !ctx_cookies.is_empty();
if needs_ctx {
group_body.push_str(" // Round 39 chain context — pre-declared so ${var:X}/${cookie:X} substitutions never ReferenceError\n");
for var in &ctx_vars {
group_body.push_str(&format!(" let __ctx_var_{} = '';\n", var));
}
for cookie in &ctx_cookies {
group_body.push_str(&format!(" let __ctx_cookie_{} = '';\n", cookie));
}
}
for (check_idx, check) in config.custom_checks.iter().enumerate() {
group_body.push_str(" {\n");
let mut all_headers: Vec<(String, String)> = Vec::new();
for (k, v) in &check.headers {
all_headers.push((k.clone(), v.clone()));
}
for (k, v) in custom_headers {
if !check.headers.contains_key(k) {
all_headers.push((k.clone(), v.clone()));
}
}
let is_upload = check.upload.is_some() || !check.uploads.is_empty();
if check.body.is_some()
&& !is_upload
&& !all_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
{
all_headers.push(("Content-Type".to_string(), "application/json".to_string()));
}
let headers_js = build_headers_object_js(&all_headers, needs_ctx);
let params_js = if uses_cookie_substitution {
format!("{{ headers: {}, jar: __custom_jar_factory() }}", headers_js)
} else {
format!("{{ headers: {} }}", headers_js)
};
let method = check.method.to_uppercase();
let url_substituted = substitute_chain_tokens(&check.path);
let url = format!("${{{}}}{}", base_url, url_substituted);
let escaped_name = check.name.replace('\'', "\\'");
let upload_specs: Vec<&UploadFile> =
check.upload.iter().chain(check.uploads.iter()).collect();
let form_var = if !upload_specs.is_empty() {
let mut form_entries: Vec<String> = Vec::with_capacity(upload_specs.len());
for spec in &upload_specs {
let var = format!("__file_{}", *upload_counter);
*upload_counter += 1;
let filename = spec.filename.clone().unwrap_or_else(|| {
Path::new(&spec.path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("upload.bin")
.to_string()
});
init_code.push_str(&format!(
"// Round 39 #79 — preload upload file for `{}`\nconst {} = open('{}', 'b');\n",
check.name,
var,
js_escape_sq(&spec.path),
));
form_entries.push(format!(
"'{}': http.file({}, '{}', '{}')",
js_escape_sq(&spec.field_name),
var,
js_escape_sq(&filename),
js_escape_sq(&spec.content_type),
));
}
let form_name = format!("__form_{}", check_idx);
group_body.push_str(&format!(
" let {} = {{ {} }};\n",
form_name,
form_entries.join(", ")
));
let summary_entries: Vec<String> = upload_specs
.iter()
.map(|spec| {
let filename = spec.filename.clone().unwrap_or_else(|| {
Path::new(&spec.path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("upload.bin")
.to_string()
});
format!(
"'{}':'{}' ({})",
js_escape_sq(&spec.field_name),
js_escape_sq(&filename),
js_escape_sq(&spec.content_type)
)
})
.collect();
group_body.push_str(&format!(
" console.log('MOCKFORGE_UPLOAD_PARTS: {} {} files: {}');\n",
js_escape_sq(&check.name),
upload_specs.len(),
js_escape_sq(&summary_entries.join(", ")),
));
Some(form_name)
} else {
None
};
let count = check.repeat.count.max(1);
let is_parallel = count > 1 && matches!(check.repeat.mode, RepeatMode::Parallel);
let body_expr = match &check.body {
Some(b) => {
let substituted = substitute_chain_tokens(b);
format!("`{}`", substituted)
}
None => "null".to_string(),
};
if let Some(form_name) = &form_var {
if check.body.is_some() {
group_body.push_str(&format!(
" // warning: custom check '{}' has both `body` and `upload`/`uploads`; ignoring body\n",
check.name
));
}
if is_parallel {
group_body.push_str(&format!(
" let __batch_{} = []; for (let __r = 0; __r < {}; __r++) {{ __batch_{}.push({{ method: 'POST', url: `{}`, body: {}, params: {} }}); }}\n",
check_idx, count, check_idx, url, form_name, params_js
));
group_body.push_str(&format!(
" let __responses_{} = http.batch(__batch_{});\n",
check_idx, check_idx
));
group_body.push_str(&format!(" let res = __responses_{}[0];\n", check_idx));
emit_check_assertions(group_body, &escaped_name, check, export_requests);
group_body.push_str(&format!(
" for (let __i = 1; __i < __responses_{}.length; __i++) {{ let res = __responses_{}[__i];",
check_idx, check_idx
));
emit_check_assertions(group_body, &escaped_name, check, export_requests);
group_body.push_str(" }\n");
} else if count > 1 {
group_body
.push_str(&format!(" for (let __r = 0; __r < {}; __r++) {{\n", count));
group_body.push_str(&format!(
" let res = http.post(`{}`, {}, {});\n",
url, form_name, params_js
));
emit_check_assertions(group_body, &escaped_name, check, export_requests);
group_body.push_str(" }\n");
} else {
group_body.push_str(&format!(
" let res = http.post(`{}`, {}, {});\n",
url, form_name, params_js
));
emit_check_assertions(group_body, &escaped_name, check, export_requests);
}
} else {
let k6_method = match method.as_str() {
"DELETE" => "del".to_string(),
other => other.to_lowercase(),
};
let body_method = !matches!(method.as_str(), "GET" | "HEAD" | "OPTIONS" | "DELETE");
let request_call = if body_method {
format!("http.{}(`{}`, {}, {})", k6_method, url, body_expr, params_js)
} else if all_headers.is_empty() && !uses_cookie_substitution {
format!("http.{}(`{}`)", k6_method, url)
} else {
format!("http.{}(`{}`, {})", k6_method, url, params_js)
};
if is_parallel {
let entry_method = match method.as_str() {
"DELETE" => "DELETE",
"GET" => "GET",
"HEAD" => "HEAD",
"OPTIONS" => "OPTIONS",
"PUT" => "PUT",
"PATCH" => "PATCH",
"POST" => "POST",
_ => "POST",
};
let body_field = if body_method {
format!("body: {}, ", body_expr)
} else {
String::new()
};
group_body.push_str(&format!(
" let __batch_{} = []; for (let __r = 0; __r < {}; __r++) {{ __batch_{}.push({{ method: '{}', url: `{}`, {}params: {} }}); }}\n",
check_idx, count, check_idx, entry_method, url, body_field, params_js
));
group_body.push_str(&format!(
" let __responses_{} = http.batch(__batch_{});\n",
check_idx, check_idx
));
group_body.push_str(&format!(" let res = __responses_{}[0];\n", check_idx));
emit_check_assertions(group_body, &escaped_name, check, export_requests);
group_body.push_str(&format!(
" for (let __i = 1; __i < __responses_{}.length; __i++) {{ let res = __responses_{}[__i];",
check_idx, check_idx
));
emit_check_assertions(group_body, &escaped_name, check, export_requests);
group_body.push_str(" }\n");
} else if count > 1 {
group_body
.push_str(&format!(" for (let __r = 0; __r < {}; __r++) {{\n", count));
group_body.push_str(&format!(" let res = {};\n", request_call));
emit_check_assertions(group_body, &escaped_name, check, export_requests);
group_body.push_str(" }\n");
} else {
group_body.push_str(&format!(" let res = {};\n", request_call));
emit_check_assertions(group_body, &escaped_name, check, export_requests);
}
}
if !check.extract.is_empty() {
emit_chain_extract(group_body, &check.extract);
}
group_body.push_str(" }\n");
}
if iters > 1 {
group_body.push_str(" }\n");
}
group_body.push_str(" });\n\n");
}
fn emit_check_assertions(
group_body: &mut String,
escaped_name: &str,
check: &CustomCheck,
export_requests: bool,
) {
if export_requests {
group_body.push_str(&format!(
" if (typeof __captureExchange === 'function') __captureExchange('{}', res);\n",
escaped_name
));
}
group_body.push_str(&format!(
" {{ let ok = check(res, {{ '{}': (r) => r.status === {} }}); if (!ok) __captureFailure('{}', res, 'status === {}'); }}\n",
escaped_name, check.expected_status, escaped_name, check.expected_status
));
for (header_name, pattern) in &check.expected_headers {
let header_check_name = format!("{}:header:{}", escaped_name, header_name);
let escaped_pattern = js_escape_sq(pattern);
let header_lower = header_name.to_lowercase();
group_body.push_str(&format!(
" {{ 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",
header_check_name, header_lower, escaped_pattern, header_check_name, header_name, escaped_pattern
));
}
for field in &check.expected_body_fields {
let field_check_name = format!("{}:body:{}:{}", escaped_name, field.name, field.field_type);
let accessor = generate_field_accessor(&field.name);
let type_check = match field.field_type.as_str() {
"string" => format!("typeof ({}) === 'string'", accessor),
"integer" => format!("Number.isInteger({})", accessor),
"number" => format!("typeof ({}) === 'number'", accessor),
"boolean" => format!("typeof ({}) === 'boolean'", accessor),
"array" => format!("Array.isArray({})", accessor),
"object" => {
format!("typeof ({}) === 'object' && !Array.isArray({})", accessor, accessor)
}
_ => format!("({}) !== undefined", accessor),
};
group_body.push_str(&format!(
" {{ let ok = check(res, {{ '{}': (r) => {{ try {{ return {}; }} catch(e) {{ return false; }} }} }}); if (!ok) __captureFailure('{}', res, 'body field {} is {}'); }}\n",
field_check_name, type_check, field_check_name, field.name, field.field_type
));
}
}
fn emit_chain_extract(group_body: &mut String, rules: &ExtractRules) {
for cookie_name in &rules.cookies {
let var = sanitize_js_ident(cookie_name);
group_body.push_str(&format!(
" if (res.cookies && res.cookies['{}'] && res.cookies['{}'][0]) {{ __ctx_cookie_{} = res.cookies['{}'][0].value; }}\n",
js_escape_sq(cookie_name),
js_escape_sq(cookie_name),
var,
js_escape_sq(cookie_name),
));
}
for (var_name, header_name) in &rules.headers {
let var = sanitize_js_ident(var_name);
let header_lower = header_name.to_lowercase();
group_body.push_str(&format!(
" {{ const _hk = Object.keys(res.headers || {{}}).find(k => k.toLowerCase() === '{}'); if (_hk) {{ __ctx_var_{} = res.headers[_hk]; }} }}\n",
js_escape_sq(&header_lower), var
));
}
if !rules.body_fields.is_empty() {
group_body.push_str(" try { let __body_json = JSON.parse(res.body || 'null');\n");
for (var_name, dotted) in &rules.body_fields {
let var = sanitize_js_ident(var_name);
let segments: Vec<String> =
dotted.split('.').map(|s| format!("['{}']", js_escape_sq(s))).collect();
let accessor = format!("__body_json{}", segments.join(""));
group_body.push_str(&format!(
" try {{ const __v = {}; if (__v !== undefined && __v !== null) __ctx_var_{} = String(__v); }} catch(e) {{}}\n",
accessor, var
));
}
group_body.push_str(" } catch(e) {}\n");
}
let _ = group_body;
}
fn generate_field_accessor(field_name: &str) -> String {
let parts: Vec<&str> = field_name.split('.').collect();
let mut expr = String::from("JSON.parse(r.body)");
for part in &parts {
if let Some(arr_name) = part.strip_suffix("[]") {
expr.push_str(&format!("['{}'][0]", arr_name));
} else {
expr.push_str(&format!("['{}']", part));
}
}
expr
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_custom_yaml() {
let yaml = r#"
custom_checks:
- name: "custom:pets-returns-200"
path: /pets
method: GET
expected_status: 200
- name: "custom:create-product"
path: /api/products
method: POST
expected_status: 201
body: '{"sku": "TEST-001", "name": "Test"}'
expected_body_fields:
- name: id
type: integer
expected_headers:
content-type: "application/json"
"#;
let config: CustomConformanceConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.custom_checks.len(), 2);
assert_eq!(config.custom_checks[0].name, "custom:pets-returns-200");
assert_eq!(config.custom_checks[0].expected_status, 200);
assert_eq!(config.custom_checks[1].expected_body_fields.len(), 1);
assert_eq!(config.custom_checks[1].expected_body_fields[0].name, "id");
assert_eq!(config.custom_checks[1].expected_body_fields[0].field_type, "integer");
}
#[test]
fn test_generate_k6_group_get() {
let config = CustomConformanceConfig {
custom_checks: vec![CustomCheck {
name: "custom:test-get".to_string(),
path: "/api/test".to_string(),
method: "GET".to_string(),
expected_status: 200,
body: None,
expected_headers: std::collections::HashMap::new(),
expected_body_fields: vec![],
headers: std::collections::HashMap::new(),
upload: None,
uploads: vec![],
extract: ExtractRules::default(),
repeat: Repeat::default(),
}],
chain_iterations: 1,
};
let script = config.generate_k6_group("BASE_URL", &[]);
assert!(script.contains("group('Custom'"));
assert!(script.contains("http.get(`${BASE_URL}/api/test`)"));
assert!(script.contains("'custom:test-get': (r) => r.status === 200"));
}
#[test]
fn test_generate_k6_group_post_with_body() {
let config = CustomConformanceConfig {
custom_checks: vec![CustomCheck {
name: "custom:create".to_string(),
path: "/api/items".to_string(),
method: "POST".to_string(),
expected_status: 201,
body: Some(r#"{"name": "test"}"#.to_string()),
expected_headers: std::collections::HashMap::new(),
expected_body_fields: vec![ExpectedBodyField {
name: "id".to_string(),
field_type: "integer".to_string(),
}],
headers: std::collections::HashMap::new(),
upload: None,
uploads: vec![],
extract: ExtractRules::default(),
repeat: Repeat::default(),
}],
chain_iterations: 1,
};
let script = config.generate_k6_group("BASE_URL", &[]);
assert!(script.contains("http.post("));
assert!(script.contains("'custom:create': (r) => r.status === 201"));
assert!(script.contains("custom:create:body:id:integer"));
assert!(script.contains("Number.isInteger"));
}
#[test]
fn test_generate_k6_group_with_header_checks() {
let mut expected_headers = std::collections::HashMap::new();
expected_headers.insert("content-type".to_string(), "application/json".to_string());
let config = CustomConformanceConfig {
custom_checks: vec![CustomCheck {
name: "custom:header-check".to_string(),
path: "/api/test".to_string(),
method: "GET".to_string(),
expected_status: 200,
body: None,
expected_headers,
expected_body_fields: vec![],
headers: std::collections::HashMap::new(),
upload: None,
uploads: vec![],
extract: ExtractRules::default(),
repeat: Repeat::default(),
}],
chain_iterations: 1,
};
let script = config.generate_k6_group("BASE_URL", &[]);
assert!(script.contains("custom:header-check:header:content-type"));
assert!(script.contains("new RegExp('application/json')"));
}
#[test]
fn test_generate_k6_group_with_custom_headers() {
let config = CustomConformanceConfig {
custom_checks: vec![CustomCheck {
name: "custom:auth-test".to_string(),
path: "/api/secure".to_string(),
method: "GET".to_string(),
expected_status: 200,
body: None,
expected_headers: std::collections::HashMap::new(),
expected_body_fields: vec![],
headers: std::collections::HashMap::new(),
upload: None,
uploads: vec![],
extract: ExtractRules::default(),
repeat: Repeat::default(),
}],
chain_iterations: 1,
};
let custom_headers = vec![("Authorization".to_string(), "Bearer token123".to_string())];
let script = config.generate_k6_group("BASE_URL", &custom_headers);
assert!(script.contains("'Authorization': 'Bearer token123'"));
}
#[test]
fn test_failure_capture_emitted() {
let config = CustomConformanceConfig {
custom_checks: vec![CustomCheck {
name: "custom:capture-test".to_string(),
path: "/api/test".to_string(),
method: "GET".to_string(),
expected_status: 200,
body: None,
expected_headers: {
let mut m = std::collections::HashMap::new();
m.insert("X-Rate-Limit".to_string(), ".*".to_string());
m
},
expected_body_fields: vec![ExpectedBodyField {
name: "id".to_string(),
field_type: "integer".to_string(),
}],
headers: std::collections::HashMap::new(),
upload: None,
uploads: vec![],
extract: ExtractRules::default(),
repeat: Repeat::default(),
}],
chain_iterations: 1,
};
let script = config.generate_k6_group("BASE_URL", &[]);
assert!(
script.contains("__captureFailure('custom:capture-test', res, 'status === 200')"),
"Status check should emit __captureFailure"
);
assert!(
script.contains("__captureFailure('custom:capture-test:header:X-Rate-Limit'"),
"Header check should emit __captureFailure"
);
assert!(
script.contains("__captureFailure('custom:capture-test:body:id:integer'"),
"Body field check should emit __captureFailure"
);
}
#[test]
fn test_from_file_nonexistent() {
let result = CustomConformanceConfig::from_file(Path::new("/nonexistent/file.yaml"));
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Failed to read custom conformance file"));
}
#[test]
fn test_generate_k6_group_delete() {
let config = CustomConformanceConfig {
custom_checks: vec![CustomCheck {
name: "custom:delete-item".to_string(),
path: "/api/items/1".to_string(),
method: "DELETE".to_string(),
expected_status: 204,
body: None,
expected_headers: std::collections::HashMap::new(),
expected_body_fields: vec![],
headers: std::collections::HashMap::new(),
upload: None,
uploads: vec![],
extract: ExtractRules::default(),
repeat: Repeat::default(),
}],
chain_iterations: 1,
};
let script = config.generate_k6_group("BASE_URL", &[]);
assert!(script.contains("http.del("));
assert!(script.contains("r.status === 204"));
}
#[test]
fn test_field_accessor_simple() {
assert_eq!(generate_field_accessor("name"), "JSON.parse(r.body)['name']");
}
#[test]
fn test_field_accessor_nested_dot() {
assert_eq!(
generate_field_accessor("config.enabled"),
"JSON.parse(r.body)['config']['enabled']"
);
}
#[test]
fn test_field_accessor_array_bracket() {
assert_eq!(generate_field_accessor("items[].id"), "JSON.parse(r.body)['items'][0]['id']");
}
#[test]
fn test_field_accessor_deep_nested() {
assert_eq!(generate_field_accessor("a.b.c"), "JSON.parse(r.body)['a']['b']['c']");
}
#[test]
fn test_generate_k6_nested_body_fields() {
let config = CustomConformanceConfig {
custom_checks: vec![CustomCheck {
name: "custom:nested".to_string(),
path: "/api/data".to_string(),
method: "GET".to_string(),
expected_status: 200,
body: None,
expected_headers: std::collections::HashMap::new(),
expected_body_fields: vec![
ExpectedBodyField {
name: "count".to_string(),
field_type: "integer".to_string(),
},
ExpectedBodyField {
name: "results[].name".to_string(),
field_type: "string".to_string(),
},
],
headers: std::collections::HashMap::new(),
upload: None,
uploads: vec![],
extract: ExtractRules::default(),
repeat: Repeat::default(),
}],
chain_iterations: 1,
};
let script = config.generate_k6_group("BASE_URL", &[]);
assert!(script.contains("JSON.parse(r.body)['count']"));
assert!(script.contains("JSON.parse(r.body)['results'][0]['name']"));
}
}