tropel_engine/cli.rs
1//! # CLI entry point
2//!
3//! Reusable CLI logic that is called by both the standard `tropel` binary
4//! and custom binaries built with `tropel build --with <ext>`.
5//!
6//! This module handles argument parsing, tracing initialization, and
7//! dispatching to the appropriate engine subcommand. Custom binaries
8//! simply call `tropel_engine::cli::run_cli()` from their `fn main()`.
9
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13use clap::{Parser, Subcommand};
14use tropel_core::config::*;
15use tropel_metrics::thresholds::evaluate_thresholds;
16use tropel_sdk::{Result, TropelError};
17
18use crate::cli_commands::{
19 archive_command, build_custom, inspect_command, list_extensions, load_data_file, print_version,
20};
21use crate::cli_overlay::{apply_overlay, merge_partial};
22use crate::cli_registry::build_registry;
23use crate::config_file::PartialConfig;
24use crate::engine::Engine;
25
26/// Tropel — A high-performance load-testing framework.
27#[derive(Parser, Debug)]
28#[command(name = "tropel", version, about, long_about = None)]
29pub struct Cli {
30 #[command(subcommand)]
31 pub command: Commands,
32}
33
34#[derive(Subcommand, Debug)]
35#[allow(clippy::large_enum_variant)]
36pub enum Commands {
37 /// Run a load test
38 Run {
39 /// Path to the input file (collection, HAR, script, etc.)
40 input: PathBuf,
41
42 /// Input format (auto-detect if not specified).
43 /// Use `tropel extensions` to list available formats.
44 #[arg(long = "format")]
45 format: Option<String>,
46
47 /// Number of virtual users (overrides collection config)
48 #[arg(short = 'u', long = "vus")]
49 vus: Option<u32>,
50
51 /// Test duration (e.g. "30s", "5m")
52 #[arg(short = 'd', long = "duration")]
53 duration: Option<String>,
54
55 /// Environment variable (can be specified multiple times: -e KEY=VALUE)
56 #[arg(short = 'e', long = "env")]
57 env: Vec<String>,
58
59 /// Environment file (JSON)
60 #[arg(short = 'E', long = "env-file")]
61 env_file: Option<PathBuf>,
62
63 /// Data file (CSV or JSON)
64 #[arg(short = 'D', long = "data-file")]
65 data_file: Option<PathBuf>,
66
67 /// JSON config file (partial JobConfig overlay). Merged with
68 /// precedence: explicit CLI flags > config file > K6_* env > defaults.
69 #[arg(long = "config")]
70 config: Option<PathBuf>,
71
72 /// Report format (stdout, json, csv)
73 #[arg(short = 'r', long = "reporter", default_value = "stdout")]
74 reporter: Vec<String>,
75
76 /// Output file path (for json/csv reporters)
77 #[arg(short = 'o', long = "output")]
78 output: Option<PathBuf>,
79
80 /// Prometheus remote-write endpoint (e.g. http://localhost:9090)
81 #[arg(long = "prometheus-url")]
82 prometheus_url: Option<String>,
83
84 /// OTLP/HTTP collector endpoint (e.g. http://localhost:4318)
85 #[arg(long = "otlp-endpoint")]
86 otlp_endpoint: Option<String>,
87
88 /// k6-style summary export path: writes the aggregated summary data
89 /// object as JSON (when no script handleSummary overrides output)
90 #[arg(long = "summary-export")]
91 summary_export: Option<PathBuf>,
92
93 /// NDJSON streaming output file (k6 `--out json=file` equivalent):
94 /// every sample is appended as one JSON line during the run
95 #[arg(long = "json-stream")]
96 json_stream: Option<PathBuf>,
97
98 /// StatsD / Datadog agent address (host:port, e.g. localhost:8125)
99 /// for streaming datagram output
100 #[arg(long = "statsd-addr")]
101 statsd_addr: Option<String>,
102
103 /// InfluxDB line-protocol UDP address (host:port, e.g. localhost:8089)
104 /// for streaming line-protocol datagrams
105 #[arg(long = "influxdb-addr")]
106 influxdb_addr: Option<String>,
107
108 /// Deterministic workload partition for this node, as "from:to"
109 /// (e.g. "0:1/3") — k6 `executionSegment`. Combined with
110 /// --execution-segment-sequence this node runs only its fraction of
111 /// the workload (VUs/iterations/rate), scaled deterministically.
112 #[arg(long = "execution-segment")]
113 execution_segment: Option<String>,
114
115 /// Full sequence of segment boundaries shared by all cooperating
116 /// nodes, e.g. "0,1/3,2/3,1" — k6 `executionSegmentSequence`.
117 #[arg(long = "execution-segment-sequence")]
118 execution_segment_sequence: Option<String>,
119
120 /// Threshold expression (can be specified multiple times)
121 #[arg(short = 't', long = "threshold")]
122 threshold: Vec<String>,
123
124 /// Port for the runtime control API (k6 REST parity). When set with
125 /// an `externally-controlled` executor, binds 127.0.0.1:<port> and
126 /// serves GET/PATCH /v1/status so VUs can be adjusted mid-run.
127 #[arg(long = "control-port")]
128 control_port: Option<u16>,
129
130 /// Insecure TLS (skip certificate verification)
131 #[arg(short = 'k', long = "insecure")]
132 insecure: bool,
133
134 /// Show verbose output
135 #[arg(short = 'v', long = "verbose")]
136 verbose: bool,
137
138 /// Log every HTTP request/response at debug level. Without `=full`,
139 /// prints the method, URL, status, timing, and body / header counts.
140 /// With `--http-debug=full`, also prints the request/response headers
141 /// and the first 1 KiB of the body. Equivalent to k6's `--http-debug`
142 /// and `--http-debug=full`.
143 #[arg(long = "http-debug", num_args = 0..=1, default_missing_value = "headers")]
144 http_debug: Option<String>,
145
146 /// Never follow redirects: each 3xx response is returned to the
147 /// script as-is and every redirect hop is counted as a request
148 /// (default) — with this flag the 3xx itself IS the final response.
149 /// k6 always follows redirects (up to maxRedirects); this opt-out
150 /// is a Tropel extra.
151 #[arg(long = "no-redirects")]
152 no_redirects: bool,
153
154 /// Skip all threshold evaluation — don't fail the run even if
155 /// thresholds would fail. Mid-run abortOnFail is also disabled.
156 /// k6 has no equivalent; this pairs with --no-summary.
157 #[arg(long = "no-thresholds")]
158 no_thresholds: bool,
159
160 /// Run mode: constant-vus, ramping-vus, shared-iterations, arrival-rate
161 /// (optional — when absent, a k6 script's own `export const options`
162 /// drives the load profile; passing this flag makes the CLI profile win)
163 #[arg(short = 'm', long = "mode")]
164 mode: Option<String>,
165
166 /// Ramping stages (JSON array, for ramping-vus mode)
167 #[arg(long = "stages")]
168 stages: Option<String>,
169
170 /// Iterations (for shared-iterations mode)
171 #[arg(long = "iterations")]
172 iterations: Option<u64>,
173
174 /// Subprocess adapter command (e.g. `--subprocess-adapter "python3 my-adapter.py"`).
175 /// Runs the command for each detect/parse call, passing bytes on stdin
176 /// and reading a JSON Scenario from stdout.
177 /// The adapter is registered as `subprocess:<cmd>` (use with `--format`)
178 /// and is also probed during content auto-detection, like WASM plugins.
179 /// Each call is bounded by a 30s timeout and a 16 MiB output cap.
180 #[arg(long = "subprocess-adapter")]
181 subprocess_adapter: Vec<String>,
182
183 /// Directory of WASM plugins (`.wasm`) to load as input adapters.
184 /// Modules are AOT-precompiled to `.cwasm` next to the source and
185 /// registered under `wasm:<plugin_id>`; content auto-detection probes
186 /// them too. Example: `--plugins-dir ./plugins`.
187 #[arg(long = "plugins-dir")]
188 plugins_dir: Option<PathBuf>,
189 },
190
191 /// List available input formats and their capabilities
192 Extensions {
193 /// Optional directory of WASM plugins to include in the listing.
194 #[arg(long = "plugins-dir")]
195 plugins_dir: Option<PathBuf>,
196 },
197
198 /// Inspect an input file without running it: shows how Tropel resolves
199 /// it (driver or adapter), the parsed scenario summary (name, request
200 /// count, methods, variables, auth), and any script-declared options.
201 Inspect {
202 /// Path to the input file (collection, HAR, script, etc.)
203 input: PathBuf,
204
205 /// Input format (auto-detect if not specified)
206 #[arg(long = "format")]
207 format: Option<String>,
208
209 /// Directory of WASM plugins to include in resolution
210 #[arg(long = "plugins-dir")]
211 plugins_dir: Option<PathBuf>,
212
213 /// Subprocess adapter command (same semantics as `run`)
214 #[arg(long = "subprocess-adapter")]
215 subprocess_adapter: Vec<String>,
216 },
217
218 /// Bundle a test into a self-contained directory: the input file plus its
219 /// referenced dependencies (data file, env file, config file) and a
220 /// manifest, so the test can be replayed on another machine without the
221 /// original paths.
222 Archive {
223 /// Path to the input file (collection, HAR, script, etc.)
224 input: PathBuf,
225
226 /// Input format (auto-detect if not specified)
227 #[arg(long = "format")]
228 format: Option<String>,
229
230 /// Output directory for the bundle (default: ./tropel-archive)
231 #[arg(short = 'o', long = "output")]
232 output: Option<PathBuf>,
233
234 /// Data file (CSV/JSON) to bundle
235 #[arg(long = "data-file")]
236 data_file: Option<PathBuf>,
237
238 /// Environment file (JSON) to bundle
239 #[arg(long = "env-file")]
240 env_file: Option<PathBuf>,
241
242 /// Config file (JSON) to bundle
243 #[arg(long = "config")]
244 config: Option<PathBuf>,
245 },
246
247 /// Build a custom Tropel binary with extensions
248 Build {
249 /// Extension crates to include.
250 /// Forms: `name` or `name@1.2.3` (crates.io), `./path` (local dir),
251 /// `https://host/user/repo` or `git@host:user/repo.git` (git),
252 /// and git refs: `git-url@main` (branch), `git-url@v1.2.3` (tag),
253 /// `git-url@<sha>` (rev).
254 /// Example: `--with tropel-x-grpc --with ./my-ext --with https://github.com/u/r@v0.2.0`
255 #[arg(long = "with", required = true)]
256 with: Vec<String>,
257
258 /// Output binary path
259 #[arg(short = 'o', long = "output", default_value = "./tropel-custom")]
260 output: Option<PathBuf>,
261
262 /// Build in debug mode (default: release)
263 #[arg(long = "debug")]
264 debug: bool,
265 },
266
267 /// Print the version and build information
268 Version,
269
270 /// Generate a new k6-style script template.
271 New {
272 /// Output file path (default: `script.js`).
273 #[arg(default_value = "script.js")]
274 output: PathBuf,
275 },
276
277 /// Run the localhost agent server (TR-405). knockport's desktop transport
278 /// reaches the same engine over this socket — one engine from Send to
279 /// 10 000 VU. Localhost-only by default; refuses a non-loopback bind.
280 Agent {
281 /// Bind port (default 9876). Only loopback addresses are accepted.
282 #[arg(short = 'p', long = "port", default_value_t = 9876)]
283 port: u16,
284
285 /// Auth token required on every request (rate-limited too).
286 #[arg(long = "token")]
287 token: Option<String>,
288
289 /// Bind address (default 127.0.0.1). Anything non-loopback is refused.
290 #[arg(long = "bind", default_value = "127.0.0.1")]
291 bind: String,
292
293 /// Exit as soon as the process that spawned this agent is gone.
294 ///
295 /// TR-471: a supervisor's own cleanup does not run when it is
296 /// SIGKILLed or crashes, and an agent left behind keeps a loopback
297 /// port open with the variables and client secrets it was sent.
298 ///
299 /// Detected by stdin EOF, so the spawning process MUST give the agent
300 /// a stdin pipe and hold it open. With stdin on /dev/null or closed,
301 /// the agent exits immediately (and says so).
302 #[arg(long = "exit-with-parent")]
303 exit_with_parent: bool,
304
305 /// Browser origin allowed to reach this agent, e.g.
306 /// `--allow-origin https://app.knockport.dev`. Repeatable.
307 ///
308 /// TR-459: an ALLOWLIST, and empty by default, so a browser cannot
309 /// reach the agent at all unless a human names the page. The agent
310 /// holds collection variables and OAuth client secrets and will
311 /// execute any request handed to it — `*` would let any tab the user
312 /// has open drive it, and the token is no defence because a browser
313 /// attaches it automatically once CORS permits the call.
314 #[arg(long = "allow-origin")]
315 allow_origin: Vec<String>,
316 },
317}
318
319impl Cli {
320 pub fn verbose(&self) -> bool {
321 match &self.command {
322 Commands::Run { verbose, .. } => *verbose,
323 _ => false,
324 }
325 }
326}
327
328/// Run the CLI — parses args, initializes tracing, dispatches to engine.
329///
330/// This is the single entry point that both the standard `tropel` binary
331/// and custom `tropel build` binaries call from their `fn main()`.
332pub async fn run_cli() -> Result<()> {
333 // Force-link built-in adapters/drivers so their `inventory::submit!`
334 // registrations survive linker dead-stripping (see `builtins` module).
335 crate::builtins::register_builtins();
336
337 let cli = Cli::parse();
338
339 // Initialize tracing
340 let filter = if cli.verbose() {
341 "tropel=debug,tropel_engine=debug"
342 } else {
343 "tropel=info"
344 };
345
346 tracing_subscriber::fmt()
347 .with_env_filter(filter)
348 .with_target(true)
349 .init();
350
351 match cli.command {
352 Commands::Run { .. } => run_command(cli).await,
353 Commands::Extensions { plugins_dir } => list_extensions(plugins_dir.as_deref()).await,
354 Commands::Build {
355 ref with,
356 ref output,
357 debug,
358 } => {
359 build_custom(
360 with,
361 output
362 .as_deref()
363 .unwrap_or(&PathBuf::from("./tropel-custom")),
364 !debug,
365 )
366 .await
367 }
368 Commands::Inspect {
369 input,
370 format,
371 plugins_dir,
372 subprocess_adapter,
373 } => {
374 inspect_command(
375 &input,
376 format.as_deref(),
377 plugins_dir.as_deref(),
378 &subprocess_adapter,
379 )
380 .await
381 }
382 Commands::Archive {
383 input,
384 format,
385 output,
386 data_file,
387 env_file,
388 config,
389 } => {
390 archive_command(
391 &input,
392 format.as_deref(),
393 output.as_deref(),
394 data_file.as_deref(),
395 env_file.as_deref(),
396 config.as_deref(),
397 )
398 .await
399 }
400 Commands::Version => print_version(),
401 Commands::New { output } => crate::cli_commands::new_command(&output),
402 Commands::Agent {
403 port,
404 token,
405 bind,
406 allow_origin,
407 exit_with_parent,
408 } => {
409 crate::agent::run_agent(
410 port,
411 bind.as_str(),
412 token.as_deref(),
413 &allow_origin,
414 exit_with_parent,
415 )
416 .await
417 }
418 }
419}
420
421async fn run_command(cli: Cli) -> Result<()> {
422 let Commands::Run {
423 input,
424 format,
425 vus,
426 duration,
427 env,
428 env_file,
429 data_file,
430 config,
431 reporter,
432 output,
433 threshold,
434 insecure,
435 verbose: _,
436 http_debug,
437 no_redirects,
438 no_thresholds,
439 mode,
440 stages,
441 iterations,
442 prometheus_url,
443 otlp_endpoint,
444 summary_export,
445 json_stream,
446 statsd_addr,
447 influxdb_addr,
448 execution_segment,
449 execution_segment_sequence,
450 control_port,
451 subprocess_adapter,
452 plugins_dir,
453 ..
454 } = &cli.command
455 else {
456 return Err(TropelError::Other("Not a Run command".into()));
457 };
458
459 let input = input.clone();
460 let format = format.clone();
461 let vus = *vus;
462 let duration = duration.clone();
463 let env = env.clone();
464 let env_file = env_file.clone();
465 let data_file = data_file.clone();
466 let reporters = reporter.clone();
467 let output = output.clone();
468 let prometheus_url = prometheus_url.clone();
469 let otlp_endpoint = otlp_endpoint.clone();
470 let summary_export = summary_export.clone();
471 let json_stream = json_stream.clone();
472 let statsd_addr = statsd_addr.clone();
473 let influxdb_addr = influxdb_addr.clone();
474 let execution_segment = execution_segment.clone();
475 let execution_segment_sequence = execution_segment_sequence.clone();
476 let control_port = *control_port;
477 let thresholds = threshold.clone();
478 let insecure = *insecure;
479 let http_debug_mode = http_debug.as_deref();
480 let http_debug = http_debug.is_some();
481 let http_debug_full = http_debug_mode
482 .map(|v| v.eq_ignore_ascii_case("full"))
483 .unwrap_or(false);
484 let no_redirects = *no_redirects;
485 let no_thresholds = *no_thresholds;
486 // `mode` is now optional so we can tell whether the user explicitly chose
487 // a load profile (mode/vus/duration/stages/iterations flags). When none of
488 // them are set, a k6 script's own `export const options` may drive the run.
489 let mode_explicit = mode.is_some();
490 let mode = mode.clone().unwrap_or_else(|| "constant-vus".to_string());
491 let stages = stages.clone();
492 let iterations = *iterations;
493
494 tracing::info!("Tropel v{}", env!("CARGO_PKG_VERSION"));
495 tracing::info!("Input: {}", input.display());
496
497 // Parse environment variables
498 let mut env_map: HashMap<String, String> = HashMap::new();
499 for e in &env {
500 if let Some((key, value)) = e.split_once('=') {
501 env_map.insert(key.to_string(), value.to_string());
502 }
503 }
504
505 // Load environment file if provided
506 if let Some(env_path) = &env_file {
507 match std::fs::read_to_string(env_path) {
508 Ok(content) => {
509 if let Ok(postman_env) = serde_json::from_str::<serde_json::Value>(&content) {
510 if let Some(values) = postman_env.get("values").and_then(|v| v.as_array()) {
511 for entry in values {
512 if let (Some(key), Some(value)) = (
513 entry.get("key").and_then(|k| k.as_str()),
514 entry.get("value").and_then(|v| v.as_str()),
515 ) {
516 let enabled = entry
517 .get("enabled")
518 .and_then(|e| e.as_bool())
519 .unwrap_or(true);
520 if enabled {
521 env_map.insert(key.to_string(), value.to_string());
522 }
523 }
524 }
525 } else if let Ok(flat_env) =
526 serde_json::from_value::<HashMap<String, String>>(postman_env.clone())
527 {
528 env_map.extend(flat_env);
529 } else {
530 tracing::warn!("Unrecognized env-file format in '{}': expected Postman env or flat JSON", env_path.display());
531 }
532 }
533 }
534 Err(e) => {
535 tracing::warn!("Failed to read env-file '{}': {}", env_path.display(), e);
536 }
537 }
538 }
539
540 // The user provided a load profile when they passed any of the load
541 // flags. Otherwise (bare `tropel run script.js`) a k6 script's own
542 // `export const options` is allowed to drive the run. Computed before
543 // `from_mode` so duration/stages can be moved into it without clones.
544 let load_profile_explicit = vus.is_some()
545 || duration.is_some()
546 || mode_explicit
547 || stages.is_some()
548 || iterations.is_some();
549
550 // Build execution config — canonical mode→executor mapping lives in
551 // tropel-core (shared with the k6 env-file builder).
552 let execution = ExecutionConfig::from_mode(&mode, vus, duration, iterations, stages);
553
554 // Parse thresholds
555 let mut threshold_map: HashMap<String, ThresholdConfig> = HashMap::new();
556 if no_thresholds {
557 tracing::info!("--no-thresholds: skipping threshold evaluation");
558 } else {
559 for t in &thresholds {
560 let name = format!("threshold_{}", threshold_map.len());
561 threshold_map.insert(
562 name,
563 ThresholdConfig {
564 expression: t.clone(),
565 abort_on_fail: false,
566 delay_abort_eval: None,
567 },
568 );
569 }
570 }
571
572 // Load data file if provided
573 let iteration_data = if let Some(data_path) = &data_file {
574 match load_data_file(data_path) {
575 Ok(data) => data,
576 Err(e) => {
577 tracing::warn!("Failed to load data-file '{}': {}", data_path.display(), e);
578 vec![]
579 }
580 }
581 } else {
582 vec![]
583 };
584
585 // Load config overlays: `K6_*` env vars first, then the `--config` JSON
586 // file (file wins over env, explicit CLI flags win over both).
587 let mut overlay = PartialConfig::from_env();
588 if let Some(config_path) = config {
589 let file_cfg = PartialConfig::load_from_file(config_path)?;
590 overlay = merge_partial(overlay, file_cfg);
591 }
592
593 // Build the full job config
594 let mut config = JobConfig {
595 input: input.to_string_lossy().to_string(),
596 input_type: format.clone(),
597 execution,
598 execution_explicit: load_profile_explicit,
599 execution_segment,
600 execution_segment_sequence,
601 control_port,
602 env: env_map,
603 iteration_data,
604 output: OutputConfig {
605 reporters: reporters.clone(),
606 output_file: output.map(|p| p.to_string_lossy().to_string()),
607 prometheus_remote_write_url: prometheus_url,
608 otlp_endpoint,
609 summary_export: summary_export.map(|p| p.to_string_lossy().to_string()),
610 json_stream: json_stream.map(|p| p.to_string_lossy().to_string()),
611 statsd_addr,
612 influxdb_addr,
613 ..Default::default()
614 },
615 thresholds: threshold_map,
616 no_thresholds,
617 tls: TlsConfig {
618 insecure_skip_verify: insecure,
619 ..Default::default()
620 },
621 ..Default::default()
622 };
623
624 // The config-file / K6_* overlay may replace `config.http` wholesale, so
625 // the explicit CLI --http-debug / --no-redirects flags are applied AFTER
626 // the overlay to make sure they always win (regardless of what the
627 // overlay set).
628 config.http.http_debug = http_debug;
629 config.http.http_debug_full = http_debug_full;
630 config.http.no_redirects = no_redirects;
631
632 // ── Apply the overlay (CLI flags win; overlay fills gaps) ──
633 // Compute BEFORE the &mut borrow (CLI --data-file already loaded
634 // iteration_data above).
635 let cli_iteration_data_empty = config.iteration_data.is_empty();
636 apply_overlay(
637 &mut config,
638 overlay,
639 &reporters,
640 insecure,
641 load_profile_explicit,
642 cli_iteration_data_empty,
643 );
644
645 // Backlog line 53: a malformed duration (e.g. `-d 30x`) used to run a
646 // zero-VU "green" run — the scheduler swallowed the parse error and the
647 // run exited 0 with http_reqs: 0. Validate the execution config up front
648 // so a bad duration fails fast with a clear config error.
649 tropel_scheduler::validate_execution_config(&config.execution)?;
650
651 tracing::info!("Execution config: {:?}", config.execution); // Create the engine with extension registry (subprocess + WASM plugins
652 // registered the same way `inspect`/`list` do — one shared builder).
653 let registry = build_registry(subprocess_adapter, plugins_dir.as_deref())?;
654 let engine = Engine::new(registry);
655 let result = engine.run(&config).await?;
656
657 tracing::info!(
658 "Load test completed: {} total requests",
659 result.metrics.http_reqs
660 );
661 tracing::info!(
662 "Checks: {}/{} passed",
663 result.metrics.checks_passed,
664 result.metrics.checks_total
665 );
666
667 // VUs that failed to START (e.g. WASM driver pool exhaustion) mean the
668 // requested load was not delivered — the summary has already printed, so
669 // fail loudly here with a non-zero exit instead of silently reporting the
670 // requested VU count as if the run succeeded.
671 if result.vu_init_failures > 0 {
672 tracing::error!(
673 "{} VU(s) failed to start — requested load was NOT delivered (see errors above)",
674 result.vu_init_failures
675 );
676 return Err(TropelError::Other(format!(
677 "{} VU(s) failed to start — requested load was not delivered",
678 result.vu_init_failures
679 )));
680 }
681
682 // Script failures (backlog line 98): a run where every prerequest/test
683 // script (or driver iteration) threw used to exit 0 with a clean summary.
684 // Now each failure is a failed check AND this counter — exit non-zero so
685 // CI pipelines see the failure. The summary has already printed.
686 //
687 // Deliberate semantics: ANY script failure (even a single transient one)
688 // makes the run exit non-zero, NOT gated behind a `checks` threshold — a
689 // script that throws is a broken test artifact, not an SLO outcome. A
690 // flaky script therefore fails CI loudly, which is the point.
691 if result.script_failures > 0 {
692 tracing::error!(
693 "{} script execution(s) failed during the run (see errors above) — exiting non-zero",
694 result.script_failures
695 );
696 return Err(TropelError::Other(format!(
697 "{} script execution(s) failed during the run",
698 result.script_failures
699 )));
700 }
701
702 // TR-244: `exec.test.abort()` maps to k6's exit code 108 (ScriptAborted)
703 // — a specific non-zero distinct from generic failures. The message has
704 // already been logged by the VU loop; exit immediately with the right code.
705 if let Some(msg) = &result.abort_message {
706 tracing::error!("Test aborted: {}", msg);
707 std::process::exit(108);
708 }
709
710 // Evaluate thresholds and drive exit code. Uses the engine's EFFECTIVE
711 // threshold set (job thresholds merged with script-declared ones, e.g.
712 // k6 `export const options` thresholds) so k6 SLOs are reported too.
713 // --no-thresholds skips this entirely.
714 let threshold_results = if no_thresholds {
715 Vec::new()
716 } else {
717 evaluate_thresholds(&result.effective_thresholds, &result.metrics)
718 };
719 let mut any_failed = false;
720 for tr in &threshold_results {
721 if tr.passed {
722 tracing::info!(
723 " ✓ Threshold '{}': {:.2} {} {:.2} (PASS)",
724 tr.name,
725 tr.actual,
726 tr.expression.split_whitespace().nth(1).unwrap_or("<?>"),
727 tr.threshold
728 );
729 } else {
730 tracing::error!(
731 " ✗ Threshold '{}': {:.2} {} {:.2} (FAIL)",
732 tr.name,
733 tr.actual,
734 tr.expression.split_whitespace().nth(1).unwrap_or("<?>"),
735 tr.threshold
736 );
737 any_failed = true;
738 if tr.abort_on_fail {
739 tracing::error!("Aborting due to threshold '{}'", tr.name);
740 return Err(TropelError::Other(format!(
741 "Threshold '{}' failed (abort-on-fail)",
742 tr.name
743 )));
744 }
745 }
746 }
747
748 if any_failed {
749 Err(TropelError::Other("One or more thresholds failed".into()))
750 } else if result.metrics.run_failed() {
751 // TR-105: the same verdict the banner uses. `checks_failed` alone
752 // (no threshold failure) used to let the exit code stay 0 while the
753 // banner printed FAIL — a run with all checks failing but no
754 // threshold configured exited 0 with a red summary.
755 Err(TropelError::Other(
756 "Run failed (checks / script failures / VU init failures)".into(),
757 ))
758 } else {
759 Ok(())
760 }
761}