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 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/// Result for a single target execution
26#[derive(Debug, Clone)]
27pub struct TargetResult {
28    /// Target URL that was tested
29    pub target_url: String,
30    /// Index of the target (for ordering)
31    pub target_index: usize,
32    /// k6 test results
33    pub results: K6Results,
34    /// Output directory for this target
35    pub output_dir: PathBuf,
36    /// Whether the test succeeded
37    pub success: bool,
38    /// Error message if test failed
39    pub error: Option<String>,
40}
41
42/// Aggregated results from all target executions
43#[derive(Debug, Clone)]
44pub struct AggregatedResults {
45    /// Results for each target
46    pub target_results: Vec<TargetResult>,
47    /// Overall statistics
48    pub total_targets: usize,
49    pub successful_targets: usize,
50    pub failed_targets: usize,
51    /// Aggregated metrics across all targets
52    pub aggregated_metrics: AggregatedMetrics,
53}
54
55/// Aggregated metrics across all targets
56#[derive(Debug, Clone)]
57pub struct AggregatedMetrics {
58    /// Total requests across all targets
59    pub total_requests: u64,
60    /// Total failed requests across all targets
61    pub total_failed_requests: u64,
62    /// Average response time across all targets (ms)
63    pub avg_duration_ms: f64,
64    /// p95 response time across all targets (ms)
65    pub p95_duration_ms: f64,
66    /// p99 response time across all targets (ms)
67    pub p99_duration_ms: f64,
68    /// Overall error rate percentage
69    pub error_rate: f64,
70    /// Total RPS across all targets
71    pub total_rps: f64,
72    /// Average RPS per target
73    pub avg_rps: f64,
74    /// Total max VUs across all targets
75    pub total_vus_max: u32,
76    /// Total connections opened across all targets (Issue #79 round 12 —
77    /// Srikanth's multi-target bench output was missing the CPS / connection
78    /// counts that single-target runs surface).
79    pub total_connections_opened: u64,
80    /// Total iterations completed across all targets.
81    pub total_iterations_completed: u64,
82}
83
84impl AggregatedMetrics {
85    /// Calculate aggregated metrics from target results
86    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
163/// Round 67 (#79) — aggregated-metrics JSON object shared by
164/// `aggregated_summary.json`, per-round `round_summary.json` and the
165/// `campaign.jsonl` lines so the three can't drift.
166fn aggregated_metrics_json(m: &AggregatedMetrics) -> serde_json::Value {
167    serde_json::json!({
168        "total_requests": m.total_requests,
169        "total_failed_requests": m.total_failed_requests,
170        "avg_duration_ms": m.avg_duration_ms,
171        "p95_duration_ms": m.p95_duration_ms,
172        "p99_duration_ms": m.p99_duration_ms,
173        "error_rate": m.error_rate,
174        "total_rps": m.total_rps,
175        "avg_rps": m.avg_rps,
176        "total_vus_max": m.total_vus_max,
177        "total_connections_opened": m.total_connections_opened,
178        "total_iterations_completed": m.total_iterations_completed,
179    })
180}
181
182/// Round 67 (#79) — per-target JSON rows shared by every stats file.
183fn target_results_json(results: &AggregatedResults) -> Vec<serde_json::Value> {
184    results
185        .target_results
186        .iter()
187        .map(|r| {
188            serde_json::json!({
189                "target_url": r.target_url,
190                "target_index": r.target_index,
191                "success": r.success,
192                "error": r.error,
193                "total_requests": r.results.total_requests,
194                "failed_requests": r.results.failed_requests,
195                "avg_duration_ms": r.results.avg_duration_ms,
196                "min_duration_ms": r.results.min_duration_ms,
197                "med_duration_ms": r.results.med_duration_ms,
198                "p90_duration_ms": r.results.p90_duration_ms,
199                "p95_duration_ms": r.results.p95_duration_ms,
200                "p99_duration_ms": r.results.p99_duration_ms,
201                "max_duration_ms": r.results.max_duration_ms,
202                "rps": r.results.rps,
203                "vus_max": r.results.vus_max,
204                "output_dir": r.output_dir.to_string_lossy(),
205            })
206        })
207        .collect()
208}
209
210/// JSON body of `aggregated_summary.json` (the campaign-level file written
211/// once at the end of a multi-target run).
212pub(crate) fn targets_summary_json(
213    results: &AggregatedResults,
214    elapsed_secs: u64,
215) -> serde_json::Value {
216    serde_json::json!({
217        "total_elapsed_seconds": elapsed_secs,
218        "total_targets": results.total_targets,
219        "successful_targets": results.successful_targets,
220        "failed_targets": results.failed_targets,
221        "aggregated_metrics": aggregated_metrics_json(&results.aggregated_metrics),
222        "target_results": target_results_json(results),
223    })
224}
225
226/// Per-target CSV rows shared by `all_targets.csv` (base output and the
227/// per-round copy inside each `round_*` dir).
228pub(crate) fn targets_csv(results: &AggregatedResults) -> String {
229    let mut csv = String::from(
230        "target_url,success,requests,failed,rps,vus,min_ms,avg_ms,med_ms,p90_ms,p95_ms,p99_ms,max_ms,error\n",
231    );
232    for r in &results.target_results {
233        csv.push_str(&format!(
234            "{},{},{},{},{:.1},{},{:.1},{:.1},{:.1},{:.1},{:.1},{:.1},{:.1},{}\n",
235            r.target_url,
236            r.success,
237            r.results.total_requests,
238            r.results.failed_requests,
239            r.results.rps,
240            r.results.vus_max,
241            r.results.min_duration_ms,
242            r.results.avg_duration_ms,
243            r.results.med_duration_ms,
244            r.results.p90_duration_ms,
245            r.results.p95_duration_ms,
246            r.results.p99_duration_ms,
247            r.results.max_duration_ms,
248            r.error.as_deref().unwrap_or(""),
249        ));
250    }
251    csv
252}
253
254/// Parallel executor for multi-target bench testing
255pub struct ParallelExecutor {
256    /// Base command configuration (shared across all targets)
257    base_command: BenchCommand,
258    /// List of targets to test
259    targets: Vec<TargetConfig>,
260    /// Explicit `--max-concurrency` override. `None` means auto (10, or 3
261    /// for huge specs — round 65 / #79).
262    max_concurrency_override: Option<usize>,
263    /// Base output directory
264    base_output: PathBuf,
265}
266
267impl ParallelExecutor {
268    /// Create a new parallel executor
269    pub fn new(
270        base_command: BenchCommand,
271        targets: Vec<TargetConfig>,
272        max_concurrency: Option<usize>,
273    ) -> Self {
274        let base_output = base_command.output.clone();
275        Self {
276            base_command,
277            targets,
278            max_concurrency_override: max_concurrency,
279            base_output,
280        }
281    }
282
283    /// Round 63 (#79): `--max-concurrency` is a semaphore, not a shared VU
284    /// pool. Each batch of up to `max_concurrency` targets runs the full
285    /// duration, then the next batch starts. Wall clock ≈
286    /// ceil(targets / concurrency) * duration.
287    pub(crate) fn estimated_wall_clock(
288        n_targets: usize,
289        max_concurrency: usize,
290        duration_secs: u64,
291    ) -> (usize, u64) {
292        let conc = max_concurrency.max(1);
293        let batches = if n_targets == 0 {
294            0
295        } else {
296            n_targets.div_ceil(conc)
297        };
298        (batches, (batches as u64).saturating_mul(duration_secs))
299    }
300
301    /// Human-readable duration for the wall-clock estimate (`35m00s`).
302    pub(crate) fn format_wall_clock(secs: u64) -> String {
303        let hours = secs / 3600;
304        let mins = (secs % 3600) / 60;
305        let rem = secs % 60;
306        if hours > 0 {
307            format!("{hours}h{mins:02}m{rem:02}s")
308        } else if mins > 0 {
309            format!("{mins}m{rem:02}s")
310        } else {
311            format!("{rem}s")
312        }
313    }
314
315    /// Execute tests against all targets in parallel
316    pub async fn execute_all(&self) -> Result<AggregatedResults> {
317        let total_targets = self.targets.len();
318        TerminalReporter::print_progress(&format!(
319            "Starting parallel execution for {} targets",
320            total_targets
321        ));
322
323        // Validate k6 installation
324        if !K6Executor::is_k6_installed() {
325            TerminalReporter::print_error("k6 is not installed");
326            TerminalReporter::print_warning(
327                "Install k6 from: https://k6.io/docs/get-started/installation/",
328            );
329            return Err(BenchError::K6NotFound);
330        }
331
332        // #79: --targets-file used to ignore --wafbench-verbatim. It always
333        // required a spec and built templates from spec operations, so the
334        // command either died with "No spec files provided" or fuzzed spec
335        // URLs instead of sending the traffic file as written.
336        let spec_supplied =
337            !self.base_command.spec.is_empty() || self.base_command.spec_dir.is_some();
338        let verbatim = self.base_command.wafbench_verbatim;
339
340        let (templates, parser) = if verbatim {
341            let verbatim_templates = self.base_command.load_verbatim_templates()?;
342            if verbatim_templates.is_empty() {
343                return Err(BenchError::Other(
344                    "--wafbench-verbatim was set but no traffic cases were loaded. Check \
345                     --wafbench-dir points at a file, directory or glob containing cases with \
346                     a `request.uri`."
347                        .to_string(),
348                ));
349            }
350            TerminalReporter::print_success(&format!(
351                "Verbatim mode: {} request(s) will be sent exactly as written (spec endpoints not used)",
352                verbatim_templates.len()
353            ));
354            let parser = if spec_supplied {
355                TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
356                let merged_spec = self.base_command.load_and_merge_specs().await?;
357                TerminalReporter::print_success("Specification(s) loaded (base path only)");
358                SpecParser::from_spec(merged_spec)
359            } else {
360                SpecParser::from_spec(OpenApiSpec {
361                    spec: Default::default(),
362                    file_path: None,
363                    raw_document: None,
364                })
365            };
366            (verbatim_templates, parser)
367        } else {
368            TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
369            let merged_spec = self.base_command.load_and_merge_specs().await?;
370            let parser = SpecParser::from_spec(merged_spec);
371            TerminalReporter::print_success("Specification(s) loaded");
372
373            let operations = if let Some(filter) = &self.base_command.operations {
374                parser.filter_operations(filter)?
375            } else {
376                parser.get_operations()
377            };
378
379            if operations.is_empty() {
380                return Err(BenchError::Other("No operations found in spec".to_string()));
381            }
382
383            TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
384
385            TerminalReporter::print_progress("Generating request templates...");
386            let templates: Vec<_> = operations
387                .iter()
388                .map(RequestGenerator::generate_template)
389                .collect::<Result<Vec<_>>>()?;
390            TerminalReporter::print_success("Request templates generated");
391            (templates, parser)
392        };
393
394        // Pre-load per-target specs. In verbatim mode a per-target spec may
395        // still resolve --base-path, but it must not replace the traffic-file
396        // templates. That swap is how --targets-file kept fuzzing spec URLs
397        // after --wafbench-verbatim was added (#79).
398        let mut per_target_data: HashMap<
399            PathBuf,
400            (Vec<crate::request_gen::RequestTemplate>, Option<String>),
401        > = HashMap::new();
402        if !verbatim {
403            let mut unique_specs: Vec<PathBuf> = Vec::new();
404            for t in &self.targets {
405                if let Some(spec_path) = &t.spec {
406                    if !unique_specs.contains(spec_path) {
407                        unique_specs.push(spec_path.clone());
408                    }
409                }
410            }
411            for spec_path in &unique_specs {
412                TerminalReporter::print_progress(&format!(
413                    "Loading per-target spec: {}",
414                    spec_path.display()
415                ));
416                match SpecParser::from_file(spec_path).await {
417                    Ok(target_parser) => {
418                        let target_ops = if let Some(filter) = &self.base_command.operations {
419                            match target_parser.filter_operations(filter) {
420                                Ok(ops) => ops,
421                                Err(e) => {
422                                    TerminalReporter::print_warning(&format!(
423                                        "Failed to filter operations from {}: {}. Using shared spec.",
424                                        spec_path.display(),
425                                        e
426                                    ));
427                                    continue;
428                                }
429                            }
430                        } else {
431                            target_parser.get_operations()
432                        };
433                        let target_templates: Vec<_> = match target_ops
434                            .iter()
435                            .map(RequestGenerator::generate_template)
436                            .collect::<Result<Vec<_>>>()
437                        {
438                            Ok(t) => t,
439                            Err(e) => {
440                                TerminalReporter::print_warning(&format!(
441                                    "Failed to generate templates from {}: {}. Using shared spec.",
442                                    spec_path.display(),
443                                    e
444                                ));
445                                continue;
446                            }
447                        };
448                        let target_base_path = if let Some(cli_bp) = &self.base_command.base_path {
449                            if cli_bp.is_empty() {
450                                None
451                            } else {
452                                Some(cli_bp.clone())
453                            }
454                        } else {
455                            target_parser.get_base_path()
456                        };
457                        TerminalReporter::print_success(&format!(
458                            "Loaded {} operations from {}",
459                            target_templates.len(),
460                            spec_path.display()
461                        ));
462                        per_target_data
463                            .insert(spec_path.clone(), (target_templates, target_base_path));
464                    }
465                    Err(e) => {
466                        TerminalReporter::print_warning(&format!(
467                            "Failed to load per-target spec {}: {}. Targets using this spec will use the shared spec.",
468                            spec_path.display(),
469                            e
470                        ));
471                    }
472                }
473            }
474        }
475
476        // Parse base headers
477        let base_headers = self.base_command.parse_headers()?;
478
479        // Resolve base path (CLI option takes priority over spec's servers URL)
480        let base_path = self.resolve_base_path(&parser);
481        if let Some(ref bp) = base_path {
482            TerminalReporter::print_progress(&format!("Using base path: {}", bp));
483        }
484
485        // Parse scenario
486        let scenario = LoadScenario::from_str(&self.base_command.scenario)
487            .map_err(BenchError::InvalidScenario)?;
488
489        let duration_secs_val = BenchCommand::parse_duration(&self.base_command.duration)?;
490
491        // Round 65 (#79) — resolve concurrency after we know op count so huge
492        // specs auto-cap parallel heavyweight k6 processes.
493        let (max_concurrency, conc_warn) = crate::k6_gen::resolve_max_concurrency(
494            self.max_concurrency_override,
495            templates.len(),
496            total_targets,
497        );
498        if let Some(msg) = conc_warn {
499            TerminalReporter::print_warning(&msg);
500        } else {
501            TerminalReporter::print_progress(&format!(
502                "Max concurrency: {} ({} target{})",
503                max_concurrency,
504                total_targets,
505                if total_targets == 1 { "" } else { "s" },
506            ));
507        }
508
509        // Round 65 (#79) — collapse per-op metrics once for all targets.
510        let (per_op_metrics, per_op_warn) = crate::k6_gen::resolve_per_op_metrics(
511            self.base_command.per_op_metrics,
512            templates.len(),
513            duration_secs_val,
514        );
515        if let Some(msg) = per_op_warn {
516            TerminalReporter::print_warning(&msg);
517        }
518
519        // Round 63 (#79): batches of `--max-concurrency` each run the full
520        // `--duration`. Wall clock is ceil(targets / concurrency) * duration,
521        // not duration alone. --vus and --rps are per target, not shared.
522        let (batches, one_pass_wall_secs) =
523            Self::estimated_wall_clock(total_targets, max_concurrency, duration_secs_val);
524        TerminalReporter::print_progress(&format!(
525            "One pass: {batches} batch(es) × {duration_secs_val}s ≈ {} ({one_pass_wall_secs}s). --vus and --rps are per target, not shared.",
526            Self::format_wall_clock(one_pass_wall_secs),
527        ));
528
529        // Round 66 (#79) — optional campaign loop so low concurrency can still
530        // hit every target over a longevity window without manual restarts.
531        let repeat_until_secs = match &self.base_command.repeat_until {
532            Some(d) => Some(BenchCommand::parse_duration(d)?),
533            None => None,
534        };
535        if let Some(n) = self.base_command.rounds {
536            if n == 0 {
537                return Err(BenchError::Other(
538                    "--rounds must be >= 1 (or omit it for a single pass / --repeat-until)".into(),
539                ));
540            }
541        }
542        let max_rounds = self.base_command.rounds.unwrap_or(if repeat_until_secs.is_some() {
543            u32::MAX
544        } else {
545            1
546        });
547        let looping = max_rounds > 1 || repeat_until_secs.is_some();
548        // Round 67 (#79) — campaign disk control. Scripts are identical every
549        // round, so they get written once per target under `<out>/scripts/`
550        // instead of a fresh copy per `round_*/target_*`. `keep_rounds`
551        // bounds how many `round_*` dirs survive (rotation); per-round stats
552        // always land in `campaign.jsonl` + `round-summaries/` first.
553        let keep_rounds = self.base_command.keep_rounds;
554        let no_k6_logs = self.base_command.no_k6_logs;
555        let scripts_base = looping.then(|| self.base_output.join("scripts"));
556        if let Some(keep) = keep_rounds {
557            if !looping {
558                TerminalReporter::print_warning(
559                    "--keep-rounds only applies to campaign runs (--repeat-until / --rounds > 1); ignoring.",
560                );
561            } else {
562                TerminalReporter::print_progress(&format!(
563                    "Round pruning: keeping newest {keep} round_* dir(s); per-round stats persist in campaign.jsonl + round-summaries/"
564                ));
565            }
566        }
567        if let Some(until) = repeat_until_secs {
568            TerminalReporter::print_progress(&format!(
569                "Campaign loop: will re-run all targets until wall clock reaches {} \
570                 (or --rounds {}). Each k6 process only lives `--duration` ({duration_secs_val}s), \
571                 so RSS resets between batches.",
572                Self::format_wall_clock(until),
573                self.base_command
574                    .rounds
575                    .map(|n| n.to_string())
576                    .unwrap_or_else(|| "unlimited".into()),
577            ));
578        } else if max_rounds > 1 {
579            TerminalReporter::print_progress(&format!(
580                "Campaign loop: will re-run all targets for {max_rounds} round(s)."
581            ));
582        }
583
584        let security_testing_enabled_val = self.base_command.security_testing_enabled();
585
586        if crate::request_gen::should_force_k6_http1(verbatim, &templates, &base_headers) {
587            TerminalReporter::print_progress(
588                "Forcing HTTP/1.1 (GODEBUG=http2client=0): Connection headers are hop-by-hop and HTTP/2 rejects them. The header stays on the wire.",
589            );
590        }
591
592        // Pre-compute enhancement code once (same for all targets)
593        let has_advanced_features = self.base_command.data_file.is_some()
594            || self.base_command.error_rate.is_some()
595            || self.base_command.security_testing_enabled()
596            || self.base_command.parallel_create.is_some();
597
598        let enhancement_code = if has_advanced_features {
599            let dummy_script = "export const options = {};";
600            let enhanced = self.base_command.generate_enhanced_script(dummy_script)?;
601            if let Some(pos) = enhanced.find("export const options") {
602                enhanced[..pos].to_string()
603            } else {
604                String::new()
605            }
606        } else {
607            String::new()
608        };
609
610        let campaign_start = std::time::Instant::now();
611        let mut last_results: Option<AggregatedResults> = None;
612        let mut round: u32 = 0;
613
614        while round < max_rounds {
615            // Stop before starting a new round once wall clock is exhausted.
616            if let Some(until) = repeat_until_secs {
617                if round > 0 && campaign_start.elapsed().as_secs() >= until {
618                    TerminalReporter::print_progress(&format!(
619                        "Reached --repeat-until wall clock ({}); stopping after {} round(s).",
620                        Self::format_wall_clock(until),
621                        round,
622                    ));
623                    break;
624                }
625            }
626
627            round += 1;
628            let round_output = if looping {
629                let dir = self.base_output.join(format!("round_{round}"));
630                let wall_note = repeat_until_secs
631                    .map(|u| {
632                        format!(
633                            " (wall {} / {})",
634                            Self::format_wall_clock(campaign_start.elapsed().as_secs()),
635                            Self::format_wall_clock(u)
636                        )
637                    })
638                    .unwrap_or_default();
639                TerminalReporter::print_progress(&format!(
640                    "Round {round}{wall_note} — results under {}",
641                    dir.display()
642                ));
643                dir
644            } else {
645                self.base_output.clone()
646            };
647
648            // Create semaphore for concurrency control
649            let semaphore = Arc::new(Semaphore::new(max_concurrency));
650            let multi_progress = MultiProgress::new();
651
652            // Create progress bars for each target
653            let progress_bars: Vec<ProgressBar> = (0..total_targets)
654                .map(|i| {
655                    let pb = multi_progress.add(ProgressBar::new(1));
656                    pb.set_style(
657                        ProgressStyle::default_bar()
658                            .template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} {msg}")
659                            .unwrap(),
660                    );
661                    pb.set_message(format!("Target {}", i + 1));
662                    pb
663                })
664                .collect();
665
666            // Spawn tasks for each target
667            let mut handles: Vec<JoinHandle<Result<TargetResult>>> = Vec::new();
668
669            for (index, target) in self.targets.iter().enumerate() {
670                let target = target.clone();
671                // Clone necessary fields from base_command instead of passing reference
672                let duration = self.base_command.duration.clone();
673                let vus = self.base_command.vus;
674                let scenario_str = self.base_command.scenario.clone();
675                let operations = self.base_command.operations.clone();
676                let auth = self.base_command.auth.clone();
677                let headers = self.base_command.headers.clone();
678                let threshold_percentile = self.base_command.threshold_percentile.clone();
679                let threshold_ms = self.base_command.threshold_ms;
680                let max_error_rate = self.base_command.max_error_rate;
681                // Issue #79 r62 (Srikanth on 0.3.209): the round-60 abort valve
682                // stopped his WAF stress runs at ~2min because a legitimate high
683                // rejection rate crossed 0.95. Thread the opt-out/threshold through
684                // the per-target path so `--no-abort-on-error` reaches the k6 script.
685                let abort_on_error = self.base_command.abort_on_error;
686                let abort_on_error_rate = self.base_command.abort_on_error_rate;
687                let verbose = self.base_command.verbose;
688                let skip_tls_verify = self.base_command.skip_tls_verify;
689                let chunked_request_bodies = self.base_command.chunked_request_bodies;
690                let target_rps = self.base_command.target_rps;
691                let no_keep_alive = self.base_command.no_keep_alive;
692                // Issue #79 r54 (Srikanth on 0.3.200): `--source-ip` was silently
693                // dropped in multi-target (`--targets-file`) mode because this
694                // executor built `K6Executor::new()` without `.with_local_ips` and
695                // the per-target BenchCommand clone zeroed `source_ips`. Thread the
696                // source IPs (as the k6 `--local-ips` list) and geo config through
697                // to each target's run.
698                let local_ips = self.base_command.source_ips.join(",");
699                let dns_policy = self.base_command.dns_policy.clone().unwrap_or_default();
700                let geo_source_ips = self.base_command.geo_source_ips.clone();
701                let geo_source_headers = self.base_command.geo_source_headers.clone();
702
703                // Select per-target templates/base_path if this target has a custom spec.
704                // Verbatim templates stay the traffic file's requests even when a
705                // target lists its own spec.
706                let (templates, base_path) = if verbatim {
707                    (templates.clone(), base_path.clone())
708                } else if let Some(spec_path) = &target.spec {
709                    if let Some((t, bp)) = per_target_data.get(spec_path) {
710                        (t.clone(), bp.clone())
711                    } else {
712                        (templates.clone(), base_path.clone())
713                    }
714                } else {
715                    (templates.clone(), base_path.clone())
716                };
717
718                let base_headers = base_headers.clone();
719                let scenario = scenario.clone();
720                let duration_secs = duration_secs_val;
721                let base_output = round_output.clone();
722                let semaphore = semaphore.clone();
723                let progress_bar = progress_bars[index].clone();
724                let target_index = index;
725                let security_testing_enabled = security_testing_enabled_val;
726                let enhancement_code = enhancement_code.clone();
727                // Round 67 (#79) — one stable script file per target for the
728                // whole campaign instead of a per-round duplicate.
729                let script_dir =
730                    scripts_base.as_ref().map(|d| d.join(format!("target_{}", index + 1)));
731
732                let handle = tokio::spawn(async move {
733                    // Acquire semaphore permit
734                    let _permit = semaphore.acquire().await.map_err(|e| {
735                        BenchError::Other(format!("Failed to acquire semaphore: {}", e))
736                    })?;
737
738                    progress_bar.set_message(format!("Testing {}", target.url));
739
740                    // Execute test for this target
741                    let result = Self::execute_single_target_internal(
742                        &duration,
743                        vus,
744                        &scenario_str,
745                        &operations,
746                        &auth,
747                        &headers,
748                        &threshold_percentile,
749                        threshold_ms,
750                        max_error_rate,
751                        abort_on_error,
752                        abort_on_error_rate,
753                        per_op_metrics,
754                        verbose,
755                        skip_tls_verify,
756                        base_path.as_ref(),
757                        &target,
758                        target_index,
759                        &templates,
760                        &base_headers,
761                        &scenario,
762                        duration_secs,
763                        &base_output,
764                        security_testing_enabled,
765                        chunked_request_bodies,
766                        target_rps,
767                        no_keep_alive,
768                        &enhancement_code,
769                        &local_ips,
770                        &dns_policy,
771                        &geo_source_ips,
772                        &geo_source_headers,
773                        verbatim,
774                        no_k6_logs,
775                        script_dir.as_deref(),
776                    )
777                    .await;
778
779                    progress_bar.inc(1);
780                    progress_bar.finish_with_message(format!("Completed {}", target.url));
781
782                    result
783                });
784
785                handles.push(handle);
786            }
787
788            // Wait for all tasks to complete and collect results
789            let mut target_results = Vec::new();
790            for (index, handle) in handles.into_iter().enumerate() {
791                match handle.await {
792                    Ok(Ok(result)) => {
793                        target_results.push(result);
794                    }
795                    Ok(Err(e)) => {
796                        // Create error result
797                        let target_url = self.targets[index].url.clone();
798                        target_results.push(TargetResult {
799                            target_url: target_url.clone(),
800                            target_index: index,
801                            results: K6Results::default(),
802                            output_dir: round_output.join(format!("target_{}", index + 1)),
803                            success: false,
804                            error: Some(e.to_string()),
805                        });
806                    }
807                    Err(e) => {
808                        // Join error
809                        let target_url = self.targets[index].url.clone();
810                        target_results.push(TargetResult {
811                            target_url: target_url.clone(),
812                            target_index: index,
813                            results: K6Results::default(),
814                            output_dir: round_output.join(format!("target_{}", index + 1)),
815                            success: false,
816                            error: Some(format!("Task join error: {}", e)),
817                        });
818                    }
819                }
820            }
821
822            // Sort results by target index
823            target_results.sort_by_key(|r| r.target_index);
824
825            // Calculate aggregated metrics
826            let aggregated_metrics = AggregatedMetrics::from_results(&target_results);
827
828            let successful_targets = target_results.iter().filter(|r| r.success).count();
829            let failed_targets = total_targets - successful_targets;
830
831            last_results = Some(AggregatedResults {
832                target_results,
833                total_targets,
834                successful_targets,
835                failed_targets,
836                aggregated_metrics,
837            });
838
839            // Round 67 (#79) — persist per-round stats before any pruning so
840            // a killed or rotated campaign still leaves an auditable trail.
841            if looping {
842                let agg = last_results.as_ref().expect("just assigned");
843                Self::write_round_artifacts(
844                    round,
845                    &round_output,
846                    &self.base_output,
847                    agg,
848                    campaign_start.elapsed(),
849                )?;
850                if let Some(keep) = keep_rounds {
851                    Self::prune_old_rounds(&self.base_output, keep);
852                }
853                TerminalReporter::print_success(&format!(
854                    "Round {round} done: {}/{} targets ok, {} requests ({:.2}% err) — stats in {}",
855                    agg.successful_targets,
856                    agg.total_targets,
857                    agg.aggregated_metrics.total_requests,
858                    agg.aggregated_metrics.error_rate,
859                    self.base_output.join("campaign.jsonl").display(),
860                ));
861            }
862        }
863
864        last_results.ok_or_else(|| {
865            BenchError::Other("No rounds executed (check --rounds / --repeat-until)".to_string())
866        })
867    }
868
869    /// Round 67 (#79) — per-round stats for campaign mode. Writes
870    /// `round_summary.json` + `all_targets.csv` inside the round dir, a
871    /// pruning-proof copy under `<out>/round-summaries/`, and appends one
872    /// JSON line to `<out>/campaign.jsonl`.
873    fn write_round_artifacts(
874        round: u32,
875        round_output: &Path,
876        base_output: &Path,
877        results: &AggregatedResults,
878        campaign_elapsed: std::time::Duration,
879    ) -> Result<()> {
880        let summary = serde_json::json!({
881            "round": round,
882            "campaign_elapsed_seconds": campaign_elapsed.as_secs(),
883            "total_targets": results.total_targets,
884            "successful_targets": results.successful_targets,
885            "failed_targets": results.failed_targets,
886            "aggregated_metrics": aggregated_metrics_json(&results.aggregated_metrics),
887            "target_results": target_results_json(results),
888        });
889        let pretty =
890            serde_json::to_string_pretty(&summary).map_err(|e| BenchError::Other(e.to_string()))?;
891
892        std::fs::write(round_output.join("round_summary.json"), &pretty)?;
893        std::fs::write(round_output.join("all_targets.csv"), targets_csv(results))?;
894
895        // Persistent copy — survives --keep-rounds pruning.
896        let summaries_dir = base_output.join("round-summaries");
897        std::fs::create_dir_all(&summaries_dir)?;
898        std::fs::write(summaries_dir.join(format!("round_{round}.json")), &pretty)?;
899
900        // Append-only campaign log: one compact record per round, greppable
901        // and complete even if the campaign is killed mid-run.
902        let mut line =
903            serde_json::to_string(&summary).map_err(|e| BenchError::Other(e.to_string()))?;
904        line.push('\n');
905        use std::io::Write;
906        let mut f = std::fs::OpenOptions::new()
907            .create(true)
908            .append(true)
909            .open(base_output.join("campaign.jsonl"))?;
910        f.write_all(line.as_bytes())
911            .map_err(|e| BenchError::Other(format!("failed to append campaign.jsonl: {e}")))?;
912        Ok(())
913    }
914
915    /// Round 67 (#79) — log rotation for campaign mode: delete all but the
916    /// newest `keep` `round_*` dirs under `base_output`. `round-summaries/`
917    /// and `campaign.jsonl` live outside the round dirs and are untouched.
918    pub(crate) fn prune_old_rounds(base_output: &Path, keep: u32) {
919        let mut round_dirs: Vec<(u32, PathBuf)> = match std::fs::read_dir(base_output) {
920            Ok(rd) => rd
921                .filter_map(|e| e.ok())
922                .filter_map(|e| {
923                    let name = e.file_name().to_string_lossy().to_string();
924                    let n = name.strip_prefix("round_")?.parse::<u32>().ok()?;
925                    if e.file_type().ok()?.is_dir() {
926                        Some((n, e.path()))
927                    } else {
928                        None
929                    }
930                })
931                .collect(),
932            Err(_) => return,
933        };
934        round_dirs.sort_by_key(|(n, _)| *n);
935        let removable = round_dirs.len().saturating_sub(keep as usize);
936        let mut pruned = 0usize;
937        for (_, path) in round_dirs.into_iter().take(removable) {
938            if std::fs::remove_dir_all(&path).is_ok() {
939                pruned += 1;
940            }
941        }
942        if pruned > 0 {
943            TerminalReporter::print_progress(&format!(
944                "Pruned {pruned} old round dir(s) (keeping newest {keep})"
945            ));
946        }
947    }
948
949    /// Resolve the effective base path for API endpoints
950    fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
951        // CLI option takes priority (including empty string to disable)
952        if let Some(cli_base_path) = &self.base_command.base_path {
953            if cli_base_path.is_empty() {
954                return None;
955            }
956            return Some(cli_base_path.clone());
957        }
958        // Fall back to spec's base path
959        parser.get_base_path()
960    }
961
962    /// Execute a single target test (internal method that doesn't require BenchCommand)
963    #[allow(clippy::too_many_arguments)]
964    async fn execute_single_target_internal(
965        _duration: &str,
966        vus: u32,
967        _scenario_str: &str,
968        _operations: &Option<String>,
969        auth: &Option<String>,
970        _headers: &[String],
971        threshold_percentile: &str,
972        threshold_ms: u64,
973        max_error_rate: f64,
974        abort_on_error: bool,
975        abort_on_error_rate: f64,
976        per_op_metrics: bool,
977        verbose: bool,
978        skip_tls_verify: bool,
979        base_path: Option<&String>,
980        target: &TargetConfig,
981        target_index: usize,
982        templates: &[crate::request_gen::RequestTemplate],
983        base_headers: &HashMap<String, String>,
984        scenario: &LoadScenario,
985        duration_secs: u64,
986        base_output: &Path,
987        security_testing_enabled: bool,
988        chunked_request_bodies: bool,
989        target_rps: Option<u32>,
990        no_keep_alive: bool,
991        enhancement_code: &str,
992        local_ips: &str,
993        dns_policy: &str,
994        geo_source_ips: &[String],
995        geo_source_headers: &[String],
996        wafbench_verbatim: bool,
997        no_k6_logs: bool,
998        script_dir: Option<&Path>,
999    ) -> Result<TargetResult> {
1000        // Merge target-specific headers with base headers
1001        let mut custom_headers = base_headers.clone();
1002        if let Some(target_headers) = &target.headers {
1003            custom_headers.extend(target_headers.clone());
1004        }
1005
1006        // Round 63 (#79): keep Connection on the wire; force HTTP/1.1.
1007        let force_http1 = crate::request_gen::should_force_k6_http1(
1008            wafbench_verbatim,
1009            templates,
1010            &custom_headers,
1011        );
1012
1013        // Use target-specific auth if provided, otherwise use base auth
1014        let auth_header = target.auth.as_ref().or(auth.as_ref()).cloned();
1015
1016        // Create k6 config for this target
1017        let k6_config = K6Config {
1018            target_url: target.url.clone(),
1019            base_path: base_path.cloned(),
1020            scenario: scenario.clone(),
1021            duration_secs,
1022            max_vus: vus,
1023            threshold_percentile: threshold_percentile.to_string(),
1024            threshold_ms,
1025            max_error_rate,
1026            auth_header,
1027            custom_headers,
1028            skip_tls_verify,
1029            security_testing_enabled,
1030            chunked_request_bodies,
1031            target_rps,
1032            no_keep_alive,
1033            geo_source_ips: geo_source_ips.to_vec(),
1034            geo_source_headers: geo_source_headers.to_vec(),
1035        };
1036
1037        // Generate k6 script
1038        let generator = K6ScriptGenerator::new(k6_config, templates.to_vec())
1039            .with_abort_valve(abort_on_error, abort_on_error_rate)
1040            .with_force_http1(force_http1)
1041            .with_per_op_metrics(per_op_metrics);
1042        let mut script = generator.generate()?;
1043
1044        // Apply pre-computed enhancement code (security definitions, etc.)
1045        if !enhancement_code.is_empty() {
1046            if let Some(pos) = script.find("export const options") {
1047                script.insert_str(pos, enhancement_code);
1048            }
1049        }
1050
1051        // Validate script
1052        let validation_errors = K6ScriptGenerator::validate_script(&script);
1053        if !validation_errors.is_empty() {
1054            return Err(BenchError::Other(format!(
1055                "Script validation failed for target {}: {}",
1056                target.url,
1057                validation_errors.join(", ")
1058            )));
1059        }
1060
1061        // Create output directory for this target
1062        let output_dir = base_output.join(format!("target_{}", target_index + 1));
1063        std::fs::create_dir_all(&output_dir)?;
1064
1065        // Write script to file. Round 67 (#79): in campaign mode the script
1066        // is identical every round, so it lives once per target under
1067        // `<out>/scripts/target_N/` instead of a per-round duplicate.
1068        let script_path = match script_dir {
1069            Some(d) => {
1070                std::fs::create_dir_all(d)?;
1071                d.join("k6-script.js")
1072            }
1073            None => output_dir.join("k6-script.js"),
1074        };
1075        std::fs::write(&script_path, script)?;
1076
1077        // Execute k6 with its own REST API server port per target. k6 defaults
1078        // to localhost:6565 and parallel instances collide on it.
1079        //
1080        // Issue #79 r58 — Srikanth on 0.3.205: every one of 10 targets failed
1081        // with `exit status: 106` (k6 `CannotStartRESTAPI`) and 0 requests, so
1082        // NO load ran at all (which read as "--discard-response-bodies isn't
1083        // helping"). The old scheme pinned each k6 to a FIXED port
1084        // (6565 + index → 6566, 6567, ...), which collides whenever those
1085        // ports are already taken — e.g. orphaned k6 processes left behind by
1086        // a previous run that was OOM-killed (his r56/r57 SIGKILL), a second
1087        // concurrent bench, or any local service. Bind an OS-assigned
1088        // ephemeral port (`:0`) instead: k6 asks the kernel for a free port at
1089        // start, so it can never be "already in use". We never query the REST
1090        // API (results come from summary.json on disk), so a random port is
1091        // fine. Verified: two concurrent k6 with `--address localhost:0` both
1092        // start cleanly where the fixed port yields exit 106.
1093        let api_port = 0; // ephemeral: kernel picks a free port, no collision
1094                          // Issue #79 r54 — forward `--source-ip` (as k6 `--local-ips`) so each
1095                          // target's VUs rotate through the configured source IP pool. Without
1096                          // this, `--source-ip` was a silent no-op in multi-target mode.
1097                          // Issue #79 r56 — plain multi-target load only checks status codes, so
1098                          // discard response bodies to keep k6's RSS bounded on long, high-VU runs.
1099                          // Without this, 10+ targets * 10 VUs * 70 min buffered every body and the
1100                          // kernel OOM-killer sent k6 `signal: 9 (SIGKILL)`.
1101        let executor = K6Executor::new()?
1102            .with_local_ips(local_ips.to_string())
1103            .with_dns_policy(dns_policy.to_string())
1104            .with_discard_response_bodies(true)
1105            .with_force_http1(force_http1)
1106            .with_capture_logs(!no_k6_logs);
1107        let results = executor
1108            .execute_with_port(&script_path, Some(&output_dir), verbose, Some(api_port))
1109            .await;
1110
1111        match results {
1112            Ok(k6_results) => Ok(TargetResult {
1113                target_url: target.url.clone(),
1114                target_index,
1115                results: k6_results,
1116                output_dir,
1117                success: true,
1118                error: None,
1119            }),
1120            Err(e) => Ok(TargetResult {
1121                target_url: target.url.clone(),
1122                target_index,
1123                results: K6Results::default(),
1124                output_dir,
1125                success: false,
1126                error: Some(e.to_string()),
1127            }),
1128        }
1129    }
1130}
1131
1132#[cfg(test)]
1133mod tests {
1134    use super::*;
1135
1136    #[test]
1137    fn test_aggregated_metrics_from_results() {
1138        let results = vec![
1139            TargetResult {
1140                target_url: "http://api1.com".to_string(),
1141                target_index: 0,
1142                results: K6Results {
1143                    total_requests: 100,
1144                    failed_requests: 5,
1145                    avg_duration_ms: 100.0,
1146                    p95_duration_ms: 200.0,
1147                    p99_duration_ms: 300.0,
1148                    ..Default::default()
1149                },
1150                output_dir: PathBuf::from("output1"),
1151                success: true,
1152                error: None,
1153            },
1154            TargetResult {
1155                target_url: "http://api2.com".to_string(),
1156                target_index: 1,
1157                results: K6Results {
1158                    total_requests: 200,
1159                    failed_requests: 10,
1160                    avg_duration_ms: 150.0,
1161                    p95_duration_ms: 250.0,
1162                    p99_duration_ms: 350.0,
1163                    ..Default::default()
1164                },
1165                output_dir: PathBuf::from("output2"),
1166                success: true,
1167                error: None,
1168            },
1169        ];
1170
1171        let metrics = AggregatedMetrics::from_results(&results);
1172        assert_eq!(metrics.total_requests, 300);
1173        assert_eq!(metrics.total_failed_requests, 15);
1174        assert_eq!(metrics.avg_duration_ms, 125.0); // (100 + 150) / 2
1175    }
1176
1177    #[test]
1178    fn test_aggregated_metrics_with_failed_targets() {
1179        let results = vec![
1180            TargetResult {
1181                target_url: "http://api1.com".to_string(),
1182                target_index: 0,
1183                results: K6Results {
1184                    total_requests: 100,
1185                    failed_requests: 5,
1186                    avg_duration_ms: 100.0,
1187                    p95_duration_ms: 200.0,
1188                    p99_duration_ms: 300.0,
1189                    ..Default::default()
1190                },
1191                output_dir: PathBuf::from("output1"),
1192                success: true,
1193                error: None,
1194            },
1195            TargetResult {
1196                target_url: "http://api2.com".to_string(),
1197                target_index: 1,
1198                results: K6Results::default(),
1199                output_dir: PathBuf::from("output2"),
1200                success: false,
1201                error: Some("Network error".to_string()),
1202            },
1203        ];
1204
1205        let metrics = AggregatedMetrics::from_results(&results);
1206        // Only successful target should be counted
1207        assert_eq!(metrics.total_requests, 100);
1208        assert_eq!(metrics.total_failed_requests, 5);
1209        assert_eq!(metrics.avg_duration_ms, 100.0);
1210    }
1211
1212    #[test]
1213    fn test_aggregated_metrics_empty_results() {
1214        let results = vec![];
1215        let metrics = AggregatedMetrics::from_results(&results);
1216        assert_eq!(metrics.total_requests, 0);
1217        assert_eq!(metrics.total_failed_requests, 0);
1218        assert_eq!(metrics.avg_duration_ms, 0.0);
1219        assert_eq!(metrics.error_rate, 0.0);
1220    }
1221
1222    #[test]
1223    fn test_aggregated_metrics_error_rate_calculation() {
1224        let results = vec![TargetResult {
1225            target_url: "http://api1.com".to_string(),
1226            target_index: 0,
1227            results: K6Results {
1228                total_requests: 1000,
1229                failed_requests: 50,
1230                avg_duration_ms: 100.0,
1231                p95_duration_ms: 200.0,
1232                p99_duration_ms: 300.0,
1233                ..Default::default()
1234            },
1235            output_dir: PathBuf::from("output1"),
1236            success: true,
1237            error: None,
1238        }];
1239
1240        let metrics = AggregatedMetrics::from_results(&results);
1241        assert_eq!(metrics.error_rate, 5.0); // 50/1000 * 100
1242    }
1243
1244    #[test]
1245    fn test_aggregated_metrics_p95_p99_calculation() {
1246        let results = vec![
1247            TargetResult {
1248                target_url: "http://api1.com".to_string(),
1249                target_index: 0,
1250                results: K6Results {
1251                    total_requests: 100,
1252                    failed_requests: 0,
1253                    avg_duration_ms: 100.0,
1254                    p95_duration_ms: 150.0,
1255                    p99_duration_ms: 200.0,
1256                    ..Default::default()
1257                },
1258                output_dir: PathBuf::from("output1"),
1259                success: true,
1260                error: None,
1261            },
1262            TargetResult {
1263                target_url: "http://api2.com".to_string(),
1264                target_index: 1,
1265                results: K6Results {
1266                    total_requests: 100,
1267                    failed_requests: 0,
1268                    avg_duration_ms: 200.0,
1269                    p95_duration_ms: 250.0,
1270                    p99_duration_ms: 300.0,
1271                    ..Default::default()
1272                },
1273                output_dir: PathBuf::from("output2"),
1274                success: true,
1275                error: None,
1276            },
1277            TargetResult {
1278                target_url: "http://api3.com".to_string(),
1279                target_index: 2,
1280                results: K6Results {
1281                    total_requests: 100,
1282                    failed_requests: 0,
1283                    avg_duration_ms: 300.0,
1284                    p95_duration_ms: 350.0,
1285                    p99_duration_ms: 400.0,
1286                    ..Default::default()
1287                },
1288                output_dir: PathBuf::from("output3"),
1289                success: true,
1290                error: None,
1291            },
1292        ];
1293
1294        let metrics = AggregatedMetrics::from_results(&results);
1295        // p95 should be the 95th percentile of [150, 250, 350] = index 2 = 350
1296        // p99 should be the 99th percentile of [200, 300, 400] = index 2 = 400
1297        assert_eq!(metrics.p95_duration_ms, 350.0);
1298        assert_eq!(metrics.p99_duration_ms, 400.0);
1299    }
1300
1301    #[test]
1302    fn estimated_wall_clock_is_batches_times_duration() {
1303        // Srikanth: 64 IPs, --max-concurrency 10, -d 300 → 7 batches × 300s.
1304        let (batches, wall) = ParallelExecutor::estimated_wall_clock(64, 10, 300);
1305        assert_eq!(batches, 7);
1306        assert_eq!(wall, 2100);
1307        assert_eq!(ParallelExecutor::format_wall_clock(2100), "35m00s");
1308        assert_eq!(ParallelExecutor::estimated_wall_clock(10, 10, 300), (1, 300));
1309        assert_eq!(ParallelExecutor::estimated_wall_clock(0, 10, 300), (0, 0));
1310        assert_eq!(ParallelExecutor::format_wall_clock(45), "45s");
1311        assert_eq!(ParallelExecutor::format_wall_clock(3661), "1h01m01s");
1312    }
1313
1314    #[test]
1315    fn parallel_k6_spawn_sets_force_http1() {
1316        let src = include_str!("parallel_executor.rs");
1317        assert!(
1318            src.contains("with_force_http1(force_http1)"),
1319            "multi-target k6 spawn must pass GODEBUG=http2client=0 when Connection headers are present"
1320        );
1321        assert!(
1322            src.contains("One pass:") || src.contains("Estimated wall clock"),
1323            "multi-target start must print ceil(targets/concurrency)*duration"
1324        );
1325        assert!(
1326            src.contains("repeat_until") && src.contains("Campaign loop"),
1327            "multi-target must support --repeat-until campaign looping (#79)"
1328        );
1329        assert!(
1330            src.contains("with_per_op_metrics(per_op_metrics)"),
1331            "multi-target k6 spawn must apply Round-65 per-op metrics collapse (#79)"
1332        );
1333        assert!(
1334            src.contains("resolve_max_concurrency"),
1335            "multi-target must auto-cap concurrency for huge specs (#79)"
1336        );
1337        assert!(
1338            src.contains("with_capture_logs(!no_k6_logs)"),
1339            "multi-target k6 spawn must honour --no-k6-logs (#79)"
1340        );
1341    }
1342
1343    fn sample_aggregated_results() -> AggregatedResults {
1344        let target_results = vec![
1345            TargetResult {
1346                target_url: "http://api1.com".to_string(),
1347                target_index: 0,
1348                results: K6Results {
1349                    total_requests: 100,
1350                    failed_requests: 5,
1351                    avg_duration_ms: 100.0,
1352                    p95_duration_ms: 200.0,
1353                    p99_duration_ms: 300.0,
1354                    ..Default::default()
1355                },
1356                output_dir: PathBuf::from("output1"),
1357                success: true,
1358                error: None,
1359            },
1360            TargetResult {
1361                target_url: "http://api2.com".to_string(),
1362                target_index: 1,
1363                results: K6Results::default(),
1364                output_dir: PathBuf::from("output2"),
1365                success: false,
1366                error: Some("boom".to_string()),
1367            },
1368        ];
1369        AggregatedResults {
1370            aggregated_metrics: AggregatedMetrics::from_results(&target_results),
1371            total_targets: 2,
1372            successful_targets: 1,
1373            failed_targets: 1,
1374            target_results,
1375        }
1376    }
1377
1378    /// Round 67 (#79) — each campaign round must drop a summary + CSV into
1379    /// the round dir, a pruning-proof copy under round-summaries/, and one
1380    /// JSONL line per round in campaign.jsonl.
1381    #[test]
1382    fn write_round_artifacts_writes_all_surfaces() {
1383        let base = tempfile::tempdir().unwrap();
1384        let round_dir = base.path().join("round_3");
1385        std::fs::create_dir_all(&round_dir).unwrap();
1386        let results = sample_aggregated_results();
1387
1388        ParallelExecutor::write_round_artifacts(
1389            3,
1390            &round_dir,
1391            base.path(),
1392            &results,
1393            std::time::Duration::from_secs(42),
1394        )
1395        .unwrap();
1396        ParallelExecutor::write_round_artifacts(
1397            4,
1398            &round_dir,
1399            base.path(),
1400            &results,
1401            std::time::Duration::from_secs(84),
1402        )
1403        .unwrap();
1404
1405        let summary_raw = std::fs::read_to_string(round_dir.join("round_summary.json")).unwrap();
1406        let summary: serde_json::Value = serde_json::from_str(&summary_raw).unwrap();
1407        assert_eq!(summary["round"], 4);
1408        assert_eq!(summary["total_targets"], 2);
1409        assert_eq!(summary["successful_targets"], 1);
1410        assert_eq!(summary["aggregated_metrics"]["total_requests"], 100);
1411
1412        assert!(base.path().join("round-summaries/round_3.json").exists());
1413        assert!(base.path().join("round-summaries/round_4.json").exists());
1414
1415        let csv = std::fs::read_to_string(round_dir.join("all_targets.csv")).unwrap();
1416        assert!(csv.contains("http://api1.com"));
1417        assert!(csv.contains("boom"));
1418
1419        // Two artifact writes → two JSONL lines.
1420        let jsonl = std::fs::read_to_string(base.path().join("campaign.jsonl")).unwrap();
1421        let lines: Vec<&str> = jsonl.lines().collect();
1422        assert_eq!(lines.len(), 2);
1423        let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
1424        assert_eq!(first["round"], 3);
1425        assert_eq!(first["campaign_elapsed_seconds"], 42);
1426        let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
1427        assert_eq!(second["round"], 4);
1428    }
1429
1430    /// Round 67 (#79) — --keep-rounds prunes only `round_*` dirs and always
1431    /// leaves the newest N plus the campaign-level stats.
1432    #[test]
1433    fn prune_old_rounds_keeps_newest_and_stats() {
1434        let base = tempfile::tempdir().unwrap();
1435        for n in 1..=5 {
1436            std::fs::create_dir_all(base.path().join(format!("round_{n}"))).unwrap();
1437        }
1438        // Things that must survive pruning.
1439        std::fs::create_dir_all(base.path().join("round-summaries")).unwrap();
1440        std::fs::write(base.path().join("campaign.jsonl"), "{}\n").unwrap();
1441        std::fs::create_dir_all(base.path().join("scripts")).unwrap();
1442        std::fs::create_dir_all(base.path().join("target_1")).unwrap();
1443        // A round dir that is not a dir must not be deleted.
1444        std::fs::write(base.path().join("round_9.txt"), "x").unwrap();
1445
1446        ParallelExecutor::prune_old_rounds(base.path(), 2);
1447
1448        for n in 1..=3 {
1449            assert!(!base.path().join(format!("round_{n}")).exists(), "round_{n} pruned");
1450        }
1451        assert!(base.path().join("round_4").exists());
1452        assert!(base.path().join("round_5").exists());
1453        assert!(base.path().join("round-summaries").exists());
1454        assert!(base.path().join("campaign.jsonl").exists());
1455        assert!(base.path().join("scripts").exists());
1456        assert!(base.path().join("target_1").exists());
1457        assert!(base.path().join("round_9.txt").exists());
1458    }
1459
1460    /// Round 67 (#79) — keep=0 prunes every round dir (campaign stats only).
1461    #[test]
1462    fn prune_old_rounds_zero_keeps_stats_only() {
1463        let base = tempfile::tempdir().unwrap();
1464        std::fs::create_dir_all(base.path().join("round_1")).unwrap();
1465        std::fs::write(base.path().join("campaign.jsonl"), "{}\n").unwrap();
1466
1467        ParallelExecutor::prune_old_rounds(base.path(), 0);
1468
1469        assert!(!base.path().join("round_1").exists());
1470        assert!(base.path().join("campaign.jsonl").exists());
1471    }
1472}