use predicates::prelude::PredicateBooleanExt;
fn alba() -> assert_cmd::Command {
assert_cmd::Command::cargo_bin("alba").unwrap()
}
fn project(beamfile: &str) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Beamfile"), beamfile).unwrap();
dir
}
#[test]
fn runs_dependency_chain_in_order() {
let dir = project(
"beam a { run \"echo alpha\" }\n\
beam b { needs [a] run \"echo bravo\" }\n",
);
let assert = alba()
.current_dir(&dir)
.args(["run", "b"])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let alpha = stdout
.find("alpha")
.unwrap_or_else(|| panic!("`a`'s output is missing from:\n{stdout}"));
let bravo = stdout
.find("bravo")
.unwrap_or_else(|| panic!("`b`'s output is missing from:\n{stdout}"));
assert!(
alpha < bravo,
"`a` must be reported before its dependent `b`, got:\n{stdout}"
);
}
#[test]
fn beam_failure_exits_1_and_cancels_dependents() {
let dir = project(
"beam boom { run \"exit 7\" }\n\
beam after { needs [boom] run \"echo reached\" }\n",
);
alba()
.current_dir(&dir)
.args(["run", "after"])
.assert()
.code(1)
.stdout(predicates::str::contains("reached").not())
.stderr(predicates::str::contains("1 failed"));
}
#[test]
fn allow_failure_exits_0() {
let dir = project("beam lint { allow_failure true run \"exit 1\" }\n");
alba()
.current_dir(&dir)
.args(["run", "lint"])
.assert()
.success()
.stdout(predicates::str::contains("failed (allowed)"));
}
#[test]
fn json_log_format_emits_one_json_object_per_line() {
let dir = project("beam a { run \"echo hi\" }\n");
let assert = alba()
.current_dir(&dir)
.args(["run", "a", "--log-format", "json"])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let mut kinds = Vec::new();
for line in stdout.lines() {
let value: serde_json::Value = serde_json::from_str(line)
.unwrap_or_else(|error| panic!("stdout line is not JSON ({error}): {line:?}"));
let kind = value["event"]
.as_str()
.unwrap_or_else(|| panic!("JSON line has no `event` field: {line:?}"));
kinds.push(kind.to_string());
}
for expected in [
"run_started",
"beam_started",
"beam_output",
"beam_finished",
"run_finished",
] {
assert!(
kinds.iter().any(|kind| kind == expected),
"missing `{expected}` among {kinds:?}"
);
}
assert_eq!(
kinds.first().map(String::as_str),
Some("run_started"),
"the stream opens on the run's shape, before any beam's own events"
);
let opening: serde_json::Value = serde_json::from_str(stdout.lines().next().unwrap()).unwrap();
assert_eq!(opening["targets"], serde_json::json!(["a"]));
assert!(opening.get("affected_by").is_none());
assert_eq!(opening["beams"], serde_json::json!(["a"]));
assert_eq!(opening["edges"], serde_json::json!([]));
}
#[test]
fn an_unchanged_run_is_cached_and_replays_its_output() {
let dir = project("beam gen { inputs [\"data.txt\"] run \"echo generated\" }\n");
std::fs::write(dir.path().join("data.txt"), "v1").unwrap();
let run = |extra: &[&str]| {
let mut args = vec!["run", "gen"];
args.extend_from_slice(extra);
let assert = alba().current_dir(&dir).args(args).assert().success();
String::from_utf8(assert.get_output().stdout.clone()).unwrap()
};
let first = run(&[]);
assert!(
!first.contains("cached"),
"a first run cannot be cached:\n{first}"
);
assert!(first.contains("generated"));
assert!(
dir.path().join(".alba/cache").is_dir(),
"the store must exist after a success"
);
let second = run(&[]);
assert!(
second.contains("cached"),
"unchanged inputs must hit:\n{second}"
);
assert!(
second.contains("generated"),
"the stored output must replay:\n{second}"
);
std::fs::write(dir.path().join("data.txt"), "v2").unwrap();
let third = run(&[]);
assert!(
!third.contains("cached"),
"a changed input must rerun:\n{third}"
);
let forced = run(&["--force"]);
assert!(
!forced.contains("cached"),
"--force must execute:\n{forced}"
);
}
#[test]
fn the_json_stream_marks_cached_beams_and_replayed_lines() {
let dir = project("beam gen { inputs [\"data.txt\"] run \"echo generated\" }\n");
std::fs::write(dir.path().join("data.txt"), "v1").unwrap();
let run_json = || {
let assert = alba()
.current_dir(&dir)
.args(["run", "gen", "--log-format", "json"])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
stdout
.lines()
.map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
.collect::<Vec<_>>()
};
let first = run_json();
assert!(
first
.iter()
.any(|event| event["event"] == "beam_output" && event["replayed"] == false)
);
let second = run_json();
assert!(second.iter().any(|event| event["event"] == "beam_cached"));
assert!(second.iter().any(|event| event["event"] == "beam_output"
&& event["replayed"] == true
&& event["text"] == "generated"));
let finished = second
.iter()
.find(|event| event["event"] == "run_finished")
.expect("the stream must end with run_finished");
assert_eq!(finished["cached"][0], "gen");
}
#[test]
fn a_template_that_fails_at_run_time_still_ends_the_json_stream() {
let dir = project(
"beam good { run \"echo ok\" }\n\
beam broken { run \"echo {env('ALBA_MISSING_VAR_XYZ')}\" }\n\
beam all { needs [good, broken] run \"echo all\" }\n",
);
let assert = alba()
.current_dir(&dir)
.args(["run", "all", "--keep-going", "--log-format", "json"])
.assert()
.code(1);
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let last: serde_json::Value = serde_json::from_str(
stdout
.lines()
.next_back()
.unwrap_or_else(|| panic!("stdout is empty: {stdout:?}")),
)
.unwrap();
assert_eq!(last["event"], "run_finished");
assert_eq!(last["failed"], serde_json::json!(["broken"]));
assert_eq!(last["succeeded"], serde_json::json!(["good"]));
let kinds: Vec<String> = stdout
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.filter(|value| value["beam"] == "broken")
.filter_map(|value| value["event"].as_str().map(str::to_string))
.collect();
assert_eq!(
kinds,
["beam_started", "beam_output", "beam_finished"],
"the beam that failed must report itself like any other"
);
}
#[test]
fn beam_params_are_interpolated() {
let dir = project("beam greet(name) { run \"echo '{name}'\" }\n");
alba()
.current_dir(&dir)
.args(["run", "greet", "hello world"])
.assert()
.success()
.stdout(predicates::str::contains("hello world"));
}
#[test]
fn unknown_beam_suggests_closest() {
let dir = project("beam build { run \"echo ok\" }\n");
alba()
.current_dir(&dir)
.args(["run", "biuld"])
.assert()
.code(2)
.stderr(predicates::str::contains("did you mean `build`?"))
.stderr(predicates::str::contains("Beamfile:").not());
}
#[test]
fn a_default_naming_an_unknown_beam_fails_check() {
let dir = project("default biuld\nbeam build { run \"echo ok\" }\n");
alba()
.current_dir(&dir)
.arg("check")
.assert()
.code(2)
.stderr(predicates::str::contains("unknown beam `biuld`"))
.stderr(predicates::str::contains("did you mean `build`?"));
}
#[test]
fn bare_alba_runs_the_default_beam() {
let dir = project("default build\nbeam build { run \"echo compiled\" }\n");
alba()
.current_dir(&dir)
.assert()
.success()
.stdout(predicates::str::contains("compiled"));
}
#[test]
fn a_beam_runs_on_the_embedded_shell_by_default() {
let dir = project("beam hello { run \"$(FOO=leaked); echo FOO=$FOO\" }\n");
alba()
.current_dir(&dir)
.args(["run", "hello"])
.assert()
.success()
.stdout(predicates::str::contains("FOO=leaked"));
}
#[test]
fn out_of_subset_syntax_fails_the_beam_with_the_diagnostic() {
let dir = project("beam bad { run \"for x in a; do echo $x; done\" }\n");
alba()
.current_dir(&dir)
.args(["run", "bad"])
.assert()
.code(1)
.stdout(predicates::str::contains("not supported"))
.stdout(predicates::str::contains("executor system_shell"));
}
#[test]
fn executor_system_shell_opts_a_beam_out() {
let dir = project("beam legacy { executor system_shell run \"echo sys\" }\n");
alba()
.current_dir(&dir)
.args(["run", "legacy"])
.assert()
.success()
.stdout(predicates::str::contains("sys"));
}
#[test]
fn jobs_zero_is_rejected() {
let dir = project("beam a { run \"echo ok\" }\n");
alba()
.current_dir(&dir)
.env("NO_COLOR", "1")
.args(["run", "a", "--jobs", "0"])
.assert()
.code(2)
.stderr(predicates::str::contains(
"invalid value '0' for '--jobs <N>'",
));
}
#[test]
fn run_without_a_beam_runs_the_default() {
let dir = project(
"default hello\n\
beam hello { run \"echo salut\" }\n",
);
alba()
.current_dir(&dir)
.args(["run"])
.assert()
.success()
.stdout(predicates::str::contains("salut"));
}
#[test]
fn run_without_a_beam_and_no_default_is_an_error() {
let dir = project("beam hello { run \"echo salut\" }\n");
alba()
.current_dir(&dir)
.args(["run"])
.assert()
.code(2)
.stderr(predicates::str::contains("default"));
}
mod layout {
use super::{alba, project};
#[test]
fn interleaved_prefixes_every_line_with_its_beam() {
let dir = project("beam a { run \"echo alpha\" }\n");
let assert = alba()
.current_dir(&dir)
.args(["run", "a", "--output", "interleaved"])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let lines: Vec<&str> = stdout.lines().collect();
assert!(
lines.contains(&"a \u{2502} started"),
"missing the start line in:\n{stdout}"
);
assert!(
lines.contains(&"a \u{2502} alpha"),
"missing the prefixed output line in:\n{stdout}"
);
assert!(
lines
.iter()
.any(|line| line.starts_with("a \u{2502} ok in ")),
"missing the status line in:\n{stdout}"
);
}
#[test]
fn grouped_frames_each_beam_with_a_header() {
let dir = project("beam a { run \"echo alpha\" }\n");
let assert = alba()
.current_dir(&dir)
.args(["run", "a", "--output", "grouped"])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let lines: Vec<&str> = stdout.lines().collect();
let header = lines
.iter()
.position(|line| line.starts_with("\u{2500}\u{2500} a ("))
.unwrap_or_else(|| panic!("no group header in:\n{stdout}"));
assert!(
lines[header].ends_with(", ok) \u{2500}\u{2500}"),
"header does not match `── a (<duration>, ok) ──`: {:?}",
lines[header]
);
assert_eq!(
lines.get(header + 1),
Some(&"alpha"),
"the beam's output must follow its header in:\n{stdout}"
);
}
#[test]
fn the_summary_reports_every_non_empty_bucket_on_stderr() {
let dir = project(
"beam good { run \"echo g\" }\n\
beam bad { needs [good] run \"exit 3\" }\n\
beam blocked { needs [bad] run \"echo never\" }\n",
);
let assert = alba()
.current_dir(&dir)
.args(["run", "blocked"])
.assert()
.code(1);
let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap();
let summary = stderr.lines().next_back().unwrap_or_default();
let expected =
"\u{2713} 1 succeeded \u{b7} \u{2717} 1 failed \u{b7} \u{2298} 1 cancelled \u{b7} ";
assert!(
summary.starts_with(expected),
"summary does not match `{expected}<duration>`: {summary:?}"
);
assert!(
summary.ends_with('s'),
"the summary must end with a duration: {summary:?}"
);
}
}
#[test]
fn a_closed_stdout_neither_panics_nor_changes_the_exit_code() {
for (mode, summarizes) in [
(["--output", "interleaved"], true),
(["--output", "grouped"], true),
(["--log-format", "json"], false),
] {
let dir = project("beam bad { run \"exit 7\" }\n");
let mut child = std::process::Command::new(assert_cmd::cargo::cargo_bin("alba"))
.current_dir(dir.path())
.args(["run", "bad"])
.args(mode)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
drop(child.stdout.take());
let output = child.wait_with_output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("panicked"),
"{mode:?}: a closed stdout must not panic a renderer:\n{stderr}"
);
assert_eq!(
output.status.code(),
Some(1),
"{mode:?}: the run's exit code must survive a closed stdout, stderr:\n{stderr}"
);
assert!(
!summarizes || stderr.contains("1 failed"),
"{mode:?}: the summary must still reach stderr:\n{stderr}"
);
}
}
#[cfg(unix)]
mod interrupts {
use std::io::{BufRead, BufReader};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use super::project;
const READY_TIMEOUT: Duration = Duration::from_secs(30);
fn spawn_running(dir: &tempfile::TempDir, beam: &str) -> Child {
let mut child = Command::new(assert_cmd::cargo::cargo_bin("alba"))
.current_dir(dir.path())
.args(["run", beam, "--output", "interleaved"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let stdout = child.stdout.take().expect("stdout was piped");
let (ready, is_ready) = mpsc::channel();
std::thread::spawn(move || {
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
if line.contains("ready") {
let _ = ready.send(());
}
}
});
is_ready
.recv_timeout(READY_TIMEOUT)
.expect("the beam never reported that it was running");
child
}
fn interrupt(child: &Child) {
let pid = nix::unistd::Pid::from_raw(child.id() as i32);
nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGINT).unwrap();
}
#[test]
fn first_interrupt_cancels_the_run_and_exits_130() {
let dir = project("beam slow { run \"echo ready; sleep 30\" }\n");
let child = spawn_running(&dir, "slow");
interrupt(&child);
let output = child.wait_with_output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("cancelling"),
"the first signal must announce the cancellation, got:\n{stderr}"
);
assert_eq!(
output.status.code(),
Some(130),
"an interrupted run must not report success, stderr:\n{stderr}"
);
}
const ABORT_DEADLINE: Duration = Duration::from_secs(3);
#[test]
fn second_interrupt_aborts_the_process() {
let dir = project(
"beam stubborn { executor system_shell run \"trap '' TERM; echo ready; i=0; while [ $i -lt 40 ]; do sleep 0.25; i=$((i+1)); done\" }\n",
);
let child = spawn_running(&dir, "stubborn");
interrupt(&child);
std::thread::sleep(Duration::from_millis(500));
let started_at = Instant::now();
interrupt(&child);
let output = child.wait_with_output().unwrap();
let elapsed = started_at.elapsed();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("aborting"),
"the second signal must say it gave up on the orderly path, got:\n{stderr}"
);
assert!(
elapsed < ABORT_DEADLINE,
"the second signal must not wait out the executor's grace period, took {elapsed:?}"
);
assert_eq!(
output.status.code(),
Some(130),
"the second signal must abort with the conventional SIGINT code, stderr:\n{stderr}"
);
}
}