#[path = "test_support/mod.rs"]
mod test_support;
use std::collections::HashMap;
use std::fs;
use std::sync::{Mutex, MutexGuard, OnceLock};
use anyhow::Result;
use serial_test::serial;
use tempfile::TempDir;
use xchecker::exit_codes;
use xchecker::hooks::{DEFAULT_HOOK_TIMEOUT_SECS, HookConfig, HooksConfig, OnFail};
use xchecker::orchestrator::{OrchestratorConfig, OrchestratorHandle};
use xchecker::types::PhaseId;
fn hook_env_command(output_file: &str) -> String {
if cfg!(windows) {
format!(
"echo %XCHECKER_SPEC_ID%:%XCHECKER_PHASE%:%XCHECKER_HOOK_TYPE% > {}",
output_file
)
} else {
format!(
r#"printf "%s:%s:%s" "$XCHECKER_SPEC_ID" "$XCHECKER_PHASE" "$XCHECKER_HOOK_TYPE" > {}"#,
output_file
)
}
}
static HOOK_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn hook_env_guard() -> MutexGuard<'static, ()> {
HOOK_ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
}
fn failing_command() -> String {
if cfg!(windows) {
"exit /b 1".to_string()
} else {
"exit 1".to_string()
}
}
fn assert_hook_output(contents: &str, spec_id: &str, phase: &str, hook_type: &str) {
let parts: Vec<&str> = contents.trim().split(':').collect();
assert_eq!(parts.len(), 3, "Hook output format mismatch: {}", contents);
assert_eq!(parts[0], spec_id);
assert_eq!(parts[1], phase);
assert_eq!(parts[2], hook_type);
}
fn unique_spec_id(test_name: &str) -> String {
format!("hooks-integration-{}-{}", test_name, std::process::id())
}
#[tokio::test]
#[serial]
#[allow(clippy::await_holding_lock)] async fn test_pre_and_post_hooks_execute() -> Result<()> {
let _guard = hook_env_guard();
let _home = xchecker::paths::with_isolated_home();
let temp_dir = TempDir::new()?;
let _cwd = test_support::CwdGuard::new(temp_dir.path())?;
let spec_id = unique_spec_id("pre-post");
let pre_file = "pre_hook.txt";
let post_file = "post_hook.txt";
let mut pre_phase = HashMap::new();
pre_phase.insert(
"requirements".to_string(),
HookConfig {
command: hook_env_command(pre_file),
on_fail: OnFail::Warn,
timeout: DEFAULT_HOOK_TIMEOUT_SECS,
},
);
let mut post_phase = HashMap::new();
post_phase.insert(
"requirements".to_string(),
HookConfig {
command: hook_env_command(post_file),
on_fail: OnFail::Warn,
timeout: DEFAULT_HOOK_TIMEOUT_SECS,
},
);
let hooks = HooksConfig {
pre_phase,
post_phase,
};
let config = OrchestratorConfig {
dry_run: true,
config: HashMap::new(),
full_config: None,
selectors: None,
strict_validation: false,
redactor: Default::default(),
hooks: Some(hooks),
};
let mut handle = OrchestratorHandle::with_config_and_force(&spec_id, config, false)?;
let result = handle.run_phase(PhaseId::Requirements).await?;
assert!(result.success, "Phase should succeed with hooks enabled");
let pre_contents = fs::read_to_string(temp_dir.path().join(pre_file))?;
let post_contents = fs::read_to_string(temp_dir.path().join(post_file))?;
assert_hook_output(&pre_contents, &spec_id, "requirements", "pre_phase");
assert_hook_output(&post_contents, &spec_id, "requirements", "post_phase");
Ok(())
}
#[tokio::test]
#[serial]
#[allow(clippy::await_holding_lock)] async fn test_pre_phase_hook_failure_aborts_phase() -> Result<()> {
let _guard = hook_env_guard();
let _home = xchecker::paths::with_isolated_home();
let temp_dir = TempDir::new()?;
let _cwd = test_support::CwdGuard::new(temp_dir.path())?;
let spec_id = unique_spec_id("pre-fail");
let mut pre_phase = HashMap::new();
pre_phase.insert(
"requirements".to_string(),
HookConfig {
command: failing_command(),
on_fail: OnFail::Fail,
timeout: DEFAULT_HOOK_TIMEOUT_SECS,
},
);
let hooks = HooksConfig {
pre_phase,
post_phase: HashMap::new(),
};
let config = OrchestratorConfig {
dry_run: true,
config: HashMap::new(),
full_config: None,
selectors: None,
strict_validation: false,
redactor: Default::default(),
hooks: Some(hooks),
};
let mut handle = OrchestratorHandle::with_config_and_force(&spec_id, config, false)?;
let result = handle.run_phase(PhaseId::Requirements).await?;
assert!(!result.success, "Phase should fail when pre-hook fails");
assert_eq!(result.exit_code, exit_codes::codes::CLAUDE_FAILURE);
let receipt_path = result.receipt_path.expect("Receipt path should exist");
let receipt_content = fs::read_to_string(receipt_path)?;
let receipt_json: serde_json::Value = serde_json::from_str(&receipt_content)?;
assert_eq!(
receipt_json["flags"]["hook_failure"],
serde_json::Value::String("pre_phase".to_string())
);
Ok(())
}