Skip to main content

mockforge_bench/
k6_gen.rs

1//! k6 script generation for load testing real endpoints
2
3use crate::dynamic_params::{DynamicParamProcessor, DynamicPlaceholder};
4use crate::error::{BenchError, Result};
5use crate::request_gen::RequestTemplate;
6use crate::scenarios::LoadScenario;
7use handlebars::Handlebars;
8use serde::Serialize;
9use serde_json::Value;
10use std::collections::{HashMap, HashSet};
11
12/// Typed template data for `k6_script.hbs`.
13///
14/// Every field referenced by `{{variable}}` or `{{#if flag}}` in the template
15/// is a required field here, so the compiler prevents the Issue-#79 class of
16/// bugs (template rendered with missing data).
17#[derive(Debug, Clone, Serialize)]
18pub struct K6ScriptTemplateData {
19    pub base_url: String,
20    pub stages: Vec<K6StageData>,
21    pub operations: Vec<K6OperationData>,
22    pub threshold_percentile: String,
23    pub threshold_ms: u64,
24    pub max_error_rate: f64,
25    /// Round 62 (#79) — when true, emit the `abortOnFail` memory safety valve
26    /// on `http_req_failed`. `--no-abort-on-error` sets this false so a stress
27    /// run against a high-rejection WAF/proxy executes its full duration.
28    pub abort_on_error: bool,
29    /// Round 62 (#79) — failure-rate threshold (0.0-1.0) for the abort valve
30    /// above. Default 0.95 (preserves round 60). Only rendered when
31    /// `abort_on_error` is true.
32    pub abort_on_error_rate: f64,
33    pub scenario_name: String,
34    pub skip_tls_verify: bool,
35    pub has_dynamic_values: bool,
36    pub dynamic_imports: Vec<String>,
37    pub dynamic_globals: Vec<String>,
38    pub security_testing_enabled: bool,
39    pub has_custom_headers: bool,
40    /// When true, emit `Transfer-Encoding: chunked` on every request that has a
41    /// body. NOTE: k6 runs on Go's `net/http`, which decides chunking based on
42    /// the body type — a string body has a known length and Go will normally
43    /// send `Content-Length`. Setting this header explicitly is the closest
44    /// k6-script-level approximation; for true raw chunked traffic, prefer
45    /// `curl --data-binary @file -H "Transfer-Encoding: chunked"` or a custom
46    /// hyper/reqwest harness.
47    pub chunked_request_bodies: bool,
48    /// Optional target RPS. When `Some(n)`, the script switches the executor
49    /// from `ramping-vus` to `constant-arrival-rate` at `n` requests/sec.
50    /// Issue #79.
51    pub target_rps: Option<u32>,
52    /// When true, the generated script sets `noConnectionReuse: true` on every
53    /// request so each one opens a fresh TCP/TLS connection. Used to drive
54    /// connections-per-second load. Issue #79.
55    pub no_keep_alive: bool,
56    /// Total test duration in seconds. Used by the `constant-arrival-rate`
57    /// executor (when `target_rps` is set) which needs a single duration
58    /// rather than a list of stages. Issue #79 — Srikanth's round-5 reply:
59    /// `--rps` was previously deriving duration from the last stage of the
60    /// chosen scenario; under `ramp-up` (the default) the last stage has
61    /// `target: 0`, which gave `preAllocatedVUs: 0` and 0 requests.
62    pub duration_secs: u64,
63    /// Max VUs to pre-allocate for the `constant-arrival-rate` executor.
64    /// Issue #79 (round 5).
65    pub max_vus: u32,
66    /// Starting VU count for the `ramping-vus` executor. For
67    /// `--scenario constant` this is set to `max_vus` so the test runs at
68    /// full concurrency immediately. For ramping scenarios it's 0 so the
69    /// stages drive the ramp.
70    ///
71    /// Issue #79 round 6 follow-up: Srikanth reported that `--vus 5 -d 600s`
72    /// took until the ~6-minute mark to reach 5 VUs because `startVUs: 0` +
73    /// a single `{duration: '600s', target: 5}` stage made `ramping-vus`
74    /// linearly ramp from 0 → 5 across the whole window. Setting startVUs
75    /// to the target for `Constant` collapses that ramp.
76    pub start_vus: u32,
77    /// Issue #79 round 22.3 — fake source IPs to rotate across the
78    /// forwarded-IP headers. Pre-round-22.3, `--geo-source-ip` only
79    /// applied to the self-test driver; the k6 bench path silently
80    /// ignored it. When non-empty, the rendered script picks a
81    /// rotating IP per iteration and adds it to every header in
82    /// `geo_source_headers` on every request. Empty = no header
83    /// injection (preserves prior behaviour).
84    pub geo_source_ips: Vec<String>,
85    /// Headers to populate with the rotating geo source IP. Default
86    /// (when CLI doesn't override) is `X-Forwarded-For`,
87    /// `True-Client-IP`, `CF-Connecting-IP`. Empty means no headers
88    /// even if `geo_source_ips` is non-empty.
89    pub geo_source_headers: Vec<String>,
90    /// True iff both `geo_source_ips` and `geo_source_headers` are
91    /// non-empty. Pre-computed so the template can use a single
92    /// `{{#if has_geo_source}}` guard instead of duplicating the
93    /// emptiness check on both lists.
94    pub has_geo_source: bool,
95    /// JSON-array string of `geo_source_ips` ready for embedding in
96    /// the rendered k6 script via `{{{geo_source_ips_json}}}`.
97    /// Pre-serialised so Handlebars doesn't have to walk the Vec at
98    /// render time.
99    pub geo_source_ips_json: String,
100    /// JSON-array string of `geo_source_headers` ready for embedding.
101    pub geo_source_headers_json: String,
102}
103
104/// Typed template data for `k6_crud_flow.hbs`.
105#[derive(Debug, Clone, Serialize)]
106pub struct K6CrudFlowTemplateData {
107    pub base_url: String,
108    pub flows: Vec<Value>,
109    pub extract_fields: Vec<String>,
110    pub duration_secs: u64,
111    pub max_vus: u32,
112    pub auth_header: Option<String>,
113    pub custom_headers: HashMap<String, String>,
114    pub skip_tls_verify: bool,
115    pub stages: Vec<K6StageData>,
116    pub threshold_percentile: String,
117    pub threshold_ms: u64,
118    pub max_error_rate: f64,
119    /// Raw JSON string for embedding in k6 script (rendered unescaped via `{{{headers}}}`)
120    pub headers: String,
121    pub dynamic_imports: Vec<String>,
122    pub dynamic_globals: Vec<String>,
123    pub extracted_values_output_path: String,
124    pub error_injection_enabled: bool,
125    pub error_rate: f64,
126    pub error_types: Vec<String>,
127    pub security_testing_enabled: bool,
128    pub has_custom_headers: bool,
129}
130
131/// A k6 load stage for template rendering.
132#[derive(Debug, Clone, Serialize)]
133pub struct K6StageData {
134    pub duration: String,
135    pub target: u32,
136}
137
138/// Per-operation data for the `k6_script.hbs` template.
139#[derive(Debug, Clone, Serialize)]
140pub struct K6OperationData {
141    pub index: usize,
142    pub name: String,
143    pub metric_name: String,
144    pub display_name: String,
145    pub method: String,
146    pub path: Value,
147    pub path_is_dynamic: bool,
148    pub headers: Value,
149    pub body: Option<Value>,
150    pub body_is_dynamic: bool,
151    pub has_body: bool,
152    pub is_get_or_head: bool,
153}
154
155/// Configuration for k6 script generation
156pub struct K6Config {
157    pub target_url: String,
158    /// API base path prefix (e.g., "/api" or "/v2")
159    /// Prepended to all API endpoint paths
160    pub base_path: Option<String>,
161    pub scenario: LoadScenario,
162    pub duration_secs: u64,
163    pub max_vus: u32,
164    pub threshold_percentile: String,
165    pub threshold_ms: u64,
166    pub max_error_rate: f64,
167    pub auth_header: Option<String>,
168    pub custom_headers: HashMap<String, String>,
169    pub skip_tls_verify: bool,
170    pub security_testing_enabled: bool,
171    /// Emit `Transfer-Encoding: chunked` on every request body. See
172    /// `K6ScriptTemplateData::chunked_request_bodies` for caveats.
173    pub chunked_request_bodies: bool,
174    /// Target RPS for `constant-arrival-rate` executor. `None` falls back to
175    /// the legacy ramping-vus executor.
176    pub target_rps: Option<u32>,
177    /// When true, set `noConnectionReuse: true` on every request so each one
178    /// opens a fresh TCP/TLS connection (drives high CPS).
179    pub no_keep_alive: bool,
180    /// Round 22.3 — fake source IPs to advertise via forwarded-IP
181    /// headers in the rendered k6 script. Empty = no header
182    /// injection (preserves pre-22.3 behaviour).
183    pub geo_source_ips: Vec<String>,
184    /// Which forwarded-IP header(s) to populate when
185    /// `geo_source_ips` is non-empty. Empty = no headers even if
186    /// `geo_source_ips` is non-empty.
187    pub geo_source_headers: Vec<String>,
188}
189
190/// Generate k6 load test script
191pub struct K6ScriptGenerator {
192    config: K6Config,
193    templates: Vec<RequestTemplate>,
194    /// Round 62 (#79) — emit the `abortOnFail` valve on `http_req_failed`.
195    /// Defaults to true (round-60 behaviour); `--no-abort-on-error` clears it.
196    abort_on_error: bool,
197    /// Round 62 (#79) — failure-rate threshold for the abort valve. Default
198    /// 0.95. Tunable via `--abort-on-error-rate`.
199    abort_on_error_rate: f64,
200}
201
202impl K6ScriptGenerator {
203    /// Create a new k6 script generator.
204    ///
205    /// The abort-on-error safety valve defaults to on at a 0.95 failure rate
206    /// (round 60). Use [`with_abort_valve`](Self::with_abort_valve) to opt out
207    /// or retune it.
208    pub fn new(config: K6Config, templates: Vec<RequestTemplate>) -> Self {
209        Self {
210            config,
211            templates,
212            abort_on_error: true,
213            abort_on_error_rate: 0.95,
214        }
215    }
216
217    /// Configure the k6 abort-on-error memory safety valve (round 62 / #79).
218    ///
219    /// `abort_on_error = false` drops the `abortOnFail` threshold entirely so
220    /// the run executes its full duration regardless of error rate — required
221    /// for stress tests against a WAF/proxy that legitimately rejects most
222    /// requests. `abort_on_error_rate` tunes the failure rate (0.0-1.0) above
223    /// which a target aborts after the 60s grace period.
224    #[must_use]
225    pub fn with_abort_valve(mut self, abort_on_error: bool, abort_on_error_rate: f64) -> Self {
226        self.abort_on_error = abort_on_error;
227        self.abort_on_error_rate = abort_on_error_rate;
228        self
229    }
230
231    /// Generate the k6 script
232    pub fn generate(&self) -> Result<String> {
233        let handlebars = Handlebars::new();
234
235        let template = include_str!("templates/k6_script.hbs");
236
237        let data = self.build_template_data()?;
238
239        let value = serde_json::to_value(&data)
240            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
241
242        handlebars
243            .render_template(template, &value)
244            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))
245    }
246
247    /// Maximum length for a k6 metric name *base* (the part before any
248    /// `_latency` / `_errors` / `_step{N}_*` suffix). k6 enforces a
249    /// 128-char limit on the full metric name; the longest suffix used by
250    /// our templates is `_step99_errors` (15 chars), so we cap the base at
251    /// 128 - 16 = 112 to be safe.
252    const K6_METRIC_NAME_BASE_MAX_LEN: usize = 112;
253
254    /// Sanitize a name into a valid k6 metric-name base, capped at
255    /// `K6_METRIC_NAME_BASE_MAX_LEN` characters.
256    ///
257    /// k6 rejects metric names longer than 128 chars, and our templates
258    /// append suffixes like `_latency`, `_errors`, `_stepN_latency` —
259    /// reserve room for the longest suffix and truncate the base name
260    /// when needed. Truncation appends an 8-hex-char hash of the original
261    /// name so distinct long names produce distinct metric names.
262    ///
263    /// Examples:
264    /// - "short_name" -> "short_name"
265    /// - 200-char OperationId -> "<first-103-chars>_<8-hex-hash>"
266    pub fn sanitize_k6_metric_name(name: &str) -> String {
267        let sanitized = Self::sanitize_js_identifier(name);
268        if sanitized.len() <= Self::K6_METRIC_NAME_BASE_MAX_LEN {
269            return sanitized;
270        }
271
272        use std::collections::hash_map::DefaultHasher;
273        use std::hash::{Hash, Hasher};
274        let mut hasher = DefaultHasher::new();
275        // Hash the original name (not the sanitized one) so two distinct
276        // sources that sanitize to the same string still get different
277        // hashes when they exceed the limit.
278        name.hash(&mut hasher);
279        let hash_suffix = format!("{:08x}", hasher.finish() as u32);
280
281        // Reserve `_<8-hex>` = 9 chars at the end.
282        let prefix_len = Self::K6_METRIC_NAME_BASE_MAX_LEN - 9;
283        let prefix = &sanitized[..prefix_len];
284        // Strip a trailing underscore on the prefix so we don't end up with `__hash`.
285        let prefix = prefix.trim_end_matches('_');
286        format!("{}_{}", prefix, hash_suffix)
287    }
288
289    /// Deduplicate a sanitized identifier among `used`.
290    ///
291    /// Two WAFBench YAML files often share titles like "normal request
292    /// allowed". Those collapse to the same JS identifier, and k6 exits
293    /// 107 (ScriptException: Identifier has already been declared) before
294    /// sending a single request. Srikanth's 32-target verbatim run (#79
295    /// (g)) hit this: 10 colliding `const` names in one script.
296    fn uniquify_name(base: String, used: &mut HashSet<String>) -> String {
297        if used.insert(base.clone()) {
298            return base;
299        }
300        let mut n = 2u32;
301        loop {
302            let candidate = format!("{base}_{n}");
303            if used.insert(candidate.clone()) {
304                return candidate;
305            }
306            n = n.saturating_add(1);
307            if n == u32::MAX {
308                use std::collections::hash_map::DefaultHasher;
309                use std::hash::{Hash, Hasher};
310                let mut hasher = DefaultHasher::new();
311                base.hash(&mut hasher);
312                used.len().hash(&mut hasher);
313                let fallback = format!("{base}_{:08x}", hasher.finish() as u32);
314                used.insert(fallback.clone());
315                return fallback;
316            }
317        }
318    }
319
320    /// Sanitize a name to be a valid JavaScript identifier
321    ///
322    /// Replaces invalid characters (dots, spaces, special chars) with underscores.
323    /// Ensures the identifier starts with a letter or underscore (not a number).
324    ///
325    /// Examples:
326    /// - "billing.subscriptions.v1" -> "billing_subscriptions_v1"
327    /// - "get user" -> "get_user"
328    /// - "123invalid" -> "_123invalid"
329    pub fn sanitize_js_identifier(name: &str) -> String {
330        let mut result = String::new();
331        let mut chars = name.chars().peekable();
332
333        // Ensure it starts with a letter or underscore (not a number)
334        if let Some(&first) = chars.peek() {
335            if first.is_ascii_digit() {
336                result.push('_');
337            }
338        }
339
340        for ch in chars {
341            if ch.is_ascii_alphanumeric() || ch == '_' {
342                result.push(ch);
343            } else {
344                // Replace invalid characters with underscore
345                // Avoid consecutive underscores
346                if !result.ends_with('_') {
347                    result.push('_');
348                }
349            }
350        }
351
352        // Remove trailing underscores
353        result = result.trim_end_matches('_').to_string();
354
355        // If empty after sanitization, use a default name
356        if result.is_empty() {
357            result = "operation".to_string();
358        }
359
360        result
361    }
362
363    /// Build the typed template data for rendering.
364    fn build_template_data(&self) -> Result<K6ScriptTemplateData> {
365        let stages = self
366            .config
367            .scenario
368            .generate_stages(self.config.duration_secs, self.config.max_vus);
369
370        // Get the base path (defaults to empty string if not set)
371        let base_path = self.config.base_path.as_deref().unwrap_or("");
372
373        // Track all placeholders used across all operations
374        let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
375        // Issue #79 (g) — colliding titles across many YAML files must not
376        // emit two `const foo_latency = new Trend(...)` lines.
377        let mut used_js_names: HashSet<String> = HashSet::new();
378        let mut used_metric_names: HashSet<String> = HashSet::new();
379
380        let mut operations = Vec::with_capacity(self.templates.len());
381        for (idx, template) in self.templates.iter().enumerate() {
382            let display_name = template.operation.display_name();
383            let sanitized_name = Self::uniquify_name(
384                Self::sanitize_js_identifier(&display_name),
385                &mut used_js_names,
386            );
387            // metric_name must satisfy k6's 128-char limit AND leave room
388            // for suffixes like `_latency` / `_errors` / `_stepN_*`.
389            // Long deeply-nested operationIds (e.g. Microsoft Graph) exceed
390            // this; sanitize_k6_metric_name truncates with a hash suffix
391            // for uniqueness. (See issue #79 — Srikanth's microsoft-graph.yaml run.)
392            // uniquify_name then splits short-name collisions that the hash
393            // path does not see (two "normal request allowed" cases).
394            let metric_name = Self::uniquify_name(
395                Self::sanitize_k6_metric_name(&display_name),
396                &mut used_metric_names,
397            );
398            // k6 uses 'del' instead of 'delete' for HTTP DELETE method
399            let k6_method = match template.operation.method.to_lowercase().as_str() {
400                "delete" => "del".to_string(),
401                m => m.to_string(),
402            };
403            // GET and HEAD methods only take 2 arguments in k6: http.get(url, params)
404            // Other methods take 3 arguments: http.post(url, body, params)
405            let is_get_or_head = matches!(k6_method.as_str(), "get" | "head");
406
407            // Process path for dynamic placeholders
408            // Prepend base_path if configured
409            let raw_path = template.generate_path();
410            let full_path = join_base_path(base_path, &raw_path);
411            let processed_path = DynamicParamProcessor::process_path(&full_path);
412            all_placeholders.extend(processed_path.placeholders.clone());
413
414            // Process body for dynamic placeholders
415            let (body_value, body_is_dynamic) = if let Some(body) = &template.body {
416                let processed_body = DynamicParamProcessor::process_json_body(body);
417                all_placeholders.extend(processed_body.placeholders.clone());
418                (Some(processed_body.value), processed_body.is_dynamic)
419            } else {
420                (None, false)
421            };
422
423            let path_value = if processed_path.is_dynamic {
424                processed_path.value
425            } else {
426                full_path
427            };
428
429            operations.push(K6OperationData {
430                index: idx,
431                name: sanitized_name,
432                metric_name,
433                display_name,
434                method: k6_method,
435                path: Value::String(path_value),
436                path_is_dynamic: processed_path.is_dynamic,
437                headers: Value::String(self.build_headers_json(template)),
438                body: body_value.map(Value::String),
439                body_is_dynamic,
440                has_body: template.body.is_some(),
441                is_get_or_head,
442            });
443        }
444
445        // Get required imports and global initializations based on placeholders used
446        let required_imports: Vec<String> =
447            DynamicParamProcessor::get_required_imports(&all_placeholders)
448                .into_iter()
449                .map(String::from)
450                .collect();
451        let required_globals: Vec<String> =
452            DynamicParamProcessor::get_required_globals(&all_placeholders)
453                .into_iter()
454                .map(String::from)
455                .collect();
456        let has_dynamic_values = !all_placeholders.is_empty();
457
458        Ok(K6ScriptTemplateData {
459            base_url: self.config.target_url.clone(),
460            stages: stages
461                .iter()
462                .map(|s| K6StageData {
463                    duration: s.duration.clone(),
464                    target: s.target,
465                })
466                .collect(),
467            operations,
468            threshold_percentile: self.config.threshold_percentile.clone(),
469            threshold_ms: self.config.threshold_ms,
470            max_error_rate: self.config.max_error_rate,
471            abort_on_error: self.abort_on_error,
472            abort_on_error_rate: self.abort_on_error_rate,
473            scenario_name: format!("{:?}", self.config.scenario).to_lowercase(),
474            skip_tls_verify: self.config.skip_tls_verify,
475            has_dynamic_values,
476            dynamic_imports: required_imports,
477            dynamic_globals: required_globals,
478            security_testing_enabled: self.config.security_testing_enabled,
479            has_custom_headers: !self.config.custom_headers.is_empty(),
480            chunked_request_bodies: self.config.chunked_request_bodies,
481            target_rps: self.config.target_rps,
482            no_keep_alive: self.config.no_keep_alive,
483            duration_secs: self.config.duration_secs,
484            max_vus: self.config.max_vus,
485            // For Constant we want the test at full VU count from t=0; for the
486            // ramping scenarios (RampUp / Spike / Stress / Soak) k6 needs to
487            // start from 0 and let the stages drive the curve.
488            start_vus: match self.config.scenario {
489                LoadScenario::Constant => self.config.max_vus,
490                _ => 0,
491            },
492            // Round 22.3 — forward the rotating-geo-IP config from
493            // K6Config into template data. `has_geo_source` is the
494            // and-gate the template uses to skip the header
495            // assignment entirely when either list is empty. The
496            // `_json` siblings pre-serialise for `{{{ }}}` triple-
497            // brace embedding (raw, no escape) so the script can
498            // declare `const GEO_SOURCE_IPS = [...]` directly.
499            geo_source_ips: self.config.geo_source_ips.clone(),
500            geo_source_headers: self.config.geo_source_headers.clone(),
501            has_geo_source: !self.config.geo_source_ips.is_empty()
502                && !self.config.geo_source_headers.is_empty(),
503            geo_source_ips_json: serde_json::to_string(&self.config.geo_source_ips)
504                .unwrap_or_else(|_| "[]".to_string()),
505            geo_source_headers_json: serde_json::to_string(&self.config.geo_source_headers)
506                .unwrap_or_else(|_| "[]".to_string()),
507        })
508    }
509
510    /// Build headers for a request template as a JSON string for k6 script
511    fn build_headers_json(&self, template: &RequestTemplate) -> String {
512        let mut headers = template.get_headers();
513
514        // Add auth header if provided
515        if let Some(auth) = &self.config.auth_header {
516            headers.insert("Authorization".to_string(), auth.clone());
517        }
518
519        // Add custom headers
520        for (key, value) in &self.config.custom_headers {
521            headers.insert(key.clone(), value.clone());
522        }
523
524        // Force chunked transfer encoding when requested. Only meaningful for
525        // requests with bodies (POST/PUT/PATCH); k6/Go may still send
526        // Content-Length in some cases — see the doc on
527        // `K6ScriptTemplateData::chunked_request_bodies`.
528        if self.config.chunked_request_bodies && template.body.is_some() {
529            headers.insert("Transfer-Encoding".to_string(), "chunked".to_string());
530        }
531
532        // Convert to JSON string for embedding in k6 script
533        serde_json::to_string(&headers).unwrap_or_else(|_| "{}".to_string())
534    }
535
536    /// Validate the generated k6 script for common issues
537    ///
538    /// Checks for:
539    /// - Invalid metric names (contains dots or special characters)
540    /// - Invalid JavaScript variable names
541    /// - Missing required k6 imports
542    ///
543    /// Returns a list of validation errors, empty if all checks pass.
544    pub fn validate_script(script: &str) -> Vec<String> {
545        let mut errors = Vec::new();
546
547        // Check for required k6 imports
548        if !script.contains("import http from 'k6/http'") {
549            errors.push("Missing required import: 'k6/http'".to_string());
550        }
551        if !script.contains("import { check") && !script.contains("import {check") {
552            errors.push("Missing required import: 'check' from 'k6'".to_string());
553        }
554        if !script.contains("import { Rate, Trend") && !script.contains("import {Rate, Trend") {
555            errors.push("Missing required import: 'Rate, Trend' from 'k6/metrics'".to_string());
556        }
557
558        // Check for invalid metric names in Trend/Rate constructors
559        // k6 metric names must only contain ASCII letters, numbers, or underscores
560        // and start with a letter or underscore
561        let lines: Vec<&str> = script.lines().collect();
562        let mut seen_metric_consts: HashSet<String> = HashSet::new();
563        for (line_num, line) in lines.iter().enumerate() {
564            let trimmed = line.trim();
565
566            // Check for Trend/Rate constructors with invalid metric names
567            if trimmed.contains("new Trend(") || trimmed.contains("new Rate(") {
568                // Duplicate `const foo_latency = new Trend(...)` is a parse
569                // error in k6 (exit 107). Catch it here so generate() fails
570                // in-process instead of after a 32-target spawn.
571                if let Some(name) = trimmed
572                    .strip_prefix("const ")
573                    .and_then(|rest| rest.split('=').next())
574                    .map(str::trim)
575                    .filter(|n| {
576                        !n.is_empty() && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
577                    })
578                {
579                    if !seen_metric_consts.insert(name.to_string()) {
580                        errors.push(format!(
581                            "Line {}: duplicate const '{name}'. k6 exits 107 (ScriptException) when two traffic cases sanitize to the same identifier.",
582                            line_num + 1
583                        ));
584                    }
585                }
586                // Extract the metric name from the string literal
587                // Pattern: new Trend('metric_name') or new Rate("metric_name")
588                if let Some(start) = trimmed.find('\'') {
589                    if let Some(end) = trimmed[start + 1..].find('\'') {
590                        let metric_name = &trimmed[start + 1..start + 1 + end];
591                        if !Self::is_valid_k6_metric_name(metric_name) {
592                            errors.push(format!(
593                                "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
594                                line_num + 1,
595                                metric_name
596                            ));
597                        }
598                    }
599                } else if let Some(start) = trimmed.find('"') {
600                    if let Some(end) = trimmed[start + 1..].find('"') {
601                        let metric_name = &trimmed[start + 1..start + 1 + end];
602                        if !Self::is_valid_k6_metric_name(metric_name) {
603                            errors.push(format!(
604                                "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
605                                line_num + 1,
606                                metric_name
607                            ));
608                        }
609                    }
610                }
611            }
612
613            // Check for invalid JavaScript variable names (containing dots)
614            if trimmed.starts_with("const ") || trimmed.starts_with("let ") {
615                if let Some(equals_pos) = trimmed.find('=') {
616                    let var_decl = &trimmed[..equals_pos];
617                    // Check if variable name contains a dot (invalid identifier)
618                    // But exclude string literals
619                    if var_decl.contains('.')
620                        && !var_decl.contains("'")
621                        && !var_decl.contains("\"")
622                        && !var_decl.trim().starts_with("//")
623                    {
624                        errors.push(format!(
625                            "Line {}: Invalid JavaScript variable name with dot: {}. Variable names cannot contain dots.",
626                            line_num + 1,
627                            var_decl.trim()
628                        ));
629                    }
630                }
631            }
632        }
633
634        errors
635    }
636
637    /// Check if a string is a valid k6 metric name
638    ///
639    /// k6 metric names must:
640    /// - Only contain ASCII letters, numbers, or underscores
641    /// - Start with a letter or underscore (not a number)
642    /// - Be at most 128 characters
643    fn is_valid_k6_metric_name(name: &str) -> bool {
644        if name.is_empty() || name.len() > 128 {
645            return false;
646        }
647
648        let mut chars = name.chars();
649
650        // First character must be a letter or underscore
651        if let Some(first) = chars.next() {
652            if !first.is_ascii_alphabetic() && first != '_' {
653                return false;
654            }
655        }
656
657        // Remaining characters must be alphanumeric or underscore
658        for ch in chars {
659            if !ch.is_ascii_alphanumeric() && ch != '_' {
660                return false;
661            }
662        }
663
664        true
665    }
666}
667
668/// Join `--base-path` onto a request path without producing `//`.
669///
670/// `--base-path /` means root, not a prefix named `/`. Concatenating it
671/// with a traffic-file URI that already starts with `/` sent
672/// `//oauth/authorize` on Srikanth's --targets-file + verbatim command (#79).
673fn join_base_path(base_path: &str, raw_path: &str) -> String {
674    match base_path {
675        "" | "/" => raw_path.to_string(),
676        bp => {
677            let bp = bp.trim_end_matches('/');
678            if raw_path.starts_with('/') {
679                format!("{}{}", bp, raw_path)
680            } else {
681                format!("{}/{}", bp, raw_path)
682            }
683        }
684    }
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690
691    #[test]
692    fn root_base_path_does_not_double_slash() {
693        assert_eq!(
694            join_base_path(
695                "/",
696                "/oauth/authorize?redirect_uri=https%3A%2F%2Fevil.example%2Flanding"
697            ),
698            "/oauth/authorize?redirect_uri=https%3A%2F%2Fevil.example%2Flanding"
699        );
700        assert_eq!(join_base_path("", "/pets"), "/pets");
701        assert_eq!(join_base_path("/v1", "/pets"), "/v1/pets");
702        assert_eq!(join_base_path("/v1/", "pets"), "/v1/pets");
703    }
704
705    #[test]
706    fn test_k6_config_creation() {
707        let config = K6Config {
708            target_url: "https://api.example.com".to_string(),
709            base_path: None,
710            scenario: LoadScenario::RampUp,
711            duration_secs: 60,
712            max_vus: 10,
713            threshold_percentile: "p(95)".to_string(),
714            threshold_ms: 500,
715            max_error_rate: 0.05,
716            auth_header: None,
717            custom_headers: HashMap::new(),
718            skip_tls_verify: false,
719            security_testing_enabled: false,
720            chunked_request_bodies: false,
721            target_rps: None,
722            no_keep_alive: false,
723            geo_source_ips: Vec::new(),
724            geo_source_headers: Vec::new(),
725        };
726
727        assert_eq!(config.duration_secs, 60);
728        assert_eq!(config.max_vus, 10);
729    }
730
731    #[test]
732    fn test_script_generator_creation() {
733        let config = K6Config {
734            target_url: "https://api.example.com".to_string(),
735            base_path: None,
736            scenario: LoadScenario::Constant,
737            duration_secs: 30,
738            max_vus: 5,
739            threshold_percentile: "p(95)".to_string(),
740            threshold_ms: 500,
741            max_error_rate: 0.05,
742            auth_header: None,
743            custom_headers: HashMap::new(),
744            skip_tls_verify: false,
745            security_testing_enabled: false,
746            chunked_request_bodies: false,
747            target_rps: None,
748            no_keep_alive: false,
749            geo_source_ips: Vec::new(),
750            geo_source_headers: Vec::new(),
751        };
752
753        let templates = vec![];
754        let generator = K6ScriptGenerator::new(config, templates);
755
756        assert_eq!(generator.templates.len(), 0);
757    }
758
759    #[test]
760    fn colliding_operation_titles_get_unique_const_names() {
761        // #79 (g): two YAML cases titled "normal request allowed" used to
762        // emit `const normal_request_allowed_latency` twice. k6 then exited
763        // 107 on every target with 0 requests.
764        use crate::spec_parser::ApiOperation;
765        use openapiv3::Operation;
766
767        fn tmpl(id: &str, path: &str) -> RequestTemplate {
768            RequestTemplate {
769                operation: ApiOperation {
770                    method: "get".to_string(),
771                    path: path.to_string(),
772                    operation: Operation::default(),
773                    operation_id: Some(id.to_string()),
774                },
775                path_params: HashMap::new(),
776                query_params: HashMap::new(),
777                headers: HashMap::new(),
778                body: None,
779            }
780        }
781
782        let config = K6Config {
783            target_url: "https://example.test".to_string(),
784            base_path: None,
785            scenario: LoadScenario::Constant,
786            duration_secs: 5,
787            max_vus: 1,
788            threshold_percentile: "p(95)".to_string(),
789            threshold_ms: 500,
790            max_error_rate: 0.05,
791            auth_header: None,
792            custom_headers: HashMap::new(),
793            skip_tls_verify: false,
794            security_testing_enabled: false,
795            chunked_request_bodies: false,
796            target_rps: None,
797            no_keep_alive: false,
798            geo_source_ips: Vec::new(),
799            geo_source_headers: Vec::new(),
800        };
801        let generator = K6ScriptGenerator::new(
802            config,
803            vec![
804                tmpl("normal request allowed", "/a"),
805                tmpl("normal request allowed", "/b"),
806            ],
807        );
808        let script = generator.generate().expect("script generates");
809        let latency = script
810            .lines()
811            .filter(|l| l.contains("new Trend(") && l.contains("normal_request_allowed"))
812            .collect::<Vec<_>>();
813        assert_eq!(latency.len(), 2, "expected two Trend consts, got {latency:#?}");
814        assert!(
815            script.contains("const normal_request_allowed_latency = new Trend"),
816            "first collision keeps the base name"
817        );
818        assert!(
819            script.contains("const normal_request_allowed_2_latency = new Trend")
820                || script.contains("const normal_request_allowed_latency_2 = new Trend"),
821            "second collision must be renamed, script snippet:\n{}",
822            latency.join("\n")
823        );
824        let errors = K6ScriptGenerator::validate_script(&script);
825        assert!(errors.is_empty(), "validate_script: {errors:#?}");
826    }
827
828    #[test]
829    fn test_sanitize_js_identifier() {
830        // Test case from issue #79: names with dots
831        assert_eq!(
832            K6ScriptGenerator::sanitize_js_identifier("billing.subscriptions.v1"),
833            "billing_subscriptions_v1"
834        );
835
836        // Test other invalid characters
837        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("get user"), "get_user");
838
839        // Test names starting with numbers
840        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("123invalid"), "_123invalid");
841
842        // Test already valid identifiers
843        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("getUsers"), "getUsers");
844
845        // Test with multiple consecutive invalid chars
846        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test...name"), "test_name");
847
848        // Test empty string (should return default)
849        assert_eq!(K6ScriptGenerator::sanitize_js_identifier(""), "operation");
850
851        // Test with special characters
852        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test@name#value"), "test_name_value");
853
854        // Test CRUD flow names with dots (issue #79 follow-up)
855        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.list"), "plans_list");
856        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.create"), "plans_create");
857        assert_eq!(
858            K6ScriptGenerator::sanitize_js_identifier("plans.update-pricing-schemes"),
859            "plans_update_pricing_schemes"
860        );
861        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("users CRUD"), "users_CRUD");
862    }
863
864    #[test]
865    fn test_sanitize_k6_metric_name_short_passthrough() {
866        // Names within the limit should pass through unchanged.
867        let short = "billing_subscriptions_list";
868        let out = K6ScriptGenerator::sanitize_k6_metric_name(short);
869        assert_eq!(out, short);
870        assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{out}_latency")));
871    }
872
873    #[test]
874    fn test_sanitize_k6_metric_name_truncates_long_microsoft_graph_id() {
875        // Real example from issue #79 (Srikanth's microsoft-graph.yaml run):
876        // operationId nested deep enough that the sanitized name + `_latency`
877        // exceeds k6's 128-char limit and gets rejected by validate_script.
878        let long = "drives.drive.items.driveItem.workbook.worksheets.workbookWorksheet.\
879                    charts.workbookChart.axes.categoryAxis.format.line.clear";
880        let metric = K6ScriptGenerator::sanitize_k6_metric_name(long);
881
882        // Base must fit within MAX_LEN, leaving room for `_latency` / `_errors`.
883        assert!(
884            metric.len() <= K6ScriptGenerator::K6_METRIC_NAME_BASE_MAX_LEN,
885            "metric base len {} exceeded cap {}",
886            metric.len(),
887            K6ScriptGenerator::K6_METRIC_NAME_BASE_MAX_LEN
888        );
889
890        // Both the bare metric and the suffixed forms must pass k6's validator.
891        assert!(K6ScriptGenerator::is_valid_k6_metric_name(&metric));
892        assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_latency")));
893        assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_errors")));
894        // Worst-case suffix used by `k6_crud_flow.hbs`.
895        assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_step99_latency")));
896    }
897
898    #[test]
899    fn test_sanitize_k6_metric_name_distinct_long_names_get_distinct_metrics() {
900        // Two long names that share a long common prefix must NOT collide
901        // after truncation — the trailing hash makes them distinct.
902        let prefix = "a".repeat(150);
903        let a = format!("{prefix}.foo");
904        let b = format!("{prefix}.bar");
905        let ma = K6ScriptGenerator::sanitize_k6_metric_name(&a);
906        let mb = K6ScriptGenerator::sanitize_k6_metric_name(&b);
907        assert_ne!(ma, mb, "distinct long names produced the same metric name");
908    }
909
910    #[test]
911    fn test_sanitize_k6_metric_name_truncated_starts_with_letter() {
912        // Truncation must preserve the "starts with letter or _" k6 rule.
913        let long = format!("{}123end", "x".repeat(120));
914        let metric = K6ScriptGenerator::sanitize_k6_metric_name(&long);
915        assert!(K6ScriptGenerator::is_valid_k6_metric_name(&metric));
916    }
917
918    #[test]
919    fn test_microsoft_graph_long_operation_id_passes_validation() {
920        // End-to-end: an ApiOperation with a microsoft-graph-style long
921        // operationId must produce a script that passes validate_script.
922        use crate::spec_parser::ApiOperation;
923        use openapiv3::Operation;
924
925        let long_op_id = "drives.drive.items.driveItem.workbook.worksheets.\
926            workbookWorksheet.charts.workbookChart.axes.categoryAxis.format.\
927            line.clear";
928
929        let operation = ApiOperation {
930            method: "post".to_string(),
931            path: "/drives/{drive-id}/items/{item-id}/workbook/worksheets/{worksheet-id}/charts/{chart-id}/axes/categoryAxis/format/line/clear".to_string(),
932            operation: Operation::default(),
933            operation_id: Some(long_op_id.to_string()),
934        };
935        let template = RequestTemplate {
936            operation,
937            path_params: HashMap::new(),
938            query_params: HashMap::new(),
939            headers: HashMap::new(),
940            body: None,
941        };
942        let config = K6Config {
943            target_url: "https://api.example.com".to_string(),
944            base_path: Some("/v1.0".to_string()),
945            scenario: LoadScenario::Constant,
946            duration_secs: 30,
947            max_vus: 5,
948            threshold_percentile: "p(95)".to_string(),
949            threshold_ms: 500,
950            max_error_rate: 0.05,
951            auth_header: None,
952            custom_headers: HashMap::new(),
953            skip_tls_verify: false,
954            security_testing_enabled: false,
955            chunked_request_bodies: false,
956            target_rps: None,
957            no_keep_alive: false,
958            geo_source_ips: Vec::new(),
959            geo_source_headers: Vec::new(),
960        };
961        let generator = K6ScriptGenerator::new(config, vec![template]);
962        let script = generator.generate().expect("script generates");
963
964        let errors = K6ScriptGenerator::validate_script(&script);
965        assert!(
966            errors.is_empty(),
967            "validate_script returned errors for long operationId: {errors:#?}"
968        );
969    }
970
971    /// Round 62 (#79) — the abort-on-error valve must default on (round 60),
972    /// drop out entirely under `--no-abort-on-error`, and honour a tuned rate.
973    /// Srikanth's WAF stress runs sat at ~95.2% rejections, just over the
974    /// hard-coded 0.95, so the valve stopped legitimate stress tests at ~2min.
975    #[test]
976    fn test_abort_valve_opt_out_and_rate() {
977        fn base_config() -> K6Config {
978            K6Config {
979                target_url: "https://api.example.com".to_string(),
980                base_path: None,
981                scenario: LoadScenario::Constant,
982                duration_secs: 30,
983                max_vus: 5,
984                threshold_percentile: "p(95)".to_string(),
985                threshold_ms: 500,
986                max_error_rate: 0.05,
987                auth_header: None,
988                custom_headers: HashMap::new(),
989                skip_tls_verify: false,
990                security_testing_enabled: false,
991                chunked_request_bodies: false,
992                target_rps: None,
993                no_keep_alive: false,
994                geo_source_ips: Vec::new(),
995                geo_source_headers: Vec::new(),
996            }
997        }
998
999        // Default: valve on at 0.95 (unchanged round-60 behaviour).
1000        let default_script = K6ScriptGenerator::new(base_config(), vec![])
1001            .generate()
1002            .expect("script generates");
1003        assert!(
1004            default_script.contains("abortOnFail: true") && default_script.contains("rate<0.95"),
1005            "default script must keep the 0.95 abort valve"
1006        );
1007
1008        // --no-abort-on-error: the abortOnFail object is omitted entirely so a
1009        // stress run executes its full duration regardless of error rate.
1010        let stress_script = K6ScriptGenerator::new(base_config(), vec![])
1011            .with_abort_valve(false, 0.95)
1012            .generate()
1013            .expect("script generates");
1014        // Match the threshold object (`abortOnFail: true`), not the word alone:
1015        // the template comment legitimately mentions "abortOnFail".
1016        assert!(
1017            !stress_script.contains("abortOnFail: true"),
1018            "--no-abort-on-error must drop the abortOnFail threshold"
1019        );
1020        // The pass/fail threshold still records the failure rate.
1021        assert!(stress_script.contains("rate<0.05"));
1022
1023        // --abort-on-error-rate 0.99: valve on, but only fires above 99%.
1024        let tuned_script = K6ScriptGenerator::new(base_config(), vec![])
1025            .with_abort_valve(true, 0.99)
1026            .generate()
1027            .expect("script generates");
1028        assert!(
1029            tuned_script.contains("abortOnFail: true") && tuned_script.contains("rate<0.99"),
1030            "--abort-on-error-rate must retune the valve threshold"
1031        );
1032    }
1033
1034    #[test]
1035    fn test_script_generation_with_dots_in_name() {
1036        use crate::spec_parser::ApiOperation;
1037        use openapiv3::Operation;
1038
1039        // Create an operation with a name containing dots (like in issue #79)
1040        let operation = ApiOperation {
1041            method: "get".to_string(),
1042            path: "/billing/subscriptions".to_string(),
1043            operation: Operation::default(),
1044            operation_id: Some("billing.subscriptions.v1".to_string()),
1045        };
1046
1047        let template = RequestTemplate {
1048            operation,
1049            path_params: HashMap::new(),
1050            query_params: HashMap::new(),
1051            headers: HashMap::new(),
1052            body: None,
1053        };
1054
1055        let config = K6Config {
1056            target_url: "https://api.example.com".to_string(),
1057            base_path: None,
1058            scenario: LoadScenario::Constant,
1059            duration_secs: 30,
1060            max_vus: 5,
1061            threshold_percentile: "p(95)".to_string(),
1062            threshold_ms: 500,
1063            max_error_rate: 0.05,
1064            auth_header: None,
1065            custom_headers: HashMap::new(),
1066            skip_tls_verify: false,
1067            security_testing_enabled: false,
1068            chunked_request_bodies: false,
1069            target_rps: None,
1070            no_keep_alive: false,
1071            geo_source_ips: Vec::new(),
1072            geo_source_headers: Vec::new(),
1073        };
1074
1075        let generator = K6ScriptGenerator::new(config, vec![template]);
1076        let script = generator.generate().expect("Should generate script");
1077
1078        // Verify the script contains sanitized variable names (no dots in variable identifiers)
1079        assert!(
1080            script.contains("const billing_subscriptions_v1_latency"),
1081            "Script should contain sanitized variable name for latency"
1082        );
1083        assert!(
1084            script.contains("const billing_subscriptions_v1_errors"),
1085            "Script should contain sanitized variable name for errors"
1086        );
1087
1088        // Verify variable names do NOT contain dots (check the actual variable identifier, not string literals)
1089        // The pattern "const billing.subscriptions" would indicate a variable name with dots
1090        assert!(
1091            !script.contains("const billing.subscriptions"),
1092            "Script should not contain variable names with dots - this would cause 'Unexpected token .' error"
1093        );
1094
1095        // Verify metric name strings are sanitized (no dots) - k6 requires valid metric names
1096        // Metric names must only contain ASCII letters, numbers, or underscores
1097        assert!(
1098            script.contains("'billing_subscriptions_v1_latency'"),
1099            "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
1100        );
1101        assert!(
1102            script.contains("'billing_subscriptions_v1_errors'"),
1103            "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
1104        );
1105
1106        // Verify the original display name is still used in comments and strings (for readability)
1107        assert!(
1108            script.contains("billing.subscriptions.v1"),
1109            "Script should contain original name in comments/strings for readability"
1110        );
1111
1112        // Most importantly: verify the variable usage doesn't have dots
1113        assert!(
1114            script.contains("billing_subscriptions_v1_latency.add"),
1115            "Variable usage should use sanitized name"
1116        );
1117        assert!(
1118            script.contains("billing_subscriptions_v1_errors.add"),
1119            "Variable usage should use sanitized name"
1120        );
1121    }
1122
1123    /// Issue #79 (round 5) regression: `--rps` with the default `ramp-up`
1124    /// scenario produced 0 requests because the script took
1125    /// `preAllocatedVUs` from the *last* stage's target — and ramp-up's last
1126    /// stage is the ramp-DOWN to `target: 0`. The fix is to use the
1127    /// configured `max_vus` directly when `target_rps` is set, and the full
1128    /// `duration_secs` rather than the last stage's duration.
1129    #[test]
1130    fn test_rps_with_ramp_up_uses_full_vu_pool_and_duration() {
1131        use crate::spec_parser::ApiOperation;
1132        use openapiv3::Operation;
1133
1134        let operation = ApiOperation {
1135            method: "get".to_string(),
1136            path: "/users".to_string(),
1137            operation: Operation::default(),
1138            operation_id: Some("listUsers".to_string()),
1139        };
1140        let template = RequestTemplate {
1141            operation,
1142            path_params: HashMap::new(),
1143            query_params: HashMap::new(),
1144            headers: HashMap::new(),
1145            body: None,
1146        };
1147
1148        let config = K6Config {
1149            target_url: "https://api.example.com".to_string(),
1150            base_path: None,
1151            scenario: LoadScenario::RampUp,
1152            duration_secs: 600,
1153            max_vus: 100,
1154            threshold_percentile: "p(95)".to_string(),
1155            threshold_ms: 500,
1156            max_error_rate: 0.05,
1157            auth_header: None,
1158            custom_headers: HashMap::new(),
1159            skip_tls_verify: false,
1160            security_testing_enabled: false,
1161            chunked_request_bodies: false,
1162            target_rps: Some(100),
1163            no_keep_alive: false,
1164            geo_source_ips: Vec::new(),
1165            geo_source_headers: Vec::new(),
1166        };
1167
1168        let generator = K6ScriptGenerator::new(config, vec![template]);
1169        let script = generator.generate().expect("Should generate script");
1170
1171        assert!(
1172            script.contains("constant-arrival-rate"),
1173            "with --rps set, executor must switch to constant-arrival-rate"
1174        );
1175        assert!(
1176            script.contains("rate: 100,"),
1177            "constant-arrival-rate must use the configured --rps as `rate`"
1178        );
1179        assert!(
1180            script.contains("duration: '600s'"),
1181            "duration must come from --duration, not the ramp-down stage; got:\n{}",
1182            script
1183        );
1184        assert!(
1185            script.contains("preAllocatedVUs: 100,"),
1186            "preAllocatedVUs must equal --vus, not the last stage's target=0; got:\n{}",
1187            script
1188        );
1189        assert!(
1190            script.contains("maxVUs: 100,"),
1191            "maxVUs must equal --vus, not the last stage's target=0; got:\n{}",
1192            script
1193        );
1194        // Make sure the regression — `preAllocatedVUs: 0` from the ramp-down —
1195        // can never silently come back. Walk the lines so we don't false-
1196        // positive on the explanatory comment that lives in the template.
1197        for (idx, line) in script.lines().enumerate() {
1198            let trimmed = line.trim_start();
1199            if trimmed.starts_with("//") || trimmed.starts_with("/*") {
1200                continue;
1201            }
1202            assert!(
1203                !trimmed.starts_with("preAllocatedVUs: 0"),
1204                "regression at line {}: preAllocatedVUs is 0 — constant-arrival-rate \
1205                 will run no VUs (issue #79 round 5 ramp-up bug). Line: {:?}",
1206                idx + 1,
1207                line,
1208            );
1209        }
1210    }
1211
1212    /// Companion to the test above: confirm `--cps` flips `noConnectionReuse`
1213    /// on. Issue #79 (round 5).
1214    #[test]
1215    fn test_cps_sets_no_connection_reuse() {
1216        use crate::spec_parser::ApiOperation;
1217        use openapiv3::Operation;
1218
1219        let operation = ApiOperation {
1220            method: "get".to_string(),
1221            path: "/u".to_string(),
1222            operation: Operation::default(),
1223            operation_id: Some("u".to_string()),
1224        };
1225        let template = RequestTemplate {
1226            operation,
1227            path_params: HashMap::new(),
1228            query_params: HashMap::new(),
1229            headers: HashMap::new(),
1230            body: None,
1231        };
1232        let config = K6Config {
1233            target_url: "https://api.example.com".to_string(),
1234            base_path: None,
1235            scenario: LoadScenario::Constant,
1236            duration_secs: 30,
1237            max_vus: 5,
1238            threshold_percentile: "p(95)".to_string(),
1239            threshold_ms: 500,
1240            max_error_rate: 0.05,
1241            auth_header: None,
1242            custom_headers: HashMap::new(),
1243            skip_tls_verify: false,
1244            security_testing_enabled: false,
1245            chunked_request_bodies: false,
1246            target_rps: None,
1247            no_keep_alive: true,
1248            geo_source_ips: Vec::new(),
1249            geo_source_headers: Vec::new(),
1250        };
1251        let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
1252        assert!(
1253            script.contains("noConnectionReuse: true"),
1254            "--cps must set noConnectionReuse: true on the k6 options block"
1255        );
1256        assert!(
1257            script.contains("Total Connections:"),
1258            "--cps summary must include connection-rate output (Srikanth's round-5 ask)"
1259        );
1260        assert!(
1261            script.contains("Connection Rate:"),
1262            "--cps summary must include 'Connection Rate:' (Srikanth's round-5 ask)"
1263        );
1264    }
1265
1266    /// Issue #79 round 6 follow-up: Srikanth reported `--vus 5 -d 600s` taking
1267    /// until the 6-minute mark to reach 5 VUs because the script always set
1268    /// `startVUs: 0`, so k6's `ramping-vus` linearly ramped 0 → 5 over the
1269    /// whole window. For `--scenario constant` we now seed startVUs at the
1270    /// target so the test runs at full concurrency from t=0.
1271    #[test]
1272    fn test_constant_scenario_starts_at_target_vus() {
1273        use crate::spec_parser::ApiOperation;
1274        use openapiv3::Operation;
1275
1276        let operation = ApiOperation {
1277            method: "get".to_string(),
1278            path: "/u".to_string(),
1279            operation: Operation::default(),
1280            operation_id: Some("u".to_string()),
1281        };
1282        let template = RequestTemplate {
1283            operation,
1284            path_params: HashMap::new(),
1285            query_params: HashMap::new(),
1286            headers: HashMap::new(),
1287            body: None,
1288        };
1289        let config = K6Config {
1290            target_url: "https://api.example.com".to_string(),
1291            base_path: None,
1292            scenario: LoadScenario::Constant,
1293            duration_secs: 600,
1294            max_vus: 5,
1295            threshold_percentile: "p(95)".to_string(),
1296            threshold_ms: 500,
1297            max_error_rate: 0.05,
1298            auth_header: None,
1299            custom_headers: HashMap::new(),
1300            skip_tls_verify: false,
1301            security_testing_enabled: false,
1302            chunked_request_bodies: false,
1303            target_rps: None,
1304            no_keep_alive: false,
1305            geo_source_ips: Vec::new(),
1306            geo_source_headers: Vec::new(),
1307        };
1308        let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
1309        assert!(
1310            script.contains("startVUs: 5,"),
1311            "--scenario constant must seed startVUs at max_vus, not 0; got:\n{}",
1312            script
1313        );
1314        // RampUp should still start at 0 — confirm we didn't break ramps.
1315        let ramp_config = K6Config {
1316            target_url: "https://api.example.com".to_string(),
1317            base_path: None,
1318            scenario: LoadScenario::RampUp,
1319            duration_secs: 600,
1320            max_vus: 5,
1321            threshold_percentile: "p(95)".to_string(),
1322            threshold_ms: 500,
1323            max_error_rate: 0.05,
1324            auth_header: None,
1325            custom_headers: HashMap::new(),
1326            skip_tls_verify: false,
1327            security_testing_enabled: false,
1328            chunked_request_bodies: false,
1329            target_rps: None,
1330            no_keep_alive: false,
1331            geo_source_ips: Vec::new(),
1332            geo_source_headers: Vec::new(),
1333        };
1334        let ramp_template = RequestTemplate {
1335            operation: ApiOperation {
1336                method: "get".to_string(),
1337                path: "/u".to_string(),
1338                operation: Operation::default(),
1339                operation_id: Some("u".to_string()),
1340            },
1341            path_params: HashMap::new(),
1342            query_params: HashMap::new(),
1343            headers: HashMap::new(),
1344            body: None,
1345        };
1346        let ramp_script =
1347            K6ScriptGenerator::new(ramp_config, vec![ramp_template]).generate().unwrap();
1348        assert!(
1349            ramp_script.contains("startVUs: 0,"),
1350            "--scenario ramp-up must keep startVUs at 0 so stages drive the ramp; got:\n{}",
1351            ramp_script
1352        );
1353    }
1354
1355    /// Issue #79 round 6 follow-up: srikr's `--rps 100 --vus 5` summary showed
1356    /// no "Connections opened" line because the client-side connection counter
1357    /// was reading `http_req_connecting.values.count` — a field that doesn't
1358    /// exist (k6's Trend metric only emits avg/min/med/max/p90/p95). The fix
1359    /// adds a dedicated Counter (`mockforge_connections_opened`) that the
1360    /// template increments whenever `res.timings.connecting > 0`. This test
1361    /// guards both the metric declaration and the per-request increment so
1362    /// the connection counter can't silently regress.
1363    #[test]
1364    fn test_connections_opened_counter_present() {
1365        use crate::spec_parser::ApiOperation;
1366        use openapiv3::Operation;
1367
1368        let operation = ApiOperation {
1369            method: "get".to_string(),
1370            path: "/u".to_string(),
1371            operation: Operation::default(),
1372            operation_id: Some("u".to_string()),
1373        };
1374        let template = RequestTemplate {
1375            operation,
1376            path_params: HashMap::new(),
1377            query_params: HashMap::new(),
1378            headers: HashMap::new(),
1379            body: None,
1380        };
1381        let config = K6Config {
1382            target_url: "https://api.example.com".to_string(),
1383            base_path: None,
1384            scenario: LoadScenario::Constant,
1385            duration_secs: 30,
1386            max_vus: 5,
1387            threshold_percentile: "p(95)".to_string(),
1388            threshold_ms: 500,
1389            max_error_rate: 0.05,
1390            auth_header: None,
1391            custom_headers: HashMap::new(),
1392            skip_tls_verify: false,
1393            security_testing_enabled: false,
1394            chunked_request_bodies: false,
1395            target_rps: Some(50),
1396            no_keep_alive: false,
1397            geo_source_ips: Vec::new(),
1398            geo_source_headers: Vec::new(),
1399        };
1400        let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
1401        assert!(
1402            script.contains("new Counter('mockforge_connections_opened')"),
1403            "template must declare the mockforge_connections_opened Counter"
1404        );
1405        assert!(
1406            script.contains("mockforge_connections_opened.add(1)"),
1407            "template must increment mockforge_connections_opened on new TCP connect"
1408        );
1409        assert!(
1410            script.contains("res.timings.connecting > 0"),
1411            "template must gate the connection-opened increment on \
1412             res.timings.connecting > 0 (only fires when a fresh socket was opened)"
1413        );
1414    }
1415
1416    #[test]
1417    fn test_validate_script_valid() {
1418        let valid_script = r#"
1419import http from 'k6/http';
1420import { check, sleep } from 'k6';
1421import { Rate, Trend } from 'k6/metrics';
1422
1423const test_latency = new Trend('test_latency');
1424const test_errors = new Rate('test_errors');
1425
1426export default function() {
1427    const res = http.get('https://example.com');
1428    test_latency.add(res.timings.duration);
1429    test_errors.add(res.status !== 200);
1430}
1431"#;
1432
1433        let errors = K6ScriptGenerator::validate_script(valid_script);
1434        assert!(errors.is_empty(), "Valid script should have no validation errors");
1435    }
1436
1437    #[test]
1438    fn test_validate_script_invalid_metric_name() {
1439        let invalid_script = r#"
1440import http from 'k6/http';
1441import { check, sleep } from 'k6';
1442import { Rate, Trend } from 'k6/metrics';
1443
1444const test_latency = new Trend('test.latency');
1445const test_errors = new Rate('test_errors');
1446
1447export default function() {
1448    const res = http.get('https://example.com');
1449    test_latency.add(res.timings.duration);
1450}
1451"#;
1452
1453        let errors = K6ScriptGenerator::validate_script(invalid_script);
1454        assert!(
1455            !errors.is_empty(),
1456            "Script with invalid metric name should have validation errors"
1457        );
1458        assert!(
1459            errors.iter().any(|e| e.contains("Invalid k6 metric name")),
1460            "Should detect invalid metric name with dot"
1461        );
1462    }
1463
1464    #[test]
1465    fn test_validate_script_missing_imports() {
1466        let invalid_script = r#"
1467const test_latency = new Trend('test_latency');
1468export default function() {}
1469"#;
1470
1471        let errors = K6ScriptGenerator::validate_script(invalid_script);
1472        assert!(!errors.is_empty(), "Script missing imports should have validation errors");
1473    }
1474
1475    #[test]
1476    fn test_validate_script_metric_name_validation() {
1477        // Test that validate_script correctly identifies invalid metric names
1478        // Valid metric names should pass
1479        let valid_script = r#"
1480import http from 'k6/http';
1481import { check, sleep } from 'k6';
1482import { Rate, Trend } from 'k6/metrics';
1483const test_latency = new Trend('test_latency');
1484const test_errors = new Rate('test_errors');
1485export default function() {}
1486"#;
1487        let errors = K6ScriptGenerator::validate_script(valid_script);
1488        assert!(errors.is_empty(), "Valid metric names should pass validation");
1489
1490        // Invalid metric names should fail
1491        let invalid_cases = vec![
1492            ("test.latency", "dot in metric name"),
1493            ("123test", "starts with number"),
1494            ("test-latency", "hyphen in metric name"),
1495            ("test@latency", "special character"),
1496        ];
1497
1498        for (invalid_name, description) in invalid_cases {
1499            let script = format!(
1500                r#"
1501import http from 'k6/http';
1502import {{ check, sleep }} from 'k6';
1503import {{ Rate, Trend }} from 'k6/metrics';
1504const test_latency = new Trend('{}');
1505export default function() {{}}
1506"#,
1507                invalid_name
1508            );
1509            let errors = K6ScriptGenerator::validate_script(&script);
1510            assert!(
1511                !errors.is_empty(),
1512                "Metric name '{}' ({}) should fail validation",
1513                invalid_name,
1514                description
1515            );
1516        }
1517    }
1518
1519    #[test]
1520    fn test_skip_tls_verify_with_body() {
1521        use crate::spec_parser::ApiOperation;
1522        use openapiv3::Operation;
1523        use serde_json::json;
1524
1525        // Create an operation with a request body
1526        let operation = ApiOperation {
1527            method: "post".to_string(),
1528            path: "/api/users".to_string(),
1529            operation: Operation::default(),
1530            operation_id: Some("createUser".to_string()),
1531        };
1532
1533        let template = RequestTemplate {
1534            operation,
1535            path_params: HashMap::new(),
1536            query_params: HashMap::new(),
1537            headers: HashMap::new(),
1538            body: Some(json!({"name": "test"})),
1539        };
1540
1541        let config = K6Config {
1542            target_url: "https://api.example.com".to_string(),
1543            base_path: None,
1544            scenario: LoadScenario::Constant,
1545            duration_secs: 30,
1546            max_vus: 5,
1547            threshold_percentile: "p(95)".to_string(),
1548            threshold_ms: 500,
1549            max_error_rate: 0.05,
1550            auth_header: None,
1551            custom_headers: HashMap::new(),
1552            skip_tls_verify: true,
1553            security_testing_enabled: false,
1554            chunked_request_bodies: false,
1555            target_rps: None,
1556            no_keep_alive: false,
1557            geo_source_ips: Vec::new(),
1558            geo_source_headers: Vec::new(),
1559        };
1560
1561        let generator = K6ScriptGenerator::new(config, vec![template]);
1562        let script = generator.generate().expect("Should generate script");
1563
1564        // Verify the script includes TLS skip option for requests with body
1565        assert!(
1566            script.contains("insecureSkipTLSVerify: true"),
1567            "Script should include insecureSkipTLSVerify option when skip_tls_verify is true"
1568        );
1569    }
1570
1571    #[test]
1572    fn test_skip_tls_verify_without_body() {
1573        use crate::spec_parser::ApiOperation;
1574        use openapiv3::Operation;
1575
1576        // Create an operation without a request body
1577        let operation = ApiOperation {
1578            method: "get".to_string(),
1579            path: "/api/users".to_string(),
1580            operation: Operation::default(),
1581            operation_id: Some("getUsers".to_string()),
1582        };
1583
1584        let template = RequestTemplate {
1585            operation,
1586            path_params: HashMap::new(),
1587            query_params: HashMap::new(),
1588            headers: HashMap::new(),
1589            body: None,
1590        };
1591
1592        let config = K6Config {
1593            target_url: "https://api.example.com".to_string(),
1594            base_path: None,
1595            scenario: LoadScenario::Constant,
1596            duration_secs: 30,
1597            max_vus: 5,
1598            threshold_percentile: "p(95)".to_string(),
1599            threshold_ms: 500,
1600            max_error_rate: 0.05,
1601            auth_header: None,
1602            custom_headers: HashMap::new(),
1603            skip_tls_verify: true,
1604            security_testing_enabled: false,
1605            chunked_request_bodies: false,
1606            target_rps: None,
1607            no_keep_alive: false,
1608            geo_source_ips: Vec::new(),
1609            geo_source_headers: Vec::new(),
1610        };
1611
1612        let generator = K6ScriptGenerator::new(config, vec![template]);
1613        let script = generator.generate().expect("Should generate script");
1614
1615        // Verify the script includes TLS skip option for requests without body
1616        assert!(
1617            script.contains("insecureSkipTLSVerify: true"),
1618            "Script should include insecureSkipTLSVerify option when skip_tls_verify is true (no body)"
1619        );
1620    }
1621
1622    #[test]
1623    fn test_no_skip_tls_verify() {
1624        use crate::spec_parser::ApiOperation;
1625        use openapiv3::Operation;
1626
1627        // Create an operation
1628        let operation = ApiOperation {
1629            method: "get".to_string(),
1630            path: "/api/users".to_string(),
1631            operation: Operation::default(),
1632            operation_id: Some("getUsers".to_string()),
1633        };
1634
1635        let template = RequestTemplate {
1636            operation,
1637            path_params: HashMap::new(),
1638            query_params: HashMap::new(),
1639            headers: HashMap::new(),
1640            body: None,
1641        };
1642
1643        let config = K6Config {
1644            target_url: "https://api.example.com".to_string(),
1645            base_path: None,
1646            scenario: LoadScenario::Constant,
1647            duration_secs: 30,
1648            max_vus: 5,
1649            threshold_percentile: "p(95)".to_string(),
1650            threshold_ms: 500,
1651            max_error_rate: 0.05,
1652            auth_header: None,
1653            custom_headers: HashMap::new(),
1654            skip_tls_verify: false,
1655            security_testing_enabled: false,
1656            chunked_request_bodies: false,
1657            target_rps: None,
1658            no_keep_alive: false,
1659            geo_source_ips: Vec::new(),
1660            geo_source_headers: Vec::new(),
1661        };
1662
1663        let generator = K6ScriptGenerator::new(config, vec![template]);
1664        let script = generator.generate().expect("Should generate script");
1665
1666        // Verify the script does NOT include TLS skip option when skip_tls_verify is false
1667        assert!(
1668            !script.contains("insecureSkipTLSVerify"),
1669            "Script should NOT include insecureSkipTLSVerify option when skip_tls_verify is false"
1670        );
1671    }
1672
1673    #[test]
1674    fn test_skip_tls_verify_multiple_operations() {
1675        use crate::spec_parser::ApiOperation;
1676        use openapiv3::Operation;
1677        use serde_json::json;
1678
1679        // Create multiple operations - one with body, one without
1680        let operation1 = ApiOperation {
1681            method: "get".to_string(),
1682            path: "/api/users".to_string(),
1683            operation: Operation::default(),
1684            operation_id: Some("getUsers".to_string()),
1685        };
1686
1687        let operation2 = ApiOperation {
1688            method: "post".to_string(),
1689            path: "/api/users".to_string(),
1690            operation: Operation::default(),
1691            operation_id: Some("createUser".to_string()),
1692        };
1693
1694        let template1 = RequestTemplate {
1695            operation: operation1,
1696            path_params: HashMap::new(),
1697            query_params: HashMap::new(),
1698            headers: HashMap::new(),
1699            body: None,
1700        };
1701
1702        let template2 = RequestTemplate {
1703            operation: operation2,
1704            path_params: HashMap::new(),
1705            query_params: HashMap::new(),
1706            headers: HashMap::new(),
1707            body: Some(json!({"name": "test"})),
1708        };
1709
1710        let config = K6Config {
1711            target_url: "https://api.example.com".to_string(),
1712            base_path: None,
1713            scenario: LoadScenario::Constant,
1714            duration_secs: 30,
1715            max_vus: 5,
1716            threshold_percentile: "p(95)".to_string(),
1717            threshold_ms: 500,
1718            max_error_rate: 0.05,
1719            auth_header: None,
1720            custom_headers: HashMap::new(),
1721            skip_tls_verify: true,
1722            security_testing_enabled: false,
1723            chunked_request_bodies: false,
1724            target_rps: None,
1725            no_keep_alive: false,
1726            geo_source_ips: Vec::new(),
1727            geo_source_headers: Vec::new(),
1728        };
1729
1730        let generator = K6ScriptGenerator::new(config, vec![template1, template2]);
1731        let script = generator.generate().expect("Should generate script");
1732
1733        // Verify the script includes TLS skip option ONCE in global options
1734        // (k6 only supports insecureSkipTLSVerify as a global option, not per-request)
1735        let skip_count = script.matches("insecureSkipTLSVerify: true").count();
1736        assert_eq!(
1737            skip_count, 1,
1738            "Script should include insecureSkipTLSVerify exactly once in global options (not per-request)"
1739        );
1740
1741        // Verify it appears in the options block, before scenarios
1742        let options_start = script.find("export const options = {").expect("Should have options");
1743        let scenarios_start = script.find("scenarios:").expect("Should have scenarios");
1744        let options_prefix = &script[options_start..scenarios_start];
1745        assert!(
1746            options_prefix.contains("insecureSkipTLSVerify: true"),
1747            "insecureSkipTLSVerify should be in global options block"
1748        );
1749    }
1750
1751    #[test]
1752    fn test_dynamic_params_in_body() {
1753        use crate::spec_parser::ApiOperation;
1754        use openapiv3::Operation;
1755        use serde_json::json;
1756
1757        // Create an operation with dynamic placeholders in the body
1758        let operation = ApiOperation {
1759            method: "post".to_string(),
1760            path: "/api/resources".to_string(),
1761            operation: Operation::default(),
1762            operation_id: Some("createResource".to_string()),
1763        };
1764
1765        let template = RequestTemplate {
1766            operation,
1767            path_params: HashMap::new(),
1768            query_params: HashMap::new(),
1769            headers: HashMap::new(),
1770            body: Some(json!({
1771                "name": "load-test-${__VU}",
1772                "iteration": "${__ITER}"
1773            })),
1774        };
1775
1776        let config = K6Config {
1777            target_url: "https://api.example.com".to_string(),
1778            base_path: None,
1779            scenario: LoadScenario::Constant,
1780            duration_secs: 30,
1781            max_vus: 5,
1782            threshold_percentile: "p(95)".to_string(),
1783            threshold_ms: 500,
1784            max_error_rate: 0.05,
1785            auth_header: None,
1786            custom_headers: HashMap::new(),
1787            skip_tls_verify: false,
1788            security_testing_enabled: false,
1789            chunked_request_bodies: false,
1790            target_rps: None,
1791            no_keep_alive: false,
1792            geo_source_ips: Vec::new(),
1793            geo_source_headers: Vec::new(),
1794        };
1795
1796        let generator = K6ScriptGenerator::new(config, vec![template]);
1797        let script = generator.generate().expect("Should generate script");
1798
1799        // Verify the script contains dynamic body indication
1800        assert!(
1801            script.contains("Dynamic body with runtime placeholders"),
1802            "Script should contain comment about dynamic body"
1803        );
1804
1805        // Verify the script contains the __VU variable reference
1806        assert!(
1807            script.contains("__VU"),
1808            "Script should contain __VU reference for dynamic VU-based values"
1809        );
1810
1811        // Verify the script contains the __ITER variable reference
1812        assert!(
1813            script.contains("__ITER"),
1814            "Script should contain __ITER reference for dynamic iteration values"
1815        );
1816    }
1817
1818    #[test]
1819    fn test_dynamic_params_with_uuid() {
1820        use crate::spec_parser::ApiOperation;
1821        use openapiv3::Operation;
1822        use serde_json::json;
1823
1824        // Create an operation with UUID placeholder
1825        let operation = ApiOperation {
1826            method: "post".to_string(),
1827            path: "/api/resources".to_string(),
1828            operation: Operation::default(),
1829            operation_id: Some("createResource".to_string()),
1830        };
1831
1832        let template = RequestTemplate {
1833            operation,
1834            path_params: HashMap::new(),
1835            query_params: HashMap::new(),
1836            headers: HashMap::new(),
1837            body: Some(json!({
1838                "id": "${__UUID}"
1839            })),
1840        };
1841
1842        let config = K6Config {
1843            target_url: "https://api.example.com".to_string(),
1844            base_path: None,
1845            scenario: LoadScenario::Constant,
1846            duration_secs: 30,
1847            max_vus: 5,
1848            threshold_percentile: "p(95)".to_string(),
1849            threshold_ms: 500,
1850            max_error_rate: 0.05,
1851            auth_header: None,
1852            custom_headers: HashMap::new(),
1853            skip_tls_verify: false,
1854            security_testing_enabled: false,
1855            chunked_request_bodies: false,
1856            target_rps: None,
1857            no_keep_alive: false,
1858            geo_source_ips: Vec::new(),
1859            geo_source_headers: Vec::new(),
1860        };
1861
1862        let generator = K6ScriptGenerator::new(config, vec![template]);
1863        let script = generator.generate().expect("Should generate script");
1864
1865        // As of k6 v1.0.0+, webcrypto is globally available - no import needed
1866        // Verify the script does NOT include the old experimental webcrypto import
1867        assert!(
1868            !script.contains("k6/experimental/webcrypto"),
1869            "Script should NOT include deprecated k6/experimental/webcrypto import"
1870        );
1871
1872        // Verify crypto.randomUUID() is in the generated code
1873        assert!(
1874            script.contains("crypto.randomUUID()"),
1875            "Script should contain crypto.randomUUID() for UUID placeholder"
1876        );
1877    }
1878
1879    #[test]
1880    fn test_dynamic_params_with_counter() {
1881        use crate::spec_parser::ApiOperation;
1882        use openapiv3::Operation;
1883        use serde_json::json;
1884
1885        // Create an operation with COUNTER placeholder
1886        let operation = ApiOperation {
1887            method: "post".to_string(),
1888            path: "/api/resources".to_string(),
1889            operation: Operation::default(),
1890            operation_id: Some("createResource".to_string()),
1891        };
1892
1893        let template = RequestTemplate {
1894            operation,
1895            path_params: HashMap::new(),
1896            query_params: HashMap::new(),
1897            headers: HashMap::new(),
1898            body: Some(json!({
1899                "sequence": "${__COUNTER}"
1900            })),
1901        };
1902
1903        let config = K6Config {
1904            target_url: "https://api.example.com".to_string(),
1905            base_path: None,
1906            scenario: LoadScenario::Constant,
1907            duration_secs: 30,
1908            max_vus: 5,
1909            threshold_percentile: "p(95)".to_string(),
1910            threshold_ms: 500,
1911            max_error_rate: 0.05,
1912            auth_header: None,
1913            custom_headers: HashMap::new(),
1914            skip_tls_verify: false,
1915            security_testing_enabled: false,
1916            chunked_request_bodies: false,
1917            target_rps: None,
1918            no_keep_alive: false,
1919            geo_source_ips: Vec::new(),
1920            geo_source_headers: Vec::new(),
1921        };
1922
1923        let generator = K6ScriptGenerator::new(config, vec![template]);
1924        let script = generator.generate().expect("Should generate script");
1925
1926        // Verify the script includes the global counter initialization
1927        assert!(
1928            script.contains("let globalCounter = 0"),
1929            "Script should include globalCounter initialization when COUNTER placeholder is used"
1930        );
1931
1932        // Verify globalCounter++ is in the generated code
1933        assert!(
1934            script.contains("globalCounter++"),
1935            "Script should contain globalCounter++ for COUNTER placeholder"
1936        );
1937    }
1938
1939    #[test]
1940    fn test_static_body_no_dynamic_marker() {
1941        use crate::spec_parser::ApiOperation;
1942        use openapiv3::Operation;
1943        use serde_json::json;
1944
1945        // Create an operation with static body (no placeholders)
1946        let operation = ApiOperation {
1947            method: "post".to_string(),
1948            path: "/api/resources".to_string(),
1949            operation: Operation::default(),
1950            operation_id: Some("createResource".to_string()),
1951        };
1952
1953        let template = RequestTemplate {
1954            operation,
1955            path_params: HashMap::new(),
1956            query_params: HashMap::new(),
1957            headers: HashMap::new(),
1958            body: Some(json!({
1959                "name": "static-value",
1960                "count": 42
1961            })),
1962        };
1963
1964        let config = K6Config {
1965            target_url: "https://api.example.com".to_string(),
1966            base_path: None,
1967            scenario: LoadScenario::Constant,
1968            duration_secs: 30,
1969            max_vus: 5,
1970            threshold_percentile: "p(95)".to_string(),
1971            threshold_ms: 500,
1972            max_error_rate: 0.05,
1973            auth_header: None,
1974            custom_headers: HashMap::new(),
1975            skip_tls_verify: false,
1976            security_testing_enabled: false,
1977            chunked_request_bodies: false,
1978            target_rps: None,
1979            no_keep_alive: false,
1980            geo_source_ips: Vec::new(),
1981            geo_source_headers: Vec::new(),
1982        };
1983
1984        let generator = K6ScriptGenerator::new(config, vec![template]);
1985        let script = generator.generate().expect("Should generate script");
1986
1987        // Verify the script does NOT contain dynamic body marker
1988        assert!(
1989            !script.contains("Dynamic body with runtime placeholders"),
1990            "Script should NOT contain dynamic body comment for static body"
1991        );
1992
1993        // Verify it does NOT include unnecessary crypto imports
1994        assert!(
1995            !script.contains("webcrypto"),
1996            "Script should NOT include webcrypto import for static body"
1997        );
1998
1999        // Verify it does NOT include global counter
2000        assert!(
2001            !script.contains("let globalCounter"),
2002            "Script should NOT include globalCounter for static body"
2003        );
2004    }
2005
2006    #[test]
2007    fn test_security_testing_enabled_generates_calling_code() {
2008        use crate::spec_parser::ApiOperation;
2009        use openapiv3::Operation;
2010        use serde_json::json;
2011
2012        let operation = ApiOperation {
2013            method: "post".to_string(),
2014            path: "/api/users".to_string(),
2015            operation: Operation::default(),
2016            operation_id: Some("createUser".to_string()),
2017        };
2018
2019        let template = RequestTemplate {
2020            operation,
2021            path_params: HashMap::new(),
2022            query_params: HashMap::new(),
2023            headers: HashMap::new(),
2024            body: Some(json!({"name": "test"})),
2025        };
2026
2027        let config = K6Config {
2028            target_url: "https://api.example.com".to_string(),
2029            base_path: None,
2030            scenario: LoadScenario::Constant,
2031            duration_secs: 30,
2032            max_vus: 5,
2033            threshold_percentile: "p(95)".to_string(),
2034            threshold_ms: 500,
2035            max_error_rate: 0.05,
2036            auth_header: None,
2037            custom_headers: HashMap::new(),
2038            skip_tls_verify: false,
2039            security_testing_enabled: true,
2040            chunked_request_bodies: false,
2041            target_rps: None,
2042            no_keep_alive: false,
2043            geo_source_ips: Vec::new(),
2044            geo_source_headers: Vec::new(),
2045        };
2046
2047        let generator = K6ScriptGenerator::new(config, vec![template]);
2048        let script = generator.generate().expect("Should generate script");
2049
2050        // Verify calling code is generated (not just function definitions)
2051        assert!(
2052            script.contains("getNextSecurityPayload"),
2053            "Script should contain getNextSecurityPayload() call when security_testing_enabled is true"
2054        );
2055        assert!(
2056            script.contains("applySecurityPayload"),
2057            "Script should contain applySecurityPayload() call when security_testing_enabled is true"
2058        );
2059        assert!(
2060            script.contains("secPayloadGroup"),
2061            "Script should contain secPayloadGroup variable when security_testing_enabled is true"
2062        );
2063        assert!(
2064            script.contains("secBodyPayload"),
2065            "Script should contain secBodyPayload variable when security_testing_enabled is true"
2066        );
2067        // Verify CookieJar skip when Cookie header payload is present
2068        assert!(
2069            script.contains("hasSecCookie"),
2070            "Script should track hasSecCookie for CookieJar conflict avoidance"
2071        );
2072        assert!(
2073            script.contains("secRequestOpts"),
2074            "Script should use secRequestOpts to conditionally skip CookieJar"
2075        );
2076        // Verify mutable headers copy for injection
2077        assert!(
2078            script.contains("const requestHeaders = { ..."),
2079            "Script should spread headers into mutable copy for security payload injection"
2080        );
2081        // Verify injectAsPath handling for path-based URI injection
2082        assert!(
2083            script.contains("secPayload.injectAsPath"),
2084            "Script should check injectAsPath for path-based URI injection"
2085        );
2086        // Verify formBody handling for form-encoded body delivery
2087        assert!(
2088            script.contains("secBodyPayload.formBody"),
2089            "Script should check formBody for form-encoded body delivery"
2090        );
2091        assert!(
2092            script.contains("application/x-www-form-urlencoded"),
2093            "Script should set Content-Type for form-encoded body"
2094        );
2095        // Verify secPayloadGroup is fetched per-operation (inside operation block), not per-iteration
2096        let op_comment_pos =
2097            script.find("// Operation 0:").expect("Should have Operation 0 comment");
2098        let sec_payload_pos = script
2099            .find("const secPayloadGroup = typeof getNextSecurityPayload")
2100            .expect("Should have secPayloadGroup assignment");
2101        assert!(
2102            sec_payload_pos > op_comment_pos,
2103            "secPayloadGroup should be fetched inside operation block (per-operation), not before it (per-iteration)"
2104        );
2105    }
2106
2107    #[test]
2108    fn test_security_testing_disabled_no_calling_code() {
2109        use crate::spec_parser::ApiOperation;
2110        use openapiv3::Operation;
2111        use serde_json::json;
2112
2113        let operation = ApiOperation {
2114            method: "post".to_string(),
2115            path: "/api/users".to_string(),
2116            operation: Operation::default(),
2117            operation_id: Some("createUser".to_string()),
2118        };
2119
2120        let template = RequestTemplate {
2121            operation,
2122            path_params: HashMap::new(),
2123            query_params: HashMap::new(),
2124            headers: HashMap::new(),
2125            body: Some(json!({"name": "test"})),
2126        };
2127
2128        let config = K6Config {
2129            target_url: "https://api.example.com".to_string(),
2130            base_path: None,
2131            scenario: LoadScenario::Constant,
2132            duration_secs: 30,
2133            max_vus: 5,
2134            threshold_percentile: "p(95)".to_string(),
2135            threshold_ms: 500,
2136            max_error_rate: 0.05,
2137            auth_header: None,
2138            custom_headers: HashMap::new(),
2139            skip_tls_verify: false,
2140            security_testing_enabled: false,
2141            chunked_request_bodies: false,
2142            target_rps: None,
2143            no_keep_alive: false,
2144            geo_source_ips: Vec::new(),
2145            geo_source_headers: Vec::new(),
2146        };
2147
2148        let generator = K6ScriptGenerator::new(config, vec![template]);
2149        let script = generator.generate().expect("Should generate script");
2150
2151        // Verify calling code is NOT generated
2152        assert!(
2153            !script.contains("getNextSecurityPayload"),
2154            "Script should NOT contain getNextSecurityPayload() when security_testing_enabled is false"
2155        );
2156        assert!(
2157            !script.contains("applySecurityPayload"),
2158            "Script should NOT contain applySecurityPayload() when security_testing_enabled is false"
2159        );
2160        assert!(
2161            !script.contains("secPayloadGroup"),
2162            "Script should NOT contain secPayloadGroup variable when security_testing_enabled is false"
2163        );
2164        assert!(
2165            !script.contains("secBodyPayload"),
2166            "Script should NOT contain secBodyPayload variable when security_testing_enabled is false"
2167        );
2168        assert!(
2169            !script.contains("hasSecCookie"),
2170            "Script should NOT contain hasSecCookie when security_testing_enabled is false"
2171        );
2172        assert!(
2173            !script.contains("secRequestOpts"),
2174            "Script should NOT contain secRequestOpts when security_testing_enabled is false"
2175        );
2176        assert!(
2177            !script.contains("injectAsPath"),
2178            "Script should NOT contain injectAsPath when security_testing_enabled is false"
2179        );
2180        assert!(
2181            !script.contains("formBody"),
2182            "Script should NOT contain formBody when security_testing_enabled is false"
2183        );
2184    }
2185
2186    /// End-to-end test: simulates the real pipeline of template rendering + enhanced script
2187    /// injection. This is what actually runs when a user passes `--security-test`.
2188    /// Verifies that the FINAL script has both function definitions AND calling code.
2189    #[test]
2190    fn test_security_e2e_definitions_and_calls_both_present() {
2191        use crate::security_payloads::{
2192            SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
2193        };
2194        use crate::spec_parser::ApiOperation;
2195        use openapiv3::Operation;
2196        use serde_json::json;
2197
2198        // Step 1: Generate base script with security_testing_enabled=true (template renders calling code)
2199        let operation = ApiOperation {
2200            method: "post".to_string(),
2201            path: "/api/users".to_string(),
2202            operation: Operation::default(),
2203            operation_id: Some("createUser".to_string()),
2204        };
2205
2206        let template = RequestTemplate {
2207            operation,
2208            path_params: HashMap::new(),
2209            query_params: HashMap::new(),
2210            headers: HashMap::new(),
2211            body: Some(json!({"name": "test"})),
2212        };
2213
2214        let config = K6Config {
2215            target_url: "https://api.example.com".to_string(),
2216            base_path: None,
2217            scenario: LoadScenario::Constant,
2218            duration_secs: 30,
2219            max_vus: 5,
2220            threshold_percentile: "p(95)".to_string(),
2221            threshold_ms: 500,
2222            max_error_rate: 0.05,
2223            auth_header: None,
2224            custom_headers: HashMap::new(),
2225            skip_tls_verify: false,
2226            security_testing_enabled: true,
2227            chunked_request_bodies: false,
2228            target_rps: None,
2229            no_keep_alive: false,
2230            geo_source_ips: Vec::new(),
2231            geo_source_headers: Vec::new(),
2232        };
2233
2234        let generator = K6ScriptGenerator::new(config, vec![template]);
2235        let mut script = generator.generate().expect("Should generate base script");
2236
2237        // Step 2: Simulate what generate_enhanced_script() does — inject function definitions
2238        let security_config = SecurityTestConfig::default().enable();
2239        let payloads = SecurityPayloads::get_payloads(&security_config);
2240        assert!(!payloads.is_empty(), "Should have built-in payloads");
2241
2242        let mut additional_code = String::new();
2243        additional_code
2244            .push_str(&SecurityTestGenerator::generate_payload_selection(&payloads, false));
2245        additional_code.push('\n');
2246        additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
2247        additional_code.push('\n');
2248
2249        // Insert definitions before 'export const options' (same as generate_enhanced_script)
2250        if let Some(pos) = script.find("export const options") {
2251            script.insert_str(
2252                pos,
2253                &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
2254            );
2255        }
2256
2257        // Step 3: Verify the FINAL script has BOTH definitions AND calls
2258        // Function definitions (injected by generate_enhanced_script)
2259        assert!(
2260            script.contains("function getNextSecurityPayload()"),
2261            "Final script must contain getNextSecurityPayload function DEFINITION"
2262        );
2263        assert!(
2264            script.contains("function applySecurityPayload("),
2265            "Final script must contain applySecurityPayload function DEFINITION"
2266        );
2267        assert!(
2268            script.contains("securityPayloads"),
2269            "Final script must contain securityPayloads array"
2270        );
2271
2272        // Calling code (rendered by template)
2273        assert!(
2274            script.contains("const secPayloadGroup = typeof getNextSecurityPayload"),
2275            "Final script must contain secPayloadGroup assignment (template calling code)"
2276        );
2277        assert!(
2278            script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
2279            "Final script must contain applySecurityPayload CALL with secBodyPayload"
2280        );
2281        assert!(
2282            script.contains("const requestHeaders = { ..."),
2283            "Final script must spread headers for security payload header injection"
2284        );
2285        assert!(
2286            script.contains("for (const secPayload of secPayloadGroup)"),
2287            "Final script must loop over secPayloadGroup"
2288        );
2289        assert!(
2290            script.contains("secPayload.injectAsPath"),
2291            "Final script must check injectAsPath for path-based URI injection"
2292        );
2293        assert!(
2294            script.contains("secBodyPayload.formBody"),
2295            "Final script must check formBody for form-encoded body delivery"
2296        );
2297
2298        // Verify ordering: definitions come BEFORE export default function (which has the calls)
2299        let def_pos = script.find("function getNextSecurityPayload()").unwrap();
2300        let call_pos =
2301            script.find("const secPayloadGroup = typeof getNextSecurityPayload").unwrap();
2302        let options_pos = script.find("export const options").unwrap();
2303        let default_fn_pos = script.find("export default function").unwrap();
2304
2305        assert!(
2306            def_pos < options_pos,
2307            "Function definitions must appear before export const options"
2308        );
2309        assert!(
2310            call_pos > default_fn_pos,
2311            "Calling code must appear inside export default function"
2312        );
2313    }
2314
2315    /// Test that URI security payload injection is generated for GET requests
2316    #[test]
2317    fn test_security_uri_injection_for_get_requests() {
2318        use crate::spec_parser::ApiOperation;
2319        use openapiv3::Operation;
2320
2321        let operation = ApiOperation {
2322            method: "get".to_string(),
2323            path: "/api/users".to_string(),
2324            operation: Operation::default(),
2325            operation_id: Some("listUsers".to_string()),
2326        };
2327
2328        let template = RequestTemplate {
2329            operation,
2330            path_params: HashMap::new(),
2331            query_params: HashMap::new(),
2332            headers: HashMap::new(),
2333            body: None,
2334        };
2335
2336        let config = K6Config {
2337            target_url: "https://api.example.com".to_string(),
2338            base_path: None,
2339            scenario: LoadScenario::Constant,
2340            duration_secs: 30,
2341            max_vus: 5,
2342            threshold_percentile: "p(95)".to_string(),
2343            threshold_ms: 500,
2344            max_error_rate: 0.05,
2345            auth_header: None,
2346            custom_headers: HashMap::new(),
2347            skip_tls_verify: false,
2348            security_testing_enabled: true,
2349            chunked_request_bodies: false,
2350            target_rps: None,
2351            no_keep_alive: false,
2352            geo_source_ips: Vec::new(),
2353            geo_source_headers: Vec::new(),
2354        };
2355
2356        let generator = K6ScriptGenerator::new(config, vec![template]);
2357        let script = generator.generate().expect("Should generate script");
2358
2359        // Verify URI injection code is present for GET requests
2360        assert!(
2361            script.contains("requestUrl"),
2362            "Script should build requestUrl variable for URI payload injection"
2363        );
2364        assert!(
2365            script.contains("secPayload.location === 'uri'"),
2366            "Script should check for URI-location payloads"
2367        );
2368        // URI payloads are URL-encoded for valid HTTP; WAF decodes before inspection
2369        assert!(
2370            script.contains("'test=' + encodeURIComponent(secPayload.payload)"),
2371            "Script should URL-encode security payload in query string for valid HTTP"
2372        );
2373        // Verify injectAsPath check for path-based injection
2374        assert!(
2375            script.contains("secPayload.injectAsPath"),
2376            "Script should check injectAsPath for path-based URI injection"
2377        );
2378        assert!(
2379            script.contains("encodeURI(secPayload.payload)"),
2380            "Script should use encodeURI for path-based injection"
2381        );
2382        // Verify the GET request uses requestUrl
2383        assert!(
2384            script.contains("http.get(requestUrl,"),
2385            "GET request should use requestUrl (with URI injection) instead of inline URL"
2386        );
2387    }
2388
2389    /// Test that URI security payload injection is generated for POST requests with body
2390    #[test]
2391    fn test_security_uri_injection_for_post_requests() {
2392        use crate::spec_parser::ApiOperation;
2393        use openapiv3::Operation;
2394        use serde_json::json;
2395
2396        let operation = ApiOperation {
2397            method: "post".to_string(),
2398            path: "/api/users".to_string(),
2399            operation: Operation::default(),
2400            operation_id: Some("createUser".to_string()),
2401        };
2402
2403        let template = RequestTemplate {
2404            operation,
2405            path_params: HashMap::new(),
2406            query_params: HashMap::new(),
2407            headers: HashMap::new(),
2408            body: Some(json!({"name": "test"})),
2409        };
2410
2411        let config = K6Config {
2412            target_url: "https://api.example.com".to_string(),
2413            base_path: None,
2414            scenario: LoadScenario::Constant,
2415            duration_secs: 30,
2416            max_vus: 5,
2417            threshold_percentile: "p(95)".to_string(),
2418            threshold_ms: 500,
2419            max_error_rate: 0.05,
2420            auth_header: None,
2421            custom_headers: HashMap::new(),
2422            skip_tls_verify: false,
2423            security_testing_enabled: true,
2424            chunked_request_bodies: false,
2425            target_rps: None,
2426            no_keep_alive: false,
2427            geo_source_ips: Vec::new(),
2428            geo_source_headers: Vec::new(),
2429        };
2430
2431        let generator = K6ScriptGenerator::new(config, vec![template]);
2432        let script = generator.generate().expect("Should generate script");
2433
2434        // POST with body should get BOTH URI injection AND body injection
2435        assert!(
2436            script.contains("requestUrl"),
2437            "POST script should build requestUrl for URI payload injection"
2438        );
2439        assert!(
2440            script.contains("secPayload.location === 'uri'"),
2441            "POST script should check for URI-location payloads"
2442        );
2443        assert!(
2444            script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
2445            "POST script should apply security body payload to request body"
2446        );
2447        // Verify the POST request uses requestUrl
2448        assert!(
2449            script.contains("http.post(requestUrl,"),
2450            "POST request should use requestUrl (with URI injection) instead of inline URL"
2451        );
2452    }
2453
2454    /// Test that security is disabled - no URI injection code present
2455    #[test]
2456    fn test_no_uri_injection_when_security_disabled() {
2457        use crate::spec_parser::ApiOperation;
2458        use openapiv3::Operation;
2459
2460        let operation = ApiOperation {
2461            method: "get".to_string(),
2462            path: "/api/users".to_string(),
2463            operation: Operation::default(),
2464            operation_id: Some("listUsers".to_string()),
2465        };
2466
2467        let template = RequestTemplate {
2468            operation,
2469            path_params: HashMap::new(),
2470            query_params: HashMap::new(),
2471            headers: HashMap::new(),
2472            body: None,
2473        };
2474
2475        let config = K6Config {
2476            target_url: "https://api.example.com".to_string(),
2477            base_path: None,
2478            scenario: LoadScenario::Constant,
2479            duration_secs: 30,
2480            max_vus: 5,
2481            threshold_percentile: "p(95)".to_string(),
2482            threshold_ms: 500,
2483            max_error_rate: 0.05,
2484            auth_header: None,
2485            custom_headers: HashMap::new(),
2486            skip_tls_verify: false,
2487            security_testing_enabled: false,
2488            chunked_request_bodies: false,
2489            target_rps: None,
2490            no_keep_alive: false,
2491            geo_source_ips: Vec::new(),
2492            geo_source_headers: Vec::new(),
2493        };
2494
2495        let generator = K6ScriptGenerator::new(config, vec![template]);
2496        let script = generator.generate().expect("Should generate script");
2497
2498        // Verify NO security injection code when disabled
2499        assert!(
2500            !script.contains("requestUrl"),
2501            "Script should NOT have requestUrl when security is disabled"
2502        );
2503        assert!(
2504            !script.contains("secPayloadGroup"),
2505            "Script should NOT have secPayloadGroup when security is disabled"
2506        );
2507        assert!(
2508            !script.contains("secBodyPayload"),
2509            "Script should NOT have secBodyPayload when security is disabled"
2510        );
2511    }
2512
2513    /// Test that scripts create a fresh CookieJar per request (not a shared constant)
2514    #[test]
2515    fn test_uses_per_request_cookie_jar() {
2516        use crate::spec_parser::ApiOperation;
2517        use openapiv3::Operation;
2518
2519        let operation = ApiOperation {
2520            method: "get".to_string(),
2521            path: "/api/users".to_string(),
2522            operation: Operation::default(),
2523            operation_id: Some("listUsers".to_string()),
2524        };
2525
2526        let template = RequestTemplate {
2527            operation,
2528            path_params: HashMap::new(),
2529            query_params: HashMap::new(),
2530            headers: HashMap::new(),
2531            body: None,
2532        };
2533
2534        let config = K6Config {
2535            target_url: "https://api.example.com".to_string(),
2536            base_path: None,
2537            scenario: LoadScenario::Constant,
2538            duration_secs: 30,
2539            max_vus: 5,
2540            threshold_percentile: "p(95)".to_string(),
2541            threshold_ms: 500,
2542            max_error_rate: 0.05,
2543            auth_header: None,
2544            custom_headers: HashMap::new(),
2545            skip_tls_verify: false,
2546            security_testing_enabled: false,
2547            chunked_request_bodies: false,
2548            target_rps: None,
2549            no_keep_alive: false,
2550            geo_source_ips: Vec::new(),
2551            geo_source_headers: Vec::new(),
2552        };
2553
2554        let generator = K6ScriptGenerator::new(config, vec![template]);
2555        let script = generator.generate().expect("Should generate script");
2556
2557        // Each request must create a fresh CookieJar to prevent Set-Cookie accumulation
2558        assert!(
2559            script.contains("jar: new http.CookieJar()"),
2560            "Script should create fresh CookieJar per request"
2561        );
2562        assert!(
2563            !script.contains("jar: null"),
2564            "Script should NOT use jar: null (does not disable default VU cookie jar in k6)"
2565        );
2566        assert!(
2567            !script.contains("EMPTY_JAR"),
2568            "Script should NOT use shared EMPTY_JAR (accumulates Set-Cookie responses)"
2569        );
2570    }
2571}