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 async fn execute_all(&self) -> Result<AggregatedResults> {
193 let total_targets = self.targets.len();
194 TerminalReporter::print_progress(&format!(
195 "Starting parallel execution for {} targets (max concurrency: {})",
196 total_targets, self.max_concurrency
197 ));
198
199 if !K6Executor::is_k6_installed() {
201 TerminalReporter::print_error("k6 is not installed");
202 TerminalReporter::print_warning(
203 "Install k6 from: https://k6.io/docs/get-started/installation/",
204 );
205 return Err(BenchError::K6NotFound);
206 }
207
208 let spec_supplied =
213 !self.base_command.spec.is_empty() || self.base_command.spec_dir.is_some();
214 let verbatim = self.base_command.wafbench_verbatim;
215
216 let (templates, parser) = if verbatim {
217 let verbatim_templates = self.base_command.load_verbatim_templates()?;
218 if verbatim_templates.is_empty() {
219 return Err(BenchError::Other(
220 "--wafbench-verbatim was set but no traffic cases were loaded. Check \
221 --wafbench-dir points at a file, directory or glob containing cases with \
222 a `request.uri`."
223 .to_string(),
224 ));
225 }
226 TerminalReporter::print_success(&format!(
227 "Verbatim mode: {} request(s) will be sent exactly as written (spec endpoints not used)",
228 verbatim_templates.len()
229 ));
230 let parser = if spec_supplied {
231 TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
232 let merged_spec = self.base_command.load_and_merge_specs().await?;
233 TerminalReporter::print_success("Specification(s) loaded (base path only)");
234 SpecParser::from_spec(merged_spec)
235 } else {
236 SpecParser::from_spec(OpenApiSpec {
237 spec: Default::default(),
238 file_path: None,
239 raw_document: None,
240 })
241 };
242 (verbatim_templates, parser)
243 } else {
244 TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
245 let merged_spec = self.base_command.load_and_merge_specs().await?;
246 let parser = SpecParser::from_spec(merged_spec);
247 TerminalReporter::print_success("Specification(s) loaded");
248
249 let operations = if let Some(filter) = &self.base_command.operations {
250 parser.filter_operations(filter)?
251 } else {
252 parser.get_operations()
253 };
254
255 if operations.is_empty() {
256 return Err(BenchError::Other("No operations found in spec".to_string()));
257 }
258
259 TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
260
261 TerminalReporter::print_progress("Generating request templates...");
262 let templates: Vec<_> = operations
263 .iter()
264 .map(RequestGenerator::generate_template)
265 .collect::<Result<Vec<_>>>()?;
266 TerminalReporter::print_success("Request templates generated");
267 (templates, parser)
268 };
269
270 let mut per_target_data: HashMap<
275 PathBuf,
276 (Vec<crate::request_gen::RequestTemplate>, Option<String>),
277 > = HashMap::new();
278 if !verbatim {
279 let mut unique_specs: Vec<PathBuf> = Vec::new();
280 for t in &self.targets {
281 if let Some(spec_path) = &t.spec {
282 if !unique_specs.contains(spec_path) {
283 unique_specs.push(spec_path.clone());
284 }
285 }
286 }
287 for spec_path in &unique_specs {
288 TerminalReporter::print_progress(&format!(
289 "Loading per-target spec: {}",
290 spec_path.display()
291 ));
292 match SpecParser::from_file(spec_path).await {
293 Ok(target_parser) => {
294 let target_ops = if let Some(filter) = &self.base_command.operations {
295 match target_parser.filter_operations(filter) {
296 Ok(ops) => ops,
297 Err(e) => {
298 TerminalReporter::print_warning(&format!(
299 "Failed to filter operations from {}: {}. Using shared spec.",
300 spec_path.display(),
301 e
302 ));
303 continue;
304 }
305 }
306 } else {
307 target_parser.get_operations()
308 };
309 let target_templates: Vec<_> = match target_ops
310 .iter()
311 .map(RequestGenerator::generate_template)
312 .collect::<Result<Vec<_>>>()
313 {
314 Ok(t) => t,
315 Err(e) => {
316 TerminalReporter::print_warning(&format!(
317 "Failed to generate templates from {}: {}. Using shared spec.",
318 spec_path.display(),
319 e
320 ));
321 continue;
322 }
323 };
324 let target_base_path = if let Some(cli_bp) = &self.base_command.base_path {
325 if cli_bp.is_empty() {
326 None
327 } else {
328 Some(cli_bp.clone())
329 }
330 } else {
331 target_parser.get_base_path()
332 };
333 TerminalReporter::print_success(&format!(
334 "Loaded {} operations from {}",
335 target_templates.len(),
336 spec_path.display()
337 ));
338 per_target_data
339 .insert(spec_path.clone(), (target_templates, target_base_path));
340 }
341 Err(e) => {
342 TerminalReporter::print_warning(&format!(
343 "Failed to load per-target spec {}: {}. Targets using this spec will use the shared spec.",
344 spec_path.display(),
345 e
346 ));
347 }
348 }
349 }
350 }
351
352 let base_headers = self.base_command.parse_headers()?;
354
355 let base_path = self.resolve_base_path(&parser);
357 if let Some(ref bp) = base_path {
358 TerminalReporter::print_progress(&format!("Using base path: {}", bp));
359 }
360
361 let scenario = LoadScenario::from_str(&self.base_command.scenario)
363 .map_err(BenchError::InvalidScenario)?;
364
365 let duration_secs_val = BenchCommand::parse_duration(&self.base_command.duration)?;
366
367 let security_testing_enabled_val = self.base_command.security_testing_enabled();
368
369 let has_advanced_features = self.base_command.data_file.is_some()
371 || self.base_command.error_rate.is_some()
372 || self.base_command.security_testing_enabled()
373 || self.base_command.parallel_create.is_some();
374
375 let enhancement_code = if has_advanced_features {
376 let dummy_script = "export const options = {};";
377 let enhanced = self.base_command.generate_enhanced_script(dummy_script)?;
378 if let Some(pos) = enhanced.find("export const options") {
379 enhanced[..pos].to_string()
380 } else {
381 String::new()
382 }
383 } else {
384 String::new()
385 };
386
387 let semaphore = Arc::new(Semaphore::new(self.max_concurrency));
389 let multi_progress = MultiProgress::new();
390
391 let progress_bars: Vec<ProgressBar> = (0..total_targets)
393 .map(|i| {
394 let pb = multi_progress.add(ProgressBar::new(1));
395 pb.set_style(
396 ProgressStyle::default_bar()
397 .template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} {msg}")
398 .unwrap(),
399 );
400 pb.set_message(format!("Target {}", i + 1));
401 pb
402 })
403 .collect();
404
405 let mut handles: Vec<JoinHandle<Result<TargetResult>>> = Vec::new();
407
408 for (index, target) in self.targets.iter().enumerate() {
409 let target = target.clone();
410 let duration = self.base_command.duration.clone();
412 let vus = self.base_command.vus;
413 let scenario_str = self.base_command.scenario.clone();
414 let operations = self.base_command.operations.clone();
415 let auth = self.base_command.auth.clone();
416 let headers = self.base_command.headers.clone();
417 let threshold_percentile = self.base_command.threshold_percentile.clone();
418 let threshold_ms = self.base_command.threshold_ms;
419 let max_error_rate = self.base_command.max_error_rate;
420 let abort_on_error = self.base_command.abort_on_error;
425 let abort_on_error_rate = self.base_command.abort_on_error_rate;
426 let verbose = self.base_command.verbose;
427 let skip_tls_verify = self.base_command.skip_tls_verify;
428 let chunked_request_bodies = self.base_command.chunked_request_bodies;
429 let target_rps = self.base_command.target_rps;
430 let no_keep_alive = self.base_command.no_keep_alive;
431 let local_ips = self.base_command.source_ips.join(",");
438 let dns_policy = self.base_command.dns_policy.clone().unwrap_or_default();
439 let geo_source_ips = self.base_command.geo_source_ips.clone();
440 let geo_source_headers = self.base_command.geo_source_headers.clone();
441
442 let (templates, base_path) = if verbatim {
446 (templates.clone(), base_path.clone())
447 } else if let Some(spec_path) = &target.spec {
448 if let Some((t, bp)) = per_target_data.get(spec_path) {
449 (t.clone(), bp.clone())
450 } else {
451 (templates.clone(), base_path.clone())
452 }
453 } else {
454 (templates.clone(), base_path.clone())
455 };
456
457 let base_headers = base_headers.clone();
458 let scenario = scenario.clone();
459 let duration_secs = duration_secs_val;
460 let base_output = self.base_output.clone();
461 let semaphore = semaphore.clone();
462 let progress_bar = progress_bars[index].clone();
463 let target_index = index;
464 let security_testing_enabled = security_testing_enabled_val;
465 let enhancement_code = enhancement_code.clone();
466
467 let handle = tokio::spawn(async move {
468 let _permit = semaphore.acquire().await.map_err(|e| {
470 BenchError::Other(format!("Failed to acquire semaphore: {}", e))
471 })?;
472
473 progress_bar.set_message(format!("Testing {}", target.url));
474
475 let result = Self::execute_single_target_internal(
477 &duration,
478 vus,
479 &scenario_str,
480 &operations,
481 &auth,
482 &headers,
483 &threshold_percentile,
484 threshold_ms,
485 max_error_rate,
486 abort_on_error,
487 abort_on_error_rate,
488 verbose,
489 skip_tls_verify,
490 base_path.as_ref(),
491 &target,
492 target_index,
493 &templates,
494 &base_headers,
495 &scenario,
496 duration_secs,
497 &base_output,
498 security_testing_enabled,
499 chunked_request_bodies,
500 target_rps,
501 no_keep_alive,
502 &enhancement_code,
503 &local_ips,
504 &dns_policy,
505 &geo_source_ips,
506 &geo_source_headers,
507 )
508 .await;
509
510 progress_bar.inc(1);
511 progress_bar.finish_with_message(format!("Completed {}", target.url));
512
513 result
514 });
515
516 handles.push(handle);
517 }
518
519 let mut target_results = Vec::new();
521 for (index, handle) in handles.into_iter().enumerate() {
522 match handle.await {
523 Ok(Ok(result)) => {
524 target_results.push(result);
525 }
526 Ok(Err(e)) => {
527 let target_url = self.targets[index].url.clone();
529 target_results.push(TargetResult {
530 target_url: target_url.clone(),
531 target_index: index,
532 results: K6Results::default(),
533 output_dir: self.base_output.join(format!("target_{}", index + 1)),
534 success: false,
535 error: Some(e.to_string()),
536 });
537 }
538 Err(e) => {
539 let target_url = self.targets[index].url.clone();
541 target_results.push(TargetResult {
542 target_url: target_url.clone(),
543 target_index: index,
544 results: K6Results::default(),
545 output_dir: self.base_output.join(format!("target_{}", index + 1)),
546 success: false,
547 error: Some(format!("Task join error: {}", e)),
548 });
549 }
550 }
551 }
552
553 target_results.sort_by_key(|r| r.target_index);
555
556 let aggregated_metrics = AggregatedMetrics::from_results(&target_results);
558
559 let successful_targets = target_results.iter().filter(|r| r.success).count();
560 let failed_targets = total_targets - successful_targets;
561
562 Ok(AggregatedResults {
563 target_results,
564 total_targets,
565 successful_targets,
566 failed_targets,
567 aggregated_metrics,
568 })
569 }
570
571 fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
573 if let Some(cli_base_path) = &self.base_command.base_path {
575 if cli_base_path.is_empty() {
576 return None;
577 }
578 return Some(cli_base_path.clone());
579 }
580 parser.get_base_path()
582 }
583
584 #[allow(clippy::too_many_arguments)]
586 async fn execute_single_target_internal(
587 _duration: &str,
588 vus: u32,
589 _scenario_str: &str,
590 _operations: &Option<String>,
591 auth: &Option<String>,
592 _headers: &[String],
593 threshold_percentile: &str,
594 threshold_ms: u64,
595 max_error_rate: f64,
596 abort_on_error: bool,
597 abort_on_error_rate: f64,
598 verbose: bool,
599 skip_tls_verify: bool,
600 base_path: Option<&String>,
601 target: &TargetConfig,
602 target_index: usize,
603 templates: &[crate::request_gen::RequestTemplate],
604 base_headers: &HashMap<String, String>,
605 scenario: &LoadScenario,
606 duration_secs: u64,
607 base_output: &Path,
608 security_testing_enabled: bool,
609 chunked_request_bodies: bool,
610 target_rps: Option<u32>,
611 no_keep_alive: bool,
612 enhancement_code: &str,
613 local_ips: &str,
614 dns_policy: &str,
615 geo_source_ips: &[String],
616 geo_source_headers: &[String],
617 ) -> Result<TargetResult> {
618 let mut custom_headers = base_headers.clone();
620 if let Some(target_headers) = &target.headers {
621 custom_headers.extend(target_headers.clone());
622 }
623
624 let auth_header = target.auth.as_ref().or(auth.as_ref()).cloned();
626
627 let k6_config = K6Config {
629 target_url: target.url.clone(),
630 base_path: base_path.cloned(),
631 scenario: scenario.clone(),
632 duration_secs,
633 max_vus: vus,
634 threshold_percentile: threshold_percentile.to_string(),
635 threshold_ms,
636 max_error_rate,
637 auth_header,
638 custom_headers,
639 skip_tls_verify,
640 security_testing_enabled,
641 chunked_request_bodies,
642 target_rps,
643 no_keep_alive,
644 geo_source_ips: geo_source_ips.to_vec(),
645 geo_source_headers: geo_source_headers.to_vec(),
646 };
647
648 let generator = K6ScriptGenerator::new(k6_config, templates.to_vec())
650 .with_abort_valve(abort_on_error, abort_on_error_rate);
651 let mut script = generator.generate()?;
652
653 if !enhancement_code.is_empty() {
655 if let Some(pos) = script.find("export const options") {
656 script.insert_str(pos, enhancement_code);
657 }
658 }
659
660 let validation_errors = K6ScriptGenerator::validate_script(&script);
662 if !validation_errors.is_empty() {
663 return Err(BenchError::Other(format!(
664 "Script validation failed for target {}: {}",
665 target.url,
666 validation_errors.join(", ")
667 )));
668 }
669
670 let output_dir = base_output.join(format!("target_{}", target_index + 1));
672 std::fs::create_dir_all(&output_dir)?;
673
674 let script_path = output_dir.join("k6-script.js");
676 std::fs::write(&script_path, script)?;
677
678 let api_port = 0; let executor = K6Executor::new()?
703 .with_local_ips(local_ips.to_string())
704 .with_dns_policy(dns_policy.to_string())
705 .with_discard_response_bodies(true);
706 let results = executor
707 .execute_with_port(&script_path, Some(&output_dir), verbose, Some(api_port))
708 .await;
709
710 match results {
711 Ok(k6_results) => Ok(TargetResult {
712 target_url: target.url.clone(),
713 target_index,
714 results: k6_results,
715 output_dir,
716 success: true,
717 error: None,
718 }),
719 Err(e) => Ok(TargetResult {
720 target_url: target.url.clone(),
721 target_index,
722 results: K6Results::default(),
723 output_dir,
724 success: false,
725 error: Some(e.to_string()),
726 }),
727 }
728 }
729}
730
731#[cfg(test)]
732mod tests {
733 use super::*;
734
735 #[test]
736 fn test_aggregated_metrics_from_results() {
737 let results = vec![
738 TargetResult {
739 target_url: "http://api1.com".to_string(),
740 target_index: 0,
741 results: K6Results {
742 total_requests: 100,
743 failed_requests: 5,
744 avg_duration_ms: 100.0,
745 p95_duration_ms: 200.0,
746 p99_duration_ms: 300.0,
747 ..Default::default()
748 },
749 output_dir: PathBuf::from("output1"),
750 success: true,
751 error: None,
752 },
753 TargetResult {
754 target_url: "http://api2.com".to_string(),
755 target_index: 1,
756 results: K6Results {
757 total_requests: 200,
758 failed_requests: 10,
759 avg_duration_ms: 150.0,
760 p95_duration_ms: 250.0,
761 p99_duration_ms: 350.0,
762 ..Default::default()
763 },
764 output_dir: PathBuf::from("output2"),
765 success: true,
766 error: None,
767 },
768 ];
769
770 let metrics = AggregatedMetrics::from_results(&results);
771 assert_eq!(metrics.total_requests, 300);
772 assert_eq!(metrics.total_failed_requests, 15);
773 assert_eq!(metrics.avg_duration_ms, 125.0); }
775
776 #[test]
777 fn test_aggregated_metrics_with_failed_targets() {
778 let results = vec![
779 TargetResult {
780 target_url: "http://api1.com".to_string(),
781 target_index: 0,
782 results: K6Results {
783 total_requests: 100,
784 failed_requests: 5,
785 avg_duration_ms: 100.0,
786 p95_duration_ms: 200.0,
787 p99_duration_ms: 300.0,
788 ..Default::default()
789 },
790 output_dir: PathBuf::from("output1"),
791 success: true,
792 error: None,
793 },
794 TargetResult {
795 target_url: "http://api2.com".to_string(),
796 target_index: 1,
797 results: K6Results::default(),
798 output_dir: PathBuf::from("output2"),
799 success: false,
800 error: Some("Network error".to_string()),
801 },
802 ];
803
804 let metrics = AggregatedMetrics::from_results(&results);
805 assert_eq!(metrics.total_requests, 100);
807 assert_eq!(metrics.total_failed_requests, 5);
808 assert_eq!(metrics.avg_duration_ms, 100.0);
809 }
810
811 #[test]
812 fn test_aggregated_metrics_empty_results() {
813 let results = vec![];
814 let metrics = AggregatedMetrics::from_results(&results);
815 assert_eq!(metrics.total_requests, 0);
816 assert_eq!(metrics.total_failed_requests, 0);
817 assert_eq!(metrics.avg_duration_ms, 0.0);
818 assert_eq!(metrics.error_rate, 0.0);
819 }
820
821 #[test]
822 fn test_aggregated_metrics_error_rate_calculation() {
823 let results = vec![TargetResult {
824 target_url: "http://api1.com".to_string(),
825 target_index: 0,
826 results: K6Results {
827 total_requests: 1000,
828 failed_requests: 50,
829 avg_duration_ms: 100.0,
830 p95_duration_ms: 200.0,
831 p99_duration_ms: 300.0,
832 ..Default::default()
833 },
834 output_dir: PathBuf::from("output1"),
835 success: true,
836 error: None,
837 }];
838
839 let metrics = AggregatedMetrics::from_results(&results);
840 assert_eq!(metrics.error_rate, 5.0); }
842
843 #[test]
844 fn test_aggregated_metrics_p95_p99_calculation() {
845 let results = vec![
846 TargetResult {
847 target_url: "http://api1.com".to_string(),
848 target_index: 0,
849 results: K6Results {
850 total_requests: 100,
851 failed_requests: 0,
852 avg_duration_ms: 100.0,
853 p95_duration_ms: 150.0,
854 p99_duration_ms: 200.0,
855 ..Default::default()
856 },
857 output_dir: PathBuf::from("output1"),
858 success: true,
859 error: None,
860 },
861 TargetResult {
862 target_url: "http://api2.com".to_string(),
863 target_index: 1,
864 results: K6Results {
865 total_requests: 100,
866 failed_requests: 0,
867 avg_duration_ms: 200.0,
868 p95_duration_ms: 250.0,
869 p99_duration_ms: 300.0,
870 ..Default::default()
871 },
872 output_dir: PathBuf::from("output2"),
873 success: true,
874 error: None,
875 },
876 TargetResult {
877 target_url: "http://api3.com".to_string(),
878 target_index: 2,
879 results: K6Results {
880 total_requests: 100,
881 failed_requests: 0,
882 avg_duration_ms: 300.0,
883 p95_duration_ms: 350.0,
884 p99_duration_ms: 400.0,
885 ..Default::default()
886 },
887 output_dir: PathBuf::from("output3"),
888 success: true,
889 error: None,
890 },
891 ];
892
893 let metrics = AggregatedMetrics::from_results(&results);
894 assert_eq!(metrics.p95_duration_ms, 350.0);
897 assert_eq!(metrics.p99_duration_ms, 400.0);
898 }
899}