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    // Round 41 (#79) — Srikanth on 0.3.185: with `extract.cookies` and
474    // `${cookie:NAME}` substitution working, k6's per-VU cookie jar
475    // ALSO auto-collected the response's `Set-Cookie` and re-injected
476    // it on every subsequent request — so the POST went out with TWO
477    // copies of `albsessid` in its `Cookie:` header (one from our
478    // explicit substitution, one from the jar). Disable the auto-jar
479    // for custom checks by passing a fresh empty `jar` per request.
480    // The explicit `Cookie:` header set by `${cookie:NAME}`
481    // substitution becomes the only source of truth. Also addresses
482    // Srikanth's round-41 ask "have ... without Cookie in GET": with
483    // no jar, the GET in iteration K+1 does not inherit cookies from
484    // iteration K's responses unless the user explicitly forwards
485    // them via `${cookie:NAME}`. The `http.CookieJar` constructor
486    // exists in k6 1.0+ (k6 wraps the underlying `cookiejar` Go type
487    // as a JS class — `new http.CookieJar()` creates an empty one
488    // with no shared state with the VU default jar).
489    let uses_cookie_substitution = config.custom_checks.iter().any(|c| {
490        !c.extract.cookies.is_empty()
491            || c.headers.values().any(|v| v.contains("${cookie:") || v.contains("${var:"))
492    });
493    if uses_cookie_substitution {
494        init_code.push_str(
495            "// Round 41 (#79) — declared once so every chain request can reuse it;\n\
496             // a fresh empty jar suppresses k6's auto-injected Set-Cookie that would\n\
497             // otherwise duplicate the explicit `${cookie:NAME}` substitution.\n\
498             const __custom_jar_factory = () => new http.CookieJar();\n",
499        );
500    }
501    group_body.push_str("  group('Custom', function () {\n");
502    let iters = config.chain_iterations.max(1);
503
504    // Declare every chain-context variable referenced by any check's
505    // path / headers / body / extract rules.
506    //
507    // Round 39 (#79) — k6 throws `ReferenceError: __ctx_cookie_X is not
508    // defined` if a `${cookie:X}` substitution lands before the
509    // capturing extract has fired (e.g. a chain's first check reads a
510    // value the second check is supposed to capture, or a defensive
511    // substitution against a prior iteration's state). The empty
512    // string resolves to the literal "" inside a template literal so
513    // the request log shows a missing capture as `Cookie: NAME=` not
514    // a runtime crash.
515    //
516    // Round 44 (#79) — Srikanth on 0.3.188: with `chain_iterations: 3`
517    // and a `${cookie:albsessid}` on the GET, all 3 GETs sent an
518    // EMPTY cookie value even though the POST extract was capturing
519    // albsessid correctly. v0.3.186 hoisted the `let __ctx_cookie_*`
520    // declarations INSIDE the `for (let __iter ...) {}` loop, so each
521    // iteration created a fresh `__ctx_cookie_albsessid = ''` that
522    // shadowed whatever the previous iteration had captured. Lifted
523    // them ABOVE the for-loop now so a single binding persists across
524    // every iteration and capture in iteration N is visible to GET in
525    // iteration N+1.
526    let (ctx_vars, ctx_cookies) = collect_referenced_ctx_idents(config);
527    let needs_ctx = iters > 1 || !ctx_vars.is_empty() || !ctx_cookies.is_empty();
528    if needs_ctx {
529        group_body.push_str("    // Round 44 chain context — pre-declared at GROUP scope (outside the iter loop) so captures from iteration N persist into iteration N+1\n");
530        for var in &ctx_vars {
531            group_body.push_str(&format!("    let __ctx_var_{} = '';\n", var));
532        }
533        for cookie in &ctx_cookies {
534            group_body.push_str(&format!("    let __ctx_cookie_{} = '';\n", cookie));
535        }
536    }
537
538    if iters > 1 {
539        group_body
540            .push_str(&format!("    for (let __iter = 0; __iter < {}; __iter++) {{\n", iters));
541    }
542
543    for (check_idx, check) in config.custom_checks.iter().enumerate() {
544        group_body.push_str("    {\n");
545
546        // Build headers object (string concatenation under
547        // substitution so captured ${var:...} values flow in).
548        let mut all_headers: Vec<(String, String)> = Vec::new();
549        for (k, v) in &check.headers {
550            all_headers.push((k.clone(), v.clone()));
551        }
552        for (k, v) in custom_headers {
553            if !check.headers.contains_key(k) {
554                all_headers.push((k.clone(), v.clone()));
555            }
556        }
557        // Auto-add JSON Content-Type when a body is set AND we're not
558        // doing a multipart upload (k6 sets the boundary header
559        // itself for multipart forms).
560        let is_upload = check.upload.is_some() || !check.uploads.is_empty();
561        if check.body.is_some()
562            && !is_upload
563            && !all_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
564        {
565            all_headers.push(("Content-Type".to_string(), "application/json".to_string()));
566        }
567
568        let headers_js = build_headers_object_js(&all_headers, needs_ctx);
569        // Round 41 (#79) — wrap headers + jar into a `params` object
570        // so each request can carry its own fresh CookieJar and the
571        // VU's shared jar can't double up cookies that the user is
572        // already injecting via `${cookie:NAME}`.
573        let params_js = if uses_cookie_substitution {
574            format!("{{ headers: {}, jar: __custom_jar_factory() }}", headers_js)
575        } else {
576            format!("{{ headers: {} }}", headers_js)
577        };
578        let method = check.method.to_uppercase();
579        // Round 39 — substitute_chain_tokens already returns
580        // template-literal-safe text; don't escape it again.
581        let url_substituted = substitute_chain_tokens(&check.path);
582        let url = format!("${{{}}}{}", base_url, url_substituted);
583        let escaped_name = check.name.replace('\'', "\\'");
584
585        // Round 39 — uploads (multipart/form-data). The bytes are
586        // pre-loaded at init scope via `open()` so k6 accepts them.
587        // We always emit `http.post(url, form, params)` for a check
588        // that has uploads, regardless of the YAML's `method` field;
589        // multipart with a non-POST method is unusual but the spec
590        // technically allows it, so we honour `method` if provided.
591        let upload_specs: Vec<&UploadFile> =
592            check.upload.iter().chain(check.uploads.iter()).collect();
593        let form_var = if !upload_specs.is_empty() {
594            let mut form_entries: Vec<String> = Vec::with_capacity(upload_specs.len());
595            for spec in &upload_specs {
596                let var = format!("__file_{}", *upload_counter);
597                *upload_counter += 1;
598                let filename = spec.filename.clone().unwrap_or_else(|| {
599                    Path::new(&spec.path)
600                        .file_name()
601                        .and_then(|n| n.to_str())
602                        .unwrap_or("upload.bin")
603                        .to_string()
604                });
605                init_code.push_str(&format!(
606                    "// Round 39 #79 — preload upload file for `{}`\nconst {} = open('{}', 'b');\n",
607                    check.name,
608                    var,
609                    js_escape_sq(&spec.path),
610                ));
611                form_entries.push(format!(
612                    "'{}': http.file({}, '{}', '{}')",
613                    js_escape_sq(&spec.field_name),
614                    var,
615                    js_escape_sq(&filename),
616                    js_escape_sq(&spec.content_type),
617                ));
618            }
619            let form_name = format!("__form_{}", check_idx);
620            group_body.push_str(&format!(
621                "      let {} = {{ {} }};\n",
622                form_name,
623                form_entries.join(", ")
624            ));
625            // Round 41 (#79) — Srikanth on 0.3.185: PCAP only showed
626            // 5 of his 9 multi-file upload parts and he asked "Is
627            // there a way from Logs I can confirm all the files in
628            // multiple upload are sent from mockforge client". Emit a
629            // single tagged log line per request listing every part,
630            // so the user can grep `MOCKFORGE_UPLOAD_PARTS` in the
631            // k6 stdout and confirm all parts left mockforge even
632            // when their proxy / capture tool drops some. We do this
633            // here (NOT inside the repeat loop) because the form is
634            // identical across repeats; the count from `repeat` tells
635            // the user how many requests will go out.
636            // Round 47 (#79) — Srikanth on 0.3.191: "Only thing is file
637            // bytes are incorrect" — the captureExchange summary was
638            // deriving byte counts from `partBody.length`, which is JS
639            // UTF-16 code units rather than the original on-disk byte
640            // count. For binary content (PDF / mp4 / docx etc) k6's
641            // `res.request.body` lossy-decodes bytes into a UTF-16
642            // string where multi-byte sequences collapse to single
643            // code units, undercounting by ~3-4%. The accurate fix is
644            // to NOT derive from the JS string at all: Rust knows the
645            // exact on-disk size, so emit a side-channel JS map keyed
646            // by check name + field name. captureExchange looks the
647            // size up when summarising. ASCII-only files (json / zip
648            // header / text) round-tripped fine before, but binary
649            // files were the visible miss.
650            let summary_entries: Vec<String> = upload_specs
651                .iter()
652                .map(|spec| {
653                    let filename = spec.filename.clone().unwrap_or_else(|| {
654                        Path::new(&spec.path)
655                            .file_name()
656                            .and_then(|n| n.to_str())
657                            .unwrap_or("upload.bin")
658                            .to_string()
659                    });
660                    format!(
661                        "'{}':'{}' ({})",
662                        js_escape_sq(&spec.field_name),
663                        js_escape_sq(&filename),
664                        js_escape_sq(&spec.content_type)
665                    )
666                })
667                .collect();
668            // Stat each file at script-gen time so the size map lives
669            // at init scope, NOT inside the per-request capture path.
670            // Best-effort: if metadata fails, fall through with `null`
671            // and captureExchange falls back to the JS-string length.
672            let mut size_map_entries: Vec<String> = Vec::with_capacity(upload_specs.len());
673            for spec in &upload_specs {
674                let bytes = std::fs::metadata(&spec.path).map(|m| m.len()).ok();
675                let bytes_js = bytes.map(|b| b.to_string()).unwrap_or_else(|| "null".to_string());
676                size_map_entries.push(format!(
677                    "'{}': {}",
678                    js_escape_sq(&spec.field_name),
679                    bytes_js
680                ));
681            }
682            // Hoist the per-check map into init scope so it survives
683            // VU lifecycle without any extra wiring; captureExchange
684            // looks up by `checkName`.
685            init_code.push_str(&format!(
686                "// Round 47 #79 — on-disk byte sizes for upload check `{}`\nif (typeof globalThis.__mfUploadSizes === 'undefined') globalThis.__mfUploadSizes = {{}};\nglobalThis.__mfUploadSizes['{}'] = {{ {} }};\n",
687                check.name,
688                js_escape_sq(&check.name),
689                size_map_entries.join(", "),
690            ));
691            group_body.push_str(&format!(
692                "      console.log('MOCKFORGE_UPLOAD_PARTS: {} {} files: {}');\n",
693                js_escape_sq(&check.name),
694                upload_specs.len(),
695                js_escape_sq(&summary_entries.join(", ")),
696            ));
697            Some(form_name)
698        } else {
699            None
700        };
701
702        // Build the request line(s) — sequential / parallel via
703        // repeat.count + repeat.mode.
704        let count = check.repeat.count.max(1);
705        let is_parallel = count > 1 && matches!(check.repeat.mode, RepeatMode::Parallel);
706        let body_expr = match &check.body {
707            Some(b) => {
708                let substituted = substitute_chain_tokens(b);
709                // Round 39 — already template-literal-safe.
710                format!("`{}`", substituted)
711            }
712            None => "null".to_string(),
713        };
714
715        if let Some(form_name) = &form_var {
716            // Multipart uploads ignore body (mutually exclusive in
717            // native; warn on stderr for k6 too via a JS comment).
718            if check.body.is_some() {
719                group_body.push_str(&format!(
720                    "      // warning: custom check '{}' has both `body` and `upload`/`uploads`; ignoring body\n",
721                    check.name
722                ));
723            }
724            // Parallel uploads: emit http.batch with multipart entries.
725            if is_parallel {
726                group_body.push_str(&format!(
727                    "      let __batch_{} = []; for (let __r = 0; __r < {}; __r++) {{ __batch_{}.push({{ method: 'POST', url: `{}`, body: {}, params: {} }}); }}\n",
728                    check_idx, count, check_idx, url, form_name, params_js
729                ));
730                group_body.push_str(&format!(
731                    "      let __responses_{} = http.batch(__batch_{});\n",
732                    check_idx, check_idx
733                ));
734                group_body.push_str(&format!("      let res = __responses_{}[0];\n", check_idx));
735                emit_check_assertions(group_body, &escaped_name, check, export_requests);
736                group_body.push_str(&format!(
737                    "      for (let __i = 1; __i < __responses_{}.length; __i++) {{ let res = __responses_{}[__i];",
738                    check_idx, check_idx
739                ));
740                emit_check_assertions(group_body, &escaped_name, check, export_requests);
741                group_body.push_str(" }\n");
742            } else if count > 1 {
743                group_body
744                    .push_str(&format!("      for (let __r = 0; __r < {}; __r++) {{\n", count));
745                group_body.push_str(&format!(
746                    "        let res = http.post(`{}`, {}, {});\n",
747                    url, form_name, params_js
748                ));
749                emit_check_assertions(group_body, &escaped_name, check, export_requests);
750                group_body.push_str("      }\n");
751            } else {
752                group_body.push_str(&format!(
753                    "      let res = http.post(`{}`, {}, {});\n",
754                    url, form_name, params_js
755                ));
756                emit_check_assertions(group_body, &escaped_name, check, export_requests);
757            }
758        } else {
759            // Non-multipart path: respect HTTP method.
760            let k6_method = match method.as_str() {
761                "DELETE" => "del".to_string(),
762                other => other.to_lowercase(),
763            };
764            let body_method = !matches!(method.as_str(), "GET" | "HEAD" | "OPTIONS" | "DELETE");
765            // Round 41 — always pass `params_js` (which carries the
766            // jar) when chain-context substitution is in play, even
767            // on requests that have no headers of their own. This is
768            // how the GET in the user's chain gets `jar: empty` so
769            // k6 doesn't accumulate Set-Cookie into the VU jar.
770            let request_call = if body_method {
771                format!("http.{}(`{}`, {}, {})", k6_method, url, body_expr, params_js)
772            } else if all_headers.is_empty() && !uses_cookie_substitution {
773                format!("http.{}(`{}`)", k6_method, url)
774            } else {
775                format!("http.{}(`{}`, {})", k6_method, url, params_js)
776            };
777
778            if is_parallel {
779                let entry_method = match method.as_str() {
780                    "DELETE" => "DELETE",
781                    "GET" => "GET",
782                    "HEAD" => "HEAD",
783                    "OPTIONS" => "OPTIONS",
784                    "PUT" => "PUT",
785                    "PATCH" => "PATCH",
786                    "POST" => "POST",
787                    _ => "POST",
788                };
789                let body_field = if body_method {
790                    format!("body: {}, ", body_expr)
791                } else {
792                    String::new()
793                };
794                group_body.push_str(&format!(
795                    "      let __batch_{} = []; for (let __r = 0; __r < {}; __r++) {{ __batch_{}.push({{ method: '{}', url: `{}`, {}params: {} }}); }}\n",
796                    check_idx, count, check_idx, entry_method, url, body_field, params_js
797                ));
798                group_body.push_str(&format!(
799                    "      let __responses_{} = http.batch(__batch_{});\n",
800                    check_idx, check_idx
801                ));
802                group_body.push_str(&format!("      let res = __responses_{}[0];\n", check_idx));
803                emit_check_assertions(group_body, &escaped_name, check, export_requests);
804                group_body.push_str(&format!(
805                    "      for (let __i = 1; __i < __responses_{}.length; __i++) {{ let res = __responses_{}[__i];",
806                    check_idx, check_idx
807                ));
808                emit_check_assertions(group_body, &escaped_name, check, export_requests);
809                group_body.push_str(" }\n");
810            } else if count > 1 {
811                group_body
812                    .push_str(&format!("      for (let __r = 0; __r < {}; __r++) {{\n", count));
813                group_body.push_str(&format!("        let res = {};\n", request_call));
814                emit_check_assertions(group_body, &escaped_name, check, export_requests);
815                group_body.push_str("      }\n");
816            } else {
817                group_body.push_str(&format!("      let res = {};\n", request_call));
818                emit_check_assertions(group_body, &escaped_name, check, export_requests);
819            }
820        }
821
822        // Round 39 — emit chain extraction from `res` after the
823        // request line(s). For parallel/sequential repeats, this
824        // captures from the LAST `res` in scope, matching the native
825        // executor's "first/last hit" semantics (k6 doesn't guarantee
826        // batch completion order so we explicitly use index 0).
827        if !check.extract.is_empty() {
828            emit_chain_extract(group_body, &check.extract);
829        }
830
831        group_body.push_str("    }\n");
832    }
833
834    if iters > 1 {
835        group_body.push_str("    }\n");
836    }
837
838    group_body.push_str("  });\n\n");
839}
840
841/// Round 39 (#79) — emit assertions + capture for one request slot.
842/// Pulled out of `write_k6_group_body` so the parallel-batch / repeat
843/// branches can share the same assertion code without duplication.
844fn emit_check_assertions(
845    group_body: &mut String,
846    escaped_name: &str,
847    check: &CustomCheck,
848    export_requests: bool,
849) {
850    if export_requests {
851        group_body.push_str(&format!(
852            "      if (typeof __captureExchange === 'function') __captureExchange('{}', res);\n",
853            escaped_name
854        ));
855    }
856    group_body.push_str(&format!(
857        "      {{ let ok = check(res, {{ '{}': (r) => r.status === {} }}); if (!ok) __captureFailure('{}', res, 'status === {}'); }}\n",
858        escaped_name, check.expected_status, escaped_name, check.expected_status
859    ));
860    for (header_name, pattern) in &check.expected_headers {
861        let header_check_name = format!("{}:header:{}", escaped_name, header_name);
862        let escaped_pattern = js_escape_sq(pattern);
863        let header_lower = header_name.to_lowercase();
864        group_body.push_str(&format!(
865            "      {{ let ok = check(res, {{ '{}': (r) => {{ const _hk = Object.keys(r.headers || {{}}).find(k => k.toLowerCase() === '{}'); return new RegExp('{}').test(_hk ? r.headers[_hk] : ''); }} }}); if (!ok) __captureFailure('{}', res, 'header {} matches /{}/'); }}\n",
866            header_check_name, header_lower, escaped_pattern, header_check_name, header_name, escaped_pattern
867        ));
868    }
869    for field in &check.expected_body_fields {
870        let field_check_name = format!("{}:body:{}:{}", escaped_name, field.name, field.field_type);
871        let accessor = generate_field_accessor(&field.name);
872        let type_check = match field.field_type.as_str() {
873            "string" => format!("typeof ({}) === 'string'", accessor),
874            "integer" => format!("Number.isInteger({})", accessor),
875            "number" => format!("typeof ({}) === 'number'", accessor),
876            "boolean" => format!("typeof ({}) === 'boolean'", accessor),
877            "array" => format!("Array.isArray({})", accessor),
878            "object" => {
879                format!("typeof ({}) === 'object' && !Array.isArray({})", accessor, accessor)
880            }
881            _ => format!("({}) !== undefined", accessor),
882        };
883        group_body.push_str(&format!(
884            "      {{ let ok = check(res, {{ '{}': (r) => {{ try {{ return {}; }} catch(e) {{ return false; }} }} }}); if (!ok) __captureFailure('{}', res, 'body field {} is {}'); }}\n",
885            field_check_name, type_check, field_check_name, field.name, field.field_type
886        ));
887    }
888}
889
890/// Round 39 (#79) — emit JS that captures `extract:` rules off `res`
891/// into the chain context. Cookies pull from `res.cookies[name][0].value`
892/// (k6's parsed cookie shape); headers use a case-insensitive lookup
893/// over `Object.keys(res.headers)`; body fields traverse a parsed JSON
894/// body via a simple dotted-path walk.
895fn emit_chain_extract(group_body: &mut String, rules: &ExtractRules) {
896    for cookie_name in &rules.cookies {
897        let var = sanitize_js_ident(cookie_name);
898        group_body.push_str(&format!(
899            "      if (res.cookies && res.cookies['{}'] && res.cookies['{}'][0]) {{ __ctx_cookie_{} = res.cookies['{}'][0].value; }}\n",
900            js_escape_sq(cookie_name),
901            js_escape_sq(cookie_name),
902            var,
903            js_escape_sq(cookie_name),
904        ));
905    }
906    for (var_name, header_name) in &rules.headers {
907        let var = sanitize_js_ident(var_name);
908        let header_lower = header_name.to_lowercase();
909        group_body.push_str(&format!(
910            "      {{ const _hk = Object.keys(res.headers || {{}}).find(k => k.toLowerCase() === '{}'); if (_hk) {{ __ctx_var_{} = res.headers[_hk]; }} }}\n",
911            js_escape_sq(&header_lower), var
912        ));
913    }
914    if !rules.body_fields.is_empty() {
915        group_body.push_str("      try { let __body_json = JSON.parse(res.body || 'null');\n");
916        for (var_name, dotted) in &rules.body_fields {
917            let var = sanitize_js_ident(var_name);
918            // Walk the path one segment at a time so a missing
919            // intermediate key short-circuits to undefined.
920            let segments: Vec<String> =
921                dotted.split('.').map(|s| format!("['{}']", js_escape_sq(s))).collect();
922            let accessor = format!("__body_json{}", segments.join(""));
923            group_body.push_str(&format!(
924                "        try {{ const __v = {}; if (__v !== undefined && __v !== null) __ctx_var_{} = String(__v); }} catch(e) {{}}\n",
925                accessor, var
926            ));
927        }
928        group_body.push_str("      } catch(e) {}\n");
929    }
930    // Round 39 — promote the captured-var references to `let` so they
931    // live across iterations / checks in the same group. Done at the
932    // start of the next request's substitution by referring directly
933    // to `__ctx_var_X` / `__ctx_cookie_X` (which we declared via
934    // `__ctx_vars = {}` + writes above).
935    let _ = group_body;
936}
937
938/// Generate a JavaScript expression to access a field in a parsed JSON body.
939///
940/// Supports three path formats:
941/// - Simple key: `"name"` → `JSON.parse(r.body)['name']`
942/// - Dot-notation: `"config.enabled"` → `JSON.parse(r.body)['config']['enabled']`
943/// - Array bracket: `"items[].id"` → `JSON.parse(r.body)['items'][0]['id']`
944fn generate_field_accessor(field_name: &str) -> String {
945    // Split on dots, handling [] array notation
946    let parts: Vec<&str> = field_name.split('.').collect();
947    let mut expr = String::from("JSON.parse(r.body)");
948
949    for part in &parts {
950        if let Some(arr_name) = part.strip_suffix("[]") {
951            // Array field — access the array then index first element
952            expr.push_str(&format!("['{}'][0]", arr_name));
953        } else {
954            expr.push_str(&format!("['{}']", part));
955        }
956    }
957
958    expr
959}
960
961#[cfg(test)]
962mod tests {
963    use super::*;
964
965    #[test]
966    fn test_parse_custom_yaml() {
967        let yaml = r#"
968custom_checks:
969  - name: "custom:pets-returns-200"
970    path: /pets
971    method: GET
972    expected_status: 200
973  - name: "custom:create-product"
974    path: /api/products
975    method: POST
976    expected_status: 201
977    body: '{"sku": "TEST-001", "name": "Test"}'
978    expected_body_fields:
979      - name: id
980        type: integer
981    expected_headers:
982      content-type: "application/json"
983"#;
984        let config: CustomConformanceConfig = serde_yaml::from_str(yaml).unwrap();
985        assert_eq!(config.custom_checks.len(), 2);
986        assert_eq!(config.custom_checks[0].name, "custom:pets-returns-200");
987        assert_eq!(config.custom_checks[0].expected_status, 200);
988        assert_eq!(config.custom_checks[1].expected_body_fields.len(), 1);
989        assert_eq!(config.custom_checks[1].expected_body_fields[0].name, "id");
990        assert_eq!(config.custom_checks[1].expected_body_fields[0].field_type, "integer");
991    }
992
993    #[test]
994    fn test_generate_k6_group_get() {
995        let config = CustomConformanceConfig {
996            custom_checks: vec![CustomCheck {
997                name: "custom:test-get".to_string(),
998                path: "/api/test".to_string(),
999                method: "GET".to_string(),
1000                expected_status: 200,
1001                body: None,
1002                expected_headers: std::collections::HashMap::new(),
1003                expected_body_fields: vec![],
1004                headers: std::collections::HashMap::new(),
1005                upload: None,
1006                uploads: vec![],
1007                extract: ExtractRules::default(),
1008                repeat: Repeat::default(),
1009            }],
1010            chain_iterations: 1,
1011        };
1012
1013        let script = config.generate_k6_group("BASE_URL", &[]);
1014        assert!(script.contains("group('Custom'"));
1015        assert!(script.contains("http.get(`${BASE_URL}/api/test`)"));
1016        assert!(script.contains("'custom:test-get': (r) => r.status === 200"));
1017    }
1018
1019    #[test]
1020    fn test_generate_k6_group_post_with_body() {
1021        let config = CustomConformanceConfig {
1022            custom_checks: vec![CustomCheck {
1023                name: "custom:create".to_string(),
1024                path: "/api/items".to_string(),
1025                method: "POST".to_string(),
1026                expected_status: 201,
1027                body: Some(r#"{"name": "test"}"#.to_string()),
1028                expected_headers: std::collections::HashMap::new(),
1029                expected_body_fields: vec![ExpectedBodyField {
1030                    name: "id".to_string(),
1031                    field_type: "integer".to_string(),
1032                }],
1033                headers: std::collections::HashMap::new(),
1034                upload: None,
1035                uploads: vec![],
1036                extract: ExtractRules::default(),
1037                repeat: Repeat::default(),
1038            }],
1039            chain_iterations: 1,
1040        };
1041
1042        let script = config.generate_k6_group("BASE_URL", &[]);
1043        assert!(script.contains("http.post("));
1044        assert!(script.contains("'custom:create': (r) => r.status === 201"));
1045        assert!(script.contains("custom:create:body:id:integer"));
1046        assert!(script.contains("Number.isInteger"));
1047    }
1048
1049    #[test]
1050    fn test_generate_k6_group_with_header_checks() {
1051        let mut expected_headers = std::collections::HashMap::new();
1052        expected_headers.insert("content-type".to_string(), "application/json".to_string());
1053
1054        let config = CustomConformanceConfig {
1055            custom_checks: vec![CustomCheck {
1056                name: "custom:header-check".to_string(),
1057                path: "/api/test".to_string(),
1058                method: "GET".to_string(),
1059                expected_status: 200,
1060                body: None,
1061                expected_headers,
1062                expected_body_fields: vec![],
1063                headers: std::collections::HashMap::new(),
1064                upload: None,
1065                uploads: vec![],
1066                extract: ExtractRules::default(),
1067                repeat: Repeat::default(),
1068            }],
1069            chain_iterations: 1,
1070        };
1071
1072        let script = config.generate_k6_group("BASE_URL", &[]);
1073        assert!(script.contains("custom:header-check:header:content-type"));
1074        assert!(script.contains("new RegExp('application/json')"));
1075    }
1076
1077    #[test]
1078    fn test_generate_k6_group_with_custom_headers() {
1079        let config = CustomConformanceConfig {
1080            custom_checks: vec![CustomCheck {
1081                name: "custom:auth-test".to_string(),
1082                path: "/api/secure".to_string(),
1083                method: "GET".to_string(),
1084                expected_status: 200,
1085                body: None,
1086                expected_headers: std::collections::HashMap::new(),
1087                expected_body_fields: vec![],
1088                headers: std::collections::HashMap::new(),
1089                upload: None,
1090                uploads: vec![],
1091                extract: ExtractRules::default(),
1092                repeat: Repeat::default(),
1093            }],
1094            chain_iterations: 1,
1095        };
1096
1097        let custom_headers = vec![("Authorization".to_string(), "Bearer token123".to_string())];
1098        let script = config.generate_k6_group("BASE_URL", &custom_headers);
1099        assert!(script.contains("'Authorization': 'Bearer token123'"));
1100    }
1101
1102    #[test]
1103    fn test_failure_capture_emitted() {
1104        let config = CustomConformanceConfig {
1105            custom_checks: vec![CustomCheck {
1106                name: "custom:capture-test".to_string(),
1107                path: "/api/test".to_string(),
1108                method: "GET".to_string(),
1109                expected_status: 200,
1110                body: None,
1111                expected_headers: {
1112                    let mut m = std::collections::HashMap::new();
1113                    m.insert("X-Rate-Limit".to_string(), ".*".to_string());
1114                    m
1115                },
1116                expected_body_fields: vec![ExpectedBodyField {
1117                    name: "id".to_string(),
1118                    field_type: "integer".to_string(),
1119                }],
1120                headers: std::collections::HashMap::new(),
1121                upload: None,
1122                uploads: vec![],
1123                extract: ExtractRules::default(),
1124                repeat: Repeat::default(),
1125            }],
1126            chain_iterations: 1,
1127        };
1128
1129        let script = config.generate_k6_group("BASE_URL", &[]);
1130        // Status check should call __captureFailure on failure
1131        assert!(
1132            script.contains("__captureFailure('custom:capture-test', res, 'status === 200')"),
1133            "Status check should emit __captureFailure"
1134        );
1135        // Header check should call __captureFailure on failure
1136        assert!(
1137            script.contains("__captureFailure('custom:capture-test:header:X-Rate-Limit'"),
1138            "Header check should emit __captureFailure"
1139        );
1140        // Body field check should call __captureFailure on failure
1141        assert!(
1142            script.contains("__captureFailure('custom:capture-test:body:id:integer'"),
1143            "Body field check should emit __captureFailure"
1144        );
1145    }
1146
1147    #[test]
1148    fn test_from_file_nonexistent() {
1149        let result = CustomConformanceConfig::from_file(Path::new("/nonexistent/file.yaml"));
1150        assert!(result.is_err());
1151        let err = result.unwrap_err().to_string();
1152        assert!(err.contains("Failed to read custom conformance file"));
1153    }
1154
1155    #[test]
1156    fn test_generate_k6_group_delete() {
1157        let config = CustomConformanceConfig {
1158            custom_checks: vec![CustomCheck {
1159                name: "custom:delete-item".to_string(),
1160                path: "/api/items/1".to_string(),
1161                method: "DELETE".to_string(),
1162                expected_status: 204,
1163                body: None,
1164                expected_headers: std::collections::HashMap::new(),
1165                expected_body_fields: vec![],
1166                headers: std::collections::HashMap::new(),
1167                upload: None,
1168                uploads: vec![],
1169                extract: ExtractRules::default(),
1170                repeat: Repeat::default(),
1171            }],
1172            chain_iterations: 1,
1173        };
1174
1175        let script = config.generate_k6_group("BASE_URL", &[]);
1176        assert!(script.contains("http.del("));
1177        assert!(script.contains("r.status === 204"));
1178    }
1179
1180    #[test]
1181    fn test_field_accessor_simple() {
1182        assert_eq!(generate_field_accessor("name"), "JSON.parse(r.body)['name']");
1183    }
1184
1185    #[test]
1186    fn test_field_accessor_nested_dot() {
1187        assert_eq!(
1188            generate_field_accessor("config.enabled"),
1189            "JSON.parse(r.body)['config']['enabled']"
1190        );
1191    }
1192
1193    #[test]
1194    fn test_field_accessor_array_bracket() {
1195        assert_eq!(generate_field_accessor("items[].id"), "JSON.parse(r.body)['items'][0]['id']");
1196    }
1197
1198    #[test]
1199    fn test_field_accessor_deep_nested() {
1200        assert_eq!(generate_field_accessor("a.b.c"), "JSON.parse(r.body)['a']['b']['c']");
1201    }
1202
1203    #[test]
1204    fn test_generate_k6_nested_body_fields() {
1205        let config = CustomConformanceConfig {
1206            custom_checks: vec![CustomCheck {
1207                name: "custom:nested".to_string(),
1208                path: "/api/data".to_string(),
1209                method: "GET".to_string(),
1210                expected_status: 200,
1211                body: None,
1212                expected_headers: std::collections::HashMap::new(),
1213                expected_body_fields: vec![
1214                    ExpectedBodyField {
1215                        name: "count".to_string(),
1216                        field_type: "integer".to_string(),
1217                    },
1218                    ExpectedBodyField {
1219                        name: "results[].name".to_string(),
1220                        field_type: "string".to_string(),
1221                    },
1222                ],
1223                headers: std::collections::HashMap::new(),
1224                upload: None,
1225                uploads: vec![],
1226                extract: ExtractRules::default(),
1227                repeat: Repeat::default(),
1228            }],
1229            chain_iterations: 1,
1230        };
1231
1232        let script = config.generate_k6_group("BASE_URL", &[]);
1233        // Simple field should use direct bracket access
1234        assert!(script.contains("JSON.parse(r.body)['count']"));
1235        // Nested array field should use [0] for array traversal
1236        assert!(script.contains("JSON.parse(r.body)['results'][0]['name']"));
1237    }
1238}