use super::harness::*; #[test] fn named_sessions_share_live_plugin_registry() { let base = unique_test_dir(); let config_home = base.join("config"); let runtime_dir = base.join("runtime"); let first_dir = base.join("plugins").join("first"); let second_dir = base.join("plugins").join("second"); for (dir, id) in [ (&first_dir, "example.first"), (&second_dir, "example.second"), ] { fs::create_dir_all(dir).unwrap(); fs::write( dir.join("herdr-plugin.toml"), format!( "id = \"{id}\"\nname = \"{id}\"\nversion = \"0.1.0\"\nmin_herdr_version = \"0.6.10\"\n\n[[actions]]\nid = \"run\"\ntitle = \"Run\"\ncommand = [\"sh\", \"-c\", \"echo run\"]\n" ), ) .unwrap(); } let alpha = spawn_named_server(&config_home, &runtime_dir, "alpha"); let beta = spawn_named_server(&config_home, &runtime_dir, "beta"); wait_for_socket( &named_session_socket(&config_home, "alpha"), Duration::from_secs(5), ); wait_for_socket( &named_session_socket(&config_home, "beta"), Duration::from_secs(5), ); std::thread::scope(|scope| { let first = scope.spawn(|| { run_named_cli_json( &config_home, &runtime_dir, &[ "--session", "alpha", "plugin", "link", first_dir.to_str().unwrap(), ], ) }); let second = scope.spawn(|| { run_named_cli_json( &config_home, &runtime_dir, &[ "--session", "beta", "plugin", "link", second_dir.to_str().unwrap(), ], ) }); assert_eq!( first.join().unwrap()["result"]["plugin"]["plugin_id"], "example.first" ); assert_eq!( second.join().unwrap()["result"]["plugin"]["plugin_id"], "example.second" ); }); let beta_list = run_named_cli_json( &config_home, &runtime_dir, &["--session", "beta", "plugin", "list", "--json"], ); assert_eq!(beta_list["result"]["plugins"].as_array().unwrap().len(), 2); run_named_cli_json( &config_home, &runtime_dir, &["--session", "beta", "plugin", "disable", "example.first"], ); let disabled = run_named_cli( &config_home, &runtime_dir, &[ "--session", "alpha", "plugin", "action", "invoke", "run", "--plugin", "example.first", ], ); assert_eq!(disabled.status.code(), Some(1)); let disabled_output = format!( "{}{}", String::from_utf8_lossy(&disabled.stdout), String::from_utf8_lossy(&disabled.stderr) ); assert!(disabled_output.contains("disabled"), "{disabled_output}"); let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "alpha"]); let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "beta"]); drop(alpha); drop(beta); cleanup_test_base(&base); } #[test] fn plugin_link_works_offline_and_is_global() { let base = unique_test_dir(); let config_home = base.join("config"); let runtime_dir = base.join("runtime"); let state_home = base.join("state"); let plugin_dir = base.join("plugins").join("offline"); fs::create_dir_all(&plugin_dir).unwrap(); fs::write( plugin_dir.join("herdr-plugin.toml"), r#" id = "example.offline" name = "Offline Plugin" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos", "windows"] "#, ) .unwrap(); let link_args = [ "--session", "offline", "plugin", "link", plugin_dir.to_str().unwrap(), "--disabled", ]; let linked = parse_cli_json_output( &link_args, run_named_cli_with_env( &config_home, &runtime_dir, &link_args, &[("XDG_STATE_HOME", &state_home)], ), ); assert_eq!(linked["result"]["type"], "plugin_linked"); assert_eq!(linked["result"]["plugin"]["plugin_id"], "example.offline"); assert_eq!(linked["result"]["plugin"]["enabled"], false); assert_eq!(linked["result"]["plugin"]["source"]["kind"], "local"); assert!(!named_session_socket(&config_home, "offline").exists()); let listed = run_named_cli_json( &config_home, &runtime_dir, &["--session", "other", "plugin", "list", "--json"], ); assert_eq!(listed["result"]["plugins"].as_array().unwrap().len(), 1); assert_eq!( listed["result"]["plugins"][0]["plugin_id"], "example.offline" ); assert_eq!(listed["result"]["plugins"][0]["enabled"], false); cleanup_test_base(&base); } #[test] fn plugin_install_through_named_server_is_global() { 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("global-plugin"); fs::create_dir_all(&plugin_dir).unwrap(); create_committed_repo(&source_repo); fs::write( plugin_dir.join("herdr-plugin.toml"), r#" id = "example.global-plugin" name = "Global Plugin" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos", "windows"] "#, ) .unwrap(); run_git(&source_repo, &["add", "global-plugin/herdr-plugin.toml"]); run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); let git_config = base.join("gitconfig"); fs::write( &git_config, format!( "[url \"file://{}\"]\n insteadOf = https://github.com/example/plugins.git\n", source_repo.display() ), ) .unwrap(); let alpha = spawn_named_server(&config_home, &runtime_dir, "alpha"); wait_for_socket( &named_session_socket(&config_home, "alpha"), Duration::from_secs(5), ); let install = run_named_cli_with_env( &config_home, &runtime_dir, &[ "--session", "alpha", "plugin", "install", "example/plugins/global-plugin", "--yes", ], &[("GIT_CONFIG_GLOBAL", &git_config)], ); assert!( install.status.success(), "install failed\nstdout: {}\nstderr: {}", String::from_utf8_lossy(&install.stdout), String::from_utf8_lossy(&install.stderr) ); let beta_list = run_named_cli_json( &config_home, &runtime_dir, &["--session", "beta", "plugin", "list", "--json"], ); assert_eq!( beta_list["result"]["plugins"][0]["plugin_id"], "example.global-plugin" ); let managed_path = PathBuf::from( beta_list["result"]["plugins"][0]["source"]["managed_path"] .as_str() .unwrap(), ); assert!(managed_path.starts_with(managed_github_plugin_dir(&config_home))); let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "alpha"]); drop(alpha); cleanup_test_base(&base); } #[test] fn plugin_update_refreshes_selected_then_all_github_plugins() { let base = unique_test_dir(); let config_home = base.join("config"); let runtime_dir = base.join("runtime"); let source_repo = base.join("source-repo"); create_committed_repo(&source_repo); run_git(&source_repo, &["checkout", "-b", "plugin-updates"]); for (subdir, id) in [("first", "example.first"), ("second", "example.second")] { let plugin_dir = source_repo.join(subdir); fs::create_dir_all(&plugin_dir).unwrap(); fs::write( plugin_dir.join("herdr-plugin.toml"), format!( "id = \"{id}\"\nname = \"{id}\"\nversion = \"0.1.0\"\nmin_herdr_version = \"0.6.10\"\n" ), ) .unwrap(); } run_git(&source_repo, &["add", "."]); run_git(&source_repo, &["commit", "--quiet", "-m", "add plugins"]); let git_config = base.join("gitconfig"); fs::write( &git_config, format!( "[url \"file://{}\"]\n insteadOf = https://github.com/example/plugins.git\n", source_repo.display() ), ) .unwrap(); for subdir in ["first", "second"] { let source = format!("example/plugins/{subdir}"); let ref_args = (subdir == "first").then_some(["--ref", "plugin-updates"]); let mut args = vec!["plugin", "install", source.as_str(), "--yes"]; if let Some(ref_args) = ref_args { args.extend(ref_args); } let output = run_named_cli_with_env( &config_home, &runtime_dir, &args, &[("GIT_CONFIG_GLOBAL", &git_config)], ); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); } let server = spawn_named_server(&config_home, &runtime_dir, "updates"); wait_for_socket( &named_session_socket(&config_home, "updates"), Duration::from_secs(5), ); run_named_cli_json( &config_home, &runtime_dir, &["--session", "updates", "plugin", "disable", "example.first"], ); let reinstalled = run_named_cli_with_env( &config_home, &runtime_dir, &[ "--session", "updates", "plugin", "install", "example/plugins/first", "--ref", "plugin-updates", "--yes", ], &[("GIT_CONFIG_GLOBAL", &git_config)], ); assert!(reinstalled.status.success()); let listed = run_named_cli_json( &config_home, &runtime_dir, &["--session", "updates", "plugin", "list", "--json"], ); assert_eq!(listed["result"]["plugins"][0]["enabled"], false); let first_installation = PathBuf::from( listed["result"]["plugins"][0]["plugin_root"] .as_str() .unwrap(), ); let offline_reinstall = run_named_cli_with_env( &config_home, &runtime_dir, &[ "--session", "offline-update", "plugin", "install", "example/plugins/first", "--ref", "plugin-updates", "--yes", ], &[("GIT_CONFIG_GLOBAL", &git_config)], ); assert!(offline_reinstall.status.success()); let listed = run_named_cli_json( &config_home, &runtime_dir, &["--session", "updates", "plugin", "list", "--json"], ); assert_eq!(listed["result"]["plugins"][0]["enabled"], false); let second_installation = PathBuf::from( listed["result"]["plugins"][0]["plugin_root"] .as_str() .unwrap(), ); assert_ne!(first_installation, second_installation); assert!(first_installation.join("herdr-plugin.toml").exists()); let malformed = run_named_cli( &config_home, &runtime_dir, &[ "--session", "updates", "plugin", "update", "example/plugins/..", "--yes", ], ); assert_eq!(malformed.status.code(), Some(2)); assert!(String::from_utf8_lossy(&malformed.stderr).contains("invalid plugin subdir segment")); for (subdir, id) in [("first", "example.first"), ("second", "example.second")] { fs::write( source_repo.join(subdir).join("herdr-plugin.toml"), format!( "id = \"{id}\"\nname = \"{id}\"\nversion = \"0.2.0\"\nmin_herdr_version = \"0.6.10\"\n" ), ) .unwrap(); } run_git(&source_repo, &["add", "."]); run_git(&source_repo, &["commit", "--quiet", "-m", "update plugins"]); let selected = run_named_cli_with_env( &config_home, &runtime_dir, &[ "--session", "updates", "plugin", "update", "EXAMPLE/PLUGINS/first", "example.first", "--yes", ], &[("GIT_CONFIG_GLOBAL", &git_config)], ); assert!(selected.status.success()); assert!(String::from_utf8_lossy(&selected.stdout).contains("Updated example.first")); let listed = run_named_cli_json( &config_home, &runtime_dir, &["--session", "updates", "plugin", "list", "--json"], ); assert_eq!(listed["result"]["plugins"][0]["version"], "0.2.0"); assert_eq!(listed["result"]["plugins"][0]["enabled"], false); assert_eq!(listed["result"]["plugins"][1]["version"], "0.1.0"); let local_dir = base.join("local-plugin"); fs::create_dir_all(&local_dir).unwrap(); fs::write( local_dir.join("herdr-plugin.toml"), "id = \"example.local\"\nname = \"Local\"\nversion = \"0.1.0\"\nmin_herdr_version = \"0.6.10\"\n", ) .unwrap(); let linked = run_named_cli( &config_home, &runtime_dir, &[ "--session", "updates", "plugin", "link", local_dir.to_str().unwrap(), ], ); assert!(linked.status.success()); let all = run_named_cli_with_env( &config_home, &runtime_dir, &["--session", "updates", "plugin", "update", "--yes"], &[("GIT_CONFIG_GLOBAL", &git_config)], ); assert!(all.status.success()); let stdout = String::from_utf8_lossy(&all.stdout); assert!(stdout.contains("example.first is already up to date")); assert!(stdout.contains("Updated example.second")); let listed = run_named_cli_json( &config_home, &runtime_dir, &["--session", "updates", "plugin", "list", "--json"], ); let plugins = listed["result"]["plugins"].as_array().unwrap(); for id in ["example.first", "example.second"] { let plugin = plugins .iter() .find(|plugin| plugin["plugin_id"] == id) .unwrap(); assert_eq!(plugin["version"], "0.2.0"); } assert!(plugins .iter() .any(|plugin| plugin["plugin_id"] == "example.local")); let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "updates"]); drop(server); let unchanged = run_named_cli_with_env( &config_home, &runtime_dir, &["plugin", "update", "--yes"], &[("GIT_CONFIG_GLOBAL", &git_config)], ); assert!(unchanged.status.success()); assert!( !first_installation.exists(), "stopped servers release old files" ); assert!(!second_installation.exists()); assert!(local_dir.exists(), "cleanup never removes local plugins"); cleanup_test_base(&base); } #[test] fn plugin_update_does_not_resurrect_a_plugin_unlinked_during_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("plugin"); let build_started = base.join("build-started"); let release_build = base.join("release-build"); create_committed_repo(&source_repo); fs::create_dir_all(&plugin_dir).unwrap(); fs::write( plugin_dir.join("herdr-plugin.toml"), "id = \"example.race\"\nname = \"Race\"\nversion = \"0.1.0\"\nmin_herdr_version = \"0.6.10\"\n", ) .unwrap(); run_git(&source_repo, &["add", "."]); run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); let git_config = base.join("gitconfig"); fs::write( &git_config, format!( "[url \"file://{}\"]\n insteadOf = https://github.com/example/race.git\n", source_repo.display() ), ) .unwrap(); let installed = run_named_cli_with_env( &config_home, &runtime_dir, &[ "--session", "race", "plugin", "install", "example/race/plugin", "--yes", ], &[("GIT_CONFIG_GLOBAL", &git_config)], ); assert!(installed.status.success()); let server = spawn_named_server(&config_home, &runtime_dir, "race"); wait_for_socket( &named_session_socket(&config_home, "race"), Duration::from_secs(5), ); let listed = run_named_cli_json( &config_home, &runtime_dir, &["--session", "race", "plugin", "list", "--json"], ); let managed_path = PathBuf::from( listed["result"]["plugins"][0]["source"]["managed_path"] .as_str() .unwrap(), ); fs::write( plugin_dir.join("herdr-plugin.toml"), format!( "id = \"example.race\"\nname = \"Race\"\nversion = \"0.2.0\"\nmin_herdr_version = \"0.6.10\"\n\n[[build]]\ncommand = [\"sh\", \"-c\", \"touch {}; while [ ! -e {} ]; do sleep 0.05; done\"]\n", build_started.display(), release_build.display() ), ) .unwrap(); run_git(&source_repo, &["add", "."]); run_git(&source_repo, &["commit", "--quiet", "-m", "update plugin"]); let mut update = Command::new(env!("CARGO_BIN_EXE_herdr")); update .args([ "--session", "race", "plugin", "update", "example.race", "--yes", ]) .env("XDG_CONFIG_HOME", &config_home) .env("XDG_RUNTIME_DIR", &runtime_dir) .env("GIT_CONFIG_GLOBAL", &git_config) .env_remove("HERDR_SOCKET_PATH") .env_remove("HERDR_CLIENT_SOCKET_PATH") .env_remove("HERDR_ENV") .stdout(Stdio::piped()) .stderr(Stdio::piped()); let child = update.spawn().unwrap(); let deadline = Instant::now() + Duration::from_secs(5); while !build_started.exists() && Instant::now() < deadline { thread::sleep(Duration::from_millis(20)); } if !build_started.exists() { fs::write(&release_build, "release").unwrap(); let output = child.wait_with_output().unwrap(); panic!( "plugin update never reached its build: {}", String::from_utf8_lossy(&output.stderr) ); } let unlinked = run_named_cli( &config_home, &runtime_dir, &["--session", "race", "plugin", "unlink", "example.race"], ); assert!(unlinked.status.success()); fs::write(&release_build, "release").unwrap(); let updated = child.wait_with_output().unwrap(); assert!(!updated.status.success()); assert!(String::from_utf8_lossy(&updated.stderr) .contains("changed while its update was in progress")); let listed = run_named_cli_json( &config_home, &runtime_dir, &["--session", "race", "plugin", "list", "--json"], ); assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); assert!( fs::read_to_string(managed_path.join("plugin/herdr-plugin.toml")) .unwrap() .contains("version = \"0.1.0\"") ); let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "race"]); drop(server); cleanup_test_base(&base); } #[test] fn plugin_updates_preserve_running_consumers_and_failed_activation() { let base = unique_test_dir(); let config_home = base.join("config"); let runtime_dir = base.join("runtime"); let source_repo = base.join("source"); create_committed_repo(&source_repo); let manifest = r#" id = "example.live" name = "Live" version = "0.1.0" min_herdr_version = "0.6.10" [[actions]] id = "read" title = "Read" command = ["sh", "./read.sh", "action"] [[panes]] id = "read" title = "Read" placement = "tab" command = ["sh", "./read.sh", "pane"] "#; fs::write(source_repo.join("herdr-plugin.toml"), manifest).unwrap(); fs::write(source_repo.join("version"), "old").unwrap(); fs::write(source_repo.join("read.sh"), r#" kind="$1" touch "$HERDR_PLUGIN_ROOT/ready-$kind" while [ ! -e "$HERDR_PLUGIN_ROOT/release" ]; do sleep 0.05; done printf '%s:%s' "$(cat version)" "$(cat "$HERDR_PLUGIN_ROOT/version")" > "$HERDR_PLUGIN_ROOT/result-$kind.tmp" mv "$HERDR_PLUGIN_ROOT/result-$kind.tmp" "$HERDR_PLUGIN_ROOT/result-$kind" "#).unwrap(); run_git(&source_repo, &["add", "."]); run_git(&source_repo, &["commit", "--quiet", "-m", "old plugin"]); let git_config = base.join("gitconfig"); fs::write( &git_config, format!( "[url \"file://{}\"]\n insteadOf = https://github.com/example/live.git\n", source_repo.display() ), ) .unwrap(); let install = run_named_cli_with_env( &config_home, &runtime_dir, &["plugin", "install", "example/live", "--yes"], &[("GIT_CONFIG_GLOBAL", &git_config)], ); assert!( install.status.success(), "{}", String::from_utf8_lossy(&install.stderr) ); // Model an existing installation from before generation directories. let registry = config_home.join("herdr-dev/plugins.json"); let mut entries: serde_json::Value = serde_json::from_slice(&fs::read(®istry).unwrap()).unwrap(); let installed_root = PathBuf::from(entries[0]["plugin_root"].as_str().unwrap()); let component = installed_root .parent() .unwrap() .parent() .unwrap() .file_name() .unwrap(); let legacy_root = config_home.join("herdr-dev/plugins/github").join(component); fs::create_dir_all(legacy_root.parent().unwrap()).unwrap(); fs::rename(&installed_root, &legacy_root).unwrap(); entries[0]["plugin_root"] = serde_json::json!(legacy_root); entries[0]["manifest_path"] = serde_json::json!(legacy_root.join("herdr-plugin.toml")); entries[0]["source"]["managed_path"] = serde_json::json!(legacy_root); fs::write(®istry, serde_json::to_vec(&entries).unwrap()).unwrap(); let alpha = spawn_named_server(&config_home, &runtime_dir, "alpha"); let beta = spawn_named_server(&config_home, &runtime_dir, "beta"); for name in ["alpha", "beta"] { wait_for_socket( &named_session_socket(&config_home, name), Duration::from_secs(5), ); } let socket = named_session_socket(&config_home, "alpha"); run_cli_json( &socket, &[ "workspace", "create", "--cwd", base.to_str().unwrap(), "--focus", ], ); let current_root = || { let listed = run_named_cli_json( &config_home, &runtime_dir, &["--session", "beta", "plugin", "list", "--json"], ); PathBuf::from( listed["result"]["plugins"][0]["plugin_root"] .as_str() .unwrap(), ) }; let launch = || { let action = run_named_cli_json( &config_home, &runtime_dir, &[ "--session", "beta", "plugin", "action", "invoke", "read", "--plugin", "example.live", ], ); assert_eq!(action["result"]["type"], "plugin_action_invoked"); let pane = run_cli_json( &socket, &[ "plugin", "pane", "open", "--plugin", "example.live", "--entrypoint", "read", ], ); assert_eq!(pane["result"]["type"], "plugin_pane_opened"); }; let wait_file = |path: &Path| { let deadline = Instant::now() + Duration::from_secs(10); while !path.exists() && Instant::now() < deadline { thread::sleep(Duration::from_millis(20)); } assert!(path.exists(), "missing {}", path.display()); }; let old_root = current_root(); launch(); for kind in ["action", "pane"] { wait_file(&old_root.join(format!("ready-{kind}"))); } fs::write(source_repo.join("version"), "new").unwrap(); run_git(&source_repo, &["add", "."]); run_git(&source_repo, &["commit", "--quiet", "-m", "new plugin"]); let update = run_named_cli_with_env( &config_home, &runtime_dir, &["--session", "alpha", "plugin", "update", "--yes"], &[("GIT_CONFIG_GLOBAL", &git_config)], ); assert!( update.status.success(), "{}", String::from_utf8_lossy(&update.stderr) ); let new_root = current_root(); assert_ne!(old_root, new_root); launch(); for root in [&old_root, &new_root] { fs::write(root.join("release"), "").unwrap(); for kind in ["action", "pane"] { wait_file(&root.join(format!("result-{kind}"))); } } for kind in ["action", "pane"] { assert_eq!( fs::read_to_string(old_root.join(format!("result-{kind}"))).unwrap(), "old:old" ); assert_eq!( fs::read_to_string(new_root.join(format!("result-{kind}"))).unwrap(), "new:new" ); } // Fail registry activation after the build, preserving the working version // and both running consumers' directories without a restore operation. let registry = config_home.join("herdr-dev/plugins.json"); let original_registry = fs::read(®istry).unwrap(); let block_write = format!("mkdir '{}'", registry.with_extension("json.tmp").display()); fs::write( source_repo.join("herdr-plugin.toml"), format!( "{manifest}\n[[build]]\ncommand = {}\n", serde_json::to_string(&["sh", "-c", block_write.as_str()]).unwrap() ), ) .unwrap(); run_git(&source_repo, &["add", "."]); run_git( &source_repo, &["commit", "--quiet", "-m", "fail activation"], ); let failed = run_named_cli_with_env( &config_home, &runtime_dir, &["--session", "alpha", "plugin", "update", "--yes"], &[("GIT_CONFIG_GLOBAL", &git_config)], ); assert!(!failed.status.success()); assert!(String::from_utf8_lossy(&failed.stderr).contains("plugin files retained at")); assert_eq!(fs::read(®istry).unwrap(), original_registry); assert_eq!(current_root(), new_root); assert_eq!(fs::read_to_string(old_root.join("version")).unwrap(), "old"); assert_eq!(fs::read_to_string(new_root.join("version")).unwrap(), "new"); for name in ["alpha", "beta"] { let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", name]); } drop((alpha, beta)); cleanup_test_base(&base); } #[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" min_herdr_version = "0.6.10" 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" min_herdr_version = "0.6.10" 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", "other", "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.starts_with(managed_github_plugin_dir(&config_home))); 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", "third", "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(), "uninstall must preserve files used by running plugins" ); let listed = run_named_cli_json( &config_home, &runtime_dir, &["--session", "other", "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" min_herdr_version = "0.6.10" 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()); assert!( fs::read_dir(managed_github_plugin_dir(&config_home)) .unwrap() .all(|plugin| path_missing_or_empty(&plugin.unwrap().path())), "failed build should not leave managed checkouts" ); 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" min_herdr_version = "0.6.10" 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" min_herdr_version = "0.6.10" 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" min_herdr_version = "0.0.1" platforms = ["linux", "macos", "windows"] [[build]] command = ["sh", "mutate.sh"] [[actions]] id = "run" title = "Run reviewed command" command = ["sh", "-c", "echo reviewed"] 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()); assert!( fs::read_dir(managed_github_plugin_dir(&config_home)) .unwrap() .all(|plugin| path_missing_or_empty(&plugin.unwrap().path())), "manifest mutation should not leave managed checkouts" ); cleanup_test_base(&base); }