Skip to main content

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}