Compare commits

...
8 changed files with 385 additions and 82 deletions

No files matched your search

+9 -4
View File
@@ -108,12 +108,16 @@ hyperfine -S 'my-command > output-${HYPERFINE_ITERATION}.log'
### Intermediate shell
By default, commands are executed directly, without an intermediate shell (`--shell=none`).
By default, benchmarked commands, including warmup runs, are executed directly, without an intermediate shell (`--shell=none`).
Arguments are split using shell-like quoting, so quoted arguments containing spaces are supported.
Shell syntax such as pipes, redirections, environment-variable expansion, `*`, and `~` is not interpreted.
This avoids shell startup overhead and the noise from correcting for it, especially for fast commands
(< 5 ms).
Commands containing unquoted, standalone shell operators such as `&&` or `>` require an explicit
choice: use `-S` / `--shell=default` to interpret shell syntax, or `-N` / `--shell=none` to pass
the operators as literal arguments.
To enable shell syntax, use `-S` (an alias for `--shell=default`). This selects `sh`
on Unix (resolved through `PATH`) or `cmd.exe` on Windows:
```sh
@@ -125,10 +129,11 @@ You can also select a specific shell with `--shell <SHELL>`:
hyperfine --shell zsh 'for i in {1..10000}; do echo test; done'
```
The shell setting applies to all commands, including `--setup`, `--prepare`, `--conclude`, and `--cleanup`.
If any of these commands need shell syntax, enable a shell explicitly.
The `--setup`, `--prepare`, `--conclude`, and `--cleanup` commands always run through a shell.
They use the shell selected by `--shell`, or the platform shell (`sh` on Unix, `cmd.exe` on Windows)
when `--shell` is omitted or set to `none`.
When a shell is enabled, hyperfine *corrects for the shell spawning time*. It runs the shell with an
When benchmarked commands use a shell, hyperfine *corrects for the shell spawning time*. It runs the shell with an
empty command multiple times to measure its startup time, then subtracts this time from each
time measurement.
+6 -4
View File
@@ -194,14 +194,16 @@ possible parameter combinations.
.HP
\fB\-\-shell\fR \fISHELL\fP
.IP
Set the shell to use for executing commands (default: none). This can be
the name or the path to the shell executable, or a full command line
Set the shell to use for executing benchmarked commands, including warmup runs
(default: none). This can be the name or the path to the shell executable,
or a full command line
like "bash \fB\-\-norc\fR". It can also be set to "default" to explicitly
select the platform shell (sh on Unix, cmd.exe on Windows). Finally, this can also be
set to "none" to disable the shell. In this case, commands will be
set to "none" to disable the shell. In this case, benchmarked commands will be
executed directly. They can still have arguments, but more complex
things like "sleep 0.1; sleep 0.2" are not possible without a shell.
This option also applies to setup, prepare, conclude, and cleanup commands.
Setup, prepare, conclude, and cleanup commands always use a shell: the selected
shell, or the platform shell when this option is omitted or set to "none".
.HP
\fB\-S\fR
.IP
+51 -20
View File
@@ -5,9 +5,7 @@ use std::process::ExitStatus;
use crate::benchmark::measurement::Measurement;
use crate::benchmark::measurement::Measurements;
use crate::command::Command;
use crate::options::{
CmdFailureAction, CommandInputPolicy, CommandOutputPolicy, Options, OutputStyleOption, Shell,
};
use crate::options::{CmdFailureAction, CommandOutputPolicy, Options, OutputStyleOption, Shell};
use crate::output::progress_bar::get_progress_bar;
use crate::quantity::{second, Information, Time, Zero};
use crate::timer::execute_and_measure;
@@ -42,6 +40,21 @@ pub trait Executor {
output_policy: &CommandOutputPolicy,
) -> Result<Measurement>;
/// Run a lifecycle command through a shell, always treating failures as errors.
fn run_intermediate_command_and_measure(
&self,
command: &Command<'_>,
iteration: BenchmarkIteration,
output_policy: &CommandOutputPolicy,
) -> Result<Measurement> {
self.run_command_and_measure(
command,
iteration,
Some(CmdFailureAction::RaiseError),
output_policy,
)
}
/// Perform a calibration of this executor. For example,
/// when running commands through a shell, we need to
/// measure the shell spawning time separately in order
@@ -58,13 +71,13 @@ pub trait Executor {
fn run_command_and_measure_common(
mut command: std::process::Command,
iteration: BenchmarkIteration,
command_failure_action: CmdFailureAction,
command_input_policy: &CommandInputPolicy,
command_failure_action: Option<CmdFailureAction>,
options: &Options,
command_output_policy: &CommandOutputPolicy,
command_name: &str,
collect_hardware_counters: bool,
) -> Result<Measurement> {
let stdin = command_input_policy.get_stdin()?;
let stdin = options.command_input_policy.get_stdin()?;
let (stdout, stderr) = command_output_policy.get_stdout_stderr()?;
command.stdin(stdin).stdout(stdout).stderr(stderr);
@@ -83,10 +96,14 @@ fn run_command_and_measure_common(
if !measurement.exit_status.success() {
use crate::util::exit_code::extract_exit_code;
let should_fail = match command_failure_action {
let show_ignore_failure_hint = command_failure_action.is_none();
let should_fail = match command_failure_action
.as_ref()
.unwrap_or(&options.command_failure_action)
{
CmdFailureAction::RaiseError => true,
CmdFailureAction::IgnoreAllFailures => false,
CmdFailureAction::IgnoreSpecificFailures(ref codes) => {
CmdFailureAction::IgnoreSpecificFailures(codes) => {
// Only fail if the exit code is not in the list of codes to ignore
if let Some(exit_code) = extract_exit_code(measurement.exit_status) {
!codes.contains(&exit_code)
@@ -105,15 +122,17 @@ fn run_command_and_measure_common(
BenchmarkIteration::Benchmark(0) => "the first benchmark run".to_string(),
BenchmarkIteration::Benchmark(i) => format!("benchmark iteration {i}"),
};
bail!(
"{cause} in {when}. Use the '-i'/'--ignore-failure' option if you want to ignore this. \
Alternatively, use the '--show-output' option to debug what went wrong.",
cause=measurement.exit_status.code().map_or(
"The process has been terminated by a signal".into(),
|c| format!("Command terminated with non-zero exit code {c}")
),
let cause = measurement.exit_status.code().map_or_else(
|| "was terminated by a signal".to_owned(),
|code| format!("terminated with non-zero exit code {code}"),
);
let hint = if show_ignore_failure_hint {
" Use the '-i'/'--ignore-failure' option if you want to ignore this. \
Alternatively, use the '--show-output' option to debug what went wrong."
} else {
" Use the '--show-output' option to debug what went wrong."
};
bail!("Command '{command_name}' {cause} in {when}.{hint}");
}
}
@@ -141,14 +160,26 @@ impl Executor for RawExecutor<'_> {
run_command_and_measure_common(
command.get_command()?,
iteration,
command_failure_action.unwrap_or_else(|| self.options.command_failure_action.clone()),
&self.options.command_input_policy,
command_failure_action,
self.options,
output_policy,
&command.get_command_line(),
matches!(iteration, BenchmarkIteration::Benchmark(_)),
)
}
fn run_intermediate_command_and_measure(
&self,
command: &Command<'_>,
iteration: BenchmarkIteration,
output_policy: &CommandOutputPolicy,
) -> Result<Measurement> {
// Keep shell startup in the hook's duration: raw benchmarks have no
// calibrated shell overhead to add back when estimating the run count.
ShellExecutor::new(&Shell::platform_default(), self.options)
.run_intermediate_command_and_measure(command, iteration, output_policy)
}
fn calibrate(&mut self) -> Result<()> {
Ok(())
}
@@ -197,8 +228,8 @@ impl Executor for ShellExecutor<'_> {
let mut measurement = run_command_and_measure_common(
command_builder,
iteration,
command_failure_action.unwrap_or_else(|| self.options.command_failure_action.clone()),
&self.options.command_input_policy,
command_failure_action,
self.options,
output_policy,
&command.get_command_line(),
false,
+16 -28
View File
@@ -11,9 +11,7 @@ use crate::benchmark::benchmark_result::Parameter;
use crate::benchmark::executor::BenchmarkIteration;
use crate::benchmark::measurement::{Measurement, Measurements};
use crate::command::Command;
use crate::options::{
CmdFailureAction, CommandOutputPolicy, ExecutorKind, Options, OutputStyleOption,
};
use crate::options::{CommandOutputPolicy, ExecutorKind, Options, OutputStyleOption};
use crate::outlier_detection::OUTLIER_THRESHOLD;
use crate::output::console_writeln;
use crate::output::progress_bar::{
@@ -55,22 +53,24 @@ impl<'a> Benchmark<'a> {
}
}
/// Run setup, cleanup, or preparation commands
/// Run setup, cleanup, preparation, or conclusion commands
fn run_intermediate_command(
&self,
command: &Command<'_>,
error_output: &'static str,
kind: &'static str,
output_policy: &CommandOutputPolicy,
iteration: executor::BenchmarkIteration,
) -> Result<Measurement> {
self.executor
.run_command_and_measure(
command,
iteration,
Some(CmdFailureAction::RaiseError),
output_policy,
)
.map_err(|_| anyhow!(error_output))
.run_intermediate_command_and_measure(command, iteration, output_policy)
.map_err(|error| {
let hint = if cfg!(windows) {
"Append ' || exit /b 0' to the command if this failure can be ignored."
} else {
"Append ' || true' to the command if this failure can be ignored."
};
anyhow!("The {kind} command failed: {error:#} {hint}")
})
}
/// Run the command specified by `--setup`.
@@ -85,14 +85,11 @@ impl<'a> Benchmark<'a> {
.as_ref()
.map(|setup_command| Command::new_parametrized(None, setup_command, parameters));
let error_output = "The setup command terminated with a non-zero exit code. \
Append ' || true' to the command if you are sure that this can be ignored.";
Ok(command
.map(|cmd| {
self.run_intermediate_command(
&cmd,
error_output,
"setup",
output_policy,
BenchmarkIteration::NonBenchmarkRun,
)
@@ -113,14 +110,11 @@ impl<'a> Benchmark<'a> {
.as_ref()
.map(|cleanup_command| Command::new_parametrized(None, cleanup_command, parameters));
let error_output = "The cleanup command terminated with a non-zero exit code. \
Append ' || true' to the command if you are sure that this can be ignored.";
Ok(command
.map(|cmd| {
self.run_intermediate_command(
&cmd,
error_output,
"cleanup",
output_policy,
BenchmarkIteration::NonBenchmarkRun,
)
@@ -136,10 +130,7 @@ impl<'a> Benchmark<'a> {
output_policy: &CommandOutputPolicy,
iteration: executor::BenchmarkIteration,
) -> Result<Measurement> {
let error_output = "The preparation command terminated with a non-zero exit code. \
Append ' || true' to the command if you are sure that this can be ignored.";
self.run_intermediate_command(command, error_output, output_policy, iteration)
self.run_intermediate_command(command, "preparation", output_policy, iteration)
}
/// Run the command specified by `--conclude`.
@@ -149,10 +140,7 @@ impl<'a> Benchmark<'a> {
output_policy: &CommandOutputPolicy,
iteration: executor::BenchmarkIteration,
) -> Result<Measurement> {
let error_output = "The conclusion command terminated with a non-zero exit code. \
Append ' || true' to the command if you are sure that this can be ignored.";
self.run_intermediate_command(command, error_output, output_policy, iteration)
self.run_intermediate_command(command, "conclusion", output_policy, iteration)
}
fn validate_measurement(&self, measurement: &Measurement) -> Result<()> {
+6 -5
View File
@@ -236,14 +236,15 @@ fn build_command() -> Command {
.value_name("SHELL")
.overrides_with("shell")
.value_hint(ValueHint::CommandString)
.help("Set the shell to use for executing commands (default: none). This can be the \
name or the path to the shell executable, or a full command line \
.help("Set the shell to use for executing benchmarked commands, including warmup runs \
(default: none). This can be the name or the path to the shell executable, or a full command line \
like \"bash --norc\". It can also be set to \"default\" to explicitly select \
the platform shell (sh on Unix, cmd.exe on Windows). It can also be set to \
\"none\" to disable the shell. In this case, commands will be executed \
\"none\" to disable the shell. In this case, benchmarked commands will be executed \
directly. They can still have arguments, but more complex things like \
\"sleep 0.1; sleep 0.2\" are not possible without a shell. This option also \
applies to setup, prepare, conclude, and cleanup commands.")
\"sleep 0.1; sleep 0.2\" are not possible without a shell. Setup, prepare, conclude, \
and cleanup commands always use a shell: the selected shell, or the platform \
shell when this option is omitted or set to \"none\".")
)
.arg(
Arg::new("default-shell")
+24 -2
View File
@@ -11,9 +11,9 @@ use cli::get_cli_arguments;
use command::Commands;
use error::ConsoleOutputError;
use export::ExportManager;
use options::Options;
use options::{ExecutorKind, Options};
use anyhow::Result;
use anyhow::{bail, Result};
use colored::*;
pub mod benchmark;
@@ -27,6 +27,7 @@ pub mod outlier_detection;
pub mod output;
pub mod parameter;
pub mod quantity;
pub mod shell_syntax;
pub mod timer;
pub mod util;
@@ -40,6 +41,27 @@ fn run() -> Result<()> {
let commands = Commands::from_cli_arguments(&cli_arguments)?;
options.validate_against_command_list(&commands)?;
if matches!(options.executor_kind, ExecutorKind::Raw)
&& !cli_arguments.get_flag("no-shell")
&& cli_arguments.get_one::<String>("shell").is_none()
{
for command in commands.iter() {
let command_line = command.get_command_line();
if let Some(operator) = shell_syntax::first_unquoted_operator(&command_line) {
bail!(
concat!(
"Command '{command_line}' contains unquoted shell syntax ('{operator}').\n",
"Explicitly choose how to execute it:\n",
" -S / --shell=default Interpret shell syntax.\n",
" -N / --shell=none Run directly, passing '{operator}' as a literal argument.",
),
command_line = command_line,
operator = operator,
);
}
}
}
let export_manager = ExportManager::from_cli_arguments(&cli_arguments, options.metrics[0])?;
let mut scheduler = Scheduler::new(&commands, &options, &export_manager);
+218
View File
@@ -0,0 +1,218 @@
//! Conservative detection of shell operators in commands run without a shell.
use std::iter::Peekable;
use std::str::CharIndices;
/// Find the first standalone operator that is neither quoted nor escaped.
///
/// Operators attached to another word (such as `hello>out`), expansions, and
/// other shell syntax are deliberately outside the scope of this check.
pub fn first_unquoted_operator(command: &str) -> Option<&'static str> {
tokenize(command).find_map(|token| {
["&&", "||", "|", ";", ">", ">>", "<"]
.iter()
.copied()
.find(|operator| {
// An escaped newline joins lines without quoting either side.
// All other quotes and escapes remain in the token, preventing
// it from matching an operator.
token
.split("\\\n")
.flat_map(str::bytes)
.eq(operator.bytes())
})
})
}
/// Split words while preserving their original quoting and escaping.
///
/// Word boundaries and comments follow the same rules as `shell_words`, which
/// parses commands for direct execution. This tokenizer neither expands shell
/// expressions nor validates balanced quotes; command parsing handles errors.
fn tokenize(command: &str) -> impl Iterator<Item = &str> {
Tokens {
command,
chars: command.char_indices().peekable(),
}
}
struct Tokens<'a> {
command: &'a str,
chars: Peekable<CharIndices<'a>>,
}
impl<'a> Iterator for Tokens<'a> {
type Item = &'a str;
fn next(&mut self) -> Option<Self::Item> {
// Whitespace, comments, and line continuations cannot start a word.
let start = loop {
let &(index, character) = self.chars.peek()?;
match character {
' ' | '\t' | '\n' => {
self.chars.next();
}
'#' => {
for (_, character) in self.chars.by_ref() {
if character == '\n' {
break;
}
}
}
'\\' if self.chars.clone().nth(1).map(|(_, c)| c) == Some('\n') => {
self.chars.next();
self.chars.next();
}
_ => break index,
}
};
let mut quote = None;
while let Some((index, character)) = self.chars.next() {
match (quote, character) {
(Some('\''), '\'') | (Some('"'), '"') => quote = None,
(Some('\''), _) => {}
(_, '\\') => {
// Within double quotes, skipping a non-special escaped
// character is also safe: it cannot end the quoted word.
self.chars.next();
}
(Some('"'), _) => {}
(None, '\'' | '"') => quote = Some(character),
(None, ' ' | '\t' | '\n') => return Some(&self.command[start..index]),
_ => {}
}
}
Some(&self.command[start..])
}
}
#[cfg(test)]
mod tests {
use super::{first_unquoted_operator, tokenize};
#[test]
fn tokenizer_preserves_words_quotes_and_escapes() {
for (command, expected) in [
("", vec![]),
(" \t\n", vec![]),
(" echo\thello\nworld ", vec!["echo", "hello", "world"]),
(
r#"echo '' "" 'hello world'"#,
vec!["echo", "''", "\"\"", "'hello world'"],
),
(
r#"echo pre'quoted part'"more"post"#,
vec!["echo", r#"pre'quoted part'"more"post"#],
),
(
r"echo hello\ world \&\&",
vec!["echo", r"hello\ world", r"\&\&"],
),
(r#"echo "a\" b" 'a\'"#, vec!["echo", r#""a\" b""#, r"'a\'"]),
("echo hello>out", vec!["echo", "hello>out"]),
(
"echo café\t&&\n日本語",
vec!["echo", "café", "&&", "日本語"],
),
("echo a\r\u{a0}b", vec!["echo", "a\r\u{a0}b"]),
] {
assert_eq!(
tokenize(command).collect::<Vec<_>>(),
expected,
"{command:?}"
);
}
}
#[test]
fn tokenizer_handles_comments_and_continuations() {
for (command, expected) in [
("# ignored &&", vec![]),
(
"echo # ignored &&\nnext | cat",
vec!["echo", "next", "|", "cat"],
),
(
r##"echo a#b \#c "#d" '#e'"##,
vec!["echo", "a#b", r"\#c", "\"#d\"", "'#e'"],
),
("\\\n echo \\\n&& more", vec!["echo", "&&", "more"]),
("echo hello\\\nworld", vec!["echo", "hello\\\nworld"]),
("\\\n# comment\necho", vec!["echo"]),
] {
assert_eq!(
tokenize(command).collect::<Vec<_>>(),
expected,
"{command:?}"
);
}
}
#[test]
fn finds_supported_operators_in_command_order() {
for operator in ["&&", "||", "|", ";", ">", ">>", "<"] {
let command = format!("echo café\t{operator}\n日本語");
assert_eq!(first_unquoted_operator(&command), Some(operator));
assert_eq!(first_unquoted_operator(operator), Some(operator));
}
assert_eq!(first_unquoted_operator("echo a >> out && next"), Some(">>"));
assert_eq!(first_unquoted_operator("echo \\\n&& next"), Some("&&"));
assert_eq!(first_unquoted_operator("echo &\\\n& next"), Some("&&"));
assert_eq!(first_unquoted_operator("echo &&\\\n next"), Some("&&"));
assert_eq!(first_unquoted_operator("echo # &&\nnext | cat"), Some("|"));
assert_eq!(first_unquoted_operator(r#"echo "&&" || next"#), Some("||"));
assert_eq!(first_unquoted_operator(r"echo \&\& | cat"), Some("|"));
}
#[test]
fn ignores_quoted_escaped_and_attached_operators() {
for command in [
r#"echo "&&""#,
"echo '&&'",
r"echo \&\&",
r"echo &\&",
r#"echo '&'"&""#,
r#"echo ""&&"#,
"echo &&''",
r#"python -c "print(1 > 0)""#,
"sh -c 'true && false'",
r#"sh -c "echo 'a | b' && false""#,
r#"sh -c 'echo "a | b" && false'"#,
r#"echo "escaped \" && still quoted""#,
"echo hello>out",
"true&&false",
"echo a;",
"echo 2> out",
"echo >>> out",
"echo $VARIABLE *.rs ~/path NAME=value",
"echo # && ignored",
"echo foo#bar",
"echo\u{a0}&&",
"echo &&\r",
] {
assert_eq!(first_unquoted_operator(command), None, "{command:?}");
}
}
#[test]
fn handles_empty_and_malformed_commands_without_panicking() {
for (command, expected) in [
("", vec![]),
("\\\n", vec![]),
("\\", vec!["\\"]),
(r"echo \", vec!["echo", r"\"]),
("echo 'unclosed &&", vec!["echo", "'unclosed &&"]),
("echo \"unclosed &&", vec!["echo", "\"unclosed &&"]),
("echo \"unclosed\\", vec!["echo", "\"unclosed\\"]),
] {
assert_eq!(
tokenize(command).collect::<Vec<_>>(),
expected,
"{command:?}"
);
assert_eq!(first_unquoted_operator(command), None, "{command:?}");
}
}
}
+55 -19
View File
@@ -266,6 +266,42 @@ fn fails_with_duplicate_parameter_names() {
.stderr(predicate::str::contains("Duplicate parameter names: x"));
}
#[test]
fn requires_explicit_shell_for_shell_syntax() {
let directory = tempfile::tempdir().unwrap();
let assert = hyperfine()
.current_dir(directory.path())
.arg("rm -rf cache/ && ./my_command")
.assert()
.failure();
insta::assert_snapshot!(String::from_utf8_lossy(&assert.get_output().stderr), @"
Error: Command 'rm -rf cache/ && ./my_command' contains unquoted shell syntax ('&&').
Explicitly choose how to execute it:
-S / --shell=default Interpret shell syntax.
-N / --shell=none Run directly, passing '&&' as a literal argument.
");
hyperfine()
.arg("--show-output")
.arg("--command-name=benchmark")
.args(["--shell=default", "--runs=1", "echo before && echo after"])
.assert()
.success()
.stdout(predicate::str::contains("before"))
.stdout(predicate::str::contains("after"))
.stdout(predicate::str::contains("&&").not());
hyperfine()
.arg("--show-output")
.arg("--command-name=benchmark")
.args(["--shell=none", "--runs=1", "echo before && echo after"])
.assert()
.success()
.stdout(predicate::str::contains("before && echo after"));
}
#[test]
fn fails_for_unknown_command() {
hyperfine()
@@ -275,7 +311,7 @@ fn fails_for_unknown_command() {
.assert()
.failure()
.stderr(predicate::str::contains(
"Command terminated with non-zero exit code",
"Command 'some-nonexisting-program-b5d9574198b7e4b12a71fa4747c0a577' terminated with non-zero exit code",
));
}
@@ -298,12 +334,15 @@ fn fails_for_failing_command_without_shell() {
hyperfine()
.arg("--shell=none")
.arg("--runs=1")
.arg("false")
.arg("--command-name=named benchmark")
.args(["--parameter-list", "status", "0,1"])
.arg("test {status} -eq 0")
.assert()
.failure()
.stderr(predicate::str::contains(
"Command terminated with non-zero exit code",
));
"Command 'test 1 -eq 0' terminated with non-zero exit code 1 in the first benchmark run.",
))
.stderr(predicate::str::contains("--ignore-failure"));
}
#[test]
@@ -315,9 +354,7 @@ fn fails_for_unknown_setup_command() {
.arg("echo test")
.assert()
.failure()
.stderr(predicate::str::contains(
"The setup command terminated with a non-zero exit code.",
));
.stderr(predicate::str::contains("The setup command failed:"));
}
#[test]
@@ -329,9 +366,7 @@ fn fails_for_unknown_cleanup_command() {
.arg("echo test")
.assert()
.failure()
.stderr(predicate::str::contains(
"The cleanup command terminated with a non-zero exit code.",
));
.stderr(predicate::str::contains("The cleanup command failed:"));
}
#[test]
@@ -342,9 +377,7 @@ fn fails_for_unknown_prepare_command() {
.arg("echo test")
.assert()
.failure()
.stderr(predicate::str::contains(
"The preparation command terminated with a non-zero exit code.",
));
.stderr(predicate::str::contains("The preparation command failed:"));
}
#[test]
@@ -355,9 +388,7 @@ fn fails_for_unknown_conclude_command() {
.arg("echo test")
.assert()
.failure()
.stderr(predicate::str::contains(
"The conclusion command terminated with a non-zero exit code.",
));
.stderr(predicate::str::contains("The conclusion command failed:"));
}
#[cfg(unix)]
@@ -368,7 +399,7 @@ fn can_run_failing_commands_with_ignore_failure_option() {
.assert()
.failure()
.stderr(predicate::str::contains(
"Command terminated with non-zero exit code",
"Command 'false' terminated with non-zero exit code",
));
hyperfine()
@@ -400,7 +431,7 @@ fn can_ignore_specific_exit_codes() {
.assert()
.failure()
.stderr(predicate::str::contains(
"Command terminated with non-zero exit code 2",
"Command 'exit 2' terminated with non-zero exit code 2",
));
}
@@ -441,7 +472,7 @@ fn can_ignore_multiple_exit_codes() {
.assert()
.failure()
.stderr(predicate::str::contains(
"Command terminated with non-zero exit code 4",
"Command 'exit 4' terminated with non-zero exit code 4",
));
}
@@ -477,9 +508,14 @@ fn runs_commands_using_user_defined_shell() {
.arg("--show-output")
.arg("--shell")
.arg("echo 'custom_shell' '--shell-arg'")
.arg("--setup=echo setup")
.arg("--prepare=echo prepare")
.arg("--conclude=echo conclude")
.arg("--cleanup=echo cleanup")
.arg("echo benchmark")
.assert()
.success()
.stdout(predicate::str::contains("custom_shell --shell-arg").count(5))
.stdout(
predicate::str::contains("custom_shell --shell-arg -c echo benchmark").or(
predicate::str::contains("custom_shell --shell-arg /C echo benchmark"),