use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{Duration, Instant};
struct Harness {
root: PathBuf,
}
impl Harness {
fn new(name: &str, config: &str) -> Self {
let root = std::env::temp_dir().join(format!(
"qx{}-{}-{}",
std::process::id(),
name,
Instant::now().elapsed().subsec_nanos()
));
std::fs::create_dir_all(root.join("cfg")).unwrap();
std::fs::create_dir_all(root.join("state")).unwrap();
std::fs::create_dir_all(root.join("run")).unwrap();
std::fs::write(root.join("cfg/qex.toml"), config).unwrap();
Self { root }
}
fn with_default_config(name: &str) -> Self {
Self::new(
name,
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
)
}
fn qex(&self, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_qex"))
.args(args)
.env("XDG_CONFIG_HOME", self.root.join("cfg"))
.env("XDG_STATE_HOME", self.root.join("state"))
.env("XDG_RUNTIME_DIR", self.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.output()
.expect("qex did not start")
}
fn ok(&self, args: &[&str]) -> String {
let out = self.qex(args);
assert!(
out.status.success(),
"the command `qex {}` failed with the code {:?}\nstdout: {}\nstderr: {}",
args.join(" "),
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn submit(&self, args: &[&str]) -> String {
let id = self.ok(args);
assert_eq!(id.lines().count(), 1, "submit must write the id only: {id}");
assert!(
id.parse::<uuid::Uuid>().is_ok(),
"submit must write a job id, and it wrote: {id}"
);
id
}
fn status_json(&self, id: &str) -> serde_json::Value {
let text = self.ok(&["status", id, "--json"]);
serde_json::from_str(&text).expect("the status output is not valid JSON")
}
fn list_json(&self) -> Vec<serde_json::Value> {
let text = self.ok(&["list", "--json"]);
serde_json::from_str(&text).expect("the list output is not valid JSON")
}
fn state_of(&self, id: &str) -> String {
self.status_json(id)["state"].as_str().unwrap().to_string()
}
fn until(&self, what: &str, limit: Duration, mut test: impl FnMut() -> bool) {
let deadline = Instant::now() + limit;
while Instant::now() < deadline {
if test() {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
panic!("qex did not reach this condition in {limit:?}: {what}");
}
fn coordinator_pid(&self) -> i32 {
let text = self.ok(&["info", "--json"]);
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
v["pid"].as_i64().unwrap() as i32
}
fn job_dir(&self, id: &str) -> PathBuf {
self.root.join("state/qex/jobs").join(id)
}
}
impl Drop for Harness {
fn drop(&mut self) {
let out = self.qex(&["info", "--json"]);
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&out.stdout) {
if let Some(pid) = v["pid"].as_i64() {
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
}
}
std::fs::remove_dir_all(&self.root).ok();
}
}
#[test]
fn a_job_that_succeeds_gives_the_exit_code_zero() {
let h = Harness::with_default_config("ok");
let id = h.submit(&["submit", "--", "true"]);
let out = h.qex(&["wait", &id]);
assert_eq!(out.status.code(), Some(0));
assert_eq!(h.state_of(&id), "completed");
}
#[test]
fn a_job_that_fails_gives_the_exit_code_one() {
let h = Harness::with_default_config("fail");
let id = h.submit(&["submit", "--", "false"]);
let out = h.qex(&["wait", &id]);
assert_eq!(out.status.code(), Some(1));
let status = h.status_json(&id);
assert_eq!(status["state"], "failed");
assert_eq!(status["exit_code"], 1);
}
#[test]
fn the_passthrough_option_gives_the_exit_code_of_the_job() {
let h = Harness::with_default_config("pass");
let id = h.submit(&["submit", "--", "sh", "-c", "exit 42"]);
let out = h.qex(&["wait", &id, "--passthrough"]);
assert_eq!(out.status.code(), Some(42));
}
#[test]
fn a_wait_that_reaches_its_limit_gives_the_code_124() {
let h = Harness::with_default_config("waitlimit");
let id = h.submit(&["submit", "--", "sleep", "30"]);
let out = h.qex(&["wait", &id, "--timeout", "2s"]);
assert_eq!(out.status.code(), Some(124));
assert_eq!(
h.state_of(&id),
"running",
"a wait that reaches its limit must not stop the job"
);
h.ok(&["kill", &id, "--grace", "1s"]);
}
#[test]
fn a_missing_job_gives_the_code_127() {
let h = Harness::with_default_config("missing");
let out = h.qex(&["wait", "3f5a1c2e-0000-4000-8000-000000000000"]);
assert_eq!(out.status.code(), Some(127));
}
#[test]
fn the_output_of_a_job_is_recorded() {
let h = Harness::with_default_config("logs");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"echo to-stdout; echo to-stderr >&2",
]);
h.ok(&["wait", &id]);
let both = h.ok(&["logs", &id]);
assert!(both.contains("to-stdout"), "the standard output is missing");
assert!(both.contains("to-stderr"), "the standard error is missing");
let out_only = h.ok(&["logs", &id, "--stdout"]);
assert!(out_only.contains("to-stdout"));
assert!(!out_only.contains("to-stderr"), "the streams are mixed");
}
#[test]
fn many_submissions_at_once_start_one_coordinator_only() {
let h = Harness::with_default_config("race");
let exe = env!("CARGO_BIN_EXE_qex");
let mut children = Vec::new();
for _ in 0..20 {
children.push(
Command::new(exe)
.args(["submit", "--", "true"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.spawn()
.expect("qex did not start"),
);
}
for mut c in children {
let status = c.wait().unwrap();
assert!(status.success(), "one submission failed during the race");
}
assert_eq!(h.list_json().len(), 20, "qex lost a job during the race");
let dirs = std::fs::read_dir(h.root.join("state/qex/jobs"))
.unwrap()
.count();
assert_eq!(dirs, 20);
}
#[test]
fn the_budget_limits_the_jobs_that_operate_together() {
let h = Harness::new(
"budget",
"[budget]\ncpu = \"4\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
for _ in 0..3 {
h.submit(&["submit", "--cpu", "2", "--mem", "128MB", "--", "sleep", "3"]);
}
let mut peak = 0;
let deadline = Instant::now() + Duration::from_secs(4);
while Instant::now() < deadline {
let running = h
.list_json()
.iter()
.filter(|j| j["state"] == "running")
.count();
peak = peak.max(running);
std::thread::sleep(Duration::from_millis(100));
}
assert!(peak > 0, "no job ever started");
assert!(
peak <= 2,
"the budget of 4 cores must hold two jobs of 2 cores, and {peak} jobs operated together"
);
}
#[test]
fn a_job_that_is_too_large_runs_when_the_queue_is_empty() {
let h = Harness::new(
"oversized",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\noversized = \"run-when-idle\"\nsettle = \"1s\"\n",
);
let small = h.submit(&["submit", "--cpu", "2", "--mem", "128MB", "--", "sleep", "3"]);
h.until("the small job starts", Duration::from_secs(45), || {
h.state_of(&small) == "running"
});
let out = h.qex(&[
"submit", "--cpu", "64", "--mem", "64GB", "--", "echo", "big",
]);
assert!(out.status.success());
let big = String::from_utf8_lossy(&out.stdout).trim().to_string();
let warning = String::from_utf8_lossy(&out.stderr);
assert!(
warning.contains("64 cores") && warning.contains("budget"),
"qex must warn at the submission: {warning}"
);
assert!(
big.parse::<uuid::Uuid>().is_ok(),
"stdout must hold the id only"
);
assert_eq!(h.state_of(&big), "queued");
let reason = h.status_json(&big)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(!reason.is_empty(), "qex must give the reason for the wait");
h.until("the large job stops", Duration::from_secs(30), || {
h.state_of(&big) == "completed"
});
let status = h.status_json(&big);
assert_eq!(status["forced"], true, "qex must mark a forced job");
assert!(
status["forced_reason"]
.as_str()
.unwrap_or("")
.contains("budget"),
"the reason must name the budget"
);
assert!(h.ok(&["logs", &big]).contains("big"), "the job did not run");
}
#[test]
fn the_reject_policy_refuses_a_job_that_is_too_large() {
let h = Harness::new(
"reject",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[queue]\noversized = \"reject\"\n",
);
let out = h.qex(&["submit", "--cpu", "64", "--", "true"]);
assert!(!out.status.success(), "qex must refuse this job");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("64 cores"),
"the error must name the claim: {err}"
);
}
#[test]
fn a_kill_stops_every_process_of_a_job() {
let h = Harness::with_default_config("kill");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"sleep 60 & sleep 60 & sleep 60 & wait",
]);
h.until("the job starts", Duration::from_secs(45), || {
h.state_of(&id) == "running"
});
let pid = h.status_json(&id)["pid"].as_i64().unwrap() as i32;
assert!(
count_in_group(pid) >= 2,
"the job did not start its children"
);
h.ok(&["kill", &id, "--grace", "1s"]);
h.until(
"every process of the job stops",
Duration::from_secs(20),
|| count_in_group(pid) == 0,
);
h.until(
"the record shows the job stopped",
Duration::from_secs(20),
|| h.state_of(&id) == "killed",
);
}
#[cfg(target_os = "linux")]
fn count_in_group(pgid: i32) -> usize {
let Ok(entries) = std::fs::read_dir("/proc") else {
return 0;
};
let mut count = 0;
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let Ok(pid) = name.parse::<i32>() else {
continue;
};
if unsafe { libc::getpgid(pid) } == pgid {
count += 1;
}
}
count
}
#[cfg(not(target_os = "linux"))]
fn count_in_group(pgid: i32) -> usize {
let Ok(out) = std::process::Command::new("ps")
.args(["-A", "-o", "pgid="])
.output()
else {
return 0;
};
String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|line| line.trim().parse::<i32>() == Ok(pgid))
.count()
}
#[test]
fn a_job_survives_the_failure_of_the_coordinator() {
let h = Harness::with_default_config("crash");
let id = h.submit(&["submit", "--", "sh", "-c", "sleep 3; echo survived; exit 7"]);
h.until("the job starts", Duration::from_secs(45), || {
h.state_of(&id) == "running"
});
let pid = h.coordinator_pid();
unsafe {
libc::kill(pid, libc::SIGKILL);
}
std::thread::sleep(Duration::from_millis(300));
assert!(
unsafe { libc::kill(pid, 0) } != 0,
"the coordinator did not stop"
);
let out = h.qex(&["wait", &id, "--timeout", "30s"]);
assert_eq!(out.status.code(), Some(1), "the job exits with the code 7");
let status = h.status_json(&id);
assert_eq!(status["state"], "failed");
assert_eq!(status["exit_code"], 7);
assert!(h.ok(&["logs", &id]).contains("survived"));
}
#[test]
fn a_job_that_reaches_its_time_limit_has_the_state_timeout() {
let h = Harness::with_default_config("jobtimeout");
let id = h.submit(&["submit", "--timeout", "1s", "--", "sleep", "60"]);
h.until("the job stops", Duration::from_secs(30), || {
h.state_of(&id) == "timeout"
});
}
#[test]
fn a_job_receives_the_environment_and_the_directory_of_the_shell() {
let h = Harness::with_default_config("env");
let dir = h.root.join("workdir");
std::fs::create_dir_all(&dir).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["submit", "--", "sh", "-c", "pwd; echo MARK=$QEX_TEST_MARK"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.env("QEX_TEST_MARK", "captured")
.current_dir(&dir)
.output()
.unwrap();
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
h.ok(&["wait", &id]);
let logs = h.ok(&["logs", &id]);
assert!(
logs.contains("MARK=captured"),
"the job must receive the environment of the shell: {logs}"
);
assert!(
logs.contains(dir.canonicalize().unwrap().to_str().unwrap()),
"the job must operate in the directory of the shell: {logs}"
);
}
#[test]
fn the_environment_mode_none_removes_the_variables_of_the_shell() {
let h = Harness::with_default_config("envnone");
let out = Command::new(env!("CARGO_BIN_EXE_qex"))
.args([
"submit",
"--no-env-capture",
"--env",
"KEPT=yes",
"--",
"sh",
"-c",
"echo MARK=$QEX_TEST_MARK KEPT=$KEPT",
])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.env("QEX_TEST_MARK", "leaked")
.output()
.unwrap();
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
h.ok(&["wait", &id]);
let logs = h.ok(&["logs", &id]);
assert!(
logs.contains("MARK= "),
"a variable of the shell leaked: {logs}"
);
assert!(
logs.contains("KEPT=yes"),
"the --env value is missing: {logs}"
);
}
#[test]
fn the_job_files_are_private() {
use std::os::unix::fs::PermissionsExt;
let h = Harness::with_default_config("modes");
let id = h.submit(&["submit", "--", "true"]);
h.ok(&["wait", &id]);
let dir = h.job_dir(&id);
let dir_mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
assert_eq!(dir_mode, 0o700, "the job directory must be private");
let spec_mode = std::fs::metadata(dir.join("spec.json"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(spec_mode, 0o600, "the job specification must be private");
let status = h.ok(&["status", &id, "--json"]);
assert!(
!status.contains("\"env\""),
"the status output must hide the environment: {status}"
);
}
#[test]
fn a_command_line_that_holds_the_word_qex_does_not_confuse_qex() {
let h = Harness::with_default_config("selfmatch");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"echo pretending to be qex daemon supervise; sleep 30",
]);
h.until("the job starts", Duration::from_secs(45), || {
h.state_of(&id) == "running"
});
let coordinator = h.coordinator_pid();
let job_pid = h.status_json(&id)["pid"].as_i64().unwrap() as i32;
assert_ne!(coordinator, job_pid);
h.ok(&["kill", &id, "--grace", "1s"]);
h.until("the job stops", Duration::from_secs(20), || {
h.state_of(&id) == "killed"
});
assert_eq!(h.coordinator_pid(), coordinator);
}
#[test]
fn cancel_removes_a_job_from_the_queue() {
let h = Harness::new(
"cancel",
"[budget]\ncpu = \"1\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let first = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "5"]);
h.until("the first job starts", Duration::from_secs(45), || {
h.state_of(&first) == "running"
});
let second = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "5"]);
assert_eq!(h.state_of(&second), "queued");
h.ok(&["cancel", &second]);
assert_eq!(h.state_of(&second), "cancelled");
let out = h.qex(&["cancel", &first]);
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("kill"),
"the error must give the correct command: {err}"
);
h.ok(&["kill", &first, "--grace", "1s"]);
}
#[test]
fn clean_deletes_the_record_of_a_job_that_stopped() {
let h = Harness::with_default_config("clean");
let id = h.submit(&["submit", "--", "true"]);
h.ok(&["wait", &id]);
assert!(h.job_dir(&id).exists());
h.ok(&["clean", &id]);
assert!(!h.job_dir(&id).exists(), "qex did not delete the directory");
assert!(h.list_json().is_empty());
}
#[test]
fn clean_refuses_a_job_that_operates() {
let h = Harness::with_default_config("cleanrun");
let id = h.submit(&["submit", "--", "sleep", "30"]);
h.until("the job starts", Duration::from_secs(45), || {
h.state_of(&id) == "running"
});
let out = h.qex(&["clean", &id]);
assert!(!out.status.success(), "qex must refuse this command");
assert!(h.job_dir(&id).exists(), "the record must stay");
h.ok(&["kill", &id, "--grace", "1s"]);
}
#[test]
fn a_command_that_does_not_exist_gives_a_clear_message() {
let h = Harness::with_default_config("nocmd");
let id = h.submit(&["submit", "--", "this-program-does-not-exist"]);
h.until("the job stops", Duration::from_secs(45), || {
h.status_json(&id)["state"]
.as_str()
.map(|s| s == "failed")
.unwrap_or(false)
});
let reason = h.status_json(&id)["error"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
reason.contains("this-program-does-not-exist"),
"the message must name the program: {reason}"
);
assert!(
reason.contains("PATH"),
"the message must give the correction: {reason}"
);
}
#[test]
fn a_command_accepts_the_first_characters_of_an_id() {
let h = Harness::with_default_config("shortid");
let id = h.submit(&["submit", "--", "true"]);
h.ok(&["wait", &id]);
let short = &id[..8];
assert_eq!(h.state_of(short), "completed");
}
#[test]
fn the_first_screen_points_to_the_topic_for_agents() {
let h = Harness::with_default_config("help");
let text = h.ok(&[]);
assert!(
text.contains("qex help agents"),
"the first screen must name the topic for agents"
);
let agents = h.ok(&["help", "agents"]);
assert!(
agents.contains("pgrep"),
"the topic must warn about the pgrep fault"
);
assert!(
agents.contains("qex wait"),
"the topic must give the solution"
);
}
#[test]
fn the_schemas_are_valid_json() {
let h = Harness::with_default_config("schema");
for name in ["job", "status"] {
let text = h.ok(&["schema", name]);
serde_json::from_str::<serde_json::Value>(&text)
.unwrap_or_else(|e| panic!("the schema `{name}` is not valid JSON: {e}"));
}
}
#[test]
fn a_job_file_describes_a_job() {
let h = Harness::with_default_config("jobfile");
let file = h.root.join("job.toml");
std::fs::write(
&file,
"name = \"from-file\"\n\
command = [\"sh\", \"-c\", \"echo from-the-file\"]\n\
tags = [\"test\"]\n\
[resources]\n\
cpu = 2\n\
mem = \"256MB\"\n\
[env]\n\
FILE_VAR = \"present\"\n",
)
.unwrap();
let id = h.submit(&["submit", "--job", file.to_str().unwrap()]);
h.ok(&["wait", &id]);
let status = h.status_json(&id);
assert_eq!(status["name"], "from-file");
assert_eq!(status["cpu"], 2);
assert_eq!(status["mem"], 256 * 1024 * 1024);
assert_eq!(status["tags"][0], "test");
assert!(h.ok(&["logs", &id]).contains("from-the-file"));
}
#[test]
fn the_claim_word_guess_gives_one_half_of_the_budget() {
let h = Harness::new(
"guess",
"[budget]\ncpu = \"8\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let a = h.submit(&[
"submit", "--cpu", "guess", "--mem", "guess", "--", "sleep", "3",
]);
let status = h.status_json(&a);
assert_eq!(status["cpu"], 4, "one half of 8 cores is 4 cores");
assert_eq!(
status["mem"],
2u64 * 1024 * 1024 * 1024,
"one half of 4GB is 2GB"
);
let b = h.submit(&[
"submit", "--cpu", "half", "--mem", "half", "--", "sleep", "3",
]);
h.until("both jobs operate", Duration::from_secs(45), || {
h.list_json()
.iter()
.filter(|j| j["state"] == "running")
.count()
== 2
});
let c = h.submit(&["submit", "--cpu", "guess", "--mem", "guess", "--", "true"]);
assert_eq!(h.state_of(&c), "queued");
for id in [&a, &b, &c] {
h.ok(&["wait", id, "--timeout", "30s"]);
}
}
#[test]
fn the_claim_word_full_gives_the_whole_budget() {
let h = Harness::new(
"full",
"[budget]\ncpu = \"4\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let big = h.submit(&[
"submit", "--cpu", "full", "--mem", "max", "--", "sleep", "3",
]);
let status = h.status_json(&big);
assert_eq!(status["cpu"], 4);
assert_eq!(status["mem"], 2u64 * 1024 * 1024 * 1024);
assert_eq!(
status["forced"], false,
"a job that asks for the budget is a normal job, and qex must not force it"
);
h.until("the full job starts", Duration::from_secs(45), || {
h.state_of(&big) == "running"
});
let other = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
assert_eq!(h.state_of(&other), "queued");
h.ok(&["wait", &big, "--timeout", "30s"]);
h.ok(&["wait", &other, "--timeout", "30s"]);
}
#[test]
fn a_job_file_accepts_the_claim_words() {
let h = Harness::new(
"guessfile",
"[budget]\ncpu = \"8\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let file = h.root.join("guess.toml");
std::fs::write(
&file,
"command = [\"true\"]\n[resources]\ncpu = \"guess\"\nmem = \"half\"\n",
)
.unwrap();
let id = h.submit(&["submit", "--job", file.to_str().unwrap()]);
let status = h.status_json(&id);
assert_eq!(status["cpu"], 4);
assert_eq!(status["mem"], 2u64 * 1024 * 1024 * 1024);
h.ok(&["wait", &id, "--timeout", "30s"]);
}
#[test]
fn a_dead_supervisor_does_not_leave_the_job_alive() {
let h = Harness::with_default_config("orphan");
let id = h.submit(&["submit", "--", "sleep", "120"]);
h.until("the job starts", Duration::from_secs(45), || {
h.state_of(&id) == "running"
});
let job_pid = h.status_json(&id)["pid"].as_i64().unwrap() as i32;
let supervisor_pid = h.status_json(&id)["supervisor_pid"]
.as_i64()
.expect("the status must record the supervisor") as i32;
unsafe {
libc::kill(supervisor_pid, libc::SIGKILL);
}
h.until(
"the job reaches a final state",
Duration::from_secs(30),
|| {
h.status_json(&id)["state"]
.as_str()
.map(|s| s != "running" && s != "starting")
.unwrap_or(false)
},
);
h.until("the job process stops", Duration::from_secs(30), || {
let rc = unsafe { libc::kill(job_pid, 0) };
rc != 0
});
let info = h.ok(&["info", "--json"]);
let v: serde_json::Value = serde_json::from_str(&info).unwrap();
assert_eq!(v["cpu_claimed"].as_u64(), Some(0));
}
#[test]
fn a_job_that_stops_at_its_time_limit_keeps_its_result() {
let h = Harness::with_default_config("timerrace");
let mut ids = Vec::new();
for i in 0..12 {
let sleep = format!("0.{:03}", 995 + i);
ids.push(h.submit(&["submit", "--timeout", "1s", "--", "sleep", &sleep]));
}
for id in &ids {
h.qex(&["wait", id, "--timeout", "60s"]);
let s = h.status_json(id);
let state = s["state"].as_str().unwrap();
let code = s["exit_code"].as_i64();
if state == "timeout" {
assert_ne!(
code,
Some(0),
"the job stopped with the code 0, so its state must not be `timeout`: {s}"
);
}
if code == Some(0) {
assert_eq!(
state, "completed",
"a job that stopped with the code 0 must be `completed`: {s}"
);
}
}
}
#[test]
fn logs_shows_output_that_is_not_utf8() {
let h = Harness::with_default_config("badbytes");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
r#"printf 'FIRST-LINE\n'; printf 'BAD\377\376\n'; printf 'LAST-LINE\n'"#,
]);
h.ok(&["wait", &id, "--timeout", "30s"]);
let logs = h.ok(&["logs", &id]);
assert!(
logs.contains("FIRST-LINE") && logs.contains("LAST-LINE"),
"one byte that is not UTF-8 hid the whole output: {logs:?}"
);
let json = h.ok(&["logs", &id, "--json"]);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(v["stdout"].as_str().unwrap().contains("LAST-LINE"));
}
#[test]
fn a_spawn_failure_uses_the_error_field() {
let h = Harness::with_default_config("spawnfail");
let id = h.submit(&["submit", "--", "this-program-does-not-exist"]);
h.until("the job stops", Duration::from_secs(45), || {
h.state_of(&id) == "failed"
});
let s = h.status_json(&id);
assert!(
s["error"]
.as_str()
.unwrap_or("")
.contains("this-program-does-not-exist"),
"the error field must name the program: {s}"
);
assert!(
s["blocked_reason"].is_null(),
"a job that failed waits for nothing: {s}"
);
}
#[test]
fn every_queued_job_gives_a_reason() {
let h = Harness::new(
"reasons",
"[budget]\ncpu = \"2\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let running = h.submit(&["submit", "--cpu", "2", "--mem", "64MB", "--", "sleep", "5"]);
h.until("the first job starts", Duration::from_secs(45), || {
h.state_of(&running) == "running"
});
let a = h.submit(&["submit", "--cpu", "2", "--mem", "64MB", "--", "true"]);
let b = h.submit(&["submit", "--cpu", "2", "--mem", "64MB", "--", "true"]);
h.until("both jobs give a reason", Duration::from_secs(45), || {
let ra = h.status_json(&a)["blocked_reason"]
.as_str()
.map(String::from);
let rb = h.status_json(&b)["blocked_reason"]
.as_str()
.map(String::from);
ra.is_some() && rb.is_some()
});
h.ok(&["kill", &running, "--grace", "1s"]);
}
#[test]
fn the_status_records_the_command_and_the_directory() {
let h = Harness::with_default_config("cmdfield");
let id = h.submit(&["submit", "--", "echo", "hello", "world"]);
h.ok(&["wait", &id, "--timeout", "30s"]);
let s = h.status_json(&id);
let command: Vec<String> = serde_json::from_value(s["command"].clone()).unwrap();
assert_eq!(command, vec!["echo", "hello", "world"]);
assert!(!s["cwd"].as_str().unwrap().is_empty());
}
#[test]
fn info_can_test_for_a_coordinator_without_starting_one() {
let h = Harness::with_default_config("nostart");
let out = h.qex(&["info", "--no-start", "--json"]);
assert!(!out.status.success(), "there is no coordinator yet");
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["running"], false);
assert!(
!h.root.join("state/qex/run/s").exists(),
"the command started a coordinator"
);
let id = h.submit(&["submit", "--", "true"]);
h.ok(&["wait", &id, "--timeout", "30s"]);
let out = h.qex(&["info", "--no-start", "--json"]);
assert!(out.status.success());
}
#[test]
fn a_claim_of_zero_cores_is_refused() {
let h = Harness::with_default_config("zeroclaim");
let out = h.qex(&["submit", "--cpu", "0", "--", "true"]);
assert!(
!out.status.success(),
"qex must refuse a claim of zero cores"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("1 core"),
"the error must give the correction: {err}"
);
}
#[test]
fn an_unknown_job_gives_the_same_code_for_each_form_of_the_name() {
let h = Harness::with_default_config("codes");
for name in ["3f5a1c2e-0000-4000-8000-000000000000", "not-a-uuid"] {
for command in ["status", "wait"] {
let out = h.qex(&[command, name]);
assert_eq!(
out.status.code(),
Some(127),
"`qex {command} {name}` must give the code 127"
);
}
}
}
#[test]
fn a_failed_stage_stops_the_stages_after_it() {
let h = Harness::with_default_config("pipeline");
let build = h.submit(&[
"submit",
"--name",
"build",
"--",
"sh",
"-c",
"echo compiling; echo 'error: undefined symbol' >&2; exit 2",
]);
let test = h.submit(&["submit", "--name", "test", "--needs", &build, "--", "true"]);
let ship = h.submit(&["submit", "--name", "ship", "--needs", &test, "--", "true"]);
let out = h.qex(&["wait", &ship, "--timeout", "60s"]);
assert_eq!(out.status.code(), Some(126));
assert_eq!(h.state_of(&build), "failed");
assert_eq!(h.state_of(&test), "skipped");
assert_eq!(h.state_of(&ship), "skipped");
let s = h.status_json(&ship);
assert_eq!(
s["caused_by"].as_str(),
Some(build.as_str()),
"the last stage must name the build, and not the test: {s}"
);
assert!(
s["error"].as_str().unwrap_or("").contains("build"),
"the reason must name the stage that failed: {s}"
);
let failed = h
.list_json()
.iter()
.filter(|j| j["state"] == "failed")
.count();
assert_eq!(failed, 1, "a pipeline must report one failure only");
let logs = h.ok(&["logs", &build]);
assert!(logs.contains("undefined symbol"));
}
#[test]
fn a_pipeline_that_succeeds_runs_each_stage_in_order() {
let h = Harness::with_default_config("pipeok");
let build = h.submit(&["submit", "--name", "build", "--", "sh", "-c", "echo one"]);
let test = h.submit(&[
"submit", "--name", "test", "--needs", &build, "--", "sh", "-c", "echo two",
]);
let ship = h.submit(&[
"submit",
"--name",
"ship",
"--needs",
&test,
"--",
"sh",
"-c",
"echo three",
]);
let out = h.qex(&["wait", &ship, "--timeout", "60s"]);
assert_eq!(out.status.code(), Some(0));
for id in [&build, &test, &ship] {
assert_eq!(h.state_of(id), "completed");
}
let s1 = h.status_json(&build);
let s3 = h.status_json(&ship);
assert!(
s3["started_at"].as_u64().unwrap() >= s1["finished_at"].as_u64().unwrap(),
"the last stage started before the first stage stopped"
);
let names: Vec<String> = h
.list_json()
.iter()
.map(|j| j["name"].as_str().unwrap().to_string())
.collect();
assert_eq!(names, vec!["build", "test", "ship"]);
}
#[test]
fn an_after_job_runs_when_the_job_before_it_fails() {
let h = Harness::with_default_config("afterjob");
let build = h.submit(&["submit", "--name", "build", "--", "sh", "-c", "exit 3"]);
let cleanup = h.submit(&[
"submit",
"--name",
"cleanup",
"--after",
&build,
"--",
"sh",
"-c",
"echo cleaned",
]);
let out = h.qex(&["wait", &cleanup, "--timeout", "60s"]);
assert_eq!(out.status.code(), Some(0), "an --after job must run");
assert_eq!(h.state_of(&build), "failed");
assert_eq!(h.state_of(&cleanup), "completed");
assert!(h.ok(&["logs", &cleanup]).contains("cleaned"));
}
#[test]
fn a_job_that_waits_for_another_job_does_not_hold_capacity() {
let h = Harness::new(
"depcapacity",
"[budget]\ncpu = \"2\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let slow = h.submit(&[
"submit", "--name", "slow", "--cpu", "1", "--mem", "64MB", "--", "sleep", "4",
]);
let waiter = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--needs", &slow, "--", "true",
]);
let free = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
let out = h.qex(&["wait", &free, "--timeout", "20s"]);
assert_eq!(
out.status.code(),
Some(0),
"a job with no dependency must not wait for a job that has one"
);
h.ok(&["wait", &waiter, "--timeout", "60s"]);
h.ok(&["wait", &slow, "--timeout", "60s"]);
}
#[test]
fn a_dependency_that_does_not_exist_is_refused() {
let h = Harness::with_default_config("nodep");
let out = h.qex(&["submit", "--needs", "no-such-job", "--", "true"]);
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("no-such-job"),
"the error must name the value: {err}"
);
}
#[test]
fn clean_keeps_a_job_that_another_job_needs() {
let h = Harness::with_default_config("cleandep");
let first = h.submit(&[
"submit",
"--name",
"first",
"--",
"sh",
"-c",
"sleep 2; exit 1",
]);
let second = h.submit(&["submit", "--needs", &first, "--", "true"]);
let out = h.qex(&["clean", &first]);
assert!(!out.status.success(), "qex must keep this job");
let out = h.qex(&["wait", &second, "--timeout", "60s"]);
assert_eq!(out.status.code(), Some(126));
assert_eq!(h.state_of(&second), "skipped");
}
#[test]
fn clean_accepts_a_state_name() {
let h = Harness::with_default_config("cleanword");
let good = h.submit(&["submit", "--", "true"]);
let bad = h.submit(&["submit", "--", "false"]);
h.ok(&["wait", &good, "--timeout", "30s"]);
h.qex(&["wait", &bad, "--timeout", "30s"]);
h.ok(&["clean", "completed"]);
let states: Vec<String> = h
.list_json()
.iter()
.map(|j| j["state"].as_str().unwrap().to_string())
.collect();
assert_eq!(states, vec!["failed"], "qex deleted the wrong jobs");
}
#[test]
fn a_job_file_accepts_dependencies() {
let h = Harness::with_default_config("depfile");
let first = h.submit(&["submit", "--name", "first", "--", "sh", "-c", "exit 1"]);
let file = h.root.join("second.toml");
std::fs::write(
&file,
format!("command = [\"true\"]\nname = \"second\"\nneeds = [\"{first}\"]\n"),
)
.unwrap();
let second = h.submit(&["submit", "--job", file.to_str().unwrap()]);
let out = h.qex(&["wait", &second, "--timeout", "60s"]);
assert_eq!(out.status.code(), Some(126));
assert_eq!(h.state_of(&second), "skipped");
assert_eq!(
h.status_json(&second)["caused_by"].as_str(),
Some(first.as_str())
);
}
#[test]
fn a_dependency_with_an_unknown_uuid_is_refused() {
let h = Harness::with_default_config("depuuid");
let out = h.qex(&[
"submit",
"--needs",
"11111111-2222-3333-4444-555555555555",
"--",
"true",
]);
assert!(
!out.status.success(),
"qex must refuse an unknown dependency"
);
assert!(h.list_json().is_empty(), "qex must not accept the job");
}
#[test]
fn a_name_must_be_live_but_an_id_need_only_exist() {
let h = Harness::with_default_config("depnameid");
let first = h.submit(&["submit", "--name", "build", "--", "true"]);
h.ok(&["wait", &first, "--timeout", "45s"]);
assert_eq!(h.state_of(&first), "completed");
for option in ["--needs", "--after"] {
let out = h.qex(&["submit", option, "build", "--", "true"]);
assert!(
!out.status.success(),
"{option} with a name that gives a job which stopped must be refused"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("already stopped"),
"the error must say that the job stopped: {err}"
);
assert!(
err.contains("earlier run"),
"the error must name the usual cause: {err}"
);
}
for option in ["--needs", "--after"] {
let out = h.qex(&["submit", option, &first, "--", "true"]);
assert!(
out.status.success(),
"{option} with an id must be accepted: {}",
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn a_dependency_that_failed_is_accepted_and_makes_the_job_skipped() {
let h = Harness::with_default_config("depfailed");
let first = h.submit(&["submit", "--name", "first", "--", "false"]);
h.qex(&["wait", &first, "--timeout", "30s"]);
assert_eq!(h.state_of(&first), "failed");
let second = h.submit(&["submit", "--needs", &first, "--", "true"]);
let out = h.qex(&["wait", &second, "--timeout", "45s"]);
assert_eq!(out.status.code(), Some(126));
assert_eq!(h.state_of(&second), "skipped");
assert_eq!(
h.status_json(&second)["caused_by"].as_str(),
Some(first.as_str())
);
}
#[test]
fn a_failed_dependency_is_seen_behind_a_blocked_queue() {
let h = Harness::new(
"depblocked",
"[budget]\ncpu = \"2\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let failer = h.submit(&[
"submit",
"--cpu",
"1",
"--mem",
"64MB",
"--name",
"failer",
"--",
"sh",
"-c",
"sleep 2; exit 1",
]);
let blocker = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--name", "blocker", "--", "sleep", "20",
]);
h.until("both jobs operate", Duration::from_secs(45), || {
h.state_of(&failer) == "running" && h.state_of(&blocker) == "running"
});
h.submit(&[
"submit", "--cpu", "2", "--mem", "64MB", "--name", "mid", "--", "true",
]);
let skipped = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--needs", &failer, "--", "true",
]);
h.until("the job is skipped", Duration::from_secs(45), || {
h.state_of(&skipped) == "skipped"
});
let out = h.qex(&["wait", &skipped, "--timeout", "10s"]);
assert_eq!(out.status.code(), Some(126));
h.ok(&["kill", &blocker, "--grace", "1s"]);
}
#[test]
fn every_command_gives_one_code_for_a_job_that_does_not_exist() {
let h = Harness::with_default_config("codes2");
let unknown = "11111111-2222-3333-4444-555555555555";
for command in ["status", "wait", "logs", "kill", "cancel"] {
let out = h.qex(&[command, unknown]);
assert_eq!(
out.status.code(),
Some(127),
"`qex {command}` must give the code 127 for a job that does not exist"
);
}
}
#[test]
fn clean_keeps_the_cause_readable_for_the_jobs_that_it_leaves() {
let h = Harness::with_default_config("cleancause");
let first = h.submit(&[
"submit",
"--name",
"first",
"--",
"sh",
"-c",
"sleep 1; exit 1",
]);
let second = h.submit(&[
"submit", "--name", "second", "--needs", &first, "--", "true",
]);
h.until("the second job is skipped", Duration::from_secs(45), || {
h.state_of(&second) == "skipped"
});
h.ok(&["clean", &first]);
let s = h.status_json(&second);
let error = s["error"].as_str().unwrap_or("");
assert!(
error.contains("first"),
"the record must still name the job that failed: {error}"
);
assert!(
!error.contains("qex logs"),
"the record must not send the reader to a log that is deleted: {error}"
);
assert!(s["caused_by"].is_null(), "the id points at nothing now");
}
#[test]
fn logs_shows_the_last_lines_by_default() {
let h = Harness::with_default_config("logcap");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"i=0; while [ $i -lt 2000 ]; do echo line-$i; i=$((i+1)); done",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let result = h.qex(&["logs", &id, "--stdout"]);
let out = String::from_utf8_lossy(&result.stdout);
let notice = String::from_utf8_lossy(&result.stderr);
assert!(
out.lines().count() < 600,
"the default output must be short, and it had {} lines",
out.lines().count()
);
assert!(out.contains("line-1999"), "the last line must be there");
assert!(
notice.contains("not shown"),
"qex must say that it hid the earlier lines: {notice}"
);
let full = h.ok(&["logs", &id, "--stdout", "--all"]);
assert!(full.contains("line-0"), "--all must give the first line");
assert!(full.lines().count() >= 2000);
}
#[test]
fn the_status_of_a_job_that_failed_holds_its_error_output() {
let h = Harness::with_default_config("statuslogs");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"echo normal; echo 'BOOM: it broke' >&2; exit 3",
]);
h.qex(&["wait", &id, "--timeout", "45s"]);
let text = h.ok(&["status", &id]);
assert!(
text.contains("BOOM: it broke"),
"the status must hold the error output: {text}"
);
let v = h.status_json(&id);
assert!(v["logs"]["stderr"]["text"]
.as_str()
.unwrap()
.contains("BOOM"));
let good = h.submit(&["submit", "--", "sh", "-c", "echo quiet"]);
h.ok(&["wait", &good, "--timeout", "45s"]);
assert!(h.status_json(&good)["logs"].is_null());
let text = h.ok(&["status", &id, "--no-logs"]);
assert!(!text.contains("BOOM"), "--no-logs must remove the output");
}
#[test]
fn the_status_of_a_failure_gives_both_streams() {
let h = Harness::with_default_config("bothstreams");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"echo 'exact 27793/27793 OK'; echo 'FAIL: 64087 mismatched' >&2; exit 1",
]);
h.qex(&["wait", &id, "--timeout", "45s"]);
let text = h.ok(&["status", &id]);
assert!(
text.contains("FAIL: 64087"),
"the status must hold the error output: {text}"
);
assert!(
text.contains("27793/27793"),
"the status must also hold the standard output, or the reader sees a \
failure with no result: {text}"
);
let v = h.status_json(&id);
assert!(v["logs"]["stderr"]["text"]
.as_str()
.unwrap()
.contains("FAIL"));
assert!(v["logs"]["stdout"]["text"]
.as_str()
.unwrap()
.contains("27793"));
let only = h.ok(&["status", &id, "--stderr"]);
assert!(only.contains("FAIL"));
assert!(
!only.contains("27793"),
"--stderr must give one stream only"
);
}
#[test]
fn the_log_options_select_the_lines() {
let h = Harness::with_default_config("logopts");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"i=1; while [ $i -le 300 ]; do echo line-$i; i=$((i+1)); done",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let head = h.ok(&["logs", &id, "--stdout", "--head", "3"]);
assert_eq!(head.lines().next().unwrap(), "line-1");
assert_eq!(head.lines().count(), 3);
let range = h.ok(&["logs", &id, "--stdout", "--lines", "100:102"]);
assert_eq!(range, "line-100\nline-101\nline-102");
let numbered = h.ok(&["logs", &id, "--stdout", "--head", "1", "--number"]);
assert!(numbered.contains("1 line-1"), "got: {numbered}");
let out = h.qex(&[
"logs",
&id,
"--stdout",
"--grep",
"line-1[0-9]$",
"--max-matches",
"3",
]);
let found = String::from_utf8_lossy(&out.stdout);
let notice = String::from_utf8_lossy(&out.stderr);
assert!(notice.contains("10 line(s) match"), "got: {notice}");
assert!(found.contains("line-10") && found.contains("line-12"));
assert!(!found.contains("line-13"), "the limit must hold");
for line in found.lines() {
assert!(
line.starts_with("line-"),
"stdout must hold log lines only: {line}"
);
}
let s = h.ok(&["status", &id, "--stdout", "--head", "2"]);
assert!(s.contains("line-1") && s.contains("line-2"));
assert!(!s.contains("line-3"), "the head limit must hold in status");
}
#[test]
fn follow_leads_with_the_last_lines_only() {
let h = Harness::with_default_config("followtail");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"i=1; while [ $i -le 200 ]; do echo old-$i; i=$((i+1)); done; sleep 2; echo NEW-A",
]);
h.until(
"the job writes its first lines",
Duration::from_secs(45),
|| {
h.job_dir(&id)
.join("stdout.log")
.metadata()
.map(|m| m.len() > 100)
.unwrap_or(false)
},
);
let out = h.ok(&["logs", &id, "--stdout", "--tail", "3", "--follow"]);
assert!(
out.contains("NEW-A"),
"follow must give the new lines: {out}"
);
assert!(
!out.contains("old-1\n"),
"follow must not write the whole file"
);
assert!(
out.lines().count() <= 6,
"got {} lines",
out.lines().count()
);
}
#[test]
fn a_second_job_of_one_command_uses_the_measurement_of_the_first() {
let h = Harness::with_default_config("learn");
let program = [
"sh",
"-c",
"head -c 40000000 /dev/zero | tail -c 1 > /dev/null",
];
let first = h.submit(&[&["submit", "--name", "one", "--"], &program[..]].concat());
h.ok(&["wait", &first, "--timeout", "60s"]);
let s1 = h.status_json(&first);
assert_eq!(
s1["claim_source"], "default",
"the first job has no measurement to use"
);
let used = s1["usage"]["max_rss"].as_u64().unwrap();
assert!(used > 0);
let second = h.submit(&[&["submit", "--name", "two", "--"], &program[..]].concat());
let s2 = h.status_json(&second);
assert_eq!(
s2["claim_source"], "learned",
"the second job must use the measurement: {s2}"
);
let claimed = s2["mem"].as_u64().unwrap();
assert!(
claimed >= used,
"the claim {claimed} must not be below the measurement {used}"
);
assert!(
claimed < s1["mem"].as_u64().unwrap(),
"the claim must be below the default, or the measurement gave nothing"
);
h.ok(&["wait", &second, "--timeout", "60s"]);
}
#[test]
fn a_claim_from_the_user_wins_over_a_measurement() {
let h = Harness::with_default_config("learnwins");
let first = h.submit(&["submit", "--", "true"]);
h.ok(&["wait", &first, "--timeout", "45s"]);
let second = h.submit(&["submit", "--cpu", "2", "--mem", "1GB", "--", "true"]);
let s = h.status_json(&second);
assert_eq!(s["claim_source"], "explicit");
assert_eq!(s["cpu"], 2);
assert_eq!(s["mem"], 1024u64 * 1024 * 1024);
h.ok(&["wait", &second, "--timeout", "45s"]);
}
#[test]
fn a_job_that_did_not_complete_is_not_a_measurement() {
let h = Harness::with_default_config("learnfail");
let program = ["sh", "-c", "exit 1"];
let first = h.submit(&[&["submit", "--"], &program[..]].concat());
h.qex(&["wait", &first, "--timeout", "45s"]);
assert_eq!(h.state_of(&first), "failed");
let second = h.submit(&[&["submit", "--"], &program[..]].concat());
assert_eq!(
h.status_json(&second)["claim_source"],
"default",
"a job that failed must not become a measurement"
);
h.qex(&["wait", &second, "--timeout", "45s"]);
}
#[test]
fn a_replacement_of_the_program_does_not_stop_the_jobs() {
let h = Harness::with_default_config("skew");
let copy = h.root.join("qex-copy");
std::fs::copy(env!("CARGO_BIN_EXE_qex"), ©).unwrap();
let run = |args: &[&str], exe: &std::path::Path| -> Output {
Command::new(exe)
.args(args)
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("QEX_IDLE_EXIT_SECS", "120")
.output()
.expect("qex did not start")
};
let first = run(&["submit", "--", "true"], ©);
assert!(first.status.success());
let id = String::from_utf8_lossy(&first.stdout).trim().to_string();
run(&["wait", &id, "--timeout", "45s"], ©);
std::fs::remove_file(©).unwrap();
std::fs::copy(env!("CARGO_BIN_EXE_qex"), ©).unwrap();
let after = run(&["submit", "--", "sh", "-c", "echo it-ran"], ©);
assert!(
after.status.success(),
"the submission failed after the replacement: {}",
String::from_utf8_lossy(&after.stderr)
);
let id2 = String::from_utf8_lossy(&after.stdout).trim().to_string();
let waited = run(&["wait", &id2, "--timeout", "45s"], ©);
assert_eq!(
waited.status.code(),
Some(0),
"the job did not run after the replacement: {}",
String::from_utf8_lossy(&waited.stderr)
);
let info = run(&["info", "--no-start", "--json"], ©);
let v: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap();
#[cfg(target_os = "linux")]
assert_eq!(v["program_replaced"], true);
if let Some(pid) = v["pid"].as_i64() {
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
}
}
#[test]
fn a_closed_pipe_does_not_give_a_panic() {
let h = Harness::with_default_config("pipe");
for _ in 0..5 {
h.submit(&["submit", "--", "true"]);
}
let out = Command::new("sh")
.arg("-c")
.arg(format!("{} list | head -2", env!("CARGO_BIN_EXE_qex")))
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("QEX_IDLE_EXIT_SECS", "120")
.output()
.unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(
!err.contains("panicked"),
"a closed pipe gave a panic: {err}"
);
assert!(!err.contains("Broken pipe"), "got: {err}");
}
#[test]
fn the_id_file_holds_the_id() {
let h = Harness::with_default_config("idfile");
let file = h.root.join("job.id");
let id = h.submit(&["submit", "--id-file", file.to_str().unwrap(), "--", "true"]);
let written = std::fs::read_to_string(&file).unwrap();
assert_eq!(written.trim(), id, "the file must hold the id");
h.ok(&["wait", written.trim(), "--timeout", "45s"]);
}
#[test]
fn an_id_file_in_a_temporary_directory_gives_a_warning() {
let h = Harness::with_default_config("idtmp");
let out = h.qex(&[
"submit",
"--id-file",
h.root.join("job.id").to_str().unwrap(),
"--",
"true",
]);
assert!(out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("does not last"),
"an id file in a temporary directory must give a warning; got: {err}"
);
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
assert!(
id.parse::<uuid::Uuid>().is_ok(),
"stdout must hold the id only, and it held: {id}"
);
let lasting = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join("qex-id-file-test");
std::fs::create_dir_all(&lasting).unwrap();
let out = h.qex(&[
"submit",
"--id-file",
lasting.join("job.id").to_str().unwrap(),
"--",
"true",
]);
assert!(out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
!err.contains("does not last"),
"an id file in a directory that lasts must give no warning; got: {err}"
);
std::fs::remove_dir_all(&lasting).ok();
}
#[test]
fn the_id_file_of_a_pipeline_holds_every_stage() {
let h = Harness::with_default_config("pipeidfile");
let pipeline = h.root.join("ci.toml");
std::fs::write(
&pipeline,
"[[jobs]]\nname = \"build\"\ncommand = [\"true\"]\n\n\
[[jobs]]\nname = \"test\"\ncommand = [\"true\"]\nneeds = [\"build\"]\n",
)
.unwrap();
let env_file = h.root.join("ids.env");
let group = h.ok(&[
"pipeline",
pipeline.to_str().unwrap(),
"--id-file",
env_file.to_str().unwrap(),
]);
let text = std::fs::read_to_string(&env_file).unwrap();
assert!(text.contains(&format!("group={group}")), "got: {text}");
assert!(
text.contains("build="),
"the build stage is missing: {text}"
);
assert!(text.contains("test="), "the test stage is missing: {text}");
for line in text.lines() {
let (_, id) = line.split_once('=').unwrap();
assert!(id.parse::<uuid::Uuid>().is_ok(), "not an id: {line}");
}
let json_file = h.root.join("ids.json");
h.ok(&[
"pipeline",
pipeline.to_str().unwrap(),
"--id-file",
json_file.to_str().unwrap(),
]);
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&json_file).unwrap()).unwrap();
assert!(v["group"].as_str().is_some());
assert!(v["jobs"]["build"].as_str().is_some());
assert!(v["jobs"]["test"].as_str().is_some());
}
#[test]
fn the_directory_filters_select_the_right_jobs() {
let h = Harness::with_default_config("dirs");
let project = h.root.join("project");
let inner = project.join("inner");
let other = h.root.join("other");
for d in [&project, &inner, &other] {
std::fs::create_dir_all(d).unwrap();
}
let run_in = |dir: &std::path::Path, name: &str| -> String {
let out = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["submit", "--name", name, "--", "true"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("QEX_IDLE_EXIT_SECS", "120")
.current_dir(dir)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
let top = run_in(&project, "top");
let deep = run_in(&inner, "deep");
let away = run_in(&other, "away");
for id in [&top, &deep, &away] {
h.ok(&["wait", id, "--timeout", "45s"]);
}
let names = |args: &[&str], dir: &std::path::Path| -> Vec<String> {
let out = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(args)
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("QEX_IDLE_EXIT_SECS", "120")
.current_dir(dir)
.output()
.unwrap();
let jobs: Vec<serde_json::Value> = serde_json::from_slice(&out.stdout).unwrap_or_default();
jobs.iter()
.map(|j| j["name"].as_str().unwrap().to_string())
.collect()
};
assert_eq!(names(&["list", "--json", "--cwd"], &project), vec!["top"]);
let under = names(&["list", "--json", "--under"], &project);
assert!(under.contains(&"top".to_string()) && under.contains(&"deep".to_string()));
assert!(
!under.contains(&"away".to_string()),
"a different directory must not appear"
);
let out = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["clean", "--under"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("QEX_IDLE_EXIT_SECS", "120")
.current_dir(&project)
.output()
.unwrap();
assert!(out.status.success());
let left: Vec<String> = h
.list_json()
.iter()
.map(|j| j["name"].as_str().unwrap().to_string())
.collect();
assert_eq!(left, vec!["away"], "the other directory must stay");
}
#[test]
fn clean_auto_keeps_the_recent_jobs() {
let h = Harness::with_default_config("auto");
let id = h.submit(&["submit", "--", "true"]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.qex(&["clean", "--auto"]);
assert!(out.status.success());
assert_eq!(
h.list_json().len(),
1,
"a job that stopped a moment ago must stay: {}",
String::from_utf8_lossy(&out.stdout)
);
}
#[test]
fn a_dependency_of_a_queued_job_is_not_finished() {
let h = Harness::new(
"depclean",
"[budget]\ncpu = \"1\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let first = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
h.ok(&["wait", &first, "--timeout", "45s"]);
assert_eq!(h.state_of(&first), "completed");
let blocker = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "20"]);
h.until("the blocker starts", Duration::from_secs(45), || {
h.state_of(&blocker) == "running"
});
let second = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--needs", &first, "--", "true",
]);
assert_eq!(h.state_of(&second), "queued");
let out = h.ok(&["clean", "--all"]);
assert!(out.contains("0 job(s)"), "nothing must go: {out}");
assert!(
out.contains("still needs them"),
"the message must give the reason: {out}"
);
assert!(
h.list_json().iter().any(|j| j["id"] == first),
"the record of the first job must stay"
);
h.ok(&["kill", &blocker, "--grace", "1s"]);
}
#[test]
fn du_reports_the_space_that_qex_holds() {
let h = Harness::with_default_config("du");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"i=0; while [ $i -lt 500 ]; do echo padding-line-$i; i=$((i+1)); done",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let text = h.ok(&["du", "--json"]);
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
assert!(v["total_bytes"].as_u64().unwrap() > 0);
assert!(v["jobs_bytes"].as_u64().unwrap() > 0);
assert_eq!(v["largest"][0]["id"].as_str(), Some(id.as_str()));
}
#[test]
fn a_long_runtime_directory_still_works() {
let long = std::env::temp_dir()
.join(format!("qex-long-{}", std::process::id()))
.join("a-directory-with-a-very-long-name")
.join("another-directory-with-a-long-name")
.join("and-one-more-to-pass-the-limit-of-sun-path");
let h = Harness::with_default_config("longpath");
std::fs::create_dir_all(&long).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["submit", "--", "echo", "long-path-ok"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", &long)
.env("QEX_IDLE_EXIT_SECS", "120")
.output()
.unwrap();
assert!(
out.status.success(),
"qex failed with a long runtime directory: {}",
String::from_utf8_lossy(&out.stderr)
);
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
let wait = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["wait", &id, "--timeout", "30s"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", &long)
.env("QEX_IDLE_EXIT_SECS", "120")
.output()
.unwrap();
assert_eq!(wait.status.code(), Some(0));
let info = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["info", "--json"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", &long)
.env("QEX_IDLE_EXIT_SECS", "120")
.output()
.unwrap();
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&info.stdout) {
if let Some(pid) = v["pid"].as_i64() {
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
}
}
std::fs::remove_dir_all(long.ancestors().nth(3).unwrap()).ok();
}
#[test]
fn the_status_gives_the_measured_use_of_a_job() {
let h = Harness::with_default_config("usage");
let id = h.submit(&[
"submit",
"--mem",
"512MB",
"--",
"sh",
"-c",
"head -c 8000000 /dev/zero > /dev/null",
]);
h.ok(&["wait", &id]);
let status = h.status_json(&id);
let rss = status["usage"]["max_rss"].as_u64().unwrap();
assert!(rss > 0, "qex must measure the memory of a job");
assert!(
rss < 512 * 1024 * 1024,
"the measurement {rss} is larger than the claim, so the unit is wrong"
);
}
fn _unused(_: &Path) {}