Skip to main content

mockforge_bench/conformance/
custom.rs

1//! Custom conformance test authoring via YAML
2//!
3//! Allows users to define additional conformance checks beyond the built-in
4//! OpenAPI 3.0.0 feature set. Custom checks are grouped under a "Custom"
5//! category in the conformance report.
6
7use crate::error::{BenchError, Result};
8use serde::Deserialize;
9use std::path::Path;
10
11/// Top-level YAML configuration for custom conformance checks
12#[derive(Debug, Deserialize)]
13pub struct CustomConformanceConfig {
14    /// List of custom checks to run
15    pub custom_checks: Vec<CustomCheck>,
16    /// Round 38 (#79) — Srikanth on 0.3.182. Repeat the entire
17    /// `custom_checks` sequence N times so a "log in, do work,
18    /// log out" chain can be exercised under load. The
19    /// `${var:...}` / `${cookie:...}` substitution context is
20    /// reset at the start of each iteration; values captured in
21    /// iteration K are NOT visible to iteration K+1. Defaults to 1.
22    #[serde(default = "default_iterations")]
23    pub chain_iterations: u32,
24}
25
26fn default_iterations() -> u32 {
27    1
28}
29
30/// A single custom conformance check
31#[derive(Debug, Deserialize)]
32pub struct CustomCheck {
33    /// Check name (should start with "custom:" for report aggregation)
34    pub name: String,
35    /// Request path (e.g., "/api/users")
36    pub path: String,
37    /// HTTP method (GET, POST, PUT, DELETE, etc.)
38    pub method: String,
39    /// Expected HTTP status code
40    pub expected_status: u16,
41    /// Optional request body (JSON string)
42    #[serde(default)]
43    pub body: Option<String>,
44    /// Optional expected response headers (name -> regex pattern)
45    #[serde(default)]
46    pub expected_headers: std::collections::HashMap<String, String>,
47    /// Optional expected body fields with type validation
48    #[serde(default)]
49    pub expected_body_fields: Vec<ExpectedBodyField>,
50    /// Optional request headers
51    #[serde(default)]
52    pub headers: std::collections::HashMap<String, String>,
53
54    /// Round 38 (#79) — file upload support. When set, the request
55    /// is sent as `multipart/form-data` with one part per file. Each
56    /// file's bytes come from a local path (so the YAML can name a
57    /// `.exe`, `.jpg`, `.json`, `.docx`, `.xml`, etc. without
58    /// embedding the bytes). `body` wins over `upload`/`uploads`.
59    #[serde(default)]
60    pub upload: Option<UploadFile>,
61    #[serde(default)]
62    pub uploads: Vec<UploadFile>,
63
64    /// Round 38 (#79) — capture values from the response into the
65    /// chain context so subsequent checks can reference them via
66    /// `${var:NAME}`, `${cookie:NAME}`, `${header:NAME}` in path /
67    /// headers / body.
68    #[serde(default)]
69    pub extract: ExtractRules,
70
71    /// Round 38 (#79) — repeat the check N times within an
72    /// iteration. `mode: parallel` fires N concurrent requests
73    /// (Srikanth's Sequence 1: "Use that cookie and csrf token in 16
74    /// subsequent requests that should be sent in parallel").
75    /// `mode: sequential` runs them one after another (Sequence 2).
76    #[serde(default)]
77    pub repeat: Repeat,
78}
79
80/// Expected field in the response body with type checking
81#[derive(Debug, Deserialize)]
82pub struct ExpectedBodyField {
83    /// Field name in the JSON response
84    pub name: String,
85    /// Expected JSON type: "string", "integer", "number", "boolean", "array", "object"
86    #[serde(rename = "type")]
87    pub field_type: String,
88}
89
90/// Round 38 (#79) — a single file to upload as a multipart form part.
91#[derive(Debug, Clone, Deserialize)]
92pub struct UploadFile {
93    /// Local path to the file; bytes are read at request time.
94    pub path: String,
95    /// `Content-Type` for this part. Common values:
96    /// `application/octet-stream`, `image/jpeg`, `application/json`,
97    /// `application/xml`.
98    #[serde(default = "default_upload_content_type")]
99    pub content_type: String,
100    /// Multipart form field name. Defaults to `"file"`.
101    #[serde(default = "default_upload_field_name")]
102    pub field_name: String,
103    /// Filename announced to the server. Defaults to the basename
104    /// of `path`.
105    #[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/// Round 38 (#79) — what to capture from a check's response.
117#[derive(Debug, Clone, Default, Deserialize)]
118pub struct ExtractRules {
119    /// Cookie names to capture from `Set-Cookie`. Stored under
120    /// `${cookie:NAME}`.
121    #[serde(default)]
122    pub cookies: Vec<String>,
123    /// Response headers to capture (var_name -> header_name). Header
124    /// name is case-insensitive. Stored under `${var:VAR_NAME}`.
125    #[serde(default)]
126    pub headers: std::collections::HashMap<String, String>,
127    /// JSON body fields by simple dotted path. Stored under
128    /// `${var:VAR_NAME}`.
129    #[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/// Round 38 (#79) — repeat semantics for a single custom check.
140#[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/// Round 38 (#79) — sequential vs parallel repeat.
168#[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    /// Parse a custom conformance config from a YAML file
178    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    /// Generate a k6 `group('Custom', ...)` block for all custom checks.
196    ///
197    /// `base_url` is the JS expression for the base URL (e.g., `"BASE_URL"`).
198    /// `custom_headers` are additional headers to inject into every request.
199    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    /// Round 39 (#79) — splits the k6 emit into the init-scope code
204    /// (`open(...)` calls for file uploads) and the per-VU group body
205    /// (the `group('Custom', ...)` block). Caller is responsible for
206    /// placing `init_code` at the top of the script (before
207    /// `export default function`) and `group_body` inside the default
208    /// function. For backwards compatibility, `generate_k6_group` and
209    /// `generate_k6_group_with_options` concatenate the two so existing
210    /// code paths keep working — but they will emit the `open()` calls
211    /// inside the VU function, which k6 rejects at runtime for
212    /// uploads. Use `emit_k6_with_options` directly when uploads are
213    /// expected.
214    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    /// Generate a k6 `group('Custom', ...)` block for all custom checks.
239    /// When `export_requests` is true, emits `__captureExchange` calls after each request.
240    ///
241    /// **Round 39 (#79) — file uploads need `open()` at init scope, so
242    /// callers that may receive an `upload`/`uploads` YAML should
243    /// switch to `emit_k6_with_options` and splice `init_code`
244    /// separately.** This method is kept for backwards compatibility
245    /// and concatenates init + body — which crashes k6 at runtime when
246    /// uploads are configured.
247    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        // Backwards compat: concat both into one string. k6 will fail
255        // at runtime if `open()` calls land inside the VU function,
256        // but tests / call paths that never set `upload`/`uploads`
257        // produce the same output as before round 39.
258        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/// Round 39 (#79) — split output from the k6 emitter so the caller can
266/// place `init_code` at script-init scope (where k6's `open()` must
267/// live) and `group_body` inside `export default function`.
268#[derive(Debug, Default, Clone)]
269pub struct K6CustomEmit {
270    pub init_code: String,
271    pub group_body: String,
272}
273
274/// Round 39 (#79) — escape a Rust string for safe embedding in a JS
275/// single-quoted string literal. Wrapping `String::push_str` calls
276/// already produce template-literal text, so this is for the simple
277/// `'...'` body / value case.
278fn 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/// Round 39 (#79) — escape a Rust string for embedding inside a k6
287/// template literal (\`...\`). Backticks and `${` must both be
288/// escaped to avoid breaking out of the literal or starting an
289/// unintended interpolation. Currently unused since
290/// `substitute_chain_tokens` does the full template-literal escape +
291/// substitute in one pass; kept around for future call sites that
292/// want a plain escape without substitution.
293#[allow(dead_code)]
294fn js_escape_tpl(s: &str) -> String {
295    s.replace('\\', "\\\\").replace('`', "\\`").replace("${", "\\${")
296}
297
298/// Round 39 (#79) — translate `${var:NAME}` / `${cookie:NAME}` /
299/// `${header:NAME}` tokens in `text` to k6 template-literal
300/// interpolations against the chain context variables. The output is
301/// inserted directly into a k6 template literal (\`...\`), so the
302/// caller MUST NOT subsequently call `js_escape_tpl` — the `${...}`
303/// interpolation sequences are intentional. Other characters that
304/// would prematurely close the template literal (backtick, backslash)
305/// are escaped here. Unrecognised `${...}` shapes from the YAML are
306/// escaped to `\\${...}` so they show up as literal text in the
307/// request log without breaking out into JS.
308fn 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        // Escape any backtick or backslash in the prefix before the `${`.
313        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                // Intentional interpolation; preserved verbatim so k6
334                // resolves it at run time against the chain context.
335                out.push_str(&replacement);
336            } else {
337                // Unknown `${...}` shape from YAML — escape `${` so k6
338                // sees the literal text instead of trying to evaluate
339                // an undefined JS expression.
340                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    // Trailing text — same escape pass as the prefix.
352    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
362/// Round 39 (#79) — JS identifier-safe form of a YAML name. Used to
363/// build per-token chain-context variable names that won't collide
364/// with reserved words or contain illegal characters.
365fn sanitize_js_ident(name: &str) -> String {
366    name.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect()
367}
368
369/// Round 39 (#79) — build the JS object literal for a request's
370/// headers. When `dynamic` is true the entries are emitted inside a
371/// template literal so `${var:...}` / `${cookie:...}` substitutions
372/// in header values flow through k6's own template-literal
373/// interpolation against the `__ctx_var_X` / `__ctx_cookie_X`
374/// captured variables.
375fn 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                // Round 39 — substitute_chain_tokens already produces
385                // template-literal-safe text (backticks + unknown
386                // ${...} are escaped, intentional ${__ctx_*} kept).
387                // Double-escaping with js_escape_tpl would break the
388                // intentional interpolations.
389                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
398/// Round 39 (#79) — scan every `${var:NAME}` / `${cookie:NAME}` /
399/// `${header:NAME}` token referenced from check headers / path / body
400/// and return the unique chain-context variable names that must be
401/// pre-declared before the first request. Without this, k6 throws
402/// `ReferenceError: __ctx_cookie_session is not defined` when the
403/// chain emits a request that reads a captured value the prior check
404/// would have written.
405fn 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        // Also pre-declare anything the extract block names so a
440        // later check that references it via `${var:...}` even though
441        // the previous check failed still has a defined variable
442        // (k6's ReferenceError is harsher than the native executor's
443        // "keep the literal token" fallback).
444        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/// Round 39 (#79) — emit the k6 `group('Custom', ...)` block plus any
458/// init-scope code (`open()` for file uploads) into the caller's
459/// buffers. This is where the round-38 native-only features
460/// (`upload` / `uploads`, `extract`, `repeat`, `chain_iterations`)
461/// finally make it into the k6 script Srikanth runs under
462/// `--use-k6`.
463#[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    // Declare every chain-context variable referenced by any check's
481    // path / headers / body / extract rules at the top of the
482    // iteration. k6 throws `ReferenceError: __ctx_cookie_X is not
483    // defined` if a `${cookie:X}` substitution lands before the
484    // capturing extract has fired — common when a chain's first
485    // check itself reads a value the second check is supposed to
486    // capture (defensive substitution against a prior iteration's
487    // state). `let undefined` resolves to the literal string
488    // "undefined" inside a template literal, which is at least
489    // visible to the user reading the request log.
490    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        // Build headers object (string concatenation under
506        // substitution so captured ${var:...} values flow in).
507        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        // Auto-add JSON Content-Type when a body is set AND we're not
517        // doing a multipart upload (k6 sets the boundary header
518        // itself for multipart forms).
519        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        // Round 39 — substitute_chain_tokens already returns
530        // template-literal-safe text; don't escape it again.
531        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        // Round 39 — uploads (multipart/form-data). The bytes are
536        // pre-loaded at init scope via `open()` so k6 accepts them.
537        // We always emit `http.post(url, form, params)` for a check
538        // that has uploads, regardless of the YAML's `method` field;
539        // multipart with a non-POST method is unusual but the spec
540        // technically allows it, so we honour `method` if provided.
541        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        // Build the request line(s) — sequential / parallel via
581        // repeat.count + repeat.mode.
582        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                // Round 39 — already template-literal-safe.
588                format!("`{}`", substituted)
589            }
590            None => "null".to_string(),
591        };
592
593        if let Some(form_name) = &form_var {
594            // Multipart uploads ignore body (mutually exclusive in
595            // native; warn on stderr for k6 too via a JS comment).
596            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            // Parallel uploads: emit http.batch with multipart entries.
603            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            // Non-multipart path: respect HTTP method.
638            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        // Round 39 — emit chain extraction from `res` after the
699        // request line(s). For parallel/sequential repeats, this
700        // captures from the LAST `res` in scope, matching the native
701        // executor's "first/last hit" semantics (k6 doesn't guarantee
702        // batch completion order so we explicitly use index 0).
703        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
717/// Round 39 (#79) — emit assertions + capture for one request slot.
718/// Pulled out of `write_k6_group_body` so the parallel-batch / repeat
719/// branches can share the same assertion code without duplication.
720fn 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
766/// Round 39 (#79) — emit JS that captures `extract:` rules off `res`
767/// into the chain context. Cookies pull from `res.cookies[name][0].value`
768/// (k6's parsed cookie shape); headers use a case-insensitive lookup
769/// over `Object.keys(res.headers)`; body fields traverse a parsed JSON
770/// body via a simple dotted-path walk.
771fn 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            // Walk the path one segment at a time so a missing
795            // intermediate key short-circuits to undefined.
796            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    // Round 39 — promote the captured-var references to `let` so they
807    // live across iterations / checks in the same group. Done at the
808    // start of the next request's substitution by referring directly
809    // to `__ctx_var_X` / `__ctx_cookie_X` (which we declared via
810    // `__ctx_vars = {}` + writes above).
811    let _ = group_body;
812}
813
814/// Generate a JavaScript expression to access a field in a parsed JSON body.
815///
816/// Supports three path formats:
817/// - Simple key: `"name"` → `JSON.parse(r.body)['name']`
818/// - Dot-notation: `"config.enabled"` → `JSON.parse(r.body)['config']['enabled']`
819/// - Array bracket: `"items[].id"` → `JSON.parse(r.body)['items'][0]['id']`
820fn generate_field_accessor(field_name: &str) -> String {
821    // Split on dots, handling [] array notation
822    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            // Array field — access the array then index first element
828            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        // Status check should call __captureFailure on failure
1007        assert!(
1008            script.contains("__captureFailure('custom:capture-test', res, 'status === 200')"),
1009            "Status check should emit __captureFailure"
1010        );
1011        // Header check should call __captureFailure on failure
1012        assert!(
1013            script.contains("__captureFailure('custom:capture-test:header:X-Rate-Limit'"),
1014            "Header check should emit __captureFailure"
1015        );
1016        // Body field check should call __captureFailure on failure
1017        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        // Simple field should use direct bracket access
1110        assert!(script.contains("JSON.parse(r.body)['count']"));
1111        // Nested array field should use [0] for array traversal
1112        assert!(script.contains("JSON.parse(r.body)['results'][0]['name']"));
1113    }
1114}