1use crate::chunked_bench::{
23 build_json_body, is_json_content_type, run, ChunkedBenchConfig, ChunkedBenchResult,
24};
25use crate::parallel_executor::ParallelExecutor;
26use crate::request_gen::RequestGenerator;
27use crate::spec_parser::SpecParser;
28use crate::target_parser::{parse_targets_file, TargetConfig};
29use anyhow::{bail, Context};
30use futures::StreamExt;
31use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33use std::sync::Arc;
34use std::time::Duration;
35
36#[derive(Debug, Clone)]
38pub struct ChunkedCommand {
39 pub target: Option<String>,
41 pub targets_file: Option<PathBuf>,
43 pub max_concurrency: u32,
45 pub spec: Option<PathBuf>,
46 pub base_path: Option<String>,
47 pub operation_id: Option<String>,
48 pub method: String,
49 pub concurrency: u32,
50 pub duration: Duration,
51 pub chunk_size_bytes: usize,
52 pub total_size_bytes: usize,
53 pub chunk_interval_ms: u64,
54 pub headers: HashMap<String, String>,
55 pub insecure: bool,
56 pub validate_requests: bool,
57 pub export_requests: bool,
58 pub output: PathBuf,
59 pub rps: Option<u32>,
61 pub cps: bool,
63 pub rounds: Option<u32>,
65 pub repeat_until: Option<Duration>,
67 pub keep_rounds: Option<u32>,
69}
70
71#[derive(Debug, Clone)]
73struct TargetRun {
74 prefix: String,
76 url: String,
77 headers: HashMap<String, String>,
78 spec: Option<PathBuf>,
79 output: PathBuf,
80}
81
82#[derive(Debug, Clone, Default)]
84struct TargetOutcome {
85 total_requests: u64,
86 successful: u64,
87 failed: u64,
88 bytes_sent: u64,
89 status_counts: HashMap<u16, u64>,
90 run_failed: bool,
92}
93
94impl TargetOutcome {
95 fn absorb(&mut self, r: &ChunkedBenchResult) {
96 self.total_requests += r.total_requests;
97 self.successful += r.successful;
98 self.failed += r.failed;
99 self.bytes_sent += r.bytes_sent;
100 for (code, n) in &r.status_counts {
101 *self.status_counts.entry(*code).or_insert(0) += n;
102 }
103 }
104}
105
106impl ChunkedCommand {
107 pub async fn execute(&self) -> anyhow::Result<bool> {
110 match (&self.target, &self.targets_file) {
111 (Some(_), Some(_)) => bail!("--target and --targets-file are mutually exclusive"),
112 (None, None) => bail!("either --target or --targets-file is required"),
113 _ => {}
114 }
115 if self.rounds == Some(0) {
116 bail!("--rounds must be >= 1 (or omit it for a single pass / --repeat-until)");
117 }
118 let max_rounds = self.rounds.unwrap_or(if self.repeat_until.is_some() {
119 u32::MAX
120 } else {
121 1
122 });
123 let looping = max_rounds > 1 || self.repeat_until.is_some();
124 if !looping {
125 if self.keep_rounds.is_some() {
126 eprintln!(
127 "Note: --keep-rounds only applies to campaign runs (--repeat-until / --rounds > 1); ignoring"
128 );
129 }
130 return Ok(self.execute_pass(&self.output).await?.0);
131 }
132
133 match self.repeat_until {
134 Some(until) => println!(
135 "Campaign: re-running every pass until {}s of wall clock (or --rounds {})",
136 until.as_secs(),
137 self.rounds.map(|n| n.to_string()).unwrap_or_else(|| "unlimited".into())
138 ),
139 None => println!("Campaign: {max_rounds} round(s)"),
140 }
141 if let Some(keep) = self.keep_rounds {
142 println!(
143 "Round pruning: keeping newest {keep} round_* dir(s); per-round stats persist in campaign.jsonl + round-summaries/"
144 );
145 }
146
147 let campaign_start = std::time::Instant::now();
148 let mut all_ok = true;
149 let mut round: u32 = 0;
150 while round < max_rounds {
151 if let Some(until) = self.repeat_until {
152 if round > 0 && campaign_start.elapsed() >= until {
153 println!("Reached --repeat-until; stopping after {round} round(s)");
154 break;
155 }
156 }
157 round += 1;
158 let round_output = self.output.join(format!("round_{round}"));
159 println!(
160 "\n=== Round {round} (campaign elapsed {}s) → {} ===",
161 campaign_start.elapsed().as_secs(),
162 round_output.display()
163 );
164 let (ok, summary) = self.execute_pass(&round_output).await?;
165 all_ok &= ok;
166 write_round_record(&self.output, round, campaign_start.elapsed(), ok, summary)?;
167 if let Some(keep) = self.keep_rounds {
168 ParallelExecutor::prune_old_rounds(&self.output, keep);
169 }
170 }
171 Ok(all_ok)
172 }
173
174 async fn execute_pass(&self, output: &Path) -> anyhow::Result<(bool, serde_json::Value)> {
177 match (&self.target, &self.targets_file) {
178 (Some(target), _) => {
179 let run = TargetRun {
180 prefix: String::new(),
181 url: target.clone(),
182 headers: self.headers.clone(),
183 spec: self.spec.clone(),
184 output: output.to_path_buf(),
185 };
186 let o = self.run_target(&run).await?;
187 let summary = serde_json::json!({
188 "url": target,
189 "total_requests": o.total_requests,
190 "successful": o.successful,
191 "failed": o.failed,
192 "bytes_sent": o.bytes_sent,
193 "status_counts": o.status_counts,
194 "run_failed": o.run_failed,
195 });
196 Ok((!o.run_failed, summary))
197 }
198 (None, Some(file)) => self.execute_multi_target(file, output).await,
199 (None, None) => bail!("either --target or --targets-file is required"),
200 }
201 }
202
203 async fn execute_multi_target(
204 &self,
205 file: &Path,
206 output: &Path,
207 ) -> anyhow::Result<(bool, serde_json::Value)> {
208 let targets = parse_targets_file(file)
209 .map_err(|e| anyhow::anyhow!("{e}"))
210 .with_context(|| format!("failed to read targets file {}", file.display()))?;
211 if targets.is_empty() {
212 bail!("no targets found in {}", file.display());
213 }
214 let runs = plan_target_runs(targets, &self.headers, self.spec.as_ref(), output);
215 let max_concurrency = self.max_concurrency.max(1) as usize;
216 println!(
217 "→ Chunked bench across {} targets from {:?} (max {} in parallel)",
218 runs.len(),
219 file,
220 max_concurrency
221 );
222
223 let mut outcomes: Vec<(usize, TargetRun, anyhow::Result<TargetOutcome>)> =
224 futures::stream::iter(runs.into_iter().enumerate())
225 .map(|(i, run)| async move {
226 let res = self.run_target(&run).await;
227 (i, run, res)
228 })
229 .buffer_unordered(max_concurrency)
230 .collect()
231 .await;
232 outcomes.sort_by_key(|(i, _, _)| *i);
233
234 println!();
235 println!("Multi-target chunked summary:");
236 let mut all_ok = true;
237 let mut rows = Vec::with_capacity(outcomes.len());
238 for (i, run, res) in &outcomes {
239 let name = format!("target_{}", i + 1);
240 match res {
241 Ok(o) => {
242 if o.run_failed {
243 all_ok = false;
244 }
245 println!(
246 " {:<10} {} requests={} ok={} failed={} bytes={}{}",
247 name,
248 run.url,
249 o.total_requests,
250 o.successful,
251 o.failed,
252 o.bytes_sent,
253 if o.run_failed {
254 " (some operations failed to run)"
255 } else {
256 ""
257 }
258 );
259 rows.push(serde_json::json!({
260 "target": name,
261 "url": run.url,
262 "spec": run.spec.as_ref().map(|p| p.display().to_string()),
263 "output_dir": run.spec.as_ref().map(|_| run.output.display().to_string()),
265 "total_requests": o.total_requests,
266 "successful": o.successful,
267 "failed": o.failed,
268 "bytes_sent": o.bytes_sent,
269 "status_counts": o.status_counts,
270 "run_failed": o.run_failed,
271 }));
272 }
273 Err(e) => {
274 all_ok = false;
275 println!(" {:<10} {} ERROR: {:#}", name, run.url, e);
276 rows.push(serde_json::json!({
277 "target": name,
278 "url": run.url,
279 "error": format!("{e:#}"),
280 }));
281 }
282 }
283 }
284
285 std::fs::create_dir_all(output)
286 .with_context(|| format!("failed to create output directory {:?}", output))?;
287 let path = output.join("chunked-multi-target-summary.json");
288 let payload = serde_json::json!({
289 "targets_file": file.display().to_string(),
290 "concurrency_per_target": self.concurrency,
291 "max_concurrency": max_concurrency,
292 "duration_secs": self.duration.as_secs(),
293 "chunk_size_bytes": self.chunk_size_bytes,
294 "total_size_bytes": self.total_size_bytes,
295 "chunk_interval_ms": self.chunk_interval_ms,
296 "rps_per_target": self.rps,
297 "new_connection_per_request": self.cps,
298 "targets": rows,
299 });
300 std::fs::write(&path, serde_json::to_string_pretty(&payload)?)
301 .with_context(|| format!("failed to write {:?}", path))?;
302 println!("📝 Wrote {:?}", path);
303 Ok((all_ok, payload))
304 }
305
306 fn bench_config(
307 &self,
308 url: String,
309 method: reqwest::Method,
310 headers: &HashMap<String, String>,
311 json_seed: Option<&serde_json::Value>,
312 ) -> ChunkedBenchConfig {
313 let body = is_json_content_type(headers)
316 .then(|| Arc::new(build_json_body(json_seed, self.total_size_bytes)));
317 if let Some(b) = &body {
318 if b.len() > self.total_size_bytes {
319 eprintln!(
320 "Note: --total-size-bytes {} is below the smallest valid JSON body; sending {} bytes",
321 self.total_size_bytes,
322 b.len()
323 );
324 }
325 }
326 ChunkedBenchConfig {
327 target_url: url,
328 method,
329 concurrency: self.concurrency,
330 duration: self.duration,
331 chunk_size_bytes: self.chunk_size_bytes,
332 total_size_bytes: self.total_size_bytes,
333 chunk_interval_ms: self.chunk_interval_ms,
334 headers: headers.clone(),
335 skip_tls_verify: self.insecure,
336 rps: self.rps,
337 no_keep_alive: self.cps,
338 body,
339 }
340 }
341
342 async fn run_target(&self, t: &TargetRun) -> anyhow::Result<TargetOutcome> {
343 match &t.spec {
344 Some(spec) => self.run_spec(t, spec).await,
345 None => self.run_single(t).await,
346 }
347 }
348
349 async fn run_single(&self, t: &TargetRun) -> anyhow::Result<TargetOutcome> {
350 let p = &t.prefix;
351 if p.is_empty() {
353 if self.base_path.is_some() {
354 eprintln!(
355 "Note: --base-path has no effect without --spec; \
356 single-target mode uses --target verbatim"
357 );
358 }
359 if self.validate_requests {
360 eprintln!("Note: --validate-requests requires --spec (skipped)");
361 }
362 if self.export_requests {
363 eprintln!("Note: --export-requests requires --spec (skipped)");
364 }
365 }
366 let method = reqwest::Method::from_bytes(self.method.to_uppercase().as_bytes())
367 .map_err(|_| anyhow::anyhow!("Invalid HTTP method: {}", self.method))?;
368 if !p.is_empty() {
369 println!("{p}→ {} {}", self.method.to_uppercase(), t.url);
370 }
371 let r = run(self.bench_config(t.url.clone(), method, &t.headers, None))
372 .await
373 .context("chunked bench failed")?;
374 print_result(
375 p,
376 if p.is_empty() {
377 "single-target"
378 } else {
379 &t.url
380 },
381 &r,
382 );
383 let mut outcome = TargetOutcome::default();
384 outcome.absorb(&r);
385 Ok(outcome)
386 }
387
388 async fn run_spec(&self, t: &TargetRun, spec_path: &Path) -> anyhow::Result<TargetOutcome> {
389 let p = &t.prefix;
390 let parser = SpecParser::from_file(spec_path)
391 .await
392 .map_err(|e| anyhow::anyhow!("Failed to parse spec {:?}: {}", spec_path, e))?;
393 let base_url = t.url.trim_end_matches('/').to_string();
394 let effective_base_path: Option<String> = match &self.base_path {
397 Some(bp) if bp.is_empty() => None,
398 Some(bp) => Some(bp.clone()),
399 None => parser.get_base_path(),
400 };
401 let want_methods = ["POST", "PUT", "PATCH"];
402 let ops: Vec<_> = parser
403 .get_operations()
404 .into_iter()
405 .filter(|op| want_methods.contains(&op.method.to_uppercase().as_str()))
406 .filter(|op| match &self.operation_id {
407 Some(id) => op.operation_id.as_deref() == Some(id.as_str()),
408 None => true,
409 })
410 .collect();
411 if ops.is_empty() {
412 bail!(
413 "No POST/PUT/PATCH operations matched in {:?}{}",
414 spec_path,
415 self.operation_id
416 .as_deref()
417 .map(|id| format!(" (operation-id={id})"))
418 .unwrap_or_default()
419 );
420 }
421
422 let mut violations: Vec<serde_json::Value> = Vec::new();
425 let mut planned: Vec<(String, String, String, String, Option<serde_json::Value>)> =
427 Vec::with_capacity(ops.len());
428 for op in &ops {
429 let label = op.display_name();
430 match RequestGenerator::generate_template(op) {
431 Ok(template) => {
432 let path_with_base = match &effective_base_path {
433 Some(bp) if !bp.is_empty() => {
434 format!("{}{}", bp, template.generate_path())
435 }
436 _ => template.generate_path().to_string(),
437 };
438 let url = format!("{}{}", base_url, path_with_base);
439 if reqwest::Method::from_bytes(op.method.to_uppercase().as_bytes()).is_err() {
440 violations.push(serde_json::json!({
441 "operation": label,
442 "method": op.method,
443 "path": op.path,
444 "kind": "invalid_method",
445 "detail": format!("`{}` is not a valid HTTP method", op.method),
446 }));
447 } else {
448 planned.push((
449 label,
450 op.method.to_uppercase(),
451 url,
452 op.path.clone(),
453 template.body.clone(),
454 ));
455 }
456 }
457 Err(e) => {
458 violations.push(serde_json::json!({
459 "operation": label,
460 "method": op.method,
461 "path": op.path,
462 "kind": "template_build_failure",
463 "detail": e.to_string(),
464 }));
465 }
466 }
467 }
468
469 if self.validate_requests {
470 if !violations.is_empty() {
471 std::fs::create_dir_all(&t.output)
472 .with_context(|| format!("Failed to create output directory {:?}", t.output))?;
473 let path = t.output.join("chunked-request-violations.json");
474 let payload = serde_json::json!({
475 "spec": spec_path.display().to_string(),
476 "base_url": base_url,
477 "base_path": effective_base_path,
478 "violations": violations,
479 });
480 std::fs::write(&path, serde_json::to_string_pretty(&payload)?)
481 .with_context(|| format!("Failed to write {:?}", path))?;
482 bail!("--validate-requests: {} violation(s); see {:?}", violations.len(), path);
483 }
484 println!("{p}✅ --validate-requests: all {} operations OK", planned.len());
485 }
486
487 println!(
488 "{p}→ {} chunked bench {} from {:?} (base {}{})",
489 planned.len(),
490 if planned.len() == 1 {
491 "operation"
492 } else {
493 "operations"
494 },
495 spec_path,
496 base_url,
497 effective_base_path
498 .as_deref()
499 .map(|bp| format!(", base-path {}", bp))
500 .unwrap_or_default()
501 );
502
503 let mut outcome = TargetOutcome::default();
504 let mut export_records: Vec<serde_json::Value> = Vec::new();
505 for (label, method_str, url, op_path, json_seed) in &planned {
506 let method = match reqwest::Method::from_bytes(method_str.as_bytes()) {
507 Ok(m) => m,
508 Err(_) => {
509 eprintln!("{p}✗ {}: invalid method `{}`", label, method_str);
510 outcome.run_failed = true;
511 continue;
512 }
513 };
514 println!();
515 println!("{p}→ {} {} ({})", method_str, op_path, url);
516 let cfg = self.bench_config(url.clone(), method, &t.headers, json_seed.as_ref());
517 match run(cfg).await {
518 Ok(r) => {
519 print_result(p, label, &r);
520 outcome.absorb(&r);
521 if self.export_requests {
522 export_records.push(export_record(
523 self, label, method_str, url, op_path, &t.headers, &r,
524 ));
525 }
526 }
527 Err(e) => {
528 eprintln!("{p}✗ {}: chunked bench failed: {}", label, e);
529 outcome.run_failed = true;
530 }
531 }
532 }
533
534 if self.export_requests {
535 std::fs::create_dir_all(&t.output)
536 .with_context(|| format!("Failed to create output directory {:?}", t.output))?;
537 let path = t.output.join("chunked-requests.json");
538 let payload = serde_json::json!({
539 "spec": spec_path.display().to_string(),
540 "base_url": base_url,
541 "base_path": effective_base_path,
542 "operations": export_records,
543 });
544 std::fs::write(&path, serde_json::to_string_pretty(&payload)?)
545 .with_context(|| format!("Failed to write {:?}", path))?;
546 println!(
547 "{p}📝 --export-requests: wrote {} operations to {:?}",
548 export_records.len(),
549 path
550 );
551 }
552
553 Ok(outcome)
554 }
555}
556
557fn plan_target_runs(
561 targets: Vec<TargetConfig>,
562 cli_headers: &HashMap<String, String>,
563 cli_spec: Option<&PathBuf>,
564 output: &Path,
565) -> Vec<TargetRun> {
566 targets
567 .into_iter()
568 .enumerate()
569 .map(|(i, mut t)| {
570 t.normalize_url();
571 let mut headers = cli_headers.clone();
572 if let Some(h) = &t.headers {
573 headers.extend(h.iter().map(|(k, v)| (k.clone(), v.clone())));
574 }
575 if let Some(auth) = &t.auth {
576 headers.insert("Authorization".to_string(), auth.clone());
577 }
578 let name = format!("target_{}", i + 1);
579 TargetRun {
580 prefix: format!("[{name}] "),
581 url: t.url,
582 headers,
583 spec: t.spec.or_else(|| cli_spec.cloned()),
584 output: output.join(name),
585 }
586 })
587 .collect()
588}
589
590fn write_round_record(
594 output: &Path,
595 round: u32,
596 campaign_elapsed: Duration,
597 ok: bool,
598 pass: serde_json::Value,
599) -> anyhow::Result<()> {
600 use std::io::Write;
601 let record = serde_json::json!({
602 "round": round,
603 "campaign_elapsed_seconds": campaign_elapsed.as_secs(),
604 "all_runs_completed": ok,
605 "pass": pass,
606 });
607 let summaries = output.join("round-summaries");
608 std::fs::create_dir_all(&summaries)
609 .with_context(|| format!("failed to create {:?}", summaries))?;
610 std::fs::write(
611 summaries.join(format!("round_{round}.json")),
612 serde_json::to_string_pretty(&record)?,
613 )?;
614 let mut line = serde_json::to_string(&record)?;
615 line.push('\n');
616 std::fs::OpenOptions::new()
617 .create(true)
618 .append(true)
619 .open(output.join("campaign.jsonl"))?
620 .write_all(line.as_bytes())
621 .context("failed to append campaign.jsonl")?;
622 Ok(())
623}
624
625fn export_record(
626 cmd: &ChunkedCommand,
627 label: &str,
628 method: &str,
629 url: &str,
630 op_path: &str,
631 headers: &HashMap<String, String>,
632 r: &ChunkedBenchResult,
633) -> serde_json::Value {
634 serde_json::json!({
635 "operation": label,
636 "method": method,
637 "url": url,
638 "spec_path": op_path,
639 "headers": headers,
640 "chunk_size_bytes": cmd.chunk_size_bytes,
641 "total_size_bytes": cmd.total_size_bytes,
642 "chunk_interval_ms": cmd.chunk_interval_ms,
643 "concurrency": cmd.concurrency,
644 "rps": cmd.rps,
645 "new_connection_per_request": cmd.cps,
646 "duration_secs": cmd.duration.as_secs(),
647 "result": {
648 "total_requests": r.total_requests,
649 "successful": r.successful,
650 "failed": r.failed,
651 "bytes_sent": r.bytes_sent,
652 "elapsed_ms": r.elapsed.as_millis(),
653 "req_per_sec": r.req_per_sec,
654 "avg_latency_ms": r.avg_latency_ms,
655 "p50_ms": r.p50_ms,
656 "p95_ms": r.p95_ms,
657 "p99_ms": r.p99_ms,
658 "status_counts": r.status_counts,
659 "error_samples": r.error_samples.iter().map(|s| serde_json::json!({
660 "status": s.status,
661 "server_header": s.server_header,
662 "body_excerpt": s.body_excerpt,
663 })).collect::<Vec<_>>(),
664 },
665 })
666}
667
668fn print_result(prefix: &str, label: &str, r: &ChunkedBenchResult) {
671 let mut out = String::new();
672 let mut line = |s: String| {
673 out.push_str(prefix);
674 out.push_str(&s);
675 out.push('\n');
676 };
677 line(format!("Chunked bench [{}] complete in {:?}", label, r.elapsed));
678 line(format!(" Total requests: {}", r.total_requests));
679 line(format!(" Successful: {}", r.successful));
680 line(format!(" Failed: {}", r.failed));
681 line(format!(" Bytes sent: {}", r.bytes_sent));
682 line(format!(" Throughput: {:.2} req/s", r.req_per_sec));
683 line(format!(
684 " Latency: avg={:.1}ms p50={}ms p95={}ms p99={}ms",
685 r.avg_latency_ms, r.p50_ms, r.p95_ms, r.p99_ms
686 ));
687 if !r.status_counts.is_empty() {
688 line(" Status codes:".to_string());
689 let mut codes: Vec<_> = r.status_counts.iter().collect();
690 codes.sort_by_key(|(k, _)| **k);
691 for (code, n) in codes {
692 line(format!(" {} = {}", code, n));
693 }
694 }
695 if !r.error_samples.is_empty() {
700 line(" Error response samples:".to_string());
701 for s in &r.error_samples {
702 let server = s.server_header.as_deref().unwrap_or("(no Server header)");
703 line(format!(" [{}] Server: {}", s.status, server));
704 if !s.body_excerpt.is_empty() {
705 line(format!(" body: {}", s.body_excerpt.replace('\n', " ")));
706 }
707 }
708 if r.status_counts.keys().any(|c| (500..600).contains(c)) {
709 line(
710 " Hint: 5xx responses with `Server:` revealing a proxy/LB usually \
711 mean the proxy timed out reading from upstream. Each chunked request \
712 takes >= (total_size_bytes / chunk_size_bytes) * chunk_interval_ms; \
713 if that exceeds the proxy's upstream timeout, errors are inevitable."
714 .to_string(),
715 );
716 }
717 }
718 print!("{out}");
719}
720
721#[cfg(test)]
722mod tests {
723 use super::*;
724
725 #[test]
726 fn plan_target_runs_merges_headers_auth_spec_and_output() {
727 let mut target_headers = HashMap::new();
728 target_headers.insert("X-Env".to_string(), "b".to_string());
729 let targets = vec![
730 TargetConfig::from_url("10.0.0.1:8080".to_string()),
731 TargetConfig {
732 url: "https://b.example".to_string(),
733 auth: Some("Bearer t".to_string()),
734 headers: Some(target_headers),
735 spec: Some(PathBuf::from("b.yaml")),
736 },
737 ];
738 let mut cli_headers = HashMap::new();
739 cli_headers.insert("X-Env".to_string(), "cli".to_string());
740 let cli_spec = PathBuf::from("cli.yaml");
741
742 let runs = plan_target_runs(targets, &cli_headers, Some(&cli_spec), Path::new("out"));
743
744 assert_eq!(runs.len(), 2);
745 assert_eq!(runs[0].url, "http://10.0.0.1:8080");
746 assert_eq!(runs[0].headers.get("X-Env").map(String::as_str), Some("cli"));
747 assert_eq!(runs[0].spec.as_deref(), Some(Path::new("cli.yaml")));
748 assert_eq!(runs[0].output, Path::new("out").join("target_1"));
749 assert_eq!(runs[0].prefix, "[target_1] ");
750
751 assert_eq!(runs[1].headers.get("X-Env").map(String::as_str), Some("b"));
752 assert_eq!(runs[1].headers.get("Authorization").map(String::as_str), Some("Bearer t"));
753 assert_eq!(runs[1].spec.as_deref(), Some(Path::new("b.yaml")));
754 assert_eq!(runs[1].output, Path::new("out").join("target_2"));
755 }
756
757 fn cmd(target: Option<&str>, targets_file: Option<PathBuf>) -> ChunkedCommand {
758 ChunkedCommand {
759 target: target.map(str::to_string),
760 targets_file,
761 max_concurrency: 10,
762 spec: None,
763 base_path: None,
764 operation_id: None,
765 method: "POST".to_string(),
766 concurrency: 1,
767 duration: Duration::from_millis(10),
768 chunk_size_bytes: 1024,
769 total_size_bytes: 4096,
770 chunk_interval_ms: 0,
771 headers: HashMap::new(),
772 insecure: false,
773 validate_requests: false,
774 export_requests: false,
775 output: PathBuf::from("bench-results"),
776 rps: None,
777 cps: false,
778 rounds: None,
779 repeat_until: None,
780 keep_rounds: None,
781 }
782 }
783
784 #[tokio::test]
785 async fn rejects_target_and_targets_file_together() {
786 let c = cmd(Some("http://127.0.0.1:1"), Some(PathBuf::from("t.txt")));
787 assert!(c.execute().await.is_err());
788 }
789
790 #[tokio::test]
791 async fn rejects_neither_target_nor_targets_file() {
792 assert!(cmd(None, None).execute().await.is_err());
793 }
794
795 fn scratch_dir(name: &str) -> PathBuf {
796 let dir = std::env::temp_dir().join(format!(
797 "mf-chunked-{name}-{}-{}",
798 std::process::id(),
799 line!()
800 ));
801 let _ = std::fs::remove_dir_all(&dir);
802 std::fs::create_dir_all(&dir).unwrap();
803 dir
804 }
805
806 #[tokio::test]
807 async fn campaign_rounds_write_records_and_prune_round_dirs() {
808 let mut server = mockito::Server::new_async().await;
809 let _m = server.mock("POST", "/upload").with_status(200).create_async().await;
810 let dir = scratch_dir("campaign");
811 let targets = dir.join("targets.txt");
812 std::fs::write(&targets, format!("{}/upload\n{}/upload\n", server.url(), server.url()))
813 .unwrap();
814 let mut c = cmd(None, Some(targets));
815 c.output = dir.join("out");
816 c.duration = Duration::from_millis(50);
817 c.rounds = Some(3);
818 c.keep_rounds = Some(1);
819
820 assert!(c.execute().await.unwrap());
821
822 let jsonl = std::fs::read_to_string(c.output.join("campaign.jsonl")).unwrap();
823 let rounds: Vec<serde_json::Value> =
824 jsonl.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
825 assert_eq!(rounds.len(), 3);
826 assert_eq!(rounds[2]["round"], 3);
827 assert_eq!(rounds[2]["pass"]["targets"].as_array().unwrap().len(), 2);
828 for n in 1..=3 {
829 assert!(c.output.join(format!("round-summaries/round_{n}.json")).exists());
830 }
831 assert!(!c.output.join("round_1").exists());
832 assert!(!c.output.join("round_2").exists());
833 assert!(c.output.join("round_3/chunked-multi-target-summary.json").exists());
834 let _ = std::fs::remove_dir_all(&dir);
835 }
836
837 #[tokio::test]
838 async fn campaign_repeat_until_stops_on_wall_clock() {
839 let mut server = mockito::Server::new_async().await;
840 let _m = server.mock("POST", "/upload").with_status(200).create_async().await;
841 let dir = scratch_dir("until");
842 let mut c = cmd(Some(&format!("{}/upload", server.url())), None);
843 c.output = dir.clone();
844 c.duration = Duration::from_millis(50);
848 c.repeat_until = Some(Duration::from_millis(500));
849
850 assert!(c.execute().await.unwrap());
851
852 let n = std::fs::read_to_string(dir.join("campaign.jsonl")).unwrap().lines().count();
853 assert!((2..=11).contains(&n), "expected 2-11 rounds of 50ms in 500ms, got {n}");
854 let _ = std::fs::remove_dir_all(&dir);
855 }
856
857 #[tokio::test]
858 async fn rejects_zero_rounds() {
859 let mut c = cmd(Some("http://127.0.0.1:1"), None);
860 c.rounds = Some(0);
861 assert!(c.execute().await.is_err());
862 }
863}