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