use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use std::{io::Read, thread};
fn runner_binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_runner"))
}
fn fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
fn just_available() -> bool {
Command::new("just")
.arg("--version")
.output()
.is_ok_and(|o| o.status.success())
}
fn isolated_install_project() -> PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("runner-install-it-{}-{unique}", std::process::id()));
std::fs::create_dir_all(dir.join("src")).expect("create temp project dir");
std::fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"runner-install-it-fixture\"\nversion = \"0.0.0\"\nedition = \
\"2021\"\npublish = false\n\n[dependencies]\n",
)
.expect("write Cargo.toml");
std::fs::write(dir.join("src/lib.rs"), "").expect("write lib.rs");
std::fs::write(dir.join("justfile"), "build:\n\t@echo build-ran\n").expect("write justfile");
dir
}
#[test]
fn sequential_chain_runs_in_order() {
if !just_available() {
eprintln!("skipping: `just` not found on PATH");
return;
}
let output = Command::new(runner_binary())
.arg("--dir")
.arg(fixture("chain-sequential"))
.args(["run", "-s", "build", "test", "lint"])
.output()
.expect("runner binary spawns");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"expected success.\nstdout: {stdout}\nstderr: {stderr}",
);
let b = stdout
.find("build-ran")
.unwrap_or_else(|| panic!("build-ran missing.\nstdout: {stdout}"));
let t = stdout
.find("test-ran")
.unwrap_or_else(|| panic!("test-ran missing.\nstdout: {stdout}"));
let l = stdout
.find("lint-ran")
.unwrap_or_else(|| panic!("lint-ran missing.\nstdout: {stdout}"));
assert!(b < t && t < l, "order should match -s arg order: {stdout}");
}
#[test]
fn sequential_chain_emits_per_task_timing_on_stderr() {
if !just_available() {
eprintln!("skipping: `just` not found on PATH");
return;
}
let output = Command::new(runner_binary())
.arg("--dir")
.arg(fixture("chain-sequential"))
.args([
"run",
"-s",
"build",
"test",
])
.env_remove("GITHUB_ACTIONS")
.output()
.expect("runner binary spawns");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"expected success.\nstdout: {stdout}\nstderr: {stderr}",
);
assert_eq!(
stderr.matches("finished in").count(),
2,
"expected one timing line per sequential task. stderr: {stderr}",
);
assert!(
stderr.contains("(exit 0)"),
"expected exit code in timing line. stderr: {stderr}",
);
}
#[test]
fn streaming_parallel_chain_emits_per_task_timing_on_stderr() {
if !just_available() {
eprintln!("skipping: `just` not found on PATH");
return;
}
let output = Command::new(runner_binary())
.arg("--dir")
.arg(fixture("chain-parallel-fail"))
.args(["run", "-p", "ok-one", "fail-mid", "ok-two"])
.output()
.expect("runner binary spawns");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(
output.status.code(),
Some(7),
"expected exit 7 from fail-mid.\nstdout: {stdout}\nstderr: {stderr}",
);
assert_eq!(
stderr.matches("finished in").count(),
3,
"expected one timing line per streaming task. stderr: {stderr}",
);
assert!(
stderr.contains("(exit 7)"),
"fail-mid's non-zero exit should surface in its timing line. stderr: {stderr}",
);
}
#[cfg(unix)]
#[test]
fn streaming_parallel_chain_returns_despite_stdio_holding_descendant() {
if !just_available() {
eprintln!("skipping: `just` not found on PATH");
return;
}
let started = Instant::now();
let output = Command::new(runner_binary())
.arg("--dir")
.arg(fixture("chain-parallel-fail"))
.args(["run", "-p", "ok-one", "daemon"])
.output()
.expect("runner binary spawns");
let elapsed = started.elapsed();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"both tasks exit 0.\nstdout: {stdout}\nstderr: {stderr}",
);
assert!(
elapsed < Duration::from_secs(15),
"streaming `-p` must not block on a descendant holding stdio; took {elapsed:?}",
);
}
#[test]
fn parallel_install_chain_times_the_install_step() {
if !just_available() {
eprintln!("skipping: `just` not found on PATH");
return;
}
let project = isolated_install_project();
let output = Command::new(runner_binary())
.arg("--dir")
.arg(&project)
.args(["install", "-p", "build"])
.env_remove("GITHUB_ACTIONS")
.output()
.expect("runner binary spawns");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let _ = std::fs::remove_dir_all(&project);
assert!(
output.status.success(),
"expected success.\nstdout: {stdout}\nstderr: {stderr}",
);
assert!(
stderr
.lines()
.any(|line| line.contains("install") && line.contains("finished in")),
"parallel install step must emit its own timing line. stderr: {stderr}",
);
assert!(
stdout.contains("build-ran"),
"post-install task should run. stdout: {stdout}",
);
assert!(
stderr.matches("finished in").count() >= 2,
"expected timing for both the install step and the task. stderr: {stderr}",
);
}
#[test]
fn grouped_parallel_chain_folds_timing_into_block_footer() {
if !just_available() {
eprintln!("skipping: `just` not found on PATH");
return;
}
let output = Command::new(runner_binary())
.arg("--dir")
.arg(fixture("parallel-grouped"))
.args(["run", "-p", "build", "test"])
.env_remove("GITHUB_ACTIONS")
.output()
.expect("runner binary spawns");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"expected success. stdout: {stdout}"
);
assert_eq!(
stdout.matches("finished in").count(),
2,
"expected one footer per grouped task on stdout. stdout: {stdout}",
);
assert!(
stdout.contains("(exit 0)"),
"grouped footer should carry the exit code. stdout: {stdout}",
);
}
#[test]
fn grouped_parallel_chain_folds_timing_into_group_footer_under_actions() {
if !just_available() {
eprintln!("skipping: `just` not found on PATH");
return;
}
let output = Command::new(runner_binary())
.arg("--dir")
.arg(fixture("parallel-grouped"))
.args(["run", "-p", "build", "test"])
.env("GITHUB_ACTIONS", "true")
.output()
.expect("runner binary spawns");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"expected success. stdout: {stdout}"
);
assert_eq!(
stdout.matches("finished in").count(),
2,
"expected one folded footer per grouped task on stdout. stdout: {stdout}",
);
assert!(
stdout.contains("(exit 0)"),
"grouped GHA footer should carry the exit code. stdout: {stdout}",
);
let groups: Vec<usize> = stdout
.match_indices("::group::runner: ")
.map(|(i, _)| i)
.collect();
let ends: Vec<usize> = stdout
.match_indices("::endgroup::")
.map(|(i, _)| i)
.collect();
assert_eq!(
groups.len(),
2,
"expected exactly two groups. stdout: {stdout}"
);
assert_eq!(
ends.len(),
2,
"expected exactly two endgroups. stdout: {stdout}",
);
for (start, end) in groups.iter().zip(ends.iter()) {
assert!(
start < end,
"each group must open before it closes. stdout: {stdout}",
);
let block = &stdout[*start..*end];
assert!(
block.contains("finished in") && block.contains("(exit 0)"),
"timing footer must sit inside the group block, before ::endgroup::. block: {block}",
);
}
}
#[test]
fn quiet_suppresses_chain_timing() {
if !just_available() {
eprintln!("skipping: `just` not found on PATH");
return;
}
let output = Command::new(runner_binary())
.arg("--dir")
.arg(fixture("chain-sequential"))
.args(["run", "-s", "build", "test"])
.env("RUNNER_QUIET", "1")
.env_remove("GITHUB_ACTIONS")
.output()
.expect("runner binary spawns");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"expected success.\nstdout: {stdout}\nstderr: {stderr}",
);
assert_eq!(
stderr.matches("finished in").count(),
0,
"--quiet must suppress timing lines. stderr: {stderr}",
);
assert!(
stdout.contains("build-ran") && stdout.contains("test-ran"),
"tasks should still run under --quiet. stdout: {stdout}",
);
}
#[test]
fn parallel_chain_exit_code_reflects_first_failure() {
if !just_available() {
eprintln!("skipping: `just` not found on PATH");
return;
}
let output = Command::new(runner_binary())
.arg("--dir")
.arg(fixture("chain-parallel-fail"))
.args(["run", "-p", "ok-one", "fail-mid", "ok-two"])
.output()
.expect("runner binary spawns");
assert_eq!(
output.status.code(),
Some(7),
"expected exit 7 from fail-mid task.\nstderr: {}",
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("[ok-one"),
"expected `[ok-one ]` prefix on ok-one's output. stdout: {stdout}",
);
assert!(
stdout.contains("[fail-mid"),
"expected `[fail-mid]` prefix on fail-mid's output. stdout: {stdout}",
);
}
#[test]
fn chain_rejects_mutually_exclusive_mode_flags() {
let output = Command::new(runner_binary())
.arg("--dir")
.arg(fixture("chain-sequential"))
.args(["run", "-s", "-p", "build"])
.output()
.expect("runner binary spawns");
assert!(
!output.status.success(),
"expected clap to reject -s + -p combo",
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("--sequential") || stderr.contains("--parallel"),
"expected clap conflict diagnostic. stderr: {stderr}",
);
}
#[test]
fn chain_rejects_whitespace_positional_in_v1() {
let output = Command::new(runner_binary())
.arg("--dir")
.arg(fixture("chain-sequential"))
.args(["run", "-s", "build --release"])
.output()
.expect("runner binary spawns");
assert!(
!output.status.success(),
"v1 rejects whitespace positionals",
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("whitespace") || stderr.contains("quoted-bundle"),
"expected v1 rejection diagnostic. stderr: {stderr}",
);
}
#[test]
fn install_completion_includes_tasks_and_options() {
if !just_available() {
eprintln!("skipping: `just` not found on PATH");
return;
}
let output = Command::new(runner_binary())
.env("COMPLETE", "zsh")
.env("_CLAP_COMPLETE_INDEX", "4")
.args(["--", "runner", "--dir"])
.arg(fixture("chain-sequential"))
.args(["install", ""])
.output()
.expect("runner binary spawns");
assert!(
output.status.success(),
"completion should succeed. stderr: {}",
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("just\x1fbuild"),
"install completion should include task candidates. stdout: {stdout}",
);
assert!(
stdout.contains("Options\x1f--frozen"),
"install completion should keep option candidates. stdout: {stdout}",
);
}
#[test]
fn chain_prevalidates_all_tokens_before_running_any_task() {
if !just_available() {
eprintln!("skipping: `just` not found on PATH");
return;
}
let output = Command::new(runner_binary())
.arg("--dir")
.arg(fixture("chain-sequential"))
.args(["run", "-s", "build", "test", "lint:cargo"])
.output()
.expect("runner binary spawns");
assert!(
!output.status.success(),
"chain with reversed-qualifier item must fail",
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("cargo:lint"),
"expected `did you mean cargo:lint?` hint in stderr: {stderr}",
);
assert!(
!stdout.contains("build-ran"),
"pre-validation should have skipped `build`. stdout: {stdout}",
);
assert!(
!stdout.contains("test-ran"),
"pre-validation should have skipped `test`. stdout: {stdout}",
);
}
#[test]
fn sequential_chain_wraps_steps_in_github_actions_groups() {
if !just_available() {
eprintln!("skipping: `just` not found on PATH");
return;
}
let output = Command::new(runner_binary())
.arg("--dir")
.arg(fixture("chain-sequential"))
.args(["run", "-s", "build", "test"])
.env("GITHUB_ACTIONS", "true")
.output()
.expect("runner binary spawns");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"expected success. stdout: {stdout}"
);
let g_build = stdout
.find("::group::runner: build")
.unwrap_or_else(|| panic!("missing build group. stdout: {stdout}"));
let end_build = g_build
+ stdout[g_build..]
.find("::endgroup::")
.unwrap_or_else(|| panic!("build group not closed. stdout: {stdout}"));
let g_test = stdout
.find("::group::runner: test")
.unwrap_or_else(|| panic!("missing test group. stdout: {stdout}"));
let build_ran = stdout
.find("build-ran")
.unwrap_or_else(|| panic!("build-ran missing. stdout: {stdout}"));
assert!(
g_build < build_ran && build_ran < end_build && end_build < g_test,
"expected build group to open, contain build-ran, close, then test group. stdout: {stdout}",
);
assert_eq!(
stdout.matches("::group::runner: ").count(),
2,
"expected exactly two groups. stdout: {stdout}",
);
assert_eq!(
stdout.matches("::endgroup::").count(),
2,
"expected exactly two endgroups. stdout: {stdout}",
);
}
#[test]
fn single_task_is_grouped_under_github_actions() {
if !just_available() {
eprintln!("skipping: `just` not found on PATH");
return;
}
let output = Command::new(runner_binary())
.arg("--dir")
.arg(fixture("chain-sequential"))
.args(["run", "build"])
.env("GITHUB_ACTIONS", "true")
.output()
.expect("runner binary spawns");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"expected success. stdout: {stdout}"
);
assert!(
stdout.contains("::group::runner: build"),
"single task should be wrapped in a group. stdout: {stdout}",
);
assert_eq!(
stdout.matches("::group::").count(),
1,
"exactly one group for a single task. stdout: {stdout}",
);
}
#[test]
fn no_groups_emitted_outside_github_actions() {
if !just_available() {
eprintln!("skipping: `just` not found on PATH");
return;
}
let output = Command::new(runner_binary())
.arg("--dir")
.arg(fixture("chain-sequential"))
.args(["run", "-s", "build", "test"])
.env_remove("GITHUB_ACTIONS")
.output()
.expect("runner binary spawns");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"expected success. stdout: {stdout}"
);
assert!(
!stdout.contains("::group::"),
"no GHA groups in a normal terminal. stdout: {stdout}",
);
assert!(
!stdout.contains("::endgroup::"),
"no GHA endgroups in a normal terminal. stdout: {stdout}",
);
}
#[test]
fn config_opt_out_disables_grouping_under_github_actions() {
if !just_available() {
eprintln!("skipping: `just` not found on PATH");
return;
}
let output = Command::new(runner_binary())
.arg("--dir")
.arg(fixture("github-no-group"))
.args(["run", "build"])
.env("GITHUB_ACTIONS", "true")
.output()
.expect("runner binary spawns");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"expected success. stdout: {stdout}"
);
assert!(
!stdout.contains("::group::"),
"config opt-out must suppress groups. stdout: {stdout}",
);
}
#[test]
fn github_group_output_false_restores_live_parallel_muxer() {
if !just_available() {
eprintln!("skipping: `just` not found on PATH");
return;
}
let output = Command::new(runner_binary())
.arg("--dir")
.arg(fixture("github-no-group"))
.args(["run", "-p", "build", "test"])
.env("GITHUB_ACTIONS", "true")
.output()
.expect("runner binary spawns");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"expected success. stdout: {stdout}"
);
assert!(
stdout.contains("[build") && stdout.contains("[test"),
"GHA group_output=false should restore live prefixes. stdout: {stdout}",
);
assert!(
!stdout.contains("::group::") && !stdout.contains("runner: build"),
"GHA group_output=false should not emit grouped blocks. stdout: {stdout}",
);
}
#[test]
fn parallel_chain_grouped_under_github_actions() {
if !just_available() {
eprintln!("skipping: `just` not found on PATH");
return;
}
let output = Command::new(runner_binary())
.arg("--dir")
.arg(fixture("chain-sequential"))
.args(["run", "-p", "build", "test"])
.env("GITHUB_ACTIONS", "true")
.output()
.expect("runner binary spawns");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"expected success. stdout: {stdout}"
);
assert!(
!stdout.contains("[build"),
"grouped parallel output must not use live prefixes. stdout: {stdout}",
);
let g_build = stdout
.find("::group::runner: build")
.unwrap_or_else(|| panic!("missing build group. stdout: {stdout}"));
let build_ran = stdout
.find("build-ran")
.unwrap_or_else(|| panic!("build-ran missing. stdout: {stdout}"));
assert!(
g_build < build_ran,
"build output must sit inside build's group. stdout: {stdout}",
);
assert!(
stdout.contains("::group::runner: test"),
"missing test group. stdout: {stdout}",
);
assert_eq!(
stdout.matches("::group::runner: ").count(),
2,
"expected exactly two groups. stdout: {stdout}",
);
assert_eq!(
stdout.matches("::endgroup::").count(),
2,
"expected exactly two endgroups. stdout: {stdout}",
);
}
#[test]
fn parallel_chain_grouped_with_plain_headers_outside_github_actions() {
if !just_available() {
eprintln!("skipping: `just` not found on PATH");
return;
}
let output = Command::new(runner_binary())
.arg("--dir")
.arg(fixture("parallel-grouped"))
.args(["run", "-p", "build", "test"])
.env_remove("GITHUB_ACTIONS")
.output()
.expect("runner binary spawns");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"expected success. stdout: {stdout}"
);
assert!(
!stdout.contains("::group::") && !stdout.contains("::endgroup::"),
"no workflow-command syntax outside GitHub Actions. stdout: {stdout}",
);
assert!(
!stdout.contains("[build"),
"grouped parallel output must not use live prefixes. stdout: {stdout}",
);
let h_build = stdout
.find("runner: build")
.unwrap_or_else(|| panic!("missing build header. stdout: {stdout}"));
let build_ran = stdout
.find("build-ran")
.unwrap_or_else(|| panic!("build-ran missing. stdout: {stdout}"));
assert!(
h_build < build_ran,
"build output must follow its header. stdout: {stdout}",
);
assert!(
stdout.contains("runner: test"),
"missing test header. stdout: {stdout}",
);
}
#[test]
fn parallel_grouped_preserves_child_stderr_stream() {
if !just_available() {
eprintln!("skipping: `just` not found on PATH");
return;
}
let output = Command::new(runner_binary())
.arg("--dir")
.arg(fixture("parallel-grouped"))
.args(["run", "-p", "build", "err"])
.env_remove("GITHUB_ACTIONS")
.output()
.expect("runner binary spawns");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"expected success. stdout: {stdout}; stderr: {stderr}"
);
assert!(
stderr.contains("err-ran"),
"child stderr must stay on stderr. stderr: {stderr}",
);
assert!(
!stdout.contains("err-ran"),
"child stderr must not be replayed to stdout. stdout: {stdout}",
);
}
#[test]
fn parallel_grouped_does_not_wait_forever_on_inherited_stdout() {
if !just_available() {
eprintln!("skipping: `just` not found on PATH");
return;
}
let mut child = Command::new(runner_binary())
.arg("--dir")
.arg(fixture("parallel-grouped"))
.args(["run", "-p", "hold-open", "build"])
.env_remove("GITHUB_ACTIONS")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("runner binary spawns");
let started = Instant::now();
let status = loop {
if let Some(status) = child.try_wait().expect("runner status checks") {
break status;
}
if started.elapsed() > Duration::from_secs(2) {
let _ = child.kill();
let _ = child.wait();
panic!("grouped parallel waited on inherited stdout for too long");
}
thread::sleep(Duration::from_millis(50));
};
let mut stdout = String::new();
if let Some(mut pipe) = child.stdout.take() {
pipe.read_to_string(&mut stdout)
.expect("stdout should be readable");
}
let mut stderr = String::new();
if let Some(mut pipe) = child.stderr.take() {
pipe.read_to_string(&mut stderr)
.expect("stderr should be readable");
}
assert!(status.success(), "stdout: {stdout}; stderr: {stderr}");
assert!(
stdout.contains("foreground-ran"),
"completed task output should still flush. stdout: {stdout}",
);
}