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;
9#[cfg(test)]
10use serde_json::json;
11use serde_json::Value;
12use std::collections::{HashMap, HashSet};
13
14/// Typed template data for `k6_script.hbs`.
15///
16/// Every field referenced by `{{variable}}` or `{{#if flag}}` in the template
17/// is a required field here, so the compiler prevents the Issue-#79 class of
18/// bugs (template rendered with missing data).
19#[derive(Debug, Clone, Serialize)]
20pub struct K6ScriptTemplateData {
21    pub base_url: String,
22    pub stages: Vec<K6StageData>,
23    pub operations: Vec<K6OperationData>,
24    pub threshold_percentile: String,
25    pub threshold_ms: u64,
26    pub max_error_rate: f64,
27    pub scenario_name: String,
28    pub skip_tls_verify: bool,
29    pub has_dynamic_values: bool,
30    pub dynamic_imports: Vec<String>,
31    pub dynamic_globals: Vec<String>,
32    pub security_testing_enabled: bool,
33    pub has_custom_headers: bool,
34    /// When true, emit `Transfer-Encoding: chunked` on every request that has a
35    /// body. NOTE: k6 runs on Go's `net/http`, which decides chunking based on
36    /// the body type — a string body has a known length and Go will normally
37    /// send `Content-Length`. Setting this header explicitly is the closest
38    /// k6-script-level approximation; for true raw chunked traffic, prefer
39    /// `curl --data-binary @file -H "Transfer-Encoding: chunked"` or a custom
40    /// hyper/reqwest harness.
41    pub chunked_request_bodies: bool,
42    /// Optional target RPS. When `Some(n)`, the script switches the executor
43    /// from `ramping-vus` to `constant-arrival-rate` at `n` requests/sec.
44    /// Issue #79.
45    pub target_rps: Option<u32>,
46    /// When true, the generated script sets `noConnectionReuse: true` on every
47    /// request so each one opens a fresh TCP/TLS connection. Used to drive
48    /// connections-per-second load. Issue #79.
49    pub no_keep_alive: bool,
50    /// Total test duration in seconds. Used by the `constant-arrival-rate`
51    /// executor (when `target_rps` is set) which needs a single duration
52    /// rather than a list of stages. Issue #79 — Srikanth's round-5 reply:
53    /// `--rps` was previously deriving duration from the last stage of the
54    /// chosen scenario; under `ramp-up` (the default) the last stage has
55    /// `target: 0`, which gave `preAllocatedVUs: 0` and 0 requests.
56    pub duration_secs: u64,
57    /// Max VUs to pre-allocate for the `constant-arrival-rate` executor.
58    /// Issue #79 (round 5).
59    pub max_vus: u32,
60}
61
62/// Typed template data for `k6_crud_flow.hbs`.
63#[derive(Debug, Clone, Serialize)]
64pub struct K6CrudFlowTemplateData {
65    pub base_url: String,
66    pub flows: Vec<Value>,
67    pub extract_fields: Vec<String>,
68    pub duration_secs: u64,
69    pub max_vus: u32,
70    pub auth_header: Option<String>,
71    pub custom_headers: HashMap<String, String>,
72    pub skip_tls_verify: bool,
73    pub stages: Vec<K6StageData>,
74    pub threshold_percentile: String,
75    pub threshold_ms: u64,
76    pub max_error_rate: f64,
77    /// Raw JSON string for embedding in k6 script (rendered unescaped via `{{{headers}}}`)
78    pub headers: String,
79    pub dynamic_imports: Vec<String>,
80    pub dynamic_globals: Vec<String>,
81    pub extracted_values_output_path: String,
82    pub error_injection_enabled: bool,
83    pub error_rate: f64,
84    pub error_types: Vec<String>,
85    pub security_testing_enabled: bool,
86    pub has_custom_headers: bool,
87}
88
89/// A k6 load stage for template rendering.
90#[derive(Debug, Clone, Serialize)]
91pub struct K6StageData {
92    pub duration: String,
93    pub target: u32,
94}
95
96/// Per-operation data for the `k6_script.hbs` template.
97#[derive(Debug, Clone, Serialize)]
98pub struct K6OperationData {
99    pub index: usize,
100    pub name: String,
101    pub metric_name: String,
102    pub display_name: String,
103    pub method: String,
104    pub path: Value,
105    pub path_is_dynamic: bool,
106    pub headers: Value,
107    pub body: Option<Value>,
108    pub body_is_dynamic: bool,
109    pub has_body: bool,
110    pub is_get_or_head: bool,
111}
112
113/// Configuration for k6 script generation
114pub struct K6Config {
115    pub target_url: String,
116    /// API base path prefix (e.g., "/api" or "/v2")
117    /// Prepended to all API endpoint paths
118    pub base_path: Option<String>,
119    pub scenario: LoadScenario,
120    pub duration_secs: u64,
121    pub max_vus: u32,
122    pub threshold_percentile: String,
123    pub threshold_ms: u64,
124    pub max_error_rate: f64,
125    pub auth_header: Option<String>,
126    pub custom_headers: HashMap<String, String>,
127    pub skip_tls_verify: bool,
128    pub security_testing_enabled: bool,
129    /// Emit `Transfer-Encoding: chunked` on every request body. See
130    /// `K6ScriptTemplateData::chunked_request_bodies` for caveats.
131    pub chunked_request_bodies: bool,
132    /// Target RPS for `constant-arrival-rate` executor. `None` falls back to
133    /// the legacy ramping-vus executor.
134    pub target_rps: Option<u32>,
135    /// When true, set `noConnectionReuse: true` on every request so each one
136    /// opens a fresh TCP/TLS connection (drives high CPS).
137    pub no_keep_alive: bool,
138}
139
140/// Generate k6 load test script
141pub struct K6ScriptGenerator {
142    config: K6Config,
143    templates: Vec<RequestTemplate>,
144}
145
146impl K6ScriptGenerator {
147    /// Create a new k6 script generator
148    pub fn new(config: K6Config, templates: Vec<RequestTemplate>) -> Self {
149        Self { config, templates }
150    }
151
152    /// Generate the k6 script
153    pub fn generate(&self) -> Result<String> {
154        let handlebars = Handlebars::new();
155
156        let template = include_str!("templates/k6_script.hbs");
157
158        let data = self.build_template_data()?;
159
160        let value = serde_json::to_value(&data)
161            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
162
163        handlebars
164            .render_template(template, &value)
165            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))
166    }
167
168    /// Maximum length for a k6 metric name *base* (the part before any
169    /// `_latency` / `_errors` / `_step{N}_*` suffix). k6 enforces a
170    /// 128-char limit on the full metric name; the longest suffix used by
171    /// our templates is `_step99_errors` (15 chars), so we cap the base at
172    /// 128 - 16 = 112 to be safe.
173    const K6_METRIC_NAME_BASE_MAX_LEN: usize = 112;
174
175    /// Sanitize a name into a valid k6 metric-name base, capped at
176    /// `K6_METRIC_NAME_BASE_MAX_LEN` characters.
177    ///
178    /// k6 rejects metric names longer than 128 chars, and our templates
179    /// append suffixes like `_latency`, `_errors`, `_stepN_latency` —
180    /// reserve room for the longest suffix and truncate the base name
181    /// when needed. Truncation appends an 8-hex-char hash of the original
182    /// name so distinct long names produce distinct metric names.
183    ///
184    /// Examples:
185    /// - "short_name" -> "short_name"
186    /// - 200-char OperationId -> "<first-103-chars>_<8-hex-hash>"
187    pub fn sanitize_k6_metric_name(name: &str) -> String {
188        let sanitized = Self::sanitize_js_identifier(name);
189        if sanitized.len() <= Self::K6_METRIC_NAME_BASE_MAX_LEN {
190            return sanitized;
191        }
192
193        use std::collections::hash_map::DefaultHasher;
194        use std::hash::{Hash, Hasher};
195        let mut hasher = DefaultHasher::new();
196        // Hash the original name (not the sanitized one) so two distinct
197        // sources that sanitize to the same string still get different
198        // hashes when they exceed the limit.
199        name.hash(&mut hasher);
200        let hash_suffix = format!("{:08x}", hasher.finish() as u32);
201
202        // Reserve `_<8-hex>` = 9 chars at the end.
203        let prefix_len = Self::K6_METRIC_NAME_BASE_MAX_LEN - 9;
204        let prefix = &sanitized[..prefix_len];
205        // Strip a trailing underscore on the prefix so we don't end up with `__hash`.
206        let prefix = prefix.trim_end_matches('_');
207        format!("{}_{}", prefix, hash_suffix)
208    }
209
210    /// Sanitize a name to be a valid JavaScript identifier
211    ///
212    /// Replaces invalid characters (dots, spaces, special chars) with underscores.
213    /// Ensures the identifier starts with a letter or underscore (not a number).
214    ///
215    /// Examples:
216    /// - "billing.subscriptions.v1" -> "billing_subscriptions_v1"
217    /// - "get user" -> "get_user"
218    /// - "123invalid" -> "_123invalid"
219    pub fn sanitize_js_identifier(name: &str) -> String {
220        let mut result = String::new();
221        let mut chars = name.chars().peekable();
222
223        // Ensure it starts with a letter or underscore (not a number)
224        if let Some(&first) = chars.peek() {
225            if first.is_ascii_digit() {
226                result.push('_');
227            }
228        }
229
230        for ch in chars {
231            if ch.is_ascii_alphanumeric() || ch == '_' {
232                result.push(ch);
233            } else {
234                // Replace invalid characters with underscore
235                // Avoid consecutive underscores
236                if !result.ends_with('_') {
237                    result.push('_');
238                }
239            }
240        }
241
242        // Remove trailing underscores
243        result = result.trim_end_matches('_').to_string();
244
245        // If empty after sanitization, use a default name
246        if result.is_empty() {
247            result = "operation".to_string();
248        }
249
250        result
251    }
252
253    /// Build the typed template data for rendering.
254    fn build_template_data(&self) -> Result<K6ScriptTemplateData> {
255        let stages = self
256            .config
257            .scenario
258            .generate_stages(self.config.duration_secs, self.config.max_vus);
259
260        // Get the base path (defaults to empty string if not set)
261        let base_path = self.config.base_path.as_deref().unwrap_or("");
262
263        // Track all placeholders used across all operations
264        let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
265
266        let operations = self
267            .templates
268            .iter()
269            .enumerate()
270            .map(|(idx, template)| {
271                let display_name = template.operation.display_name();
272                let sanitized_name = Self::sanitize_js_identifier(&display_name);
273                // metric_name must satisfy k6's 128-char limit AND leave room
274                // for suffixes like `_latency` / `_errors` / `_stepN_*`.
275                // Long deeply-nested operationIds (e.g. Microsoft Graph) exceed
276                // this; sanitize_k6_metric_name truncates with a hash suffix
277                // for uniqueness. (See issue #79 — Srikanth's microsoft-graph.yaml run.)
278                let metric_name = Self::sanitize_k6_metric_name(&display_name);
279                // k6 uses 'del' instead of 'delete' for HTTP DELETE method
280                let k6_method = match template.operation.method.to_lowercase().as_str() {
281                    "delete" => "del".to_string(),
282                    m => m.to_string(),
283                };
284                // GET and HEAD methods only take 2 arguments in k6: http.get(url, params)
285                // Other methods take 3 arguments: http.post(url, body, params)
286                let is_get_or_head = matches!(k6_method.as_str(), "get" | "head");
287
288                // Process path for dynamic placeholders
289                // Prepend base_path if configured
290                let raw_path = template.generate_path();
291                let full_path = if base_path.is_empty() {
292                    raw_path
293                } else {
294                    format!("{}{}", base_path, raw_path)
295                };
296                let processed_path = DynamicParamProcessor::process_path(&full_path);
297                all_placeholders.extend(processed_path.placeholders.clone());
298
299                // Process body for dynamic placeholders
300                let (body_value, body_is_dynamic) = if let Some(body) = &template.body {
301                    let processed_body = DynamicParamProcessor::process_json_body(body);
302                    all_placeholders.extend(processed_body.placeholders.clone());
303                    (Some(processed_body.value), processed_body.is_dynamic)
304                } else {
305                    (None, false)
306                };
307
308                let path_value = if processed_path.is_dynamic {
309                    processed_path.value
310                } else {
311                    full_path
312                };
313
314                K6OperationData {
315                    index: idx,
316                    name: sanitized_name,
317                    metric_name,
318                    display_name,
319                    method: k6_method,
320                    path: Value::String(path_value),
321                    path_is_dynamic: processed_path.is_dynamic,
322                    headers: Value::String(self.build_headers_json(template)),
323                    body: body_value.map(Value::String),
324                    body_is_dynamic,
325                    has_body: template.body.is_some(),
326                    is_get_or_head,
327                }
328            })
329            .collect::<Vec<_>>();
330
331        // Get required imports and global initializations based on placeholders used
332        let required_imports: Vec<String> =
333            DynamicParamProcessor::get_required_imports(&all_placeholders)
334                .into_iter()
335                .map(String::from)
336                .collect();
337        let required_globals: Vec<String> =
338            DynamicParamProcessor::get_required_globals(&all_placeholders)
339                .into_iter()
340                .map(String::from)
341                .collect();
342        let has_dynamic_values = !all_placeholders.is_empty();
343
344        Ok(K6ScriptTemplateData {
345            base_url: self.config.target_url.clone(),
346            stages: stages
347                .iter()
348                .map(|s| K6StageData {
349                    duration: s.duration.clone(),
350                    target: s.target,
351                })
352                .collect(),
353            operations,
354            threshold_percentile: self.config.threshold_percentile.clone(),
355            threshold_ms: self.config.threshold_ms,
356            max_error_rate: self.config.max_error_rate,
357            scenario_name: format!("{:?}", self.config.scenario).to_lowercase(),
358            skip_tls_verify: self.config.skip_tls_verify,
359            has_dynamic_values,
360            dynamic_imports: required_imports,
361            dynamic_globals: required_globals,
362            security_testing_enabled: self.config.security_testing_enabled,
363            has_custom_headers: !self.config.custom_headers.is_empty(),
364            chunked_request_bodies: self.config.chunked_request_bodies,
365            target_rps: self.config.target_rps,
366            no_keep_alive: self.config.no_keep_alive,
367            duration_secs: self.config.duration_secs,
368            max_vus: self.config.max_vus,
369        })
370    }
371
372    /// Build headers for a request template as a JSON string for k6 script
373    fn build_headers_json(&self, template: &RequestTemplate) -> String {
374        let mut headers = template.get_headers();
375
376        // Add auth header if provided
377        if let Some(auth) = &self.config.auth_header {
378            headers.insert("Authorization".to_string(), auth.clone());
379        }
380
381        // Add custom headers
382        for (key, value) in &self.config.custom_headers {
383            headers.insert(key.clone(), value.clone());
384        }
385
386        // Force chunked transfer encoding when requested. Only meaningful for
387        // requests with bodies (POST/PUT/PATCH); k6/Go may still send
388        // Content-Length in some cases — see the doc on
389        // `K6ScriptTemplateData::chunked_request_bodies`.
390        if self.config.chunked_request_bodies && template.body.is_some() {
391            headers.insert("Transfer-Encoding".to_string(), "chunked".to_string());
392        }
393
394        // Convert to JSON string for embedding in k6 script
395        serde_json::to_string(&headers).unwrap_or_else(|_| "{}".to_string())
396    }
397
398    /// Validate the generated k6 script for common issues
399    ///
400    /// Checks for:
401    /// - Invalid metric names (contains dots or special characters)
402    /// - Invalid JavaScript variable names
403    /// - Missing required k6 imports
404    ///
405    /// Returns a list of validation errors, empty if all checks pass.
406    pub fn validate_script(script: &str) -> Vec<String> {
407        let mut errors = Vec::new();
408
409        // Check for required k6 imports
410        if !script.contains("import http from 'k6/http'") {
411            errors.push("Missing required import: 'k6/http'".to_string());
412        }
413        if !script.contains("import { check") && !script.contains("import {check") {
414            errors.push("Missing required import: 'check' from 'k6'".to_string());
415        }
416        if !script.contains("import { Rate, Trend") && !script.contains("import {Rate, Trend") {
417            errors.push("Missing required import: 'Rate, Trend' from 'k6/metrics'".to_string());
418        }
419
420        // Check for invalid metric names in Trend/Rate constructors
421        // k6 metric names must only contain ASCII letters, numbers, or underscores
422        // and start with a letter or underscore
423        let lines: Vec<&str> = script.lines().collect();
424        for (line_num, line) in lines.iter().enumerate() {
425            let trimmed = line.trim();
426
427            // Check for Trend/Rate constructors with invalid metric names
428            if trimmed.contains("new Trend(") || trimmed.contains("new Rate(") {
429                // Extract the metric name from the string literal
430                // Pattern: new Trend('metric_name') or new Rate("metric_name")
431                if let Some(start) = trimmed.find('\'') {
432                    if let Some(end) = trimmed[start + 1..].find('\'') {
433                        let metric_name = &trimmed[start + 1..start + 1 + end];
434                        if !Self::is_valid_k6_metric_name(metric_name) {
435                            errors.push(format!(
436                                "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
437                                line_num + 1,
438                                metric_name
439                            ));
440                        }
441                    }
442                } else if let Some(start) = trimmed.find('"') {
443                    if let Some(end) = trimmed[start + 1..].find('"') {
444                        let metric_name = &trimmed[start + 1..start + 1 + end];
445                        if !Self::is_valid_k6_metric_name(metric_name) {
446                            errors.push(format!(
447                                "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
448                                line_num + 1,
449                                metric_name
450                            ));
451                        }
452                    }
453                }
454            }
455
456            // Check for invalid JavaScript variable names (containing dots)
457            if trimmed.starts_with("const ") || trimmed.starts_with("let ") {
458                if let Some(equals_pos) = trimmed.find('=') {
459                    let var_decl = &trimmed[..equals_pos];
460                    // Check if variable name contains a dot (invalid identifier)
461                    // But exclude string literals
462                    if var_decl.contains('.')
463                        && !var_decl.contains("'")
464                        && !var_decl.contains("\"")
465                        && !var_decl.trim().starts_with("//")
466                    {
467                        errors.push(format!(
468                            "Line {}: Invalid JavaScript variable name with dot: {}. Variable names cannot contain dots.",
469                            line_num + 1,
470                            var_decl.trim()
471                        ));
472                    }
473                }
474            }
475        }
476
477        errors
478    }
479
480    /// Check if a string is a valid k6 metric name
481    ///
482    /// k6 metric names must:
483    /// - Only contain ASCII letters, numbers, or underscores
484    /// - Start with a letter or underscore (not a number)
485    /// - Be at most 128 characters
486    fn is_valid_k6_metric_name(name: &str) -> bool {
487        if name.is_empty() || name.len() > 128 {
488            return false;
489        }
490
491        let mut chars = name.chars();
492
493        // First character must be a letter or underscore
494        if let Some(first) = chars.next() {
495            if !first.is_ascii_alphabetic() && first != '_' {
496                return false;
497            }
498        }
499
500        // Remaining characters must be alphanumeric or underscore
501        for ch in chars {
502            if !ch.is_ascii_alphanumeric() && ch != '_' {
503                return false;
504            }
505        }
506
507        true
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    #[test]
516    fn test_k6_config_creation() {
517        let config = K6Config {
518            target_url: "https://api.example.com".to_string(),
519            base_path: None,
520            scenario: LoadScenario::RampUp,
521            duration_secs: 60,
522            max_vus: 10,
523            threshold_percentile: "p(95)".to_string(),
524            threshold_ms: 500,
525            max_error_rate: 0.05,
526            auth_header: None,
527            custom_headers: HashMap::new(),
528            skip_tls_verify: false,
529            security_testing_enabled: false,
530            chunked_request_bodies: false,
531            target_rps: None,
532            no_keep_alive: false,
533        };
534
535        assert_eq!(config.duration_secs, 60);
536        assert_eq!(config.max_vus, 10);
537    }
538
539    #[test]
540    fn test_script_generator_creation() {
541        let config = K6Config {
542            target_url: "https://api.example.com".to_string(),
543            base_path: None,
544            scenario: LoadScenario::Constant,
545            duration_secs: 30,
546            max_vus: 5,
547            threshold_percentile: "p(95)".to_string(),
548            threshold_ms: 500,
549            max_error_rate: 0.05,
550            auth_header: None,
551            custom_headers: HashMap::new(),
552            skip_tls_verify: false,
553            security_testing_enabled: false,
554            chunked_request_bodies: false,
555            target_rps: None,
556            no_keep_alive: false,
557        };
558
559        let templates = vec![];
560        let generator = K6ScriptGenerator::new(config, templates);
561
562        assert_eq!(generator.templates.len(), 0);
563    }
564
565    #[test]
566    fn test_sanitize_js_identifier() {
567        // Test case from issue #79: names with dots
568        assert_eq!(
569            K6ScriptGenerator::sanitize_js_identifier("billing.subscriptions.v1"),
570            "billing_subscriptions_v1"
571        );
572
573        // Test other invalid characters
574        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("get user"), "get_user");
575
576        // Test names starting with numbers
577        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("123invalid"), "_123invalid");
578
579        // Test already valid identifiers
580        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("getUsers"), "getUsers");
581
582        // Test with multiple consecutive invalid chars
583        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test...name"), "test_name");
584
585        // Test empty string (should return default)
586        assert_eq!(K6ScriptGenerator::sanitize_js_identifier(""), "operation");
587
588        // Test with special characters
589        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test@name#value"), "test_name_value");
590
591        // Test CRUD flow names with dots (issue #79 follow-up)
592        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.list"), "plans_list");
593        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.create"), "plans_create");
594        assert_eq!(
595            K6ScriptGenerator::sanitize_js_identifier("plans.update-pricing-schemes"),
596            "plans_update_pricing_schemes"
597        );
598        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("users CRUD"), "users_CRUD");
599    }
600
601    #[test]
602    fn test_sanitize_k6_metric_name_short_passthrough() {
603        // Names within the limit should pass through unchanged.
604        let short = "billing_subscriptions_list";
605        let out = K6ScriptGenerator::sanitize_k6_metric_name(short);
606        assert_eq!(out, short);
607        assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{out}_latency")));
608    }
609
610    #[test]
611    fn test_sanitize_k6_metric_name_truncates_long_microsoft_graph_id() {
612        // Real example from issue #79 (Srikanth's microsoft-graph.yaml run):
613        // operationId nested deep enough that the sanitized name + `_latency`
614        // exceeds k6's 128-char limit and gets rejected by validate_script.
615        let long = "drives.drive.items.driveItem.workbook.worksheets.workbookWorksheet.\
616                    charts.workbookChart.axes.categoryAxis.format.line.clear";
617        let metric = K6ScriptGenerator::sanitize_k6_metric_name(long);
618
619        // Base must fit within MAX_LEN, leaving room for `_latency` / `_errors`.
620        assert!(
621            metric.len() <= K6ScriptGenerator::K6_METRIC_NAME_BASE_MAX_LEN,
622            "metric base len {} exceeded cap {}",
623            metric.len(),
624            K6ScriptGenerator::K6_METRIC_NAME_BASE_MAX_LEN
625        );
626
627        // Both the bare metric and the suffixed forms must pass k6's validator.
628        assert!(K6ScriptGenerator::is_valid_k6_metric_name(&metric));
629        assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_latency")));
630        assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_errors")));
631        // Worst-case suffix used by `k6_crud_flow.hbs`.
632        assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_step99_latency")));
633    }
634
635    #[test]
636    fn test_sanitize_k6_metric_name_distinct_long_names_get_distinct_metrics() {
637        // Two long names that share a long common prefix must NOT collide
638        // after truncation — the trailing hash makes them distinct.
639        let prefix = "a".repeat(150);
640        let a = format!("{prefix}.foo");
641        let b = format!("{prefix}.bar");
642        let ma = K6ScriptGenerator::sanitize_k6_metric_name(&a);
643        let mb = K6ScriptGenerator::sanitize_k6_metric_name(&b);
644        assert_ne!(ma, mb, "distinct long names produced the same metric name");
645    }
646
647    #[test]
648    fn test_sanitize_k6_metric_name_truncated_starts_with_letter() {
649        // Truncation must preserve the "starts with letter or _" k6 rule.
650        let long = format!("{}123end", "x".repeat(120));
651        let metric = K6ScriptGenerator::sanitize_k6_metric_name(&long);
652        assert!(K6ScriptGenerator::is_valid_k6_metric_name(&metric));
653    }
654
655    #[test]
656    fn test_microsoft_graph_long_operation_id_passes_validation() {
657        // End-to-end: an ApiOperation with a microsoft-graph-style long
658        // operationId must produce a script that passes validate_script.
659        use crate::spec_parser::ApiOperation;
660        use openapiv3::Operation;
661
662        let long_op_id = "drives.drive.items.driveItem.workbook.worksheets.\
663            workbookWorksheet.charts.workbookChart.axes.categoryAxis.format.\
664            line.clear";
665
666        let operation = ApiOperation {
667            method: "post".to_string(),
668            path: "/drives/{drive-id}/items/{item-id}/workbook/worksheets/{worksheet-id}/charts/{chart-id}/axes/categoryAxis/format/line/clear".to_string(),
669            operation: Operation::default(),
670            operation_id: Some(long_op_id.to_string()),
671        };
672        let template = RequestTemplate {
673            operation,
674            path_params: HashMap::new(),
675            query_params: HashMap::new(),
676            headers: HashMap::new(),
677            body: None,
678        };
679        let config = K6Config {
680            target_url: "https://api.example.com".to_string(),
681            base_path: Some("/v1.0".to_string()),
682            scenario: LoadScenario::Constant,
683            duration_secs: 30,
684            max_vus: 5,
685            threshold_percentile: "p(95)".to_string(),
686            threshold_ms: 500,
687            max_error_rate: 0.05,
688            auth_header: None,
689            custom_headers: HashMap::new(),
690            skip_tls_verify: false,
691            security_testing_enabled: false,
692            chunked_request_bodies: false,
693            target_rps: None,
694            no_keep_alive: false,
695        };
696        let generator = K6ScriptGenerator::new(config, vec![template]);
697        let script = generator.generate().expect("script generates");
698
699        let errors = K6ScriptGenerator::validate_script(&script);
700        assert!(
701            errors.is_empty(),
702            "validate_script returned errors for long operationId: {errors:#?}"
703        );
704    }
705
706    #[test]
707    fn test_script_generation_with_dots_in_name() {
708        use crate::spec_parser::ApiOperation;
709        use openapiv3::Operation;
710
711        // Create an operation with a name containing dots (like in issue #79)
712        let operation = ApiOperation {
713            method: "get".to_string(),
714            path: "/billing/subscriptions".to_string(),
715            operation: Operation::default(),
716            operation_id: Some("billing.subscriptions.v1".to_string()),
717        };
718
719        let template = RequestTemplate {
720            operation,
721            path_params: HashMap::new(),
722            query_params: HashMap::new(),
723            headers: HashMap::new(),
724            body: None,
725        };
726
727        let config = K6Config {
728            target_url: "https://api.example.com".to_string(),
729            base_path: None,
730            scenario: LoadScenario::Constant,
731            duration_secs: 30,
732            max_vus: 5,
733            threshold_percentile: "p(95)".to_string(),
734            threshold_ms: 500,
735            max_error_rate: 0.05,
736            auth_header: None,
737            custom_headers: HashMap::new(),
738            skip_tls_verify: false,
739            security_testing_enabled: false,
740            chunked_request_bodies: false,
741            target_rps: None,
742            no_keep_alive: false,
743        };
744
745        let generator = K6ScriptGenerator::new(config, vec![template]);
746        let script = generator.generate().expect("Should generate script");
747
748        // Verify the script contains sanitized variable names (no dots in variable identifiers)
749        assert!(
750            script.contains("const billing_subscriptions_v1_latency"),
751            "Script should contain sanitized variable name for latency"
752        );
753        assert!(
754            script.contains("const billing_subscriptions_v1_errors"),
755            "Script should contain sanitized variable name for errors"
756        );
757
758        // Verify variable names do NOT contain dots (check the actual variable identifier, not string literals)
759        // The pattern "const billing.subscriptions" would indicate a variable name with dots
760        assert!(
761            !script.contains("const billing.subscriptions"),
762            "Script should not contain variable names with dots - this would cause 'Unexpected token .' error"
763        );
764
765        // Verify metric name strings are sanitized (no dots) - k6 requires valid metric names
766        // Metric names must only contain ASCII letters, numbers, or underscores
767        assert!(
768            script.contains("'billing_subscriptions_v1_latency'"),
769            "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
770        );
771        assert!(
772            script.contains("'billing_subscriptions_v1_errors'"),
773            "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
774        );
775
776        // Verify the original display name is still used in comments and strings (for readability)
777        assert!(
778            script.contains("billing.subscriptions.v1"),
779            "Script should contain original name in comments/strings for readability"
780        );
781
782        // Most importantly: verify the variable usage doesn't have dots
783        assert!(
784            script.contains("billing_subscriptions_v1_latency.add"),
785            "Variable usage should use sanitized name"
786        );
787        assert!(
788            script.contains("billing_subscriptions_v1_errors.add"),
789            "Variable usage should use sanitized name"
790        );
791    }
792
793    /// Issue #79 (round 5) regression: `--rps` with the default `ramp-up`
794    /// scenario produced 0 requests because the script took
795    /// `preAllocatedVUs` from the *last* stage's target — and ramp-up's last
796    /// stage is the ramp-DOWN to `target: 0`. The fix is to use the
797    /// configured `max_vus` directly when `target_rps` is set, and the full
798    /// `duration_secs` rather than the last stage's duration.
799    #[test]
800    fn test_rps_with_ramp_up_uses_full_vu_pool_and_duration() {
801        use crate::spec_parser::ApiOperation;
802        use openapiv3::Operation;
803
804        let operation = ApiOperation {
805            method: "get".to_string(),
806            path: "/users".to_string(),
807            operation: Operation::default(),
808            operation_id: Some("listUsers".to_string()),
809        };
810        let template = RequestTemplate {
811            operation,
812            path_params: HashMap::new(),
813            query_params: HashMap::new(),
814            headers: HashMap::new(),
815            body: None,
816        };
817
818        let config = K6Config {
819            target_url: "https://api.example.com".to_string(),
820            base_path: None,
821            scenario: LoadScenario::RampUp,
822            duration_secs: 600,
823            max_vus: 100,
824            threshold_percentile: "p(95)".to_string(),
825            threshold_ms: 500,
826            max_error_rate: 0.05,
827            auth_header: None,
828            custom_headers: HashMap::new(),
829            skip_tls_verify: false,
830            security_testing_enabled: false,
831            chunked_request_bodies: false,
832            target_rps: Some(100),
833            no_keep_alive: false,
834        };
835
836        let generator = K6ScriptGenerator::new(config, vec![template]);
837        let script = generator.generate().expect("Should generate script");
838
839        assert!(
840            script.contains("constant-arrival-rate"),
841            "with --rps set, executor must switch to constant-arrival-rate"
842        );
843        assert!(
844            script.contains("rate: 100,"),
845            "constant-arrival-rate must use the configured --rps as `rate`"
846        );
847        assert!(
848            script.contains("duration: '600s'"),
849            "duration must come from --duration, not the ramp-down stage; got:\n{}",
850            script
851        );
852        assert!(
853            script.contains("preAllocatedVUs: 100,"),
854            "preAllocatedVUs must equal --vus, not the last stage's target=0; got:\n{}",
855            script
856        );
857        assert!(
858            script.contains("maxVUs: 100,"),
859            "maxVUs must equal --vus, not the last stage's target=0; got:\n{}",
860            script
861        );
862        // Make sure the regression — `preAllocatedVUs: 0` from the ramp-down —
863        // can never silently come back. Walk the lines so we don't false-
864        // positive on the explanatory comment that lives in the template.
865        for (idx, line) in script.lines().enumerate() {
866            let trimmed = line.trim_start();
867            if trimmed.starts_with("//") || trimmed.starts_with("/*") {
868                continue;
869            }
870            assert!(
871                !trimmed.starts_with("preAllocatedVUs: 0"),
872                "regression at line {}: preAllocatedVUs is 0 — constant-arrival-rate \
873                 will run no VUs (issue #79 round 5 ramp-up bug). Line: {:?}",
874                idx + 1,
875                line,
876            );
877        }
878    }
879
880    /// Companion to the test above: confirm `--cps` flips `noConnectionReuse`
881    /// on. Issue #79 (round 5).
882    #[test]
883    fn test_cps_sets_no_connection_reuse() {
884        use crate::spec_parser::ApiOperation;
885        use openapiv3::Operation;
886
887        let operation = ApiOperation {
888            method: "get".to_string(),
889            path: "/u".to_string(),
890            operation: Operation::default(),
891            operation_id: Some("u".to_string()),
892        };
893        let template = RequestTemplate {
894            operation,
895            path_params: HashMap::new(),
896            query_params: HashMap::new(),
897            headers: HashMap::new(),
898            body: None,
899        };
900        let config = K6Config {
901            target_url: "https://api.example.com".to_string(),
902            base_path: None,
903            scenario: LoadScenario::Constant,
904            duration_secs: 30,
905            max_vus: 5,
906            threshold_percentile: "p(95)".to_string(),
907            threshold_ms: 500,
908            max_error_rate: 0.05,
909            auth_header: None,
910            custom_headers: HashMap::new(),
911            skip_tls_verify: false,
912            security_testing_enabled: false,
913            chunked_request_bodies: false,
914            target_rps: None,
915            no_keep_alive: true,
916        };
917        let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
918        assert!(
919            script.contains("noConnectionReuse: true"),
920            "--cps must set noConnectionReuse: true on the k6 options block"
921        );
922        assert!(
923            script.contains("Total Connections:"),
924            "--cps summary must include connection-rate output (Srikanth's round-5 ask)"
925        );
926        assert!(
927            script.contains("Connection Rate:"),
928            "--cps summary must include 'Connection Rate:' (Srikanth's round-5 ask)"
929        );
930    }
931
932    #[test]
933    fn test_validate_script_valid() {
934        let valid_script = r#"
935import http from 'k6/http';
936import { check, sleep } from 'k6';
937import { Rate, Trend } from 'k6/metrics';
938
939const test_latency = new Trend('test_latency');
940const test_errors = new Rate('test_errors');
941
942export default function() {
943    const res = http.get('https://example.com');
944    test_latency.add(res.timings.duration);
945    test_errors.add(res.status !== 200);
946}
947"#;
948
949        let errors = K6ScriptGenerator::validate_script(valid_script);
950        assert!(errors.is_empty(), "Valid script should have no validation errors");
951    }
952
953    #[test]
954    fn test_validate_script_invalid_metric_name() {
955        let invalid_script = r#"
956import http from 'k6/http';
957import { check, sleep } from 'k6';
958import { Rate, Trend } from 'k6/metrics';
959
960const test_latency = new Trend('test.latency');
961const test_errors = new Rate('test_errors');
962
963export default function() {
964    const res = http.get('https://example.com');
965    test_latency.add(res.timings.duration);
966}
967"#;
968
969        let errors = K6ScriptGenerator::validate_script(invalid_script);
970        assert!(
971            !errors.is_empty(),
972            "Script with invalid metric name should have validation errors"
973        );
974        assert!(
975            errors.iter().any(|e| e.contains("Invalid k6 metric name")),
976            "Should detect invalid metric name with dot"
977        );
978    }
979
980    #[test]
981    fn test_validate_script_missing_imports() {
982        let invalid_script = r#"
983const test_latency = new Trend('test_latency');
984export default function() {}
985"#;
986
987        let errors = K6ScriptGenerator::validate_script(invalid_script);
988        assert!(!errors.is_empty(), "Script missing imports should have validation errors");
989    }
990
991    #[test]
992    fn test_validate_script_metric_name_validation() {
993        // Test that validate_script correctly identifies invalid metric names
994        // Valid metric names should pass
995        let valid_script = r#"
996import http from 'k6/http';
997import { check, sleep } from 'k6';
998import { Rate, Trend } from 'k6/metrics';
999const test_latency = new Trend('test_latency');
1000const test_errors = new Rate('test_errors');
1001export default function() {}
1002"#;
1003        let errors = K6ScriptGenerator::validate_script(valid_script);
1004        assert!(errors.is_empty(), "Valid metric names should pass validation");
1005
1006        // Invalid metric names should fail
1007        let invalid_cases = vec![
1008            ("test.latency", "dot in metric name"),
1009            ("123test", "starts with number"),
1010            ("test-latency", "hyphen in metric name"),
1011            ("test@latency", "special character"),
1012        ];
1013
1014        for (invalid_name, description) in invalid_cases {
1015            let script = format!(
1016                r#"
1017import http from 'k6/http';
1018import {{ check, sleep }} from 'k6';
1019import {{ Rate, Trend }} from 'k6/metrics';
1020const test_latency = new Trend('{}');
1021export default function() {{}}
1022"#,
1023                invalid_name
1024            );
1025            let errors = K6ScriptGenerator::validate_script(&script);
1026            assert!(
1027                !errors.is_empty(),
1028                "Metric name '{}' ({}) should fail validation",
1029                invalid_name,
1030                description
1031            );
1032        }
1033    }
1034
1035    #[test]
1036    fn test_skip_tls_verify_with_body() {
1037        use crate::spec_parser::ApiOperation;
1038        use openapiv3::Operation;
1039        use serde_json::json;
1040
1041        // Create an operation with a request body
1042        let operation = ApiOperation {
1043            method: "post".to_string(),
1044            path: "/api/users".to_string(),
1045            operation: Operation::default(),
1046            operation_id: Some("createUser".to_string()),
1047        };
1048
1049        let template = RequestTemplate {
1050            operation,
1051            path_params: HashMap::new(),
1052            query_params: HashMap::new(),
1053            headers: HashMap::new(),
1054            body: Some(json!({"name": "test"})),
1055        };
1056
1057        let config = K6Config {
1058            target_url: "https://api.example.com".to_string(),
1059            base_path: None,
1060            scenario: LoadScenario::Constant,
1061            duration_secs: 30,
1062            max_vus: 5,
1063            threshold_percentile: "p(95)".to_string(),
1064            threshold_ms: 500,
1065            max_error_rate: 0.05,
1066            auth_header: None,
1067            custom_headers: HashMap::new(),
1068            skip_tls_verify: true,
1069            security_testing_enabled: false,
1070            chunked_request_bodies: false,
1071            target_rps: None,
1072            no_keep_alive: false,
1073        };
1074
1075        let generator = K6ScriptGenerator::new(config, vec![template]);
1076        let script = generator.generate().expect("Should generate script");
1077
1078        // Verify the script includes TLS skip option for requests with body
1079        assert!(
1080            script.contains("insecureSkipTLSVerify: true"),
1081            "Script should include insecureSkipTLSVerify option when skip_tls_verify is true"
1082        );
1083    }
1084
1085    #[test]
1086    fn test_skip_tls_verify_without_body() {
1087        use crate::spec_parser::ApiOperation;
1088        use openapiv3::Operation;
1089
1090        // Create an operation without a request body
1091        let operation = ApiOperation {
1092            method: "get".to_string(),
1093            path: "/api/users".to_string(),
1094            operation: Operation::default(),
1095            operation_id: Some("getUsers".to_string()),
1096        };
1097
1098        let template = RequestTemplate {
1099            operation,
1100            path_params: HashMap::new(),
1101            query_params: HashMap::new(),
1102            headers: HashMap::new(),
1103            body: None,
1104        };
1105
1106        let config = K6Config {
1107            target_url: "https://api.example.com".to_string(),
1108            base_path: None,
1109            scenario: LoadScenario::Constant,
1110            duration_secs: 30,
1111            max_vus: 5,
1112            threshold_percentile: "p(95)".to_string(),
1113            threshold_ms: 500,
1114            max_error_rate: 0.05,
1115            auth_header: None,
1116            custom_headers: HashMap::new(),
1117            skip_tls_verify: true,
1118            security_testing_enabled: false,
1119            chunked_request_bodies: false,
1120            target_rps: None,
1121            no_keep_alive: false,
1122        };
1123
1124        let generator = K6ScriptGenerator::new(config, vec![template]);
1125        let script = generator.generate().expect("Should generate script");
1126
1127        // Verify the script includes TLS skip option for requests without body
1128        assert!(
1129            script.contains("insecureSkipTLSVerify: true"),
1130            "Script should include insecureSkipTLSVerify option when skip_tls_verify is true (no body)"
1131        );
1132    }
1133
1134    #[test]
1135    fn test_no_skip_tls_verify() {
1136        use crate::spec_parser::ApiOperation;
1137        use openapiv3::Operation;
1138
1139        // Create an operation
1140        let operation = ApiOperation {
1141            method: "get".to_string(),
1142            path: "/api/users".to_string(),
1143            operation: Operation::default(),
1144            operation_id: Some("getUsers".to_string()),
1145        };
1146
1147        let template = RequestTemplate {
1148            operation,
1149            path_params: HashMap::new(),
1150            query_params: HashMap::new(),
1151            headers: HashMap::new(),
1152            body: None,
1153        };
1154
1155        let config = K6Config {
1156            target_url: "https://api.example.com".to_string(),
1157            base_path: None,
1158            scenario: LoadScenario::Constant,
1159            duration_secs: 30,
1160            max_vus: 5,
1161            threshold_percentile: "p(95)".to_string(),
1162            threshold_ms: 500,
1163            max_error_rate: 0.05,
1164            auth_header: None,
1165            custom_headers: HashMap::new(),
1166            skip_tls_verify: false,
1167            security_testing_enabled: false,
1168            chunked_request_bodies: false,
1169            target_rps: None,
1170            no_keep_alive: false,
1171        };
1172
1173        let generator = K6ScriptGenerator::new(config, vec![template]);
1174        let script = generator.generate().expect("Should generate script");
1175
1176        // Verify the script does NOT include TLS skip option when skip_tls_verify is false
1177        assert!(
1178            !script.contains("insecureSkipTLSVerify"),
1179            "Script should NOT include insecureSkipTLSVerify option when skip_tls_verify is false"
1180        );
1181    }
1182
1183    #[test]
1184    fn test_skip_tls_verify_multiple_operations() {
1185        use crate::spec_parser::ApiOperation;
1186        use openapiv3::Operation;
1187        use serde_json::json;
1188
1189        // Create multiple operations - one with body, one without
1190        let operation1 = ApiOperation {
1191            method: "get".to_string(),
1192            path: "/api/users".to_string(),
1193            operation: Operation::default(),
1194            operation_id: Some("getUsers".to_string()),
1195        };
1196
1197        let operation2 = ApiOperation {
1198            method: "post".to_string(),
1199            path: "/api/users".to_string(),
1200            operation: Operation::default(),
1201            operation_id: Some("createUser".to_string()),
1202        };
1203
1204        let template1 = RequestTemplate {
1205            operation: operation1,
1206            path_params: HashMap::new(),
1207            query_params: HashMap::new(),
1208            headers: HashMap::new(),
1209            body: None,
1210        };
1211
1212        let template2 = RequestTemplate {
1213            operation: operation2,
1214            path_params: HashMap::new(),
1215            query_params: HashMap::new(),
1216            headers: HashMap::new(),
1217            body: Some(json!({"name": "test"})),
1218        };
1219
1220        let config = K6Config {
1221            target_url: "https://api.example.com".to_string(),
1222            base_path: None,
1223            scenario: LoadScenario::Constant,
1224            duration_secs: 30,
1225            max_vus: 5,
1226            threshold_percentile: "p(95)".to_string(),
1227            threshold_ms: 500,
1228            max_error_rate: 0.05,
1229            auth_header: None,
1230            custom_headers: HashMap::new(),
1231            skip_tls_verify: true,
1232            security_testing_enabled: false,
1233            chunked_request_bodies: false,
1234            target_rps: None,
1235            no_keep_alive: false,
1236        };
1237
1238        let generator = K6ScriptGenerator::new(config, vec![template1, template2]);
1239        let script = generator.generate().expect("Should generate script");
1240
1241        // Verify the script includes TLS skip option ONCE in global options
1242        // (k6 only supports insecureSkipTLSVerify as a global option, not per-request)
1243        let skip_count = script.matches("insecureSkipTLSVerify: true").count();
1244        assert_eq!(
1245            skip_count, 1,
1246            "Script should include insecureSkipTLSVerify exactly once in global options (not per-request)"
1247        );
1248
1249        // Verify it appears in the options block, before scenarios
1250        let options_start = script.find("export const options = {").expect("Should have options");
1251        let scenarios_start = script.find("scenarios:").expect("Should have scenarios");
1252        let options_prefix = &script[options_start..scenarios_start];
1253        assert!(
1254            options_prefix.contains("insecureSkipTLSVerify: true"),
1255            "insecureSkipTLSVerify should be in global options block"
1256        );
1257    }
1258
1259    #[test]
1260    fn test_dynamic_params_in_body() {
1261        use crate::spec_parser::ApiOperation;
1262        use openapiv3::Operation;
1263        use serde_json::json;
1264
1265        // Create an operation with dynamic placeholders in the body
1266        let operation = ApiOperation {
1267            method: "post".to_string(),
1268            path: "/api/resources".to_string(),
1269            operation: Operation::default(),
1270            operation_id: Some("createResource".to_string()),
1271        };
1272
1273        let template = RequestTemplate {
1274            operation,
1275            path_params: HashMap::new(),
1276            query_params: HashMap::new(),
1277            headers: HashMap::new(),
1278            body: Some(json!({
1279                "name": "load-test-${__VU}",
1280                "iteration": "${__ITER}"
1281            })),
1282        };
1283
1284        let config = K6Config {
1285            target_url: "https://api.example.com".to_string(),
1286            base_path: None,
1287            scenario: LoadScenario::Constant,
1288            duration_secs: 30,
1289            max_vus: 5,
1290            threshold_percentile: "p(95)".to_string(),
1291            threshold_ms: 500,
1292            max_error_rate: 0.05,
1293            auth_header: None,
1294            custom_headers: HashMap::new(),
1295            skip_tls_verify: false,
1296            security_testing_enabled: false,
1297            chunked_request_bodies: false,
1298            target_rps: None,
1299            no_keep_alive: false,
1300        };
1301
1302        let generator = K6ScriptGenerator::new(config, vec![template]);
1303        let script = generator.generate().expect("Should generate script");
1304
1305        // Verify the script contains dynamic body indication
1306        assert!(
1307            script.contains("Dynamic body with runtime placeholders"),
1308            "Script should contain comment about dynamic body"
1309        );
1310
1311        // Verify the script contains the __VU variable reference
1312        assert!(
1313            script.contains("__VU"),
1314            "Script should contain __VU reference for dynamic VU-based values"
1315        );
1316
1317        // Verify the script contains the __ITER variable reference
1318        assert!(
1319            script.contains("__ITER"),
1320            "Script should contain __ITER reference for dynamic iteration values"
1321        );
1322    }
1323
1324    #[test]
1325    fn test_dynamic_params_with_uuid() {
1326        use crate::spec_parser::ApiOperation;
1327        use openapiv3::Operation;
1328        use serde_json::json;
1329
1330        // Create an operation with UUID placeholder
1331        let operation = ApiOperation {
1332            method: "post".to_string(),
1333            path: "/api/resources".to_string(),
1334            operation: Operation::default(),
1335            operation_id: Some("createResource".to_string()),
1336        };
1337
1338        let template = RequestTemplate {
1339            operation,
1340            path_params: HashMap::new(),
1341            query_params: HashMap::new(),
1342            headers: HashMap::new(),
1343            body: Some(json!({
1344                "id": "${__UUID}"
1345            })),
1346        };
1347
1348        let config = K6Config {
1349            target_url: "https://api.example.com".to_string(),
1350            base_path: None,
1351            scenario: LoadScenario::Constant,
1352            duration_secs: 30,
1353            max_vus: 5,
1354            threshold_percentile: "p(95)".to_string(),
1355            threshold_ms: 500,
1356            max_error_rate: 0.05,
1357            auth_header: None,
1358            custom_headers: HashMap::new(),
1359            skip_tls_verify: false,
1360            security_testing_enabled: false,
1361            chunked_request_bodies: false,
1362            target_rps: None,
1363            no_keep_alive: false,
1364        };
1365
1366        let generator = K6ScriptGenerator::new(config, vec![template]);
1367        let script = generator.generate().expect("Should generate script");
1368
1369        // As of k6 v1.0.0+, webcrypto is globally available - no import needed
1370        // Verify the script does NOT include the old experimental webcrypto import
1371        assert!(
1372            !script.contains("k6/experimental/webcrypto"),
1373            "Script should NOT include deprecated k6/experimental/webcrypto import"
1374        );
1375
1376        // Verify crypto.randomUUID() is in the generated code
1377        assert!(
1378            script.contains("crypto.randomUUID()"),
1379            "Script should contain crypto.randomUUID() for UUID placeholder"
1380        );
1381    }
1382
1383    #[test]
1384    fn test_dynamic_params_with_counter() {
1385        use crate::spec_parser::ApiOperation;
1386        use openapiv3::Operation;
1387        use serde_json::json;
1388
1389        // Create an operation with COUNTER placeholder
1390        let operation = ApiOperation {
1391            method: "post".to_string(),
1392            path: "/api/resources".to_string(),
1393            operation: Operation::default(),
1394            operation_id: Some("createResource".to_string()),
1395        };
1396
1397        let template = RequestTemplate {
1398            operation,
1399            path_params: HashMap::new(),
1400            query_params: HashMap::new(),
1401            headers: HashMap::new(),
1402            body: Some(json!({
1403                "sequence": "${__COUNTER}"
1404            })),
1405        };
1406
1407        let config = K6Config {
1408            target_url: "https://api.example.com".to_string(),
1409            base_path: None,
1410            scenario: LoadScenario::Constant,
1411            duration_secs: 30,
1412            max_vus: 5,
1413            threshold_percentile: "p(95)".to_string(),
1414            threshold_ms: 500,
1415            max_error_rate: 0.05,
1416            auth_header: None,
1417            custom_headers: HashMap::new(),
1418            skip_tls_verify: false,
1419            security_testing_enabled: false,
1420            chunked_request_bodies: false,
1421            target_rps: None,
1422            no_keep_alive: false,
1423        };
1424
1425        let generator = K6ScriptGenerator::new(config, vec![template]);
1426        let script = generator.generate().expect("Should generate script");
1427
1428        // Verify the script includes the global counter initialization
1429        assert!(
1430            script.contains("let globalCounter = 0"),
1431            "Script should include globalCounter initialization when COUNTER placeholder is used"
1432        );
1433
1434        // Verify globalCounter++ is in the generated code
1435        assert!(
1436            script.contains("globalCounter++"),
1437            "Script should contain globalCounter++ for COUNTER placeholder"
1438        );
1439    }
1440
1441    #[test]
1442    fn test_static_body_no_dynamic_marker() {
1443        use crate::spec_parser::ApiOperation;
1444        use openapiv3::Operation;
1445        use serde_json::json;
1446
1447        // Create an operation with static body (no placeholders)
1448        let operation = ApiOperation {
1449            method: "post".to_string(),
1450            path: "/api/resources".to_string(),
1451            operation: Operation::default(),
1452            operation_id: Some("createResource".to_string()),
1453        };
1454
1455        let template = RequestTemplate {
1456            operation,
1457            path_params: HashMap::new(),
1458            query_params: HashMap::new(),
1459            headers: HashMap::new(),
1460            body: Some(json!({
1461                "name": "static-value",
1462                "count": 42
1463            })),
1464        };
1465
1466        let config = K6Config {
1467            target_url: "https://api.example.com".to_string(),
1468            base_path: None,
1469            scenario: LoadScenario::Constant,
1470            duration_secs: 30,
1471            max_vus: 5,
1472            threshold_percentile: "p(95)".to_string(),
1473            threshold_ms: 500,
1474            max_error_rate: 0.05,
1475            auth_header: None,
1476            custom_headers: HashMap::new(),
1477            skip_tls_verify: false,
1478            security_testing_enabled: false,
1479            chunked_request_bodies: false,
1480            target_rps: None,
1481            no_keep_alive: false,
1482        };
1483
1484        let generator = K6ScriptGenerator::new(config, vec![template]);
1485        let script = generator.generate().expect("Should generate script");
1486
1487        // Verify the script does NOT contain dynamic body marker
1488        assert!(
1489            !script.contains("Dynamic body with runtime placeholders"),
1490            "Script should NOT contain dynamic body comment for static body"
1491        );
1492
1493        // Verify it does NOT include unnecessary crypto imports
1494        assert!(
1495            !script.contains("webcrypto"),
1496            "Script should NOT include webcrypto import for static body"
1497        );
1498
1499        // Verify it does NOT include global counter
1500        assert!(
1501            !script.contains("let globalCounter"),
1502            "Script should NOT include globalCounter for static body"
1503        );
1504    }
1505
1506    #[test]
1507    fn test_security_testing_enabled_generates_calling_code() {
1508        use crate::spec_parser::ApiOperation;
1509        use openapiv3::Operation;
1510        use serde_json::json;
1511
1512        let operation = ApiOperation {
1513            method: "post".to_string(),
1514            path: "/api/users".to_string(),
1515            operation: Operation::default(),
1516            operation_id: Some("createUser".to_string()),
1517        };
1518
1519        let template = RequestTemplate {
1520            operation,
1521            path_params: HashMap::new(),
1522            query_params: HashMap::new(),
1523            headers: HashMap::new(),
1524            body: Some(json!({"name": "test"})),
1525        };
1526
1527        let config = K6Config {
1528            target_url: "https://api.example.com".to_string(),
1529            base_path: None,
1530            scenario: LoadScenario::Constant,
1531            duration_secs: 30,
1532            max_vus: 5,
1533            threshold_percentile: "p(95)".to_string(),
1534            threshold_ms: 500,
1535            max_error_rate: 0.05,
1536            auth_header: None,
1537            custom_headers: HashMap::new(),
1538            skip_tls_verify: false,
1539            security_testing_enabled: true,
1540            chunked_request_bodies: false,
1541            target_rps: None,
1542            no_keep_alive: false,
1543        };
1544
1545        let generator = K6ScriptGenerator::new(config, vec![template]);
1546        let script = generator.generate().expect("Should generate script");
1547
1548        // Verify calling code is generated (not just function definitions)
1549        assert!(
1550            script.contains("getNextSecurityPayload"),
1551            "Script should contain getNextSecurityPayload() call when security_testing_enabled is true"
1552        );
1553        assert!(
1554            script.contains("applySecurityPayload"),
1555            "Script should contain applySecurityPayload() call when security_testing_enabled is true"
1556        );
1557        assert!(
1558            script.contains("secPayloadGroup"),
1559            "Script should contain secPayloadGroup variable when security_testing_enabled is true"
1560        );
1561        assert!(
1562            script.contains("secBodyPayload"),
1563            "Script should contain secBodyPayload variable when security_testing_enabled is true"
1564        );
1565        // Verify CookieJar skip when Cookie header payload is present
1566        assert!(
1567            script.contains("hasSecCookie"),
1568            "Script should track hasSecCookie for CookieJar conflict avoidance"
1569        );
1570        assert!(
1571            script.contains("secRequestOpts"),
1572            "Script should use secRequestOpts to conditionally skip CookieJar"
1573        );
1574        // Verify mutable headers copy for injection
1575        assert!(
1576            script.contains("const requestHeaders = { ..."),
1577            "Script should spread headers into mutable copy for security payload injection"
1578        );
1579        // Verify injectAsPath handling for path-based URI injection
1580        assert!(
1581            script.contains("secPayload.injectAsPath"),
1582            "Script should check injectAsPath for path-based URI injection"
1583        );
1584        // Verify formBody handling for form-encoded body delivery
1585        assert!(
1586            script.contains("secBodyPayload.formBody"),
1587            "Script should check formBody for form-encoded body delivery"
1588        );
1589        assert!(
1590            script.contains("application/x-www-form-urlencoded"),
1591            "Script should set Content-Type for form-encoded body"
1592        );
1593        // Verify secPayloadGroup is fetched per-operation (inside operation block), not per-iteration
1594        let op_comment_pos =
1595            script.find("// Operation 0:").expect("Should have Operation 0 comment");
1596        let sec_payload_pos = script
1597            .find("const secPayloadGroup = typeof getNextSecurityPayload")
1598            .expect("Should have secPayloadGroup assignment");
1599        assert!(
1600            sec_payload_pos > op_comment_pos,
1601            "secPayloadGroup should be fetched inside operation block (per-operation), not before it (per-iteration)"
1602        );
1603    }
1604
1605    #[test]
1606    fn test_security_testing_disabled_no_calling_code() {
1607        use crate::spec_parser::ApiOperation;
1608        use openapiv3::Operation;
1609        use serde_json::json;
1610
1611        let operation = ApiOperation {
1612            method: "post".to_string(),
1613            path: "/api/users".to_string(),
1614            operation: Operation::default(),
1615            operation_id: Some("createUser".to_string()),
1616        };
1617
1618        let template = RequestTemplate {
1619            operation,
1620            path_params: HashMap::new(),
1621            query_params: HashMap::new(),
1622            headers: HashMap::new(),
1623            body: Some(json!({"name": "test"})),
1624        };
1625
1626        let config = K6Config {
1627            target_url: "https://api.example.com".to_string(),
1628            base_path: None,
1629            scenario: LoadScenario::Constant,
1630            duration_secs: 30,
1631            max_vus: 5,
1632            threshold_percentile: "p(95)".to_string(),
1633            threshold_ms: 500,
1634            max_error_rate: 0.05,
1635            auth_header: None,
1636            custom_headers: HashMap::new(),
1637            skip_tls_verify: false,
1638            security_testing_enabled: false,
1639            chunked_request_bodies: false,
1640            target_rps: None,
1641            no_keep_alive: false,
1642        };
1643
1644        let generator = K6ScriptGenerator::new(config, vec![template]);
1645        let script = generator.generate().expect("Should generate script");
1646
1647        // Verify calling code is NOT generated
1648        assert!(
1649            !script.contains("getNextSecurityPayload"),
1650            "Script should NOT contain getNextSecurityPayload() when security_testing_enabled is false"
1651        );
1652        assert!(
1653            !script.contains("applySecurityPayload"),
1654            "Script should NOT contain applySecurityPayload() when security_testing_enabled is false"
1655        );
1656        assert!(
1657            !script.contains("secPayloadGroup"),
1658            "Script should NOT contain secPayloadGroup variable when security_testing_enabled is false"
1659        );
1660        assert!(
1661            !script.contains("secBodyPayload"),
1662            "Script should NOT contain secBodyPayload variable when security_testing_enabled is false"
1663        );
1664        assert!(
1665            !script.contains("hasSecCookie"),
1666            "Script should NOT contain hasSecCookie when security_testing_enabled is false"
1667        );
1668        assert!(
1669            !script.contains("secRequestOpts"),
1670            "Script should NOT contain secRequestOpts when security_testing_enabled is false"
1671        );
1672        assert!(
1673            !script.contains("injectAsPath"),
1674            "Script should NOT contain injectAsPath when security_testing_enabled is false"
1675        );
1676        assert!(
1677            !script.contains("formBody"),
1678            "Script should NOT contain formBody when security_testing_enabled is false"
1679        );
1680    }
1681
1682    /// End-to-end test: simulates the real pipeline of template rendering + enhanced script
1683    /// injection. This is what actually runs when a user passes `--security-test`.
1684    /// Verifies that the FINAL script has both function definitions AND calling code.
1685    #[test]
1686    fn test_security_e2e_definitions_and_calls_both_present() {
1687        use crate::security_payloads::{
1688            SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
1689        };
1690        use crate::spec_parser::ApiOperation;
1691        use openapiv3::Operation;
1692        use serde_json::json;
1693
1694        // Step 1: Generate base script with security_testing_enabled=true (template renders calling code)
1695        let operation = ApiOperation {
1696            method: "post".to_string(),
1697            path: "/api/users".to_string(),
1698            operation: Operation::default(),
1699            operation_id: Some("createUser".to_string()),
1700        };
1701
1702        let template = RequestTemplate {
1703            operation,
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: false,
1722            security_testing_enabled: true,
1723            chunked_request_bodies: false,
1724            target_rps: None,
1725            no_keep_alive: false,
1726        };
1727
1728        let generator = K6ScriptGenerator::new(config, vec![template]);
1729        let mut script = generator.generate().expect("Should generate base script");
1730
1731        // Step 2: Simulate what generate_enhanced_script() does — inject function definitions
1732        let security_config = SecurityTestConfig::default().enable();
1733        let payloads = SecurityPayloads::get_payloads(&security_config);
1734        assert!(!payloads.is_empty(), "Should have built-in payloads");
1735
1736        let mut additional_code = String::new();
1737        additional_code
1738            .push_str(&SecurityTestGenerator::generate_payload_selection(&payloads, false));
1739        additional_code.push('\n');
1740        additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
1741        additional_code.push('\n');
1742
1743        // Insert definitions before 'export const options' (same as generate_enhanced_script)
1744        if let Some(pos) = script.find("export const options") {
1745            script.insert_str(
1746                pos,
1747                &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
1748            );
1749        }
1750
1751        // Step 3: Verify the FINAL script has BOTH definitions AND calls
1752        // Function definitions (injected by generate_enhanced_script)
1753        assert!(
1754            script.contains("function getNextSecurityPayload()"),
1755            "Final script must contain getNextSecurityPayload function DEFINITION"
1756        );
1757        assert!(
1758            script.contains("function applySecurityPayload("),
1759            "Final script must contain applySecurityPayload function DEFINITION"
1760        );
1761        assert!(
1762            script.contains("securityPayloads"),
1763            "Final script must contain securityPayloads array"
1764        );
1765
1766        // Calling code (rendered by template)
1767        assert!(
1768            script.contains("const secPayloadGroup = typeof getNextSecurityPayload"),
1769            "Final script must contain secPayloadGroup assignment (template calling code)"
1770        );
1771        assert!(
1772            script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
1773            "Final script must contain applySecurityPayload CALL with secBodyPayload"
1774        );
1775        assert!(
1776            script.contains("const requestHeaders = { ..."),
1777            "Final script must spread headers for security payload header injection"
1778        );
1779        assert!(
1780            script.contains("for (const secPayload of secPayloadGroup)"),
1781            "Final script must loop over secPayloadGroup"
1782        );
1783        assert!(
1784            script.contains("secPayload.injectAsPath"),
1785            "Final script must check injectAsPath for path-based URI injection"
1786        );
1787        assert!(
1788            script.contains("secBodyPayload.formBody"),
1789            "Final script must check formBody for form-encoded body delivery"
1790        );
1791
1792        // Verify ordering: definitions come BEFORE export default function (which has the calls)
1793        let def_pos = script.find("function getNextSecurityPayload()").unwrap();
1794        let call_pos =
1795            script.find("const secPayloadGroup = typeof getNextSecurityPayload").unwrap();
1796        let options_pos = script.find("export const options").unwrap();
1797        let default_fn_pos = script.find("export default function").unwrap();
1798
1799        assert!(
1800            def_pos < options_pos,
1801            "Function definitions must appear before export const options"
1802        );
1803        assert!(
1804            call_pos > default_fn_pos,
1805            "Calling code must appear inside export default function"
1806        );
1807    }
1808
1809    /// Test that URI security payload injection is generated for GET requests
1810    #[test]
1811    fn test_security_uri_injection_for_get_requests() {
1812        use crate::spec_parser::ApiOperation;
1813        use openapiv3::Operation;
1814
1815        let operation = ApiOperation {
1816            method: "get".to_string(),
1817            path: "/api/users".to_string(),
1818            operation: Operation::default(),
1819            operation_id: Some("listUsers".to_string()),
1820        };
1821
1822        let template = RequestTemplate {
1823            operation,
1824            path_params: HashMap::new(),
1825            query_params: HashMap::new(),
1826            headers: HashMap::new(),
1827            body: None,
1828        };
1829
1830        let config = K6Config {
1831            target_url: "https://api.example.com".to_string(),
1832            base_path: None,
1833            scenario: LoadScenario::Constant,
1834            duration_secs: 30,
1835            max_vus: 5,
1836            threshold_percentile: "p(95)".to_string(),
1837            threshold_ms: 500,
1838            max_error_rate: 0.05,
1839            auth_header: None,
1840            custom_headers: HashMap::new(),
1841            skip_tls_verify: false,
1842            security_testing_enabled: true,
1843            chunked_request_bodies: false,
1844            target_rps: None,
1845            no_keep_alive: false,
1846        };
1847
1848        let generator = K6ScriptGenerator::new(config, vec![template]);
1849        let script = generator.generate().expect("Should generate script");
1850
1851        // Verify URI injection code is present for GET requests
1852        assert!(
1853            script.contains("requestUrl"),
1854            "Script should build requestUrl variable for URI payload injection"
1855        );
1856        assert!(
1857            script.contains("secPayload.location === 'uri'"),
1858            "Script should check for URI-location payloads"
1859        );
1860        // URI payloads are URL-encoded for valid HTTP; WAF decodes before inspection
1861        assert!(
1862            script.contains("'test=' + encodeURIComponent(secPayload.payload)"),
1863            "Script should URL-encode security payload in query string for valid HTTP"
1864        );
1865        // Verify injectAsPath check for path-based injection
1866        assert!(
1867            script.contains("secPayload.injectAsPath"),
1868            "Script should check injectAsPath for path-based URI injection"
1869        );
1870        assert!(
1871            script.contains("encodeURI(secPayload.payload)"),
1872            "Script should use encodeURI for path-based injection"
1873        );
1874        // Verify the GET request uses requestUrl
1875        assert!(
1876            script.contains("http.get(requestUrl,"),
1877            "GET request should use requestUrl (with URI injection) instead of inline URL"
1878        );
1879    }
1880
1881    /// Test that URI security payload injection is generated for POST requests with body
1882    #[test]
1883    fn test_security_uri_injection_for_post_requests() {
1884        use crate::spec_parser::ApiOperation;
1885        use openapiv3::Operation;
1886        use serde_json::json;
1887
1888        let operation = ApiOperation {
1889            method: "post".to_string(),
1890            path: "/api/users".to_string(),
1891            operation: Operation::default(),
1892            operation_id: Some("createUser".to_string()),
1893        };
1894
1895        let template = RequestTemplate {
1896            operation,
1897            path_params: HashMap::new(),
1898            query_params: HashMap::new(),
1899            headers: HashMap::new(),
1900            body: Some(json!({"name": "test"})),
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: true,
1916            chunked_request_bodies: false,
1917            target_rps: None,
1918            no_keep_alive: false,
1919        };
1920
1921        let generator = K6ScriptGenerator::new(config, vec![template]);
1922        let script = generator.generate().expect("Should generate script");
1923
1924        // POST with body should get BOTH URI injection AND body injection
1925        assert!(
1926            script.contains("requestUrl"),
1927            "POST script should build requestUrl for URI payload injection"
1928        );
1929        assert!(
1930            script.contains("secPayload.location === 'uri'"),
1931            "POST script should check for URI-location payloads"
1932        );
1933        assert!(
1934            script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
1935            "POST script should apply security body payload to request body"
1936        );
1937        // Verify the POST request uses requestUrl
1938        assert!(
1939            script.contains("http.post(requestUrl,"),
1940            "POST request should use requestUrl (with URI injection) instead of inline URL"
1941        );
1942    }
1943
1944    /// Test that security is disabled - no URI injection code present
1945    #[test]
1946    fn test_no_uri_injection_when_security_disabled() {
1947        use crate::spec_parser::ApiOperation;
1948        use openapiv3::Operation;
1949
1950        let operation = ApiOperation {
1951            method: "get".to_string(),
1952            path: "/api/users".to_string(),
1953            operation: Operation::default(),
1954            operation_id: Some("listUsers".to_string()),
1955        };
1956
1957        let template = RequestTemplate {
1958            operation,
1959            path_params: HashMap::new(),
1960            query_params: HashMap::new(),
1961            headers: HashMap::new(),
1962            body: None,
1963        };
1964
1965        let config = K6Config {
1966            target_url: "https://api.example.com".to_string(),
1967            base_path: None,
1968            scenario: LoadScenario::Constant,
1969            duration_secs: 30,
1970            max_vus: 5,
1971            threshold_percentile: "p(95)".to_string(),
1972            threshold_ms: 500,
1973            max_error_rate: 0.05,
1974            auth_header: None,
1975            custom_headers: HashMap::new(),
1976            skip_tls_verify: false,
1977            security_testing_enabled: false,
1978            chunked_request_bodies: false,
1979            target_rps: None,
1980            no_keep_alive: false,
1981        };
1982
1983        let generator = K6ScriptGenerator::new(config, vec![template]);
1984        let script = generator.generate().expect("Should generate script");
1985
1986        // Verify NO security injection code when disabled
1987        assert!(
1988            !script.contains("requestUrl"),
1989            "Script should NOT have requestUrl when security is disabled"
1990        );
1991        assert!(
1992            !script.contains("secPayloadGroup"),
1993            "Script should NOT have secPayloadGroup when security is disabled"
1994        );
1995        assert!(
1996            !script.contains("secBodyPayload"),
1997            "Script should NOT have secBodyPayload when security is disabled"
1998        );
1999    }
2000
2001    /// Test that scripts create a fresh CookieJar per request (not a shared constant)
2002    #[test]
2003    fn test_uses_per_request_cookie_jar() {
2004        use crate::spec_parser::ApiOperation;
2005        use openapiv3::Operation;
2006
2007        let operation = ApiOperation {
2008            method: "get".to_string(),
2009            path: "/api/users".to_string(),
2010            operation: Operation::default(),
2011            operation_id: Some("listUsers".to_string()),
2012        };
2013
2014        let template = RequestTemplate {
2015            operation,
2016            path_params: HashMap::new(),
2017            query_params: HashMap::new(),
2018            headers: HashMap::new(),
2019            body: None,
2020        };
2021
2022        let config = K6Config {
2023            target_url: "https://api.example.com".to_string(),
2024            base_path: None,
2025            scenario: LoadScenario::Constant,
2026            duration_secs: 30,
2027            max_vus: 5,
2028            threshold_percentile: "p(95)".to_string(),
2029            threshold_ms: 500,
2030            max_error_rate: 0.05,
2031            auth_header: None,
2032            custom_headers: HashMap::new(),
2033            skip_tls_verify: false,
2034            security_testing_enabled: false,
2035            chunked_request_bodies: false,
2036            target_rps: None,
2037            no_keep_alive: false,
2038        };
2039
2040        let generator = K6ScriptGenerator::new(config, vec![template]);
2041        let script = generator.generate().expect("Should generate script");
2042
2043        // Each request must create a fresh CookieJar to prevent Set-Cookie accumulation
2044        assert!(
2045            script.contains("jar: new http.CookieJar()"),
2046            "Script should create fresh CookieJar per request"
2047        );
2048        assert!(
2049            !script.contains("jar: null"),
2050            "Script should NOT use jar: null (does not disable default VU cookie jar in k6)"
2051        );
2052        assert!(
2053            !script.contains("EMPTY_JAR"),
2054            "Script should NOT use shared EMPTY_JAR (accumulates Set-Cookie responses)"
2055        );
2056    }
2057}