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