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_override: Option<usize>,
172 base_output: PathBuf,
174}
175
176impl ParallelExecutor {
177 pub fn new(
179 base_command: BenchCommand,
180 targets: Vec<TargetConfig>,
181 max_concurrency: Option<usize>,
182 ) -> Self {
183 let base_output = base_command.output.clone();
184 Self {
185 base_command,
186 targets,
187 max_concurrency_override: max_concurrency,
188 base_output,
189 }
190 }
191
192 pub(crate) fn estimated_wall_clock(
197 n_targets: usize,
198 max_concurrency: usize,
199 duration_secs: u64,
200 ) -> (usize, u64) {
201 let conc = max_concurrency.max(1);
202 let batches = if n_targets == 0 {
203 0
204 } else {
205 n_targets.div_ceil(conc)
206 };
207 (batches, (batches as u64).saturating_mul(duration_secs))
208 }
209
210 pub(crate) fn format_wall_clock(secs: u64) -> String {
212 let hours = secs / 3600;
213 let mins = (secs % 3600) / 60;
214 let rem = secs % 60;
215 if hours > 0 {
216 format!("{hours}h{mins:02}m{rem:02}s")
217 } else if mins > 0 {
218 format!("{mins}m{rem:02}s")
219 } else {
220 format!("{rem}s")
221 }
222 }
223
224 pub async fn execute_all(&self) -> Result<AggregatedResults> {
226 let total_targets = self.targets.len();
227 TerminalReporter::print_progress(&format!(
228 "Starting parallel execution for {} targets",
229 total_targets
230 ));
231
232 if !K6Executor::is_k6_installed() {
234 TerminalReporter::print_error("k6 is not installed");
235 TerminalReporter::print_warning(
236 "Install k6 from: https://k6.io/docs/get-started/installation/",
237 );
238 return Err(BenchError::K6NotFound);
239 }
240
241 let spec_supplied =
246 !self.base_command.spec.is_empty() || self.base_command.spec_dir.is_some();
247 let verbatim = self.base_command.wafbench_verbatim;
248
249 let (templates, parser) = if verbatim {
250 let verbatim_templates = self.base_command.load_verbatim_templates()?;
251 if verbatim_templates.is_empty() {
252 return Err(BenchError::Other(
253 "--wafbench-verbatim was set but no traffic cases were loaded. Check \
254 --wafbench-dir points at a file, directory or glob containing cases with \
255 a `request.uri`."
256 .to_string(),
257 ));
258 }
259 TerminalReporter::print_success(&format!(
260 "Verbatim mode: {} request(s) will be sent exactly as written (spec endpoints not used)",
261 verbatim_templates.len()
262 ));
263 let parser = if spec_supplied {
264 TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
265 let merged_spec = self.base_command.load_and_merge_specs().await?;
266 TerminalReporter::print_success("Specification(s) loaded (base path only)");
267 SpecParser::from_spec(merged_spec)
268 } else {
269 SpecParser::from_spec(OpenApiSpec {
270 spec: Default::default(),
271 file_path: None,
272 raw_document: None,
273 })
274 };
275 (verbatim_templates, parser)
276 } else {
277 TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
278 let merged_spec = self.base_command.load_and_merge_specs().await?;
279 let parser = SpecParser::from_spec(merged_spec);
280 TerminalReporter::print_success("Specification(s) loaded");
281
282 let operations = if let Some(filter) = &self.base_command.operations {
283 parser.filter_operations(filter)?
284 } else {
285 parser.get_operations()
286 };
287
288 if operations.is_empty() {
289 return Err(BenchError::Other("No operations found in spec".to_string()));
290 }
291
292 TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
293
294 TerminalReporter::print_progress("Generating request templates...");
295 let templates: Vec<_> = operations
296 .iter()
297 .map(RequestGenerator::generate_template)
298 .collect::<Result<Vec<_>>>()?;
299 TerminalReporter::print_success("Request templates generated");
300 (templates, parser)
301 };
302
303 let mut per_target_data: HashMap<
308 PathBuf,
309 (Vec<crate::request_gen::RequestTemplate>, Option<String>),
310 > = HashMap::new();
311 if !verbatim {
312 let mut unique_specs: Vec<PathBuf> = Vec::new();
313 for t in &self.targets {
314 if let Some(spec_path) = &t.spec {
315 if !unique_specs.contains(spec_path) {
316 unique_specs.push(spec_path.clone());
317 }
318 }
319 }
320 for spec_path in &unique_specs {
321 TerminalReporter::print_progress(&format!(
322 "Loading per-target spec: {}",
323 spec_path.display()
324 ));
325 match SpecParser::from_file(spec_path).await {
326 Ok(target_parser) => {
327 let target_ops = if let Some(filter) = &self.base_command.operations {
328 match target_parser.filter_operations(filter) {
329 Ok(ops) => ops,
330 Err(e) => {
331 TerminalReporter::print_warning(&format!(
332 "Failed to filter operations from {}: {}. Using shared spec.",
333 spec_path.display(),
334 e
335 ));
336 continue;
337 }
338 }
339 } else {
340 target_parser.get_operations()
341 };
342 let target_templates: Vec<_> = match target_ops
343 .iter()
344 .map(RequestGenerator::generate_template)
345 .collect::<Result<Vec<_>>>()
346 {
347 Ok(t) => t,
348 Err(e) => {
349 TerminalReporter::print_warning(&format!(
350 "Failed to generate templates from {}: {}. Using shared spec.",
351 spec_path.display(),
352 e
353 ));
354 continue;
355 }
356 };
357 let target_base_path = if let Some(cli_bp) = &self.base_command.base_path {
358 if cli_bp.is_empty() {
359 None
360 } else {
361 Some(cli_bp.clone())
362 }
363 } else {
364 target_parser.get_base_path()
365 };
366 TerminalReporter::print_success(&format!(
367 "Loaded {} operations from {}",
368 target_templates.len(),
369 spec_path.display()
370 ));
371 per_target_data
372 .insert(spec_path.clone(), (target_templates, target_base_path));
373 }
374 Err(e) => {
375 TerminalReporter::print_warning(&format!(
376 "Failed to load per-target spec {}: {}. Targets using this spec will use the shared spec.",
377 spec_path.display(),
378 e
379 ));
380 }
381 }
382 }
383 }
384
385 let base_headers = self.base_command.parse_headers()?;
387
388 let base_path = self.resolve_base_path(&parser);
390 if let Some(ref bp) = base_path {
391 TerminalReporter::print_progress(&format!("Using base path: {}", bp));
392 }
393
394 let scenario = LoadScenario::from_str(&self.base_command.scenario)
396 .map_err(BenchError::InvalidScenario)?;
397
398 let duration_secs_val = BenchCommand::parse_duration(&self.base_command.duration)?;
399
400 let (max_concurrency, conc_warn) = crate::k6_gen::resolve_max_concurrency(
403 self.max_concurrency_override,
404 templates.len(),
405 total_targets,
406 );
407 if let Some(msg) = conc_warn {
408 TerminalReporter::print_warning(&msg);
409 } else {
410 TerminalReporter::print_progress(&format!(
411 "Max concurrency: {} ({} target{})",
412 max_concurrency,
413 total_targets,
414 if total_targets == 1 { "" } else { "s" },
415 ));
416 }
417
418 let (per_op_metrics, per_op_warn) = crate::k6_gen::resolve_per_op_metrics(
420 self.base_command.per_op_metrics,
421 templates.len(),
422 duration_secs_val,
423 );
424 if let Some(msg) = per_op_warn {
425 TerminalReporter::print_warning(&msg);
426 }
427
428 let (batches, wall_secs) =
432 Self::estimated_wall_clock(total_targets, max_concurrency, duration_secs_val);
433 TerminalReporter::print_progress(&format!(
434 "Estimated wall clock: {batches} batch(es) × {duration_secs_val}s ≈ {} ({wall_secs}s). --vus and --rps are per target, not shared.",
435 Self::format_wall_clock(wall_secs),
436 ));
437
438 let security_testing_enabled_val = self.base_command.security_testing_enabled();
439
440 if crate::request_gen::should_force_k6_http1(verbatim, &templates, &base_headers) {
441 TerminalReporter::print_progress(
442 "Forcing HTTP/1.1 (GODEBUG=http2client=0): Connection headers are hop-by-hop and HTTP/2 rejects them. The header stays on the wire.",
443 );
444 }
445
446 let has_advanced_features = self.base_command.data_file.is_some()
448 || self.base_command.error_rate.is_some()
449 || self.base_command.security_testing_enabled()
450 || self.base_command.parallel_create.is_some();
451
452 let enhancement_code = if has_advanced_features {
453 let dummy_script = "export const options = {};";
454 let enhanced = self.base_command.generate_enhanced_script(dummy_script)?;
455 if let Some(pos) = enhanced.find("export const options") {
456 enhanced[..pos].to_string()
457 } else {
458 String::new()
459 }
460 } else {
461 String::new()
462 };
463
464 let semaphore = Arc::new(Semaphore::new(max_concurrency));
466 let multi_progress = MultiProgress::new();
467
468 let progress_bars: Vec<ProgressBar> = (0..total_targets)
470 .map(|i| {
471 let pb = multi_progress.add(ProgressBar::new(1));
472 pb.set_style(
473 ProgressStyle::default_bar()
474 .template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} {msg}")
475 .unwrap(),
476 );
477 pb.set_message(format!("Target {}", i + 1));
478 pb
479 })
480 .collect();
481
482 let mut handles: Vec<JoinHandle<Result<TargetResult>>> = Vec::new();
484
485 for (index, target) in self.targets.iter().enumerate() {
486 let target = target.clone();
487 let duration = self.base_command.duration.clone();
489 let vus = self.base_command.vus;
490 let scenario_str = self.base_command.scenario.clone();
491 let operations = self.base_command.operations.clone();
492 let auth = self.base_command.auth.clone();
493 let headers = self.base_command.headers.clone();
494 let threshold_percentile = self.base_command.threshold_percentile.clone();
495 let threshold_ms = self.base_command.threshold_ms;
496 let max_error_rate = self.base_command.max_error_rate;
497 let abort_on_error = self.base_command.abort_on_error;
502 let abort_on_error_rate = self.base_command.abort_on_error_rate;
503 let verbose = self.base_command.verbose;
504 let skip_tls_verify = self.base_command.skip_tls_verify;
505 let chunked_request_bodies = self.base_command.chunked_request_bodies;
506 let target_rps = self.base_command.target_rps;
507 let no_keep_alive = self.base_command.no_keep_alive;
508 let local_ips = self.base_command.source_ips.join(",");
515 let dns_policy = self.base_command.dns_policy.clone().unwrap_or_default();
516 let geo_source_ips = self.base_command.geo_source_ips.clone();
517 let geo_source_headers = self.base_command.geo_source_headers.clone();
518
519 let (templates, base_path) = if verbatim {
523 (templates.clone(), base_path.clone())
524 } else if let Some(spec_path) = &target.spec {
525 if let Some((t, bp)) = per_target_data.get(spec_path) {
526 (t.clone(), bp.clone())
527 } else {
528 (templates.clone(), base_path.clone())
529 }
530 } else {
531 (templates.clone(), base_path.clone())
532 };
533
534 let base_headers = base_headers.clone();
535 let scenario = scenario.clone();
536 let duration_secs = duration_secs_val;
537 let base_output = self.base_output.clone();
538 let semaphore = semaphore.clone();
539 let progress_bar = progress_bars[index].clone();
540 let target_index = index;
541 let security_testing_enabled = security_testing_enabled_val;
542 let enhancement_code = enhancement_code.clone();
543
544 let handle = tokio::spawn(async move {
545 let _permit = semaphore.acquire().await.map_err(|e| {
547 BenchError::Other(format!("Failed to acquire semaphore: {}", e))
548 })?;
549
550 progress_bar.set_message(format!("Testing {}", target.url));
551
552 let result = Self::execute_single_target_internal(
554 &duration,
555 vus,
556 &scenario_str,
557 &operations,
558 &auth,
559 &headers,
560 &threshold_percentile,
561 threshold_ms,
562 max_error_rate,
563 abort_on_error,
564 abort_on_error_rate,
565 per_op_metrics,
566 verbose,
567 skip_tls_verify,
568 base_path.as_ref(),
569 &target,
570 target_index,
571 &templates,
572 &base_headers,
573 &scenario,
574 duration_secs,
575 &base_output,
576 security_testing_enabled,
577 chunked_request_bodies,
578 target_rps,
579 no_keep_alive,
580 &enhancement_code,
581 &local_ips,
582 &dns_policy,
583 &geo_source_ips,
584 &geo_source_headers,
585 verbatim,
586 )
587 .await;
588
589 progress_bar.inc(1);
590 progress_bar.finish_with_message(format!("Completed {}", target.url));
591
592 result
593 });
594
595 handles.push(handle);
596 }
597
598 let mut target_results = Vec::new();
600 for (index, handle) in handles.into_iter().enumerate() {
601 match handle.await {
602 Ok(Ok(result)) => {
603 target_results.push(result);
604 }
605 Ok(Err(e)) => {
606 let target_url = self.targets[index].url.clone();
608 target_results.push(TargetResult {
609 target_url: target_url.clone(),
610 target_index: index,
611 results: K6Results::default(),
612 output_dir: self.base_output.join(format!("target_{}", index + 1)),
613 success: false,
614 error: Some(e.to_string()),
615 });
616 }
617 Err(e) => {
618 let target_url = self.targets[index].url.clone();
620 target_results.push(TargetResult {
621 target_url: target_url.clone(),
622 target_index: index,
623 results: K6Results::default(),
624 output_dir: self.base_output.join(format!("target_{}", index + 1)),
625 success: false,
626 error: Some(format!("Task join error: {}", e)),
627 });
628 }
629 }
630 }
631
632 target_results.sort_by_key(|r| r.target_index);
634
635 let aggregated_metrics = AggregatedMetrics::from_results(&target_results);
637
638 let successful_targets = target_results.iter().filter(|r| r.success).count();
639 let failed_targets = total_targets - successful_targets;
640
641 Ok(AggregatedResults {
642 target_results,
643 total_targets,
644 successful_targets,
645 failed_targets,
646 aggregated_metrics,
647 })
648 }
649
650 fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
652 if let Some(cli_base_path) = &self.base_command.base_path {
654 if cli_base_path.is_empty() {
655 return None;
656 }
657 return Some(cli_base_path.clone());
658 }
659 parser.get_base_path()
661 }
662
663 #[allow(clippy::too_many_arguments)]
665 async fn execute_single_target_internal(
666 _duration: &str,
667 vus: u32,
668 _scenario_str: &str,
669 _operations: &Option<String>,
670 auth: &Option<String>,
671 _headers: &[String],
672 threshold_percentile: &str,
673 threshold_ms: u64,
674 max_error_rate: f64,
675 abort_on_error: bool,
676 abort_on_error_rate: f64,
677 per_op_metrics: bool,
678 verbose: bool,
679 skip_tls_verify: bool,
680 base_path: Option<&String>,
681 target: &TargetConfig,
682 target_index: usize,
683 templates: &[crate::request_gen::RequestTemplate],
684 base_headers: &HashMap<String, String>,
685 scenario: &LoadScenario,
686 duration_secs: u64,
687 base_output: &Path,
688 security_testing_enabled: bool,
689 chunked_request_bodies: bool,
690 target_rps: Option<u32>,
691 no_keep_alive: bool,
692 enhancement_code: &str,
693 local_ips: &str,
694 dns_policy: &str,
695 geo_source_ips: &[String],
696 geo_source_headers: &[String],
697 wafbench_verbatim: bool,
698 ) -> Result<TargetResult> {
699 let mut custom_headers = base_headers.clone();
701 if let Some(target_headers) = &target.headers {
702 custom_headers.extend(target_headers.clone());
703 }
704
705 let force_http1 = crate::request_gen::should_force_k6_http1(
707 wafbench_verbatim,
708 templates,
709 &custom_headers,
710 );
711
712 let auth_header = target.auth.as_ref().or(auth.as_ref()).cloned();
714
715 let k6_config = K6Config {
717 target_url: target.url.clone(),
718 base_path: base_path.cloned(),
719 scenario: scenario.clone(),
720 duration_secs,
721 max_vus: vus,
722 threshold_percentile: threshold_percentile.to_string(),
723 threshold_ms,
724 max_error_rate,
725 auth_header,
726 custom_headers,
727 skip_tls_verify,
728 security_testing_enabled,
729 chunked_request_bodies,
730 target_rps,
731 no_keep_alive,
732 geo_source_ips: geo_source_ips.to_vec(),
733 geo_source_headers: geo_source_headers.to_vec(),
734 };
735
736 let generator = K6ScriptGenerator::new(k6_config, templates.to_vec())
738 .with_abort_valve(abort_on_error, abort_on_error_rate)
739 .with_force_http1(force_http1)
740 .with_per_op_metrics(per_op_metrics);
741 let mut script = generator.generate()?;
742
743 if !enhancement_code.is_empty() {
745 if let Some(pos) = script.find("export const options") {
746 script.insert_str(pos, enhancement_code);
747 }
748 }
749
750 let validation_errors = K6ScriptGenerator::validate_script(&script);
752 if !validation_errors.is_empty() {
753 return Err(BenchError::Other(format!(
754 "Script validation failed for target {}: {}",
755 target.url,
756 validation_errors.join(", ")
757 )));
758 }
759
760 let output_dir = base_output.join(format!("target_{}", target_index + 1));
762 std::fs::create_dir_all(&output_dir)?;
763
764 let script_path = output_dir.join("k6-script.js");
766 std::fs::write(&script_path, script)?;
767
768 let api_port = 0; let executor = K6Executor::new()?
793 .with_local_ips(local_ips.to_string())
794 .with_dns_policy(dns_policy.to_string())
795 .with_discard_response_bodies(true)
796 .with_force_http1(force_http1);
797 let results = executor
798 .execute_with_port(&script_path, Some(&output_dir), verbose, Some(api_port))
799 .await;
800
801 match results {
802 Ok(k6_results) => Ok(TargetResult {
803 target_url: target.url.clone(),
804 target_index,
805 results: k6_results,
806 output_dir,
807 success: true,
808 error: None,
809 }),
810 Err(e) => Ok(TargetResult {
811 target_url: target.url.clone(),
812 target_index,
813 results: K6Results::default(),
814 output_dir,
815 success: false,
816 error: Some(e.to_string()),
817 }),
818 }
819 }
820}
821
822#[cfg(test)]
823mod tests {
824 use super::*;
825
826 #[test]
827 fn test_aggregated_metrics_from_results() {
828 let results = vec![
829 TargetResult {
830 target_url: "http://api1.com".to_string(),
831 target_index: 0,
832 results: K6Results {
833 total_requests: 100,
834 failed_requests: 5,
835 avg_duration_ms: 100.0,
836 p95_duration_ms: 200.0,
837 p99_duration_ms: 300.0,
838 ..Default::default()
839 },
840 output_dir: PathBuf::from("output1"),
841 success: true,
842 error: None,
843 },
844 TargetResult {
845 target_url: "http://api2.com".to_string(),
846 target_index: 1,
847 results: K6Results {
848 total_requests: 200,
849 failed_requests: 10,
850 avg_duration_ms: 150.0,
851 p95_duration_ms: 250.0,
852 p99_duration_ms: 350.0,
853 ..Default::default()
854 },
855 output_dir: PathBuf::from("output2"),
856 success: true,
857 error: None,
858 },
859 ];
860
861 let metrics = AggregatedMetrics::from_results(&results);
862 assert_eq!(metrics.total_requests, 300);
863 assert_eq!(metrics.total_failed_requests, 15);
864 assert_eq!(metrics.avg_duration_ms, 125.0); }
866
867 #[test]
868 fn test_aggregated_metrics_with_failed_targets() {
869 let results = vec![
870 TargetResult {
871 target_url: "http://api1.com".to_string(),
872 target_index: 0,
873 results: K6Results {
874 total_requests: 100,
875 failed_requests: 5,
876 avg_duration_ms: 100.0,
877 p95_duration_ms: 200.0,
878 p99_duration_ms: 300.0,
879 ..Default::default()
880 },
881 output_dir: PathBuf::from("output1"),
882 success: true,
883 error: None,
884 },
885 TargetResult {
886 target_url: "http://api2.com".to_string(),
887 target_index: 1,
888 results: K6Results::default(),
889 output_dir: PathBuf::from("output2"),
890 success: false,
891 error: Some("Network error".to_string()),
892 },
893 ];
894
895 let metrics = AggregatedMetrics::from_results(&results);
896 assert_eq!(metrics.total_requests, 100);
898 assert_eq!(metrics.total_failed_requests, 5);
899 assert_eq!(metrics.avg_duration_ms, 100.0);
900 }
901
902 #[test]
903 fn test_aggregated_metrics_empty_results() {
904 let results = vec![];
905 let metrics = AggregatedMetrics::from_results(&results);
906 assert_eq!(metrics.total_requests, 0);
907 assert_eq!(metrics.total_failed_requests, 0);
908 assert_eq!(metrics.avg_duration_ms, 0.0);
909 assert_eq!(metrics.error_rate, 0.0);
910 }
911
912 #[test]
913 fn test_aggregated_metrics_error_rate_calculation() {
914 let results = vec![TargetResult {
915 target_url: "http://api1.com".to_string(),
916 target_index: 0,
917 results: K6Results {
918 total_requests: 1000,
919 failed_requests: 50,
920 avg_duration_ms: 100.0,
921 p95_duration_ms: 200.0,
922 p99_duration_ms: 300.0,
923 ..Default::default()
924 },
925 output_dir: PathBuf::from("output1"),
926 success: true,
927 error: None,
928 }];
929
930 let metrics = AggregatedMetrics::from_results(&results);
931 assert_eq!(metrics.error_rate, 5.0); }
933
934 #[test]
935 fn test_aggregated_metrics_p95_p99_calculation() {
936 let results = vec![
937 TargetResult {
938 target_url: "http://api1.com".to_string(),
939 target_index: 0,
940 results: K6Results {
941 total_requests: 100,
942 failed_requests: 0,
943 avg_duration_ms: 100.0,
944 p95_duration_ms: 150.0,
945 p99_duration_ms: 200.0,
946 ..Default::default()
947 },
948 output_dir: PathBuf::from("output1"),
949 success: true,
950 error: None,
951 },
952 TargetResult {
953 target_url: "http://api2.com".to_string(),
954 target_index: 1,
955 results: K6Results {
956 total_requests: 100,
957 failed_requests: 0,
958 avg_duration_ms: 200.0,
959 p95_duration_ms: 250.0,
960 p99_duration_ms: 300.0,
961 ..Default::default()
962 },
963 output_dir: PathBuf::from("output2"),
964 success: true,
965 error: None,
966 },
967 TargetResult {
968 target_url: "http://api3.com".to_string(),
969 target_index: 2,
970 results: K6Results {
971 total_requests: 100,
972 failed_requests: 0,
973 avg_duration_ms: 300.0,
974 p95_duration_ms: 350.0,
975 p99_duration_ms: 400.0,
976 ..Default::default()
977 },
978 output_dir: PathBuf::from("output3"),
979 success: true,
980 error: None,
981 },
982 ];
983
984 let metrics = AggregatedMetrics::from_results(&results);
985 assert_eq!(metrics.p95_duration_ms, 350.0);
988 assert_eq!(metrics.p99_duration_ms, 400.0);
989 }
990
991 #[test]
992 fn estimated_wall_clock_is_batches_times_duration() {
993 let (batches, wall) = ParallelExecutor::estimated_wall_clock(64, 10, 300);
995 assert_eq!(batches, 7);
996 assert_eq!(wall, 2100);
997 assert_eq!(ParallelExecutor::format_wall_clock(2100), "35m00s");
998 assert_eq!(ParallelExecutor::estimated_wall_clock(10, 10, 300), (1, 300));
999 assert_eq!(ParallelExecutor::estimated_wall_clock(0, 10, 300), (0, 0));
1000 assert_eq!(ParallelExecutor::format_wall_clock(45), "45s");
1001 assert_eq!(ParallelExecutor::format_wall_clock(3661), "1h01m01s");
1002 }
1003
1004 #[test]
1005 fn parallel_k6_spawn_sets_force_http1() {
1006 let src = include_str!("parallel_executor.rs");
1007 assert!(
1008 src.contains("with_force_http1(force_http1)"),
1009 "multi-target k6 spawn must pass GODEBUG=http2client=0 when Connection headers are present"
1010 );
1011 assert!(
1012 src.contains("Estimated wall clock"),
1013 "multi-target start must print ceil(targets/concurrency)*duration"
1014 );
1015 assert!(
1016 src.contains("with_per_op_metrics(per_op_metrics)"),
1017 "multi-target k6 spawn must apply Round-65 per-op metrics collapse (#79)"
1018 );
1019 assert!(
1020 src.contains("resolve_max_concurrency"),
1021 "multi-target must auto-cap concurrency for huge specs (#79)"
1022 );
1023 }
1024}