aboutsummaryrefslogtreecommitdiff
path: root/tests/cli_wrapper.rs
diff options
context:
space:
mode:
Diffstat (limited to 'tests/cli_wrapper.rs')
-rw-r--r--tests/cli_wrapper.rs972
1 files changed, 972 insertions, 0 deletions
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index da730b80..367c3ece 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -186,6 +186,25 @@ fn run_named_cli_with_socket_override(
args: &[&str],
socket_override: Option<&Path>,
) -> std::process::Output {
+ run_named_cli_with_env_and_socket_override(config_home, runtime_dir, args, &[], socket_override)
+}
+
+fn run_named_cli_with_env(
+ config_home: &Path,
+ runtime_dir: &Path,
+ args: &[&str],
+ envs: &[(&str, &Path)],
+) -> std::process::Output {
+ run_named_cli_with_env_and_socket_override(config_home, runtime_dir, args, envs, None)
+}
+
+fn run_named_cli_with_env_and_socket_override(
+ config_home: &Path,
+ runtime_dir: &Path,
+ args: &[&str],
+ envs: &[(&str, &Path)],
+ socket_override: Option<&Path>,
+) -> std::process::Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
command
.args(args)
@@ -193,6 +212,9 @@ fn run_named_cli_with_socket_override(
.env("XDG_RUNTIME_DIR", runtime_dir)
.env_remove("HERDR_CLIENT_SOCKET_PATH")
.env_remove("HERDR_ENV");
+ for (key, value) in envs {
+ command.env(key, value);
+ }
if let Some(socket_override) = socket_override {
command.env("HERDR_SOCKET_PATH", socket_override);
} else {
@@ -2741,6 +2763,956 @@ fn wait_agent_status_exits_when_idle_status_matches() {
}
#[test]
+fn plugin_link_list_unlink_cli_smoke_test() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("herdr.sock");
+ let plugin_dir = base.join("plugins").join("layout");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.layout"
+name = "Layout"
+version = "0.1.0"
+description = "Apply a preferred Herdr layout"
+
+[[actions]]
+id = "apply"
+title = "Apply layout"
+contexts = ["workspace"]
+command = ["sh", "-c", "echo layout"]
+
+[[events]]
+on = "worktree.created"
+command = ["sh", "-c", "echo worktree"]
+
+[[panes]]
+id = "board"
+title = "Board"
+placement = "tab"
+command = ["sh", "-c", "sleep 5"]
+"#,
+ )
+ .unwrap();
+
+ let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+ let workspace = run_cli_json(
+ &socket_path,
+ &[
+ "workspace",
+ "create",
+ "--cwd",
+ base.to_str().unwrap(),
+ "--focus",
+ ],
+ );
+ assert_eq!(workspace["result"]["type"], "workspace_created");
+
+ let linked = run_cli_json_in_dir(&socket_path, &["plugin", "link", "plugins/layout"], &base);
+ assert_eq!(linked["result"]["type"], "plugin_linked");
+ assert_eq!(linked["result"]["plugin"]["plugin_id"], "example.layout");
+ assert_eq!(linked["result"]["plugin"]["actions"][0]["id"], "apply");
+ assert_eq!(
+ linked["result"]["plugin"]["events"][0]["on"],
+ "worktree.created"
+ );
+ assert_eq!(linked["result"]["plugin"]["panes"][0]["id"], "board");
+
+ let listed_human = run_cli(&socket_path, &["plugin", "list"]);
+ assert!(listed_human.status.success());
+ assert!(String::from_utf8_lossy(&listed_human.stdout).contains("example.layout"));
+
+ let listed = run_cli_json(&socket_path, &["plugin", "list", "--json"]);
+ assert_eq!(listed["result"]["type"], "plugin_list");
+ assert_eq!(
+ listed["result"]["plugins"][0]["plugin_id"],
+ "example.layout"
+ );
+
+ let invoked = run_cli_json(
+ &socket_path,
+ &[
+ "plugin",
+ "action",
+ "invoke",
+ "apply",
+ "--plugin",
+ "example.layout",
+ ],
+ );
+ assert_eq!(invoked["result"]["type"], "plugin_action_invoked");
+ assert_eq!(invoked["result"]["action"]["action_id"], "apply");
+
+ let logs = run_cli_json(
+ &socket_path,
+ &[
+ "plugin",
+ "log",
+ "list",
+ "--plugin",
+ "example.layout",
+ "--limit",
+ "5",
+ ],
+ );
+ assert_eq!(logs["result"]["type"], "plugin_log_list");
+ assert!(!logs["result"]["logs"].as_array().unwrap().is_empty());
+
+ let pane = run_cli_json(
+ &socket_path,
+ &[
+ "plugin",
+ "pane",
+ "open",
+ "--plugin",
+ "example.layout",
+ "--entrypoint",
+ "board",
+ "--env",
+ "HERDR_ROLE=board",
+ "--no-focus",
+ ],
+ );
+ assert_eq!(pane["result"]["type"], "plugin_pane_opened");
+ assert_eq!(pane["result"]["plugin_pane"]["entrypoint"], "board");
+
+ let missing_plugin_value = run_cli(&socket_path, &["plugin", "list", "--plugin"]);
+ assert_eq!(missing_plugin_value.status.code(), Some(2));
+ assert!(String::from_utf8_lossy(&missing_plugin_value.stderr)
+ .contains("missing value for --plugin"));
+
+ let invalid_limit = run_cli(
+ &socket_path,
+ &["plugin", "log", "list", "--limit", "not-a-number"],
+ );
+ assert_eq!(invalid_limit.status.code(), Some(2));
+ assert!(String::from_utf8_lossy(&invalid_limit.stderr).contains("invalid --limit value"));
+
+ let unlinked = run_cli_json(&socket_path, &["plugin", "unlink", "example.layout"]);
+ assert_eq!(unlinked["result"]["type"], "plugin_unlinked");
+ assert_eq!(unlinked["result"]["removed"], true);
+
+ let listed = run_cli_json(&socket_path, &["plugin", "list", "--json"]);
+ assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn plugin_install_list_uninstall_offline_cli_smoke_test() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let source_repo = base.join("source-repo");
+ let plugin_dir = source_repo.join("worktree-bootstrap");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ create_committed_repo(&source_repo);
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.worktree-bootstrap"
+name = "Worktree Bootstrap"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[build]]
+command = ["sh", "-c", "echo built > built.txt; if [ -n \"$HERDR_SESSION\" ]; then echo \"$HERDR_SESSION\" > leaked-session.txt; fi"]
+
+[[actions]]
+id = "bootstrap"
+title = "Bootstrap"
+command = ["sh", "-c", "echo bootstrap"]
+"#,
+ )
+ .unwrap();
+ run_git(
+ &source_repo,
+ &["add", "worktree-bootstrap/herdr-plugin.toml"],
+ );
+ run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]);
+
+ fs::create_dir_all(&config_home).unwrap();
+ fs::create_dir_all(&runtime_dir).unwrap();
+ let git_config = base.join("gitconfig");
+ fs::write(
+ &git_config,
+ format!(
+ "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
+ source_repo.display()
+ ),
+ )
+ .unwrap();
+
+ let install = run_named_cli_with_env(
+ &config_home,
+ &runtime_dir,
+ &[
+ "--session",
+ "plugins",
+ "plugin",
+ "install",
+ "ogulcancelik/herdr-plugin-examples/worktree-bootstrap",
+ "--yes",
+ ],
+ &[
+ ("GIT_CONFIG_GLOBAL", &git_config),
+ ("HERDR_SESSION", Path::new("leaked-session")),
+ ],
+ );
+ assert!(
+ install.status.success(),
+ "install failed\nstdout: {}\nstderr: {}",
+ String::from_utf8_lossy(&install.stdout),
+ String::from_utf8_lossy(&install.stderr)
+ );
+
+ let listed = run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &["--session", "plugins", "plugin", "list", "--json"],
+ );
+ let plugin = &listed["result"]["plugins"][0];
+ assert_eq!(plugin["plugin_id"], "example.worktree-bootstrap");
+ assert_eq!(plugin["source"]["kind"], "github");
+ assert_eq!(plugin["source"]["owner"], "ogulcancelik");
+ assert_eq!(plugin["source"]["repo"], "herdr-plugin-examples");
+ assert_eq!(plugin["source"]["subdir"], "worktree-bootstrap");
+ assert!(plugin["source"]["resolved_commit"].as_str().is_some());
+ let managed_path = PathBuf::from(plugin["source"]["managed_path"].as_str().unwrap());
+ assert!(managed_path.exists(), "managed checkout should exist");
+ assert!(
+ managed_path
+ .join("worktree-bootstrap")
+ .join("built.txt")
+ .exists(),
+ "build artifact should be preserved in managed checkout"
+ );
+ assert!(
+ !managed_path
+ .join("worktree-bootstrap")
+ .join("leaked-session.txt")
+ .exists(),
+ "build command should not inherit HERDR_SESSION"
+ );
+
+ let uninstall = run_named_cli(
+ &config_home,
+ &runtime_dir,
+ &[
+ "--session",
+ "plugins",
+ "plugin",
+ "uninstall",
+ "example.worktree-bootstrap",
+ ],
+ );
+ assert!(
+ uninstall.status.success(),
+ "uninstall failed\nstdout: {}\nstderr: {}",
+ String::from_utf8_lossy(&uninstall.stdout),
+ String::from_utf8_lossy(&uninstall.stderr)
+ );
+ assert!(
+ !managed_path.exists(),
+ "managed checkout should be deleted on uninstall"
+ );
+
+ let listed = run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &["--session", "plugins", "plugin", "list", "--json"],
+ );
+ assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn plugin_install_build_failure_does_not_register_or_create_checkout() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let source_repo = base.join("source-repo");
+ let plugin_dir = source_repo.join("build-fail");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ create_committed_repo(&source_repo);
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.build-fail"
+name = "Build Fail"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[build]]
+command = ["sh", "-c", "echo before-fail && echo failed-build >&2 && exit 7"]
+
+[[actions]]
+id = "run"
+title = "Run"
+command = ["sh", "-c", "echo should-not-install"]
+"#,
+ )
+ .unwrap();
+ run_git(&source_repo, &["add", "build-fail/herdr-plugin.toml"]);
+ run_git(
+ &source_repo,
+ &["commit", "--quiet", "-m", "add failing plugin"],
+ );
+
+ fs::create_dir_all(&config_home).unwrap();
+ fs::create_dir_all(&runtime_dir).unwrap();
+ let git_config = base.join("gitconfig");
+ fs::write(
+ &git_config,
+ format!(
+ "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
+ source_repo.display()
+ ),
+ )
+ .unwrap();
+
+ let install = run_named_cli_with_env(
+ &config_home,
+ &runtime_dir,
+ &[
+ "--session",
+ "plugins",
+ "plugin",
+ "install",
+ "ogulcancelik/herdr-plugin-examples/build-fail",
+ "--yes",
+ ],
+ &[("GIT_CONFIG_GLOBAL", &git_config)],
+ );
+ assert!(
+ !install.status.success(),
+ "install should fail when build command fails"
+ );
+ assert_eq!(install.status.code(), Some(1));
+ let stderr = String::from_utf8_lossy(&install.stderr);
+ assert!(stderr.contains("error: plugin build failed"), "{stderr}");
+ assert!(stderr.contains(" plugin: example.build-fail"), "{stderr}");
+ assert!(stderr.contains(" build: 1/1"), "{stderr}");
+ assert!(stderr.contains(" cwd: "), "{stderr}");
+ assert!(
+ stderr.contains(" command: sh -c echo before-fail && echo failed-build >&2 && exit 7"),
+ "{stderr}"
+ );
+ assert!(stderr.contains(" status: exit status: 7"), "{stderr}");
+ assert!(stderr.contains("stdout:\nbefore-fail"), "{stderr}");
+ assert!(stderr.contains("stderr:\nfailed-build"), "{stderr}");
+ assert!(stderr.contains("Plugin was not installed."), "{stderr}");
+ assert!(!stderr.contains("Error: Custom"), "{stderr}");
+
+ let listed = run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &["--session", "plugins", "plugin", "list", "--json"],
+ );
+ assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
+
+ let managed_checkout = config_home
+ .join("herdr-dev")
+ .join("plugins")
+ .join("github")
+ .join("example.build-fail");
+ assert!(
+ !managed_checkout.exists(),
+ "failed build should not create managed checkout"
+ );
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn plugin_install_build_spawn_failure_prints_clean_error() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let source_repo = base.join("source-repo");
+ let plugin_dir = source_repo.join("missing-tool");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ create_committed_repo(&source_repo);
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.missing-tool"
+name = "Missing Tool"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[build]]
+command = ["definitely-missing-herdr-build-tool-xyz"]
+
+[[actions]]
+id = "run"
+title = "Run"
+command = ["sh", "-c", "echo should-not-install"]
+"#,
+ )
+ .unwrap();
+ run_git(&source_repo, &["add", "missing-tool/herdr-plugin.toml"]);
+ run_git(
+ &source_repo,
+ &["commit", "--quiet", "-m", "add missing tool plugin"],
+ );
+
+ fs::create_dir_all(&config_home).unwrap();
+ fs::create_dir_all(&runtime_dir).unwrap();
+ let git_config = base.join("gitconfig");
+ fs::write(
+ &git_config,
+ format!(
+ "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
+ source_repo.display()
+ ),
+ )
+ .unwrap();
+
+ let install = run_named_cli_with_env(
+ &config_home,
+ &runtime_dir,
+ &[
+ "--session",
+ "plugins",
+ "plugin",
+ "install",
+ "ogulcancelik/herdr-plugin-examples/missing-tool",
+ "--yes",
+ ],
+ &[("GIT_CONFIG_GLOBAL", &git_config)],
+ );
+ assert!(
+ !install.status.success(),
+ "install should fail when build command cannot start"
+ );
+ assert_eq!(install.status.code(), Some(1));
+ let stderr = String::from_utf8_lossy(&install.stderr);
+ assert!(stderr.contains("error: plugin build failed"), "{stderr}");
+ assert!(
+ stderr.contains(" plugin: example.missing-tool"),
+ "{stderr}"
+ );
+ assert!(stderr.contains(" build: 1/1"), "{stderr}");
+ assert!(
+ stderr.contains(" command: definitely-missing-herdr-build-tool-xyz"),
+ "{stderr}"
+ );
+ assert!(stderr.contains(" error: failed to start:"), "{stderr}");
+ assert!(stderr.contains("Plugin was not installed."), "{stderr}");
+ assert!(!stderr.contains("Error: Custom"), "{stderr}");
+
+ let listed = run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &["--session", "plugins", "plugin", "list", "--json"],
+ );
+ assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn plugin_install_rejects_manifest_changed_by_build() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let source_repo = base.join("source-repo");
+ let plugin_dir = source_repo.join("manifest-mutator");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ create_committed_repo(&source_repo);
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.manifest-mutator"
+name = "Manifest Mutator"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[build]]
+command = ["sh", "mutate.sh"]
+
+[[actions]]
+id = "run"
+title = "Run reviewed command"
+command = ["sh", "-c", "echo reviewed"]
+"#,
+ )
+ .unwrap();
+ fs::write(
+ plugin_dir.join("mutate.sh"),
+ r#"cat > herdr-plugin.toml <<'EOF'
+id = "example.manifest-mutator"
+name = "Manifest Mutator"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[actions]]
+id = "run"
+title = "Run changed command"
+command = ["sh", "-c", "echo changed"]
+EOF
+"#,
+ )
+ .unwrap();
+ run_git(&source_repo, &["add", "manifest-mutator"]);
+ run_git(
+ &source_repo,
+ &["commit", "--quiet", "-m", "add mutating plugin"],
+ );
+
+ fs::create_dir_all(&config_home).unwrap();
+ fs::create_dir_all(&runtime_dir).unwrap();
+ let git_config = base.join("gitconfig");
+ fs::write(
+ &git_config,
+ format!(
+ "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
+ source_repo.display()
+ ),
+ )
+ .unwrap();
+
+ let install = run_named_cli_with_env(
+ &config_home,
+ &runtime_dir,
+ &[
+ "--session",
+ "plugins",
+ "plugin",
+ "install",
+ "ogulcancelik/herdr-plugin-examples/manifest-mutator",
+ "--yes",
+ ],
+ &[("GIT_CONFIG_GLOBAL", &git_config)],
+ );
+ assert!(
+ !install.status.success(),
+ "install should fail when build changes reviewed manifest"
+ );
+ let stderr = String::from_utf8_lossy(&install.stderr);
+ assert!(
+ stderr.contains("plugin build changed herdr-plugin.toml after install preview"),
+ "{stderr}"
+ );
+
+ let listed = run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &["--session", "plugins", "plugin", "list", "--json"],
+ );
+ assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
+
+ let managed_checkout = config_home
+ .join("herdr-dev")
+ .join("plugins")
+ .join("github")
+ .join("example.manifest-mutator");
+ assert!(
+ !managed_checkout.exists(),
+ "manifest mutation should not create managed checkout"
+ );
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn plugin_install_restores_previous_checkout_when_registration_fails() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("fake-herdr.sock");
+ let source_repo = base.join("source-repo");
+ let plugin_dir = source_repo.join("worktree-bootstrap");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ create_committed_repo(&source_repo);
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.worktree-bootstrap"
+name = "Worktree Bootstrap"
+version = "0.2.0"
+platforms = ["linux", "macos", "windows"]
+
+[[actions]]
+id = "bootstrap"
+title = "Bootstrap"
+command = ["sh", "-c", "echo new"]
+"#,
+ )
+ .unwrap();
+ run_git(
+ &source_repo,
+ &["add", "worktree-bootstrap/herdr-plugin.toml"],
+ );
+ run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]);
+
+ fs::create_dir_all(&config_home).unwrap();
+ fs::create_dir_all(&runtime_dir).unwrap();
+ let managed_checkout = config_home
+ .join("herdr-dev")
+ .join("plugins")
+ .join("github")
+ .join("example.worktree-bootstrap");
+ fs::create_dir_all(&managed_checkout).unwrap();
+ fs::write(managed_checkout.join("old-marker"), "old checkout\n").unwrap();
+
+ let git_config = base.join("gitconfig");
+ fs::write(
+ &git_config,
+ format!(
+ "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
+ source_repo.display()
+ ),
+ )
+ .unwrap();
+
+ let listener = UnixListener::bind(&socket_path).unwrap();
+ let managed_checkout_for_server = managed_checkout.clone();
+ let server = thread::spawn(move || {
+ let (mut first, _) = listener.accept().unwrap();
+ let mut first_line = String::new();
+ let mut first_reader = BufReader::new(first.try_clone().unwrap());
+ first_reader.read_line(&mut first_line).unwrap();
+ let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap();
+ assert_eq!(first_request["method"], "plugin.list");
+ writeln!(
+ first,
+ "{}",
+ serde_json::json!({
+ "id": "cli:plugin",
+ "result": {
+ "type": "plugin_list",
+ "plugins": [{
+ "plugin_id": "example.worktree-bootstrap",
+ "name": "Worktree Bootstrap",
+ "version": "0.1.0",
+ "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(),
+ "plugin_root": managed_checkout_for_server.display().to_string(),
+ "enabled": true,
+ "source": {
+ "kind": "github",
+ "owner": "ogulcancelik",
+ "repo": "herdr-plugin-examples",
+ "subdir": "worktree-bootstrap",
+ "resolved_commit": "old",
+ "managed_path": managed_checkout_for_server.display().to_string(),
+ "installed_unix_ms": 1
+ }
+ }]
+ }
+ })
+ )
+ .unwrap();
+ first.flush().unwrap();
+
+ let (mut second, _) = listener.accept().unwrap();
+ let mut second_line = String::new();
+ let mut second_reader = BufReader::new(second.try_clone().unwrap());
+ second_reader.read_line(&mut second_line).unwrap();
+ let second_request: serde_json::Value = serde_json::from_str(&second_line).unwrap();
+ assert_eq!(second_request["method"], "plugin.link");
+ second
+ .write_all(
+ br#"{"id":"cli:plugin","error":{"code":"plugin_registry_save_failed","message":"forced failure"}}"#,
+ )
+ .unwrap();
+ second.write_all(b"\n").unwrap();
+ second.flush().unwrap();
+ });
+
+ let install = run_named_cli_with_env_and_socket_override(
+ &config_home,
+ &runtime_dir,
+ &[
+ "plugin",
+ "install",
+ "ogulcancelik/herdr-plugin-examples/worktree-bootstrap",
+ "--yes",
+ ],
+ &[("GIT_CONFIG_GLOBAL", &git_config)],
+ Some(&socket_path),
+ );
+ assert!(
+ !install.status.success(),
+ "install should fail when plugin.link fails"
+ );
+ server.join().unwrap();
+ assert!(
+ managed_checkout.join("old-marker").exists(),
+ "old checkout should be restored after registration failure"
+ );
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn plugin_install_rejects_server_that_drops_source_metadata() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("fake-herdr.sock");
+ let source_repo = base.join("source-repo");
+ let plugin_dir = source_repo.join("worktree-bootstrap");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ create_committed_repo(&source_repo);
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.worktree-bootstrap"
+name = "Worktree Bootstrap"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[actions]]
+id = "bootstrap"
+title = "Bootstrap"
+command = ["sh", "-c", "echo install"]
+"#,
+ )
+ .unwrap();
+ run_git(
+ &source_repo,
+ &["add", "worktree-bootstrap/herdr-plugin.toml"],
+ );
+ run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]);
+
+ fs::create_dir_all(&config_home).unwrap();
+ fs::create_dir_all(&runtime_dir).unwrap();
+ let managed_checkout = config_home
+ .join("herdr-dev")
+ .join("plugins")
+ .join("github")
+ .join("example.worktree-bootstrap");
+ let git_config = base.join("gitconfig");
+ fs::write(
+ &git_config,
+ format!(
+ "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
+ source_repo.display()
+ ),
+ )
+ .unwrap();
+
+ let listener = UnixListener::bind(&socket_path).unwrap();
+ let managed_checkout_for_server = managed_checkout.clone();
+ let server = thread::spawn(move || {
+ let (mut first, _) = listener.accept().unwrap();
+ let mut first_line = String::new();
+ let mut first_reader = BufReader::new(first.try_clone().unwrap());
+ first_reader.read_line(&mut first_line).unwrap();
+ let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap();
+ assert_eq!(first_request["method"], "plugin.list");
+ first
+ .write_all(br#"{"id":"cli:plugin","result":{"type":"plugin_list","plugins":[]}}"#)
+ .unwrap();
+ first.write_all(b"\n").unwrap();
+ first.flush().unwrap();
+
+ let (mut second, _) = listener.accept().unwrap();
+ let mut second_line = String::new();
+ let mut second_reader = BufReader::new(second.try_clone().unwrap());
+ second_reader.read_line(&mut second_line).unwrap();
+ let second_request: serde_json::Value = serde_json::from_str(&second_line).unwrap();
+ assert_eq!(second_request["method"], "plugin.link");
+ writeln!(
+ second,
+ "{}",
+ serde_json::json!({
+ "id": "cli:plugin",
+ "result": {
+ "type": "plugin_linked",
+ "plugin": {
+ "plugin_id": "example.worktree-bootstrap",
+ "name": "Worktree Bootstrap",
+ "version": "0.1.0",
+ "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(),
+ "plugin_root": managed_checkout_for_server.display().to_string(),
+ "enabled": true,
+ "source": {"kind": "local"}
+ }
+ }
+ })
+ )
+ .unwrap();
+ second.flush().unwrap();
+
+ let (mut third, _) = listener.accept().unwrap();
+ let mut third_line = String::new();
+ let mut third_reader = BufReader::new(third.try_clone().unwrap());
+ third_reader.read_line(&mut third_line).unwrap();
+ let third_request: serde_json::Value = serde_json::from_str(&third_line).unwrap();
+ assert_eq!(third_request["method"], "plugin.unlink");
+ assert_eq!(
+ third_request["params"]["plugin_id"],
+ "example.worktree-bootstrap"
+ );
+ third
+ .write_all(
+ br#"{"id":"cli:plugin","result":{"type":"plugin_unlinked","plugin_id":"example.worktree-bootstrap","removed":true}}"#,
+ )
+ .unwrap();
+ third.write_all(b"\n").unwrap();
+ third.flush().unwrap();
+ });
+
+ let install = run_named_cli_with_env_and_socket_override(
+ &config_home,
+ &runtime_dir,
+ &[
+ "plugin",
+ "install",
+ "ogulcancelik/herdr-plugin-examples/worktree-bootstrap",
+ "--yes",
+ ],
+ &[("GIT_CONFIG_GLOBAL", &git_config)],
+ Some(&socket_path),
+ );
+ assert!(
+ !install.status.success(),
+ "install should fail when server drops GitHub source metadata"
+ );
+ server.join().unwrap();
+ assert!(
+ !managed_checkout.exists(),
+ "new checkout should be removed after incompatible plugin.link response"
+ );
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn plugin_install_keeps_checkout_when_incompatible_server_cleanup_fails() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("fake-herdr.sock");
+ let source_repo = base.join("source-repo");
+ let plugin_dir = source_repo.join("worktree-bootstrap");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ create_committed_repo(&source_repo);
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.worktree-bootstrap"
+name = "Worktree Bootstrap"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[actions]]
+id = "bootstrap"
+title = "Bootstrap"
+command = ["sh", "-c", "echo install"]
+"#,
+ )
+ .unwrap();
+ run_git(
+ &source_repo,
+ &["add", "worktree-bootstrap/herdr-plugin.toml"],
+ );
+ run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]);
+
+ fs::create_dir_all(&config_home).unwrap();
+ fs::create_dir_all(&runtime_dir).unwrap();
+ let managed_checkout = config_home
+ .join("herdr-dev")
+ .join("plugins")
+ .join("github")
+ .join("example.worktree-bootstrap");
+ let git_config = base.join("gitconfig");
+ fs::write(
+ &git_config,
+ format!(
+ "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
+ source_repo.display()
+ ),
+ )
+ .unwrap();
+
+ let listener = UnixListener::bind(&socket_path).unwrap();
+ let managed_checkout_for_server = managed_checkout.clone();
+ let server = thread::spawn(move || {
+ let (mut first, _) = listener.accept().unwrap();
+ let mut first_line = String::new();
+ let mut first_reader = BufReader::new(first.try_clone().unwrap());
+ first_reader.read_line(&mut first_line).unwrap();
+ first
+ .write_all(br#"{"id":"cli:plugin","result":{"type":"plugin_list","plugins":[]}}"#)
+ .unwrap();
+ first.write_all(b"\n").unwrap();
+ first.flush().unwrap();
+
+ let (mut second, _) = listener.accept().unwrap();
+ let mut second_line = String::new();
+ let mut second_reader = BufReader::new(second.try_clone().unwrap());
+ second_reader.read_line(&mut second_line).unwrap();
+ writeln!(
+ second,
+ "{}",
+ serde_json::json!({
+ "id": "cli:plugin",
+ "result": {
+ "type": "plugin_linked",
+ "plugin": {
+ "plugin_id": "example.worktree-bootstrap",
+ "name": "Worktree Bootstrap",
+ "version": "0.1.0",
+ "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(),
+ "plugin_root": managed_checkout_for_server.display().to_string(),
+ "enabled": true,
+ "source": {"kind": "local"}
+ }
+ }
+ })
+ )
+ .unwrap();
+ second.flush().unwrap();
+
+ let (mut third, _) = listener.accept().unwrap();
+ let mut third_line = String::new();
+ let mut third_reader = BufReader::new(third.try_clone().unwrap());
+ third_reader.read_line(&mut third_line).unwrap();
+ let third_request: serde_json::Value = serde_json::from_str(&third_line).unwrap();
+ assert_eq!(third_request["method"], "plugin.unlink");
+ third
+ .write_all(
+ br#"{"id":"cli:plugin","error":{"code":"plugin_registry_save_failed","message":"forced unlink failure"}}"#,
+ )
+ .unwrap();
+ third.write_all(b"\n").unwrap();
+ third.flush().unwrap();
+ });
+
+ let install = run_named_cli_with_env_and_socket_override(
+ &config_home,
+ &runtime_dir,
+ &[
+ "plugin",
+ "install",
+ "ogulcancelik/herdr-plugin-examples/worktree-bootstrap",
+ "--yes",
+ ],
+ &[("GIT_CONFIG_GLOBAL", &git_config)],
+ Some(&socket_path),
+ );
+ assert!(
+ !install.status.success(),
+ "install should fail when source metadata is dropped and cleanup fails"
+ );
+ server.join().unwrap();
+ assert!(
+ managed_checkout.exists(),
+ "checkout should stay when server cleanup fails"
+ );
+
+ cleanup_test_base(&base);
+}
+
+#[test]
fn wait_agent_status_exits_immediately_when_status_already_matches() {
let base = unique_test_dir();
let config_home = base.join("config");