1use crate::command::BenchCommand;
8use crate::error::{BenchError, Result};
9use crate::executor::{K6Executor, K6Results};
10use crate::k6_gen::{K6Config, K6ScriptGenerator};
11use crate::reporter::TerminalReporter;
12use crate::request_gen::RequestGenerator;
13use crate::scenarios::LoadScenario;
14use crate::spec_parser::SpecParser;
15use crate::target_parser::TargetConfig;
16use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
17use mockforge_openapi::spec::OpenApiSpec;
18use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20use std::str::FromStr;
21use std::sync::Arc;
22use tokio::sync::Semaphore;
23use tokio::task::JoinHandle;
24
25#[derive(Debug, Clone)]
27pub struct TargetResult {
28 pub target_url: String,
30 pub target_index: usize,
32 pub results: K6Results,
34 pub output_dir: PathBuf,
36 pub success: bool,
38 pub error: Option<String>,
40}
41
42#[derive(Debug, Clone)]
44pub struct AggregatedResults {
45 pub target_results: Vec<TargetResult>,
47 pub total_targets: usize,
49 pub successful_targets: usize,
50 pub failed_targets: usize,
51 pub aggregated_metrics: AggregatedMetrics,
53}
54
55#[derive(Debug, Clone)]
57pub struct AggregatedMetrics {
58 pub total_requests: u64,
60 pub total_failed_requests: u64,
62 pub avg_duration_ms: f64,
64 pub p95_duration_ms: f64,
66 pub p99_duration_ms: f64,
68 pub error_rate: f64,
70 pub total_rps: f64,
72 pub avg_rps: f64,
74 pub total_vus_max: u32,
76 pub total_connections_opened: u64,
80 pub total_iterations_completed: u64,
82}
83
84impl AggregatedMetrics {
85 fn from_results(results: &[TargetResult]) -> Self {
87 let mut total_requests = 0u64;
88 let mut total_failed_requests = 0u64;
89 let mut durations = Vec::new();
90 let mut p95_values = Vec::new();
91 let mut p99_values = Vec::new();
92 let mut total_rps = 0.0f64;
93 let mut total_vus_max = 0u32;
94 let mut total_connections_opened = 0u64;
95 let mut total_iterations_completed = 0u64;
96 let mut successful_count = 0usize;
97
98 for result in results {
99 if result.success {
100 total_requests += result.results.total_requests;
101 total_failed_requests += result.results.failed_requests;
102 durations.push(result.results.avg_duration_ms);
103 p95_values.push(result.results.p95_duration_ms);
104 p99_values.push(result.results.p99_duration_ms);
105 total_rps += result.results.rps;
106 total_vus_max += result.results.vus_max;
107 total_connections_opened += result.results.tcp_connect_samples;
108 total_iterations_completed += result.results.iterations_completed;
109 successful_count += 1;
110 }
111 }
112
113 let avg_duration_ms = if !durations.is_empty() {
114 durations.iter().sum::<f64>() / durations.len() as f64
115 } else {
116 0.0
117 };
118
119 let p95_duration_ms = if !p95_values.is_empty() {
120 p95_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
121 let index = (p95_values.len() as f64 * 0.95).ceil() as usize - 1;
122 p95_values[index.min(p95_values.len() - 1)]
123 } else {
124 0.0
125 };
126
127 let p99_duration_ms = if !p99_values.is_empty() {
128 p99_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
129 let index = (p99_values.len() as f64 * 0.99).ceil() as usize - 1;
130 p99_values[index.min(p99_values.len() - 1)]
131 } else {
132 0.0
133 };
134
135 let error_rate = if total_requests > 0 {
136 (total_failed_requests as f64 / total_requests as f64) * 100.0
137 } else {
138 0.0
139 };
140
141 let avg_rps = if successful_count > 0 {
142 total_rps / successful_count as f64
143 } else {
144 0.0
145 };
146
147 Self {
148 total_requests,
149 total_failed_requests,
150 avg_duration_ms,
151 p95_duration_ms,
152 p99_duration_ms,
153 error_rate,
154 total_rps,
155 avg_rps,
156 total_vus_max,
157 total_connections_opened,
158 total_iterations_completed,
159 }
160 }
161}
162
163pub struct ParallelExecutor {
165 base_command: BenchCommand,
167 targets: Vec<TargetConfig>,
169 max_concurrency_override: Option<usize>,
172 base_output: PathBuf,
174}
175
176impl ParallelExecutor {
177 pub fn new(
179 base_command: BenchCommand,
180 targets: Vec<TargetConfig>,
181 max_concurrency: Option<usize>,
182 ) -> Self {
183 let base_output = base_command.output.clone();
184 Self {
185 base_command,
186 targets,
187 max_concurrency_override: max_concurrency,
188 base_output,
189 }
190 }
191
192 pub(crate) fn estimated_wall_clock(
197 n_targets: usize,
198 max_concurrency: usize,
199 duration_secs: u64,
200 ) -> (usize, u64) {
201 let conc = max_concurrency.max(1);
202 let batches = if n_targets == 0 {
203 0
204 } else {
205 n_targets.div_ceil(conc)
206 };
207 (batches, (batches as u64).saturating_mul(duration_secs))
208 }
209
210 pub(crate) fn format_wall_clock(secs: u64) -> String {
212 let hours = secs / 3600;
213 let mins = (secs % 3600) / 60;
214 let rem = secs % 60;
215 if hours > 0 {
216 format!("{hours}h{mins:02}m{rem:02}s")
217 } else if mins > 0 {
218 format!("{mins}m{rem:02}s")
219 } else {
220 format!("{rem}s")
221 }
222 }
223
224 pub async fn execute_all(&self) -> Result<AggregatedResults> {
226 let total_targets = self.targets.len();
227 TerminalReporter::print_progress(&format!(
228 "Starting parallel execution for {} targets",
229 total_targets
230 ));
231
232 if !K6Executor::is_k6_installed() {
234 TerminalReporter::print_error("k6 is not installed");
235 TerminalReporter::print_warning(
236 "Install k6 from: https://k6.io/docs/get-started/installation/",
237 );
238 return Err(BenchError::K6NotFound);
239 }
240
241 let spec_supplied =
246 !self.base_command.spec.is_empty() || self.base_command.spec_dir.is_some();
247 let verbatim = self.base_command.wafbench_verbatim;
248
249 let (templates, parser) = if verbatim {
250 let verbatim_templates = self.base_command.load_verbatim_templates()?;
251 if verbatim_templates.is_empty() {
252 return Err(BenchError::Other(
253 "--wafbench-verbatim was set but no traffic cases were loaded. Check \
254 --wafbench-dir points at a file, directory or glob containing cases with \
255 a `request.uri`."
256 .to_string(),
257 ));
258 }
259 TerminalReporter::print_success(&format!(
260 "Verbatim mode: {} request(s) will be sent exactly as written (spec endpoints not used)",
261 verbatim_templates.len()
262 ));
263 let parser = if spec_supplied {
264 TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
265 let merged_spec = self.base_command.load_and_merge_specs().await?;
266 TerminalReporter::print_success("Specification(s) loaded (base path only)");
267 SpecParser::from_spec(merged_spec)
268 } else {
269 SpecParser::from_spec(OpenApiSpec {
270 spec: Default::default(),
271 file_path: None,
272 raw_document: None,
273 })
274 };
275 (verbatim_templates, parser)
276 } else {
277 TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
278 let merged_spec = self.base_command.load_and_merge_specs().await?;
279 let parser = SpecParser::from_spec(merged_spec);
280 TerminalReporter::print_success("Specification(s) loaded");
281
282 let operations = if let Some(filter) = &self.base_command.operations {
283 parser.filter_operations(filter)?
284 } else {
285 parser.get_operations()
286 };
287
288 if operations.is_empty() {
289 return Err(BenchError::Other("No operations found in spec".to_string()));
290 }
291
292 TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
293
294 TerminalReporter::print_progress("Generating request templates...");
295 let templates: Vec<_> = operations
296 .iter()
297 .map(RequestGenerator::generate_template)
298 .collect::<Result<Vec<_>>>()?;
299 TerminalReporter::print_success("Request templates generated");
300 (templates, parser)
301 };
302
303 let mut per_target_data: HashMap<
308 PathBuf,
309 (Vec<crate::request_gen::RequestTemplate>, Option<String>),
310 > = HashMap::new();
311 if !verbatim {
312 let mut unique_specs: Vec<PathBuf> = Vec::new();
313 for t in &self.targets {
314 if let Some(spec_path) = &t.spec {
315 if !unique_specs.contains(spec_path) {
316 unique_specs.push(spec_path.clone());
317 }
318 }
319 }
320 for spec_path in &unique_specs {
321 TerminalReporter::print_progress(&format!(
322 "Loading per-target spec: {}",
323 spec_path.display()
324 ));
325 match SpecParser::from_file(spec_path).await {
326 Ok(target_parser) => {
327 let target_ops = if let Some(filter) = &self.base_command.operations {
328 match target_parser.filter_operations(filter) {
329 Ok(ops) => ops,
330 Err(e) => {
331 TerminalReporter::print_warning(&format!(
332 "Failed to filter operations from {}: {}. Using shared spec.",
333 spec_path.display(),
334 e
335 ));
336 continue;
337 }
338 }
339 } else {
340 target_parser.get_operations()
341 };
342 let target_templates: Vec<_> = match target_ops
343 .iter()
344 .map(RequestGenerator::generate_template)
345 .collect::<Result<Vec<_>>>()
346 {
347 Ok(t) => t,
348 Err(e) => {
349 TerminalReporter::print_warning(&format!(
350 "Failed to generate templates from {}: {}. Using shared spec.",
351 spec_path.display(),
352 e
353 ));
354 continue;
355 }
356 };
357 let target_base_path = if let Some(cli_bp) = &self.base_command.base_path {
358 if cli_bp.is_empty() {
359 None
360 } else {
361 Some(cli_bp.clone())
362 }
363 } else {
364 target_parser.get_base_path()
365 };
366 TerminalReporter::print_success(&format!(
367 "Loaded {} operations from {}",
368 target_templates.len(),
369 spec_path.display()
370 ));
371 per_target_data
372 .insert(spec_path.clone(), (target_templates, target_base_path));
373 }
374 Err(e) => {
375 TerminalReporter::print_warning(&format!(
376 "Failed to load per-target spec {}: {}. Targets using this spec will use the shared spec.",
377 spec_path.display(),
378 e
379 ));
380 }
381 }
382 }
383 }
384
385 let base_headers = self.base_command.parse_headers()?;
387
388 let base_path = self.resolve_base_path(&parser);
390 if let Some(ref bp) = base_path {
391 TerminalReporter::print_progress(&format!("Using base path: {}", bp));
392 }
393
394 let scenario = LoadScenario::from_str(&self.base_command.scenario)
396 .map_err(BenchError::InvalidScenario)?;
397
398 let duration_secs_val = BenchCommand::parse_duration(&self.base_command.duration)?;
399
400 let (max_concurrency, conc_warn) = crate::k6_gen::resolve_max_concurrency(
403 self.max_concurrency_override,
404 templates.len(),
405 total_targets,
406 );
407 if let Some(msg) = conc_warn {
408 TerminalReporter::print_warning(&msg);
409 } else {
410 TerminalReporter::print_progress(&format!(
411 "Max concurrency: {} ({} target{})",
412 max_concurrency,
413 total_targets,
414 if total_targets == 1 { "" } else { "s" },
415 ));
416 }
417
418 let (per_op_metrics, per_op_warn) = crate::k6_gen::resolve_per_op_metrics(
420 self.base_command.per_op_metrics,
421 templates.len(),
422 duration_secs_val,
423 );
424 if let Some(msg) = per_op_warn {
425 TerminalReporter::print_warning(&msg);
426 }
427
428 let (batches, one_pass_wall_secs) =
432 Self::estimated_wall_clock(total_targets, max_concurrency, duration_secs_val);
433 TerminalReporter::print_progress(&format!(
434 "One pass: {batches} batch(es) × {duration_secs_val}s ≈ {} ({one_pass_wall_secs}s). --vus and --rps are per target, not shared.",
435 Self::format_wall_clock(one_pass_wall_secs),
436 ));
437
438 let repeat_until_secs = match &self.base_command.repeat_until {
441 Some(d) => Some(BenchCommand::parse_duration(d)?),
442 None => None,
443 };
444 if let Some(n) = self.base_command.rounds {
445 if n == 0 {
446 return Err(BenchError::Other(
447 "--rounds must be >= 1 (or omit it for a single pass / --repeat-until)".into(),
448 ));
449 }
450 }
451 let max_rounds = self.base_command.rounds.unwrap_or(if repeat_until_secs.is_some() {
452 u32::MAX
453 } else {
454 1
455 });
456 let looping = max_rounds > 1 || repeat_until_secs.is_some();
457 if let Some(until) = repeat_until_secs {
458 TerminalReporter::print_progress(&format!(
459 "Campaign loop: will re-run all targets until wall clock reaches {} \
460 (or --rounds {}). Each k6 process only lives `--duration` ({duration_secs_val}s), \
461 so RSS resets between batches.",
462 Self::format_wall_clock(until),
463 self.base_command
464 .rounds
465 .map(|n| n.to_string())
466 .unwrap_or_else(|| "unlimited".into()),
467 ));
468 } else if max_rounds > 1 {
469 TerminalReporter::print_progress(&format!(
470 "Campaign loop: will re-run all targets for {max_rounds} round(s)."
471 ));
472 }
473
474 let security_testing_enabled_val = self.base_command.security_testing_enabled();
475
476 if crate::request_gen::should_force_k6_http1(verbatim, &templates, &base_headers) {
477 TerminalReporter::print_progress(
478 "Forcing HTTP/1.1 (GODEBUG=http2client=0): Connection headers are hop-by-hop and HTTP/2 rejects them. The header stays on the wire.",
479 );
480 }
481
482 let has_advanced_features = self.base_command.data_file.is_some()
484 || self.base_command.error_rate.is_some()
485 || self.base_command.security_testing_enabled()
486 || self.base_command.parallel_create.is_some();
487
488 let enhancement_code = if has_advanced_features {
489 let dummy_script = "export const options = {};";
490 let enhanced = self.base_command.generate_enhanced_script(dummy_script)?;
491 if let Some(pos) = enhanced.find("export const options") {
492 enhanced[..pos].to_string()
493 } else {
494 String::new()
495 }
496 } else {
497 String::new()
498 };
499
500 let campaign_start = std::time::Instant::now();
501 let mut last_results: Option<AggregatedResults> = None;
502 let mut round: u32 = 0;
503
504 while round < max_rounds {
505 if let Some(until) = repeat_until_secs {
507 if round > 0 && campaign_start.elapsed().as_secs() >= until {
508 TerminalReporter::print_progress(&format!(
509 "Reached --repeat-until wall clock ({}); stopping after {} round(s).",
510 Self::format_wall_clock(until),
511 round,
512 ));
513 break;
514 }
515 }
516
517 round += 1;
518 let round_output = if looping {
519 let dir = self.base_output.join(format!("round_{round}"));
520 let wall_note = repeat_until_secs
521 .map(|u| {
522 format!(
523 " (wall {} / {})",
524 Self::format_wall_clock(campaign_start.elapsed().as_secs()),
525 Self::format_wall_clock(u)
526 )
527 })
528 .unwrap_or_default();
529 TerminalReporter::print_progress(&format!(
530 "Round {round}{wall_note} — results under {}",
531 dir.display()
532 ));
533 dir
534 } else {
535 self.base_output.clone()
536 };
537
538 let semaphore = Arc::new(Semaphore::new(max_concurrency));
540 let multi_progress = MultiProgress::new();
541
542 let progress_bars: Vec<ProgressBar> = (0..total_targets)
544 .map(|i| {
545 let pb = multi_progress.add(ProgressBar::new(1));
546 pb.set_style(
547 ProgressStyle::default_bar()
548 .template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} {msg}")
549 .unwrap(),
550 );
551 pb.set_message(format!("Target {}", i + 1));
552 pb
553 })
554 .collect();
555
556 let mut handles: Vec<JoinHandle<Result<TargetResult>>> = Vec::new();
558
559 for (index, target) in self.targets.iter().enumerate() {
560 let target = target.clone();
561 let duration = self.base_command.duration.clone();
563 let vus = self.base_command.vus;
564 let scenario_str = self.base_command.scenario.clone();
565 let operations = self.base_command.operations.clone();
566 let auth = self.base_command.auth.clone();
567 let headers = self.base_command.headers.clone();
568 let threshold_percentile = self.base_command.threshold_percentile.clone();
569 let threshold_ms = self.base_command.threshold_ms;
570 let max_error_rate = self.base_command.max_error_rate;
571 let abort_on_error = self.base_command.abort_on_error;
576 let abort_on_error_rate = self.base_command.abort_on_error_rate;
577 let verbose = self.base_command.verbose;
578 let skip_tls_verify = self.base_command.skip_tls_verify;
579 let chunked_request_bodies = self.base_command.chunked_request_bodies;
580 let target_rps = self.base_command.target_rps;
581 let no_keep_alive = self.base_command.no_keep_alive;
582 let local_ips = self.base_command.source_ips.join(",");
589 let dns_policy = self.base_command.dns_policy.clone().unwrap_or_default();
590 let geo_source_ips = self.base_command.geo_source_ips.clone();
591 let geo_source_headers = self.base_command.geo_source_headers.clone();
592
593 let (templates, base_path) = if verbatim {
597 (templates.clone(), base_path.clone())
598 } else if let Some(spec_path) = &target.spec {
599 if let Some((t, bp)) = per_target_data.get(spec_path) {
600 (t.clone(), bp.clone())
601 } else {
602 (templates.clone(), base_path.clone())
603 }
604 } else {
605 (templates.clone(), base_path.clone())
606 };
607
608 let base_headers = base_headers.clone();
609 let scenario = scenario.clone();
610 let duration_secs = duration_secs_val;
611 let base_output = round_output.clone();
612 let semaphore = semaphore.clone();
613 let progress_bar = progress_bars[index].clone();
614 let target_index = index;
615 let security_testing_enabled = security_testing_enabled_val;
616 let enhancement_code = enhancement_code.clone();
617
618 let handle = tokio::spawn(async move {
619 let _permit = semaphore.acquire().await.map_err(|e| {
621 BenchError::Other(format!("Failed to acquire semaphore: {}", e))
622 })?;
623
624 progress_bar.set_message(format!("Testing {}", target.url));
625
626 let result = Self::execute_single_target_internal(
628 &duration,
629 vus,
630 &scenario_str,
631 &operations,
632 &auth,
633 &headers,
634 &threshold_percentile,
635 threshold_ms,
636 max_error_rate,
637 abort_on_error,
638 abort_on_error_rate,
639 per_op_metrics,
640 verbose,
641 skip_tls_verify,
642 base_path.as_ref(),
643 &target,
644 target_index,
645 &templates,
646 &base_headers,
647 &scenario,
648 duration_secs,
649 &base_output,
650 security_testing_enabled,
651 chunked_request_bodies,
652 target_rps,
653 no_keep_alive,
654 &enhancement_code,
655 &local_ips,
656 &dns_policy,
657 &geo_source_ips,
658 &geo_source_headers,
659 verbatim,
660 )
661 .await;
662
663 progress_bar.inc(1);
664 progress_bar.finish_with_message(format!("Completed {}", target.url));
665
666 result
667 });
668
669 handles.push(handle);
670 }
671
672 let mut target_results = Vec::new();
674 for (index, handle) in handles.into_iter().enumerate() {
675 match handle.await {
676 Ok(Ok(result)) => {
677 target_results.push(result);
678 }
679 Ok(Err(e)) => {
680 let target_url = self.targets[index].url.clone();
682 target_results.push(TargetResult {
683 target_url: target_url.clone(),
684 target_index: index,
685 results: K6Results::default(),
686 output_dir: round_output.join(format!("target_{}", index + 1)),
687 success: false,
688 error: Some(e.to_string()),
689 });
690 }
691 Err(e) => {
692 let target_url = self.targets[index].url.clone();
694 target_results.push(TargetResult {
695 target_url: target_url.clone(),
696 target_index: index,
697 results: K6Results::default(),
698 output_dir: round_output.join(format!("target_{}", index + 1)),
699 success: false,
700 error: Some(format!("Task join error: {}", e)),
701 });
702 }
703 }
704 }
705
706 target_results.sort_by_key(|r| r.target_index);
708
709 let aggregated_metrics = AggregatedMetrics::from_results(&target_results);
711
712 let successful_targets = target_results.iter().filter(|r| r.success).count();
713 let failed_targets = total_targets - successful_targets;
714
715 last_results = Some(AggregatedResults {
716 target_results,
717 total_targets,
718 successful_targets,
719 failed_targets,
720 aggregated_metrics,
721 });
722 }
723
724 last_results.ok_or_else(|| {
725 BenchError::Other("No rounds executed (check --rounds / --repeat-until)".to_string())
726 })
727 }
728
729 fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
731 if let Some(cli_base_path) = &self.base_command.base_path {
733 if cli_base_path.is_empty() {
734 return None;
735 }
736 return Some(cli_base_path.clone());
737 }
738 parser.get_base_path()
740 }
741
742 #[allow(clippy::too_many_arguments)]
744 async fn execute_single_target_internal(
745 _duration: &str,
746 vus: u32,
747 _scenario_str: &str,
748 _operations: &Option<String>,
749 auth: &Option<String>,
750 _headers: &[String],
751 threshold_percentile: &str,
752 threshold_ms: u64,
753 max_error_rate: f64,
754 abort_on_error: bool,
755 abort_on_error_rate: f64,
756 per_op_metrics: bool,
757 verbose: bool,
758 skip_tls_verify: bool,
759 base_path: Option<&String>,
760 target: &TargetConfig,
761 target_index: usize,
762 templates: &[crate::request_gen::RequestTemplate],
763 base_headers: &HashMap<String, String>,
764 scenario: &LoadScenario,
765 duration_secs: u64,
766 base_output: &Path,
767 security_testing_enabled: bool,
768 chunked_request_bodies: bool,
769 target_rps: Option<u32>,
770 no_keep_alive: bool,
771 enhancement_code: &str,
772 local_ips: &str,
773 dns_policy: &str,
774 geo_source_ips: &[String],
775 geo_source_headers: &[String],
776 wafbench_verbatim: bool,
777 ) -> Result<TargetResult> {
778 let mut custom_headers = base_headers.clone();
780 if let Some(target_headers) = &target.headers {
781 custom_headers.extend(target_headers.clone());
782 }
783
784 let force_http1 = crate::request_gen::should_force_k6_http1(
786 wafbench_verbatim,
787 templates,
788 &custom_headers,
789 );
790
791 let auth_header = target.auth.as_ref().or(auth.as_ref()).cloned();
793
794 let k6_config = K6Config {
796 target_url: target.url.clone(),
797 base_path: base_path.cloned(),
798 scenario: scenario.clone(),
799 duration_secs,
800 max_vus: vus,
801 threshold_percentile: threshold_percentile.to_string(),
802 threshold_ms,
803 max_error_rate,
804 auth_header,
805 custom_headers,
806 skip_tls_verify,
807 security_testing_enabled,
808 chunked_request_bodies,
809 target_rps,
810 no_keep_alive,
811 geo_source_ips: geo_source_ips.to_vec(),
812 geo_source_headers: geo_source_headers.to_vec(),
813 };
814
815 let generator = K6ScriptGenerator::new(k6_config, templates.to_vec())
817 .with_abort_valve(abort_on_error, abort_on_error_rate)
818 .with_force_http1(force_http1)
819 .with_per_op_metrics(per_op_metrics);
820 let mut script = generator.generate()?;
821
822 if !enhancement_code.is_empty() {
824 if let Some(pos) = script.find("export const options") {
825 script.insert_str(pos, enhancement_code);
826 }
827 }
828
829 let validation_errors = K6ScriptGenerator::validate_script(&script);
831 if !validation_errors.is_empty() {
832 return Err(BenchError::Other(format!(
833 "Script validation failed for target {}: {}",
834 target.url,
835 validation_errors.join(", ")
836 )));
837 }
838
839 let output_dir = base_output.join(format!("target_{}", target_index + 1));
841 std::fs::create_dir_all(&output_dir)?;
842
843 let script_path = output_dir.join("k6-script.js");
845 std::fs::write(&script_path, script)?;
846
847 let api_port = 0; let executor = K6Executor::new()?
872 .with_local_ips(local_ips.to_string())
873 .with_dns_policy(dns_policy.to_string())
874 .with_discard_response_bodies(true)
875 .with_force_http1(force_http1);
876 let results = executor
877 .execute_with_port(&script_path, Some(&output_dir), verbose, Some(api_port))
878 .await;
879
880 match results {
881 Ok(k6_results) => Ok(TargetResult {
882 target_url: target.url.clone(),
883 target_index,
884 results: k6_results,
885 output_dir,
886 success: true,
887 error: None,
888 }),
889 Err(e) => Ok(TargetResult {
890 target_url: target.url.clone(),
891 target_index,
892 results: K6Results::default(),
893 output_dir,
894 success: false,
895 error: Some(e.to_string()),
896 }),
897 }
898 }
899}
900
901#[cfg(test)]
902mod tests {
903 use super::*;
904
905 #[test]
906 fn test_aggregated_metrics_from_results() {
907 let results = vec![
908 TargetResult {
909 target_url: "http://api1.com".to_string(),
910 target_index: 0,
911 results: K6Results {
912 total_requests: 100,
913 failed_requests: 5,
914 avg_duration_ms: 100.0,
915 p95_duration_ms: 200.0,
916 p99_duration_ms: 300.0,
917 ..Default::default()
918 },
919 output_dir: PathBuf::from("output1"),
920 success: true,
921 error: None,
922 },
923 TargetResult {
924 target_url: "http://api2.com".to_string(),
925 target_index: 1,
926 results: K6Results {
927 total_requests: 200,
928 failed_requests: 10,
929 avg_duration_ms: 150.0,
930 p95_duration_ms: 250.0,
931 p99_duration_ms: 350.0,
932 ..Default::default()
933 },
934 output_dir: PathBuf::from("output2"),
935 success: true,
936 error: None,
937 },
938 ];
939
940 let metrics = AggregatedMetrics::from_results(&results);
941 assert_eq!(metrics.total_requests, 300);
942 assert_eq!(metrics.total_failed_requests, 15);
943 assert_eq!(metrics.avg_duration_ms, 125.0); }
945
946 #[test]
947 fn test_aggregated_metrics_with_failed_targets() {
948 let results = vec![
949 TargetResult {
950 target_url: "http://api1.com".to_string(),
951 target_index: 0,
952 results: K6Results {
953 total_requests: 100,
954 failed_requests: 5,
955 avg_duration_ms: 100.0,
956 p95_duration_ms: 200.0,
957 p99_duration_ms: 300.0,
958 ..Default::default()
959 },
960 output_dir: PathBuf::from("output1"),
961 success: true,
962 error: None,
963 },
964 TargetResult {
965 target_url: "http://api2.com".to_string(),
966 target_index: 1,
967 results: K6Results::default(),
968 output_dir: PathBuf::from("output2"),
969 success: false,
970 error: Some("Network error".to_string()),
971 },
972 ];
973
974 let metrics = AggregatedMetrics::from_results(&results);
975 assert_eq!(metrics.total_requests, 100);
977 assert_eq!(metrics.total_failed_requests, 5);
978 assert_eq!(metrics.avg_duration_ms, 100.0);
979 }
980
981 #[test]
982 fn test_aggregated_metrics_empty_results() {
983 let results = vec![];
984 let metrics = AggregatedMetrics::from_results(&results);
985 assert_eq!(metrics.total_requests, 0);
986 assert_eq!(metrics.total_failed_requests, 0);
987 assert_eq!(metrics.avg_duration_ms, 0.0);
988 assert_eq!(metrics.error_rate, 0.0);
989 }
990
991 #[test]
992 fn test_aggregated_metrics_error_rate_calculation() {
993 let results = vec![TargetResult {
994 target_url: "http://api1.com".to_string(),
995 target_index: 0,
996 results: K6Results {
997 total_requests: 1000,
998 failed_requests: 50,
999 avg_duration_ms: 100.0,
1000 p95_duration_ms: 200.0,
1001 p99_duration_ms: 300.0,
1002 ..Default::default()
1003 },
1004 output_dir: PathBuf::from("output1"),
1005 success: true,
1006 error: None,
1007 }];
1008
1009 let metrics = AggregatedMetrics::from_results(&results);
1010 assert_eq!(metrics.error_rate, 5.0); }
1012
1013 #[test]
1014 fn test_aggregated_metrics_p95_p99_calculation() {
1015 let results = vec![
1016 TargetResult {
1017 target_url: "http://api1.com".to_string(),
1018 target_index: 0,
1019 results: K6Results {
1020 total_requests: 100,
1021 failed_requests: 0,
1022 avg_duration_ms: 100.0,
1023 p95_duration_ms: 150.0,
1024 p99_duration_ms: 200.0,
1025 ..Default::default()
1026 },
1027 output_dir: PathBuf::from("output1"),
1028 success: true,
1029 error: None,
1030 },
1031 TargetResult {
1032 target_url: "http://api2.com".to_string(),
1033 target_index: 1,
1034 results: K6Results {
1035 total_requests: 100,
1036 failed_requests: 0,
1037 avg_duration_ms: 200.0,
1038 p95_duration_ms: 250.0,
1039 p99_duration_ms: 300.0,
1040 ..Default::default()
1041 },
1042 output_dir: PathBuf::from("output2"),
1043 success: true,
1044 error: None,
1045 },
1046 TargetResult {
1047 target_url: "http://api3.com".to_string(),
1048 target_index: 2,
1049 results: K6Results {
1050 total_requests: 100,
1051 failed_requests: 0,
1052 avg_duration_ms: 300.0,
1053 p95_duration_ms: 350.0,
1054 p99_duration_ms: 400.0,
1055 ..Default::default()
1056 },
1057 output_dir: PathBuf::from("output3"),
1058 success: true,
1059 error: None,
1060 },
1061 ];
1062
1063 let metrics = AggregatedMetrics::from_results(&results);
1064 assert_eq!(metrics.p95_duration_ms, 350.0);
1067 assert_eq!(metrics.p99_duration_ms, 400.0);
1068 }
1069
1070 #[test]
1071 fn estimated_wall_clock_is_batches_times_duration() {
1072 let (batches, wall) = ParallelExecutor::estimated_wall_clock(64, 10, 300);
1074 assert_eq!(batches, 7);
1075 assert_eq!(wall, 2100);
1076 assert_eq!(ParallelExecutor::format_wall_clock(2100), "35m00s");
1077 assert_eq!(ParallelExecutor::estimated_wall_clock(10, 10, 300), (1, 300));
1078 assert_eq!(ParallelExecutor::estimated_wall_clock(0, 10, 300), (0, 0));
1079 assert_eq!(ParallelExecutor::format_wall_clock(45), "45s");
1080 assert_eq!(ParallelExecutor::format_wall_clock(3661), "1h01m01s");
1081 }
1082
1083 #[test]
1084 fn parallel_k6_spawn_sets_force_http1() {
1085 let src = include_str!("parallel_executor.rs");
1086 assert!(
1087 src.contains("with_force_http1(force_http1)"),
1088 "multi-target k6 spawn must pass GODEBUG=http2client=0 when Connection headers are present"
1089 );
1090 assert!(
1091 src.contains("One pass:") || src.contains("Estimated wall clock"),
1092 "multi-target start must print ceil(targets/concurrency)*duration"
1093 );
1094 assert!(
1095 src.contains("repeat_until") && src.contains("Campaign loop"),
1096 "multi-target must support --repeat-until campaign looping (#79)"
1097 );
1098 assert!(
1099 src.contains("with_per_op_metrics(per_op_metrics)"),
1100 "multi-target k6 spawn must apply Round-65 per-op metrics collapse (#79)"
1101 );
1102 assert!(
1103 src.contains("resolve_max_concurrency"),
1104 "multi-target must auto-cap concurrency for huge specs (#79)"
1105 );
1106 }
1107}