1use 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
10pub 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
24pub struct K6ScriptGenerator {
26 config: K6Config,
27 templates: Vec<RequestTemplate>,
28}
29
30impl K6ScriptGenerator {
31 pub fn new(config: K6Config, templates: Vec<RequestTemplate>) -> Self {
33 Self { config, templates }
34 }
35
36 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 fn sanitize_js_identifier(name: &str) -> String {
59 let mut result = String::new();
60 let mut chars = name.chars().peekable();
61
62 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 if !result.ends_with('_') {
76 result.push('_');
77 }
78 }
79 }
80
81 result = result.trim_end_matches('_').to_string();
83
84 if result.is_empty() {
86 result = "operation".to_string();
87 }
88
89 result
90 }
91
92 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 let metric_name = sanitized_name.clone();
109 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, "metric_name": metric_name, "display_name": display_name, "method": k6_method, "path": template.generate_path(),
121 "headers": self.build_headers_json(template), "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 fn build_headers_json(&self, template: &RequestTemplate) -> String {
145 let mut headers = template.get_headers();
146
147 if let Some(auth) = &self.config.auth_header {
149 headers.insert("Authorization".to_string(), auth.clone());
150 }
151
152 for (key, value) in &self.config.custom_headers {
154 headers.insert(key.clone(), value.clone());
155 }
156
157 serde_json::to_string(&headers).unwrap_or_else(|_| "{}".to_string())
159 }
160
161 pub fn validate_script(script: &str) -> Vec<String> {
170 let mut errors = Vec::new();
171
172 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 let lines: Vec<&str> = script.lines().collect();
187 for (line_num, line) in lines.iter().enumerate() {
188 let trimmed = line.trim();
189
190 if trimmed.contains("new Trend(") || trimmed.contains("new Rate(") {
192 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 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 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 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 if let Some(first) = chars.next() {
258 if !first.is_ascii_alphabetic() && first != '_' {
259 return false;
260 }
261 }
262
263 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 assert_eq!(
322 K6ScriptGenerator::sanitize_js_identifier("billing.subscriptions.v1"),
323 "billing_subscriptions_v1"
324 );
325
326 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("get user"), "get_user");
328
329 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("123invalid"), "_123invalid");
331
332 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("getUsers"), "getUsers");
334
335 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test...name"), "test_name");
337
338 assert_eq!(K6ScriptGenerator::sanitize_js_identifier(""), "operation");
340
341 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 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 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 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 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 assert!(
412 script.contains("billing.subscriptions.v1"),
413 "Script should contain original name in comments/strings for readability"
414 );
415
416 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 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 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 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 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 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 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 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 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 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 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}