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