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    pub 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        // Test CRUD flow names with dots (issue #79 follow-up)
378        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.list"), "plans_list");
379        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.create"), "plans_create");
380        assert_eq!(
381            K6ScriptGenerator::sanitize_js_identifier("plans.update-pricing-schemes"),
382            "plans_update_pricing_schemes"
383        );
384        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("users CRUD"), "users_CRUD");
385    }
386
387    #[test]
388    fn test_script_generation_with_dots_in_name() {
389        use crate::spec_parser::ApiOperation;
390        use openapiv3::Operation;
391
392        // Create an operation with a name containing dots (like in issue #79)
393        let operation = ApiOperation {
394            method: "get".to_string(),
395            path: "/billing/subscriptions".to_string(),
396            operation: Operation::default(),
397            operation_id: Some("billing.subscriptions.v1".to_string()),
398        };
399
400        let template = RequestTemplate {
401            operation,
402            path_params: HashMap::new(),
403            query_params: HashMap::new(),
404            headers: HashMap::new(),
405            body: None,
406        };
407
408        let config = K6Config {
409            target_url: "https://api.example.com".to_string(),
410            scenario: LoadScenario::Constant,
411            duration_secs: 30,
412            max_vus: 5,
413            threshold_percentile: "p(95)".to_string(),
414            threshold_ms: 500,
415            max_error_rate: 0.05,
416            auth_header: None,
417            custom_headers: HashMap::new(),
418            skip_tls_verify: false,
419        };
420
421        let generator = K6ScriptGenerator::new(config, vec![template]);
422        let script = generator.generate().expect("Should generate script");
423
424        // Verify the script contains sanitized variable names (no dots in variable identifiers)
425        assert!(
426            script.contains("const billing_subscriptions_v1_latency"),
427            "Script should contain sanitized variable name for latency"
428        );
429        assert!(
430            script.contains("const billing_subscriptions_v1_errors"),
431            "Script should contain sanitized variable name for errors"
432        );
433
434        // Verify variable names do NOT contain dots (check the actual variable identifier, not string literals)
435        // The pattern "const billing.subscriptions" would indicate a variable name with dots
436        assert!(
437            !script.contains("const billing.subscriptions"),
438            "Script should not contain variable names with dots - this would cause 'Unexpected token .' error"
439        );
440
441        // Verify metric name strings are sanitized (no dots) - k6 requires valid metric names
442        // Metric names must only contain ASCII letters, numbers, or underscores
443        assert!(
444            script.contains("'billing_subscriptions_v1_latency'"),
445            "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
446        );
447        assert!(
448            script.contains("'billing_subscriptions_v1_errors'"),
449            "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
450        );
451
452        // Verify the original display name is still used in comments and strings (for readability)
453        assert!(
454            script.contains("billing.subscriptions.v1"),
455            "Script should contain original name in comments/strings for readability"
456        );
457
458        // Most importantly: verify the variable usage doesn't have dots
459        assert!(
460            script.contains("billing_subscriptions_v1_latency.add"),
461            "Variable usage should use sanitized name"
462        );
463        assert!(
464            script.contains("billing_subscriptions_v1_errors.add"),
465            "Variable usage should use sanitized name"
466        );
467    }
468
469    #[test]
470    fn test_validate_script_valid() {
471        let valid_script = r#"
472import http from 'k6/http';
473import { check, sleep } from 'k6';
474import { Rate, Trend } from 'k6/metrics';
475
476const test_latency = new Trend('test_latency');
477const test_errors = new Rate('test_errors');
478
479export default function() {
480    const res = http.get('https://example.com');
481    test_latency.add(res.timings.duration);
482    test_errors.add(res.status !== 200);
483}
484"#;
485
486        let errors = K6ScriptGenerator::validate_script(valid_script);
487        assert!(errors.is_empty(), "Valid script should have no validation errors");
488    }
489
490    #[test]
491    fn test_validate_script_invalid_metric_name() {
492        let invalid_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}
504"#;
505
506        let errors = K6ScriptGenerator::validate_script(invalid_script);
507        assert!(
508            !errors.is_empty(),
509            "Script with invalid metric name should have validation errors"
510        );
511        assert!(
512            errors.iter().any(|e| e.contains("Invalid k6 metric name")),
513            "Should detect invalid metric name with dot"
514        );
515    }
516
517    #[test]
518    fn test_validate_script_missing_imports() {
519        let invalid_script = r#"
520const test_latency = new Trend('test_latency');
521export default function() {}
522"#;
523
524        let errors = K6ScriptGenerator::validate_script(invalid_script);
525        assert!(!errors.is_empty(), "Script missing imports should have validation errors");
526    }
527
528    #[test]
529    fn test_validate_script_metric_name_validation() {
530        // Test that validate_script correctly identifies invalid metric names
531        // Valid metric names should pass
532        let valid_script = r#"
533import http from 'k6/http';
534import { check, sleep } from 'k6';
535import { Rate, Trend } from 'k6/metrics';
536const test_latency = new Trend('test_latency');
537const test_errors = new Rate('test_errors');
538export default function() {}
539"#;
540        let errors = K6ScriptGenerator::validate_script(valid_script);
541        assert!(errors.is_empty(), "Valid metric names should pass validation");
542
543        // Invalid metric names should fail
544        let invalid_cases = vec![
545            ("test.latency", "dot in metric name"),
546            ("123test", "starts with number"),
547            ("test-latency", "hyphen in metric name"),
548            ("test@latency", "special character"),
549        ];
550
551        for (invalid_name, description) in invalid_cases {
552            let script = format!(
553                r#"
554import http from 'k6/http';
555import {{ check, sleep }} from 'k6';
556import {{ Rate, Trend }} from 'k6/metrics';
557const test_latency = new Trend('{}');
558export default function() {{}}
559"#,
560                invalid_name
561            );
562            let errors = K6ScriptGenerator::validate_script(&script);
563            assert!(
564                !errors.is_empty(),
565                "Metric name '{}' ({}) should fail validation",
566                invalid_name,
567                description
568            );
569        }
570    }
571
572    #[test]
573    fn test_skip_tls_verify_with_body() {
574        use crate::spec_parser::ApiOperation;
575        use openapiv3::Operation;
576        use serde_json::json;
577
578        // Create an operation with a request body
579        let operation = ApiOperation {
580            method: "post".to_string(),
581            path: "/api/users".to_string(),
582            operation: Operation::default(),
583            operation_id: Some("createUser".to_string()),
584        };
585
586        let template = RequestTemplate {
587            operation,
588            path_params: HashMap::new(),
589            query_params: HashMap::new(),
590            headers: HashMap::new(),
591            body: Some(json!({"name": "test"})),
592        };
593
594        let config = K6Config {
595            target_url: "https://api.example.com".to_string(),
596            scenario: LoadScenario::Constant,
597            duration_secs: 30,
598            max_vus: 5,
599            threshold_percentile: "p(95)".to_string(),
600            threshold_ms: 500,
601            max_error_rate: 0.05,
602            auth_header: None,
603            custom_headers: HashMap::new(),
604            skip_tls_verify: true,
605        };
606
607        let generator = K6ScriptGenerator::new(config, vec![template]);
608        let script = generator.generate().expect("Should generate script");
609
610        // Verify the script includes TLS skip option for requests with body
611        assert!(
612            script.contains("insecureSkipTLSVerify: true"),
613            "Script should include insecureSkipTLSVerify option when skip_tls_verify is true"
614        );
615    }
616
617    #[test]
618    fn test_skip_tls_verify_without_body() {
619        use crate::spec_parser::ApiOperation;
620        use openapiv3::Operation;
621
622        // Create an operation without a request body
623        let operation = ApiOperation {
624            method: "get".to_string(),
625            path: "/api/users".to_string(),
626            operation: Operation::default(),
627            operation_id: Some("getUsers".to_string()),
628        };
629
630        let template = RequestTemplate {
631            operation,
632            path_params: HashMap::new(),
633            query_params: HashMap::new(),
634            headers: HashMap::new(),
635            body: None,
636        };
637
638        let config = K6Config {
639            target_url: "https://api.example.com".to_string(),
640            scenario: LoadScenario::Constant,
641            duration_secs: 30,
642            max_vus: 5,
643            threshold_percentile: "p(95)".to_string(),
644            threshold_ms: 500,
645            max_error_rate: 0.05,
646            auth_header: None,
647            custom_headers: HashMap::new(),
648            skip_tls_verify: true,
649        };
650
651        let generator = K6ScriptGenerator::new(config, vec![template]);
652        let script = generator.generate().expect("Should generate script");
653
654        // Verify the script includes TLS skip option for requests without body
655        assert!(
656            script.contains("insecureSkipTLSVerify: true"),
657            "Script should include insecureSkipTLSVerify option when skip_tls_verify is true (no body)"
658        );
659    }
660
661    #[test]
662    fn test_no_skip_tls_verify() {
663        use crate::spec_parser::ApiOperation;
664        use openapiv3::Operation;
665
666        // Create an operation
667        let operation = ApiOperation {
668            method: "get".to_string(),
669            path: "/api/users".to_string(),
670            operation: Operation::default(),
671            operation_id: Some("getUsers".to_string()),
672        };
673
674        let template = RequestTemplate {
675            operation,
676            path_params: HashMap::new(),
677            query_params: HashMap::new(),
678            headers: HashMap::new(),
679            body: None,
680        };
681
682        let config = K6Config {
683            target_url: "https://api.example.com".to_string(),
684            scenario: LoadScenario::Constant,
685            duration_secs: 30,
686            max_vus: 5,
687            threshold_percentile: "p(95)".to_string(),
688            threshold_ms: 500,
689            max_error_rate: 0.05,
690            auth_header: None,
691            custom_headers: HashMap::new(),
692            skip_tls_verify: false,
693        };
694
695        let generator = K6ScriptGenerator::new(config, vec![template]);
696        let script = generator.generate().expect("Should generate script");
697
698        // Verify the script does NOT include TLS skip option when skip_tls_verify is false
699        assert!(
700            !script.contains("insecureSkipTLSVerify"),
701            "Script should NOT include insecureSkipTLSVerify option when skip_tls_verify is false"
702        );
703    }
704
705    #[test]
706    fn test_skip_tls_verify_multiple_operations() {
707        use crate::spec_parser::ApiOperation;
708        use openapiv3::Operation;
709        use serde_json::json;
710
711        // Create multiple operations - one with body, one without
712        let operation1 = ApiOperation {
713            method: "get".to_string(),
714            path: "/api/users".to_string(),
715            operation: Operation::default(),
716            operation_id: Some("getUsers".to_string()),
717        };
718
719        let operation2 = ApiOperation {
720            method: "post".to_string(),
721            path: "/api/users".to_string(),
722            operation: Operation::default(),
723            operation_id: Some("createUser".to_string()),
724        };
725
726        let template1 = RequestTemplate {
727            operation: operation1,
728            path_params: HashMap::new(),
729            query_params: HashMap::new(),
730            headers: HashMap::new(),
731            body: None,
732        };
733
734        let template2 = RequestTemplate {
735            operation: operation2,
736            path_params: HashMap::new(),
737            query_params: HashMap::new(),
738            headers: HashMap::new(),
739            body: Some(json!({"name": "test"})),
740        };
741
742        let config = K6Config {
743            target_url: "https://api.example.com".to_string(),
744            scenario: LoadScenario::Constant,
745            duration_secs: 30,
746            max_vus: 5,
747            threshold_percentile: "p(95)".to_string(),
748            threshold_ms: 500,
749            max_error_rate: 0.05,
750            auth_header: None,
751            custom_headers: HashMap::new(),
752            skip_tls_verify: true,
753        };
754
755        let generator = K6ScriptGenerator::new(config, vec![template1, template2]);
756        let script = generator.generate().expect("Should generate script");
757
758        // Verify the script includes TLS skip option ONCE in global options
759        // (k6 only supports insecureSkipTLSVerify as a global option, not per-request)
760        let skip_count = script.matches("insecureSkipTLSVerify: true").count();
761        assert_eq!(
762            skip_count, 1,
763            "Script should include insecureSkipTLSVerify exactly once in global options (not per-request)"
764        );
765
766        // Verify it appears in the options block, before scenarios
767        let options_start = script.find("export const options = {").expect("Should have options");
768        let scenarios_start = script.find("scenarios:").expect("Should have scenarios");
769        let options_prefix = &script[options_start..scenarios_start];
770        assert!(
771            options_prefix.contains("insecureSkipTLSVerify: true"),
772            "insecureSkipTLSVerify should be in global options block"
773        );
774    }
775
776    #[test]
777    fn test_dynamic_params_in_body() {
778        use crate::spec_parser::ApiOperation;
779        use openapiv3::Operation;
780        use serde_json::json;
781
782        // Create an operation with dynamic placeholders in the body
783        let operation = ApiOperation {
784            method: "post".to_string(),
785            path: "/api/resources".to_string(),
786            operation: Operation::default(),
787            operation_id: Some("createResource".to_string()),
788        };
789
790        let template = RequestTemplate {
791            operation,
792            path_params: HashMap::new(),
793            query_params: HashMap::new(),
794            headers: HashMap::new(),
795            body: Some(json!({
796                "name": "load-test-${__VU}",
797                "iteration": "${__ITER}"
798            })),
799        };
800
801        let config = K6Config {
802            target_url: "https://api.example.com".to_string(),
803            scenario: LoadScenario::Constant,
804            duration_secs: 30,
805            max_vus: 5,
806            threshold_percentile: "p(95)".to_string(),
807            threshold_ms: 500,
808            max_error_rate: 0.05,
809            auth_header: None,
810            custom_headers: HashMap::new(),
811            skip_tls_verify: false,
812        };
813
814        let generator = K6ScriptGenerator::new(config, vec![template]);
815        let script = generator.generate().expect("Should generate script");
816
817        // Verify the script contains dynamic body indication
818        assert!(
819            script.contains("Dynamic body with runtime placeholders"),
820            "Script should contain comment about dynamic body"
821        );
822
823        // Verify the script contains the __VU variable reference
824        assert!(
825            script.contains("__VU"),
826            "Script should contain __VU reference for dynamic VU-based values"
827        );
828
829        // Verify the script contains the __ITER variable reference
830        assert!(
831            script.contains("__ITER"),
832            "Script should contain __ITER reference for dynamic iteration values"
833        );
834    }
835
836    #[test]
837    fn test_dynamic_params_with_uuid() {
838        use crate::spec_parser::ApiOperation;
839        use openapiv3::Operation;
840        use serde_json::json;
841
842        // Create an operation with UUID placeholder
843        let operation = ApiOperation {
844            method: "post".to_string(),
845            path: "/api/resources".to_string(),
846            operation: Operation::default(),
847            operation_id: Some("createResource".to_string()),
848        };
849
850        let template = RequestTemplate {
851            operation,
852            path_params: HashMap::new(),
853            query_params: HashMap::new(),
854            headers: HashMap::new(),
855            body: Some(json!({
856                "id": "${__UUID}"
857            })),
858        };
859
860        let config = K6Config {
861            target_url: "https://api.example.com".to_string(),
862            scenario: LoadScenario::Constant,
863            duration_secs: 30,
864            max_vus: 5,
865            threshold_percentile: "p(95)".to_string(),
866            threshold_ms: 500,
867            max_error_rate: 0.05,
868            auth_header: None,
869            custom_headers: HashMap::new(),
870            skip_tls_verify: false,
871        };
872
873        let generator = K6ScriptGenerator::new(config, vec![template]);
874        let script = generator.generate().expect("Should generate script");
875
876        // As of k6 v1.0.0+, webcrypto is globally available - no import needed
877        // Verify the script does NOT include the old experimental webcrypto import
878        assert!(
879            !script.contains("k6/experimental/webcrypto"),
880            "Script should NOT include deprecated k6/experimental/webcrypto import"
881        );
882
883        // Verify crypto.randomUUID() is in the generated code
884        assert!(
885            script.contains("crypto.randomUUID()"),
886            "Script should contain crypto.randomUUID() for UUID placeholder"
887        );
888    }
889
890    #[test]
891    fn test_dynamic_params_with_counter() {
892        use crate::spec_parser::ApiOperation;
893        use openapiv3::Operation;
894        use serde_json::json;
895
896        // Create an operation with COUNTER placeholder
897        let operation = ApiOperation {
898            method: "post".to_string(),
899            path: "/api/resources".to_string(),
900            operation: Operation::default(),
901            operation_id: Some("createResource".to_string()),
902        };
903
904        let template = RequestTemplate {
905            operation,
906            path_params: HashMap::new(),
907            query_params: HashMap::new(),
908            headers: HashMap::new(),
909            body: Some(json!({
910                "sequence": "${__COUNTER}"
911            })),
912        };
913
914        let config = K6Config {
915            target_url: "https://api.example.com".to_string(),
916            scenario: LoadScenario::Constant,
917            duration_secs: 30,
918            max_vus: 5,
919            threshold_percentile: "p(95)".to_string(),
920            threshold_ms: 500,
921            max_error_rate: 0.05,
922            auth_header: None,
923            custom_headers: HashMap::new(),
924            skip_tls_verify: false,
925        };
926
927        let generator = K6ScriptGenerator::new(config, vec![template]);
928        let script = generator.generate().expect("Should generate script");
929
930        // Verify the script includes the global counter initialization
931        assert!(
932            script.contains("let globalCounter = 0"),
933            "Script should include globalCounter initialization when COUNTER placeholder is used"
934        );
935
936        // Verify globalCounter++ is in the generated code
937        assert!(
938            script.contains("globalCounter++"),
939            "Script should contain globalCounter++ for COUNTER placeholder"
940        );
941    }
942
943    #[test]
944    fn test_static_body_no_dynamic_marker() {
945        use crate::spec_parser::ApiOperation;
946        use openapiv3::Operation;
947        use serde_json::json;
948
949        // Create an operation with static body (no placeholders)
950        let operation = ApiOperation {
951            method: "post".to_string(),
952            path: "/api/resources".to_string(),
953            operation: Operation::default(),
954            operation_id: Some("createResource".to_string()),
955        };
956
957        let template = RequestTemplate {
958            operation,
959            path_params: HashMap::new(),
960            query_params: HashMap::new(),
961            headers: HashMap::new(),
962            body: Some(json!({
963                "name": "static-value",
964                "count": 42
965            })),
966        };
967
968        let config = K6Config {
969            target_url: "https://api.example.com".to_string(),
970            scenario: LoadScenario::Constant,
971            duration_secs: 30,
972            max_vus: 5,
973            threshold_percentile: "p(95)".to_string(),
974            threshold_ms: 500,
975            max_error_rate: 0.05,
976            auth_header: None,
977            custom_headers: HashMap::new(),
978            skip_tls_verify: false,
979        };
980
981        let generator = K6ScriptGenerator::new(config, vec![template]);
982        let script = generator.generate().expect("Should generate script");
983
984        // Verify the script does NOT contain dynamic body marker
985        assert!(
986            !script.contains("Dynamic body with runtime placeholders"),
987            "Script should NOT contain dynamic body comment for static body"
988        );
989
990        // Verify it does NOT include unnecessary crypto imports
991        assert!(
992            !script.contains("webcrypto"),
993            "Script should NOT include webcrypto import for static body"
994        );
995
996        // Verify it does NOT include global counter
997        assert!(
998            !script.contains("let globalCounter"),
999            "Script should NOT include globalCounter for static body"
1000        );
1001    }
1002}