1use crate::command::BenchCommand;
8use crate::error::{BenchError, Result};
9use crate::executor::{K6Executor, K6Results};
10use crate::k6_gen::{K6Config, K6ScriptGenerator};
11use crate::reporter::TerminalReporter;
12use crate::request_gen::RequestGenerator;
13use crate::scenarios::LoadScenario;
14use crate::spec_parser::SpecParser;
15use crate::target_parser::TargetConfig;
16use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
17use mockforge_openapi::spec::OpenApiSpec;
18use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20use std::str::FromStr;
21use std::sync::Arc;
22use tokio::sync::Semaphore;
23use tokio::task::JoinHandle;
24
25#[derive(Debug, Clone)]
27pub struct TargetResult {
28 pub target_url: String,
30 pub target_index: usize,
32 pub results: K6Results,
34 pub output_dir: PathBuf,
36 pub success: bool,
38 pub error: Option<String>,
40}
41
42#[derive(Debug, Clone)]
44pub struct AggregatedResults {
45 pub target_results: Vec<TargetResult>,
47 pub total_targets: usize,
49 pub successful_targets: usize,
50 pub failed_targets: usize,
51 pub aggregated_metrics: AggregatedMetrics,
53}
54
55#[derive(Debug, Clone)]
57pub struct AggregatedMetrics {
58 pub total_requests: u64,
60 pub total_failed_requests: u64,
62 pub avg_duration_ms: f64,
64 pub p95_duration_ms: f64,
66 pub p99_duration_ms: f64,
68 pub error_rate: f64,
70 pub total_rps: f64,
72 pub avg_rps: f64,
74 pub total_vus_max: u32,
76 pub total_connections_opened: u64,
80 pub total_iterations_completed: u64,
82}
83
84impl AggregatedMetrics {
85 fn from_results(results: &[TargetResult]) -> Self {
87 let mut total_requests = 0u64;
88 let mut total_failed_requests = 0u64;
89 let mut durations = Vec::new();
90 let mut p95_values = Vec::new();
91 let mut p99_values = Vec::new();
92 let mut total_rps = 0.0f64;
93 let mut total_vus_max = 0u32;
94 let mut total_connections_opened = 0u64;
95 let mut total_iterations_completed = 0u64;
96 let mut successful_count = 0usize;
97
98 for result in results {
99 if result.success {
100 total_requests += result.results.total_requests;
101 total_failed_requests += result.results.failed_requests;
102 durations.push(result.results.avg_duration_ms);
103 p95_values.push(result.results.p95_duration_ms);
104 p99_values.push(result.results.p99_duration_ms);
105 total_rps += result.results.rps;
106 total_vus_max += result.results.vus_max;
107 total_connections_opened += result.results.tcp_connect_samples;
108 total_iterations_completed += result.results.iterations_completed;
109 successful_count += 1;
110 }
111 }
112
113 let avg_duration_ms = if !durations.is_empty() {
114 durations.iter().sum::<f64>() / durations.len() as f64
115 } else {
116 0.0
117 };
118
119 let p95_duration_ms = if !p95_values.is_empty() {
120 p95_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
121 let index = (p95_values.len() as f64 * 0.95).ceil() as usize - 1;
122 p95_values[index.min(p95_values.len() - 1)]
123 } else {
124 0.0
125 };
126
127 let p99_duration_ms = if !p99_values.is_empty() {
128 p99_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
129 let index = (p99_values.len() as f64 * 0.99).ceil() as usize - 1;
130 p99_values[index.min(p99_values.len() - 1)]
131 } else {
132 0.0
133 };
134
135 let error_rate = if total_requests > 0 {
136 (total_failed_requests as f64 / total_requests as f64) * 100.0
137 } else {
138 0.0
139 };
140
141 let avg_rps = if successful_count > 0 {
142 total_rps / successful_count as f64
143 } else {
144 0.0
145 };
146
147 Self {
148 total_requests,
149 total_failed_requests,
150 avg_duration_ms,
151 p95_duration_ms,
152 p99_duration_ms,
153 error_rate,
154 total_rps,
155 avg_rps,
156 total_vus_max,
157 total_connections_opened,
158 total_iterations_completed,
159 }
160 }
161}
162
163pub struct ParallelExecutor {
165 base_command: BenchCommand,
167 targets: Vec<TargetConfig>,
169 max_concurrency: usize,
171 base_output: PathBuf,
173}
174
175impl ParallelExecutor {
176 pub fn new(
178 base_command: BenchCommand,
179 targets: Vec<TargetConfig>,
180 max_concurrency: usize,
181 ) -> Self {
182 let base_output = base_command.output.clone();
183 Self {
184 base_command,
185 targets,
186 max_concurrency,
187 base_output,
188 }
189 }
190
191 pub(crate) fn estimated_wall_clock(
196 n_targets: usize,
197 max_concurrency: usize,
198 duration_secs: u64,
199 ) -> (usize, u64) {
200 let conc = max_concurrency.max(1);
201 let batches = if n_targets == 0 {
202 0
203 } else {
204 n_targets.div_ceil(conc)
205 };
206 (batches, (batches as u64).saturating_mul(duration_secs))
207 }
208
209 pub(crate) fn format_wall_clock(secs: u64) -> String {
211 let hours = secs / 3600;
212 let mins = (secs % 3600) / 60;
213 let rem = secs % 60;
214 if hours > 0 {
215 format!("{hours}h{mins:02}m{rem:02}s")
216 } else if mins > 0 {
217 format!("{mins}m{rem:02}s")
218 } else {
219 format!("{rem}s")
220 }
221 }
222
223 pub async fn execute_all(&self) -> Result<AggregatedResults> {
225 let total_targets = self.targets.len();
226 TerminalReporter::print_progress(&format!(
227 "Starting parallel execution for {} targets (max concurrency: {})",
228 total_targets, self.max_concurrency
229 ));
230
231 if !K6Executor::is_k6_installed() {
233 TerminalReporter::print_error("k6 is not installed");
234 TerminalReporter::print_warning(
235 "Install k6 from: https://k6.io/docs/get-started/installation/",
236 );
237 return Err(BenchError::K6NotFound);
238 }
239
240 let spec_supplied =
245 !self.base_command.spec.is_empty() || self.base_command.spec_dir.is_some();
246 let verbatim = self.base_command.wafbench_verbatim;
247
248 let (templates, parser) = if verbatim {
249 let verbatim_templates = self.base_command.load_verbatim_templates()?;
250 if verbatim_templates.is_empty() {
251 return Err(BenchError::Other(
252 "--wafbench-verbatim was set but no traffic cases were loaded. Check \
253 --wafbench-dir points at a file, directory or glob containing cases with \
254 a `request.uri`."
255 .to_string(),
256 ));
257 }
258 TerminalReporter::print_success(&format!(
259 "Verbatim mode: {} request(s) will be sent exactly as written (spec endpoints not used)",
260 verbatim_templates.len()
261 ));
262 let parser = if spec_supplied {
263 TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
264 let merged_spec = self.base_command.load_and_merge_specs().await?;
265 TerminalReporter::print_success("Specification(s) loaded (base path only)");
266 SpecParser::from_spec(merged_spec)
267 } else {
268 SpecParser::from_spec(OpenApiSpec {
269 spec: Default::default(),
270 file_path: None,
271 raw_document: None,
272 })
273 };
274 (verbatim_templates, parser)
275 } else {
276 TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
277 let merged_spec = self.base_command.load_and_merge_specs().await?;
278 let parser = SpecParser::from_spec(merged_spec);
279 TerminalReporter::print_success("Specification(s) loaded");
280
281 let operations = if let Some(filter) = &self.base_command.operations {
282 parser.filter_operations(filter)?
283 } else {
284 parser.get_operations()
285 };
286
287 if operations.is_empty() {
288 return Err(BenchError::Other("No operations found in spec".to_string()));
289 }
290
291 TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
292
293 TerminalReporter::print_progress("Generating request templates...");
294 let templates: Vec<_> = operations
295 .iter()
296 .map(RequestGenerator::generate_template)
297 .collect::<Result<Vec<_>>>()?;
298 TerminalReporter::print_success("Request templates generated");
299 (templates, parser)
300 };
301
302 let mut per_target_data: HashMap<
307 PathBuf,
308 (Vec<crate::request_gen::RequestTemplate>, Option<String>),
309 > = HashMap::new();
310 if !verbatim {
311 let mut unique_specs: Vec<PathBuf> = Vec::new();
312 for t in &self.targets {
313 if let Some(spec_path) = &t.spec {
314 if !unique_specs.contains(spec_path) {
315 unique_specs.push(spec_path.clone());
316 }
317 }
318 }
319 for spec_path in &unique_specs {
320 TerminalReporter::print_progress(&format!(
321 "Loading per-target spec: {}",
322 spec_path.display()
323 ));
324 match SpecParser::from_file(spec_path).await {
325 Ok(target_parser) => {
326 let target_ops = if let Some(filter) = &self.base_command.operations {
327 match target_parser.filter_operations(filter) {
328 Ok(ops) => ops,
329 Err(e) => {
330 TerminalReporter::print_warning(&format!(
331 "Failed to filter operations from {}: {}. Using shared spec.",
332 spec_path.display(),
333 e
334 ));
335 continue;
336 }
337 }
338 } else {
339 target_parser.get_operations()
340 };
341 let target_templates: Vec<_> = match target_ops
342 .iter()
343 .map(RequestGenerator::generate_template)
344 .collect::<Result<Vec<_>>>()
345 {
346 Ok(t) => t,
347 Err(e) => {
348 TerminalReporter::print_warning(&format!(
349 "Failed to generate templates from {}: {}. Using shared spec.",
350 spec_path.display(),
351 e
352 ));
353 continue;
354 }
355 };
356 let target_base_path = if let Some(cli_bp) = &self.base_command.base_path {
357 if cli_bp.is_empty() {
358 None
359 } else {
360 Some(cli_bp.clone())
361 }
362 } else {
363 target_parser.get_base_path()
364 };
365 TerminalReporter::print_success(&format!(
366 "Loaded {} operations from {}",
367 target_templates.len(),
368 spec_path.display()
369 ));
370 per_target_data
371 .insert(spec_path.clone(), (target_templates, target_base_path));
372 }
373 Err(e) => {
374 TerminalReporter::print_warning(&format!(
375 "Failed to load per-target spec {}: {}. Targets using this spec will use the shared spec.",
376 spec_path.display(),
377 e
378 ));
379 }
380 }
381 }
382 }
383
384 let base_headers = self.base_command.parse_headers()?;
386
387 let base_path = self.resolve_base_path(&parser);
389 if let Some(ref bp) = base_path {
390 TerminalReporter::print_progress(&format!("Using base path: {}", bp));
391 }
392
393 let scenario = LoadScenario::from_str(&self.base_command.scenario)
395 .map_err(BenchError::InvalidScenario)?;
396
397 let duration_secs_val = BenchCommand::parse_duration(&self.base_command.duration)?;
398
399 let (batches, wall_secs) =
403 Self::estimated_wall_clock(total_targets, self.max_concurrency, duration_secs_val);
404 TerminalReporter::print_progress(&format!(
405 "Estimated wall clock: {batches} batch(es) × {duration_secs_val}s ≈ {} ({wall_secs}s). --vus and --rps are per target, not shared.",
406 Self::format_wall_clock(wall_secs),
407 ));
408
409 let security_testing_enabled_val = self.base_command.security_testing_enabled();
410
411 if crate::request_gen::should_force_k6_http1(verbatim, &templates, &base_headers) {
412 TerminalReporter::print_progress(
413 "Forcing HTTP/1.1 (GODEBUG=http2client=0): Connection headers are hop-by-hop and HTTP/2 rejects them. The header stays on the wire.",
414 );
415 }
416
417 let has_advanced_features = self.base_command.data_file.is_some()
419 || self.base_command.error_rate.is_some()
420 || self.base_command.security_testing_enabled()
421 || self.base_command.parallel_create.is_some();
422
423 let enhancement_code = if has_advanced_features {
424 let dummy_script = "export const options = {};";
425 let enhanced = self.base_command.generate_enhanced_script(dummy_script)?;
426 if let Some(pos) = enhanced.find("export const options") {
427 enhanced[..pos].to_string()
428 } else {
429 String::new()
430 }
431 } else {
432 String::new()
433 };
434
435 let semaphore = Arc::new(Semaphore::new(self.max_concurrency));
437 let multi_progress = MultiProgress::new();
438
439 let progress_bars: Vec<ProgressBar> = (0..total_targets)
441 .map(|i| {
442 let pb = multi_progress.add(ProgressBar::new(1));
443 pb.set_style(
444 ProgressStyle::default_bar()
445 .template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} {msg}")
446 .unwrap(),
447 );
448 pb.set_message(format!("Target {}", i + 1));
449 pb
450 })
451 .collect();
452
453 let mut handles: Vec<JoinHandle<Result<TargetResult>>> = Vec::new();
455
456 for (index, target) in self.targets.iter().enumerate() {
457 let target = target.clone();
458 let duration = self.base_command.duration.clone();
460 let vus = self.base_command.vus;
461 let scenario_str = self.base_command.scenario.clone();
462 let operations = self.base_command.operations.clone();
463 let auth = self.base_command.auth.clone();
464 let headers = self.base_command.headers.clone();
465 let threshold_percentile = self.base_command.threshold_percentile.clone();
466 let threshold_ms = self.base_command.threshold_ms;
467 let max_error_rate = self.base_command.max_error_rate;
468 let abort_on_error = self.base_command.abort_on_error;
473 let abort_on_error_rate = self.base_command.abort_on_error_rate;
474 let verbose = self.base_command.verbose;
475 let skip_tls_verify = self.base_command.skip_tls_verify;
476 let chunked_request_bodies = self.base_command.chunked_request_bodies;
477 let target_rps = self.base_command.target_rps;
478 let no_keep_alive = self.base_command.no_keep_alive;
479 let local_ips = self.base_command.source_ips.join(",");
486 let dns_policy = self.base_command.dns_policy.clone().unwrap_or_default();
487 let geo_source_ips = self.base_command.geo_source_ips.clone();
488 let geo_source_headers = self.base_command.geo_source_headers.clone();
489
490 let (templates, base_path) = if verbatim {
494 (templates.clone(), base_path.clone())
495 } else if let Some(spec_path) = &target.spec {
496 if let Some((t, bp)) = per_target_data.get(spec_path) {
497 (t.clone(), bp.clone())
498 } else {
499 (templates.clone(), base_path.clone())
500 }
501 } else {
502 (templates.clone(), base_path.clone())
503 };
504
505 let base_headers = base_headers.clone();
506 let scenario = scenario.clone();
507 let duration_secs = duration_secs_val;
508 let base_output = self.base_output.clone();
509 let semaphore = semaphore.clone();
510 let progress_bar = progress_bars[index].clone();
511 let target_index = index;
512 let security_testing_enabled = security_testing_enabled_val;
513 let enhancement_code = enhancement_code.clone();
514
515 let handle = tokio::spawn(async move {
516 let _permit = semaphore.acquire().await.map_err(|e| {
518 BenchError::Other(format!("Failed to acquire semaphore: {}", e))
519 })?;
520
521 progress_bar.set_message(format!("Testing {}", target.url));
522
523 let result = Self::execute_single_target_internal(
525 &duration,
526 vus,
527 &scenario_str,
528 &operations,
529 &auth,
530 &headers,
531 &threshold_percentile,
532 threshold_ms,
533 max_error_rate,
534 abort_on_error,
535 abort_on_error_rate,
536 verbose,
537 skip_tls_verify,
538 base_path.as_ref(),
539 &target,
540 target_index,
541 &templates,
542 &base_headers,
543 &scenario,
544 duration_secs,
545 &base_output,
546 security_testing_enabled,
547 chunked_request_bodies,
548 target_rps,
549 no_keep_alive,
550 &enhancement_code,
551 &local_ips,
552 &dns_policy,
553 &geo_source_ips,
554 &geo_source_headers,
555 verbatim,
556 )
557 .await;
558
559 progress_bar.inc(1);
560 progress_bar.finish_with_message(format!("Completed {}", target.url));
561
562 result
563 });
564
565 handles.push(handle);
566 }
567
568 let mut target_results = Vec::new();
570 for (index, handle) in handles.into_iter().enumerate() {
571 match handle.await {
572 Ok(Ok(result)) => {
573 target_results.push(result);
574 }
575 Ok(Err(e)) => {
576 let target_url = self.targets[index].url.clone();
578 target_results.push(TargetResult {
579 target_url: target_url.clone(),
580 target_index: index,
581 results: K6Results::default(),
582 output_dir: self.base_output.join(format!("target_{}", index + 1)),
583 success: false,
584 error: Some(e.to_string()),
585 });
586 }
587 Err(e) => {
588 let target_url = self.targets[index].url.clone();
590 target_results.push(TargetResult {
591 target_url: target_url.clone(),
592 target_index: index,
593 results: K6Results::default(),
594 output_dir: self.base_output.join(format!("target_{}", index + 1)),
595 success: false,
596 error: Some(format!("Task join error: {}", e)),
597 });
598 }
599 }
600 }
601
602 target_results.sort_by_key(|r| r.target_index);
604
605 let aggregated_metrics = AggregatedMetrics::from_results(&target_results);
607
608 let successful_targets = target_results.iter().filter(|r| r.success).count();
609 let failed_targets = total_targets - successful_targets;
610
611 Ok(AggregatedResults {
612 target_results,
613 total_targets,
614 successful_targets,
615 failed_targets,
616 aggregated_metrics,
617 })
618 }
619
620 fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
622 if let Some(cli_base_path) = &self.base_command.base_path {
624 if cli_base_path.is_empty() {
625 return None;
626 }
627 return Some(cli_base_path.clone());
628 }
629 parser.get_base_path()
631 }
632
633 #[allow(clippy::too_many_arguments)]
635 async fn execute_single_target_internal(
636 _duration: &str,
637 vus: u32,
638 _scenario_str: &str,
639 _operations: &Option<String>,
640 auth: &Option<String>,
641 _headers: &[String],
642 threshold_percentile: &str,
643 threshold_ms: u64,
644 max_error_rate: f64,
645 abort_on_error: bool,
646 abort_on_error_rate: f64,
647 verbose: bool,
648 skip_tls_verify: bool,
649 base_path: Option<&String>,
650 target: &TargetConfig,
651 target_index: usize,
652 templates: &[crate::request_gen::RequestTemplate],
653 base_headers: &HashMap<String, String>,
654 scenario: &LoadScenario,
655 duration_secs: u64,
656 base_output: &Path,
657 security_testing_enabled: bool,
658 chunked_request_bodies: bool,
659 target_rps: Option<u32>,
660 no_keep_alive: bool,
661 enhancement_code: &str,
662 local_ips: &str,
663 dns_policy: &str,
664 geo_source_ips: &[String],
665 geo_source_headers: &[String],
666 wafbench_verbatim: bool,
667 ) -> Result<TargetResult> {
668 let mut custom_headers = base_headers.clone();
670 if let Some(target_headers) = &target.headers {
671 custom_headers.extend(target_headers.clone());
672 }
673
674 let force_http1 = crate::request_gen::should_force_k6_http1(
676 wafbench_verbatim,
677 templates,
678 &custom_headers,
679 );
680
681 let auth_header = target.auth.as_ref().or(auth.as_ref()).cloned();
683
684 let k6_config = K6Config {
686 target_url: target.url.clone(),
687 base_path: base_path.cloned(),
688 scenario: scenario.clone(),
689 duration_secs,
690 max_vus: vus,
691 threshold_percentile: threshold_percentile.to_string(),
692 threshold_ms,
693 max_error_rate,
694 auth_header,
695 custom_headers,
696 skip_tls_verify,
697 security_testing_enabled,
698 chunked_request_bodies,
699 target_rps,
700 no_keep_alive,
701 geo_source_ips: geo_source_ips.to_vec(),
702 geo_source_headers: geo_source_headers.to_vec(),
703 };
704
705 let generator = K6ScriptGenerator::new(k6_config, templates.to_vec())
707 .with_abort_valve(abort_on_error, abort_on_error_rate)
708 .with_force_http1(force_http1);
709 let mut script = generator.generate()?;
710
711 if !enhancement_code.is_empty() {
713 if let Some(pos) = script.find("export const options") {
714 script.insert_str(pos, enhancement_code);
715 }
716 }
717
718 let validation_errors = K6ScriptGenerator::validate_script(&script);
720 if !validation_errors.is_empty() {
721 return Err(BenchError::Other(format!(
722 "Script validation failed for target {}: {}",
723 target.url,
724 validation_errors.join(", ")
725 )));
726 }
727
728 let output_dir = base_output.join(format!("target_{}", target_index + 1));
730 std::fs::create_dir_all(&output_dir)?;
731
732 let script_path = output_dir.join("k6-script.js");
734 std::fs::write(&script_path, script)?;
735
736 let api_port = 0; let executor = K6Executor::new()?
761 .with_local_ips(local_ips.to_string())
762 .with_dns_policy(dns_policy.to_string())
763 .with_discard_response_bodies(true)
764 .with_force_http1(force_http1);
765 let results = executor
766 .execute_with_port(&script_path, Some(&output_dir), verbose, Some(api_port))
767 .await;
768
769 match results {
770 Ok(k6_results) => Ok(TargetResult {
771 target_url: target.url.clone(),
772 target_index,
773 results: k6_results,
774 output_dir,
775 success: true,
776 error: None,
777 }),
778 Err(e) => Ok(TargetResult {
779 target_url: target.url.clone(),
780 target_index,
781 results: K6Results::default(),
782 output_dir,
783 success: false,
784 error: Some(e.to_string()),
785 }),
786 }
787 }
788}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793
794 #[test]
795 fn test_aggregated_metrics_from_results() {
796 let results = vec![
797 TargetResult {
798 target_url: "http://api1.com".to_string(),
799 target_index: 0,
800 results: K6Results {
801 total_requests: 100,
802 failed_requests: 5,
803 avg_duration_ms: 100.0,
804 p95_duration_ms: 200.0,
805 p99_duration_ms: 300.0,
806 ..Default::default()
807 },
808 output_dir: PathBuf::from("output1"),
809 success: true,
810 error: None,
811 },
812 TargetResult {
813 target_url: "http://api2.com".to_string(),
814 target_index: 1,
815 results: K6Results {
816 total_requests: 200,
817 failed_requests: 10,
818 avg_duration_ms: 150.0,
819 p95_duration_ms: 250.0,
820 p99_duration_ms: 350.0,
821 ..Default::default()
822 },
823 output_dir: PathBuf::from("output2"),
824 success: true,
825 error: None,
826 },
827 ];
828
829 let metrics = AggregatedMetrics::from_results(&results);
830 assert_eq!(metrics.total_requests, 300);
831 assert_eq!(metrics.total_failed_requests, 15);
832 assert_eq!(metrics.avg_duration_ms, 125.0); }
834
835 #[test]
836 fn test_aggregated_metrics_with_failed_targets() {
837 let results = vec![
838 TargetResult {
839 target_url: "http://api1.com".to_string(),
840 target_index: 0,
841 results: K6Results {
842 total_requests: 100,
843 failed_requests: 5,
844 avg_duration_ms: 100.0,
845 p95_duration_ms: 200.0,
846 p99_duration_ms: 300.0,
847 ..Default::default()
848 },
849 output_dir: PathBuf::from("output1"),
850 success: true,
851 error: None,
852 },
853 TargetResult {
854 target_url: "http://api2.com".to_string(),
855 target_index: 1,
856 results: K6Results::default(),
857 output_dir: PathBuf::from("output2"),
858 success: false,
859 error: Some("Network error".to_string()),
860 },
861 ];
862
863 let metrics = AggregatedMetrics::from_results(&results);
864 assert_eq!(metrics.total_requests, 100);
866 assert_eq!(metrics.total_failed_requests, 5);
867 assert_eq!(metrics.avg_duration_ms, 100.0);
868 }
869
870 #[test]
871 fn test_aggregated_metrics_empty_results() {
872 let results = vec![];
873 let metrics = AggregatedMetrics::from_results(&results);
874 assert_eq!(metrics.total_requests, 0);
875 assert_eq!(metrics.total_failed_requests, 0);
876 assert_eq!(metrics.avg_duration_ms, 0.0);
877 assert_eq!(metrics.error_rate, 0.0);
878 }
879
880 #[test]
881 fn test_aggregated_metrics_error_rate_calculation() {
882 let results = vec![TargetResult {
883 target_url: "http://api1.com".to_string(),
884 target_index: 0,
885 results: K6Results {
886 total_requests: 1000,
887 failed_requests: 50,
888 avg_duration_ms: 100.0,
889 p95_duration_ms: 200.0,
890 p99_duration_ms: 300.0,
891 ..Default::default()
892 },
893 output_dir: PathBuf::from("output1"),
894 success: true,
895 error: None,
896 }];
897
898 let metrics = AggregatedMetrics::from_results(&results);
899 assert_eq!(metrics.error_rate, 5.0); }
901
902 #[test]
903 fn test_aggregated_metrics_p95_p99_calculation() {
904 let results = vec![
905 TargetResult {
906 target_url: "http://api1.com".to_string(),
907 target_index: 0,
908 results: K6Results {
909 total_requests: 100,
910 failed_requests: 0,
911 avg_duration_ms: 100.0,
912 p95_duration_ms: 150.0,
913 p99_duration_ms: 200.0,
914 ..Default::default()
915 },
916 output_dir: PathBuf::from("output1"),
917 success: true,
918 error: None,
919 },
920 TargetResult {
921 target_url: "http://api2.com".to_string(),
922 target_index: 1,
923 results: K6Results {
924 total_requests: 100,
925 failed_requests: 0,
926 avg_duration_ms: 200.0,
927 p95_duration_ms: 250.0,
928 p99_duration_ms: 300.0,
929 ..Default::default()
930 },
931 output_dir: PathBuf::from("output2"),
932 success: true,
933 error: None,
934 },
935 TargetResult {
936 target_url: "http://api3.com".to_string(),
937 target_index: 2,
938 results: K6Results {
939 total_requests: 100,
940 failed_requests: 0,
941 avg_duration_ms: 300.0,
942 p95_duration_ms: 350.0,
943 p99_duration_ms: 400.0,
944 ..Default::default()
945 },
946 output_dir: PathBuf::from("output3"),
947 success: true,
948 error: None,
949 },
950 ];
951
952 let metrics = AggregatedMetrics::from_results(&results);
953 assert_eq!(metrics.p95_duration_ms, 350.0);
956 assert_eq!(metrics.p99_duration_ms, 400.0);
957 }
958
959 #[test]
960 fn estimated_wall_clock_is_batches_times_duration() {
961 let (batches, wall) = ParallelExecutor::estimated_wall_clock(64, 10, 300);
963 assert_eq!(batches, 7);
964 assert_eq!(wall, 2100);
965 assert_eq!(ParallelExecutor::format_wall_clock(2100), "35m00s");
966 assert_eq!(ParallelExecutor::estimated_wall_clock(10, 10, 300), (1, 300));
967 assert_eq!(ParallelExecutor::estimated_wall_clock(0, 10, 300), (0, 0));
968 assert_eq!(ParallelExecutor::format_wall_clock(45), "45s");
969 assert_eq!(ParallelExecutor::format_wall_clock(3661), "1h01m01s");
970 }
971
972 #[test]
973 fn parallel_k6_spawn_sets_force_http1() {
974 let src = include_str!("parallel_executor.rs");
975 assert!(
976 src.contains("with_force_http1(force_http1)"),
977 "multi-target k6 spawn must pass GODEBUG=http2client=0 when Connection headers are present"
978 );
979 assert!(
980 src.contains("Estimated wall clock"),
981 "multi-target start must print ceil(targets/concurrency)*duration"
982 );
983 }
984}