Skip to main content

mockforge_bench/
parallel_executor.rs

1//! Parallel execution engine for multi-target bench testing
2//!
3//! Executes load tests against multiple targets in parallel with configurable
4//! concurrency limits. Uses tokio for async execution and semaphores for
5//! backpressure control.
6
7use 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 std::collections::HashMap;
18use std::path::{Path, PathBuf};
19use std::str::FromStr;
20use std::sync::Arc;
21use tokio::sync::Semaphore;
22use tokio::task::JoinHandle;
23
24/// Result for a single target execution
25#[derive(Debug, Clone)]
26pub struct TargetResult {
27    /// Target URL that was tested
28    pub target_url: String,
29    /// Index of the target (for ordering)
30    pub target_index: usize,
31    /// k6 test results
32    pub results: K6Results,
33    /// Output directory for this target
34    pub output_dir: PathBuf,
35    /// Whether the test succeeded
36    pub success: bool,
37    /// Error message if test failed
38    pub error: Option<String>,
39}
40
41/// Aggregated results from all target executions
42#[derive(Debug, Clone)]
43pub struct AggregatedResults {
44    /// Results for each target
45    pub target_results: Vec<TargetResult>,
46    /// Overall statistics
47    pub total_targets: usize,
48    pub successful_targets: usize,
49    pub failed_targets: usize,
50    /// Aggregated metrics across all targets
51    pub aggregated_metrics: AggregatedMetrics,
52}
53
54/// Aggregated metrics across all targets
55#[derive(Debug, Clone)]
56pub struct AggregatedMetrics {
57    /// Total requests across all targets
58    pub total_requests: u64,
59    /// Total failed requests across all targets
60    pub total_failed_requests: u64,
61    /// Average response time across all targets (ms)
62    pub avg_duration_ms: f64,
63    /// p95 response time across all targets (ms)
64    pub p95_duration_ms: f64,
65    /// p99 response time across all targets (ms)
66    pub p99_duration_ms: f64,
67    /// Overall error rate percentage
68    pub error_rate: f64,
69    /// Total RPS across all targets
70    pub total_rps: f64,
71    /// Average RPS per target
72    pub avg_rps: f64,
73    /// Total max VUs across all targets
74    pub total_vus_max: u32,
75    /// Total connections opened across all targets (Issue #79 round 12 —
76    /// Srikanth's multi-target bench output was missing the CPS / connection
77    /// counts that single-target runs surface).
78    pub total_connections_opened: u64,
79    /// Total iterations completed across all targets.
80    pub total_iterations_completed: u64,
81}
82
83impl AggregatedMetrics {
84    /// Calculate aggregated metrics from target results
85    fn from_results(results: &[TargetResult]) -> Self {
86        let mut total_requests = 0u64;
87        let mut total_failed_requests = 0u64;
88        let mut durations = Vec::new();
89        let mut p95_values = Vec::new();
90        let mut p99_values = Vec::new();
91        let mut total_rps = 0.0f64;
92        let mut total_vus_max = 0u32;
93        let mut total_connections_opened = 0u64;
94        let mut total_iterations_completed = 0u64;
95        let mut successful_count = 0usize;
96
97        for result in results {
98            if result.success {
99                total_requests += result.results.total_requests;
100                total_failed_requests += result.results.failed_requests;
101                durations.push(result.results.avg_duration_ms);
102                p95_values.push(result.results.p95_duration_ms);
103                p99_values.push(result.results.p99_duration_ms);
104                total_rps += result.results.rps;
105                total_vus_max += result.results.vus_max;
106                total_connections_opened += result.results.tcp_connect_samples;
107                total_iterations_completed += result.results.iterations_completed;
108                successful_count += 1;
109            }
110        }
111
112        let avg_duration_ms = if !durations.is_empty() {
113            durations.iter().sum::<f64>() / durations.len() as f64
114        } else {
115            0.0
116        };
117
118        let p95_duration_ms = if !p95_values.is_empty() {
119            p95_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
120            let index = (p95_values.len() as f64 * 0.95).ceil() as usize - 1;
121            p95_values[index.min(p95_values.len() - 1)]
122        } else {
123            0.0
124        };
125
126        let p99_duration_ms = if !p99_values.is_empty() {
127            p99_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
128            let index = (p99_values.len() as f64 * 0.99).ceil() as usize - 1;
129            p99_values[index.min(p99_values.len() - 1)]
130        } else {
131            0.0
132        };
133
134        let error_rate = if total_requests > 0 {
135            (total_failed_requests as f64 / total_requests as f64) * 100.0
136        } else {
137            0.0
138        };
139
140        let avg_rps = if successful_count > 0 {
141            total_rps / successful_count as f64
142        } else {
143            0.0
144        };
145
146        Self {
147            total_requests,
148            total_failed_requests,
149            avg_duration_ms,
150            p95_duration_ms,
151            p99_duration_ms,
152            error_rate,
153            total_rps,
154            avg_rps,
155            total_vus_max,
156            total_connections_opened,
157            total_iterations_completed,
158        }
159    }
160}
161
162/// Parallel executor for multi-target bench testing
163pub struct ParallelExecutor {
164    /// Base command configuration (shared across all targets)
165    base_command: BenchCommand,
166    /// List of targets to test
167    targets: Vec<TargetConfig>,
168    /// Maximum number of concurrent executions
169    max_concurrency: usize,
170    /// Base output directory
171    base_output: PathBuf,
172}
173
174impl ParallelExecutor {
175    /// Create a new parallel executor
176    pub fn new(
177        base_command: BenchCommand,
178        targets: Vec<TargetConfig>,
179        max_concurrency: usize,
180    ) -> Self {
181        let base_output = base_command.output.clone();
182        Self {
183            base_command,
184            targets,
185            max_concurrency,
186            base_output,
187        }
188    }
189
190    /// Execute tests against all targets in parallel
191    pub async fn execute_all(&self) -> Result<AggregatedResults> {
192        let total_targets = self.targets.len();
193        TerminalReporter::print_progress(&format!(
194            "Starting parallel execution for {} targets (max concurrency: {})",
195            total_targets, self.max_concurrency
196        ));
197
198        // Validate k6 installation
199        if !K6Executor::is_k6_installed() {
200            TerminalReporter::print_error("k6 is not installed");
201            TerminalReporter::print_warning(
202                "Install k6 from: https://k6.io/docs/get-started/installation/",
203            );
204            return Err(BenchError::K6NotFound);
205        }
206
207        // Load and parse spec(s) (shared across all targets)
208        TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
209        let merged_spec = self.base_command.load_and_merge_specs().await?;
210        let parser = SpecParser::from_spec(merged_spec);
211        TerminalReporter::print_success("Specification(s) loaded");
212
213        // Get operations
214        let operations = if let Some(filter) = &self.base_command.operations {
215            parser.filter_operations(filter)?
216        } else {
217            parser.get_operations()
218        };
219
220        if operations.is_empty() {
221            return Err(BenchError::Other("No operations found in spec".to_string()));
222        }
223
224        TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
225
226        // Generate request templates (shared across all targets)
227        TerminalReporter::print_progress("Generating request templates...");
228        let templates: Vec<_> = operations
229            .iter()
230            .map(RequestGenerator::generate_template)
231            .collect::<Result<Vec<_>>>()?;
232        TerminalReporter::print_success("Request templates generated");
233
234        // Pre-load per-target specs
235        let mut per_target_data: HashMap<
236            PathBuf,
237            (Vec<crate::request_gen::RequestTemplate>, Option<String>),
238        > = HashMap::new();
239        {
240            let mut unique_specs: Vec<PathBuf> = Vec::new();
241            for t in &self.targets {
242                if let Some(spec_path) = &t.spec {
243                    if !unique_specs.contains(spec_path) {
244                        unique_specs.push(spec_path.clone());
245                    }
246                }
247            }
248            for spec_path in &unique_specs {
249                TerminalReporter::print_progress(&format!(
250                    "Loading per-target spec: {}",
251                    spec_path.display()
252                ));
253                match SpecParser::from_file(spec_path).await {
254                    Ok(target_parser) => {
255                        let target_ops = if let Some(filter) = &self.base_command.operations {
256                            match target_parser.filter_operations(filter) {
257                                Ok(ops) => ops,
258                                Err(e) => {
259                                    TerminalReporter::print_warning(&format!(
260                                        "Failed to filter operations from {}: {}. Using shared spec.",
261                                        spec_path.display(),
262                                        e
263                                    ));
264                                    continue;
265                                }
266                            }
267                        } else {
268                            target_parser.get_operations()
269                        };
270                        let target_templates: Vec<_> = match target_ops
271                            .iter()
272                            .map(RequestGenerator::generate_template)
273                            .collect::<Result<Vec<_>>>()
274                        {
275                            Ok(t) => t,
276                            Err(e) => {
277                                TerminalReporter::print_warning(&format!(
278                                    "Failed to generate templates from {}: {}. Using shared spec.",
279                                    spec_path.display(),
280                                    e
281                                ));
282                                continue;
283                            }
284                        };
285                        let target_base_path = if let Some(cli_bp) = &self.base_command.base_path {
286                            if cli_bp.is_empty() {
287                                None
288                            } else {
289                                Some(cli_bp.clone())
290                            }
291                        } else {
292                            target_parser.get_base_path()
293                        };
294                        TerminalReporter::print_success(&format!(
295                            "Loaded {} operations from {}",
296                            target_templates.len(),
297                            spec_path.display()
298                        ));
299                        per_target_data
300                            .insert(spec_path.clone(), (target_templates, target_base_path));
301                    }
302                    Err(e) => {
303                        TerminalReporter::print_warning(&format!(
304                            "Failed to load per-target spec {}: {}. Targets using this spec will use the shared spec.",
305                            spec_path.display(),
306                            e
307                        ));
308                    }
309                }
310            }
311        }
312
313        // Parse base headers
314        let base_headers = self.base_command.parse_headers()?;
315
316        // Resolve base path (CLI option takes priority over spec's servers URL)
317        let base_path = self.resolve_base_path(&parser);
318        if let Some(ref bp) = base_path {
319            TerminalReporter::print_progress(&format!("Using base path: {}", bp));
320        }
321
322        // Parse scenario
323        let scenario = LoadScenario::from_str(&self.base_command.scenario)
324            .map_err(BenchError::InvalidScenario)?;
325
326        let duration_secs_val = BenchCommand::parse_duration(&self.base_command.duration)?;
327
328        // Compute security testing flag
329        let security_testing_enabled_val =
330            self.base_command.security_test || self.base_command.wafbench_dir.is_some();
331
332        // Pre-compute enhancement code once (same for all targets)
333        let has_advanced_features = self.base_command.data_file.is_some()
334            || self.base_command.error_rate.is_some()
335            || self.base_command.security_test
336            || self.base_command.parallel_create.is_some()
337            || self.base_command.wafbench_dir.is_some();
338
339        let enhancement_code = if has_advanced_features {
340            let dummy_script = "export const options = {};";
341            let enhanced = self.base_command.generate_enhanced_script(dummy_script)?;
342            if let Some(pos) = enhanced.find("export const options") {
343                enhanced[..pos].to_string()
344            } else {
345                String::new()
346            }
347        } else {
348            String::new()
349        };
350
351        // Create semaphore for concurrency control
352        let semaphore = Arc::new(Semaphore::new(self.max_concurrency));
353        let multi_progress = MultiProgress::new();
354
355        // Create progress bars for each target
356        let progress_bars: Vec<ProgressBar> = (0..total_targets)
357            .map(|i| {
358                let pb = multi_progress.add(ProgressBar::new(1));
359                pb.set_style(
360                    ProgressStyle::default_bar()
361                        .template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} {msg}")
362                        .unwrap(),
363                );
364                pb.set_message(format!("Target {}", i + 1));
365                pb
366            })
367            .collect();
368
369        // Spawn tasks for each target
370        let mut handles: Vec<JoinHandle<Result<TargetResult>>> = Vec::new();
371
372        for (index, target) in self.targets.iter().enumerate() {
373            let target = target.clone();
374            // Clone necessary fields from base_command instead of passing reference
375            let duration = self.base_command.duration.clone();
376            let vus = self.base_command.vus;
377            let scenario_str = self.base_command.scenario.clone();
378            let operations = self.base_command.operations.clone();
379            let auth = self.base_command.auth.clone();
380            let headers = self.base_command.headers.clone();
381            let threshold_percentile = self.base_command.threshold_percentile.clone();
382            let threshold_ms = self.base_command.threshold_ms;
383            let max_error_rate = self.base_command.max_error_rate;
384            // Issue #79 r62 (Srikanth on 0.3.209): the round-60 abort valve
385            // stopped his WAF stress runs at ~2min because a legitimate high
386            // rejection rate crossed 0.95. Thread the opt-out/threshold through
387            // the per-target path so `--no-abort-on-error` reaches the k6 script.
388            let abort_on_error = self.base_command.abort_on_error;
389            let abort_on_error_rate = self.base_command.abort_on_error_rate;
390            let verbose = self.base_command.verbose;
391            let skip_tls_verify = self.base_command.skip_tls_verify;
392            let chunked_request_bodies = self.base_command.chunked_request_bodies;
393            let target_rps = self.base_command.target_rps;
394            let no_keep_alive = self.base_command.no_keep_alive;
395            // Issue #79 r54 (Srikanth on 0.3.200): `--source-ip` was silently
396            // dropped in multi-target (`--targets-file`) mode because this
397            // executor built `K6Executor::new()` without `.with_local_ips` and
398            // the per-target BenchCommand clone zeroed `source_ips`. Thread the
399            // source IPs (as the k6 `--local-ips` list) and geo config through
400            // to each target's run.
401            let local_ips = self.base_command.source_ips.join(",");
402            let dns_policy = self.base_command.dns_policy.clone().unwrap_or_default();
403            let geo_source_ips = self.base_command.geo_source_ips.clone();
404            let geo_source_headers = self.base_command.geo_source_headers.clone();
405
406            // Select per-target templates/base_path if this target has a custom spec
407            let (templates, base_path) = if let Some(spec_path) = &target.spec {
408                if let Some((t, bp)) = per_target_data.get(spec_path) {
409                    (t.clone(), bp.clone())
410                } else {
411                    (templates.clone(), base_path.clone())
412                }
413            } else {
414                (templates.clone(), base_path.clone())
415            };
416
417            let base_headers = base_headers.clone();
418            let scenario = scenario.clone();
419            let duration_secs = duration_secs_val;
420            let base_output = self.base_output.clone();
421            let semaphore = semaphore.clone();
422            let progress_bar = progress_bars[index].clone();
423            let target_index = index;
424            let security_testing_enabled = security_testing_enabled_val;
425            let enhancement_code = enhancement_code.clone();
426
427            let handle = tokio::spawn(async move {
428                // Acquire semaphore permit
429                let _permit = semaphore.acquire().await.map_err(|e| {
430                    BenchError::Other(format!("Failed to acquire semaphore: {}", e))
431                })?;
432
433                progress_bar.set_message(format!("Testing {}", target.url));
434
435                // Execute test for this target
436                let result = Self::execute_single_target_internal(
437                    &duration,
438                    vus,
439                    &scenario_str,
440                    &operations,
441                    &auth,
442                    &headers,
443                    &threshold_percentile,
444                    threshold_ms,
445                    max_error_rate,
446                    abort_on_error,
447                    abort_on_error_rate,
448                    verbose,
449                    skip_tls_verify,
450                    base_path.as_ref(),
451                    &target,
452                    target_index,
453                    &templates,
454                    &base_headers,
455                    &scenario,
456                    duration_secs,
457                    &base_output,
458                    security_testing_enabled,
459                    chunked_request_bodies,
460                    target_rps,
461                    no_keep_alive,
462                    &enhancement_code,
463                    &local_ips,
464                    &dns_policy,
465                    &geo_source_ips,
466                    &geo_source_headers,
467                )
468                .await;
469
470                progress_bar.inc(1);
471                progress_bar.finish_with_message(format!("Completed {}", target.url));
472
473                result
474            });
475
476            handles.push(handle);
477        }
478
479        // Wait for all tasks to complete and collect results
480        let mut target_results = Vec::new();
481        for (index, handle) in handles.into_iter().enumerate() {
482            match handle.await {
483                Ok(Ok(result)) => {
484                    target_results.push(result);
485                }
486                Ok(Err(e)) => {
487                    // Create error result
488                    let target_url = self.targets[index].url.clone();
489                    target_results.push(TargetResult {
490                        target_url: target_url.clone(),
491                        target_index: index,
492                        results: K6Results::default(),
493                        output_dir: self.base_output.join(format!("target_{}", index + 1)),
494                        success: false,
495                        error: Some(e.to_string()),
496                    });
497                }
498                Err(e) => {
499                    // Join error
500                    let target_url = self.targets[index].url.clone();
501                    target_results.push(TargetResult {
502                        target_url: target_url.clone(),
503                        target_index: index,
504                        results: K6Results::default(),
505                        output_dir: self.base_output.join(format!("target_{}", index + 1)),
506                        success: false,
507                        error: Some(format!("Task join error: {}", e)),
508                    });
509                }
510            }
511        }
512
513        // Sort results by target index
514        target_results.sort_by_key(|r| r.target_index);
515
516        // Calculate aggregated metrics
517        let aggregated_metrics = AggregatedMetrics::from_results(&target_results);
518
519        let successful_targets = target_results.iter().filter(|r| r.success).count();
520        let failed_targets = total_targets - successful_targets;
521
522        Ok(AggregatedResults {
523            target_results,
524            total_targets,
525            successful_targets,
526            failed_targets,
527            aggregated_metrics,
528        })
529    }
530
531    /// Resolve the effective base path for API endpoints
532    fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
533        // CLI option takes priority (including empty string to disable)
534        if let Some(cli_base_path) = &self.base_command.base_path {
535            if cli_base_path.is_empty() {
536                return None;
537            }
538            return Some(cli_base_path.clone());
539        }
540        // Fall back to spec's base path
541        parser.get_base_path()
542    }
543
544    /// Execute a single target test (internal method that doesn't require BenchCommand)
545    #[allow(clippy::too_many_arguments)]
546    async fn execute_single_target_internal(
547        _duration: &str,
548        vus: u32,
549        _scenario_str: &str,
550        _operations: &Option<String>,
551        auth: &Option<String>,
552        _headers: &[String],
553        threshold_percentile: &str,
554        threshold_ms: u64,
555        max_error_rate: f64,
556        abort_on_error: bool,
557        abort_on_error_rate: f64,
558        verbose: bool,
559        skip_tls_verify: bool,
560        base_path: Option<&String>,
561        target: &TargetConfig,
562        target_index: usize,
563        templates: &[crate::request_gen::RequestTemplate],
564        base_headers: &HashMap<String, String>,
565        scenario: &LoadScenario,
566        duration_secs: u64,
567        base_output: &Path,
568        security_testing_enabled: bool,
569        chunked_request_bodies: bool,
570        target_rps: Option<u32>,
571        no_keep_alive: bool,
572        enhancement_code: &str,
573        local_ips: &str,
574        dns_policy: &str,
575        geo_source_ips: &[String],
576        geo_source_headers: &[String],
577    ) -> Result<TargetResult> {
578        // Merge target-specific headers with base headers
579        let mut custom_headers = base_headers.clone();
580        if let Some(target_headers) = &target.headers {
581            custom_headers.extend(target_headers.clone());
582        }
583
584        // Use target-specific auth if provided, otherwise use base auth
585        let auth_header = target.auth.as_ref().or(auth.as_ref()).cloned();
586
587        // Create k6 config for this target
588        let k6_config = K6Config {
589            target_url: target.url.clone(),
590            base_path: base_path.cloned(),
591            scenario: scenario.clone(),
592            duration_secs,
593            max_vus: vus,
594            threshold_percentile: threshold_percentile.to_string(),
595            threshold_ms,
596            max_error_rate,
597            auth_header,
598            custom_headers,
599            skip_tls_verify,
600            security_testing_enabled,
601            chunked_request_bodies,
602            target_rps,
603            no_keep_alive,
604            geo_source_ips: geo_source_ips.to_vec(),
605            geo_source_headers: geo_source_headers.to_vec(),
606        };
607
608        // Generate k6 script
609        let generator = K6ScriptGenerator::new(k6_config, templates.to_vec())
610            .with_abort_valve(abort_on_error, abort_on_error_rate);
611        let mut script = generator.generate()?;
612
613        // Apply pre-computed enhancement code (security definitions, etc.)
614        if !enhancement_code.is_empty() {
615            if let Some(pos) = script.find("export const options") {
616                script.insert_str(pos, enhancement_code);
617            }
618        }
619
620        // Validate script
621        let validation_errors = K6ScriptGenerator::validate_script(&script);
622        if !validation_errors.is_empty() {
623            return Err(BenchError::Other(format!(
624                "Script validation failed for target {}: {}",
625                target.url,
626                validation_errors.join(", ")
627            )));
628        }
629
630        // Create output directory for this target
631        let output_dir = base_output.join(format!("target_{}", target_index + 1));
632        std::fs::create_dir_all(&output_dir)?;
633
634        // Write script to file
635        let script_path = output_dir.join("k6-script.js");
636        std::fs::write(&script_path, script)?;
637
638        // Execute k6 with its own REST API server port per target. k6 defaults
639        // to localhost:6565 and parallel instances collide on it.
640        //
641        // Issue #79 r58 — Srikanth on 0.3.205: every one of 10 targets failed
642        // with `exit status: 106` (k6 `CannotStartRESTAPI`) and 0 requests, so
643        // NO load ran at all (which read as "--discard-response-bodies isn't
644        // helping"). The old scheme pinned each k6 to a FIXED port
645        // (6565 + index → 6566, 6567, ...), which collides whenever those
646        // ports are already taken — e.g. orphaned k6 processes left behind by
647        // a previous run that was OOM-killed (his r56/r57 SIGKILL), a second
648        // concurrent bench, or any local service. Bind an OS-assigned
649        // ephemeral port (`:0`) instead: k6 asks the kernel for a free port at
650        // start, so it can never be "already in use". We never query the REST
651        // API (results come from summary.json on disk), so a random port is
652        // fine. Verified: two concurrent k6 with `--address localhost:0` both
653        // start cleanly where the fixed port yields exit 106.
654        let api_port = 0; // ephemeral: kernel picks a free port, no collision
655                          // Issue #79 r54 — forward `--source-ip` (as k6 `--local-ips`) so each
656                          // target's VUs rotate through the configured source IP pool. Without
657                          // this, `--source-ip` was a silent no-op in multi-target mode.
658                          // Issue #79 r56 — plain multi-target load only checks status codes, so
659                          // discard response bodies to keep k6's RSS bounded on long, high-VU runs.
660                          // Without this, 10+ targets * 10 VUs * 70 min buffered every body and the
661                          // kernel OOM-killer sent k6 `signal: 9 (SIGKILL)`.
662        let executor = K6Executor::new()?
663            .with_local_ips(local_ips.to_string())
664            .with_dns_policy(dns_policy.to_string())
665            .with_discard_response_bodies(true);
666        let results = executor
667            .execute_with_port(&script_path, Some(&output_dir), verbose, Some(api_port))
668            .await;
669
670        match results {
671            Ok(k6_results) => Ok(TargetResult {
672                target_url: target.url.clone(),
673                target_index,
674                results: k6_results,
675                output_dir,
676                success: true,
677                error: None,
678            }),
679            Err(e) => Ok(TargetResult {
680                target_url: target.url.clone(),
681                target_index,
682                results: K6Results::default(),
683                output_dir,
684                success: false,
685                error: Some(e.to_string()),
686            }),
687        }
688    }
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    #[test]
696    fn test_aggregated_metrics_from_results() {
697        let results = vec![
698            TargetResult {
699                target_url: "http://api1.com".to_string(),
700                target_index: 0,
701                results: K6Results {
702                    total_requests: 100,
703                    failed_requests: 5,
704                    avg_duration_ms: 100.0,
705                    p95_duration_ms: 200.0,
706                    p99_duration_ms: 300.0,
707                    ..Default::default()
708                },
709                output_dir: PathBuf::from("output1"),
710                success: true,
711                error: None,
712            },
713            TargetResult {
714                target_url: "http://api2.com".to_string(),
715                target_index: 1,
716                results: K6Results {
717                    total_requests: 200,
718                    failed_requests: 10,
719                    avg_duration_ms: 150.0,
720                    p95_duration_ms: 250.0,
721                    p99_duration_ms: 350.0,
722                    ..Default::default()
723                },
724                output_dir: PathBuf::from("output2"),
725                success: true,
726                error: None,
727            },
728        ];
729
730        let metrics = AggregatedMetrics::from_results(&results);
731        assert_eq!(metrics.total_requests, 300);
732        assert_eq!(metrics.total_failed_requests, 15);
733        assert_eq!(metrics.avg_duration_ms, 125.0); // (100 + 150) / 2
734    }
735
736    #[test]
737    fn test_aggregated_metrics_with_failed_targets() {
738        let results = vec![
739            TargetResult {
740                target_url: "http://api1.com".to_string(),
741                target_index: 0,
742                results: K6Results {
743                    total_requests: 100,
744                    failed_requests: 5,
745                    avg_duration_ms: 100.0,
746                    p95_duration_ms: 200.0,
747                    p99_duration_ms: 300.0,
748                    ..Default::default()
749                },
750                output_dir: PathBuf::from("output1"),
751                success: true,
752                error: None,
753            },
754            TargetResult {
755                target_url: "http://api2.com".to_string(),
756                target_index: 1,
757                results: K6Results::default(),
758                output_dir: PathBuf::from("output2"),
759                success: false,
760                error: Some("Network error".to_string()),
761            },
762        ];
763
764        let metrics = AggregatedMetrics::from_results(&results);
765        // Only successful target should be counted
766        assert_eq!(metrics.total_requests, 100);
767        assert_eq!(metrics.total_failed_requests, 5);
768        assert_eq!(metrics.avg_duration_ms, 100.0);
769    }
770
771    #[test]
772    fn test_aggregated_metrics_empty_results() {
773        let results = vec![];
774        let metrics = AggregatedMetrics::from_results(&results);
775        assert_eq!(metrics.total_requests, 0);
776        assert_eq!(metrics.total_failed_requests, 0);
777        assert_eq!(metrics.avg_duration_ms, 0.0);
778        assert_eq!(metrics.error_rate, 0.0);
779    }
780
781    #[test]
782    fn test_aggregated_metrics_error_rate_calculation() {
783        let results = vec![TargetResult {
784            target_url: "http://api1.com".to_string(),
785            target_index: 0,
786            results: K6Results {
787                total_requests: 1000,
788                failed_requests: 50,
789                avg_duration_ms: 100.0,
790                p95_duration_ms: 200.0,
791                p99_duration_ms: 300.0,
792                ..Default::default()
793            },
794            output_dir: PathBuf::from("output1"),
795            success: true,
796            error: None,
797        }];
798
799        let metrics = AggregatedMetrics::from_results(&results);
800        assert_eq!(metrics.error_rate, 5.0); // 50/1000 * 100
801    }
802
803    #[test]
804    fn test_aggregated_metrics_p95_p99_calculation() {
805        let results = vec![
806            TargetResult {
807                target_url: "http://api1.com".to_string(),
808                target_index: 0,
809                results: K6Results {
810                    total_requests: 100,
811                    failed_requests: 0,
812                    avg_duration_ms: 100.0,
813                    p95_duration_ms: 150.0,
814                    p99_duration_ms: 200.0,
815                    ..Default::default()
816                },
817                output_dir: PathBuf::from("output1"),
818                success: true,
819                error: None,
820            },
821            TargetResult {
822                target_url: "http://api2.com".to_string(),
823                target_index: 1,
824                results: K6Results {
825                    total_requests: 100,
826                    failed_requests: 0,
827                    avg_duration_ms: 200.0,
828                    p95_duration_ms: 250.0,
829                    p99_duration_ms: 300.0,
830                    ..Default::default()
831                },
832                output_dir: PathBuf::from("output2"),
833                success: true,
834                error: None,
835            },
836            TargetResult {
837                target_url: "http://api3.com".to_string(),
838                target_index: 2,
839                results: K6Results {
840                    total_requests: 100,
841                    failed_requests: 0,
842                    avg_duration_ms: 300.0,
843                    p95_duration_ms: 350.0,
844                    p99_duration_ms: 400.0,
845                    ..Default::default()
846                },
847                output_dir: PathBuf::from("output3"),
848                success: true,
849                error: None,
850            },
851        ];
852
853        let metrics = AggregatedMetrics::from_results(&results);
854        // p95 should be the 95th percentile of [150, 250, 350] = index 2 = 350
855        // p99 should be the 99th percentile of [200, 300, 400] = index 2 = 400
856        assert_eq!(metrics.p95_duration_ms, 350.0);
857        assert_eq!(metrics.p99_duration_ms, 400.0);
858    }
859}