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_json::{json, Value};
9use std::collections::{HashMap, HashSet};
10
11/// Configuration for k6 script generation
12pub struct K6Config {
13    pub target_url: String,
14    /// API base path prefix (e.g., "/api" or "/v2")
15    /// Prepended to all API endpoint paths
16    pub base_path: Option<String>,
17    pub scenario: LoadScenario,
18    pub duration_secs: u64,
19    pub max_vus: u32,
20    pub threshold_percentile: String,
21    pub threshold_ms: u64,
22    pub max_error_rate: f64,
23    pub auth_header: Option<String>,
24    pub custom_headers: HashMap<String, String>,
25    pub skip_tls_verify: bool,
26    pub security_testing_enabled: bool,
27}
28
29/// Generate k6 load test script
30pub struct K6ScriptGenerator {
31    config: K6Config,
32    templates: Vec<RequestTemplate>,
33}
34
35impl K6ScriptGenerator {
36    /// Create a new k6 script generator
37    pub fn new(config: K6Config, templates: Vec<RequestTemplate>) -> Self {
38        Self { config, templates }
39    }
40
41    /// Generate the k6 script
42    pub fn generate(&self) -> Result<String> {
43        let handlebars = Handlebars::new();
44
45        let template = include_str!("templates/k6_script.hbs");
46
47        let data = self.build_template_data()?;
48
49        handlebars
50            .render_template(template, &data)
51            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))
52    }
53
54    /// Sanitize a name to be a valid JavaScript identifier
55    ///
56    /// Replaces invalid characters (dots, spaces, special chars) with underscores.
57    /// Ensures the identifier starts with a letter or underscore (not a number).
58    ///
59    /// Examples:
60    /// - "billing.subscriptions.v1" -> "billing_subscriptions_v1"
61    /// - "get user" -> "get_user"
62    /// - "123invalid" -> "_123invalid"
63    pub fn sanitize_js_identifier(name: &str) -> String {
64        let mut result = String::new();
65        let mut chars = name.chars().peekable();
66
67        // Ensure it starts with a letter or underscore (not a number)
68        if let Some(&first) = chars.peek() {
69            if first.is_ascii_digit() {
70                result.push('_');
71            }
72        }
73
74        for ch in chars {
75            if ch.is_ascii_alphanumeric() || ch == '_' {
76                result.push(ch);
77            } else {
78                // Replace invalid characters with underscore
79                // Avoid consecutive underscores
80                if !result.ends_with('_') {
81                    result.push('_');
82                }
83            }
84        }
85
86        // Remove trailing underscores
87        result = result.trim_end_matches('_').to_string();
88
89        // If empty after sanitization, use a default name
90        if result.is_empty() {
91            result = "operation".to_string();
92        }
93
94        result
95    }
96
97    /// Build the template data for rendering
98    fn build_template_data(&self) -> Result<Value> {
99        let stages = self
100            .config
101            .scenario
102            .generate_stages(self.config.duration_secs, self.config.max_vus);
103
104        // Get the base path (defaults to empty string if not set)
105        let base_path = self.config.base_path.as_deref().unwrap_or("");
106
107        // Track all placeholders used across all operations
108        let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
109
110        let operations = self
111            .templates
112            .iter()
113            .enumerate()
114            .map(|(idx, template)| {
115                let display_name = template.operation.display_name();
116                let sanitized_name = Self::sanitize_js_identifier(&display_name);
117                // metric_name must also be sanitized for k6 metric name validation
118                // k6 metric names must only contain ASCII letters, numbers, or underscores
119                let metric_name = sanitized_name.clone();
120                // k6 uses 'del' instead of 'delete' for HTTP DELETE method
121                let k6_method = match template.operation.method.to_lowercase().as_str() {
122                    "delete" => "del".to_string(),
123                    m => m.to_string(),
124                };
125                // GET and HEAD methods only take 2 arguments in k6: http.get(url, params)
126                // Other methods take 3 arguments: http.post(url, body, params)
127                let is_get_or_head = matches!(k6_method.as_str(), "get" | "head");
128
129                // Process path for dynamic placeholders
130                // Prepend base_path if configured
131                let raw_path = template.generate_path();
132                let full_path = if base_path.is_empty() {
133                    raw_path
134                } else {
135                    format!("{}{}", base_path, raw_path)
136                };
137                let processed_path = DynamicParamProcessor::process_path(&full_path);
138                all_placeholders.extend(processed_path.placeholders.clone());
139
140                // Process body for dynamic placeholders
141                let (body_value, body_is_dynamic) = if let Some(body) = &template.body {
142                    let processed_body = DynamicParamProcessor::process_json_body(body);
143                    all_placeholders.extend(processed_body.placeholders.clone());
144                    (Some(processed_body.value), processed_body.is_dynamic)
145                } else {
146                    (None, false)
147                };
148
149                json!({
150                    "index": idx,
151                    "name": sanitized_name,  // Use sanitized name for variable names
152                    "metric_name": metric_name,  // Use sanitized name for metric name strings (k6 validation)
153                    "display_name": display_name,  // Keep original for comments/display
154                    "method": k6_method,  // k6 uses lowercase methods (http.get, http.post, http.del)
155                    "path": if processed_path.is_dynamic { processed_path.value } else { full_path },
156                    "path_is_dynamic": processed_path.is_dynamic,
157                    "headers": self.build_headers_json(template),  // Returns JSON string for template
158                    "body": body_value,
159                    "body_is_dynamic": body_is_dynamic,
160                    "has_body": template.body.is_some(),
161                    "is_get_or_head": is_get_or_head,  // For correct k6 function signature
162                })
163            })
164            .collect::<Vec<_>>();
165
166        // Get required imports and global initializations based on placeholders used
167        let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
168        let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
169        let has_dynamic_values = !all_placeholders.is_empty();
170
171        Ok(json!({
172            "base_url": self.config.target_url,
173            "stages": stages.iter().map(|s| json!({
174                "duration": s.duration,
175                "target": s.target,
176            })).collect::<Vec<_>>(),
177            "operations": operations,
178            "threshold_percentile": self.config.threshold_percentile,
179            "threshold_ms": self.config.threshold_ms,
180            "max_error_rate": self.config.max_error_rate,
181            "scenario_name": format!("{:?}", self.config.scenario).to_lowercase(),
182            "skip_tls_verify": self.config.skip_tls_verify,
183            "has_dynamic_values": has_dynamic_values,
184            "dynamic_imports": required_imports,
185            "dynamic_globals": required_globals,
186            "security_testing_enabled": self.config.security_testing_enabled,
187            "has_custom_headers": !self.config.custom_headers.is_empty(),
188        }))
189    }
190
191    /// Build headers for a request template as a JSON string for k6 script
192    fn build_headers_json(&self, template: &RequestTemplate) -> String {
193        let mut headers = template.get_headers();
194
195        // Add auth header if provided
196        if let Some(auth) = &self.config.auth_header {
197            headers.insert("Authorization".to_string(), auth.clone());
198        }
199
200        // Add custom headers
201        for (key, value) in &self.config.custom_headers {
202            headers.insert(key.clone(), value.clone());
203        }
204
205        // Convert to JSON string for embedding in k6 script
206        serde_json::to_string(&headers).unwrap_or_else(|_| "{}".to_string())
207    }
208
209    /// Validate the generated k6 script for common issues
210    ///
211    /// Checks for:
212    /// - Invalid metric names (contains dots or special characters)
213    /// - Invalid JavaScript variable names
214    /// - Missing required k6 imports
215    ///
216    /// Returns a list of validation errors, empty if all checks pass.
217    pub fn validate_script(script: &str) -> Vec<String> {
218        let mut errors = Vec::new();
219
220        // Check for required k6 imports
221        if !script.contains("import http from 'k6/http'") {
222            errors.push("Missing required import: 'k6/http'".to_string());
223        }
224        if !script.contains("import { check") && !script.contains("import {check") {
225            errors.push("Missing required import: 'check' from 'k6'".to_string());
226        }
227        if !script.contains("import { Rate, Trend") && !script.contains("import {Rate, Trend") {
228            errors.push("Missing required import: 'Rate, Trend' from 'k6/metrics'".to_string());
229        }
230
231        // Check for invalid metric names in Trend/Rate constructors
232        // k6 metric names must only contain ASCII letters, numbers, or underscores
233        // and start with a letter or underscore
234        let lines: Vec<&str> = script.lines().collect();
235        for (line_num, line) in lines.iter().enumerate() {
236            let trimmed = line.trim();
237
238            // Check for Trend/Rate constructors with invalid metric names
239            if trimmed.contains("new Trend(") || trimmed.contains("new Rate(") {
240                // Extract the metric name from the string literal
241                // Pattern: new Trend('metric_name') or new Rate("metric_name")
242                if let Some(start) = trimmed.find('\'') {
243                    if let Some(end) = trimmed[start + 1..].find('\'') {
244                        let metric_name = &trimmed[start + 1..start + 1 + end];
245                        if !Self::is_valid_k6_metric_name(metric_name) {
246                            errors.push(format!(
247                                "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
248                                line_num + 1,
249                                metric_name
250                            ));
251                        }
252                    }
253                } else if let Some(start) = trimmed.find('"') {
254                    if let Some(end) = trimmed[start + 1..].find('"') {
255                        let metric_name = &trimmed[start + 1..start + 1 + end];
256                        if !Self::is_valid_k6_metric_name(metric_name) {
257                            errors.push(format!(
258                                "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
259                                line_num + 1,
260                                metric_name
261                            ));
262                        }
263                    }
264                }
265            }
266
267            // Check for invalid JavaScript variable names (containing dots)
268            if trimmed.starts_with("const ") || trimmed.starts_with("let ") {
269                if let Some(equals_pos) = trimmed.find('=') {
270                    let var_decl = &trimmed[..equals_pos];
271                    // Check if variable name contains a dot (invalid identifier)
272                    // But exclude string literals
273                    if var_decl.contains('.')
274                        && !var_decl.contains("'")
275                        && !var_decl.contains("\"")
276                        && !var_decl.trim().starts_with("//")
277                    {
278                        errors.push(format!(
279                            "Line {}: Invalid JavaScript variable name with dot: {}. Variable names cannot contain dots.",
280                            line_num + 1,
281                            var_decl.trim()
282                        ));
283                    }
284                }
285            }
286        }
287
288        errors
289    }
290
291    /// Check if a string is a valid k6 metric name
292    ///
293    /// k6 metric names must:
294    /// - Only contain ASCII letters, numbers, or underscores
295    /// - Start with a letter or underscore (not a number)
296    /// - Be at most 128 characters
297    fn is_valid_k6_metric_name(name: &str) -> bool {
298        if name.is_empty() || name.len() > 128 {
299            return false;
300        }
301
302        let mut chars = name.chars();
303
304        // First character must be a letter or underscore
305        if let Some(first) = chars.next() {
306            if !first.is_ascii_alphabetic() && first != '_' {
307                return false;
308            }
309        }
310
311        // Remaining characters must be alphanumeric or underscore
312        for ch in chars {
313            if !ch.is_ascii_alphanumeric() && ch != '_' {
314                return false;
315            }
316        }
317
318        true
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_k6_config_creation() {
328        let config = K6Config {
329            target_url: "https://api.example.com".to_string(),
330            base_path: None,
331            scenario: LoadScenario::RampUp,
332            duration_secs: 60,
333            max_vus: 10,
334            threshold_percentile: "p(95)".to_string(),
335            threshold_ms: 500,
336            max_error_rate: 0.05,
337            auth_header: None,
338            custom_headers: HashMap::new(),
339            skip_tls_verify: false,
340            security_testing_enabled: false,
341        };
342
343        assert_eq!(config.duration_secs, 60);
344        assert_eq!(config.max_vus, 10);
345    }
346
347    #[test]
348    fn test_script_generator_creation() {
349        let config = K6Config {
350            target_url: "https://api.example.com".to_string(),
351            base_path: None,
352            scenario: LoadScenario::Constant,
353            duration_secs: 30,
354            max_vus: 5,
355            threshold_percentile: "p(95)".to_string(),
356            threshold_ms: 500,
357            max_error_rate: 0.05,
358            auth_header: None,
359            custom_headers: HashMap::new(),
360            skip_tls_verify: false,
361            security_testing_enabled: false,
362        };
363
364        let templates = vec![];
365        let generator = K6ScriptGenerator::new(config, templates);
366
367        assert_eq!(generator.templates.len(), 0);
368    }
369
370    #[test]
371    fn test_sanitize_js_identifier() {
372        // Test case from issue #79: names with dots
373        assert_eq!(
374            K6ScriptGenerator::sanitize_js_identifier("billing.subscriptions.v1"),
375            "billing_subscriptions_v1"
376        );
377
378        // Test other invalid characters
379        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("get user"), "get_user");
380
381        // Test names starting with numbers
382        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("123invalid"), "_123invalid");
383
384        // Test already valid identifiers
385        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("getUsers"), "getUsers");
386
387        // Test with multiple consecutive invalid chars
388        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test...name"), "test_name");
389
390        // Test empty string (should return default)
391        assert_eq!(K6ScriptGenerator::sanitize_js_identifier(""), "operation");
392
393        // Test with special characters
394        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test@name#value"), "test_name_value");
395
396        // Test CRUD flow names with dots (issue #79 follow-up)
397        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.list"), "plans_list");
398        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.create"), "plans_create");
399        assert_eq!(
400            K6ScriptGenerator::sanitize_js_identifier("plans.update-pricing-schemes"),
401            "plans_update_pricing_schemes"
402        );
403        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("users CRUD"), "users_CRUD");
404    }
405
406    #[test]
407    fn test_script_generation_with_dots_in_name() {
408        use crate::spec_parser::ApiOperation;
409        use openapiv3::Operation;
410
411        // Create an operation with a name containing dots (like in issue #79)
412        let operation = ApiOperation {
413            method: "get".to_string(),
414            path: "/billing/subscriptions".to_string(),
415            operation: Operation::default(),
416            operation_id: Some("billing.subscriptions.v1".to_string()),
417        };
418
419        let template = RequestTemplate {
420            operation,
421            path_params: HashMap::new(),
422            query_params: HashMap::new(),
423            headers: HashMap::new(),
424            body: None,
425        };
426
427        let config = K6Config {
428            target_url: "https://api.example.com".to_string(),
429            base_path: None,
430            scenario: LoadScenario::Constant,
431            duration_secs: 30,
432            max_vus: 5,
433            threshold_percentile: "p(95)".to_string(),
434            threshold_ms: 500,
435            max_error_rate: 0.05,
436            auth_header: None,
437            custom_headers: HashMap::new(),
438            skip_tls_verify: false,
439            security_testing_enabled: false,
440        };
441
442        let generator = K6ScriptGenerator::new(config, vec![template]);
443        let script = generator.generate().expect("Should generate script");
444
445        // Verify the script contains sanitized variable names (no dots in variable identifiers)
446        assert!(
447            script.contains("const billing_subscriptions_v1_latency"),
448            "Script should contain sanitized variable name for latency"
449        );
450        assert!(
451            script.contains("const billing_subscriptions_v1_errors"),
452            "Script should contain sanitized variable name for errors"
453        );
454
455        // Verify variable names do NOT contain dots (check the actual variable identifier, not string literals)
456        // The pattern "const billing.subscriptions" would indicate a variable name with dots
457        assert!(
458            !script.contains("const billing.subscriptions"),
459            "Script should not contain variable names with dots - this would cause 'Unexpected token .' error"
460        );
461
462        // Verify metric name strings are sanitized (no dots) - k6 requires valid metric names
463        // Metric names must only contain ASCII letters, numbers, or underscores
464        assert!(
465            script.contains("'billing_subscriptions_v1_latency'"),
466            "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
467        );
468        assert!(
469            script.contains("'billing_subscriptions_v1_errors'"),
470            "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
471        );
472
473        // Verify the original display name is still used in comments and strings (for readability)
474        assert!(
475            script.contains("billing.subscriptions.v1"),
476            "Script should contain original name in comments/strings for readability"
477        );
478
479        // Most importantly: verify the variable usage doesn't have dots
480        assert!(
481            script.contains("billing_subscriptions_v1_latency.add"),
482            "Variable usage should use sanitized name"
483        );
484        assert!(
485            script.contains("billing_subscriptions_v1_errors.add"),
486            "Variable usage should use sanitized name"
487        );
488    }
489
490    #[test]
491    fn test_validate_script_valid() {
492        let valid_script = r#"
493import http from 'k6/http';
494import { check, sleep } from 'k6';
495import { Rate, Trend } from 'k6/metrics';
496
497const test_latency = new Trend('test_latency');
498const test_errors = new Rate('test_errors');
499
500export default function() {
501    const res = http.get('https://example.com');
502    test_latency.add(res.timings.duration);
503    test_errors.add(res.status !== 200);
504}
505"#;
506
507        let errors = K6ScriptGenerator::validate_script(valid_script);
508        assert!(errors.is_empty(), "Valid script should have no validation errors");
509    }
510
511    #[test]
512    fn test_validate_script_invalid_metric_name() {
513        let invalid_script = r#"
514import http from 'k6/http';
515import { check, sleep } from 'k6';
516import { Rate, Trend } from 'k6/metrics';
517
518const test_latency = new Trend('test.latency');
519const test_errors = new Rate('test_errors');
520
521export default function() {
522    const res = http.get('https://example.com');
523    test_latency.add(res.timings.duration);
524}
525"#;
526
527        let errors = K6ScriptGenerator::validate_script(invalid_script);
528        assert!(
529            !errors.is_empty(),
530            "Script with invalid metric name should have validation errors"
531        );
532        assert!(
533            errors.iter().any(|e| e.contains("Invalid k6 metric name")),
534            "Should detect invalid metric name with dot"
535        );
536    }
537
538    #[test]
539    fn test_validate_script_missing_imports() {
540        let invalid_script = r#"
541const test_latency = new Trend('test_latency');
542export default function() {}
543"#;
544
545        let errors = K6ScriptGenerator::validate_script(invalid_script);
546        assert!(!errors.is_empty(), "Script missing imports should have validation errors");
547    }
548
549    #[test]
550    fn test_validate_script_metric_name_validation() {
551        // Test that validate_script correctly identifies invalid metric names
552        // Valid metric names should pass
553        let valid_script = r#"
554import http from 'k6/http';
555import { check, sleep } from 'k6';
556import { Rate, Trend } from 'k6/metrics';
557const test_latency = new Trend('test_latency');
558const test_errors = new Rate('test_errors');
559export default function() {}
560"#;
561        let errors = K6ScriptGenerator::validate_script(valid_script);
562        assert!(errors.is_empty(), "Valid metric names should pass validation");
563
564        // Invalid metric names should fail
565        let invalid_cases = vec![
566            ("test.latency", "dot in metric name"),
567            ("123test", "starts with number"),
568            ("test-latency", "hyphen in metric name"),
569            ("test@latency", "special character"),
570        ];
571
572        for (invalid_name, description) in invalid_cases {
573            let script = format!(
574                r#"
575import http from 'k6/http';
576import {{ check, sleep }} from 'k6';
577import {{ Rate, Trend }} from 'k6/metrics';
578const test_latency = new Trend('{}');
579export default function() {{}}
580"#,
581                invalid_name
582            );
583            let errors = K6ScriptGenerator::validate_script(&script);
584            assert!(
585                !errors.is_empty(),
586                "Metric name '{}' ({}) should fail validation",
587                invalid_name,
588                description
589            );
590        }
591    }
592
593    #[test]
594    fn test_skip_tls_verify_with_body() {
595        use crate::spec_parser::ApiOperation;
596        use openapiv3::Operation;
597        use serde_json::json;
598
599        // Create an operation with a request body
600        let operation = ApiOperation {
601            method: "post".to_string(),
602            path: "/api/users".to_string(),
603            operation: Operation::default(),
604            operation_id: Some("createUser".to_string()),
605        };
606
607        let template = RequestTemplate {
608            operation,
609            path_params: HashMap::new(),
610            query_params: HashMap::new(),
611            headers: HashMap::new(),
612            body: Some(json!({"name": "test"})),
613        };
614
615        let config = K6Config {
616            target_url: "https://api.example.com".to_string(),
617            base_path: None,
618            scenario: LoadScenario::Constant,
619            duration_secs: 30,
620            max_vus: 5,
621            threshold_percentile: "p(95)".to_string(),
622            threshold_ms: 500,
623            max_error_rate: 0.05,
624            auth_header: None,
625            custom_headers: HashMap::new(),
626            skip_tls_verify: true,
627            security_testing_enabled: false,
628        };
629
630        let generator = K6ScriptGenerator::new(config, vec![template]);
631        let script = generator.generate().expect("Should generate script");
632
633        // Verify the script includes TLS skip option for requests with body
634        assert!(
635            script.contains("insecureSkipTLSVerify: true"),
636            "Script should include insecureSkipTLSVerify option when skip_tls_verify is true"
637        );
638    }
639
640    #[test]
641    fn test_skip_tls_verify_without_body() {
642        use crate::spec_parser::ApiOperation;
643        use openapiv3::Operation;
644
645        // Create an operation without a request body
646        let operation = ApiOperation {
647            method: "get".to_string(),
648            path: "/api/users".to_string(),
649            operation: Operation::default(),
650            operation_id: Some("getUsers".to_string()),
651        };
652
653        let template = RequestTemplate {
654            operation,
655            path_params: HashMap::new(),
656            query_params: HashMap::new(),
657            headers: HashMap::new(),
658            body: None,
659        };
660
661        let config = K6Config {
662            target_url: "https://api.example.com".to_string(),
663            base_path: None,
664            scenario: LoadScenario::Constant,
665            duration_secs: 30,
666            max_vus: 5,
667            threshold_percentile: "p(95)".to_string(),
668            threshold_ms: 500,
669            max_error_rate: 0.05,
670            auth_header: None,
671            custom_headers: HashMap::new(),
672            skip_tls_verify: true,
673            security_testing_enabled: false,
674        };
675
676        let generator = K6ScriptGenerator::new(config, vec![template]);
677        let script = generator.generate().expect("Should generate script");
678
679        // Verify the script includes TLS skip option for requests without body
680        assert!(
681            script.contains("insecureSkipTLSVerify: true"),
682            "Script should include insecureSkipTLSVerify option when skip_tls_verify is true (no body)"
683        );
684    }
685
686    #[test]
687    fn test_no_skip_tls_verify() {
688        use crate::spec_parser::ApiOperation;
689        use openapiv3::Operation;
690
691        // Create an operation
692        let operation = ApiOperation {
693            method: "get".to_string(),
694            path: "/api/users".to_string(),
695            operation: Operation::default(),
696            operation_id: Some("getUsers".to_string()),
697        };
698
699        let template = RequestTemplate {
700            operation,
701            path_params: HashMap::new(),
702            query_params: HashMap::new(),
703            headers: HashMap::new(),
704            body: None,
705        };
706
707        let config = K6Config {
708            target_url: "https://api.example.com".to_string(),
709            base_path: None,
710            scenario: LoadScenario::Constant,
711            duration_secs: 30,
712            max_vus: 5,
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        };
721
722        let generator = K6ScriptGenerator::new(config, vec![template]);
723        let script = generator.generate().expect("Should generate script");
724
725        // Verify the script does NOT include TLS skip option when skip_tls_verify is false
726        assert!(
727            !script.contains("insecureSkipTLSVerify"),
728            "Script should NOT include insecureSkipTLSVerify option when skip_tls_verify is false"
729        );
730    }
731
732    #[test]
733    fn test_skip_tls_verify_multiple_operations() {
734        use crate::spec_parser::ApiOperation;
735        use openapiv3::Operation;
736        use serde_json::json;
737
738        // Create multiple operations - one with body, one without
739        let operation1 = ApiOperation {
740            method: "get".to_string(),
741            path: "/api/users".to_string(),
742            operation: Operation::default(),
743            operation_id: Some("getUsers".to_string()),
744        };
745
746        let operation2 = ApiOperation {
747            method: "post".to_string(),
748            path: "/api/users".to_string(),
749            operation: Operation::default(),
750            operation_id: Some("createUser".to_string()),
751        };
752
753        let template1 = RequestTemplate {
754            operation: operation1,
755            path_params: HashMap::new(),
756            query_params: HashMap::new(),
757            headers: HashMap::new(),
758            body: None,
759        };
760
761        let template2 = RequestTemplate {
762            operation: operation2,
763            path_params: HashMap::new(),
764            query_params: HashMap::new(),
765            headers: HashMap::new(),
766            body: Some(json!({"name": "test"})),
767        };
768
769        let config = K6Config {
770            target_url: "https://api.example.com".to_string(),
771            base_path: None,
772            scenario: LoadScenario::Constant,
773            duration_secs: 30,
774            max_vus: 5,
775            threshold_percentile: "p(95)".to_string(),
776            threshold_ms: 500,
777            max_error_rate: 0.05,
778            auth_header: None,
779            custom_headers: HashMap::new(),
780            skip_tls_verify: true,
781            security_testing_enabled: false,
782        };
783
784        let generator = K6ScriptGenerator::new(config, vec![template1, template2]);
785        let script = generator.generate().expect("Should generate script");
786
787        // Verify the script includes TLS skip option ONCE in global options
788        // (k6 only supports insecureSkipTLSVerify as a global option, not per-request)
789        let skip_count = script.matches("insecureSkipTLSVerify: true").count();
790        assert_eq!(
791            skip_count, 1,
792            "Script should include insecureSkipTLSVerify exactly once in global options (not per-request)"
793        );
794
795        // Verify it appears in the options block, before scenarios
796        let options_start = script.find("export const options = {").expect("Should have options");
797        let scenarios_start = script.find("scenarios:").expect("Should have scenarios");
798        let options_prefix = &script[options_start..scenarios_start];
799        assert!(
800            options_prefix.contains("insecureSkipTLSVerify: true"),
801            "insecureSkipTLSVerify should be in global options block"
802        );
803    }
804
805    #[test]
806    fn test_dynamic_params_in_body() {
807        use crate::spec_parser::ApiOperation;
808        use openapiv3::Operation;
809        use serde_json::json;
810
811        // Create an operation with dynamic placeholders in the body
812        let operation = ApiOperation {
813            method: "post".to_string(),
814            path: "/api/resources".to_string(),
815            operation: Operation::default(),
816            operation_id: Some("createResource".to_string()),
817        };
818
819        let template = RequestTemplate {
820            operation,
821            path_params: HashMap::new(),
822            query_params: HashMap::new(),
823            headers: HashMap::new(),
824            body: Some(json!({
825                "name": "load-test-${__VU}",
826                "iteration": "${__ITER}"
827            })),
828        };
829
830        let config = K6Config {
831            target_url: "https://api.example.com".to_string(),
832            base_path: None,
833            scenario: LoadScenario::Constant,
834            duration_secs: 30,
835            max_vus: 5,
836            threshold_percentile: "p(95)".to_string(),
837            threshold_ms: 500,
838            max_error_rate: 0.05,
839            auth_header: None,
840            custom_headers: HashMap::new(),
841            skip_tls_verify: false,
842            security_testing_enabled: false,
843        };
844
845        let generator = K6ScriptGenerator::new(config, vec![template]);
846        let script = generator.generate().expect("Should generate script");
847
848        // Verify the script contains dynamic body indication
849        assert!(
850            script.contains("Dynamic body with runtime placeholders"),
851            "Script should contain comment about dynamic body"
852        );
853
854        // Verify the script contains the __VU variable reference
855        assert!(
856            script.contains("__VU"),
857            "Script should contain __VU reference for dynamic VU-based values"
858        );
859
860        // Verify the script contains the __ITER variable reference
861        assert!(
862            script.contains("__ITER"),
863            "Script should contain __ITER reference for dynamic iteration values"
864        );
865    }
866
867    #[test]
868    fn test_dynamic_params_with_uuid() {
869        use crate::spec_parser::ApiOperation;
870        use openapiv3::Operation;
871        use serde_json::json;
872
873        // Create an operation with UUID placeholder
874        let operation = ApiOperation {
875            method: "post".to_string(),
876            path: "/api/resources".to_string(),
877            operation: Operation::default(),
878            operation_id: Some("createResource".to_string()),
879        };
880
881        let template = RequestTemplate {
882            operation,
883            path_params: HashMap::new(),
884            query_params: HashMap::new(),
885            headers: HashMap::new(),
886            body: Some(json!({
887                "id": "${__UUID}"
888            })),
889        };
890
891        let config = K6Config {
892            target_url: "https://api.example.com".to_string(),
893            base_path: None,
894            scenario: LoadScenario::Constant,
895            duration_secs: 30,
896            max_vus: 5,
897            threshold_percentile: "p(95)".to_string(),
898            threshold_ms: 500,
899            max_error_rate: 0.05,
900            auth_header: None,
901            custom_headers: HashMap::new(),
902            skip_tls_verify: false,
903            security_testing_enabled: false,
904        };
905
906        let generator = K6ScriptGenerator::new(config, vec![template]);
907        let script = generator.generate().expect("Should generate script");
908
909        // As of k6 v1.0.0+, webcrypto is globally available - no import needed
910        // Verify the script does NOT include the old experimental webcrypto import
911        assert!(
912            !script.contains("k6/experimental/webcrypto"),
913            "Script should NOT include deprecated k6/experimental/webcrypto import"
914        );
915
916        // Verify crypto.randomUUID() is in the generated code
917        assert!(
918            script.contains("crypto.randomUUID()"),
919            "Script should contain crypto.randomUUID() for UUID placeholder"
920        );
921    }
922
923    #[test]
924    fn test_dynamic_params_with_counter() {
925        use crate::spec_parser::ApiOperation;
926        use openapiv3::Operation;
927        use serde_json::json;
928
929        // Create an operation with COUNTER placeholder
930        let operation = ApiOperation {
931            method: "post".to_string(),
932            path: "/api/resources".to_string(),
933            operation: Operation::default(),
934            operation_id: Some("createResource".to_string()),
935        };
936
937        let template = RequestTemplate {
938            operation,
939            path_params: HashMap::new(),
940            query_params: HashMap::new(),
941            headers: HashMap::new(),
942            body: Some(json!({
943                "sequence": "${__COUNTER}"
944            })),
945        };
946
947        let config = K6Config {
948            target_url: "https://api.example.com".to_string(),
949            base_path: None,
950            scenario: LoadScenario::Constant,
951            duration_secs: 30,
952            max_vus: 5,
953            threshold_percentile: "p(95)".to_string(),
954            threshold_ms: 500,
955            max_error_rate: 0.05,
956            auth_header: None,
957            custom_headers: HashMap::new(),
958            skip_tls_verify: false,
959            security_testing_enabled: false,
960        };
961
962        let generator = K6ScriptGenerator::new(config, vec![template]);
963        let script = generator.generate().expect("Should generate script");
964
965        // Verify the script includes the global counter initialization
966        assert!(
967            script.contains("let globalCounter = 0"),
968            "Script should include globalCounter initialization when COUNTER placeholder is used"
969        );
970
971        // Verify globalCounter++ is in the generated code
972        assert!(
973            script.contains("globalCounter++"),
974            "Script should contain globalCounter++ for COUNTER placeholder"
975        );
976    }
977
978    #[test]
979    fn test_static_body_no_dynamic_marker() {
980        use crate::spec_parser::ApiOperation;
981        use openapiv3::Operation;
982        use serde_json::json;
983
984        // Create an operation with static body (no placeholders)
985        let operation = ApiOperation {
986            method: "post".to_string(),
987            path: "/api/resources".to_string(),
988            operation: Operation::default(),
989            operation_id: Some("createResource".to_string()),
990        };
991
992        let template = RequestTemplate {
993            operation,
994            path_params: HashMap::new(),
995            query_params: HashMap::new(),
996            headers: HashMap::new(),
997            body: Some(json!({
998                "name": "static-value",
999                "count": 42
1000            })),
1001        };
1002
1003        let config = K6Config {
1004            target_url: "https://api.example.com".to_string(),
1005            base_path: None,
1006            scenario: LoadScenario::Constant,
1007            duration_secs: 30,
1008            max_vus: 5,
1009            threshold_percentile: "p(95)".to_string(),
1010            threshold_ms: 500,
1011            max_error_rate: 0.05,
1012            auth_header: None,
1013            custom_headers: HashMap::new(),
1014            skip_tls_verify: false,
1015            security_testing_enabled: false,
1016        };
1017
1018        let generator = K6ScriptGenerator::new(config, vec![template]);
1019        let script = generator.generate().expect("Should generate script");
1020
1021        // Verify the script does NOT contain dynamic body marker
1022        assert!(
1023            !script.contains("Dynamic body with runtime placeholders"),
1024            "Script should NOT contain dynamic body comment for static body"
1025        );
1026
1027        // Verify it does NOT include unnecessary crypto imports
1028        assert!(
1029            !script.contains("webcrypto"),
1030            "Script should NOT include webcrypto import for static body"
1031        );
1032
1033        // Verify it does NOT include global counter
1034        assert!(
1035            !script.contains("let globalCounter"),
1036            "Script should NOT include globalCounter for static body"
1037        );
1038    }
1039
1040    #[test]
1041    fn test_security_testing_enabled_generates_calling_code() {
1042        use crate::spec_parser::ApiOperation;
1043        use openapiv3::Operation;
1044        use serde_json::json;
1045
1046        let operation = ApiOperation {
1047            method: "post".to_string(),
1048            path: "/api/users".to_string(),
1049            operation: Operation::default(),
1050            operation_id: Some("createUser".to_string()),
1051        };
1052
1053        let template = RequestTemplate {
1054            operation,
1055            path_params: HashMap::new(),
1056            query_params: HashMap::new(),
1057            headers: HashMap::new(),
1058            body: Some(json!({"name": "test"})),
1059        };
1060
1061        let config = K6Config {
1062            target_url: "https://api.example.com".to_string(),
1063            base_path: None,
1064            scenario: LoadScenario::Constant,
1065            duration_secs: 30,
1066            max_vus: 5,
1067            threshold_percentile: "p(95)".to_string(),
1068            threshold_ms: 500,
1069            max_error_rate: 0.05,
1070            auth_header: None,
1071            custom_headers: HashMap::new(),
1072            skip_tls_verify: false,
1073            security_testing_enabled: true,
1074        };
1075
1076        let generator = K6ScriptGenerator::new(config, vec![template]);
1077        let script = generator.generate().expect("Should generate script");
1078
1079        // Verify calling code is generated (not just function definitions)
1080        assert!(
1081            script.contains("getNextSecurityPayload"),
1082            "Script should contain getNextSecurityPayload() call when security_testing_enabled is true"
1083        );
1084        assert!(
1085            script.contains("applySecurityPayload"),
1086            "Script should contain applySecurityPayload() call when security_testing_enabled is true"
1087        );
1088        assert!(
1089            script.contains("secPayload"),
1090            "Script should contain secPayload variable when security_testing_enabled is true"
1091        );
1092        // Verify mutable headers copy for injection
1093        assert!(
1094            script.contains("const requestHeaders = { ..."),
1095            "Script should spread headers into mutable copy for security payload injection"
1096        );
1097        // Verify secPayload is fetched per-operation (inside operation block), not per-iteration
1098        // The getNextSecurityPayload call should appear AFTER "Operation 0:" comment
1099        let op_comment_pos =
1100            script.find("// Operation 0:").expect("Should have Operation 0 comment");
1101        let sec_payload_pos = script
1102            .find("const secPayload = typeof getNextSecurityPayload")
1103            .expect("Should have secPayload assignment");
1104        assert!(
1105            sec_payload_pos > op_comment_pos,
1106            "secPayload should be fetched inside operation block (per-operation), not before it (per-iteration)"
1107        );
1108    }
1109
1110    #[test]
1111    fn test_security_testing_disabled_no_calling_code() {
1112        use crate::spec_parser::ApiOperation;
1113        use openapiv3::Operation;
1114        use serde_json::json;
1115
1116        let operation = ApiOperation {
1117            method: "post".to_string(),
1118            path: "/api/users".to_string(),
1119            operation: Operation::default(),
1120            operation_id: Some("createUser".to_string()),
1121        };
1122
1123        let template = RequestTemplate {
1124            operation,
1125            path_params: HashMap::new(),
1126            query_params: HashMap::new(),
1127            headers: HashMap::new(),
1128            body: Some(json!({"name": "test"})),
1129        };
1130
1131        let config = K6Config {
1132            target_url: "https://api.example.com".to_string(),
1133            base_path: None,
1134            scenario: LoadScenario::Constant,
1135            duration_secs: 30,
1136            max_vus: 5,
1137            threshold_percentile: "p(95)".to_string(),
1138            threshold_ms: 500,
1139            max_error_rate: 0.05,
1140            auth_header: None,
1141            custom_headers: HashMap::new(),
1142            skip_tls_verify: false,
1143            security_testing_enabled: false,
1144        };
1145
1146        let generator = K6ScriptGenerator::new(config, vec![template]);
1147        let script = generator.generate().expect("Should generate script");
1148
1149        // Verify calling code is NOT generated
1150        assert!(
1151            !script.contains("getNextSecurityPayload"),
1152            "Script should NOT contain getNextSecurityPayload() when security_testing_enabled is false"
1153        );
1154        assert!(
1155            !script.contains("applySecurityPayload"),
1156            "Script should NOT contain applySecurityPayload() when security_testing_enabled is false"
1157        );
1158        assert!(
1159            !script.contains("secPayload"),
1160            "Script should NOT contain secPayload variable when security_testing_enabled is false"
1161        );
1162    }
1163
1164    /// End-to-end test: simulates the real pipeline of template rendering + enhanced script
1165    /// injection. This is what actually runs when a user passes `--security-test`.
1166    /// Verifies that the FINAL script has both function definitions AND calling code.
1167    #[test]
1168    fn test_security_e2e_definitions_and_calls_both_present() {
1169        use crate::security_payloads::{
1170            SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
1171        };
1172        use crate::spec_parser::ApiOperation;
1173        use openapiv3::Operation;
1174        use serde_json::json;
1175
1176        // Step 1: Generate base script with security_testing_enabled=true (template renders calling code)
1177        let operation = ApiOperation {
1178            method: "post".to_string(),
1179            path: "/api/users".to_string(),
1180            operation: Operation::default(),
1181            operation_id: Some("createUser".to_string()),
1182        };
1183
1184        let template = RequestTemplate {
1185            operation,
1186            path_params: HashMap::new(),
1187            query_params: HashMap::new(),
1188            headers: HashMap::new(),
1189            body: Some(json!({"name": "test"})),
1190        };
1191
1192        let config = K6Config {
1193            target_url: "https://api.example.com".to_string(),
1194            base_path: None,
1195            scenario: LoadScenario::Constant,
1196            duration_secs: 30,
1197            max_vus: 5,
1198            threshold_percentile: "p(95)".to_string(),
1199            threshold_ms: 500,
1200            max_error_rate: 0.05,
1201            auth_header: None,
1202            custom_headers: HashMap::new(),
1203            skip_tls_verify: false,
1204            security_testing_enabled: true,
1205        };
1206
1207        let generator = K6ScriptGenerator::new(config, vec![template]);
1208        let mut script = generator.generate().expect("Should generate base script");
1209
1210        // Step 2: Simulate what generate_enhanced_script() does — inject function definitions
1211        let security_config = SecurityTestConfig::default().enable();
1212        let payloads = SecurityPayloads::get_payloads(&security_config);
1213        assert!(!payloads.is_empty(), "Should have built-in payloads");
1214
1215        let mut additional_code = String::new();
1216        additional_code
1217            .push_str(&SecurityTestGenerator::generate_payload_selection(&payloads, false));
1218        additional_code.push('\n');
1219        additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
1220        additional_code.push('\n');
1221
1222        // Insert definitions before 'export const options' (same as generate_enhanced_script)
1223        if let Some(pos) = script.find("export const options") {
1224            script.insert_str(
1225                pos,
1226                &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
1227            );
1228        }
1229
1230        // Step 3: Verify the FINAL script has BOTH definitions AND calls
1231        // Function definitions (injected by generate_enhanced_script)
1232        assert!(
1233            script.contains("function getNextSecurityPayload()"),
1234            "Final script must contain getNextSecurityPayload function DEFINITION"
1235        );
1236        assert!(
1237            script.contains("function applySecurityPayload("),
1238            "Final script must contain applySecurityPayload function DEFINITION"
1239        );
1240        assert!(
1241            script.contains("securityPayloads"),
1242            "Final script must contain securityPayloads array"
1243        );
1244
1245        // Calling code (rendered by template)
1246        assert!(
1247            script.contains("const secPayload = typeof getNextSecurityPayload"),
1248            "Final script must contain secPayload assignment (template calling code)"
1249        );
1250        assert!(
1251            script.contains("applySecurityPayload(payload, [], secPayload)"),
1252            "Final script must contain applySecurityPayload CALL in request body injection"
1253        );
1254        assert!(
1255            script.contains("const requestHeaders = { ..."),
1256            "Final script must spread headers for security payload header injection"
1257        );
1258
1259        // Verify ordering: definitions come BEFORE export default function (which has the calls)
1260        let def_pos = script.find("function getNextSecurityPayload()").unwrap();
1261        let call_pos = script.find("const secPayload = typeof getNextSecurityPayload").unwrap();
1262        let options_pos = script.find("export const options").unwrap();
1263        let default_fn_pos = script.find("export default function").unwrap();
1264
1265        assert!(
1266            def_pos < options_pos,
1267            "Function definitions must appear before export const options"
1268        );
1269        assert!(
1270            call_pos > default_fn_pos,
1271            "Calling code must appear inside export default function"
1272        );
1273    }
1274
1275    /// Test that URI security payload injection is generated for GET requests
1276    #[test]
1277    fn test_security_uri_injection_for_get_requests() {
1278        use crate::spec_parser::ApiOperation;
1279        use openapiv3::Operation;
1280
1281        let operation = ApiOperation {
1282            method: "get".to_string(),
1283            path: "/api/users".to_string(),
1284            operation: Operation::default(),
1285            operation_id: Some("listUsers".to_string()),
1286        };
1287
1288        let template = RequestTemplate {
1289            operation,
1290            path_params: HashMap::new(),
1291            query_params: HashMap::new(),
1292            headers: HashMap::new(),
1293            body: None,
1294        };
1295
1296        let config = K6Config {
1297            target_url: "https://api.example.com".to_string(),
1298            base_path: None,
1299            scenario: LoadScenario::Constant,
1300            duration_secs: 30,
1301            max_vus: 5,
1302            threshold_percentile: "p(95)".to_string(),
1303            threshold_ms: 500,
1304            max_error_rate: 0.05,
1305            auth_header: None,
1306            custom_headers: HashMap::new(),
1307            skip_tls_verify: false,
1308            security_testing_enabled: true,
1309        };
1310
1311        let generator = K6ScriptGenerator::new(config, vec![template]);
1312        let script = generator.generate().expect("Should generate script");
1313
1314        // Verify URI injection code is present for GET requests
1315        assert!(
1316            script.contains("requestUrl"),
1317            "Script should build requestUrl variable for URI payload injection"
1318        );
1319        assert!(
1320            script.contains("secPayload.location === 'uri'"),
1321            "Script should check for URI-location payloads"
1322        );
1323        // URI payloads are sent RAW (not encoded) so WAFs can detect them
1324        assert!(
1325            script.contains("'test=' + secPayload.payload"),
1326            "Script should inject raw (unencoded) security payload into query string"
1327        );
1328        // Verify the GET request uses requestUrl
1329        assert!(
1330            script.contains("http.get(requestUrl,"),
1331            "GET request should use requestUrl (with URI injection) instead of inline URL"
1332        );
1333    }
1334
1335    /// Test that URI security payload injection is generated for POST requests with body
1336    #[test]
1337    fn test_security_uri_injection_for_post_requests() {
1338        use crate::spec_parser::ApiOperation;
1339        use openapiv3::Operation;
1340        use serde_json::json;
1341
1342        let operation = ApiOperation {
1343            method: "post".to_string(),
1344            path: "/api/users".to_string(),
1345            operation: Operation::default(),
1346            operation_id: Some("createUser".to_string()),
1347        };
1348
1349        let template = RequestTemplate {
1350            operation,
1351            path_params: HashMap::new(),
1352            query_params: HashMap::new(),
1353            headers: HashMap::new(),
1354            body: Some(json!({"name": "test"})),
1355        };
1356
1357        let config = K6Config {
1358            target_url: "https://api.example.com".to_string(),
1359            base_path: None,
1360            scenario: LoadScenario::Constant,
1361            duration_secs: 30,
1362            max_vus: 5,
1363            threshold_percentile: "p(95)".to_string(),
1364            threshold_ms: 500,
1365            max_error_rate: 0.05,
1366            auth_header: None,
1367            custom_headers: HashMap::new(),
1368            skip_tls_verify: false,
1369            security_testing_enabled: true,
1370        };
1371
1372        let generator = K6ScriptGenerator::new(config, vec![template]);
1373        let script = generator.generate().expect("Should generate script");
1374
1375        // POST with body should get BOTH URI injection AND body injection
1376        assert!(
1377            script.contains("requestUrl"),
1378            "POST script should build requestUrl for URI payload injection"
1379        );
1380        assert!(
1381            script.contains("secPayload.location === 'uri'"),
1382            "POST script should check for URI-location payloads"
1383        );
1384        assert!(
1385            script.contains("applySecurityPayload(payload, [], secPayload)"),
1386            "POST script should also apply security payload to request body"
1387        );
1388        // Verify the POST request uses requestUrl
1389        assert!(
1390            script.contains("http.post(requestUrl,"),
1391            "POST request should use requestUrl (with URI injection) instead of inline URL"
1392        );
1393    }
1394
1395    /// Test that security is disabled - no URI injection code present
1396    #[test]
1397    fn test_no_uri_injection_when_security_disabled() {
1398        use crate::spec_parser::ApiOperation;
1399        use openapiv3::Operation;
1400
1401        let operation = ApiOperation {
1402            method: "get".to_string(),
1403            path: "/api/users".to_string(),
1404            operation: Operation::default(),
1405            operation_id: Some("listUsers".to_string()),
1406        };
1407
1408        let template = RequestTemplate {
1409            operation,
1410            path_params: HashMap::new(),
1411            query_params: HashMap::new(),
1412            headers: HashMap::new(),
1413            body: None,
1414        };
1415
1416        let config = K6Config {
1417            target_url: "https://api.example.com".to_string(),
1418            base_path: None,
1419            scenario: LoadScenario::Constant,
1420            duration_secs: 30,
1421            max_vus: 5,
1422            threshold_percentile: "p(95)".to_string(),
1423            threshold_ms: 500,
1424            max_error_rate: 0.05,
1425            auth_header: None,
1426            custom_headers: HashMap::new(),
1427            skip_tls_verify: false,
1428            security_testing_enabled: false,
1429        };
1430
1431        let generator = K6ScriptGenerator::new(config, vec![template]);
1432        let script = generator.generate().expect("Should generate script");
1433
1434        // Verify NO security injection code when disabled
1435        assert!(
1436            !script.contains("requestUrl"),
1437            "Script should NOT have requestUrl when security is disabled"
1438        );
1439        assert!(
1440            !script.contains("secPayload"),
1441            "Script should NOT have secPayload when security is disabled"
1442        );
1443    }
1444
1445    /// Test that scripts create a fresh CookieJar per request (not a shared constant)
1446    #[test]
1447    fn test_uses_per_request_cookie_jar() {
1448        use crate::spec_parser::ApiOperation;
1449        use openapiv3::Operation;
1450
1451        let operation = ApiOperation {
1452            method: "get".to_string(),
1453            path: "/api/users".to_string(),
1454            operation: Operation::default(),
1455            operation_id: Some("listUsers".to_string()),
1456        };
1457
1458        let template = RequestTemplate {
1459            operation,
1460            path_params: HashMap::new(),
1461            query_params: HashMap::new(),
1462            headers: HashMap::new(),
1463            body: None,
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        };
1480
1481        let generator = K6ScriptGenerator::new(config, vec![template]);
1482        let script = generator.generate().expect("Should generate script");
1483
1484        // Each request must create a fresh CookieJar to prevent Set-Cookie accumulation
1485        assert!(
1486            script.contains("jar: new http.CookieJar()"),
1487            "Script should create fresh CookieJar per request"
1488        );
1489        assert!(
1490            !script.contains("jar: null"),
1491            "Script should NOT use jar: null (does not disable default VU cookie jar in k6)"
1492        );
1493        assert!(
1494            !script.contains("EMPTY_JAR"),
1495            "Script should NOT use shared EMPTY_JAR (accumulates Set-Cookie responses)"
1496        );
1497    }
1498}