mockforge_bench/
k6_gen.rs

1//! k6 script generation for load testing real endpoints
2
3use crate::error::{BenchError, Result};
4use crate::request_gen::RequestTemplate;
5use crate::scenarios::LoadScenario;
6use handlebars::Handlebars;
7use serde_json::{json, Value};
8use std::collections::HashMap;
9
10/// Configuration for k6 script generation
11pub struct K6Config {
12    pub target_url: String,
13    pub scenario: LoadScenario,
14    pub duration_secs: u64,
15    pub max_vus: u32,
16    pub threshold_percentile: String,
17    pub threshold_ms: u64,
18    pub max_error_rate: f64,
19    pub auth_header: Option<String>,
20    pub custom_headers: HashMap<String, String>,
21    pub skip_tls_verify: bool,
22}
23
24/// Generate k6 load test script
25pub struct K6ScriptGenerator {
26    config: K6Config,
27    templates: Vec<RequestTemplate>,
28}
29
30impl K6ScriptGenerator {
31    /// Create a new k6 script generator
32    pub fn new(config: K6Config, templates: Vec<RequestTemplate>) -> Self {
33        Self { config, templates }
34    }
35
36    /// Generate the k6 script
37    pub fn generate(&self) -> Result<String> {
38        let handlebars = Handlebars::new();
39
40        let template = include_str!("templates/k6_script.hbs");
41
42        let data = self.build_template_data()?;
43
44        handlebars
45            .render_template(template, &data)
46            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))
47    }
48
49    /// Sanitize a name to be a valid JavaScript identifier
50    ///
51    /// Replaces invalid characters (dots, spaces, special chars) with underscores.
52    /// Ensures the identifier starts with a letter or underscore (not a number).
53    ///
54    /// Examples:
55    /// - "billing.subscriptions.v1" -> "billing_subscriptions_v1"
56    /// - "get user" -> "get_user"
57    /// - "123invalid" -> "_123invalid"
58    fn sanitize_js_identifier(name: &str) -> String {
59        let mut result = String::new();
60        let mut chars = name.chars().peekable();
61
62        // Ensure it starts with a letter or underscore (not a number)
63        if let Some(&first) = chars.peek() {
64            if first.is_ascii_digit() {
65                result.push('_');
66            }
67        }
68
69        for ch in chars {
70            if ch.is_ascii_alphanumeric() || ch == '_' {
71                result.push(ch);
72            } else {
73                // Replace invalid characters with underscore
74                // Avoid consecutive underscores
75                if !result.ends_with('_') {
76                    result.push('_');
77                }
78            }
79        }
80
81        // Remove trailing underscores
82        result = result.trim_end_matches('_').to_string();
83
84        // If empty after sanitization, use a default name
85        if result.is_empty() {
86            result = "operation".to_string();
87        }
88
89        result
90    }
91
92    /// Build the template data for rendering
93    fn build_template_data(&self) -> Result<Value> {
94        let stages = self
95            .config
96            .scenario
97            .generate_stages(self.config.duration_secs, self.config.max_vus);
98
99        let operations = self
100            .templates
101            .iter()
102            .enumerate()
103            .map(|(idx, template)| {
104                let display_name = template.operation.display_name();
105                let sanitized_name = Self::sanitize_js_identifier(&display_name);
106                // metric_name must also be sanitized for k6 metric name validation
107                // k6 metric names must only contain ASCII letters, numbers, or underscores
108                let metric_name = sanitized_name.clone();
109                // k6 uses 'del' instead of 'delete' for HTTP DELETE method
110                let k6_method = match template.operation.method.to_lowercase().as_str() {
111                    "delete" => "del".to_string(),
112                    m => m.to_string(),
113                };
114                json!({
115                    "index": idx,
116                    "name": sanitized_name,  // Use sanitized name for variable names
117                    "metric_name": metric_name,  // Use sanitized name for metric name strings (k6 validation)
118                    "display_name": display_name,  // Keep original for comments/display
119                    "method": k6_method,  // k6 uses lowercase methods (http.get, http.post, http.del)
120                    "path": template.generate_path(),
121                    "headers": self.build_headers_json(template),  // Returns JSON string for template
122                    "body": template.body.as_ref().map(|b| b.to_string()),
123                    "has_body": template.body.is_some(),
124                })
125            })
126            .collect::<Vec<_>>();
127
128        Ok(json!({
129            "base_url": self.config.target_url,
130            "stages": stages.iter().map(|s| json!({
131                "duration": s.duration,
132                "target": s.target,
133            })).collect::<Vec<_>>(),
134            "operations": operations,
135            "threshold_percentile": self.config.threshold_percentile,
136            "threshold_ms": self.config.threshold_ms,
137            "max_error_rate": self.config.max_error_rate,
138            "scenario_name": format!("{:?}", self.config.scenario).to_lowercase(),
139            "skip_tls_verify": self.config.skip_tls_verify,
140        }))
141    }
142
143    /// Build headers for a request template as a JSON string for k6 script
144    fn build_headers_json(&self, template: &RequestTemplate) -> String {
145        let mut headers = template.get_headers();
146
147        // Add auth header if provided
148        if let Some(auth) = &self.config.auth_header {
149            headers.insert("Authorization".to_string(), auth.clone());
150        }
151
152        // Add custom headers
153        for (key, value) in &self.config.custom_headers {
154            headers.insert(key.clone(), value.clone());
155        }
156
157        // Convert to JSON string for embedding in k6 script
158        serde_json::to_string(&headers).unwrap_or_else(|_| "{}".to_string())
159    }
160
161    /// Validate the generated k6 script for common issues
162    ///
163    /// Checks for:
164    /// - Invalid metric names (contains dots or special characters)
165    /// - Invalid JavaScript variable names
166    /// - Missing required k6 imports
167    ///
168    /// Returns a list of validation errors, empty if all checks pass.
169    pub fn validate_script(script: &str) -> Vec<String> {
170        let mut errors = Vec::new();
171
172        // Check for required k6 imports
173        if !script.contains("import http from 'k6/http'") {
174            errors.push("Missing required import: 'k6/http'".to_string());
175        }
176        if !script.contains("import { check") && !script.contains("import {check") {
177            errors.push("Missing required import: 'check' from 'k6'".to_string());
178        }
179        if !script.contains("import { Rate, Trend") && !script.contains("import {Rate, Trend") {
180            errors.push("Missing required import: 'Rate, Trend' from 'k6/metrics'".to_string());
181        }
182
183        // Check for invalid metric names in Trend/Rate constructors
184        // k6 metric names must only contain ASCII letters, numbers, or underscores
185        // and start with a letter or underscore
186        let lines: Vec<&str> = script.lines().collect();
187        for (line_num, line) in lines.iter().enumerate() {
188            let trimmed = line.trim();
189
190            // Check for Trend/Rate constructors with invalid metric names
191            if trimmed.contains("new Trend(") || trimmed.contains("new Rate(") {
192                // Extract the metric name from the string literal
193                // Pattern: new Trend('metric_name') or new Rate("metric_name")
194                if let Some(start) = trimmed.find('\'') {
195                    if let Some(end) = trimmed[start + 1..].find('\'') {
196                        let metric_name = &trimmed[start + 1..start + 1 + end];
197                        if !Self::is_valid_k6_metric_name(metric_name) {
198                            errors.push(format!(
199                                "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
200                                line_num + 1,
201                                metric_name
202                            ));
203                        }
204                    }
205                } else if let Some(start) = trimmed.find('"') {
206                    if let Some(end) = trimmed[start + 1..].find('"') {
207                        let metric_name = &trimmed[start + 1..start + 1 + end];
208                        if !Self::is_valid_k6_metric_name(metric_name) {
209                            errors.push(format!(
210                                "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
211                                line_num + 1,
212                                metric_name
213                            ));
214                        }
215                    }
216                }
217            }
218
219            // Check for invalid JavaScript variable names (containing dots)
220            if trimmed.starts_with("const ") || trimmed.starts_with("let ") {
221                if let Some(equals_pos) = trimmed.find('=') {
222                    let var_decl = &trimmed[..equals_pos];
223                    // Check if variable name contains a dot (invalid identifier)
224                    // But exclude string literals
225                    if var_decl.contains('.')
226                        && !var_decl.contains("'")
227                        && !var_decl.contains("\"")
228                        && !var_decl.trim().starts_with("//")
229                    {
230                        errors.push(format!(
231                            "Line {}: Invalid JavaScript variable name with dot: {}. Variable names cannot contain dots.",
232                            line_num + 1,
233                            var_decl.trim()
234                        ));
235                    }
236                }
237            }
238        }
239
240        errors
241    }
242
243    /// Check if a string is a valid k6 metric name
244    ///
245    /// k6 metric names must:
246    /// - Only contain ASCII letters, numbers, or underscores
247    /// - Start with a letter or underscore (not a number)
248    /// - Be at most 128 characters
249    fn is_valid_k6_metric_name(name: &str) -> bool {
250        if name.is_empty() || name.len() > 128 {
251            return false;
252        }
253
254        let mut chars = name.chars();
255
256        // First character must be a letter or underscore
257        if let Some(first) = chars.next() {
258            if !first.is_ascii_alphabetic() && first != '_' {
259                return false;
260            }
261        }
262
263        // Remaining characters must be alphanumeric or underscore
264        for ch in chars {
265            if !ch.is_ascii_alphanumeric() && ch != '_' {
266                return false;
267            }
268        }
269
270        true
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn test_k6_config_creation() {
280        let config = K6Config {
281            target_url: "https://api.example.com".to_string(),
282            scenario: LoadScenario::RampUp,
283            duration_secs: 60,
284            max_vus: 10,
285            threshold_percentile: "p(95)".to_string(),
286            threshold_ms: 500,
287            max_error_rate: 0.05,
288            auth_header: None,
289            custom_headers: HashMap::new(),
290            skip_tls_verify: false,
291        };
292
293        assert_eq!(config.duration_secs, 60);
294        assert_eq!(config.max_vus, 10);
295    }
296
297    #[test]
298    fn test_script_generator_creation() {
299        let config = K6Config {
300            target_url: "https://api.example.com".to_string(),
301            scenario: LoadScenario::Constant,
302            duration_secs: 30,
303            max_vus: 5,
304            threshold_percentile: "p(95)".to_string(),
305            threshold_ms: 500,
306            max_error_rate: 0.05,
307            auth_header: None,
308            custom_headers: HashMap::new(),
309            skip_tls_verify: false,
310        };
311
312        let templates = vec![];
313        let generator = K6ScriptGenerator::new(config, templates);
314
315        assert_eq!(generator.templates.len(), 0);
316    }
317
318    #[test]
319    fn test_sanitize_js_identifier() {
320        // Test case from issue #79: names with dots
321        assert_eq!(
322            K6ScriptGenerator::sanitize_js_identifier("billing.subscriptions.v1"),
323            "billing_subscriptions_v1"
324        );
325
326        // Test other invalid characters
327        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("get user"), "get_user");
328
329        // Test names starting with numbers
330        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("123invalid"), "_123invalid");
331
332        // Test already valid identifiers
333        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("getUsers"), "getUsers");
334
335        // Test with multiple consecutive invalid chars
336        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test...name"), "test_name");
337
338        // Test empty string (should return default)
339        assert_eq!(K6ScriptGenerator::sanitize_js_identifier(""), "operation");
340
341        // Test with special characters
342        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test@name#value"), "test_name_value");
343    }
344
345    #[test]
346    fn test_script_generation_with_dots_in_name() {
347        use crate::spec_parser::ApiOperation;
348        use openapiv3::Operation;
349
350        // Create an operation with a name containing dots (like in issue #79)
351        let operation = ApiOperation {
352            method: "get".to_string(),
353            path: "/billing/subscriptions".to_string(),
354            operation: Operation::default(),
355            operation_id: Some("billing.subscriptions.v1".to_string()),
356        };
357
358        let template = RequestTemplate {
359            operation,
360            path_params: HashMap::new(),
361            query_params: HashMap::new(),
362            headers: HashMap::new(),
363            body: None,
364        };
365
366        let config = K6Config {
367            target_url: "https://api.example.com".to_string(),
368            scenario: LoadScenario::Constant,
369            duration_secs: 30,
370            max_vus: 5,
371            threshold_percentile: "p(95)".to_string(),
372            threshold_ms: 500,
373            max_error_rate: 0.05,
374            auth_header: None,
375            custom_headers: HashMap::new(),
376            skip_tls_verify: false,
377        };
378
379        let generator = K6ScriptGenerator::new(config, vec![template]);
380        let script = generator.generate().expect("Should generate script");
381
382        // Verify the script contains sanitized variable names (no dots in variable identifiers)
383        assert!(
384            script.contains("const billing_subscriptions_v1_latency"),
385            "Script should contain sanitized variable name for latency"
386        );
387        assert!(
388            script.contains("const billing_subscriptions_v1_errors"),
389            "Script should contain sanitized variable name for errors"
390        );
391
392        // Verify variable names do NOT contain dots (check the actual variable identifier, not string literals)
393        // The pattern "const billing.subscriptions" would indicate a variable name with dots
394        assert!(
395            !script.contains("const billing.subscriptions"),
396            "Script should not contain variable names with dots - this would cause 'Unexpected token .' error"
397        );
398
399        // Verify metric name strings are sanitized (no dots) - k6 requires valid metric names
400        // Metric names must only contain ASCII letters, numbers, or underscores
401        assert!(
402            script.contains("'billing_subscriptions_v1_latency'"),
403            "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
404        );
405        assert!(
406            script.contains("'billing_subscriptions_v1_errors'"),
407            "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
408        );
409
410        // Verify the original display name is still used in comments and strings (for readability)
411        assert!(
412            script.contains("billing.subscriptions.v1"),
413            "Script should contain original name in comments/strings for readability"
414        );
415
416        // Most importantly: verify the variable usage doesn't have dots
417        assert!(
418            script.contains("billing_subscriptions_v1_latency.add"),
419            "Variable usage should use sanitized name"
420        );
421        assert!(
422            script.contains("billing_subscriptions_v1_errors.add"),
423            "Variable usage should use sanitized name"
424        );
425    }
426
427    #[test]
428    fn test_validate_script_valid() {
429        let valid_script = r#"
430import http from 'k6/http';
431import { check, sleep } from 'k6';
432import { Rate, Trend } from 'k6/metrics';
433
434const test_latency = new Trend('test_latency');
435const test_errors = new Rate('test_errors');
436
437export default function() {
438    const res = http.get('https://example.com');
439    test_latency.add(res.timings.duration);
440    test_errors.add(res.status !== 200);
441}
442"#;
443
444        let errors = K6ScriptGenerator::validate_script(valid_script);
445        assert!(errors.is_empty(), "Valid script should have no validation errors");
446    }
447
448    #[test]
449    fn test_validate_script_invalid_metric_name() {
450        let invalid_script = r#"
451import http from 'k6/http';
452import { check, sleep } from 'k6';
453import { Rate, Trend } from 'k6/metrics';
454
455const test_latency = new Trend('test.latency');
456const test_errors = new Rate('test_errors');
457
458export default function() {
459    const res = http.get('https://example.com');
460    test_latency.add(res.timings.duration);
461}
462"#;
463
464        let errors = K6ScriptGenerator::validate_script(invalid_script);
465        assert!(
466            !errors.is_empty(),
467            "Script with invalid metric name should have validation errors"
468        );
469        assert!(
470            errors.iter().any(|e| e.contains("Invalid k6 metric name")),
471            "Should detect invalid metric name with dot"
472        );
473    }
474
475    #[test]
476    fn test_validate_script_missing_imports() {
477        let invalid_script = r#"
478const test_latency = new Trend('test_latency');
479export default function() {}
480"#;
481
482        let errors = K6ScriptGenerator::validate_script(invalid_script);
483        assert!(!errors.is_empty(), "Script missing imports should have validation errors");
484    }
485
486    #[test]
487    fn test_validate_script_metric_name_validation() {
488        // Test that validate_script correctly identifies invalid metric names
489        // Valid metric names should pass
490        let valid_script = r#"
491import http from 'k6/http';
492import { check, sleep } from 'k6';
493import { Rate, Trend } from 'k6/metrics';
494const test_latency = new Trend('test_latency');
495const test_errors = new Rate('test_errors');
496export default function() {}
497"#;
498        let errors = K6ScriptGenerator::validate_script(valid_script);
499        assert!(errors.is_empty(), "Valid metric names should pass validation");
500
501        // Invalid metric names should fail
502        let invalid_cases = vec![
503            ("test.latency", "dot in metric name"),
504            ("123test", "starts with number"),
505            ("test-latency", "hyphen in metric name"),
506            ("test@latency", "special character"),
507        ];
508
509        for (invalid_name, description) in invalid_cases {
510            let script = format!(
511                r#"
512import http from 'k6/http';
513import {{ check, sleep }} from 'k6';
514import {{ Rate, Trend }} from 'k6/metrics';
515const test_latency = new Trend('{}');
516export default function() {{}}
517"#,
518                invalid_name
519            );
520            let errors = K6ScriptGenerator::validate_script(&script);
521            assert!(
522                !errors.is_empty(),
523                "Metric name '{}' ({}) should fail validation",
524                invalid_name,
525                description
526            );
527        }
528    }
529
530    #[test]
531    fn test_skip_tls_verify_with_body() {
532        use crate::spec_parser::ApiOperation;
533        use openapiv3::Operation;
534        use serde_json::json;
535
536        // Create an operation with a request body
537        let operation = ApiOperation {
538            method: "post".to_string(),
539            path: "/api/users".to_string(),
540            operation: Operation::default(),
541            operation_id: Some("createUser".to_string()),
542        };
543
544        let template = RequestTemplate {
545            operation,
546            path_params: HashMap::new(),
547            query_params: HashMap::new(),
548            headers: HashMap::new(),
549            body: Some(json!({"name": "test"})),
550        };
551
552        let config = K6Config {
553            target_url: "https://api.example.com".to_string(),
554            scenario: LoadScenario::Constant,
555            duration_secs: 30,
556            max_vus: 5,
557            threshold_percentile: "p(95)".to_string(),
558            threshold_ms: 500,
559            max_error_rate: 0.05,
560            auth_header: None,
561            custom_headers: HashMap::new(),
562            skip_tls_verify: true,
563        };
564
565        let generator = K6ScriptGenerator::new(config, vec![template]);
566        let script = generator.generate().expect("Should generate script");
567
568        // Verify the script includes TLS skip option for requests with body
569        assert!(
570            script.contains("insecureSkipTLSVerify: true"),
571            "Script should include insecureSkipTLSVerify option when skip_tls_verify is true"
572        );
573    }
574
575    #[test]
576    fn test_skip_tls_verify_without_body() {
577        use crate::spec_parser::ApiOperation;
578        use openapiv3::Operation;
579
580        // Create an operation without a request body
581        let operation = ApiOperation {
582            method: "get".to_string(),
583            path: "/api/users".to_string(),
584            operation: Operation::default(),
585            operation_id: Some("getUsers".to_string()),
586        };
587
588        let template = RequestTemplate {
589            operation,
590            path_params: HashMap::new(),
591            query_params: HashMap::new(),
592            headers: HashMap::new(),
593            body: None,
594        };
595
596        let config = K6Config {
597            target_url: "https://api.example.com".to_string(),
598            scenario: LoadScenario::Constant,
599            duration_secs: 30,
600            max_vus: 5,
601            threshold_percentile: "p(95)".to_string(),
602            threshold_ms: 500,
603            max_error_rate: 0.05,
604            auth_header: None,
605            custom_headers: HashMap::new(),
606            skip_tls_verify: true,
607        };
608
609        let generator = K6ScriptGenerator::new(config, vec![template]);
610        let script = generator.generate().expect("Should generate script");
611
612        // Verify the script includes TLS skip option for requests without body
613        assert!(
614            script.contains("insecureSkipTLSVerify: true"),
615            "Script should include insecureSkipTLSVerify option when skip_tls_verify is true (no body)"
616        );
617    }
618
619    #[test]
620    fn test_no_skip_tls_verify() {
621        use crate::spec_parser::ApiOperation;
622        use openapiv3::Operation;
623
624        // Create an operation
625        let operation = ApiOperation {
626            method: "get".to_string(),
627            path: "/api/users".to_string(),
628            operation: Operation::default(),
629            operation_id: Some("getUsers".to_string()),
630        };
631
632        let template = RequestTemplate {
633            operation,
634            path_params: HashMap::new(),
635            query_params: HashMap::new(),
636            headers: HashMap::new(),
637            body: None,
638        };
639
640        let config = K6Config {
641            target_url: "https://api.example.com".to_string(),
642            scenario: LoadScenario::Constant,
643            duration_secs: 30,
644            max_vus: 5,
645            threshold_percentile: "p(95)".to_string(),
646            threshold_ms: 500,
647            max_error_rate: 0.05,
648            auth_header: None,
649            custom_headers: HashMap::new(),
650            skip_tls_verify: false,
651        };
652
653        let generator = K6ScriptGenerator::new(config, vec![template]);
654        let script = generator.generate().expect("Should generate script");
655
656        // Verify the script does NOT include TLS skip option when skip_tls_verify is false
657        assert!(
658            !script.contains("insecureSkipTLSVerify"),
659            "Script should NOT include insecureSkipTLSVerify option when skip_tls_verify is false"
660        );
661    }
662
663    #[test]
664    fn test_skip_tls_verify_multiple_operations() {
665        use crate::spec_parser::ApiOperation;
666        use openapiv3::Operation;
667        use serde_json::json;
668
669        // Create multiple operations - one with body, one without
670        let operation1 = ApiOperation {
671            method: "get".to_string(),
672            path: "/api/users".to_string(),
673            operation: Operation::default(),
674            operation_id: Some("getUsers".to_string()),
675        };
676
677        let operation2 = ApiOperation {
678            method: "post".to_string(),
679            path: "/api/users".to_string(),
680            operation: Operation::default(),
681            operation_id: Some("createUser".to_string()),
682        };
683
684        let template1 = RequestTemplate {
685            operation: operation1,
686            path_params: HashMap::new(),
687            query_params: HashMap::new(),
688            headers: HashMap::new(),
689            body: None,
690        };
691
692        let template2 = RequestTemplate {
693            operation: operation2,
694            path_params: HashMap::new(),
695            query_params: HashMap::new(),
696            headers: HashMap::new(),
697            body: Some(json!({"name": "test"})),
698        };
699
700        let config = K6Config {
701            target_url: "https://api.example.com".to_string(),
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: true,
711        };
712
713        let generator = K6ScriptGenerator::new(config, vec![template1, template2]);
714        let script = generator.generate().expect("Should generate script");
715
716        // Verify the script includes TLS skip option for all operations
717        let skip_count = script.matches("insecureSkipTLSVerify: true").count();
718        assert_eq!(
719            skip_count, 2,
720            "Script should include insecureSkipTLSVerify option for all operations when skip_tls_verify is true"
721        );
722    }
723}