use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use serde_json::{json, Value};
fn command(dir: &Path, program: &str, args: &[&str]) -> Command {
let mut cmd = Command::new(program);
cmd.args(args).current_dir(dir).env_clear();
if let Some(value) = std::env::var_os("SYSTEMROOT") {
cmd.env("SYSTEMROOT", value);
}
cmd.env("PATH", std::env::var_os("PATH").unwrap_or_default())
.env("HOME", dir)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@example.com")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@example.com");
cmd
}
fn fairlead(dir: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, String, String) {
let mut cmd = command(dir, env!("CARGO_BIN_EXE_fairlead"), args);
for (key, value) in env {
cmd.env(key, value);
}
let out = cmd.output().unwrap();
(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
fn repo(name: &str, files: &[(&str, &str)]) -> PathBuf {
let dir = std::env::temp_dir().join(format!("fairlead-told-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
for (path, content) in files {
let path = dir.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, content).unwrap();
}
for args in [
&["init", "-q", "-b", "main"][..],
&["add", "-A"],
&["commit", "-q", "-m", "base"],
] {
let out = command(&dir, "git", args).output().unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
}
dir
}
const CONFIG: &str = r#"
[tests]
match = { replace = ["**/*.test.ts"] }
[[tests.runners]]
id = "unit"
match = ["**/*.test.ts"]
command = ["sh", "-c", "exit 0", "sh", "{files}"]
"#;
const FILES: &[(&str, &str)] = &[
("fairlead.toml", CONFIG),
("src/a.ts", "export const a = 1;\n"),
("src/a.test.ts", "import { a } from './a';\n"),
];
fn session_start(dir: &Path) -> String {
let call = json!({"session_id": "s", "cwd": dir, "source": "startup"}).to_string();
let mut child = command(dir, env!("CARGO_BIN_EXE_fairlead"), &["resume", "--hook"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(call.as_bytes())
.unwrap();
let out = child.wait_with_output().unwrap();
assert!(out.status.success(), "the hook always exits 0");
let text = String::from_utf8_lossy(&out.stdout).into_owned();
if text.trim().is_empty() {
return String::new();
}
let answer: Value = serde_json::from_str(text.trim()).unwrap();
answer["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap_or_default()
.to_string()
}
#[test]
fn a_new_session_is_told_of_required_open_items_once_a_day() {
let dir = repo("session", FILES);
let told = session_start(&dir);
assert!(
told.starts_with("fairlead: setup has ") && told.contains("run `fairlead setup`"),
"{told}"
);
assert!(told.contains("hooks"), "{told}");
assert_eq!(told.lines().count(), 1, "{told}");
assert_eq!(session_start(&dir), "", "the same day says it once");
std::fs::write(dir.join(".git/fairlead/setup-told"), "1").unwrap();
assert_eq!(session_start(&dir), told);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_session_is_told_nothing_once_no_required_item_is_open() {
let dir = repo("quiet", FILES);
let (_, out, err) = fairlead(&dir, &["setup"], &[]);
let (code, check, _) = fairlead(&dir, &["setup", "--check"], &[]);
assert_eq!(code, 0, "{out}{err}{check}");
assert_eq!(session_start(&dir), "");
assert!(
dir.join(".git/fairlead/setup-told").exists(),
"the day's one look is stamped, so the tree isn't read again"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn ci_plan_warns_about_what_the_checkout_shows_and_only_that() {
let dir = repo("ci", FILES);
let plan = ["ci", "plan", "--base", "main", "--out", "plan.json"];
let (code, _, err) = fairlead(&dir, &plan, &[("GITHUB_ACTIONS", "true")]);
assert_eq!(code, 0, "{err}");
let line = (err.lines())
.find(|l| l.starts_with("::warning title=Fairlead setup::"))
.unwrap_or_else(|| panic!("no warning in: {err}"));
assert!(line.contains("(agents)"), "{line}");
assert!(
line.ends_with("run `fairlead setup` and commit what it writes"),
"{line}"
);
let (_, _, err) = fairlead(&dir, &plan, &[]);
assert!(err.contains("fairlead: setup has 1 open"), "{err}");
fairlead(&dir, &["agents", "sync"], &[]);
let (code, _, err) = fairlead(&dir, &plan, &[("GITHUB_ACTIONS", "true")]);
assert_eq!(code, 0, "{err}");
assert!(!err.contains("Fairlead setup"), "{err}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn config_check_and_setup_name_a_smoke_entry_that_matches_no_test_file() {
let stale = format!("{CONFIG}smoke = [\"src/a.test.ts\", \"src/gone.test.ts\"]\n");
let dir = repo(
"smoke",
&[
("fairlead.toml", stale.as_str()),
("src/a.ts", "export const a = 1;\n"),
("src/a.test.ts", "import { a } from './a';\n"),
],
);
let (code, out, err) = fairlead(&dir, &["config", "check"], &[]);
assert_eq!(code, 0, "{out}{err}");
assert!(
err.contains("warning: tests.runners `unit`: its smoke entry `src/gone.test.ts` names no test file the runner matches"),
"{err}"
);
assert!(!err.contains("`src/a.test.ts`"), "{err}");
let (_, out, _) = fairlead(&dir, &["setup", "--check"], &[]);
assert!(
out.contains("unit: its `smoke` entry `src/gone.test.ts` names no test file it matches"),
"{out}"
);
assert!(
out.contains("fix: name test files that exist under `smoke` on runner `unit`"),
"{out}"
);
std::fs::remove_dir_all(&dir).unwrap();
}