use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn fairlead_in(dir: &Path, args: &[&str]) -> Output {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_fairlead"));
cmd.args(args).current_dir(dir).env_clear();
for keep in ["PATH", "SYSTEMROOT", "HOME", "USERPROFILE"] {
if let Some(value) = std::env::var_os(keep) {
cmd.env(keep, value);
}
}
cmd.output().unwrap()
}
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args([
"-c",
"user.name=t",
"-c",
"user.email=t@example.com",
"-c",
"commit.gpgsign=false",
])
.args(args)
.current_dir(dir)
.output()
.unwrap();
assert!(
status.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&status.stderr)
);
}
fn write(dir: &Path, path: &str, text: &str) {
let full = dir.join(path);
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
std::fs::write(full, text).unwrap();
}
fn project(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("fairlead-plan-cli-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git(&dir, &["init", "-q", "-b", "main"]);
write(&dir, "fairlead.toml", "[[tests.runners]]\nid = \"vitest\"\nmatch = [\"**\"]\ncommand = [\"vitest\", \"run\", \"{files}\"]\n\n[[checks]]\nid = \"typecheck\"\ncommand = [\"tsc\"]\npaths = [\"**/*.ts\"]\n");
write(&dir, "src/a.ts", "export const a = 1;\n");
write(&dir, "src/b.ts", "export const b = 1;\n");
write(&dir, "test/a.test.ts", "import { a } from '../src/a';\n");
write(&dir, "test/b.test.ts", "import { b } from '../src/b';\n");
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-q", "-m", "init"]);
dir
}
#[test]
fn plan_reads_the_changes_from_git_and_prints_the_selection() {
let dir = project("git");
write(&dir, "src/a.ts", "export const a = 2;\n");
let out = fairlead_in(&dir, &["plan", "--base", "main", "--json"]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let plan: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(plan["version"], 1);
assert_eq!(plan["changed"][0]["path"], "src/a.ts");
assert_eq!(plan["tests"].as_array().unwrap().len(), 1);
assert_eq!(plan["tests"][0]["path"], "test/a.test.ts");
assert_eq!(plan["head"], "worktree");
assert!(plan["tree_hash"].as_str().unwrap().starts_with("worktree:"));
assert_eq!(
plan["invocations"][0]["argv"],
serde_json::json!(["vitest", "run", "test/a.test.ts"])
);
let text = fairlead_in(&dir, &["plan", "--base", "main"]);
assert!(String::from_utf8_lossy(&text.stdout).contains("test/a.test.ts"));
}
#[test]
fn a_clean_checkout_reports_its_commit_and_tree() {
let dir = project("clean");
let out = fairlead_in(&dir, &["plan", "--base", "main", "--json"]);
let plan: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(plan["head"].as_str().unwrap().len(), 40);
assert_eq!(plan["tree_hash"].as_str().unwrap().len(), 40);
assert!(plan["tests"].as_array().unwrap().is_empty());
}
#[test]
fn a_base_with_no_merge_base_fails_instead_of_planning_nothing() {
let dir = project("no-base");
let out = fairlead_in(&dir, &["plan", "--base", "origin/not-there"]);
assert_eq!(out.status.code(), Some(2));
assert!(String::from_utf8_lossy(&out.stderr).contains("no merge base"));
}
#[test]
fn explain_gives_the_chain_for_a_selected_test_and_the_gap_for_another() {
let dir = project("explain");
write(&dir, "src/a.ts", "export const a = 3;\n");
let yes = fairlead_in(
&dir,
&["test", "--base", "main", "--explain", "test/a.test.ts"],
);
let yes = String::from_utf8_lossy(&yes.stdout).to_string();
assert!(
yes.contains("is selected (unit)") && yes.contains("-> test/a.test.ts"),
"{yes}"
);
let no = fairlead_in(
&dir,
&["test", "--base", "main", "--explain", "test/b.test.ts"],
);
assert!(String::from_utf8_lossy(&no.stdout).contains("isn't selected"));
let check = fairlead_in(&dir, &["test", "--base", "main", "--explain", "typecheck"]);
assert!(String::from_utf8_lossy(&check.stdout).contains("check typecheck is selected"));
}
#[test]
fn explain_names_each_hop_that_isnt_a_plain_import() {
let dir = project("explain-hops");
write(
&dir,
"src/api.ts",
"import { g } from './gen/missing';\nexport const api = g;\n",
);
write(
&dir,
"src/types.ts",
"import type { api } from './api';\nexport type T = typeof api;\n",
);
write(
&dir,
"test/pricing.test.ts",
"import type { T } from '../src/types';\nimport { b } from '../src/b';\n",
);
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-q", "-m", "api"]);
write(&dir, "src/a.ts", "export const a = 4;\n");
let out = fairlead_in(
&dir,
&[
"test",
"--base",
"main",
"--explain",
"test/pricing.test.ts",
],
);
let out = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
out.contains(
"-> src/api.ts (unresolved import `./gen/missing`, so it depends on its whole module)"
),
"{out}"
);
assert!(out.contains("-> src/types.ts (type import)"), "{out}");
assert!(
out.contains("-> test/pricing.test.ts (type import)"),
"{out}"
);
}
#[test]
fn explicit_files_plan_without_git() {
let dir = project("files");
let out = fairlead_in(&dir.join("src"), &["plan", "--files", "b.ts", "--json"]);
let plan: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(plan["changed"][0]["path"], "src/b.ts");
assert_eq!(plan["tests"][0]["path"], "test/b.test.ts");
}
#[test]
fn a_repository_the_config_finds_no_tests_in_is_told_to_run_init() {
let dir = std::env::temp_dir().join(format!("fairlead-plan-cli-none-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git(&dir, &["init", "-q", "-b", "main"]);
write(&dir, "main.go", "package main\n");
write(&dir, "main_test.go", "package main\n");
let out = fairlead_in(&dir, &["plan", "--files", "main.go"]);
let text = String::from_utf8_lossy(&out.stdout);
assert!(
text.contains("0 tests, 0 checks (everything, and there's nothing to run)"),
"{text}"
);
assert!(
text.contains("[no-tests]") && text.contains("fairlead init"),
"{text}"
);
let project = project("has-tests");
let out = fairlead_in(&project, &["plan", "--files", "src/a.ts"]);
assert!(!String::from_utf8_lossy(&out.stdout).contains("no-tests"));
}
#[test]
fn plan_schema_prints_the_committed_schema() {
let dir = project("schema");
let out = fairlead_in(&dir, &["plan", "--schema"]);
let schema: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert!(schema["properties"]["invocations"].is_object());
}
#[test]
fn config_check_names_test_files_no_runner_matches() {
let dir = project("runner-check");
write(
&dir,
"fairlead.toml",
"[[tests.runners]]\nid = \"vitest\"\nmatch = [\"test/a*\"]\ncommand = [\"vitest\"]\n",
);
let out = fairlead_in(&dir, &["config", "check"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr)
.contains("test/b.test.ts: no [[tests.runners]] matches it"));
}
#[test]
fn explicit_files_resolve_dot_dot_and_expand_directories() {
let dir = project("files-norm");
let up = fairlead_in(
&dir.join("test"),
&["plan", "--files", "../src/a.ts", "--json"],
);
let plan: serde_json::Value = serde_json::from_slice(&up.stdout).unwrap();
assert_eq!(plan["changed"][0]["path"], "src/a.ts");
assert_eq!(plan["tests"][0]["path"], "test/a.test.ts");
let folder = fairlead_in(&dir, &["plan", "--files", "src", "--json"]);
let plan: serde_json::Value = serde_json::from_slice(&folder.stdout).unwrap();
assert_eq!(plan["changed"].as_array().unwrap().len(), 2);
assert_eq!(plan["tests"].as_array().unwrap().len(), 2);
let outside = fairlead_in(&dir, &["plan", "--files", "../../elsewhere.ts"]);
assert_eq!(outside.status.code(), Some(2));
let everything = fairlead_in(&dir, &["plan", "--files", ".", "--json"]);
let plan: serde_json::Value = serde_json::from_slice(&everything.stdout).unwrap();
assert!(plan["changed"].as_array().unwrap().len() >= 5);
}
fn fairlead_with(dir: &Path, args: &[&str], env: &[(&str, &Path)]) -> Output {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_fairlead"));
cmd.args(args).current_dir(dir).env_clear();
for keep in ["PATH", "SYSTEMROOT", "HOME", "USERPROFILE"] {
if let Some(value) = std::env::var_os(keep) {
cmd.env(keep, value);
}
}
for (key, value) in env {
cmd.env(key, value);
}
cmd.output().unwrap()
}
#[test]
fn ci_plan_writes_the_plan_file_and_github_outputs() {
let dir = project("ci-plan");
write(&dir, "src/b.ts", "export const b = 2;\n");
let outputs = dir.join("github-output.txt");
let temp = dir.join("runner-temp");
std::fs::create_dir_all(&temp).unwrap();
let out = fairlead_with(
&dir,
&["ci", "plan", "--base", "main", "--format", "github"],
&[("GITHUB_OUTPUT", &outputs), ("RUNNER_TEMP", &temp)],
);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let plan_file = temp.join("fairlead-plan.json");
let plan: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&plan_file).unwrap()).unwrap();
assert_eq!(plan["tests"][0]["path"], "test/b.test.ts");
let written = std::fs::read_to_string(&outputs).unwrap();
assert!(
written.contains("all<<FAIRLEAD_EOF_0\nfalse\n"),
"{written}"
);
assert!(
written.contains("checks<<FAIRLEAD_EOF_0\ntypecheck\n"),
"{written}"
);
assert!(
written.contains("\"argv\":[\"vitest\",\"run\",\"test/b.test.ts\"]"),
"{written}"
);
}
#[test]
fn ci_plan_refuses_a_head_that_isnt_checked_out() {
let dir = project("ci-head");
let sha = |dir: &Path| {
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.unwrap();
String::from_utf8(out.stdout).unwrap().trim().to_string()
};
let first = sha(&dir);
std::fs::write(dir.join("later.txt"), "later\n").unwrap();
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-q", "-m", "later"]);
let head = sha(&dir);
let plan = |rev: &str| fairlead_in(&dir, &["ci", "plan", "--base", "main", "--head", rev]);
assert_eq!(plan(&head[..7]).status.code(), Some(0));
for rev in [first.as_str(), &head[..1], "0123456789abcdef"] {
assert_eq!(plan(rev).status.code(), Some(2), "--head {rev}");
}
let out = plan(&first);
assert!(String::from_utf8_lossy(&out.stderr).contains("check out the head commit"));
}
fn plan_with(dir: &Path, argvs: &[&[&str]]) -> PathBuf {
let invocations: Vec<serde_json::Value> = argvs
.iter()
.enumerate()
.map(|(i, argv)| serde_json::json!({ "id": format!("step{i}"), "kind": "check", "cwd": ".", "argv": argv }))
.collect();
let plan = serde_json::json!({
"version": 1, "plan_id": "pl_x", "fairlead_version": "0", "config_digest": "sha256:x",
"tree_hash": "x", "base": null, "head": "worktree", "all": false, "changed": [], "ignored": [],
"tests": [], "checks": [], "invocations": invocations, "unreached": [], "warnings": []
});
let path = dir.join("plan.json");
std::fs::write(&path, plan.to_string()).unwrap();
path
}
#[test]
fn ci_run_runs_every_invocation_and_fails_if_any_did() {
let dir = project("ci-run");
plan_with(
&dir,
&[
&["git", "--version"],
&["git", "not-a-git-command"],
&["git", "--version"],
],
);
let out = fairlead_in(&dir, &["ci", "run", "--plan", "plan.json"]);
assert_eq!(out.status.code(), Some(1));
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(stdout.matches("fairlead: (.) git").count(), 3, "{stdout}");
assert!(String::from_utf8_lossy(&out.stderr).contains("failed: step1"));
let fast = fairlead_in(&dir, &["ci", "run", "--plan", "plan.json", "--fail-fast"]);
assert_eq!(
String::from_utf8_lossy(&fast.stdout)
.matches("fairlead: (.) git")
.count(),
2
);
plan_with(&dir, &[&["git", "--version"]]);
let ok = fairlead_in(&dir, &["ci", "run", "--plan", "plan.json"]);
assert!(ok.status.success());
plan_with(&dir, &[&[], &["fairlead-no-such-program"]]);
let bad = fairlead_in(&dir, &["ci", "run", "--plan", "plan.json"]);
assert_eq!(bad.status.code(), Some(1));
assert!(String::from_utf8_lossy(&bad.stderr).contains("failed: step0, step1"));
}
#[test]
fn ci_run_refuses_a_plan_of_another_version() {
let dir = project("ci-version");
std::fs::write(dir.join("plan.json"), r#"{ "version": 2 }"#).unwrap();
let out = fairlead_in(&dir, &["ci", "run", "--plan", "plan.json"]);
assert_eq!(out.status.code(), Some(2));
assert!(String::from_utf8_lossy(&out.stderr).contains("plan version"));
}
#[test]
fn nested_and_escaped_globs_plan_and_a_broken_one_fails_config_check() {
let dir = project("globs");
let runner = "[[tests.runners]]\nid = \"vitest\"\nmatch = [\"**\"]\ncommand = [\"vitest\", \"{files}\"]\n";
write(&dir, "package.json", "{}\n");
write(
&dir,
"fairlead.toml",
&format!("{runner}\n[plan]\nrun_all = {{ replace = [] }}\n\n[[tests.owners]]\nmatch = \"{{test/**/*.test.{{ts,tsx}},e2e/**/*.test.ts}}\"\ncovers = [\"package.json\"]\n\n[[tests.owners]]\nmatch = \"test/a.test.ts\"\ncovers = [\"app/**/\\\\[...slug\\\\]/**\"]\n"),
);
let out = fairlead_in(&dir, &["config", "check"]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let out = fairlead_in(&dir, &["plan", "--files", "package.json", "--json"]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let plan: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(plan["tests"].as_array().unwrap().len(), 2);
let out = fairlead_in(&dir, &["plan", "--files", "src/a.ts", "--json"]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
write(
&dir,
"fairlead.toml",
&format!("{runner}\n[[tests.owners]]\nmatch = \"test/a.test.ts\"\ncovers = [\"{{src,{{lib}}\"]\n"),
);
let out = fairlead_in(&dir, &["config", "check"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("tests.owners[0].covers[0]"));
}
#[test]
fn an_empty_list_over_inherited_items_is_a_warning() {
let dir = project("empty-append");
write(
&dir,
"fairlead.toml",
"[[tests.runners]]\nid = \"vitest\"\nmatch = [\"**\"]\ncommand = [\"vitest\"]\n\n[plan]\nrun_all = []\n",
);
let out = fairlead_in(&dir, &["config", "check"]);
assert!(out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("warning: plan.run_all:"), "{err}");
assert!(err.contains("{ replace = [] }"), "{err}");
}
#[test]
fn config_check_warns_about_an_owner_rule_that_names_no_test() {
let dir = project("idle-owner");
write(
&dir,
"fairlead.toml",
"[[tests.runners]]\nid = \"vitest\"\nmatch = [\"**\"]\ncommand = [\"vitest\"]\n\n[[tests.owners]]\nmatch = \"e2e/**/*.spec.ts\"\ncovers = [\"src/**\"]\n\n[[tests.owners]]\nmatch = \"test/a.test.ts\"\ncovers = [\"src/a.ts\"]\n",
);
let out = fairlead_in(&dir, &["config", "check"]);
assert!(out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("warning: tests.owners[0]: `e2e/**/*.spec.ts` matches no test file"),
"{err}"
);
assert!(!err.contains("tests.owners[1]"), "{err}");
}
#[test]
fn the_laravel_pack_links_feature_tests_to_the_http_layer_and_stops_at_the_boot_path() {
let dir =
std::env::temp_dir().join(format!("fairlead-plan-cli-laravel-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git(&dir, &["init", "-q", "-b", "main"]);
write(&dir, "fairlead.toml", "extends = [\"laravel\"]\n\n[tests]\nmatch = { replace = [\"tests/**/*Test.php\"] }\n\n[[tests.runners]]\nid = \"phpunit\"\nmatch = [\"tests/**/*Test.php\"]\ncommand = [\"vendor/bin/phpunit\", \"{files}\"]\n");
write(&dir, "composer.json", "{\"autoload\": {\"psr-4\": {\"App\\\\\": \"app/\"}}, \"autoload-dev\": {\"psr-4\": {\"Tests\\\\\": \"tests/\"}}}\n");
write(
&dir,
"bootstrap/app.php",
"<?php\nrequire __DIR__ . '/../routes/web.php';\n",
);
write(&dir, "routes/web.php", "<?php\nuse App\\Http\\Controllers\\UserController;\nRoute::get('/users', [UserController::class, 'index']);\n");
write(&dir, "app/Http/Controllers/UserController.php", "<?php\nnamespace App\\Http\\Controllers;\nclass UserController { public function index() { return view('users.index'); } }\n");
write(
&dir,
"resources/views/users/index.blade.php",
"<h1>Users</h1>\n",
);
write(
&dir,
"app/Providers/AppServiceProvider.php",
"<?php\nnamespace App\\Providers;\nclass AppServiceProvider {}\n",
);
write(
&dir,
"app/Support/Money.php",
"<?php\nnamespace App\\Support;\nclass Money {}\n",
);
write(&dir, "tests/TestCase.php", "<?php\nnamespace Tests;\nabstract class TestCase { public function createApplication() { return require __DIR__ . '/../bootstrap/app.php'; } }\n");
write(&dir, "tests/Feature/UsersTest.php", "<?php\nnamespace Tests\\Feature;\nuse Tests\\TestCase;\nclass UsersTest extends TestCase {}\n");
write(&dir, "tests/Unit/MoneyTest.php", "<?php\nnamespace Tests\\Unit;\nuse Tests\\TestCase;\nuse App\\Support\\Money;\nclass MoneyTest extends TestCase {}\n");
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-q", "-m", "app"]);
let plan = |file: &str| -> (Vec<String>, bool) {
let out = fairlead_in(&dir, &["plan", "--files", file, "--json"]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let plan: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let tests = plan["tests"]
.as_array()
.unwrap()
.iter()
.map(|t| t["path"].as_str().unwrap().to_string())
.collect();
(tests, plan["all"].as_bool().unwrap_or(false))
};
assert_eq!(
plan("app/Http/Controllers/UserController.php"),
(vec!["tests/Feature/UsersTest.php".to_string()], false),
"the feature test, and not the unit test through the boot path"
);
assert_eq!(
plan("resources/views/users/index.blade.php").0,
["tests/Feature/UsersTest.php"]
);
assert_eq!(
plan("app/Support/Money.php").0,
["tests/Unit/MoneyTest.php"]
);
assert!(
plan("app/Providers/AppServiceProvider.php").1,
"a provider runs everything"
);
}
#[test]
fn the_next_pack_links_routes_to_their_layouts_and_next_e2e_adds_the_specs() {
let dir = std::env::temp_dir().join(format!("fairlead-plan-cli-next-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git(&dir, &["init", "-q", "-b", "main"]);
write(
&dir,
"package.json",
"{\"dependencies\": {\"next\": \"^15\"}}\n",
);
write(
&dir,
"app/layout.tsx",
"export default function Root() { return null }\n",
);
write(
&dir,
"app/shop/layout.tsx",
"export default function Shop() { return null }\n",
);
write(
&dir,
"app/shop/page.tsx",
"export default function Page() { return null }\n",
);
write(
&dir,
"app/shop/page.test.tsx",
"import Page from './page'\n",
);
write(
&dir,
"e2e/shop.spec.ts",
"test('shop', async ({ page }) => { await page.goto('/shop') })\n",
);
let config = |packs: &str| {
format!("extends = [{packs}]\n\n[tests]\nmatch = {{ replace = [\"app/**/*.test.tsx\", \"e2e/*.spec.ts\"] }}\n\n[[tests.runners]]\nid = \"vitest\"\nmatch = [\"app/**\"]\ncommand = [\"vitest\", \"run\", \"{{files}}\"]\n\n[[tests.runners]]\nid = \"playwright\"\nmatch = [\"e2e/**\"]\ncommand = [\"playwright\", \"test\", \"{{files}}\"]\n")
};
let plan = |packs: &str| -> Vec<String> {
write(&dir, "fairlead.toml", &config(packs));
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-q", "--allow-empty", "-m", packs]);
let out = fairlead_in(&dir, &["plan", "--files", "app/shop/layout.tsx", "--json"]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let plan: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let mut tests: Vec<String> = plan["tests"]
.as_array()
.unwrap()
.iter()
.map(|t| t["path"].as_str().unwrap().to_string())
.collect();
tests.sort();
tests
};
assert_eq!(
plan("\"next\""),
["app/shop/page.test.tsx"],
"a layout reaches its routes' tests, not every spec"
);
assert_eq!(
plan("\"next\", \"next-e2e\""),
["app/shop/page.test.tsx", "e2e/shop.spec.ts"]
);
}