diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e56cca5..e5c89155 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,7 @@ After releasing `memtrack` or `exec-harness`, you **must** update the version re const MEMTRACK_CODSPEED_VERSION: &str = "X.Y.Z"; // Update to new version ``` -2. **For exec-harness**: Update `EXEC_HARNESS_VERSION` in `src/exec/mod.rs`: +2. **For exec-harness**: Update `EXEC_HARNESS_VERSION` in `src/executor/orchestrator.rs`: ```rust const EXEC_HARNESS_VERSION: &str = "X.Y.Z"; // Update to new version ``` @@ -57,7 +57,7 @@ The main runner (`codspeed-runner`) should be released after ensuring all depend **Verify binary version references**: Check that version constants in the runner code match the released versions: - `MEMTRACK_CODSPEED_VERSION` in `src/executor/memory/executor.rs` -- `EXEC_HARNESS_VERSION` in `src/exec/mod.rs` +- `EXEC_HARNESS_VERSION` in `src/executor/orchestrator.rs` #### Release Command diff --git a/src/cli/exec/mod.rs b/src/cli/exec/mod.rs index accdf706..99ca05ea 100644 --- a/src/cli/exec/mod.rs +++ b/src/cli/exec/mod.rs @@ -7,8 +7,7 @@ use crate::instruments::Instruments; use crate::prelude::*; use crate::project_config::ProjectConfig; use crate::project_config::merger::ConfigMerger; -use crate::upload::UploadResult; -use crate::upload::poll_results::{PollResultsOptions, poll_results}; +use crate::upload::poll_results::PollResultsOptions; use clap::Args; use std::path::Path; use url::Url; @@ -18,9 +17,6 @@ pub mod multi_targets; /// We temporarily force this name for all exec runs pub const DEFAULT_REPOSITORY_NAME: &str = "local-runs"; -pub const EXEC_HARNESS_COMMAND: &str = "exec-harness"; -pub const EXEC_HARNESS_VERSION: &str = "1.2.0"; - #[derive(Args, Debug)] pub struct ExecArgs { #[command(flatten)] @@ -92,6 +88,8 @@ fn build_orchestrator_config( skip_setup: args.shared.skip_setup, allow_empty: args.shared.allow_empty, go_runner_version: args.shared.go_runner_version, + show_full_output: args.shared.show_full_output, + poll_results_options: PollResultsOptions::for_exec(), }) } @@ -131,14 +129,7 @@ pub async fn execute_config( debug!("config: {:#?}", orchestrator.config); - let poll_opts = PollResultsOptions::for_exec(); - let poll_results_fn = async |upload_result: &UploadResult| { - poll_results(api_client, upload_result, &poll_opts).await - }; - - orchestrator - .execute(setup_cache_dir, poll_results_fn) - .await?; + orchestrator.execute(setup_cache_dir, api_client).await?; Ok(()) } diff --git a/src/cli/exec/multi_targets.rs b/src/cli/exec/multi_targets.rs index 1841f8a4..d24c16b9 100644 --- a/src/cli/exec/multi_targets.rs +++ b/src/cli/exec/multi_targets.rs @@ -1,5 +1,5 @@ -use super::EXEC_HARNESS_COMMAND; use crate::executor::config::BenchmarkTarget; +use crate::executor::orchestrator::EXEC_HARNESS_COMMAND; use crate::prelude::*; use crate::project_config::{Target, TargetCommand, WalltimeOptions}; use exec_harness::BenchmarkCommand; diff --git a/src/cli/run/helpers/benchmark_display.rs b/src/cli/run/helpers/benchmark_display.rs index 23f05dd4..d9764db8 100644 --- a/src/cli/run/helpers/benchmark_display.rs +++ b/src/cli/run/helpers/benchmark_display.rs @@ -1,11 +1,12 @@ use crate::api_client::FetchLocalRunBenchmarkResult; use crate::cli::run::helpers; use crate::executor::ExecutorName; +use console::style; use std::collections::HashMap; use tabled::settings::object::{Columns, Rows}; use tabled::settings::panel::Panel; use tabled::settings::style::HorizontalLine; -use tabled::settings::{Alignment, Color, Modify, Style}; +use tabled::settings::{Alignment, Color, Modify, Padding, Style}; use tabled::{Table, Tabled}; fn format_with_thousands_sep(n: u64) -> String { @@ -20,6 +21,30 @@ fn format_with_thousands_sep(n: u64) -> String { result.chars().rev().collect() } +/// Format StdDev with color coding based on value +fn format_stdev_colored(stdev_pct: f64) -> String { + let formatted = format!("{stdev_pct:.2}%"); + if stdev_pct <= 2.0 { + format!("{}", style(&formatted).green()) + } else if stdev_pct <= 5.0 { + format!("{}", style(&formatted).yellow()) + } else { + format!("{}", style(&formatted).red()) + } +} + +/// Format a percentage distribution value with color intensity +fn format_distribution_pct(pct: f64) -> String { + let formatted = format!("{pct:.1}%"); + if pct >= 70.0 { + format!("{}", style(&formatted).white().bold()) + } else if pct >= 20.0 { + format!("{}", style(&formatted).white()) + } else { + format!("{}", style(&formatted).dim()) + } +} + #[derive(Tabled)] struct SimulationRow { #[tabled(rename = "Benchmark")] @@ -62,7 +87,7 @@ struct MemoryRow { alloc_calls: String, } -fn build_table_with_style(rows: &[T], instrument: &str) -> String { +fn build_table_with_style(rows: &[T], instrument: &str, icon: &str) -> String { // Line after panel header: use ┬ to connect with columns below let header_line = HorizontalLine::full('─', '┬', '├', '┤'); // Line after column headers: keep intersection @@ -71,7 +96,7 @@ fn build_table_with_style(rows: &[T], instrument: &str) -> String { // Format title in bold CodSpeed orange (#FF8700) let codspeed_orange = Color::rgb_fg(255, 135, 0); let title_style = Color::BOLD | codspeed_orange; - let title = title_style.colorize(format!("{instrument} Instrument")); + let title = title_style.colorize(format!("{icon} {instrument}")); let mut table = Table::new(rows); table @@ -83,10 +108,12 @@ fn build_table_with_style(rows: &[T], instrument: &str) -> String { .horizontals([(1, header_line), (2, column_line)]), ) .with(Modify::new(Rows::first()).with(Alignment::center())) - // Make column headers bold + // Make column headers bold and dimmed for visual hierarchy .with(Modify::new(Rows::new(1..2)).with(Color::BOLD)) // Right-align numeric columns (all except first column) - .with(Modify::new(Columns::new(1..)).with(Alignment::right())); + .with(Modify::new(Columns::new(1..)).with(Alignment::right())) + // Add some padding for breathing room + .with(Modify::new(Columns::new(0..)).with(Padding::new(1, 1, 0, 0))); table.to_string() } @@ -100,10 +127,13 @@ fn build_simulation_table(results: &[&FetchLocalRunBenchmarkResult]) -> String { .and_then(|v| v.time_distribution.as_ref()) .map(|td| { let total = result.value; + let ir_pct = (td.ir / total) * 100.0; + let l1m_pct = (td.l1m / total) * 100.0; + let llm_pct = (td.llm / total) * 100.0; ( - format!("{:.1}%", (td.ir / total) * 100.0), - format!("{:.1}%", (td.l1m / total) * 100.0), - format!("{:.1}%", (td.llm / total) * 100.0), + format_distribution_pct(ir_pct), + format_distribution_pct(l1m_pct), + format_distribution_pct(llm_pct), helpers::format_duration(td.sys, Some(2)), ) }) @@ -118,7 +148,10 @@ fn build_simulation_table(results: &[&FetchLocalRunBenchmarkResult]) -> String { SimulationRow { name: result.benchmark.name.clone(), - time: helpers::format_duration(result.value, Some(2)), + time: format!( + "{}", + style(helpers::format_duration(result.value, Some(2))).cyan() + ), instructions, cache, memory, @@ -126,7 +159,11 @@ fn build_simulation_table(results: &[&FetchLocalRunBenchmarkResult]) -> String { } }) .collect(); - build_table_with_style(&rows, "CPU Simulation") + build_table_with_style( + &rows, + ExecutorName::Valgrind.label(), + ExecutorName::Valgrind.icon(), + ) } fn build_walltime_table(results: &[&FetchLocalRunBenchmarkResult]) -> String { @@ -134,15 +171,22 @@ fn build_walltime_table(results: &[&FetchLocalRunBenchmarkResult]) -> String { .iter() .map(|result| { let (time_best, iterations, rel_stdev, run_time) = if let Some(wt) = &result.walltime { + let stdev_pct = (wt.stdev / result.value) * 100.0; ( - helpers::format_duration(result.value, Some(2)), + format!( + "{}", + style(helpers::format_duration(result.value, Some(2))).cyan() + ), format_with_thousands_sep(wt.iterations as u64), - format!("{:.2}%", (wt.stdev / result.value) * 100.0), + format_stdev_colored(stdev_pct), helpers::format_duration(wt.total_time, Some(2)), ) } else { ( - helpers::format_duration(result.value, Some(2)), + format!( + "{}", + style(helpers::format_duration(result.value, Some(2))).cyan() + ), "-".to_string(), "-".to_string(), "-".to_string(), @@ -157,7 +201,11 @@ fn build_walltime_table(results: &[&FetchLocalRunBenchmarkResult]) -> String { } }) .collect(); - build_table_with_style(&rows, "Walltime") + build_table_with_style( + &rows, + ExecutorName::WallTime.label(), + ExecutorName::WallTime.icon(), + ) } fn build_memory_table(results: &[&FetchLocalRunBenchmarkResult]) -> String { @@ -166,13 +214,19 @@ fn build_memory_table(results: &[&FetchLocalRunBenchmarkResult]) -> String { .map(|result| { let (peak_memory, total_allocated, alloc_calls) = if let Some(mem) = &result.memory { ( - helpers::format_memory(mem.peak_memory as f64, Some(1)), + format!( + "{}", + style(helpers::format_memory(mem.peak_memory as f64, Some(1))).cyan() + ), helpers::format_memory(mem.total_allocated as f64, Some(1)), format_with_thousands_sep(mem.alloc_calls as u64), ) } else { ( - helpers::format_memory(result.value, Some(1)), + format!( + "{}", + style(helpers::format_memory(result.value, Some(1))).cyan() + ), "-".to_string(), "-".to_string(), ) @@ -185,7 +239,11 @@ fn build_memory_table(results: &[&FetchLocalRunBenchmarkResult]) -> String { } }) .collect(); - build_table_with_style(&rows, "Memory") + build_table_with_style( + &rows, + ExecutorName::Memory.label(), + ExecutorName::Memory.icon(), + ) } pub fn build_benchmark_table(results: &[FetchLocalRunBenchmarkResult]) -> String { @@ -224,47 +282,36 @@ pub fn build_benchmark_table(results: &[FetchLocalRunBenchmarkResult]) -> String } pub fn build_detailed_summary(result: &FetchLocalRunBenchmarkResult) -> String { + let name = &result.benchmark.name; match result.benchmark.executor { ExecutorName::Valgrind => { - format!( - "{}: {}", - result.benchmark.name, - helpers::format_duration(result.value, Some(2)) - ) + let time = style(helpers::format_duration(result.value, Some(2))).cyan(); + format!("{name}: {time}") } ExecutorName::WallTime => { if let Some(wt) = &result.walltime { + let time = style(helpers::format_duration(result.value, Some(2))).cyan(); + let iters = format_with_thousands_sep(wt.iterations as u64); + let stdev_pct = (wt.stdev / result.value) * 100.0; + let stdev = format_stdev_colored(stdev_pct); + let total = helpers::format_duration(wt.total_time, Some(2)); format!( - "{}: best {} ({} iterations, rel. stddev: {:.2}%, total {})", - result.benchmark.name, - helpers::format_duration(result.value, Some(2)), - format_with_thousands_sep(wt.iterations as u64), - (wt.stdev / result.value) * 100.0, - helpers::format_duration(wt.total_time, Some(2)) + "{name}: best {time} ({iters} iterations, rel. stddev: {stdev}, total {total})" ) } else { - format!( - "{}: {}", - result.benchmark.name, - helpers::format_duration(result.value, Some(2)) - ) + let time = style(helpers::format_duration(result.value, Some(2))).cyan(); + format!("{name}: {time}") } } ExecutorName::Memory => { if let Some(mem) = &result.memory { - format!( - "{}: peak {} (total allocated: {}, {} allocations)", - result.benchmark.name, - helpers::format_memory(mem.peak_memory as f64, Some(1)), - helpers::format_memory(mem.total_allocated as f64, Some(1)), - format_with_thousands_sep(mem.alloc_calls as u64) - ) + let peak = style(helpers::format_memory(mem.peak_memory as f64, Some(1))).cyan(); + let total = helpers::format_memory(mem.total_allocated as f64, Some(1)); + let allocs = format_with_thousands_sep(mem.alloc_calls as u64); + format!("{name}: peak {peak} (total allocated: {total}, {allocs} allocations)") } else { - format!( - "{}: {}", - result.benchmark.name, - helpers::format_memory(result.value, Some(1)) - ) + let mem = style(helpers::format_memory(result.value, Some(1))).cyan(); + format!("{name}: {mem}") } } } @@ -396,6 +443,7 @@ mod tests { }; let summary = build_detailed_summary(&result); + let summary = console::strip_ansi_codes(&summary).to_string(); insta::assert_snapshot!(summary, @"benchmark_fast: 1.23 ms"); } @@ -417,6 +465,7 @@ mod tests { }; let summary = build_detailed_summary(&result); + let summary = console::strip_ansi_codes(&summary).to_string(); insta::assert_snapshot!(summary, @"benchmark_wt: best 1.50 s (50 iterations, rel. stddev: 1.67%, total 1.50 s)"); } @@ -438,6 +487,7 @@ mod tests { }; let summary = build_detailed_summary(&result); + let summary = console::strip_ansi_codes(&summary).to_string(); insta::assert_snapshot!(summary, @"benchmark_mem: peak 1 MB (total allocated: 5 MB, 500 allocations)"); } } diff --git a/src/cli/run/helpers/snapshots/codspeed_runner__cli__run__helpers__benchmark_display__tests__benchmark_table_formatting.snap b/src/cli/run/helpers/snapshots/codspeed_runner__cli__run__helpers__benchmark_display__tests__benchmark_table_formatting.snap index 12d95485..c425f93a 100644 --- a/src/cli/run/helpers/snapshots/codspeed_runner__cli__run__helpers__benchmark_display__tests__benchmark_table_formatting.snap +++ b/src/cli/run/helpers/snapshots/codspeed_runner__cli__run__helpers__benchmark_display__tests__benchmark_table_formatting.snap @@ -3,7 +3,7 @@ source: src/cli/run/helpers/benchmark_display.rs expression: table --- ╭─────────────────────────────────────────────────────────────────╮ -│ CPU Simulation Instrument │ +│  CPU Simulation │ ├─────────────────┬─────────┬────────┬───────┬────────┬───────────┤ │ Benchmark │ Time │ Instr. │ Cache │ Memory │ Sys. Time │ ├─────────────────┼─────────┼────────┼───────┼────────┼───────────┤ @@ -11,7 +11,7 @@ expression: table │ bench_serialize │ 2.57 ms │ 70.0% │ 20.0% │ 8.0% │ 51.34 µs │ ╰─────────────────┴─────────┴────────┴───────┴────────┴───────────╯ ╭─────────────────────────────────────────────────────────────────────╮ -│ Walltime Instrument │ +│  Walltime │ ├────────────────────┬─────────────┬────────────┬────────┬────────────┤ │ Benchmark │ Time (best) │ Iterations │ StdDev │ Total time │ ├────────────────────┼─────────────┼────────────┼────────┼────────────┤ @@ -19,7 +19,7 @@ expression: table │ bench_db_query │ 25.00 ms │ 500 │ 2.00% │ 25.00 ms │ ╰────────────────────┴─────────────┴────────────┴────────┴────────────╯ ╭─────────────────────────────────────────────────────────────────╮ -│ Memory Instrument │ +│  Memory │ ├───────────────────┬─────────────┬─────────────────┬─────────────┤ │ Benchmark │ Peak memory │ Total allocated │ Allocations │ ├───────────────────┼─────────────┼─────────────────┼─────────────┤ diff --git a/src/cli/run/mod.rs b/src/cli/run/mod.rs index 1d57094b..5807a2b6 100644 --- a/src/cli/run/mod.rs +++ b/src/cli/run/mod.rs @@ -8,8 +8,7 @@ use crate::prelude::*; use crate::project_config::ProjectConfig; use crate::project_config::merger::ConfigMerger; use crate::run_environment::interfaces::RepositoryProvider; -use crate::upload::UploadResult; -use crate::upload::poll_results::{PollResultsOptions, poll_results}; +use crate::upload::poll_results::PollResultsOptions; use clap::{Args, ValueEnum}; use std::path::Path; use url::Url; @@ -80,6 +79,7 @@ impl RunArgs { skip_setup: false, allow_empty: false, go_runner_version: None, + show_full_output: false, perf_run_args: PerfRunArgs { enable_perf: false, perf_unwinding_mode: None, @@ -96,6 +96,7 @@ impl RunArgs { fn build_orchestrator_config( args: RunArgs, targets: Vec, + poll_results_options: PollResultsOptions, ) -> Result { let instruments = Instruments::try_from(&args)?; let modes = args.shared.resolve_modes()?; @@ -127,6 +128,8 @@ fn build_orchestrator_config( skip_setup: args.shared.skip_setup, allow_empty: args.shared.allow_empty, go_runner_version: args.shared.go_runner_version, + show_full_output: args.shared.show_full_output, + poll_results_options, }) } @@ -185,6 +188,7 @@ pub async fn run( command, name: None, }], + PollResultsOptions::for_run(output_json), )?; let orchestrator = executor::Orchestrator::new(config, codspeed_config, api_client).await?; @@ -192,15 +196,9 @@ pub async fn run( if !orchestrator.is_local() { super::show_banner(); } - debug!("config: {:#?}", orchestrator.config); - - let poll_opts = PollResultsOptions::for_run(output_json); - let poll_results_fn = async |upload_result: &UploadResult| { - poll_results(api_client, upload_result, &poll_opts).await - }; - orchestrator - .execute(setup_cache_dir, poll_results_fn) - .await?; + debug!("config: {:?}", orchestrator.config); + + orchestrator.execute(setup_cache_dir, api_client).await?; } RunTarget::ConfigTargets { @@ -210,7 +208,8 @@ pub async fn run( } => { let benchmark_targets = super::exec::multi_targets::build_benchmark_targets(targets, default_walltime)?; - let config = build_orchestrator_config(args, benchmark_targets)?; + let config = + build_orchestrator_config(args, benchmark_targets, PollResultsOptions::for_exec())?; super::exec::execute_config(config, api_client, codspeed_config, setup_cache_dir) .await?; } diff --git a/src/cli/setup.rs b/src/cli/setup.rs index 759a7c51..fbd6b49f 100644 --- a/src/cli/setup.rs +++ b/src/cli/setup.rs @@ -10,7 +10,7 @@ pub async fn setup(setup_cache_dir: Option<&Path>) -> Result<()> { for executor in executors { info!( "Setting up the environment for the executor: {}", - executor.name().to_string() + executor.name() ); executor.setup(&system_info, setup_cache_dir).await?; } diff --git a/src/cli/shared.rs b/src/cli/shared.rs index bc45579e..8d62a081 100644 --- a/src/cli/shared.rs +++ b/src/cli/shared.rs @@ -1,24 +1,31 @@ use crate::VERSION; use crate::executor::config::SimulationTool; +use crate::local_logger::CODSPEED_U8_COLOR_CODE; use crate::prelude::*; use crate::run_environment::interfaces::RepositoryProvider; use crate::runner_mode::{RunnerMode, load_shell_session_mode}; use clap::Args; use clap::ValueEnum; +use console::style; use std::path::PathBuf; pub(crate) fn show_banner() { - let banner = format!( - r#" - ______ __ _____ __ + let logo = r#" ______ __ _____ __ / ____/____ ____/ // ___/ ____ ___ ___ ____/ / / / / __ \ / __ / \__ \ / __ \ / _ \ / _ \ / __ / / /___ / /_/ // /_/ / ___/ // /_/ // __// __// /_/ / \____/ \____/ \__,_/ /____// .___/ \___/ \___/ \__,_/ - https://codspeed.io /_/ runner v{VERSION} -"# - ); - println!("{banner}"); + /_/"#; + + let version_tag = style(format!("v{VERSION}")).bold(); + let url = style("codspeed.io").color256(CODSPEED_U8_COLOR_CODE); + let separator = style("─".repeat(52)).dim(); + + eprintln!(); + eprintln!("{}", style(logo).color256(CODSPEED_U8_COLOR_CODE).bold()); + eprintln!(" {separator}"); + eprintln!(" {url} {version_tag}"); + eprintln!(); debug!("codspeed v{VERSION}"); } @@ -99,6 +106,10 @@ pub struct ExecAndRunSharedArgs { #[arg(long, env = "CODSPEED_GO_RUNNER_VERSION", value_parser = parse_version)] pub go_runner_version: Option, + /// Show full executor output instead of a rolling buffer window + #[arg(long, default_value = "false")] + pub show_full_output: bool, + #[command(flatten)] pub perf_run_args: PerfRunArgs, } diff --git a/src/executor/config.rs b/src/executor/config.rs index fb8e8e5b..65077fa3 100644 --- a/src/executor/config.rs +++ b/src/executor/config.rs @@ -3,6 +3,7 @@ use crate::instruments::Instruments; use crate::prelude::*; use crate::run_environment::RepositoryProvider; use crate::runner_mode::RunnerMode; +use crate::upload::poll_results::PollResultsOptions; use clap::ValueEnum; use semver::Version; use std::path::PathBuf; @@ -71,6 +72,10 @@ pub struct OrchestratorConfig { pub allow_empty: bool, /// The version of go-runner to install (if None, installs latest) pub go_runner_version: Option, + /// If true, show full executor output instead of a rolling buffer window + pub show_full_output: bool, + /// Options controlling post-upload result polling and display + pub poll_results_options: PollResultsOptions, } /// Per-execution configuration passed to executors. @@ -92,7 +97,6 @@ pub struct ExecutorConfig { pub simulation_tool: SimulationTool, - pub profile_folder: Option, pub skip_run: bool, pub skip_setup: bool, /// If true, allow execution even when no benchmarks are found @@ -161,7 +165,6 @@ impl OrchestratorConfig { enable_perf: self.enable_perf, perf_unwinding_mode: self.perf_unwinding_mode, simulation_tool: self.simulation_tool, - profile_folder: self.profile_folder.clone(), skip_run: self.skip_run, skip_setup: self.skip_setup, allow_empty: self.allow_empty, @@ -200,6 +203,8 @@ impl OrchestratorConfig { skip_setup: false, allow_empty: false, go_runner_version: None, + show_full_output: false, + poll_results_options: PollResultsOptions::for_exec(), } } } diff --git a/src/executor/execution_context.rs b/src/executor/execution_context.rs index 8713287c..83097025 100644 --- a/src/executor/execution_context.rs +++ b/src/executor/execution_context.rs @@ -1,8 +1,6 @@ use super::ExecutorConfig; use std::path::PathBuf; -use super::create_profile_folder; - /// Per-mode execution context. /// /// Contains only the mode-specific configuration and the profile folder path. @@ -14,16 +12,10 @@ pub struct ExecutionContext { } impl ExecutionContext { - pub fn new(config: ExecutorConfig) -> anyhow::Result { - let profile_folder = if let Some(profile_folder) = &config.profile_folder { - profile_folder.clone() - } else { - create_profile_folder()? - }; - - Ok(ExecutionContext { + pub fn new(config: ExecutorConfig, profile_folder: PathBuf) -> Self { + ExecutionContext { config, profile_folder, - }) + } } } diff --git a/src/executor/helpers/run_command_with_log_pipe.rs b/src/executor/helpers/run_command_with_log_pipe.rs index 387a35b6..b20f0683 100644 --- a/src/executor/helpers/run_command_with_log_pipe.rs +++ b/src/executor/helpers/run_command_with_log_pipe.rs @@ -1,4 +1,5 @@ use crate::executor::EXECUTOR_TARGET; +use crate::local_logger::rolling_buffer::ROLLING_BUFFER; use crate::local_logger::suspend_progress_bar; use crate::prelude::*; use std::future::Future; @@ -25,6 +26,19 @@ where F: FnOnce(std::process::Child) -> Fut, Fut: Future>, { + /// Write text to the rolling buffer if active, otherwise write raw bytes to the writer. + fn write_to_rolling_buffer_or_output(text: &str, raw_bytes: &[u8], writer: &mut impl Write) { + if let Ok(mut guard) = ROLLING_BUFFER.lock() { + if let Some(rb) = guard.as_mut() { + if rb.is_active() { + rb.push_lines(text); + return; + } + } + } + suspend_progress_bar(|| writer.write_all(raw_bytes).unwrap()); + } + fn log_tee( mut reader: impl Read, mut writer: impl Write, @@ -39,15 +53,9 @@ where if bytes_read == 0 { // Flush any remaining data in the line buffer if !line_buffer.is_empty() { - suspend_progress_bar(|| { - writer.write_all(&line_buffer).unwrap(); - trace!( - target: EXECUTOR_TARGET, - "{}{}", - prefix, - String::from_utf8_lossy(&line_buffer) - ); - }); + let text = String::from_utf8_lossy(&line_buffer); + trace!(target: EXECUTOR_TARGET, "{prefix}{text}"); + write_to_rolling_buffer_or_output(&text, &line_buffer, &mut writer); } break; } @@ -61,15 +69,9 @@ where { // We have at least one complete line, flush up to and including the last newline let to_flush = &line_buffer[..=last_newline_pos]; - suspend_progress_bar(|| { - writer.write_all(to_flush).unwrap(); - trace!( - target: EXECUTOR_TARGET, - "{}{}", - prefix, - String::from_utf8_lossy(to_flush) - ); - }); + let text = String::from_utf8_lossy(to_flush); + trace!(target: EXECUTOR_TARGET, "{prefix}{text}"); + write_to_rolling_buffer_or_output(&text, to_flush, &mut writer); // Keep the remainder in the buffer line_buffer = line_buffer[last_newline_pos + 1..].to_vec(); @@ -85,15 +87,22 @@ where .context("failed to spawn the process")?; let stdout = process.stdout.take().expect("unable to get stdout"); let stderr = process.stderr.take().expect("unable to get stderr"); - thread::spawn(move || { + + let stdout_handle = thread::spawn(move || { log_tee(stdout, std::io::stdout(), None).unwrap(); }); - thread::spawn(move || { + let stderr_handle = thread::spawn(move || { log_tee(stderr, std::io::stderr(), Some("[stderr]")).unwrap(); }); - cb(process).await + let result = cb(process).await; + + // Wait for threads to drain remaining output + let _ = stdout_handle.join(); + let _ = stderr_handle.join(); + + result } pub async fn run_command_with_log_pipe(cmd: Command) -> Result { diff --git a/src/executor/interfaces.rs b/src/executor/interfaces.rs index 52c6ed16..8da9fb6a 100644 --- a/src/executor/interfaces.rs +++ b/src/executor/interfaces.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::fmt; #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] #[serde(rename_all = "lowercase")] @@ -8,13 +9,32 @@ pub enum ExecutorName { Memory, } -#[allow(clippy::to_string_trait_impl)] -impl ToString for ExecutorName { - fn to_string(&self) -> String { +impl fmt::Display for ExecutorName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - ExecutorName::Valgrind => "valgrind".to_string(), - ExecutorName::WallTime => "walltime".to_string(), - ExecutorName::Memory => "memory".to_string(), + ExecutorName::Valgrind => write!(f, "valgrind"), + ExecutorName::WallTime => write!(f, "walltime"), + ExecutorName::Memory => write!(f, "memory"), + } + } +} + +impl ExecutorName { + /// Human-readable label for this executor. + pub fn label(&self) -> &'static str { + match self { + ExecutorName::Valgrind => "CPU Simulation", + ExecutorName::WallTime => "Walltime", + ExecutorName::Memory => "Memory", + } + } + + /// Nerd Font icon for this executor. + pub fn icon(&self) -> &'static str { + match self { + ExecutorName::Valgrind => "\u{f4bc}", + ExecutorName::WallTime => "\u{f520}", + ExecutorName::Memory => "\u{efc5}", } } } diff --git a/src/executor/mod.rs b/src/executor/mod.rs index 79ac48ec..e7c70026 100644 --- a/src/executor/mod.rs +++ b/src/executor/mod.rs @@ -19,7 +19,6 @@ use crate::system::SystemInfo; use async_trait::async_trait; pub use config::{BenchmarkTarget, ExecutorConfig, OrchestratorConfig}; pub use execution_context::ExecutionContext; -pub use helpers::profile_folder::create_profile_folder; pub use interfaces::ExecutorName; pub use orchestrator::Orchestrator; @@ -115,7 +114,7 @@ pub async fn run_executor( None }; - executor.run(execution_context, &mongo_tracer).await?; + let run_result = executor.run(execution_context, &mongo_tracer).await; // TODO: refactor and move directly in the Instruments struct as a `stop` method if let Some(mut mongo_tracer) = mongo_tracer { @@ -124,6 +123,9 @@ pub async fn run_executor( debug!("Tearing down the executor"); executor.teardown(execution_context).await?; + // Propagate any run error after cleanup + run_result?; + orchestrator .logger .persist_log_to_profile_folder(&execution_context.profile_folder)?; diff --git a/src/executor/orchestrator.rs b/src/executor/orchestrator.rs index 0e732a4b..58c7005a 100644 --- a/src/executor/orchestrator.rs +++ b/src/executor/orchestrator.rs @@ -1,19 +1,25 @@ use super::{ExecutionContext, ExecutorName, get_executor_from_mode, run_executor}; use crate::api_client::CodSpeedAPIClient; use crate::binary_installer::ensure_binary_installed; -use crate::cli::exec::{EXEC_HARNESS_COMMAND, EXEC_HARNESS_VERSION, multi_targets}; +use crate::cli::exec::multi_targets; use crate::cli::run::logger::Logger; use crate::config::CodSpeedConfig; use crate::executor::config::BenchmarkTarget; use crate::executor::config::OrchestratorConfig; +use crate::executor::helpers::profile_folder::create_profile_folder; +use crate::local_logger::rolling_buffer::{activate_rolling_buffer, deactivate_rolling_buffer}; use crate::prelude::*; use crate::run_environment::{self, RunEnvironment, RunEnvironmentProvider}; use crate::runner_mode::RunnerMode; use crate::system::{self, SystemInfo}; +use crate::upload::poll_results::poll_results; use crate::upload::{UploadResult, upload}; use serde_json::Value; use std::collections::BTreeMap; -use std::path::Path; +use std::path::{Path, PathBuf}; + +pub const EXEC_HARNESS_COMMAND: &str = "exec-harness"; +pub const EXEC_HARNESS_VERSION: &str = "1.2.0"; /// Shared orchestration state created once per CLI invocation. /// @@ -66,13 +72,22 @@ impl Orchestrator { /// Execute all benchmark targets for all configured modes, then upload results. /// - /// Processes `self.config.targets` as follows: - /// - All `Exec` targets are combined into a single exec-harness invocation (one executor per mode) - /// - Each `Entrypoint` target is run independently (one executor per mode per target) - pub async fn execute(&self, setup_cache_dir: Option<&Path>, poll_results: F) -> Result<()> - where - F: AsyncFn(&UploadResult) -> Result<()>, - { + /// Flattens all `(command, mode)` pairs into a single iteration: + /// - All `Exec` targets are combined into a single exec-harness command + /// - Each `Entrypoint` target produces its own command + /// - Each command is crossed with every configured mode + /// + /// Each `(command, mode)` pair gets its own profile folder. When the user + /// specifies `--profile-folder` and there are multiple pairs, deterministic + /// subdirectories (`-`) are created under that folder. + pub async fn execute( + &self, + setup_cache_dir: Option<&Path>, + api_client: &CodSpeedAPIClient, + ) -> Result<()> { + // Build (command, label) pairs while we still know the target type + let mut command_labels: Vec<(String, String)> = vec![]; + let exec_targets: Vec<&BenchmarkTarget> = self .config .targets @@ -80,20 +95,6 @@ impl Orchestrator { .filter(|t| matches!(t, BenchmarkTarget::Exec { .. })) .collect(); - let entrypoint_targets: Vec<&BenchmarkTarget> = self - .config - .targets - .iter() - .filter(|t| matches!(t, BenchmarkTarget::Entrypoint { .. })) - .collect(); - - let mut all_completed_runs = vec![]; - - if !self.config.skip_run { - start_opened_group!("Running the benchmarks"); - } - - // All exec targets combined into a single exec-harness invocation if !exec_targets.is_empty() { ensure_binary_installed(EXEC_HARNESS_COMMAND, EXEC_HARNESS_VERSION, || { format!( @@ -103,66 +104,116 @@ impl Orchestrator { .await?; let pipe_cmd = multi_targets::build_exec_targets_pipe_command(&exec_targets)?; - let completed_runs = self.run_all_modes(pipe_cmd, setup_cache_dir).await?; - all_completed_runs.extend(completed_runs); + let label = match exec_targets.as_slice() { + [BenchmarkTarget::Exec { command, .. }] => { + format!("Running `{}` with exec-harness", command.join(" ")) + } + targets => format!("Running {} commands with exec-harness", targets.len()), + }; + command_labels.push((pipe_cmd, label)); } - // Each entrypoint target runs independently - for target in entrypoint_targets { - let BenchmarkTarget::Entrypoint { command, .. } = target else { - unreachable!() - }; - let completed_runs = self.run_all_modes(command.clone(), setup_cache_dir).await?; - all_completed_runs.extend(completed_runs); + for target in &self.config.targets { + if let BenchmarkTarget::Entrypoint { command, .. } = target { + command_labels.push((command.clone(), command.clone())); + } + } + + struct ExecutorTarget<'a> { + command: String, + mode: &'a RunnerMode, + label: String, + } + + // Flatten into (command, mode) run parts + let modes = &self.config.modes; + let run_parts: Vec = command_labels + .iter() + .flat_map(|(cmd, label)| { + modes.iter().map(move |mode| { + let executor_name = get_executor_from_mode(mode).name(); + ExecutorTarget { + command: cmd.clone(), + mode, + label: format!( + "{} {} - {label}", + executor_name.icon(), + executor_name.label() + ), + } + }) + }) + .collect(); + + let total_parts = run_parts.len(); + let mut all_completed_runs = vec![]; + + if !self.config.skip_run { + start_opened_group!("Running the benchmarks"); + } + + for (run_part_index, part) in run_parts.into_iter().enumerate() { + let config = self.config.executor_config_for_command(part.command); + let executor = get_executor_from_mode(part.mode); + let profile_folder = + self.resolve_profile_folder(&executor.name(), run_part_index, total_parts)?; + + let ctx = ExecutionContext::new(config, profile_folder); + + if !self.config.show_full_output { + activate_rolling_buffer(&part.label); + } + + run_executor(executor.as_ref(), self, &ctx, setup_cache_dir).await?; + + if !self.config.show_full_output { + deactivate_rolling_buffer(); + } + all_completed_runs.push((ctx, executor.name())); } if !self.config.skip_run { end_group!(); } - self.upload_and_poll(all_completed_runs, &poll_results) - .await?; + self.upload_and_poll(all_completed_runs, api_client).await?; Ok(()) } - /// Run the given command across all configured modes, returning completed run contexts. - async fn run_all_modes( + /// Resolve the profile folder for a given run part. + /// + /// - Single run part + user-specified folder: use as-is + /// - Multiple run parts + user-specified folder: `/-` + /// - No user-specified folder: create a random temp folder + fn resolve_profile_folder( &self, - command: String, - setup_cache_dir: Option<&Path>, - ) -> Result> { - let modes = &self.config.modes; - let is_multi_mode = modes.len() > 1; - let mut completed_runs: Vec<(ExecutionContext, ExecutorName)> = vec![]; - for mode in modes { - if is_multi_mode { - info!("Running benchmarks for {mode} mode"); - } - let mut per_mode_config = self.config.executor_config_for_command(command.clone()); - // For multi-mode runs, always create a fresh profile folder per mode - // even if the user specified one (to avoid modes overwriting each other). - if is_multi_mode { - per_mode_config.profile_folder = None; + executor_name: &ExecutorName, + run_part_index: usize, + total_parts: usize, + ) -> Result { + match (&self.config.profile_folder, total_parts) { + (Some(folder), 1) => Ok(folder.clone()), + (Some(folder), _) => { + let subfolder = folder.join(format!("{executor_name}-{run_part_index}")); + std::fs::create_dir_all(&subfolder).with_context(|| { + format!( + "Failed to create profile subfolder: {}", + subfolder.display() + ) + })?; + Ok(subfolder) } - let ctx = ExecutionContext::new(per_mode_config)?; - let executor = get_executor_from_mode(mode); - - run_executor(executor.as_ref(), self, &ctx, setup_cache_dir).await?; - completed_runs.push((ctx, executor.name())); + (None, _) => create_profile_folder(), } - Ok(completed_runs) } /// Upload completed runs and poll results. - async fn upload_and_poll( + async fn upload_and_poll( &self, mut completed_runs: Vec<(ExecutionContext, ExecutorName)>, - poll_results: F, - ) -> Result<()> - where - F: AsyncFn(&UploadResult) -> Result<()>, - { + api_client: &CodSpeedAPIClient, + ) -> Result<()> { let skip_upload = self.config.skip_upload; if !skip_upload { @@ -171,7 +222,12 @@ impl Orchestrator { end_group!(); if self.is_local() { - poll_results(&last_upload_result).await?; + poll_results( + api_client, + &last_upload_result, + &self.config.poll_results_options, + ) + .await?; } } else { debug!("Skipping upload of performance data"); @@ -210,7 +266,7 @@ impl Orchestrator { } if total_runs > 1 { - info!("Uploading results for {executor_name:?} executor"); + info!("Uploading results {}/{total_runs}", run_part_index + 1); } let run_part_suffix = Self::build_run_part_suffix(executor_name, run_part_index, total_runs); diff --git a/src/executor/tests.rs b/src/executor/tests.rs index 317d9f17..49fa6ce0 100644 --- a/src/executor/tests.rs +++ b/src/executor/tests.rs @@ -120,16 +120,15 @@ fn env_test_cases(#[case] env_case: (&str, &str)) {} async fn create_test_setup(config: ExecutorConfig) -> (ExecutionContext, TempDir) { let temp_dir = TempDir::new().unwrap(); - let mut config_with_folder = config; - config_with_folder.profile_folder = Some(temp_dir.path().to_path_buf()); + let mut config = config; // Provide a test token so authentication doesn't fail - if config_with_folder.token.is_none() { - config_with_folder.token = Some("test-token".to_string()); + if config.token.is_none() { + config.token = Some("test-token".to_string()); } - let execution_context = - ExecutionContext::new(config_with_folder).expect("Failed to create ExecutionContext"); + let profile_folder = temp_dir.path().to_path_buf(); + let execution_context = ExecutionContext::new(config, profile_folder); (execution_context, temp_dir) } @@ -342,7 +341,7 @@ fi command: &[String], ) -> String { shell_words::join( - std::iter::once(crate::cli::exec::EXEC_HARNESS_COMMAND) + std::iter::once(crate::executor::orchestrator::EXEC_HARNESS_COMMAND) .chain(walltime_args.to_cli_args().iter().map(|s| s.as_str())) .chain(command.iter().map(|s| s.as_str())), ) diff --git a/src/local_logger.rs b/src/local_logger.rs deleted file mode 100644 index 9c14e15f..00000000 --- a/src/local_logger.rs +++ /dev/null @@ -1,173 +0,0 @@ -use std::{ - env, - sync::{Arc, Mutex}, - time::Duration, -}; - -use crate::prelude::*; -use console::{Style, style}; -use indicatif::{ProgressBar, ProgressStyle}; -use lazy_static::lazy_static; -use log::Log; -use simplelog::{CombinedLogger, SharedLogger}; -use std::io::Write; - -use crate::logger::{GroupEvent, JsonEvent, get_group_event, get_json_event}; - -pub const CODSPEED_U8_COLOR_CODE: u8 = 208; // #FF8700 - -lazy_static! { - pub static ref SPINNER: Arc>> = Arc::new(Mutex::new(None)); - pub static ref IS_TTY: bool = std::io::IsTerminal::is_terminal(&std::io::stdout()); -} - -/// Hide the progress bar temporarily, execute `f`, then redraw the progress bar. -/// -/// If the output is not a TTY, `f` will be executed without hiding the progress bar. -pub fn suspend_progress_bar R, R>(f: F) -> R { - // If the output is a TTY, and there is a spinner, suspend it - if *IS_TTY { - // Use try_lock to avoid deadlock on reentrant calls - if let Ok(mut spinner) = SPINNER.try_lock() { - if let Some(spinner) = spinner.as_mut() { - return spinner.suspend(f); - } - } - } - - // Otherwise, just run the function - f() -} - -pub struct LocalLogger { - log_level: log::LevelFilter, -} - -impl LocalLogger { - pub fn new() -> Self { - let log_level = env::var("CODSPEED_LOG") - .ok() - .and_then(|log_level| log_level.parse::().ok()) - .unwrap_or(log::LevelFilter::Info); - - LocalLogger { log_level } - } -} - -impl Log for LocalLogger { - fn enabled(&self, metadata: &log::Metadata) -> bool { - metadata.level() <= self.log_level - } - - fn log(&self, record: &log::Record) { - if !self.enabled(record.metadata()) { - return; - } - - if let Some(group_event) = get_group_event(record) { - match group_event { - GroupEvent::Start(name) | GroupEvent::StartOpened(name) => { - eprintln!( - "\n{}", - style(format!("►►► {name} ")) - .bold() - .color256(CODSPEED_U8_COLOR_CODE) - ); - - if *IS_TTY { - let spinner = ProgressBar::new_spinner(); - spinner.set_style( - ProgressStyle::with_template( - format!( - " {{spinner:>.{CODSPEED_U8_COLOR_CODE}}} {{wide_msg:.{CODSPEED_U8_COLOR_CODE}.bold}}" - ) - .as_str(), - ) - .unwrap(), - ); - spinner.set_message(format!("{name}...")); - spinner.enable_steady_tick(Duration::from_millis(100)); - SPINNER.lock().unwrap().replace(spinner); - } else { - eprintln!("{name}..."); - } - } - GroupEvent::End => { - if *IS_TTY { - let mut spinner = SPINNER.lock().unwrap(); - if let Some(spinner) = spinner.as_mut() { - spinner.finish_and_clear(); - } - } - } - } - - return; - } - - if let Some(JsonEvent(json_string)) = get_json_event(record) { - println!("{json_string}"); - return; - } - - suspend_progress_bar(|| print_record(record)); - } - - fn flush(&self) { - std::io::stdout().flush().unwrap(); - } -} - -/// Print a log record to the console with the appropriate style -fn print_record(record: &log::Record) { - let error_style = Style::new().red(); - let info_style = Style::new().white(); - let warn_style = Style::new().yellow(); - let debug_style = Style::new().blue().dim(); - let trace_style = Style::new().black().dim(); - - match record.level() { - log::Level::Error => eprintln!("{}", error_style.apply_to(record.args())), - log::Level::Warn => eprintln!("{}", warn_style.apply_to(record.args())), - log::Level::Info => eprintln!("{}", info_style.apply_to(record.args())), - log::Level::Debug => eprintln!( - "{}", - debug_style.apply_to(format!("[DEBUG::{}] {}", record.target(), record.args())), - ), - log::Level::Trace => eprintln!( - "{}", - trace_style.apply_to(format!("[TRACE::{}] {}", record.target(), record.args())) - ), - } -} - -impl SharedLogger for LocalLogger { - fn level(&self) -> log::LevelFilter { - self.log_level - } - - fn config(&self) -> Option<&simplelog::Config> { - None - } - - fn as_log(self: Box) -> Box { - Box::new(*self) - } -} - -pub fn get_local_logger() -> Box { - Box::new(LocalLogger::new()) -} - -pub fn init_local_logger() -> Result<()> { - let logger = get_local_logger(); - CombinedLogger::init(vec![logger])?; - Ok(()) -} - -pub fn clean_logger() { - let mut spinner = SPINNER.lock().unwrap(); - if let Some(spinner) = spinner.as_mut() { - spinner.finish_and_clear(); - } -} diff --git a/src/local_logger/mod.rs b/src/local_logger/mod.rs new file mode 100644 index 00000000..a6a2564b --- /dev/null +++ b/src/local_logger/mod.rs @@ -0,0 +1,296 @@ +pub mod rolling_buffer; + +use std::{ + env, + sync::{Arc, Mutex}, + time::Duration, +}; + +use crate::prelude::*; +use console::{Style, style}; +use indicatif::{ProgressBar, ProgressStyle}; +use lazy_static::lazy_static; +use log::Log; +use simplelog::{CombinedLogger, SharedLogger}; +use std::io::Write; + +use crate::logger::{GroupEvent, JsonEvent, get_group_event, get_json_event}; + +pub const CODSPEED_U8_COLOR_CODE: u8 = 208; // #FF8700 + +/// Spinner tick characters - smooth animation for a polished feel +pub(crate) const SPINNER_TICKS: &[&str] = &[" ", ". ", "..", " ."]; + +/// Interval between spinner animation ticks (milliseconds) +pub(crate) const TICK_INTERVAL_MS: u64 = 300; + +lazy_static! { + pub static ref SPINNER: Arc>> = Arc::new(Mutex::new(None)); + pub static ref IS_TTY: bool = std::io::IsTerminal::is_terminal(&std::io::stdout()); + static ref CURRENT_GROUP_NAME: Arc>> = Arc::new(Mutex::new(None)); +} + +/// Hide the progress bar temporarily, execute `f`, then redraw the progress bar. +/// +/// If the output is not a TTY, `f` will be executed without hiding the progress bar. +pub fn suspend_progress_bar R, R>(f: F) -> R { + // If the output is a TTY, and there is a spinner, suspend it + if *IS_TTY { + // Use try_lock to avoid deadlock on reentrant calls + if let Ok(mut spinner) = SPINNER.try_lock() { + if let Some(spinner) = spinner.as_mut() { + return spinner.suspend(f); + } + } + } + + // Otherwise, just run the function + f() +} + +pub struct LocalLogger { + log_level: log::LevelFilter, +} + +impl LocalLogger { + pub fn new() -> Self { + let log_level = env::var("CODSPEED_LOG") + .ok() + .and_then(|log_level| log_level.parse::().ok()) + .unwrap_or(log::LevelFilter::Info); + + LocalLogger { log_level } + } +} + +impl Log for LocalLogger { + fn enabled(&self, metadata: &log::Metadata) -> bool { + metadata.level() <= self.log_level + } + + fn log(&self, record: &log::Record) { + if !self.enabled(record.metadata()) { + return; + } + + if let Some(group_event) = get_group_event(record) { + match group_event { + GroupEvent::Start(ref name) | GroupEvent::StartOpened(ref name) => { + let opened = matches!(group_event, GroupEvent::StartOpened(_)); + let name = name.clone(); + + let header = format_group_header(&name); + eprintln!(); + eprintln!("{header}"); + eprintln!(); + + // Opened groups don't get a spinner or closing checkmark + if !opened { + // Store current group name for completion message + if let Ok(mut current) = CURRENT_GROUP_NAME.lock() { + *current = Some(name.clone()); + } + + install_spinner(&name); + } + } + GroupEvent::End => { + if *IS_TTY { + let mut spinner = SPINNER.lock().unwrap(); + if let Some(pb) = spinner.as_mut() { + let elapsed = pb.elapsed(); + pb.finish_and_clear(); + + // Show completion message with checkmark + if let Ok(mut current) = CURRENT_GROUP_NAME.lock() { + if let Some(name) = current.take() { + let elapsed_str = format_elapsed(elapsed); + eprintln!( + "{} {}", + format_checkmark(&name, true), + style(elapsed_str).dim(), + ); + } + } + } + } + } + } + + return; + } + + if let Some(JsonEvent(json_string)) = get_json_event(record) { + println!("{json_string}"); + return; + } + + suspend_progress_bar(|| print_record(record)); + } + + fn flush(&self) { + std::io::stdout().flush().unwrap(); + } +} + +/// Format a group header with styled prefix +fn format_group_header(name: &str) -> String { + let prefix = style("\u{f0da}").color256(CODSPEED_U8_COLOR_CODE).bold(); + let title = style(name).bold(); + format!("{prefix} {title}") +} + +/// Format a completion checkmark with a label. +pub(crate) fn format_checkmark(label: &str, dim: bool) -> String { + let label = if dim { + style(label).dim().to_string() + } else { + label.to_string() + }; + format!(" {} {}", style("\u{f00c}").green().bold(), label) +} + +/// Format elapsed duration in a compact human-readable way +fn format_elapsed(duration: Duration) -> String { + let secs = duration.as_secs(); + let millis = duration.as_millis(); + + if secs >= 60 { + let mins = secs / 60; + let remaining_secs = secs % 60; + format!("{mins}m {remaining_secs}s") + } else if secs > 0 { + format!("{secs}.{:01}s", (millis % 1000) / 100) + } else { + format!("{millis}ms") + } +} + +/// Indent every line of a string with the given prefix +fn indent_lines(s: &str, indent: &str) -> String { + s.lines() + .enumerate() + .map(|(i, line)| { + if i == 0 { + line.to_string() + } else { + format!("{indent}{line}") + } + }) + .collect::>() + .join("\n") +} + +/// Print a log record to the console with the appropriate style +fn print_record(record: &log::Record) { + match record.level() { + log::Level::Error => { + let prefix = style("\u{f00d}").red().bold(); + let msg = indent_lines(&format!("{}", record.args()), " "); + let msg = Style::new().red().apply_to(msg); + eprintln!(" {prefix} {msg}"); + } + log::Level::Warn => { + let prefix = style("\u{f071}").yellow(); + let msg = indent_lines(&format!("{}", record.args()), " "); + let msg = Style::new().yellow().apply_to(msg); + eprintln!(" {prefix} {msg}"); + } + log::Level::Info => { + let msg = indent_lines(&format!("{}", record.args()), " "); + let msg = Style::new().white().apply_to(msg); + eprintln!(" {msg}"); + } + log::Level::Debug => { + let prefix = style("\u{00B7}").dim(); + let msg = indent_lines(&format!("{}", record.args()), " "); + let msg = Style::new().blue().dim().apply_to(msg); + eprintln!(" {prefix} {msg}"); + } + log::Level::Trace => { + let raw = format!("[TRACE::{}] {}", record.target(), record.args()); + let msg = indent_lines(&raw, " "); + let msg = Style::new().black().dim().apply_to(msg); + eprintln!(" {msg}"); + } + } +} + +impl SharedLogger for LocalLogger { + fn level(&self) -> log::LevelFilter { + self.log_level + } + + fn config(&self) -> Option<&simplelog::Config> { + None + } + + fn as_log(self: Box) -> Box { + Box::new(*self) + } +} + +pub fn get_local_logger() -> Box { + Box::new(LocalLogger::new()) +} + +pub fn init_local_logger() -> Result<()> { + let logger = get_local_logger(); + CombinedLogger::init(vec![logger])?; + Ok(()) +} + +/// Create a styled spinner progress bar with CodSpeed branding. +fn create_spinner(message: &str) -> ProgressBar { + let spinner = ProgressBar::new_spinner(); + let tick_strings: Vec = SPINNER_TICKS + .iter() + .map(|s| format!("{}", style(s).color256(CODSPEED_U8_COLOR_CODE).dim())) + .collect(); + let tick_strs: Vec<&str> = tick_strings.iter().map(|s| s.as_str()).collect(); + + spinner.set_style( + ProgressStyle::with_template(&format!( + " {{spinner}} {{wide_msg:.{CODSPEED_U8_COLOR_CODE}}} {{elapsed:.dim}}" + )) + .unwrap() + .tick_strings(&tick_strs), + ); + spinner.set_message({ message }.to_string()); + spinner.enable_steady_tick(Duration::from_millis(TICK_INTERVAL_MS)); + spinner +} + +/// Install a spinner into the global slot so log records suspend it. +fn install_spinner(message: &str) { + if *IS_TTY { + let spinner = create_spinner(message); + SPINNER.lock().unwrap().replace(spinner); + } else { + eprintln!("{message}..."); + } +} + +/// Start a standalone spinner with a message (no group header or checkmark). +/// +/// The spinner animates on TTY outputs. On non-TTY, prints the message once. +/// Call [`stop_spinner`] to clear it when done. +pub fn start_spinner(message: &str) { + install_spinner(message); +} + +/// Stop and clear the current standalone spinner. +pub fn stop_spinner() { + if let Ok(mut spinner) = SPINNER.lock() { + if let Some(pb) = spinner.take() { + pb.finish_and_clear(); + } + } +} + +pub fn clean_logger() { + let mut spinner = SPINNER.lock().unwrap(); + if let Some(spinner) = spinner.as_mut() { + spinner.finish_and_clear(); + } +} diff --git a/src/local_logger/rolling_buffer/mod.rs b/src/local_logger/rolling_buffer/mod.rs new file mode 100644 index 00000000..de602219 --- /dev/null +++ b/src/local_logger/rolling_buffer/mod.rs @@ -0,0 +1,335 @@ +use std::collections::VecDeque; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use super::{ + CODSPEED_U8_COLOR_CODE, IS_TTY, SPINNER, SPINNER_TICKS, TICK_INTERVAL_MS, format_checkmark, +}; +use console::{Term, style}; +use lazy_static::lazy_static; + +const INDENT: &str = " "; + +lazy_static! { + /// Global shared rolling buffer, set by `activate_rolling_buffer` and + /// consumed by `log_tee` in `run_command_with_log_pipe`. + pub(crate) static ref ROLLING_BUFFER: Mutex> = + Mutex::new(None); +} + +/// Stop signal for the tick thread. +/// +/// The rolling buffer manages its own background tick thread rather than using +/// `ProgressBar` because it renders a multi-line frame (title + bordered log box) +/// via direct terminal cursor manipulation. `ProgressBar` only manages a single +/// line and would conflict with the rolling buffer's cursor movements. +static TICK_STOP: AtomicBool = AtomicBool::new(false); + +pub struct RollingBuffer { + lines: VecDeque, + max_lines: usize, + total_lines: usize, + /// Number of lines currently drawn on screen + /// (title + top_delim + content lines + bottom_delim) + rendered_count: usize, + term: Term, + term_width: usize, + active: bool, + title: String, + start: Instant, + finished: bool, +} + +impl RollingBuffer { + fn new(title: &str) -> Self { + let term = Term::stderr(); + let (rows, cols) = term.size(); + let rows = rows as usize; + let cols = cols as usize; + + let active = *IS_TTY && rows >= 5; + // Reserve space for title + delimiters + let max_lines = if active { + 20.min(rows.saturating_sub(6)) + } else { + 0 + }; + + Self { + lines: VecDeque::with_capacity(max_lines), + max_lines, + total_lines: 0, + rendered_count: 0, + term, + term_width: cols, + active, + title: title.to_string(), + start: Instant::now(), + finished: false, + } + } + + pub fn is_active(&self) -> bool { + self.active + } + + /// Ingest text into the rolling buffer, splitting on newlines and + /// maintaining the max_lines window. + fn ingest(&mut self, text: &str) { + for line in text.split('\n') { + if line.is_empty() { + continue; + } + let line = line.trim_end_matches('\r'); + self.total_lines += 1; + self.lines.push_back(line.to_string()); + while self.lines.len() > self.max_lines { + self.lines.pop_front(); + } + } + } + + pub fn push_lines(&mut self, text: &str) { + if !self.active { + return; + } + + self.ingest(text); + self.redraw(); + } + + fn truncated_count(&self) -> usize { + self.total_lines.saturating_sub(self.lines.len()) + } + + fn spinner_tick(&self) -> &'static str { + let elapsed_ms = self.start.elapsed().as_millis(); + let idx = (elapsed_ms / TICK_INTERVAL_MS as u128) as usize % SPINNER_TICKS.len(); + SPINNER_TICKS[idx] + } + + fn render_title_line(&self) -> String { + let tick = self.spinner_tick(); + let tick_styled = style(tick).color256(CODSPEED_U8_COLOR_CODE).dim(); + let title_styled = style(&self.title).color256(CODSPEED_U8_COLOR_CODE); + + let line = format!(" {tick_styled} {title_styled}"); + console::truncate_str(&line, self.term_width, "…").into_owned() + } + + fn render_top_delimiter(&self) -> String { + let truncated = self.truncated_count(); + let label = if truncated > 0 { + format!( + " {} lines above ", + style(truncated).color256(CODSPEED_U8_COLOR_CODE).dim() + ) + } else { + String::new() + }; + let prefix = format!("{INDENT}\u{256d}\u{2500}"); + let suffix = "\u{256e}"; // ╮ + let label_visible_len = if truncated > 0 { + format!(" {truncated} lines above ").len() + } else { + 0 + }; + let used = console::measure_text_width(&prefix) + + label_visible_len + + console::measure_text_width(suffix); + let remaining = self.term_width.saturating_sub(used); + let bar = "\u{2500}".repeat(remaining); + format!( + "{}{}{}", + style(prefix.to_string()).dim(), + label, + style(format!("{bar}{suffix}")).dim() + ) + } + + fn render_bottom_delimiter(&self) -> String { + let prefix = format!("{INDENT}\u{2570}"); + let suffix = "\u{256f}"; // ╯ + let used = console::measure_text_width(&prefix) + console::measure_text_width(suffix); + let remaining = self.term_width.saturating_sub(used); + let bar = "\u{2500}".repeat(remaining); + format!("{}", style(format!("{prefix}{bar}{suffix}")).dim()) + } + + fn render_content_line(&self, line: &str) -> String { + let inner_indent = format!("{INDENT}\u{2502} "); + let right_border = "\u{2502}"; // │ + let chrome_width = + console::measure_text_width(&inner_indent) + console::measure_text_width(right_border); + let max_content_width = self.term_width.saturating_sub(chrome_width); + let truncated = if max_content_width > 0 { + console::truncate_str(line, max_content_width, "…") + } else { + std::borrow::Cow::Borrowed("") + }; + let content_visible_len = console::measure_text_width(&truncated); + let padding = max_content_width.saturating_sub(content_visible_len); + format!( + "{}{}{}{}", + style(&inner_indent).dim(), + style(&*truncated).dim(), + " ".repeat(padding), + style(right_border).dim() + ) + } + + /// Return the full rendered frame as a vector of strings. + fn render_frame(&self) -> Vec { + let mut frame = Vec::new(); + frame.push(self.render_title_line()); + frame.push(self.render_top_delimiter()); + for line in &self.lines { + frame.push(self.render_content_line(line)); + } + frame.push(self.render_bottom_delimiter()); + frame + } + + /// Render the finished frame (checkmark title instead of spinner). + fn render_finished_frame(&self) -> Vec { + let mut frame = Vec::new(); + frame.push(format_checkmark(&self.title, false)); + frame.push(self.render_top_delimiter()); + for line in &self.lines { + frame.push(self.render_content_line(line)); + } + frame.push(self.render_bottom_delimiter()); + frame + } + + /// Write a frame to the terminal, clearing and replacing any previously rendered lines. + fn draw_frame(&mut self, frame: &[String]) { + // Move cursor up to erase all previously rendered lines + if self.rendered_count > 0 { + self.term.move_cursor_up(self.rendered_count).ok(); + } + + for line in frame { + self.term.clear_line().ok(); + self.term.write_line(line).ok(); + } + + let new_count = frame.len(); + + // Clear any extra lines from previous render + for _ in new_count..self.rendered_count { + self.term.clear_line().ok(); + self.term.write_line("").ok(); + } + + // Move cursor back if we rendered fewer lines than before + if new_count < self.rendered_count { + self.term + .move_cursor_up(self.rendered_count - new_count) + .ok(); + } + + self.rendered_count = new_count; + } + + /// Redraw only the title line (for spinner animation ticks). + fn redraw_title(&mut self) { + if self.rendered_count == 0 || self.finished { + return; + } + // Move up to the title line, rewrite it, then move back down + self.term.move_cursor_up(self.rendered_count).ok(); + self.term.clear_line().ok(); + self.term.write_line(&self.render_title_line()).ok(); + let rest = self.rendered_count - 1; + if rest > 0 { + self.term.move_cursor_down(rest).ok(); + } + } + + fn redraw(&mut self) { + let frame = self.render_frame(); + self.draw_frame(&frame); + } + + /// Finish the rolling display, replacing the spinner title with a checkmark + /// and leaving the last content lines visible on screen. + pub fn finish(&mut self) { + if self.finished || self.rendered_count == 0 { + self.finished = true; + return; + } + self.finished = true; + + let frame = self.render_finished_frame(); + self.draw_frame(&frame); + self.rendered_count = 0; + } +} + +impl Drop for RollingBuffer { + fn drop(&mut self) { + if !self.finished { + self.finish(); + } + } +} + +/// Activate a rolling buffer for the current executor run. +/// +/// Suspends the group spinner and installs a shared rolling buffer that +/// `run_command_with_log_pipe` will automatically pick up. Starts a background +/// tick thread to keep the spinner animating. +pub fn activate_rolling_buffer(title: &str) { + if !*IS_TTY { + return; + } + let rb = RollingBuffer::new(title); + if !rb.is_active() { + return; + } + // Suspend the group spinner so it doesn't interfere with rolling output + if let Ok(mut spinner) = SPINNER.lock() { + if let Some(pb) = spinner.take() { + pb.suspend(|| eprintln!()); + pb.finish_and_clear(); + } + } + *ROLLING_BUFFER.lock().unwrap() = Some(rb); + + // Start a background thread that redraws periodically to animate the spinner + TICK_STOP.store(false, Ordering::Relaxed); + std::thread::spawn(|| { + while !TICK_STOP.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(TICK_INTERVAL_MS)); + if TICK_STOP.load(Ordering::Relaxed) { + break; + } + if let Ok(mut guard) = ROLLING_BUFFER.try_lock() { + if let Some(rb) = guard.as_mut() { + if rb.finished { + break; + } + rb.redraw_title(); + } + } + } + }); +} + +/// Finish and deactivate the current rolling buffer. +pub fn deactivate_rolling_buffer() { + // Stop the tick thread first + TICK_STOP.store(true, Ordering::Relaxed); + + if let Ok(mut guard) = ROLLING_BUFFER.lock() { + if let Some(rb) = guard.as_mut() { + rb.finish(); + } + *guard = None; + } +} + +#[cfg(test)] +mod tests; diff --git a/src/local_logger/rolling_buffer/snapshots/codspeed_runner__local_logger__rolling_buffer__tests__render_empty.snap b/src/local_logger/rolling_buffer/snapshots/codspeed_runner__local_logger__rolling_buffer__tests__render_empty.snap new file mode 100644 index 00000000..ecb29a3f --- /dev/null +++ b/src/local_logger/rolling_buffer/snapshots/codspeed_runner__local_logger__rolling_buffer__tests__render_empty.snap @@ -0,0 +1,7 @@ +--- +source: src/local_logger/rolling_buffer/tests.rs +expression: render_stripped(&rb) +--- + Running benchmarks + ╭──────────────────────────────────────────────────────╮ + ╰──────────────────────────────────────────────────────╯ diff --git a/src/local_logger/rolling_buffer/snapshots/codspeed_runner__local_logger__rolling_buffer__tests__render_few_lines.snap b/src/local_logger/rolling_buffer/snapshots/codspeed_runner__local_logger__rolling_buffer__tests__render_few_lines.snap new file mode 100644 index 00000000..a4db9688 --- /dev/null +++ b/src/local_logger/rolling_buffer/snapshots/codspeed_runner__local_logger__rolling_buffer__tests__render_few_lines.snap @@ -0,0 +1,10 @@ +--- +source: src/local_logger/rolling_buffer/tests.rs +expression: render_stripped(&rb) +--- + Running benchmarks + ╭──────────────────────────────────────────────────────╮ + │ line 1 │ + │ line 2 │ + │ line 3 │ + ╰──────────────────────────────────────────────────────╯ diff --git a/src/local_logger/rolling_buffer/snapshots/codspeed_runner__local_logger__rolling_buffer__tests__render_long_content_is_truncated.snap b/src/local_logger/rolling_buffer/snapshots/codspeed_runner__local_logger__rolling_buffer__tests__render_long_content_is_truncated.snap new file mode 100644 index 00000000..5296aefa --- /dev/null +++ b/src/local_logger/rolling_buffer/snapshots/codspeed_runner__local_logger__rolling_buffer__tests__render_long_content_is_truncated.snap @@ -0,0 +1,8 @@ +--- +source: src/local_logger/rolling_buffer/tests.rs +expression: render_stripped(&rb) +--- + Running benchmarks + ╭──────────────────────────────────╮ + │ this is a very long line that sh…│ + ╰──────────────────────────────────╯ diff --git a/src/local_logger/rolling_buffer/snapshots/codspeed_runner__local_logger__rolling_buffer__tests__render_with_truncation.snap b/src/local_logger/rolling_buffer/snapshots/codspeed_runner__local_logger__rolling_buffer__tests__render_with_truncation.snap new file mode 100644 index 00000000..086abbed --- /dev/null +++ b/src/local_logger/rolling_buffer/snapshots/codspeed_runner__local_logger__rolling_buffer__tests__render_with_truncation.snap @@ -0,0 +1,12 @@ +--- +source: src/local_logger/rolling_buffer/tests.rs +expression: render_stripped(&rb) +--- + Running benchmarks + ╭─ 3 lines above ──────────────────────────────────────╮ + │ line 4 │ + │ line 5 │ + │ line 6 │ + │ line 7 │ + │ line 8 │ + ╰──────────────────────────────────────────────────────╯ diff --git a/src/local_logger/rolling_buffer/tests.rs b/src/local_logger/rolling_buffer/tests.rs new file mode 100644 index 00000000..4b66a751 --- /dev/null +++ b/src/local_logger/rolling_buffer/tests.rs @@ -0,0 +1,70 @@ +use super::*; + +/// Create a RollingBuffer with fixed dimensions for deterministic test output. +fn make_test_buffer(title: &str, term_width: usize, max_lines: usize) -> RollingBuffer { + RollingBuffer { + lines: VecDeque::with_capacity(max_lines), + max_lines, + total_lines: 0, + rendered_count: 0, + term: Term::stderr(), + term_width, + active: true, + title: title.to_string(), + start: Instant::now(), + finished: false, + } +} + +/// Strip ANSI codes and join frame lines for readable snapshots. +fn render_stripped(rb: &RollingBuffer) -> String { + rb.render_frame() + .into_iter() + .map(|l| console::strip_ansi_codes(&l).to_string()) + .collect::>() + .join("\n") +} + +#[test] +fn test_render_few_lines() { + let mut rb = make_test_buffer("Running benchmarks", 60, 20); + rb.ingest("line 1\nline 2\nline 3\n"); + insta::assert_snapshot!(render_stripped(&rb)); +} + +#[test] +fn test_render_with_truncation() { + let mut rb = make_test_buffer("Running benchmarks", 60, 5); + rb.ingest("line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\n"); + insta::assert_snapshot!(render_stripped(&rb)); +} + +#[test] +fn test_render_long_content_is_truncated() { + let mut rb = make_test_buffer("Running benchmarks", 40, 20); + rb.ingest("this is a very long line that should be truncated at the terminal width\n"); + insta::assert_snapshot!(render_stripped(&rb)); +} + +#[test] +fn test_render_empty() { + let rb = make_test_buffer("Running benchmarks", 60, 20); + insta::assert_snapshot!(render_stripped(&rb)); +} + +#[test] +fn test_delimiters_and_content_lines_match_term_width() { + let mut rb = make_test_buffer("Running benchmarks", 60, 20); + rb.ingest("short\na slightly longer line\nfoo\n"); + let frame = rb.render_frame(); + // Skip the title line (index 0) — it's intentionally not padded to full width + for line in &frame[1..] { + let width = console::measure_text_width(line); + assert_eq!( + width, + 60, + "line has width {width} instead of 60: {:?}", + console::strip_ansi_codes(line) + ); + } +} diff --git a/src/main.rs b/src/main.rs index c44740ca..518b8d3e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,16 +6,19 @@ use log::log_enabled; async fn main() { let res = cli::run().await; if let Err(err) = res { - for cause in err.chain() { + // Show the primary error + let mut chain = err.chain(); + if let Some(primary) = chain.next() { if log_enabled!(log::Level::Error) { - log::error!("{} {}", style("Error:").bold().red(), style(cause).red()); + log::error!("{}", style(primary).red()); } else { - eprintln!("Error: {cause}"); + eprintln!("{} {}", style("Error:").bold().red(), style(primary).red()); } } + // Show causes in debug mode if log_enabled!(log::Level::Debug) { - for e in err.chain().skip(1) { - log::debug!("Caused by: {e}"); + for cause in chain { + log::debug!("Caused by: {cause}"); } } clean_logger(); diff --git a/src/project_config/merger.rs b/src/project_config/merger.rs index a063fd6a..760e18e1 100644 --- a/src/project_config/merger.rs +++ b/src/project_config/merger.rs @@ -160,6 +160,7 @@ mod tests { skip_setup: false, allow_empty: false, go_runner_version: None, + show_full_output: false, perf_run_args: PerfRunArgs { enable_perf: true, perf_unwinding_mode: None, @@ -193,6 +194,7 @@ mod tests { skip_setup: false, allow_empty: false, go_runner_version: None, + show_full_output: false, perf_run_args: PerfRunArgs { enable_perf: true, perf_unwinding_mode: None, @@ -228,6 +230,7 @@ mod tests { skip_setup: false, allow_empty: false, go_runner_version: None, + show_full_output: false, perf_run_args: PerfRunArgs { enable_perf: false, perf_unwinding_mode: None, diff --git a/src/upload/poll_results.rs b/src/upload/poll_results.rs index c984f9cc..2683e637 100644 --- a/src/upload/poll_results.rs +++ b/src/upload/poll_results.rs @@ -2,11 +2,13 @@ use console::style; use crate::api_client::CodSpeedAPIClient; use crate::cli::run::helpers::benchmark_display::{build_benchmark_table, build_detailed_summary}; +use crate::local_logger::{start_spinner, stop_spinner}; use crate::prelude::*; use super::{UploadResult, poll_run_report}; /// Options controlling poll_results display behavior. +#[derive(Debug, Clone)] pub struct PollResultsOptions { /// If true, show impact percentage (used by `codspeed run`) pub show_impact: bool, @@ -41,26 +43,41 @@ pub async fn poll_results( upload_result: &UploadResult, options: &PollResultsOptions, ) -> Result<()> { - let response = poll_run_report(api_client, upload_result).await?; + start_spinner("Waiting for results"); + let response = poll_run_report(api_client, upload_result).await; + stop_spinner(); + let response = response?; if options.show_impact { let report = response.run.head_reports.into_iter().next(); if let Some(report) = report { if let Some(impact) = report.impact { let rounded_impact = (impact * 100.0).round(); - let impact_text = if impact > 0.0 { - style(format!("+{rounded_impact}%")).green().bold() + let (arrow, impact_text) = if impact > 0.0 { + ( + style("\u{f062}").green(), + style(format!("+{rounded_impact}%")).green().bold(), + ) + } else if impact < 0.0 { + ( + style("\u{f063}").red(), + style(format!("{rounded_impact}%")).red().bold(), + ) } else { - style(format!("{rounded_impact}%")).red().bold() + ( + style("\u{25CF}").dim(), + style(format!("{rounded_impact}%")).dim().bold(), + ) }; + let allowed = (response.allowed_regression * 100.0).round(); + info!("{arrow} Impact: {impact_text} (allowed regression: -{allowed}%)"); + } else { info!( - "Impact: {} (allowed regression: -{}%)", - impact_text, - (response.allowed_regression * 100.0).round() + "{} No impact detected, reason: {}", + style("\u{25CB}").dim(), + report.conclusion ); - } else { - info!("No impact detected, reason: {}", report.conclusion); } } } @@ -78,14 +95,14 @@ pub async fn poll_results( ); } else { end_group!(); - start_group!("Benchmark results"); + start_opened_group!("Benchmark results"); if options.detailed_single && response.run.results.len() == 1 { let summary = build_detailed_summary(&response.run.results[0]); - info!("\n{summary}"); + info!("{summary}\n"); } else { let table = build_benchmark_table(&response.run.results); - info!("\n{table}"); + info!("{table}\n"); } if options.output_json { @@ -98,10 +115,10 @@ pub async fn poll_results( } info!( - "\nTo see the full report, visit: {}", + "\n{} {}", + style("View full report:").dim(), style(response.run.url).blue().bold().underlined() ); - end_group!(); } Ok(()) diff --git a/src/upload/polling.rs b/src/upload/polling.rs index c6a64ca5..29be3fbc 100644 --- a/src/upload/polling.rs +++ b/src/upload/polling.rs @@ -25,10 +25,12 @@ pub async fn poll_run_report( run_id: upload_result.run_id.clone(), }; + debug!("Waiting for results to be processed..."); + let response; loop { if start.elapsed() > RUN_PROCESSING_MAX_DURATION { - bail!("Polling results timed out"); + bail!("Polling results timed out after 5 minutes. Please try again later."); } let fetch_result = api_client diff --git a/src/upload/uploader.rs b/src/upload/uploader.rs index a4c22cff..434fca05 100644 --- a/src/upload/uploader.rs +++ b/src/upload/uploader.rs @@ -321,13 +321,13 @@ mod tests { ))), ..OrchestratorConfig::test() }; + let profile_folder = PathBuf::from(format!( + "{}/src/uploader/samples/adrien-python-test", + env!("CARGO_MANIFEST_DIR") + )); let executor_config = ExecutorConfig { command: "pytest tests/ --codspeed".into(), token: Some("change me".into()), - profile_folder: Some(PathBuf::from(format!( - "{}/src/uploader/samples/adrien-python-test", - env!("CARGO_MANIFEST_DIR") - ))), ..ExecutorConfig::test() }; async_with_vars( @@ -365,8 +365,7 @@ mod tests { Orchestrator::new(orchestrator_config, &codspeed_config, &api_client) .await .expect("Failed to create Orchestrator for test"); - let execution_context = ExecutionContext::new(executor_config) - .expect("Failed to create ExecutionContext"); + let execution_context = ExecutionContext::new(executor_config, profile_folder); let run_part_suffix = BTreeMap::from([("executor".to_string(), Value::from("valgrind"))]); upload(