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 let is_get_or_head = matches!(k6_method.as_str(), "get" | "head");
117 json!({
118 "index": idx,
119 "name": sanitized_name, "metric_name": metric_name, "display_name": display_name, "method": k6_method, "path": template.generate_path(),
124 "headers": self.build_headers_json(template), "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, })
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 fn build_headers_json(&self, template: &RequestTemplate) -> String {
149 let mut headers = template.get_headers();
150
151 if let Some(auth) = &self.config.auth_header {
153 headers.insert("Authorization".to_string(), auth.clone());
154 }
155
156 for (key, value) in &self.config.custom_headers {
158 headers.insert(key.clone(), value.clone());
159 }
160
161 serde_json::to_string(&headers).unwrap_or_else(|_| "{}".to_string())
163 }
164
165 pub fn validate_script(script: &str) -> Vec<String> {
174 let mut errors = Vec::new();
175
176 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 let lines: Vec<&str> = script.lines().collect();
191 for (line_num, line) in lines.iter().enumerate() {
192 let trimmed = line.trim();
193
194 if trimmed.contains("new Trend(") || trimmed.contains("new Rate(") {
196 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 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 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 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 if let Some(first) = chars.next() {
262 if !first.is_ascii_alphabetic() && first != '_' {
263 return false;
264 }
265 }
266
267 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 assert_eq!(
326 K6ScriptGenerator::sanitize_js_identifier("billing.subscriptions.v1"),
327 "billing_subscriptions_v1"
328 );
329
330 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("get user"), "get_user");
332
333 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("123invalid"), "_123invalid");
335
336 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("getUsers"), "getUsers");
338
339 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test...name"), "test_name");
341
342 assert_eq!(K6ScriptGenerator::sanitize_js_identifier(""), "operation");
344
345 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 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 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 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 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 assert!(
416 script.contains("billing.subscriptions.v1"),
417 "Script should contain original name in comments/strings for readability"
418 );
419
420 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 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 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 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 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 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 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 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 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 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 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 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}