test: add integration tests for worker boot, lane events, hooks

Add five runtime integration tests that exercise worker boot readiness, lane event emission, hook merging, task packet roundtrips, and config validation through the public crate surface.

Constraint: Keep coverage in rust/crates/runtime/tests without adding dependencies
Rejected: Fold the cases into existing integration_tests.rs | harder to isolate the requested workflow coverage
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep these tests focused on cross-module wiring; leave unit-level edge cases in module-local test suites
Tested: cargo test --workspace (from rust)
Not-tested: Push result on remote CI
This commit is contained in:
Yeachan-Heo
2026-04-04 16:08:32 +00:00
parent 43058dc789
commit 21d823f597
@@ -0,0 +1,238 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use runtime::task_registry::{TaskRegistry, TaskStatus};
use runtime::{
validate_packet, ConfigLoader, HookRunner, LaneEvent, LaneEventBlocker, LaneFailureClass,
RuntimeHookConfig, TaskPacket, WorkerEventKind, WorkerFailureKind, WorkerRegistry,
WorkerStatus,
};
use serde_json::json;
struct TestDir {
path: PathBuf,
}
impl TestDir {
fn new(prefix: &str) -> Self {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
let path = std::env::temp_dir().join(format!("{prefix}-{unique}"));
fs::create_dir_all(&path).expect("temp dir should be created");
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TestDir {
fn drop(&mut self) {
if self.path.exists() {
let _ = fs::remove_dir_all(&self.path);
}
}
}
#[test]
fn worker_boot_state_progresses_from_spawning_to_ready_snapshot() {
let registry = WorkerRegistry::new();
let worker = registry.create("/tmp/runtime-integration-worker", &[], true);
assert_eq!(worker.status, WorkerStatus::Spawning);
assert_eq!(worker.events.len(), 1);
assert_eq!(worker.events[0].kind, WorkerEventKind::Spawning);
let ready = registry
.observe(&worker.worker_id, "Ready for your input\n>")
.expect("ready observe should succeed");
assert_eq!(ready.status, WorkerStatus::ReadyForPrompt);
assert!(ready.last_error.is_none());
assert_eq!(
ready.events.last().map(|event| event.kind),
Some(WorkerEventKind::ReadyForPrompt)
);
let snapshot = registry
.await_ready(&worker.worker_id)
.expect("ready snapshot should succeed");
assert_eq!(snapshot.worker_id, worker.worker_id);
assert!(snapshot.ready);
assert!(!snapshot.blocked);
assert!(!snapshot.replay_prompt_ready);
assert!(snapshot.last_error.is_none());
}
#[test]
fn lane_event_emission_serializes_worker_prompt_delivery_failure() {
let registry = WorkerRegistry::new();
let worker = registry.create("/tmp/runtime-integration-lane", &[], true);
registry
.observe(&worker.worker_id, "Ready for input\n>")
.expect("ready observe should succeed");
registry
.send_prompt(&worker.worker_id, Some("Run lane event emission test"))
.expect("prompt send should succeed");
let failed = registry
.observe(
&worker.worker_id,
"% Run lane event emission test\nzsh: command not found: Run",
)
.expect("misdelivery observe should succeed");
let error = failed
.last_error
.clone()
.expect("prompt delivery failure should be recorded");
assert_eq!(error.kind, WorkerFailureKind::PromptDelivery);
let blocker = LaneEventBlocker {
failure_class: LaneFailureClass::PromptDelivery,
detail: error.message,
};
let lane_event = LaneEvent::blocked("2026-04-04T00:00:00Z", &blocker).with_data(json!({
"worker_id": failed.worker_id,
"worker_status": failed.status,
"worker_event_kinds": failed
.events
.iter()
.map(|event| format!("{:?}", event.kind))
.collect::<Vec<_>>()
}));
let emitted = serde_json::to_value(&lane_event).expect("lane event should serialize");
assert_eq!(emitted["event"], json!("lane.blocked"));
assert_eq!(emitted["status"], json!("blocked"));
assert_eq!(emitted["failureClass"], json!("prompt_delivery"));
assert!(emitted["detail"]
.as_str()
.expect("detail should be a string")
.contains("worker prompt landed in shell"));
assert_eq!(emitted["data"]["worker_status"], json!("ready_for_prompt"));
assert!(emitted["data"]["worker_event_kinds"]
.as_array()
.expect("worker event kinds should be an array")
.iter()
.any(|value| value == "PromptMisdelivery"));
}
#[test]
fn hook_merge_runs_loaded_config_hooks_and_overlay_once_each() {
let temp = TestDir::new("runtime-hooks-integration");
let cwd = temp.path().join("project");
let home = temp.path().join("home").join(".claw");
fs::create_dir_all(cwd.join(".claw")).expect("project config dir should exist");
fs::create_dir_all(&home).expect("home config dir should exist");
fs::write(
home.join("settings.json"),
r#"{
"hooks": {
"PreToolUse": ["printf 'config pre'"]
}
}"#,
)
.expect("home settings should be written");
fs::write(
cwd.join(".claw").join("settings.local.json"),
r#"{
"hooks": {
"PostToolUse": ["printf 'config post'"]
}
}"#,
)
.expect("project settings should be written");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
let overlay = RuntimeHookConfig::new(
vec![
"printf 'config pre'".to_string(),
"printf 'overlay pre'".to_string(),
],
vec![],
vec![],
);
let runner = HookRunner::new(loaded.hooks().merged(&overlay));
let result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#);
assert_eq!(
result.messages(),
&["config pre".to_string(), "overlay pre".to_string()]
);
assert!(!result.is_failed());
assert!(!result.is_denied());
}
#[test]
fn task_packet_roundtrip_validates_and_creates_registry_task() {
let packet = TaskPacket {
objective: "Ship runtime integration coverage".to_string(),
scope: "runtime/tests".to_string(),
repo: "claw-code-parity".to_string(),
branch_policy: "origin/main only".to_string(),
acceptance_tests: vec!["cargo test --workspace".to_string()],
commit_policy: "single verified commit".to_string(),
reporting_contract: "print verification summary and sha".to_string(),
escalation_policy: "escalate only on destructive ambiguity".to_string(),
};
let serialized = serde_json::to_string(&packet).expect("packet should serialize");
let roundtrip: TaskPacket =
serde_json::from_str(&serialized).expect("packet should deserialize");
let validated = validate_packet(roundtrip.clone()).expect("packet should validate");
let registry = TaskRegistry::new();
let task = registry
.create_from_packet(validated.into_inner())
.expect("task should be created from packet");
registry
.set_status(&task.task_id, TaskStatus::Running)
.expect("status should update");
let stored = registry.get(&task.task_id).expect("task should be stored");
assert_eq!(stored.prompt, packet.objective);
assert_eq!(stored.description.as_deref(), Some("runtime/tests"));
assert_eq!(stored.task_packet, Some(packet));
assert_eq!(stored.status, TaskStatus::Running);
}
#[test]
fn config_validation_rejects_invalid_hook_entries_before_merge() {
let temp = TestDir::new("runtime-config-validation");
let cwd = temp.path().join("project");
let home = temp.path().join("home").join(".claw");
let project_settings = cwd.join(".claw").join("settings.json");
fs::create_dir_all(cwd.join(".claw")).expect("project config dir should exist");
fs::create_dir_all(&home).expect("home config dir should exist");
fs::write(
home.join("settings.json"),
r#"{"hooks":{"PreToolUse":["printf 'base'"]}}"#,
)
.expect("home settings should be written");
fs::write(
&project_settings,
r#"{"hooks":{"PreToolUse":["printf 'project'",42]}}"#,
)
.expect("project settings should be written");
let error = ConfigLoader::new(&cwd, &home)
.load()
.expect_err("invalid hooks should fail validation");
let rendered = error.to_string();
assert!(rendered.contains(&format!(
"{}: hooks: field PreToolUse must contain only strings",
project_settings.display()
)));
assert!(!rendered.contains("merged settings.hooks"));
}