mod support;
use std::fs;
use std::path::Path;
use std::process::{Command, Output};
use loopflow::id::WaveId;
use loopflow::store::sqlite::SqliteStore;
use loopflow::work::wave::Wave;
use loopflow_test_support::TestRepo;
fn command(home: &Path, cwd: &Path, args: &[&str]) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_lf"));
command
.env_clear()
.env("HOME", home)
.env("LF_HOME", home.join(".lf"))
.env("LF_DB_PATH", home.join(".lf/loopflow.db"))
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
.env("NO_COLOR", "1")
.current_dir(cwd)
.args(args);
command
}
fn success(output: Output) -> String {
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap()
}
#[test]
fn explicit_branch_data_overrides_inherited_observation_and_run_context() {
let home = tempfile::tempdir().unwrap();
let source = tempfile::tempdir().unwrap();
let store_path = home.path().join(".lf/loopflow.db");
SqliteStore::new(&store_path).unwrap();
let source_db = source.path().join("loopflow.db");
fs::write(&source_db, b"source must not be opened").unwrap();
let marker = loopflow::durable::HomeId::new();
for args in [
vec![
"home",
"observe",
marker.as_str(),
"ssh://proof@example.invalid",
"--json",
],
vec!["ps", "--json"],
] {
let output = command(home.path(), home.path(), &args)
.env_remove("LF_DB_PATH")
.env("LF_CONTROL_HOME", source.path())
.env("LF_CONTROL_DB_PATH", &source_db)
.env("LF_RUN_DIR", source.path().join("runs/parent"))
.env("LF_RUN_ID", "run_parent")
.output()
.unwrap();
success(output);
}
let connection = rusqlite::Connection::open(&store_path).unwrap();
let route: String = connection
.query_row(
"SELECT route FROM homes WHERE id=?1",
[marker.as_str()],
|row| row.get(0),
)
.unwrap();
assert_eq!(route, "ssh://proof@example.invalid");
assert_eq!(fs::read(source_db).unwrap(), b"source must not be opened");
assert_eq!(fs::read_dir(source.path()).unwrap().count(), 1);
}
#[test]
fn installation_restriction_uses_checkout_or_explicit_declaration() {
let home = tempfile::tempdir().unwrap();
let repo = TestRepo::new();
repo.create_branch("install-task");
let task = support::register_unrun_task(
&home.path().join(".lf"),
repo.path(),
"install-task",
&repo.head_sha(),
);
let store_path = home.path().join(".lf/loopflow.db");
for (cwd, declaration, restricted) in [
(repo.path(), None, true),
(home.path(), Some(format!("task:{}", task.task.id)), true),
(home.path(), None, false),
] {
let mut cmd = command(
home.path(),
cwd,
&["install", "promote", "--cli-target", "/unused/lf"],
);
cmd.env("LF_TASK_ORIGIN", "1")
.env("LF_WORK_ADVANCE_CLAIM", "obsolete")
.env("LF_INSTALL_PROMOTE_HOP", "6");
if let Some(value) = declaration {
cmd.env("LF_AS", value);
}
let output = cmd.output().unwrap();
assert!(!output.status.success());
let error = String::from_utf8_lossy(&output.stderr);
let expected = if restricted {
"Task Work cannot change"
} else {
"did not converge"
};
assert!(error.contains(expected), "{error}");
}
let output = command(
home.path(),
repo.path(),
&[
"install",
"local-preflight",
"--store",
store_path.to_str().unwrap(),
"--json",
],
)
.output()
.unwrap();
let preview: serde_json::Value = serde_json::from_str(&success(output)).unwrap();
assert!(preview.get("compatibility").is_some());
}
#[test]
fn machine_commands_and_catalog_work_without_git_or_a_repository() {
let home = tempfile::tempdir().unwrap();
let cwd = tempfile::tempdir().unwrap();
let no_tools = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink("/bin/ps", no_tools.path().join("ps")).unwrap();
for args in [
vec!["list"],
vec!["flow", "show", "code"],
vec!["flow", "validate", "code"],
vec!["auth", "status"],
vec!["auth", "status", "--details"],
vec!["auth", "route", "show"],
vec!["auth", "route", "show", "--repo", "example/project"],
vec!["wave", "list", "--json"],
vec!["ps", "--json"],
] {
let output = command(home.path(), cwd.path(), &args)
.env("PATH", no_tools.path())
.output()
.unwrap();
let stdout = success(output);
if args == ["list"] {
assert!(stdout.contains("debug"));
assert!(stdout.contains("unbreak"));
}
if args.get(1) == Some(&"route") {
assert!(stdout.contains("claude"));
}
assert!(
!cwd.path().join(".lf").exists(),
"{args:?} wrote repository state"
);
}
}
#[test]
fn repository_errors_do_not_prevent_home_command_admission() {
let home = tempfile::tempdir().unwrap();
let cwd = tempfile::tempdir().unwrap();
let output = command(home.path(), cwd.path(), &["sync", "--plan"])
.output()
.unwrap();
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("Run lf sync from a Git repository"));
let output = command(home.path(), cwd.path(), &["home", "id"])
.output()
.unwrap();
let identity = success(output);
assert!(!identity.trim().is_empty());
let database = rusqlite::Connection::open(home.path().join(".lf/loopflow.db")).unwrap();
let commands: i64 = database
.query_row("SELECT count(*) FROM execs", [], |row| row.get(0))
.unwrap();
assert_eq!(
commands, 2,
"the failed sync and Home read each own an Exec"
);
let output = command(
home.path(),
cwd.path(),
&["auth", "route", "set", "claude", "person@example.com"],
)
.output()
.unwrap();
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("--repo owner/name"));
assert!(!cwd.path().join(".lf").exists());
}
#[test]
fn global_wave_listing_and_repository_catalog_use_real_checkout_scope() {
let home = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let repo = TestRepo::new();
let other = TestRepo::new();
let linked = repo.create_named_worktree("list");
let nested = repo.path().join("nested");
fs::create_dir_all(&nested).unwrap();
for root in [repo.path(), linked.as_path()] {
fs::create_dir_all(root.join(".lf/skills")).unwrap();
fs::write(
root.join(".lf/skills/checkout-marker.md"),
"A repository-specific skill.",
)
.unwrap();
}
fs::create_dir_all(home.path().join(".lf")).unwrap();
let store = SqliteStore::new(&home.path().join(".lf/loopflow.db")).unwrap();
for (name, root) in [("first", repo.path()), ("second", other.path())] {
store
.create_wave(&Wave::new(
WaveId::new(),
name.into(),
root.display().to_string(),
))
.unwrap();
}
for (cwd, count, local_catalog) in [
(outside.path(), 2, false),
(repo.path(), 1, true),
(nested.as_path(), 1, true),
(linked.as_path(), 1, true),
] {
let output = success(
command(home.path(), cwd, &["wave", "list", "--json"])
.output()
.unwrap(),
);
let waves: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(
waves.as_array().unwrap().len(),
count,
"cwd: {}",
cwd.display()
);
let catalog = success(command(home.path(), cwd, &["list"]).output().unwrap());
assert_eq!(catalog.contains("checkout-marker"), local_catalog);
}
assert!(!outside.path().join(".lf").exists());
}
#[test]
fn broken_git_metadata_is_not_treated_as_an_ordinary_folder() {
let home = tempfile::tempdir().unwrap();
let cwd = tempfile::tempdir().unwrap();
fs::write(
cwd.path().join(".git"),
"gitdir: /nonexistent/loopflow-test-repository",
)
.unwrap();
let output = command(home.path(), cwd.path(), &["list"])
.output()
.unwrap();
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("cannot discover Git repository"));
}
#[cfg(target_os = "macos")]
#[test]
fn scheduled_install_is_independent_of_the_invoking_checkout_and_reusable() {
use std::os::unix::fs::{symlink, PermissionsExt};
let home = tempfile::tempdir().unwrap();
let cwd = tempfile::tempdir().unwrap();
let bin = home.path().join("bin");
fs::create_dir_all(&bin).unwrap();
symlink("/usr/bin/id", bin.join("id")).unwrap();
fs::write(bin.join("launchctl"), "#!/bin/sh\ncase $1 in\nprint) test -f \"$HOME/loaded\";;\nbootstrap) : > \"$HOME/loaded\";;\nbootout) /bin/rm \"$HOME/loaded\";;\nesac\n").unwrap();
fs::set_permissions(bin.join("launchctl"), fs::Permissions::from_mode(0o755)).unwrap();
let run = |args: &[&str]| {
success(
command(home.path(), cwd.path(), &["install", "schedule"])
.args(args)
.env("PATH", &bin)
.env("LF_INSTALL_DIR", home.path().join("installed & current"))
.output()
.unwrap(),
)
};
assert!(run(&[]).contains("weekly"));
let path = home
.path()
.join("Library/LaunchAgents/com.loopflow.refresh.plist");
let read_plist = || -> serde_json::Value {
let output = Command::new("/usr/bin/plutil")
.args(["-convert", "json", "-o", "-"])
.arg(&path)
.output()
.unwrap();
serde_json::from_str(&success(output)).unwrap()
};
let plist = read_plist();
assert_eq!(plist["ProgramArguments"][1], "install");
assert_eq!(
plist["EnvironmentVariables"]["LF_INSTALL_DIR"],
home.path().join("installed & current").to_str().unwrap()
);
assert!(plist.get("WorkingDirectory").is_none());
assert_eq!(plist["RunAtLoad"], true);
assert_eq!(
plist["StartCalendarInterval"],
serde_json::json!({"Weekday": 1, "Hour": 9, "Minute": 0})
);
assert!(home.path().join("loaded").exists());
let modified = fs::metadata(&path).unwrap().modified().unwrap();
run(&[]);
assert_eq!(fs::metadata(&path).unwrap().modified().unwrap(), modified);
fs::remove_file(home.path().join("loaded")).unwrap();
run(&[]);
assert!(home.path().join("loaded").exists());
for (frequency, expected) in [
("daily", serde_json::json!({"Hour": 9, "Minute": 0})),
("hourly", serde_json::json!({"Minute": 0})),
(
"5min",
serde_json::json!([
{"Minute": 0}, {"Minute": 5}, {"Minute": 10}, {"Minute": 15},
{"Minute": 20}, {"Minute": 25}, {"Minute": 30}, {"Minute": 35},
{"Minute": 40}, {"Minute": 45}, {"Minute": 50}, {"Minute": 55}
]),
),
(
"weekly",
serde_json::json!({"Weekday": 1, "Hour": 9, "Minute": 0}),
),
] {
run(&[frequency]);
assert_eq!(read_plist()["StartCalendarInterval"], expected);
assert!(home.path().join("loaded").exists());
let modified = fs::metadata(&path).unwrap().modified().unwrap();
run(&[frequency]);
assert_eq!(fs::metadata(&path).unwrap().modified().unwrap(), modified);
}
let before = fs::read(&path).unwrap();
let output = command(home.path(), cwd.path(), &["install", "schedule", "monthly"])
.env("PATH", &bin)
.output()
.unwrap();
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("invalid value"));
assert_eq!(fs::read(&path).unwrap(), before);
}