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            let summary_entries: Vec<String> = upload_specs
637                .iter()
638                .map(|spec| {
639                    let filename = spec.filename.clone().unwrap_or_else(|| {
640                        Path::new(&spec.path)
641                            .file_name()
642                            .and_then(|n| n.to_str())
643                            .unwrap_or("upload.bin")
644                            .to_string()
645                    });
646                    format!(
647                        "'{}':'{}' ({})",
648                        js_escape_sq(&spec.field_name),
649                        js_escape_sq(&filename),
650                        js_escape_sq(&spec.content_type)
651                    )
652                })
653                .collect();
654            group_body.push_str(&format!(
655                "      console.log('MOCKFORGE_UPLOAD_PARTS: {} {} files: {}');\n",
656                js_escape_sq(&check.name),
657                upload_specs.len(),
658                js_escape_sq(&summary_entries.join(", ")),
659            ));
660            Some(form_name)
661        } else {
662            None
663        };
664
665        // Build the request line(s) — sequential / parallel via
666        // repeat.count + repeat.mode.
667        let count = check.repeat.count.max(1);
668        let is_parallel = count > 1 && matches!(check.repeat.mode, RepeatMode::Parallel);
669        let body_expr = match &check.body {
670            Some(b) => {
671                let substituted = substitute_chain_tokens(b);
672                // Round 39 — already template-literal-safe.
673                format!("`{}`", substituted)
674            }
675            None => "null".to_string(),
676        };
677
678        if let Some(form_name) = &form_var {
679            // Multipart uploads ignore body (mutually exclusive in
680            // native; warn on stderr for k6 too via a JS comment).
681            if check.body.is_some() {
682                group_body.push_str(&format!(
683                    "      // warning: custom check '{}' has both `body` and `upload`/`uploads`; ignoring body\n",
684                    check.name
685                ));
686            }
687            // Parallel uploads: emit http.batch with multipart entries.
688            if is_parallel {
689                group_body.push_str(&format!(
690                    "      let __batch_{} = []; for (let __r = 0; __r < {}; __r++) {{ __batch_{}.push({{ method: 'POST', url: `{}`, body: {}, params: {} }}); }}\n",
691                    check_idx, count, check_idx, url, form_name, params_js
692                ));
693                group_body.push_str(&format!(
694                    "      let __responses_{} = http.batch(__batch_{});\n",
695                    check_idx, check_idx
696                ));
697                group_body.push_str(&format!("      let res = __responses_{}[0];\n", check_idx));
698                emit_check_assertions(group_body, &escaped_name, check, export_requests);
699                group_body.push_str(&format!(
700                    "      for (let __i = 1; __i < __responses_{}.length; __i++) {{ let res = __responses_{}[__i];",
701                    check_idx, check_idx
702                ));
703                emit_check_assertions(group_body, &escaped_name, check, export_requests);
704                group_body.push_str(" }\n");
705            } else if count > 1 {
706                group_body
707                    .push_str(&format!("      for (let __r = 0; __r < {}; __r++) {{\n", count));
708                group_body.push_str(&format!(
709                    "        let res = http.post(`{}`, {}, {});\n",
710                    url, form_name, params_js
711                ));
712                emit_check_assertions(group_body, &escaped_name, check, export_requests);
713                group_body.push_str("      }\n");
714            } else {
715                group_body.push_str(&format!(
716                    "      let res = http.post(`{}`, {}, {});\n",
717                    url, form_name, params_js
718                ));
719                emit_check_assertions(group_body, &escaped_name, check, export_requests);
720            }
721        } else {
722            // Non-multipart path: respect HTTP method.
723            let k6_method = match method.as_str() {
724                "DELETE" => "del".to_string(),
725                other => other.to_lowercase(),
726            };
727            let body_method = !matches!(method.as_str(), "GET" | "HEAD" | "OPTIONS" | "DELETE");
728            // Round 41 — always pass `params_js` (which carries the
729            // jar) when chain-context substitution is in play, even
730            // on requests that have no headers of their own. This is
731            // how the GET in the user's chain gets `jar: empty` so
732            // k6 doesn't accumulate Set-Cookie into the VU jar.
733            let request_call = if body_method {
734                format!("http.{}(`{}`, {}, {})", k6_method, url, body_expr, params_js)
735            } else if all_headers.is_empty() && !uses_cookie_substitution {
736                format!("http.{}(`{}`)", k6_method, url)
737            } else {
738                format!("http.{}(`{}`, {})", k6_method, url, params_js)
739            };
740
741            if is_parallel {
742                let entry_method = match method.as_str() {
743                    "DELETE" => "DELETE",
744                    "GET" => "GET",
745                    "HEAD" => "HEAD",
746                    "OPTIONS" => "OPTIONS",
747                    "PUT" => "PUT",
748                    "PATCH" => "PATCH",
749                    "POST" => "POST",
750                    _ => "POST",
751                };
752                let body_field = if body_method {
753                    format!("body: {}, ", body_expr)
754                } else {
755                    String::new()
756                };
757                group_body.push_str(&format!(
758                    "      let __batch_{} = []; for (let __r = 0; __r < {}; __r++) {{ __batch_{}.push({{ method: '{}', url: `{}`, {}params: {} }}); }}\n",
759                    check_idx, count, check_idx, entry_method, url, body_field, params_js
760                ));
761                group_body.push_str(&format!(
762                    "      let __responses_{} = http.batch(__batch_{});\n",
763                    check_idx, check_idx
764                ));
765                group_body.push_str(&format!("      let res = __responses_{}[0];\n", check_idx));
766                emit_check_assertions(group_body, &escaped_name, check, export_requests);
767                group_body.push_str(&format!(
768                    "      for (let __i = 1; __i < __responses_{}.length; __i++) {{ let res = __responses_{}[__i];",
769                    check_idx, check_idx
770                ));
771                emit_check_assertions(group_body, &escaped_name, check, export_requests);
772                group_body.push_str(" }\n");
773            } else if count > 1 {
774                group_body
775                    .push_str(&format!("      for (let __r = 0; __r < {}; __r++) {{\n", count));
776                group_body.push_str(&format!("        let res = {};\n", request_call));
777                emit_check_assertions(group_body, &escaped_name, check, export_requests);
778                group_body.push_str("      }\n");
779            } else {
780                group_body.push_str(&format!("      let res = {};\n", request_call));
781                emit_check_assertions(group_body, &escaped_name, check, export_requests);
782            }
783        }
784
785        // Round 39 — emit chain extraction from `res` after the
786        // request line(s). For parallel/sequential repeats, this
787        // captures from the LAST `res` in scope, matching the native
788        // executor's "first/last hit" semantics (k6 doesn't guarantee
789        // batch completion order so we explicitly use index 0).
790        if !check.extract.is_empty() {
791            emit_chain_extract(group_body, &check.extract);
792        }
793
794        group_body.push_str("    }\n");
795    }
796
797    if iters > 1 {
798        group_body.push_str("    }\n");
799    }
800
801    group_body.push_str("  });\n\n");
802}
803
804/// Round 39 (#79) — emit assertions + capture for one request slot.
805/// Pulled out of `write_k6_group_body` so the parallel-batch / repeat
806/// branches can share the same assertion code without duplication.
807fn emit_check_assertions(
808    group_body: &mut String,
809    escaped_name: &str,
810    check: &CustomCheck,
811    export_requests: bool,
812) {
813    if export_requests {
814        group_body.push_str(&format!(
815            "      if (typeof __captureExchange === 'function') __captureExchange('{}', res);\n",
816            escaped_name
817        ));
818    }
819    group_body.push_str(&format!(
820        "      {{ let ok = check(res, {{ '{}': (r) => r.status === {} }}); if (!ok) __captureFailure('{}', res, 'status === {}'); }}\n",
821        escaped_name, check.expected_status, escaped_name, check.expected_status
822    ));
823    for (header_name, pattern) in &check.expected_headers {
824        let header_check_name = format!("{}:header:{}", escaped_name, header_name);
825        let escaped_pattern = js_escape_sq(pattern);
826        let header_lower = header_name.to_lowercase();
827        group_body.push_str(&format!(
828            "      {{ 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",
829            header_check_name, header_lower, escaped_pattern, header_check_name, header_name, escaped_pattern
830        ));
831    }
832    for field in &check.expected_body_fields {
833        let field_check_name = format!("{}:body:{}:{}", escaped_name, field.name, field.field_type);
834        let accessor = generate_field_accessor(&field.name);
835        let type_check = match field.field_type.as_str() {
836            "string" => format!("typeof ({}) === 'string'", accessor),
837            "integer" => format!("Number.isInteger({})", accessor),
838            "number" => format!("typeof ({}) === 'number'", accessor),
839            "boolean" => format!("typeof ({}) === 'boolean'", accessor),
840            "array" => format!("Array.isArray({})", accessor),
841            "object" => {
842                format!("typeof ({}) === 'object' && !Array.isArray({})", accessor, accessor)
843            }
844            _ => format!("({}) !== undefined", accessor),
845        };
846        group_body.push_str(&format!(
847            "      {{ let ok = check(res, {{ '{}': (r) => {{ try {{ return {}; }} catch(e) {{ return false; }} }} }}); if (!ok) __captureFailure('{}', res, 'body field {} is {}'); }}\n",
848            field_check_name, type_check, field_check_name, field.name, field.field_type
849        ));
850    }
851}
852
853/// Round 39 (#79) — emit JS that captures `extract:` rules off `res`
854/// into the chain context. Cookies pull from `res.cookies[name][0].value`
855/// (k6's parsed cookie shape); headers use a case-insensitive lookup
856/// over `Object.keys(res.headers)`; body fields traverse a parsed JSON
857/// body via a simple dotted-path walk.
858fn emit_chain_extract(group_body: &mut String, rules: &ExtractRules) {
859    for cookie_name in &rules.cookies {
860        let var = sanitize_js_ident(cookie_name);
861        group_body.push_str(&format!(
862            "      if (res.cookies && res.cookies['{}'] && res.cookies['{}'][0]) {{ __ctx_cookie_{} = res.cookies['{}'][0].value; }}\n",
863            js_escape_sq(cookie_name),
864            js_escape_sq(cookie_name),
865            var,
866            js_escape_sq(cookie_name),
867        ));
868    }
869    for (var_name, header_name) in &rules.headers {
870        let var = sanitize_js_ident(var_name);
871        let header_lower = header_name.to_lowercase();
872        group_body.push_str(&format!(
873            "      {{ const _hk = Object.keys(res.headers || {{}}).find(k => k.toLowerCase() === '{}'); if (_hk) {{ __ctx_var_{} = res.headers[_hk]; }} }}\n",
874            js_escape_sq(&header_lower), var
875        ));
876    }
877    if !rules.body_fields.is_empty() {
878        group_body.push_str("      try { let __body_json = JSON.parse(res.body || 'null');\n");
879        for (var_name, dotted) in &rules.body_fields {
880            let var = sanitize_js_ident(var_name);
881            // Walk the path one segment at a time so a missing
882            // intermediate key short-circuits to undefined.
883            let segments: Vec<String> =
884                dotted.split('.').map(|s| format!("['{}']", js_escape_sq(s))).collect();
885            let accessor = format!("__body_json{}", segments.join(""));
886            group_body.push_str(&format!(
887                "        try {{ const __v = {}; if (__v !== undefined && __v !== null) __ctx_var_{} = String(__v); }} catch(e) {{}}\n",
888                accessor, var
889            ));
890        }
891        group_body.push_str("      } catch(e) {}\n");
892    }
893    // Round 39 — promote the captured-var references to `let` so they
894    // live across iterations / checks in the same group. Done at the
895    // start of the next request's substitution by referring directly
896    // to `__ctx_var_X` / `__ctx_cookie_X` (which we declared via
897    // `__ctx_vars = {}` + writes above).
898    let _ = group_body;
899}
900
901/// Generate a JavaScript expression to access a field in a parsed JSON body.
902///
903/// Supports three path formats:
904/// - Simple key: `"name"` → `JSON.parse(r.body)['name']`
905/// - Dot-notation: `"config.enabled"` → `JSON.parse(r.body)['config']['enabled']`
906/// - Array bracket: `"items[].id"` → `JSON.parse(r.body)['items'][0]['id']`
907fn generate_field_accessor(field_name: &str) -> String {
908    // Split on dots, handling [] array notation
909    let parts: Vec<&str> = field_name.split('.').collect();
910    let mut expr = String::from("JSON.parse(r.body)");
911
912    for part in &parts {
913        if let Some(arr_name) = part.strip_suffix("[]") {
914            // Array field — access the array then index first element
915            expr.push_str(&format!("['{}'][0]", arr_name));
916        } else {
917            expr.push_str(&format!("['{}']", part));
918        }
919    }
920
921    expr
922}
923
924#[cfg(test)]
925mod tests {
926    use super::*;
927
928    #[test]
929    fn test_parse_custom_yaml() {
930        let yaml = r#"
931custom_checks:
932  - name: "custom:pets-returns-200"
933    path: /pets
934    method: GET
935    expected_status: 200
936  - name: "custom:create-product"
937    path: /api/products
938    method: POST
939    expected_status: 201
940    body: '{"sku": "TEST-001", "name": "Test"}'
941    expected_body_fields:
942      - name: id
943        type: integer
944    expected_headers:
945      content-type: "application/json"
946"#;
947        let config: CustomConformanceConfig = serde_yaml::from_str(yaml).unwrap();
948        assert_eq!(config.custom_checks.len(), 2);
949        assert_eq!(config.custom_checks[0].name, "custom:pets-returns-200");
950        assert_eq!(config.custom_checks[0].expected_status, 200);
951        assert_eq!(config.custom_checks[1].expected_body_fields.len(), 1);
952        assert_eq!(config.custom_checks[1].expected_body_fields[0].name, "id");
953        assert_eq!(config.custom_checks[1].expected_body_fields[0].field_type, "integer");
954    }
955
956    #[test]
957    fn test_generate_k6_group_get() {
958        let config = CustomConformanceConfig {
959            custom_checks: vec![CustomCheck {
960                name: "custom:test-get".to_string(),
961                path: "/api/test".to_string(),
962                method: "GET".to_string(),
963                expected_status: 200,
964                body: None,
965                expected_headers: std::collections::HashMap::new(),
966                expected_body_fields: vec![],
967                headers: std::collections::HashMap::new(),
968                upload: None,
969                uploads: vec![],
970                extract: ExtractRules::default(),
971                repeat: Repeat::default(),
972            }],
973            chain_iterations: 1,
974        };
975
976        let script = config.generate_k6_group("BASE_URL", &[]);
977        assert!(script.contains("group('Custom'"));
978        assert!(script.contains("http.get(`${BASE_URL}/api/test`)"));
979        assert!(script.contains("'custom:test-get': (r) => r.status === 200"));
980    }
981
982    #[test]
983    fn test_generate_k6_group_post_with_body() {
984        let config = CustomConformanceConfig {
985            custom_checks: vec![CustomCheck {
986                name: "custom:create".to_string(),
987                path: "/api/items".to_string(),
988                method: "POST".to_string(),
989                expected_status: 201,
990                body: Some(r#"{"name": "test"}"#.to_string()),
991                expected_headers: std::collections::HashMap::new(),
992                expected_body_fields: vec![ExpectedBodyField {
993                    name: "id".to_string(),
994                    field_type: "integer".to_string(),
995                }],
996                headers: std::collections::HashMap::new(),
997                upload: None,
998                uploads: vec![],
999                extract: ExtractRules::default(),
1000                repeat: Repeat::default(),
1001            }],
1002            chain_iterations: 1,
1003        };
1004
1005        let script = config.generate_k6_group("BASE_URL", &[]);
1006        assert!(script.contains("http.post("));
1007        assert!(script.contains("'custom:create': (r) => r.status === 201"));
1008        assert!(script.contains("custom:create:body:id:integer"));
1009        assert!(script.contains("Number.isInteger"));
1010    }
1011
1012    #[test]
1013    fn test_generate_k6_group_with_header_checks() {
1014        let mut expected_headers = std::collections::HashMap::new();
1015        expected_headers.insert("content-type".to_string(), "application/json".to_string());
1016
1017        let config = CustomConformanceConfig {
1018            custom_checks: vec![CustomCheck {
1019                name: "custom:header-check".to_string(),
1020                path: "/api/test".to_string(),
1021                method: "GET".to_string(),
1022                expected_status: 200,
1023                body: None,
1024                expected_headers,
1025                expected_body_fields: vec![],
1026                headers: std::collections::HashMap::new(),
1027                upload: None,
1028                uploads: vec![],
1029                extract: ExtractRules::default(),
1030                repeat: Repeat::default(),
1031            }],
1032            chain_iterations: 1,
1033        };
1034
1035        let script = config.generate_k6_group("BASE_URL", &[]);
1036        assert!(script.contains("custom:header-check:header:content-type"));
1037        assert!(script.contains("new RegExp('application/json')"));
1038    }
1039
1040    #[test]
1041    fn test_generate_k6_group_with_custom_headers() {
1042        let config = CustomConformanceConfig {
1043            custom_checks: vec![CustomCheck {
1044                name: "custom:auth-test".to_string(),
1045                path: "/api/secure".to_string(),
1046                method: "GET".to_string(),
1047                expected_status: 200,
1048                body: None,
1049                expected_headers: std::collections::HashMap::new(),
1050                expected_body_fields: vec![],
1051                headers: std::collections::HashMap::new(),
1052                upload: None,
1053                uploads: vec![],
1054                extract: ExtractRules::default(),
1055                repeat: Repeat::default(),
1056            }],
1057            chain_iterations: 1,
1058        };
1059
1060        let custom_headers = vec![("Authorization".to_string(), "Bearer token123".to_string())];
1061        let script = config.generate_k6_group("BASE_URL", &custom_headers);
1062        assert!(script.contains("'Authorization': 'Bearer token123'"));
1063    }
1064
1065    #[test]
1066    fn test_failure_capture_emitted() {
1067        let config = CustomConformanceConfig {
1068            custom_checks: vec![CustomCheck {
1069                name: "custom:capture-test".to_string(),
1070                path: "/api/test".to_string(),
1071                method: "GET".to_string(),
1072                expected_status: 200,
1073                body: None,
1074                expected_headers: {
1075                    let mut m = std::collections::HashMap::new();
1076                    m.insert("X-Rate-Limit".to_string(), ".*".to_string());
1077                    m
1078                },
1079                expected_body_fields: vec![ExpectedBodyField {
1080                    name: "id".to_string(),
1081                    field_type: "integer".to_string(),
1082                }],
1083                headers: std::collections::HashMap::new(),
1084                upload: None,
1085                uploads: vec![],
1086                extract: ExtractRules::default(),
1087                repeat: Repeat::default(),
1088            }],
1089            chain_iterations: 1,
1090        };
1091
1092        let script = config.generate_k6_group("BASE_URL", &[]);
1093        // Status check should call __captureFailure on failure
1094        assert!(
1095            script.contains("__captureFailure('custom:capture-test', res, 'status === 200')"),
1096            "Status check should emit __captureFailure"
1097        );
1098        // Header check should call __captureFailure on failure
1099        assert!(
1100            script.contains("__captureFailure('custom:capture-test:header:X-Rate-Limit'"),
1101            "Header check should emit __captureFailure"
1102        );
1103        // Body field check should call __captureFailure on failure
1104        assert!(
1105            script.contains("__captureFailure('custom:capture-test:body:id:integer'"),
1106            "Body field check should emit __captureFailure"
1107        );
1108    }
1109
1110    #[test]
1111    fn test_from_file_nonexistent() {
1112        let result = CustomConformanceConfig::from_file(Path::new("/nonexistent/file.yaml"));
1113        assert!(result.is_err());
1114        let err = result.unwrap_err().to_string();
1115        assert!(err.contains("Failed to read custom conformance file"));
1116    }
1117
1118    #[test]
1119    fn test_generate_k6_group_delete() {
1120        let config = CustomConformanceConfig {
1121            custom_checks: vec![CustomCheck {
1122                name: "custom:delete-item".to_string(),
1123                path: "/api/items/1".to_string(),
1124                method: "DELETE".to_string(),
1125                expected_status: 204,
1126                body: None,
1127                expected_headers: std::collections::HashMap::new(),
1128                expected_body_fields: vec![],
1129                headers: std::collections::HashMap::new(),
1130                upload: None,
1131                uploads: vec![],
1132                extract: ExtractRules::default(),
1133                repeat: Repeat::default(),
1134            }],
1135            chain_iterations: 1,
1136        };
1137
1138        let script = config.generate_k6_group("BASE_URL", &[]);
1139        assert!(script.contains("http.del("));
1140        assert!(script.contains("r.status === 204"));
1141    }
1142
1143    #[test]
1144    fn test_field_accessor_simple() {
1145        assert_eq!(generate_field_accessor("name"), "JSON.parse(r.body)['name']");
1146    }
1147
1148    #[test]
1149    fn test_field_accessor_nested_dot() {
1150        assert_eq!(
1151            generate_field_accessor("config.enabled"),
1152            "JSON.parse(r.body)['config']['enabled']"
1153        );
1154    }
1155
1156    #[test]
1157    fn test_field_accessor_array_bracket() {
1158        assert_eq!(generate_field_accessor("items[].id"), "JSON.parse(r.body)['items'][0]['id']");
1159    }
1160
1161    #[test]
1162    fn test_field_accessor_deep_nested() {
1163        assert_eq!(generate_field_accessor("a.b.c"), "JSON.parse(r.body)['a']['b']['c']");
1164    }
1165
1166    #[test]
1167    fn test_generate_k6_nested_body_fields() {
1168        let config = CustomConformanceConfig {
1169            custom_checks: vec![CustomCheck {
1170                name: "custom:nested".to_string(),
1171                path: "/api/data".to_string(),
1172                method: "GET".to_string(),
1173                expected_status: 200,
1174                body: None,
1175                expected_headers: std::collections::HashMap::new(),
1176                expected_body_fields: vec![
1177                    ExpectedBodyField {
1178                        name: "count".to_string(),
1179                        field_type: "integer".to_string(),
1180                    },
1181                    ExpectedBodyField {
1182                        name: "results[].name".to_string(),
1183                        field_type: "string".to_string(),
1184                    },
1185                ],
1186                headers: std::collections::HashMap::new(),
1187                upload: None,
1188                uploads: vec![],
1189                extract: ExtractRules::default(),
1190                repeat: Repeat::default(),
1191            }],
1192            chain_iterations: 1,
1193        };
1194
1195        let script = config.generate_k6_group("BASE_URL", &[]);
1196        // Simple field should use direct bracket access
1197        assert!(script.contains("JSON.parse(r.body)['count']"));
1198        // Nested array field should use [0] for array traversal
1199        assert!(script.contains("JSON.parse(r.body)['results'][0]['name']"));
1200    }
1201}