mockforge_bench/
executor.rs1use crate::error::{BenchError, Result};
4use indicatif::{ProgressBar, ProgressStyle};
5use std::path::Path;
6use std::process::Stdio;
7use tokio::io::{AsyncBufReadExt, BufReader};
8use tokio::process::Command as TokioCommand;
9
10pub struct K6Executor {
12 k6_path: String,
13}
14
15impl K6Executor {
16 pub fn new() -> Result<Self> {
18 let k6_path = which::which("k6")
19 .map_err(|_| BenchError::K6NotFound)?
20 .to_string_lossy()
21 .to_string();
22
23 Ok(Self { k6_path })
24 }
25
26 pub fn is_k6_installed() -> bool {
28 which::which("k6").is_ok()
29 }
30
31 pub async fn get_version(&self) -> Result<String> {
33 let output = TokioCommand::new(&self.k6_path)
34 .arg("version")
35 .output()
36 .await
37 .map_err(|e| BenchError::K6ExecutionFailed(e.to_string()))?;
38
39 if !output.status.success() {
40 return Err(BenchError::K6ExecutionFailed("Failed to get k6 version".to_string()));
41 }
42
43 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
44 }
45
46 pub async fn execute(
48 &self,
49 script_path: &Path,
50 output_dir: Option<&Path>,
51 verbose: bool,
52 ) -> Result<K6Results> {
53 println!("Starting load test...\n");
54
55 let mut cmd = TokioCommand::new(&self.k6_path);
56 cmd.arg("run");
57
58 if let Some(dir) = output_dir {
60 let summary_path = dir.join("summary.json");
61 cmd.arg("--summary-export").arg(summary_path);
62 }
63
64 if verbose {
66 cmd.arg("--verbose");
67 }
68
69 cmd.arg(script_path);
70 cmd.stdout(Stdio::piped());
71 cmd.stderr(Stdio::piped());
72
73 let mut child = cmd.spawn().map_err(|e| BenchError::K6ExecutionFailed(e.to_string()))?;
74
75 let stdout = child
76 .stdout
77 .take()
78 .ok_or_else(|| BenchError::K6ExecutionFailed("Failed to capture stdout".to_string()))?;
79
80 let stderr = child
81 .stderr
82 .take()
83 .ok_or_else(|| BenchError::K6ExecutionFailed("Failed to capture stderr".to_string()))?;
84
85 let stdout_reader = BufReader::new(stdout);
87 let stderr_reader = BufReader::new(stderr);
88
89 let mut stdout_lines = stdout_reader.lines();
90 let mut stderr_lines = stderr_reader.lines();
91
92 let spinner = ProgressBar::new_spinner();
94 spinner.set_style(
95 ProgressStyle::default_spinner().template("{spinner:.green} {msg}").unwrap(),
96 );
97 spinner.set_message("Running load test...");
98
99 tokio::spawn(async move {
101 while let Ok(Some(line)) = stdout_lines.next_line().await {
102 spinner.set_message(line.clone());
103 if !line.is_empty() && !line.contains("running") && !line.contains("default") {
104 println!("{}", line);
105 }
106 }
107 spinner.finish_and_clear();
108 });
109
110 tokio::spawn(async move {
111 while let Ok(Some(line)) = stderr_lines.next_line().await {
112 if !line.is_empty() {
113 eprintln!("{}", line);
114 }
115 }
116 });
117
118 let status =
120 child.wait().await.map_err(|e| BenchError::K6ExecutionFailed(e.to_string()))?;
121
122 if !status.success() {
123 return Err(BenchError::K6ExecutionFailed(format!(
124 "k6 exited with status: {}",
125 status
126 )));
127 }
128
129 let results = if let Some(dir) = output_dir {
131 Self::parse_results(dir)?
132 } else {
133 K6Results::default()
134 };
135
136 Ok(results)
137 }
138
139 fn parse_results(output_dir: &Path) -> Result<K6Results> {
141 let summary_path = output_dir.join("summary.json");
142
143 if !summary_path.exists() {
144 return Ok(K6Results::default());
145 }
146
147 let content = std::fs::read_to_string(summary_path)
148 .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
149
150 let json: serde_json::Value = serde_json::from_str(&content)
151 .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
152
153 Ok(K6Results {
154 total_requests: json["metrics"]["http_reqs"]["values"]["count"].as_u64().unwrap_or(0),
155 failed_requests: json["metrics"]["http_req_failed"]["values"]["passes"]
156 .as_u64()
157 .unwrap_or(0),
158 avg_duration_ms: json["metrics"]["http_req_duration"]["values"]["avg"]
159 .as_f64()
160 .unwrap_or(0.0),
161 p95_duration_ms: json["metrics"]["http_req_duration"]["values"]["p(95)"]
162 .as_f64()
163 .unwrap_or(0.0),
164 p99_duration_ms: json["metrics"]["http_req_duration"]["values"]["p(99)"]
165 .as_f64()
166 .unwrap_or(0.0),
167 })
168 }
169}
170
171impl Default for K6Executor {
172 fn default() -> Self {
173 Self::new().expect("k6 not found")
174 }
175}
176
177#[derive(Debug, Clone, Default)]
179pub struct K6Results {
180 pub total_requests: u64,
181 pub failed_requests: u64,
182 pub avg_duration_ms: f64,
183 pub p95_duration_ms: f64,
184 pub p99_duration_ms: f64,
185}
186
187impl K6Results {
188 pub fn error_rate(&self) -> f64 {
190 if self.total_requests == 0 {
191 return 0.0;
192 }
193 (self.failed_requests as f64 / self.total_requests as f64) * 100.0
194 }
195
196 pub fn success_rate(&self) -> f64 {
198 100.0 - self.error_rate()
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn test_k6_results_error_rate() {
208 let results = K6Results {
209 total_requests: 100,
210 failed_requests: 5,
211 avg_duration_ms: 100.0,
212 p95_duration_ms: 200.0,
213 p99_duration_ms: 300.0,
214 };
215
216 assert_eq!(results.error_rate(), 5.0);
217 assert_eq!(results.success_rate(), 95.0);
218 }
219
220 #[test]
221 fn test_k6_results_zero_requests() {
222 let results = K6Results::default();
223 assert_eq!(results.error_rate(), 0.0);
224 }
225}