test(TASK-051): cover windows hook dispatch, payload posture, and install semantics

Add a #[cfg(windows)] dispatch test module exercising the real cmd /C
raw_arg path: env delivery through a verbatim command line, fire()
detachment via a gate-file batch loop, payload-file lifecycle with the
per-user temp-dir posture assertion, backslashed COYOTE_HOOKS_DIR
install override, per-scope cwd resolution with an end-to-end %CD%
check, and exec-bit-free install. Hoist wait_for to shared test scope
and move the two OS-agnostic dispatch tests (spawn-failure payload
cleanup, debug-only error paths) into an ungated dispatch_portable
module so both cfg lanes share them instead of duplicating.
This commit is contained in:
Dark-Alex-17 committed 2026-09-16 22:32:42 -06:00
1 parent f7d0f93fa1
commit 22078b1ddc
1 file changed
+343 -98
+343 -98
View File
@@ -2234,6 +2234,119 @@ mod tests {
assert_eq!(resolved[1].cwd, paths::roles_dir());
}
async fn wait_for(what: &str, cond: impl Fn() -> bool) {
for _ in 0..400 {
if cond() {
return;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
panic!("timed out waiting for {what}");
}
/// Dispatch tests whose hooks fail before any shell is involved (bad
/// cwd, empty command), so they run identically on every OS.
mod dispatch_portable {
use super::*;
use crate::testing::TestConfigDirGuard;
use std::fs::create_dir_all;
use std::time::Duration;
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn spawn_failure_removes_the_payload_file() {
crate::testing::install_log_collector();
let guard = TestConfigDirGuard::new("hooks-dispatch");
let payload_dir = guard.path.join("payloads");
create_dir_all(&payload_dir).unwrap();
let _payload_dir = PayloadDirOverrideGuard::new(payload_dir.clone());
let hooks = vec![ResolvedHook {
name: "spawnfail-marker-c9d".to_string(),
full_name: "tool.started.spawnfail-marker-c9d".to_string(),
command: "true".to_string(),
cwd: guard.path.join("nonexistent-spawnfail-marker-c9d"),
}];
fire_resolved(
HookEvent::ToolStarted,
hooks,
Vec::new(),
&[],
Some(r#"{"probe":true}"#.to_string()),
);
drain_pending(Duration::from_secs(10)).await;
// The unspawnable cwd must have driven the spawn-error branch,
// and the payload write before it must have succeeded: only then
// does the empty directory below demonstrate the orphan unlink
// rather than a payload file that never existed.
wait_for("spawn failure log", || {
crate::testing::debug_snapshot().iter().any(|message| {
message.contains("Failed to spawn hook 'tool.started.spawnfail-marker-c9d'")
})
})
.await;
let debugs = crate::testing::debug_snapshot();
assert!(debugs.iter().all(|message| {
!(message.contains("spawnfail-marker-c9d")
&& message.contains("Failed to write payload file"))
}));
wait_for("payload file removal", || {
std::fs::read_dir(&payload_dir)
.unwrap()
.flatten()
.next()
.is_none()
})
.await;
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn error_paths_log_debug_only() {
crate::testing::install_log_collector();
let empty_cwd = env::temp_dir();
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let bad_cwd = env::temp_dir().join(format!("coyote-badcwd-marker-f5b-{unique}"));
let hooks = vec![
ResolvedHook {
name: "empty-marker-f5b".to_string(),
full_name: "turn.failed.empty-marker-f5b".to_string(),
command: " ".to_string(),
cwd: empty_cwd.clone(),
},
ResolvedHook {
name: "badcwd-marker-f5b".to_string(),
full_name: "turn.failed.badcwd-marker-f5b".to_string(),
command: "true".to_string(),
cwd: bad_cwd.clone(),
},
];
fire_resolved(HookEvent::TurnFailed, hooks, Vec::new(), &[], None);
drain_pending(Duration::from_secs(10)).await;
let debugs = crate::testing::debug_snapshot();
let empty_cwd_display = empty_cwd.display().to_string();
assert!(debugs.iter().any(|message| {
message.contains("empty-marker-f5b")
&& message.contains("empty command")
&& message.contains(empty_cwd_display.as_str())
}));
let bad_cwd_display = bad_cwd.display().to_string();
assert!(debugs.iter().any(|message| {
message.contains("Failed to spawn hook 'turn.failed.badcwd-marker-f5b'")
&& message.contains(bad_cwd_display.as_str())
}));
let warns = crate::testing::warn_snapshot();
assert!(warns.iter().all(|message| !message.contains("marker-f5b")));
}
}
#[cfg(unix)]
mod dispatch {
use super::*;
@@ -2242,16 +2355,6 @@ mod tests {
use std::path::PathBuf;
use std::time::Duration;
async fn wait_for(what: &str, cond: impl Fn() -> bool) {
for _ in 0..400 {
if cond() {
return;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
panic!("timed out waiting for {what}");
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn fire_spawns_detached_with_envs() {
@@ -2350,56 +2453,6 @@ mod tests {
wait_for("payload file removal", || !payload_file.exists()).await;
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn spawn_failure_removes_the_payload_file() {
crate::testing::install_log_collector();
let guard = TestConfigDirGuard::new("hooks-dispatch");
let payload_dir = guard.path.join("payloads");
create_dir_all(&payload_dir).unwrap();
let _payload_dir = PayloadDirOverrideGuard::new(payload_dir.clone());
let hooks = vec![ResolvedHook {
name: "spawnfail-marker-c9d".to_string(),
full_name: "tool.started.spawnfail-marker-c9d".to_string(),
command: "true".to_string(),
cwd: PathBuf::from("/nonexistent/coyote-spawnfail-marker-c9d"),
}];
fire_resolved(
HookEvent::ToolStarted,
hooks,
Vec::new(),
&[],
Some(r#"{"probe":true}"#.to_string()),
);
drain_pending(Duration::from_secs(10)).await;
// The unspawnable cwd must have driven the spawn-error branch,
// and the payload write before it must have succeeded: only then
// does the empty directory below demonstrate the orphan unlink
// rather than a payload file that never existed.
wait_for("spawn failure log", || {
crate::testing::debug_snapshot().iter().any(|message| {
message.contains("Failed to spawn hook 'tool.started.spawnfail-marker-c9d'")
})
})
.await;
let debugs = crate::testing::debug_snapshot();
assert!(debugs.iter().all(|message| {
!(message.contains("spawnfail-marker-c9d")
&& message.contains("Failed to write payload file"))
}));
wait_for("payload file removal", || {
std::fs::read_dir(&payload_dir)
.unwrap()
.flatten()
.next()
.is_none()
})
.await;
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn drain_pending_waits_for_spawn_not_completion() {
@@ -2432,44 +2485,6 @@ mod tests {
wait_for("hook completion", || done.exists()).await;
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn error_paths_log_debug_only() {
crate::testing::install_log_collector();
let empty_cwd = env::temp_dir();
let hooks = vec![
ResolvedHook {
name: "empty-marker-f5b".to_string(),
full_name: "turn.failed.empty-marker-f5b".to_string(),
command: " ".to_string(),
cwd: empty_cwd.clone(),
},
ResolvedHook {
name: "badcwd-marker-f5b".to_string(),
full_name: "turn.failed.badcwd-marker-f5b".to_string(),
command: "true".to_string(),
cwd: PathBuf::from("/nonexistent/coyote-badcwd-marker-f5b"),
},
];
fire_resolved(HookEvent::TurnFailed, hooks, Vec::new(), &[], None);
drain_pending(Duration::from_secs(10)).await;
let debugs = crate::testing::debug_snapshot();
let empty_cwd_display = empty_cwd.display().to_string();
assert!(debugs.iter().any(|message| {
message.contains("empty-marker-f5b")
&& message.contains("empty command")
&& message.contains(empty_cwd_display.as_str())
}));
assert!(debugs.iter().any(|message| {
message.contains("Failed to spawn hook 'turn.failed.badcwd-marker-f5b'")
&& message.contains("/nonexistent/coyote-badcwd-marker-f5b")
}));
let warns = crate::testing::warn_snapshot();
assert!(warns.iter().all(|message| !message.contains("marker-f5b")));
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn failing_hook_never_disturbs_the_engine() {
@@ -2669,4 +2684,234 @@ mod tests {
assert_eq!(resolved[0].cwd, paths::agent_data_dir(agent_name));
}
}
/// Dispatch through the real `cmd /C` engine branch, plus the Windows
/// posture of install and payload placement.
#[cfg(windows)]
mod windows_dispatch {
use super::*;
use crate::testing::TestConfigDirGuard;
use std::path::PathBuf;
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn fire_spawns_via_cmd_with_envs() {
let guard = TestConfigDirGuard::new("hooks-win-env");
let out = guard.path.join("env-out");
let command = r#"set > "%HOOK_OUT%.tmp" && move /Y "%HOOK_OUT%.tmp" "%HOOK_OUT%""#;
let ctx =
ctx_with_global_hooks(hooks_map("turn.completed", &[("win-envdump", command)]));
fire(
HookEvent::TurnCompleted,
&ctx,
&[("HOOK_OUT", out.display().to_string())],
None,
);
wait_for("hook env dump", || out.exists()).await;
let env_dump = std::fs::read_to_string(&out).unwrap();
let lines: Vec<&str> = env_dump.lines().map(str::trim_end).collect();
assert!(lines.contains(&"COYOTE_EVENT=turn.completed"));
assert!(lines.contains(&"COYOTE_HOOK_NAME=win-envdump"));
let config_dir_line = format!("COYOTE_CONFIG_DIR={}", guard.path.display());
assert!(lines.contains(&config_dir_line.as_str()));
assert!(
lines
.iter()
.any(|line| line.starts_with("COYOTE_EVENT_TIMESTAMP="))
);
let out_line = format!("HOOK_OUT={}", out.display());
assert!(lines.contains(&out_line.as_str()));
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn fire_returns_while_the_hook_still_runs() {
let guard = TestConfigDirGuard::new("hooks-win-detach");
let bat = guard.path.join("gate-wait.bat");
let out = guard.path.join("started");
let gate = guard.path.join("gate");
let done = guard.path.join("done");
// cmd one-liners cannot loop, so the gate wait lives in a batch
// file; `ping -n 2` is the batch idiom for a short sleep.
std::fs::write(
&bat,
concat!(
"@echo off\r\n",
"type nul > \"%HOOK_OUT%\"\r\n",
":wait\r\n",
"if exist \"%HOOK_GATE%\" goto done\r\n",
"ping -n 2 127.0.0.1 >nul\r\n",
"goto wait\r\n",
":done\r\n",
"type nul > \"%HOOK_DONE%\"\r\n",
),
)
.unwrap();
let ctx = ctx_with_global_hooks(hooks_map(
"turn.completed",
&[("win-gate-probe", r#"call "%HOOK_BAT%""#)],
));
fire(
HookEvent::TurnCompleted,
&ctx,
&[
("HOOK_BAT", bat.display().to_string()),
("HOOK_OUT", out.display().to_string()),
("HOOK_GATE", gate.display().to_string()),
("HOOK_DONE", done.display().to_string()),
],
None,
);
wait_for("hook start marker", || out.exists()).await;
assert!(
!done.exists(),
"dispatch must return before the hook completes"
);
std::fs::write(&gate, "").unwrap();
wait_for("hook completion", || done.exists()).await;
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn payload_file_is_written_and_removed() {
let guard = TestConfigDirGuard::new("hooks-win-payload");
let out = guard.path.join("payload-out");
let path_out = guard.path.join("payload-path");
let command = r#"copy /Y "%COYOTE_HOOK_PAYLOAD_FILE%" "%HOOK_OUT%" >nul && (echo %COYOTE_HOOK_PAYLOAD_FILE%)> "%HOOK_PATH%""#;
let ctx = ctx_with_global_hooks(hooks_map("tool.started", &[("win-payload", command)]));
let payload = r#"{"path":"C:/target","recursive":true}"#;
fire(
HookEvent::ToolStarted,
&ctx,
&[
("HOOK_OUT", out.display().to_string()),
("HOOK_PATH", path_out.display().to_string()),
],
Some(payload.to_string()),
);
// `echo` terminates the path with CRLF, so a complete read of the
// path file also proves the earlier `copy` finished.
wait_for("payload path echo", || {
std::fs::read_to_string(&path_out).is_ok_and(|path| path.ends_with("\r\n"))
})
.await;
assert_eq!(std::fs::read_to_string(&out).unwrap(), payload);
let payload_file =
PathBuf::from(std::fs::read_to_string(&path_out).unwrap().trim_end());
let file_name = payload_file.file_name().unwrap().to_string_lossy();
assert!(file_name.starts_with("coyote-hook-tool.started-"));
// No PayloadDirOverrideGuard here: the engine must fall through
// to the per-user temp dir, which is what scopes payload files to
// the owning user on Windows.
assert!(payload_file.starts_with(env::temp_dir()));
wait_for("payload file removal", || !payload_file.exists()).await;
}
#[test]
#[serial]
fn install_builtin_hooks_lands_under_a_backslashed_override() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let hooks_dir = PathBuf::from(format!(
"{}\\coyote-win-bs-{unique}\\hooks",
env::temp_dir().display()
));
let _env = crate::testing::EnvVarGuard::set(
crate::utils::get_env_name("hooks_dir"),
&hooks_dir,
);
let result = install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None);
// Capture outcomes first and assert only after the temp dir is
// removed, so a failed assertion cannot leak it.
let installed = (
hooks_dir.join("notify.sh").is_file(),
hooks_dir.join("log-events.sh").is_file(),
);
let _ = std::fs::remove_dir_all(hooks_dir.parent().unwrap());
result.unwrap();
assert!(installed.0, "notify.sh must land under the backslashed dir");
assert!(
installed.1,
"log-events.sh must land under the backslashed dir"
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn hook_cwds_resolve_per_scope_on_windows_paths() {
let guard = TestConfigDirGuard::new("hooks-win-cwd");
let global = hooks_map("turn.completed", &[("win-cwd-global", "global-cmd")]);
let role = hooks_map("turn.completed", &[("win-cwd-role", "role-cmd")]);
let agent = hooks_map("turn.completed", &[("win-cwd-agent", "agent-cmd")]);
let resolved = resolve_hooks(
HookEvent::TurnCompleted,
&global,
None,
Some(&role),
Some((&agent, "win-cwd-probe")),
);
assert_eq!(
names(&resolved),
["win-cwd-global", "win-cwd-role", "win-cwd-agent"]
);
assert_eq!(resolved[0].cwd, paths::config_dir());
assert_eq!(resolved[1].cwd, paths::roles_dir());
assert_eq!(resolved[2].cwd, paths::agent_data_dir("win-cwd-probe"));
let out = guard.path.join("cwd-out");
let ctx = ctx_with_global_hooks(hooks_map(
"turn.completed",
&[("win-cwd-echo", r#"(echo %CD%)> "%HOOK_OUT%""#)],
));
fire(
HookEvent::TurnCompleted,
&ctx,
&[("HOOK_OUT", out.display().to_string())],
None,
);
wait_for("hook cwd echo", || {
std::fs::read_to_string(&out).is_ok_and(|cwd| cwd.ends_with("\r\n"))
})
.await;
// %CD% can surface 8.3 short names or different casing;
// canonicalize both sides before comparing.
let echoed = std::fs::read_to_string(&out).unwrap();
assert_eq!(
std::fs::canonicalize(echoed.trim_end()).unwrap(),
std::fs::canonicalize(paths::config_dir()).unwrap()
);
}
#[test]
#[serial]
fn install_completes_without_the_unix_exec_bit() {
let guard = HooksDirGuard::new("win-hooks-exec-noop");
install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap();
let notify = guard.root.join("notify.sh");
assert!(notify.is_file());
let embedded = HookAssets::get("notify.sh").unwrap();
assert_eq!(
std::fs::read_to_string(&notify).unwrap(),
std::str::from_utf8(&embedded.data).unwrap()
);
}
}
}