1use crate::error::{BenchError, Result};
4use indicatif::{ProgressBar, ProgressStyle};
5use std::path::Path;
6use std::process::Stdio;
7use std::sync::Arc;
8use tokio::io::{AsyncBufReadExt, BufReader};
9use tokio::process::Command as TokioCommand;
10
11fn extract_mockforge_marker_json(line: &str, marker: &str) -> Option<String> {
34 let start = line.find(marker)?;
35 let json_start = start + marker.len();
36 let rest = &line[json_start..];
37
38 let is_logfmt = start >= 5 && line.as_bytes().get(start - 5..start) == Some(b"msg=\"");
42 if is_logfmt {
43 let bytes = rest.as_bytes();
48 let mut i = 0;
49 let mut out = String::with_capacity(rest.len());
50 while i < bytes.len() {
51 let b = bytes[i];
52 if b == b'"' {
53 return Some(out);
55 }
56 if b == b'\\' && i + 1 < bytes.len() {
57 let next = bytes[i + 1];
58 match next {
59 b'"' => out.push('"'),
60 b'\\' => out.push('\\'),
61 other => {
65 out.push('\\');
66 out.push(other as char);
67 }
68 }
69 i += 2;
70 continue;
71 }
72 let ch_start = i;
75 i += 1;
77 while i < bytes.len() && (bytes[i] & 0b1100_0000) == 0b1000_0000 {
78 i += 1;
79 }
80 out.push_str(&rest[ch_start..i]);
81 }
82 if out.is_empty() {
85 None
86 } else {
87 Some(out)
88 }
89 } else {
90 let trimmed = rest.trim();
93 if trimmed.is_empty() {
94 None
95 } else {
96 Some(trimmed.to_string())
97 }
98 }
99}
100
101fn extract_exchange_json(line: &str) -> Option<String> {
103 extract_mockforge_marker_json(line, "MOCKFORGE_EXCHANGE:")
104}
105
106fn extract_failure_json(line: &str) -> Option<String> {
108 extract_mockforge_marker_json(line, "MOCKFORGE_FAILURE:")
109}
110
111fn extract_network_event_json(line: &str) -> Option<String> {
115 extract_mockforge_marker_json(line, "MOCKFORGE_NETWORK_EVENT:")
116}
117
118pub struct K6Executor {
120 k6_path: String,
121 local_ips: String,
127 discard_response_bodies: bool,
134 dns_policy: String,
142}
143
144impl K6Executor {
145 pub fn new() -> Result<Self> {
147 let k6_path = which::which("k6")
148 .map_err(|_| BenchError::K6NotFound)?
149 .to_string_lossy()
150 .to_string();
151
152 Ok(Self {
153 k6_path,
154 local_ips: String::new(),
155 discard_response_bodies: false,
156 dns_policy: String::new(),
157 })
158 }
159
160 pub fn with_local_ips(mut self, local_ips: impl Into<String>) -> Self {
164 self.local_ips = local_ips.into();
165 self
166 }
167
168 pub fn with_discard_response_bodies(mut self, discard: bool) -> Self {
172 self.discard_response_bodies = discard;
173 self
174 }
175
176 pub fn with_dns_policy(mut self, policy: impl Into<String>) -> Self {
180 self.dns_policy = policy.into();
181 self
182 }
183
184 pub fn is_k6_installed() -> bool {
186 which::which("k6").is_ok()
187 }
188
189 pub async fn warn_if_pre_v1() {
194 const DOCS: &str =
195 "See https://mockforge.dev/docs/reference/bench-capacity-sizing#tooling-requirements";
196 let output = match TokioCommand::new("k6").arg("version").output().await {
197 Ok(o) if o.status.success() => o,
198 _ => return,
199 };
200 let stdout = String::from_utf8_lossy(&output.stdout);
201 let Some(major) = stdout
202 .split_whitespace()
203 .find_map(|t| t.strip_prefix('v'))
204 .and_then(|rest| rest.split('.').next())
205 .and_then(|major| major.parse::<u64>().ok())
206 else {
207 return;
208 };
209 if major < 1 {
210 crate::reporter::TerminalReporter::print_warning(&format!(
211 "Detected k6 {}.x; mockforge bench requires k6 >= 1.0.0 \
212 (older versions break executors and summary parsing). Upgrade k6. {DOCS}",
213 major
214 ));
215 }
216 }
217
218 pub async fn get_version(&self) -> Result<String> {
220 let output = TokioCommand::new(&self.k6_path)
221 .arg("version")
222 .output()
223 .await
224 .map_err(|e| BenchError::K6ExecutionFailed(e.to_string()))?;
225
226 if !output.status.success() {
227 return Err(BenchError::K6ExecutionFailed("Failed to get k6 version".to_string()));
228 }
229
230 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
231 }
232
233 pub async fn execute(
243 &self,
244 script_path: &Path,
245 output_dir: Option<&Path>,
246 verbose: bool,
247 ) -> Result<K6Results> {
248 self.execute_with_port(script_path, output_dir, verbose, None).await
249 }
250
251 pub async fn execute_with_port(
253 &self,
254 script_path: &Path,
255 output_dir: Option<&Path>,
256 verbose: bool,
257 api_port: Option<u16>,
258 ) -> Result<K6Results> {
259 println!("Starting load test...\n");
260
261 let mut cmd = TokioCommand::new(&self.k6_path);
262 cmd.arg("run");
263
264 if let Some(port) = api_port {
267 cmd.arg("--address").arg(format!("localhost:{}", port));
268 }
269
270 if !self.local_ips.is_empty() {
275 cmd.arg("--local-ips").arg(&self.local_ips);
276 }
277
278 if self.discard_response_bodies {
281 cmd.env("K6_DISCARD_RESPONSE_BODIES", "true");
282 }
283
284 if !self.dns_policy.is_empty() {
289 cmd.arg("--dns").arg(format!("policy={}", self.dns_policy));
290 }
291
292 if verbose {
299 cmd.arg("--verbose");
300 }
301
302 let abs_script =
304 std::fs::canonicalize(script_path).unwrap_or_else(|_| script_path.to_path_buf());
305 cmd.arg(&abs_script);
306
307 if let Some(dir) = output_dir {
310 cmd.current_dir(dir);
311 }
312
313 cmd.stdout(Stdio::piped());
314 cmd.stderr(Stdio::piped());
315
316 let mut child = cmd.spawn().map_err(|e| BenchError::K6ExecutionFailed(e.to_string()))?;
317
318 let stdout = child
319 .stdout
320 .take()
321 .ok_or_else(|| BenchError::K6ExecutionFailed("Failed to capture stdout".to_string()))?;
322
323 let stderr = child
324 .stderr
325 .take()
326 .ok_or_else(|| BenchError::K6ExecutionFailed("Failed to capture stderr".to_string()))?;
327
328 let stdout_reader = BufReader::new(stdout);
330 let stderr_reader = BufReader::new(stderr);
331
332 let mut stdout_lines = stdout_reader.lines();
333 let mut stderr_lines = stderr_reader.lines();
334
335 let spinner = ProgressBar::new_spinner();
337 spinner.set_style(
338 ProgressStyle::default_spinner().template("{spinner:.green} {msg}").unwrap(),
339 );
340 spinner.set_message("Running load test...");
341
342 let failure_details: Arc<tokio::sync::Mutex<Vec<String>>> =
345 Arc::new(tokio::sync::Mutex::new(Vec::new()));
346 let fd_stdout = Arc::clone(&failure_details);
347 let fd_stderr = Arc::clone(&failure_details);
348
349 let exchange_details: Arc<tokio::sync::Mutex<Vec<String>>> =
351 Arc::new(tokio::sync::Mutex::new(Vec::new()));
352 let ex_stdout = Arc::clone(&exchange_details);
353 let ex_stderr = Arc::clone(&exchange_details);
354
355 let network_events: Arc<tokio::sync::Mutex<Vec<String>>> =
360 Arc::new(tokio::sync::Mutex::new(Vec::new()));
361 let ne_stdout = Arc::clone(&network_events);
362 let ne_stderr = Arc::clone(&network_events);
363
364 let log_lines: Arc<tokio::sync::Mutex<Vec<String>>> =
366 Arc::new(tokio::sync::Mutex::new(Vec::new()));
367 let log_stdout = Arc::clone(&log_lines);
368 let log_stderr = Arc::clone(&log_lines);
369
370 let stdout_handle = tokio::spawn(async move {
372 while let Ok(Some(line)) = stdout_lines.next_line().await {
373 log_stdout.lock().await.push(format!("[stdout] {}", line));
374 if let Some(json_str) = extract_failure_json(&line) {
375 fd_stdout.lock().await.push(json_str);
376 } else if let Some(json_str) = extract_exchange_json(&line) {
377 ex_stdout.lock().await.push(json_str);
378 } else if let Some(json_str) = extract_network_event_json(&line) {
379 ne_stdout.lock().await.push(json_str);
380 } else {
381 spinner.set_message(line.clone());
382 if !line.is_empty() && !line.contains("running") && !line.contains("default") {
383 println!("{}", line);
384 }
385 }
386 }
387 spinner.finish_and_clear();
388 });
389
390 let stderr_handle = tokio::spawn(async move {
392 while let Ok(Some(line)) = stderr_lines.next_line().await {
393 if !line.is_empty() {
394 log_stderr.lock().await.push(format!("[stderr] {}", line));
395 if let Some(json_str) = extract_failure_json(&line) {
396 fd_stderr.lock().await.push(json_str);
397 } else if let Some(json_str) = extract_exchange_json(&line) {
398 ex_stderr.lock().await.push(json_str);
399 } else if let Some(json_str) = extract_network_event_json(&line) {
400 ne_stderr.lock().await.push(json_str);
401 } else {
402 eprintln!("{}", line);
403 }
404 }
405 }
406 });
407
408 let status =
410 child.wait().await.map_err(|e| BenchError::K6ExecutionFailed(e.to_string()))?;
411
412 let _ = stdout_handle.await;
414 let _ = stderr_handle.await;
415
416 let exit_code = status.code().unwrap_or(-1);
419 if !status.success() && exit_code != 99 {
420 return Err(BenchError::K6ExecutionFailed(format!(
421 "k6 exited with status: {}",
422 status
423 )));
424 }
425 if exit_code == 99 {
426 tracing::warn!("k6 thresholds crossed (exit code 99) — results will still be parsed");
427 }
428
429 if let Some(dir) = output_dir {
431 let details = failure_details.lock().await;
432 if !details.is_empty() {
433 let failure_path = dir.join("conformance-failure-details.json");
434 let parsed: Vec<serde_json::Value> =
435 details.iter().filter_map(|s| serde_json::from_str(s).ok()).collect();
436 if let Ok(json) = serde_json::to_string_pretty(&parsed) {
437 let _ = std::fs::write(&failure_path, json);
438 }
439 }
440
441 let exchanges = exchange_details.lock().await;
443 if !exchanges.is_empty() {
444 let exchange_path = dir.join("conformance-requests.json");
445 let parsed: Vec<serde_json::Value> =
446 exchanges.iter().filter_map(|s| serde_json::from_str(s).ok()).collect();
447 if let Ok(json) = serde_json::to_string_pretty(&parsed) {
448 let _ = std::fs::write(&exchange_path, json);
449 tracing::info!(
450 "Exported {} request/response pairs to {}",
451 parsed.len(),
452 exchange_path.display()
453 );
454 }
455 }
456
457 let net_events = network_events.lock().await;
462 let net_path = dir.join("conformance-network-events.json");
463 let parsed: Vec<serde_json::Value> =
464 net_events.iter().filter_map(|s| serde_json::from_str(s).ok()).collect();
465 if let Ok(json) = serde_json::to_string_pretty(&parsed) {
466 let _ = std::fs::write(&net_path, json);
467 if !parsed.is_empty() {
468 tracing::warn!(
469 "Recorded {} wire-level network event(s) to {}",
470 parsed.len(),
471 net_path.display()
472 );
473 }
474 }
475
476 let lines = log_lines.lock().await;
478 if !lines.is_empty() {
479 let log_path = dir.join("k6-output.log");
480 let _ = std::fs::write(&log_path, lines.join("\n"));
481 println!("k6 output log saved to: {}", log_path.display());
482 }
483 }
484
485 let results = if let Some(dir) = output_dir {
487 Self::parse_results(dir)?
488 } else {
489 K6Results::default()
490 };
491
492 Ok(results)
493 }
494
495 fn parse_results(output_dir: &Path) -> Result<K6Results> {
497 let summary_path = output_dir.join("summary.json");
498
499 if !summary_path.exists() {
500 return Ok(K6Results::default());
501 }
502
503 let content = std::fs::read_to_string(summary_path)
504 .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
505
506 let json: serde_json::Value = serde_json::from_str(&content)
507 .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
508
509 let duration_values = &json["metrics"]["http_req_duration"]["values"];
510
511 let server_latency = &json["metrics"]["mockforge_server_injected_latency_ms"]["values"];
512 let server_jitter = &json["metrics"]["mockforge_server_injected_jitter_ms"]["values"];
513 let server_fault = &json["metrics"]["mockforge_server_fault_total"]["values"]["count"];
514
515 let tcp_connecting = &json["metrics"]["http_req_connecting"]["values"];
526 let tls_handshake = &json["metrics"]["http_req_tls_handshaking"]["values"];
527 let mf_conns_opened = &json["metrics"]["mockforge_connections_opened"]["values"]["count"];
528
529 Ok(K6Results {
530 total_requests: json["metrics"]["http_reqs"]["values"]["count"].as_u64().unwrap_or(0),
531 failed_requests: json["metrics"]["http_req_failed"]["values"]["passes"]
535 .as_u64()
536 .unwrap_or(0),
537 avg_duration_ms: duration_values["avg"].as_f64().unwrap_or(0.0),
538 p95_duration_ms: duration_values["p(95)"].as_f64().unwrap_or(0.0),
539 p99_duration_ms: duration_values["p(99)"].as_f64().unwrap_or(0.0),
540 rps: json["metrics"]["http_reqs"]["values"]["rate"].as_f64().unwrap_or(0.0),
541 vus_max: json["metrics"]["vus_max"]["values"]["value"].as_u64().unwrap_or(0) as u32,
542 min_duration_ms: duration_values["min"].as_f64().unwrap_or(0.0),
543 max_duration_ms: duration_values["max"].as_f64().unwrap_or(0.0),
544 med_duration_ms: duration_values["med"].as_f64().unwrap_or(0.0),
545 p90_duration_ms: duration_values["p(90)"].as_f64().unwrap_or(0.0),
546 server_injected_latency_samples: server_latency["count"].as_u64().unwrap_or(0),
547 server_injected_latency_avg_ms: server_latency["avg"].as_f64().unwrap_or(0.0),
548 server_injected_latency_max_ms: server_latency["max"].as_f64().unwrap_or(0.0),
549 server_injected_jitter_samples: server_jitter["count"].as_u64().unwrap_or(0),
550 server_injected_jitter_avg_ms: server_jitter["avg"].as_f64().unwrap_or(0.0),
551 server_reported_faults: server_fault.as_u64().unwrap_or(0),
552 tcp_connect_samples: mf_conns_opened.as_u64().unwrap_or(0),
555 tcp_connect_avg_ms: tcp_connecting["avg"].as_f64().unwrap_or(0.0),
556 tcp_connect_max_ms: tcp_connecting["max"].as_f64().unwrap_or(0.0),
557 tls_handshake_samples: if tls_handshake["avg"].as_f64().unwrap_or(0.0) > 0.0 {
559 mf_conns_opened.as_u64().unwrap_or(0)
562 } else {
563 0
564 },
565 tls_handshake_avg_ms: tls_handshake["avg"].as_f64().unwrap_or(0.0),
566 tls_handshake_max_ms: tls_handshake["max"].as_f64().unwrap_or(0.0),
567 iterations_completed: json["metrics"]["iterations"]["values"]["count"]
568 .as_u64()
569 .unwrap_or(0),
570 })
571 }
572}
573
574impl Default for K6Executor {
575 fn default() -> Self {
576 Self::new().expect("k6 not found")
577 }
578}
579
580#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
582pub struct K6Results {
583 pub total_requests: u64,
584 pub failed_requests: u64,
585 pub avg_duration_ms: f64,
586 pub p95_duration_ms: f64,
587 pub p99_duration_ms: f64,
588 pub rps: f64,
589 pub vus_max: u32,
590 pub min_duration_ms: f64,
591 pub max_duration_ms: f64,
592 pub med_duration_ms: f64,
593 pub p90_duration_ms: f64,
594 #[serde(default)]
599 pub server_injected_latency_samples: u64,
600 #[serde(default)]
601 pub server_injected_latency_avg_ms: f64,
602 #[serde(default)]
603 pub server_injected_latency_max_ms: f64,
604 #[serde(default)]
605 pub server_injected_jitter_samples: u64,
606 #[serde(default)]
607 pub server_injected_jitter_avg_ms: f64,
608 #[serde(default)]
610 pub server_reported_faults: u64,
611 #[serde(default)]
617 pub tcp_connect_samples: u64,
618 #[serde(default)]
619 pub tcp_connect_avg_ms: f64,
620 #[serde(default)]
621 pub tcp_connect_max_ms: f64,
622 #[serde(default)]
625 pub tls_handshake_samples: u64,
626 #[serde(default)]
627 pub tls_handshake_avg_ms: f64,
628 #[serde(default)]
629 pub tls_handshake_max_ms: f64,
630 #[serde(default)]
636 pub iterations_completed: u64,
637}
638
639impl K6Results {
640 pub fn error_rate(&self) -> f64 {
642 if self.total_requests == 0 {
643 return 0.0;
644 }
645 (self.failed_requests as f64 / self.total_requests as f64) * 100.0
646 }
647
648 pub fn success_rate(&self) -> f64 {
650 100.0 - self.error_rate()
651 }
652}
653
654#[cfg(test)]
655mod tests {
656 use super::*;
657
658 #[test]
659 fn test_k6_results_error_rate() {
660 let results = K6Results {
661 total_requests: 100,
662 failed_requests: 5,
663 avg_duration_ms: 100.0,
664 p95_duration_ms: 200.0,
665 p99_duration_ms: 300.0,
666 ..Default::default()
667 };
668
669 assert_eq!(results.error_rate(), 5.0);
670 assert_eq!(results.success_rate(), 95.0);
671 }
672
673 #[test]
674 fn test_k6_results_zero_requests() {
675 let results = K6Results::default();
676 assert_eq!(results.error_rate(), 0.0);
677 }
678
679 #[test]
680 fn discard_response_bodies_defaults_off_and_builder_flips_it() {
681 let exec = K6Executor {
685 k6_path: "k6".to_string(),
686 local_ips: String::new(),
687 discard_response_bodies: false,
688 dns_policy: String::new(),
689 };
690 assert!(!exec.discard_response_bodies);
691 let exec = exec.with_discard_response_bodies(true);
692 assert!(exec.discard_response_bodies);
693 }
694
695 #[test]
696 fn dns_policy_defaults_empty_and_builder_sets_it() {
697 let exec = K6Executor {
701 k6_path: "k6".to_string(),
702 local_ips: String::new(),
703 discard_response_bodies: false,
704 dns_policy: String::new(),
705 };
706 assert!(exec.dns_policy.is_empty());
707 let exec = exec.with_dns_policy("preferIPv6");
708 assert_eq!(exec.dns_policy, "preferIPv6");
709 }
710
711 #[test]
712 fn test_extract_failure_json_raw() {
713 let line = r#"MOCKFORGE_FAILURE:{"check":"test","expected":"status === 200"}"#;
714 let result = extract_failure_json(line).unwrap();
715 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
716 assert_eq!(parsed["check"], "test");
717 }
718
719 #[test]
720 fn test_extract_failure_json_logfmt() {
721 let line = r#"time="2026-01-01T00:00:00Z" level=info msg="MOCKFORGE_FAILURE:{\"check\":\"test\",\"response\":{\"body\":\"{\\\"key\\\":\\\"val\\\"}\"}} " source=console"#;
722 let result = extract_failure_json(line).unwrap();
723 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
724 assert_eq!(parsed["check"], "test");
725 assert_eq!(parsed["response"]["body"], r#"{"key":"val"}"#);
726 }
727
728 #[test]
729 fn test_extract_failure_json_no_marker() {
730 assert!(extract_failure_json("just a regular log line").is_none());
731 }
732
733 #[test]
739 fn test_extract_exchange_logfmt_with_backslash_escapes() {
740 let line = r#"time="2026-06-26T10:00:00Z" level=info msg="MOCKFORGE_EXCHANGE:{\"check\":\"u\",\"request\":{\"body\":\"--bnd\\r\\n\\u001a\"}}" source=console"#;
744 let result = extract_exchange_json(line).unwrap();
745 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
746 assert_eq!(parsed["check"], "u");
747 assert_eq!(parsed["request"]["body"], "--bnd\r\n\u{001a}");
750 }
751
752 #[test]
753 fn test_extract_exchange_raw_no_logfmt_wrapping() {
754 let line =
755 r#"MOCKFORGE_EXCHANGE:{"check":"x","request":{"body":""},"response":{"status":200}}"#;
756 let result = extract_exchange_json(line).unwrap();
757 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
758 assert_eq!(parsed["check"], "x");
759 assert_eq!(parsed["response"]["status"], 200);
760 }
761
762 #[test]
766 fn test_extract_exchange_logfmt_tolerates_extra_trailing_fields() {
767 let line = r#"msg="MOCKFORGE_EXCHANGE:{\"check\":\"t\"}" source=console vu=1 iter=0"#;
768 let result = extract_exchange_json(line).unwrap();
769 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
770 assert_eq!(parsed["check"], "t");
771 }
772
773 #[test]
777 fn test_extract_exchange_double_backslash_followed_by_quote() {
778 let line = r#"msg="MOCKFORGE_EXCHANGE:{\"k\":\"a\\\\\\\"x\\\"\"}" source=console"#;
781 let result = extract_exchange_json(line).unwrap();
782 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
783 assert_eq!(parsed["k"], r#"a\"x""#);
784 }
785}