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    pub scenario: LoadScenario,
15    pub duration_secs: u64,
16    pub max_vus: u32,
17    pub threshold_percentile: String,
18    pub threshold_ms: u64,
19    pub max_error_rate: f64,
20    pub auth_header: Option<String>,
21    pub custom_headers: HashMap<String, String>,
22    pub skip_tls_verify: bool,
23}
24
25/// Generate k6 load test script
26pub struct K6ScriptGenerator {
27    config: K6Config,
28    templates: Vec<RequestTemplate>,
29}
30
31impl K6ScriptGenerator {
32    /// Create a new k6 script generator
33    pub fn new(config: K6Config, templates: Vec<RequestTemplate>) -> Self {
34        Self { config, templates }
35    }
36
37    /// Generate the k6 script
38    pub fn generate(&self) -> Result<String> {
39        let handlebars = Handlebars::new();
40
41        let template = include_str!("templates/k6_script.hbs");
42
43        let data = self.build_template_data()?;
44
45        handlebars
46            .render_template(template, &data)
47            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))
48    }
49
50    /// Sanitize a name to be a valid JavaScript identifier
51    ///
52    /// Replaces invalid characters (dots, spaces, special chars) with underscores.
53    /// Ensures the identifier starts with a letter or underscore (not a number).
54    ///
55    /// Examples:
56    /// - "billing.subscriptions.v1" -> "billing_subscriptions_v1"
57    /// - "get user" -> "get_user"
58    /// - "123invalid" -> "_123invalid"
59    fn sanitize_js_identifier(name: &str) -> String {
60        let mut result = String::new();
61        let mut chars = name.chars().peekable();
62
63        // Ensure it starts with a letter or underscore (not a number)
64        if let Some(&first) = chars.peek() {
65            if first.is_ascii_digit() {
66                result.push('_');
67            }
68        }
69
70        for ch in chars {
71            if ch.is_ascii_alphanumeric() || ch == '_' {
72                result.push(ch);
73            } else {
74                // Replace invalid characters with underscore
75                // Avoid consecutive underscores
76                if !result.ends_with('_') {
77                    result.push('_');
78                }
79            }
80        }
81
82        // Remove trailing underscores
83        result = result.trim_end_matches('_').to_string();
84
85        // If empty after sanitization, use a default name
86        if result.is_empty() {
87            result = "operation".to_string();
88        }
89
90        result
91    }
92
93    /// Build the template data for rendering
94    fn build_template_data(&self) -> Result<Value> {
95        let stages = self
96            .config
97            .scenario
98            .generate_stages(self.config.duration_secs, self.config.max_vus);
99
100        // Track all placeholders used across all operations
101        let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
102
103        let operations = self
104            .templates
105            .iter()
106            .enumerate()
107            .map(|(idx, template)| {
108                let display_name = template.operation.display_name();
109                let sanitized_name = Self::sanitize_js_identifier(&display_name);
110                // metric_name must also be sanitized for k6 metric name validation
111                // k6 metric names must only contain ASCII letters, numbers, or underscores
112                let metric_name = sanitized_name.clone();
113                // k6 uses 'del' instead of 'delete' for HTTP DELETE method
114                let k6_method = match template.operation.method.to_lowercase().as_str() {
115                    "delete" => "del".to_string(),
116                    m => m.to_string(),
117                };
118                // GET and HEAD methods only take 2 arguments in k6: http.get(url, params)
119                // Other methods take 3 arguments: http.post(url, body, params)
120                let is_get_or_head = matches!(k6_method.as_str(), "get" | "head");
121
122                // Process path for dynamic placeholders
123                let path = template.generate_path();
124                let processed_path = DynamicParamProcessor::process_path(&path);
125                all_placeholders.extend(processed_path.placeholders.clone());
126
127                // Process body for dynamic placeholders
128                let (body_value, body_is_dynamic) = if let Some(body) = &template.body {
129                    let processed_body = DynamicParamProcessor::process_json_body(body);
130                    all_placeholders.extend(processed_body.placeholders.clone());
131                    (Some(processed_body.value), processed_body.is_dynamic)
132                } else {
133                    (None, false)
134                };
135
136                json!({
137                    "index": idx,
138                    "name": sanitized_name,  // Use sanitized name for variable names
139                    "metric_name": metric_name,  // Use sanitized name for metric name strings (k6 validation)
140                    "display_name": display_name,  // Keep original for comments/display
141                    "method": k6_method,  // k6 uses lowercase methods (http.get, http.post, http.del)
142                    "path": if processed_path.is_dynamic { processed_path.value } else { path },
143                    "path_is_dynamic": processed_path.is_dynamic,
144                    "headers": self.build_headers_json(template),  // Returns JSON string for template
145                    "body": body_value,
146                    "body_is_dynamic": body_is_dynamic,
147                    "has_body": template.body.is_some(),
148                    "is_get_or_head": is_get_or_head,  // For correct k6 function signature
149                })
150            })
151            .collect::<Vec<_>>();
152
153        // Get required imports and global initializations based on placeholders used
154        let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
155        let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
156        let has_dynamic_values = !all_placeholders.is_empty();
157
158        Ok(json!({
159            "base_url": self.config.target_url,
160            "stages": stages.iter().map(|s| json!({
161                "duration": s.duration,
162                "target": s.target,
163            })).collect::<Vec<_>>(),
164            "operations": operations,
165            "threshold_percentile": self.config.threshold_percentile,
166            "threshold_ms": self.config.threshold_ms,
167            "max_error_rate": self.config.max_error_rate,
168            "scenario_name": format!("{:?}", self.config.scenario).to_lowercase(),
169            "skip_tls_verify": self.config.skip_tls_verify,
170            "has_dynamic_values": has_dynamic_values,
171            "dynamic_imports": required_imports,
172            "dynamic_globals": required_globals,
173        }))
174    }
175
176    /// Build headers for a request template as a JSON string for k6 script
177    fn build_headers_json(&self, template: &RequestTemplate) -> String {
178        let mut headers = template.get_headers();
179
180        // Add auth header if provided
181        if let Some(auth) = &self.config.auth_header {
182            headers.insert("Authorization".to_string(), auth.clone());
183        }
184
185        // Add custom headers
186        for (key, value) in &self.config.custom_headers {
187            headers.insert(key.clone(), value.clone());
188        }
189
190        // Convert to JSON string for embedding in k6 script
191        serde_json::to_string(&headers).unwrap_or_else(|_| "{}".to_string())
192    }
193
194    /// Validate the generated k6 script for common issues
195    ///
196    /// Checks for:
197    /// - Invalid metric names (contains dots or special characters)
198    /// - Invalid JavaScript variable names
199    /// - Missing required k6 imports
200    ///
201    /// Returns a list of validation errors, empty if all checks pass.
202    pub fn validate_script(script: &str) -> Vec<String> {
203        let mut errors = Vec::new();
204
205        // Check for required k6 imports
206        if !script.contains("import http from 'k6/http'") {
207            errors.push("Missing required import: 'k6/http'".to_string());
208        }
209        if !script.contains("import { check") && !script.contains("import {check") {
210            errors.push("Missing required import: 'check' from 'k6'".to_string());
211        }
212        if !script.contains("import { Rate, Trend") && !script.contains("import {Rate, Trend") {
213            errors.push("Missing required import: 'Rate, Trend' from 'k6/metrics'".to_string());
214        }
215
216        // Check for invalid metric names in Trend/Rate constructors
217        // k6 metric names must only contain ASCII letters, numbers, or underscores
218        // and start with a letter or underscore
219        let lines: Vec<&str> = script.lines().collect();
220        for (line_num, line) in lines.iter().enumerate() {
221            let trimmed = line.trim();
222
223            // Check for Trend/Rate constructors with invalid metric names
224            if trimmed.contains("new Trend(") || trimmed.contains("new Rate(") {
225                // Extract the metric name from the string literal
226                // Pattern: new Trend('metric_name') or new Rate("metric_name")
227                if let Some(start) = trimmed.find('\'') {
228                    if let Some(end) = trimmed[start + 1..].find('\'') {
229                        let metric_name = &trimmed[start + 1..start + 1 + end];
230                        if !Self::is_valid_k6_metric_name(metric_name) {
231                            errors.push(format!(
232                                "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
233                                line_num + 1,
234                                metric_name
235                            ));
236                        }
237                    }
238                } else if let Some(start) = trimmed.find('"') {
239                    if let Some(end) = trimmed[start + 1..].find('"') {
240                        let metric_name = &trimmed[start + 1..start + 1 + end];
241                        if !Self::is_valid_k6_metric_name(metric_name) {
242                            errors.push(format!(
243                                "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
244                                line_num + 1,
245                                metric_name
246                            ));
247                        }
248                    }
249                }
250            }
251
252            // Check for invalid JavaScript variable names (containing dots)
253            if trimmed.starts_with("const ") || trimmed.starts_with("let ") {
254                if let Some(equals_pos) = trimmed.find('=') {
255                    let var_decl = &trimmed[..equals_pos];
256                    // Check if variable name contains a dot (invalid identifier)
257                    // But exclude string literals
258                    if var_decl.contains('.')
259                        && !var_decl.contains("'")
260                        && !var_decl.contains("\"")
261                        && !var_decl.trim().starts_with("//")
262                    {
263                        errors.push(format!(
264                            "Line {}: Invalid JavaScript variable name with dot: {}. Variable names cannot contain dots.",
265                            line_num + 1,
266                            var_decl.trim()
267                        ));
268                    }
269                }
270            }
271        }
272
273        errors
274    }
275
276    /// Check if a string is a valid k6 metric name
277    ///
278    /// k6 metric names must:
279    /// - Only contain ASCII letters, numbers, or underscores
280    /// - Start with a letter or underscore (not a number)
281    /// - Be at most 128 characters
282    fn is_valid_k6_metric_name(name: &str) -> bool {
283        if name.is_empty() || name.len() > 128 {
284            return false;
285        }
286
287        let mut chars = name.chars();
288
289        // First character must be a letter or underscore
290        if let Some(first) = chars.next() {
291            if !first.is_ascii_alphabetic() && first != '_' {
292                return false;
293            }
294        }
295
296        // Remaining characters must be alphanumeric or underscore
297        for ch in chars {
298            if !ch.is_ascii_alphanumeric() && ch != '_' {
299                return false;
300            }
301        }
302
303        true
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_k6_config_creation() {
313        let config = K6Config {
314            target_url: "https://api.example.com".to_string(),
315            scenario: LoadScenario::RampUp,
316            duration_secs: 60,
317            max_vus: 10,
318            threshold_percentile: "p(95)".to_string(),
319            threshold_ms: 500,
320            max_error_rate: 0.05,
321            auth_header: None,
322            custom_headers: HashMap::new(),
323            skip_tls_verify: false,
324        };
325
326        assert_eq!(config.duration_secs, 60);
327        assert_eq!(config.max_vus, 10);
328    }
329
330    #[test]
331    fn test_script_generator_creation() {
332        let config = K6Config {
333            target_url: "https://api.example.com".to_string(),
334            scenario: LoadScenario::Constant,
335            duration_secs: 30,
336            max_vus: 5,
337            threshold_percentile: "p(95)".to_string(),
338            threshold_ms: 500,
339            max_error_rate: 0.05,
340            auth_header: None,
341            custom_headers: HashMap::new(),
342            skip_tls_verify: false,
343        };
344
345        let templates = vec![];
346        let generator = K6ScriptGenerator::new(config, templates);
347
348        assert_eq!(generator.templates.len(), 0);
349    }
350
351    #[test]
352    fn test_sanitize_js_identifier() {
353        // Test case from issue #79: names with dots
354        assert_eq!(
355            K6ScriptGenerator::sanitize_js_identifier("billing.subscriptions.v1"),
356            "billing_subscriptions_v1"
357        );
358
359        // Test other invalid characters
360        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("get user"), "get_user");
361
362        // Test names starting with numbers
363        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("123invalid"), "_123invalid");
364
365        // Test already valid identifiers
366        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("getUsers"), "getUsers");
367
368        // Test with multiple consecutive invalid chars
369        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test...name"), "test_name");
370
371        // Test empty string (should return default)
372        assert_eq!(K6ScriptGenerator::sanitize_js_identifier(""), "operation");
373
374        // Test with special characters
375        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test@name#value"), "test_name_value");
376    }
377
378    #[test]
379    fn test_script_generation_with_dots_in_name() {
380        use crate::spec_parser::ApiOperation;
381        use openapiv3::Operation;
382
383        // Create an operation with a name containing dots (like in issue #79)
384        let operation = ApiOperation {
385            method: "get".to_string(),
386            path: "/billing/subscriptions".to_string(),
387            operation: Operation::default(),
388            operation_id: Some("billing.subscriptions.v1".to_string()),
389        };
390
391        let template = RequestTemplate {
392            operation,
393            path_params: HashMap::new(),
394            query_params: HashMap::new(),
395            headers: HashMap::new(),
396            body: None,
397        };
398
399        let config = K6Config {
400            target_url: "https://api.example.com".to_string(),
401            scenario: LoadScenario::Constant,
402            duration_secs: 30,
403            max_vus: 5,
404            threshold_percentile: "p(95)".to_string(),
405            threshold_ms: 500,
406            max_error_rate: 0.05,
407            auth_header: None,
408            custom_headers: HashMap::new(),
409            skip_tls_verify: false,
410        };
411
412        let generator = K6ScriptGenerator::new(config, vec![template]);
413        let script = generator.generate().expect("Should generate script");
414
415        // Verify the script contains sanitized variable names (no dots in variable identifiers)
416        assert!(
417            script.contains("const billing_subscriptions_v1_latency"),
418            "Script should contain sanitized variable name for latency"
419        );
420        assert!(
421            script.contains("const billing_subscriptions_v1_errors"),
422            "Script should contain sanitized variable name for errors"
423        );
424
425        // Verify variable names do NOT contain dots (check the actual variable identifier, not string literals)
426        // The pattern "const billing.subscriptions" would indicate a variable name with dots
427        assert!(
428            !script.contains("const billing.subscriptions"),
429            "Script should not contain variable names with dots - this would cause 'Unexpected token .' error"
430        );
431
432        // Verify metric name strings are sanitized (no dots) - k6 requires valid metric names
433        // Metric names must only contain ASCII letters, numbers, or underscores
434        assert!(
435            script.contains("'billing_subscriptions_v1_latency'"),
436            "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
437        );
438        assert!(
439            script.contains("'billing_subscriptions_v1_errors'"),
440            "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
441        );
442
443        // Verify the original display name is still used in comments and strings (for readability)
444        assert!(
445            script.contains("billing.subscriptions.v1"),
446            "Script should contain original name in comments/strings for readability"
447        );
448
449        // Most importantly: verify the variable usage doesn't have dots
450        assert!(
451            script.contains("billing_subscriptions_v1_latency.add"),
452            "Variable usage should use sanitized name"
453        );
454        assert!(
455            script.contains("billing_subscriptions_v1_errors.add"),
456            "Variable usage should use sanitized name"
457        );
458    }
459
460    #[test]
461    fn test_validate_script_valid() {
462        let valid_script = r#"
463import http from 'k6/http';
464import { check, sleep } from 'k6';
465import { Rate, Trend } from 'k6/metrics';
466
467const test_latency = new Trend('test_latency');
468const test_errors = new Rate('test_errors');
469
470export default function() {
471    const res = http.get('https://example.com');
472    test_latency.add(res.timings.duration);
473    test_errors.add(res.status !== 200);
474}
475"#;
476
477        let errors = K6ScriptGenerator::validate_script(valid_script);
478        assert!(errors.is_empty(), "Valid script should have no validation errors");
479    }
480
481    #[test]
482    fn test_validate_script_invalid_metric_name() {
483        let invalid_script = r#"
484import http from 'k6/http';
485import { check, sleep } from 'k6';
486import { Rate, Trend } from 'k6/metrics';
487
488const test_latency = new Trend('test.latency');
489const test_errors = new Rate('test_errors');
490
491export default function() {
492    const res = http.get('https://example.com');
493    test_latency.add(res.timings.duration);
494}
495"#;
496
497        let errors = K6ScriptGenerator::validate_script(invalid_script);
498        assert!(
499            !errors.is_empty(),
500            "Script with invalid metric name should have validation errors"
501        );
502        assert!(
503            errors.iter().any(|e| e.contains("Invalid k6 metric name")),
504            "Should detect invalid metric name with dot"
505        );
506    }
507
508    #[test]
509    fn test_validate_script_missing_imports() {
510        let invalid_script = r#"
511const test_latency = new Trend('test_latency');
512export default function() {}
513"#;
514
515        let errors = K6ScriptGenerator::validate_script(invalid_script);
516        assert!(!errors.is_empty(), "Script missing imports should have validation errors");
517    }
518
519    #[test]
520    fn test_validate_script_metric_name_validation() {
521        // Test that validate_script correctly identifies invalid metric names
522        // Valid metric names should pass
523        let valid_script = r#"
524import http from 'k6/http';
525import { check, sleep } from 'k6';
526import { Rate, Trend } from 'k6/metrics';
527const test_latency = new Trend('test_latency');
528const test_errors = new Rate('test_errors');
529export default function() {}
530"#;
531        let errors = K6ScriptGenerator::validate_script(valid_script);
532        assert!(errors.is_empty(), "Valid metric names should pass validation");
533
534        // Invalid metric names should fail
535        let invalid_cases = vec![
536            ("test.latency", "dot in metric name"),
537            ("123test", "starts with number"),
538            ("test-latency", "hyphen in metric name"),
539            ("test@latency", "special character"),
540        ];
541
542        for (invalid_name, description) in invalid_cases {
543            let script = format!(
544                r#"
545import http from 'k6/http';
546import {{ check, sleep }} from 'k6';
547import {{ Rate, Trend }} from 'k6/metrics';
548const test_latency = new Trend('{}');
549export default function() {{}}
550"#,
551                invalid_name
552            );
553            let errors = K6ScriptGenerator::validate_script(&script);
554            assert!(
555                !errors.is_empty(),
556                "Metric name '{}' ({}) should fail validation",
557                invalid_name,
558                description
559            );
560        }
561    }
562
563    #[test]
564    fn test_skip_tls_verify_with_body() {
565        use crate::spec_parser::ApiOperation;
566        use openapiv3::Operation;
567        use serde_json::json;
568
569        // Create an operation with a request body
570        let operation = ApiOperation {
571            method: "post".to_string(),
572            path: "/api/users".to_string(),
573            operation: Operation::default(),
574            operation_id: Some("createUser".to_string()),
575        };
576
577        let template = RequestTemplate {
578            operation,
579            path_params: HashMap::new(),
580            query_params: HashMap::new(),
581            headers: HashMap::new(),
582            body: Some(json!({"name": "test"})),
583        };
584
585        let config = K6Config {
586            target_url: "https://api.example.com".to_string(),
587            scenario: LoadScenario::Constant,
588            duration_secs: 30,
589            max_vus: 5,
590            threshold_percentile: "p(95)".to_string(),
591            threshold_ms: 500,
592            max_error_rate: 0.05,
593            auth_header: None,
594            custom_headers: HashMap::new(),
595            skip_tls_verify: true,
596        };
597
598        let generator = K6ScriptGenerator::new(config, vec![template]);
599        let script = generator.generate().expect("Should generate script");
600
601        // Verify the script includes TLS skip option for requests with body
602        assert!(
603            script.contains("insecureSkipTLSVerify: true"),
604            "Script should include insecureSkipTLSVerify option when skip_tls_verify is true"
605        );
606    }
607
608    #[test]
609    fn test_skip_tls_verify_without_body() {
610        use crate::spec_parser::ApiOperation;
611        use openapiv3::Operation;
612
613        // Create an operation without a request body
614        let operation = ApiOperation {
615            method: "get".to_string(),
616            path: "/api/users".to_string(),
617            operation: Operation::default(),
618            operation_id: Some("getUsers".to_string()),
619        };
620
621        let template = RequestTemplate {
622            operation,
623            path_params: HashMap::new(),
624            query_params: HashMap::new(),
625            headers: HashMap::new(),
626            body: None,
627        };
628
629        let config = K6Config {
630            target_url: "https://api.example.com".to_string(),
631            scenario: LoadScenario::Constant,
632            duration_secs: 30,
633            max_vus: 5,
634            threshold_percentile: "p(95)".to_string(),
635            threshold_ms: 500,
636            max_error_rate: 0.05,
637            auth_header: None,
638            custom_headers: HashMap::new(),
639            skip_tls_verify: true,
640        };
641
642        let generator = K6ScriptGenerator::new(config, vec![template]);
643        let script = generator.generate().expect("Should generate script");
644
645        // Verify the script includes TLS skip option for requests without body
646        assert!(
647            script.contains("insecureSkipTLSVerify: true"),
648            "Script should include insecureSkipTLSVerify option when skip_tls_verify is true (no body)"
649        );
650    }
651
652    #[test]
653    fn test_no_skip_tls_verify() {
654        use crate::spec_parser::ApiOperation;
655        use openapiv3::Operation;
656
657        // Create an operation
658        let operation = ApiOperation {
659            method: "get".to_string(),
660            path: "/api/users".to_string(),
661            operation: Operation::default(),
662            operation_id: Some("getUsers".to_string()),
663        };
664
665        let template = RequestTemplate {
666            operation,
667            path_params: HashMap::new(),
668            query_params: HashMap::new(),
669            headers: HashMap::new(),
670            body: None,
671        };
672
673        let config = K6Config {
674            target_url: "https://api.example.com".to_string(),
675            scenario: LoadScenario::Constant,
676            duration_secs: 30,
677            max_vus: 5,
678            threshold_percentile: "p(95)".to_string(),
679            threshold_ms: 500,
680            max_error_rate: 0.05,
681            auth_header: None,
682            custom_headers: HashMap::new(),
683            skip_tls_verify: false,
684        };
685
686        let generator = K6ScriptGenerator::new(config, vec![template]);
687        let script = generator.generate().expect("Should generate script");
688
689        // Verify the script does NOT include TLS skip option when skip_tls_verify is false
690        assert!(
691            !script.contains("insecureSkipTLSVerify"),
692            "Script should NOT include insecureSkipTLSVerify option when skip_tls_verify is false"
693        );
694    }
695
696    #[test]
697    fn test_skip_tls_verify_multiple_operations() {
698        use crate::spec_parser::ApiOperation;
699        use openapiv3::Operation;
700        use serde_json::json;
701
702        // Create multiple operations - one with body, one without
703        let operation1 = ApiOperation {
704            method: "get".to_string(),
705            path: "/api/users".to_string(),
706            operation: Operation::default(),
707            operation_id: Some("getUsers".to_string()),
708        };
709
710        let operation2 = ApiOperation {
711            method: "post".to_string(),
712            path: "/api/users".to_string(),
713            operation: Operation::default(),
714            operation_id: Some("createUser".to_string()),
715        };
716
717        let template1 = RequestTemplate {
718            operation: operation1,
719            path_params: HashMap::new(),
720            query_params: HashMap::new(),
721            headers: HashMap::new(),
722            body: None,
723        };
724
725        let template2 = RequestTemplate {
726            operation: operation2,
727            path_params: HashMap::new(),
728            query_params: HashMap::new(),
729            headers: HashMap::new(),
730            body: Some(json!({"name": "test"})),
731        };
732
733        let config = K6Config {
734            target_url: "https://api.example.com".to_string(),
735            scenario: LoadScenario::Constant,
736            duration_secs: 30,
737            max_vus: 5,
738            threshold_percentile: "p(95)".to_string(),
739            threshold_ms: 500,
740            max_error_rate: 0.05,
741            auth_header: None,
742            custom_headers: HashMap::new(),
743            skip_tls_verify: true,
744        };
745
746        let generator = K6ScriptGenerator::new(config, vec![template1, template2]);
747        let script = generator.generate().expect("Should generate script");
748
749        // Verify the script includes TLS skip option ONCE in global options
750        // (k6 only supports insecureSkipTLSVerify as a global option, not per-request)
751        let skip_count = script.matches("insecureSkipTLSVerify: true").count();
752        assert_eq!(
753            skip_count, 1,
754            "Script should include insecureSkipTLSVerify exactly once in global options (not per-request)"
755        );
756
757        // Verify it appears in the options block, before scenarios
758        let options_start = script.find("export const options = {").expect("Should have options");
759        let scenarios_start = script.find("scenarios:").expect("Should have scenarios");
760        let options_prefix = &script[options_start..scenarios_start];
761        assert!(
762            options_prefix.contains("insecureSkipTLSVerify: true"),
763            "insecureSkipTLSVerify should be in global options block"
764        );
765    }
766
767    #[test]
768    fn test_dynamic_params_in_body() {
769        use crate::spec_parser::ApiOperation;
770        use openapiv3::Operation;
771        use serde_json::json;
772
773        // Create an operation with dynamic placeholders in the body
774        let operation = ApiOperation {
775            method: "post".to_string(),
776            path: "/api/resources".to_string(),
777            operation: Operation::default(),
778            operation_id: Some("createResource".to_string()),
779        };
780
781        let template = RequestTemplate {
782            operation,
783            path_params: HashMap::new(),
784            query_params: HashMap::new(),
785            headers: HashMap::new(),
786            body: Some(json!({
787                "name": "load-test-${__VU}",
788                "iteration": "${__ITER}"
789            })),
790        };
791
792        let config = K6Config {
793            target_url: "https://api.example.com".to_string(),
794            scenario: LoadScenario::Constant,
795            duration_secs: 30,
796            max_vus: 5,
797            threshold_percentile: "p(95)".to_string(),
798            threshold_ms: 500,
799            max_error_rate: 0.05,
800            auth_header: None,
801            custom_headers: HashMap::new(),
802            skip_tls_verify: false,
803        };
804
805        let generator = K6ScriptGenerator::new(config, vec![template]);
806        let script = generator.generate().expect("Should generate script");
807
808        // Verify the script contains dynamic body indication
809        assert!(
810            script.contains("Dynamic body with runtime placeholders"),
811            "Script should contain comment about dynamic body"
812        );
813
814        // Verify the script contains the __VU variable reference
815        assert!(
816            script.contains("__VU"),
817            "Script should contain __VU reference for dynamic VU-based values"
818        );
819
820        // Verify the script contains the __ITER variable reference
821        assert!(
822            script.contains("__ITER"),
823            "Script should contain __ITER reference for dynamic iteration values"
824        );
825    }
826
827    #[test]
828    fn test_dynamic_params_with_uuid() {
829        use crate::spec_parser::ApiOperation;
830        use openapiv3::Operation;
831        use serde_json::json;
832
833        // Create an operation with UUID placeholder
834        let operation = ApiOperation {
835            method: "post".to_string(),
836            path: "/api/resources".to_string(),
837            operation: Operation::default(),
838            operation_id: Some("createResource".to_string()),
839        };
840
841        let template = RequestTemplate {
842            operation,
843            path_params: HashMap::new(),
844            query_params: HashMap::new(),
845            headers: HashMap::new(),
846            body: Some(json!({
847                "id": "${__UUID}"
848            })),
849        };
850
851        let config = K6Config {
852            target_url: "https://api.example.com".to_string(),
853            scenario: LoadScenario::Constant,
854            duration_secs: 30,
855            max_vus: 5,
856            threshold_percentile: "p(95)".to_string(),
857            threshold_ms: 500,
858            max_error_rate: 0.05,
859            auth_header: None,
860            custom_headers: HashMap::new(),
861            skip_tls_verify: false,
862        };
863
864        let generator = K6ScriptGenerator::new(config, vec![template]);
865        let script = generator.generate().expect("Should generate script");
866
867        // Verify the script includes the crypto import for UUID
868        assert!(
869            script.contains("import { crypto }") || script.contains("webcrypto"),
870            "Script should include crypto import when UUID placeholder is used"
871        );
872
873        // Verify crypto.randomUUID() is in the generated code
874        assert!(
875            script.contains("crypto.randomUUID()"),
876            "Script should contain crypto.randomUUID() for UUID placeholder"
877        );
878    }
879
880    #[test]
881    fn test_dynamic_params_with_counter() {
882        use crate::spec_parser::ApiOperation;
883        use openapiv3::Operation;
884        use serde_json::json;
885
886        // Create an operation with COUNTER placeholder
887        let operation = ApiOperation {
888            method: "post".to_string(),
889            path: "/api/resources".to_string(),
890            operation: Operation::default(),
891            operation_id: Some("createResource".to_string()),
892        };
893
894        let template = RequestTemplate {
895            operation,
896            path_params: HashMap::new(),
897            query_params: HashMap::new(),
898            headers: HashMap::new(),
899            body: Some(json!({
900                "sequence": "${__COUNTER}"
901            })),
902        };
903
904        let config = K6Config {
905            target_url: "https://api.example.com".to_string(),
906            scenario: LoadScenario::Constant,
907            duration_secs: 30,
908            max_vus: 5,
909            threshold_percentile: "p(95)".to_string(),
910            threshold_ms: 500,
911            max_error_rate: 0.05,
912            auth_header: None,
913            custom_headers: HashMap::new(),
914            skip_tls_verify: false,
915        };
916
917        let generator = K6ScriptGenerator::new(config, vec![template]);
918        let script = generator.generate().expect("Should generate script");
919
920        // Verify the script includes the global counter initialization
921        assert!(
922            script.contains("let globalCounter = 0"),
923            "Script should include globalCounter initialization when COUNTER placeholder is used"
924        );
925
926        // Verify globalCounter++ is in the generated code
927        assert!(
928            script.contains("globalCounter++"),
929            "Script should contain globalCounter++ for COUNTER placeholder"
930        );
931    }
932
933    #[test]
934    fn test_static_body_no_dynamic_marker() {
935        use crate::spec_parser::ApiOperation;
936        use openapiv3::Operation;
937        use serde_json::json;
938
939        // Create an operation with static body (no placeholders)
940        let operation = ApiOperation {
941            method: "post".to_string(),
942            path: "/api/resources".to_string(),
943            operation: Operation::default(),
944            operation_id: Some("createResource".to_string()),
945        };
946
947        let template = RequestTemplate {
948            operation,
949            path_params: HashMap::new(),
950            query_params: HashMap::new(),
951            headers: HashMap::new(),
952            body: Some(json!({
953                "name": "static-value",
954                "count": 42
955            })),
956        };
957
958        let config = K6Config {
959            target_url: "https://api.example.com".to_string(),
960            scenario: LoadScenario::Constant,
961            duration_secs: 30,
962            max_vus: 5,
963            threshold_percentile: "p(95)".to_string(),
964            threshold_ms: 500,
965            max_error_rate: 0.05,
966            auth_header: None,
967            custom_headers: HashMap::new(),
968            skip_tls_verify: false,
969        };
970
971        let generator = K6ScriptGenerator::new(config, vec![template]);
972        let script = generator.generate().expect("Should generate script");
973
974        // Verify the script does NOT contain dynamic body marker
975        assert!(
976            !script.contains("Dynamic body with runtime placeholders"),
977            "Script should NOT contain dynamic body comment for static body"
978        );
979
980        // Verify it does NOT include unnecessary crypto imports
981        assert!(
982            !script.contains("webcrypto"),
983            "Script should NOT include webcrypto import for static body"
984        );
985
986        // Verify it does NOT include global counter
987        assert!(
988            !script.contains("let globalCounter"),
989            "Script should NOT include globalCounter for static body"
990        );
991    }
992}