#![cfg(unix)]
use super::*;
use crate::lifecycle::{Plugins, Sealed};
use crate::log::{Level, Log};
use crate::op::Phase;
use crate::registry::PluginRef;
use crate::verb::Verb;
use crate::wire::{Binding, Command, OpContext};
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use tempfile::TempDir;
fn script(dir: &Path, name: &str, body: &str) -> PathBuf {
let path = dir.join(name);
fs::write(&path, format!("#!/bin/sh\n{body}")).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
path
}
const RECORDER: &str = "env > env.txt\ncat > stdin.txt\nprintf 'log from %s\\n' \"$BALLS_PLUGIN_NAME\" >&2\n";
fn pref(name: &str, bin: Option<PathBuf>) -> PluginRef {
PluginRef { name: name.into(), bin, source: None }
}
fn ctx() -> OpContext {
OpContext {
actor: "me@example.com".into(),
binding: Binding {
remote: None,
stealth: false,
tasks_branch: "balls/tasks".into(),
store: "/store".into(),
landing: "/landing".into(),
invocation_path: "/proj".into(),
},
command: Some(Command { op: "close".into(), body_change: None, message: None }),
before: None,
}
}
fn clk() -> i64 { 0 }
struct Env {
home: TempDir,
log: Log,
}
impl Env {
fn new() -> Self {
let home = TempDir::new().unwrap();
for sub in ["bin", "cwd"] {
fs::create_dir(home.path().join(sub)).unwrap();
}
let log = Log::new(home.path().join("log"), Level::Debug, Verb::Close, clk);
Self { home, log }
}
fn at(&self, sub: &str) -> PathBuf {
self.home.path().join(sub)
}
fn log_path(&self) -> PathBuf {
self.home.path().join("log")
}
fn dispatcher(&self, depth: u32) -> Subprocess<'_> {
Subprocess::new(ctx(), &self.log, depth)
}
}
#[test]
fn run_delivers_the_env_stdin_and_cwd() {
let e = Env::new();
let bin = script(&e.at("bin"), "rec", RECORDER);
e.dispatcher(0)
.run(&pref("tracker", Some(bin)), Verb::Close, Phase::Pre, &e.at("cwd"), None)
.unwrap();
let env = fs::read_to_string(e.at("cwd").join("env.txt")).unwrap();
assert!(env.contains("BALLS_PROTOCOL=1"));
assert!(env.contains("BALLS_PLUGIN_NAME=tracker"));
assert!(env.contains("BALLS_PLUGIN_DEPTH=1")); let stdin = fs::read_to_string(e.at("cwd").join("stdin.txt")).unwrap();
let v: serde_json::Value = serde_json::from_str(&stdin).unwrap();
assert_eq!(v["op"], "close");
assert_eq!(v["phase"], "pre");
assert_eq!(v["plugin_name"], "tracker");
}
#[test]
fn a_dated_dispatch_exports_the_op_instant_as_git_dates() {
let e = Env::new();
let bin = script(&e.at("bin"), "rec", RECORDER);
e.dispatcher(0)
.dated(1_700_000_000)
.run(&pref("delivery", Some(bin)), Verb::Close, Phase::Pre, &e.at("cwd"), None)
.unwrap();
let env = fs::read_to_string(e.at("cwd").join("env.txt")).unwrap();
assert!(env.contains("GIT_AUTHOR_DATE=@1700000000"), "author date not exported: {env}");
assert!(env.contains("GIT_COMMITTER_DATE=@1700000000"), "committer date not exported: {env}");
}
#[test]
fn run_envelopes_stderr_into_the_unified_log() {
let e = Env::new();
let bin = script(&e.at("bin"), "rec", RECORDER);
e.dispatcher(0)
.run(&pref("tracker", Some(bin)), Verb::Close, Phase::Pre, &e.at("cwd"), None)
.unwrap();
let log = fs::read_to_string(e.log_path()).unwrap();
let recs: Vec<serde_json::Value> = log.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(recs[0]["src"], "core");
assert_eq!(recs[0]["msg"], "invoke tracker");
let envelope = recs.iter().find(|r| r["src"] == "tracker").unwrap();
assert_eq!(envelope["lvl"], "info");
assert_eq!(envelope["phase"], "pre");
assert_eq!(envelope["msg"], "log from tracker");
}
#[test]
fn a_post_run_carries_the_sealed_commit_and_parsed_metadata() {
let e = Env::new();
let bin = script(&e.at("bin"), "rec", RECORDER);
let sealed = Sealed { commit: "C1", previous_commit: "T0", message: Some("subj\n\nbl-id: bl-9\n") };
e.dispatcher(0)
.run(&pref("tracker", Some(bin)), Verb::Close, Phase::Post, &e.at("cwd"), Some(&sealed))
.unwrap();
let v: serde_json::Value =
serde_json::from_str(&fs::read_to_string(e.at("cwd").join("stdin.txt")).unwrap()).unwrap();
assert_eq!(v["commit"], "C1");
assert_eq!(v["previous_commit"], "T0");
assert_eq!(v["metadata"]["bl-id"][0], "bl-9");
}
#[test]
fn a_diffless_post_run_carries_the_commit_pair_but_no_metadata() {
let e = Env::new();
let bin = script(&e.at("bin"), "rec", RECORDER);
let sealed = Sealed { commit: "T1", previous_commit: "T0", message: None };
e.dispatcher(0)
.run(&pref("tracker", Some(bin)), Verb::Sync, Phase::Post, &e.at("cwd"), Some(&sealed))
.unwrap();
let v: serde_json::Value =
serde_json::from_str(&fs::read_to_string(e.at("cwd").join("stdin.txt")).unwrap()).unwrap();
assert_eq!(v["commit"], "T1");
assert_eq!(v["previous_commit"], "T0");
assert!(v.get("metadata").is_none(), "diffless post must omit metadata");
}
#[test]
fn a_nonzero_exit_aborts_the_op() {
let e = Env::new();
let bin = script(&e.at("bin"), "fail", "cat >/dev/null\nexit 7\n");
let err = e
.dispatcher(0)
.run(&pref("tracker", Some(bin)), Verb::Close, Phase::Pre, &e.at("cwd"), None)
.unwrap_err();
assert!(err.to_string().contains("tracker aborted the op"));
let log = fs::read_to_string(e.log_path()).unwrap();
let err_rec = log.lines().map(|l| serde_json::from_str::<serde_json::Value>(l).unwrap()).find(|r| r["lvl"] == "error").unwrap();
assert_eq!(err_rec["src"], "core");
assert!(err_rec["msg"].as_str().unwrap().contains("tracker aborted the op"));
}
#[test]
fn a_missing_binary_path_surfaces_the_spawn_error() {
let e = Env::new();
let bin = e.at("bin").join("does-not-exist");
let err = e
.dispatcher(0)
.run(&pref("gone", Some(bin)), Verb::Close, Phase::Pre, &e.at("cwd"), None)
.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
#[test]
fn a_busy_binary_retries_then_surfaces_the_error() {
let e = Env::new();
let bin = script(&e.at("bin"), "rec", RECORDER);
let _held = fs::OpenOptions::new().write(true).open(&bin).unwrap();
let err = e
.dispatcher(0)
.run(&pref("tracker", Some(bin)), Verb::Close, Phase::Pre, &e.at("cwd"), None)
.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::ExecutableFileBusy);
}
#[test]
fn the_depth_cap_aborts_the_op() {
let e = Env::new();
let bin = script(&e.at("bin"), "rec", RECORDER);
let plugin = pref("tracker", Some(bin));
let d = e.dispatcher(DEPTH_CAP);
let err = d.run(&plugin, Verb::Close, Phase::Pre, &e.at("cwd"), None).unwrap_err();
assert!(err.to_string().contains("depth cap"), "the abort names the cap");
d.rollback(&plugin, Verb::Close, Phase::Pre, &e.at("cwd"), None);
assert!(!e.at("cwd").join("stdin.txt").exists(), "the plugin never spawned at the cap");
let log = fs::read_to_string(e.log_path()).unwrap();
assert!(log.contains("depth cap") && log.contains("\"lvl\":\"error\""));
}
#[test]
fn rollback_tags_the_payload_and_ignores_the_exit() {
let e = Env::new();
let bin = script(&e.at("bin"), "rec", &format!("{RECORDER}exit 3\n"));
let sealed = Sealed { commit: "C1", previous_commit: "T0", message: Some("s\n\nbl-id: bl-1\n") };
e.dispatcher(0)
.rollback(&pref("tracker", Some(bin)), Verb::Close, Phase::Post, &e.at("cwd"), Some(&sealed));
let v: serde_json::Value =
serde_json::from_str(&fs::read_to_string(e.at("cwd").join("stdin.txt")).unwrap()).unwrap();
assert_eq!(v["rolling_back"], "post");
assert_eq!(v["commit"], "C1");
assert!(fs::read_to_string(e.log_path()).unwrap().contains("rollback failed (exit status: 3) — its close.post side effects may not be unwound"));
}