|
| 1 | +//! Integration tests for runtime workflows that span multiple subsystems. |
| 2 | +
|
| 3 | +use std::fs; |
| 4 | +use std::path::PathBuf; |
| 5 | +use std::time::{SystemTime, UNIX_EPOCH}; |
| 6 | + |
| 7 | +use runtime::task_registry::TaskRegistry; |
| 8 | +use runtime::{ |
| 9 | + validate_packet, ConfigLoader, HookRunner, LaneEvent, LaneEventBlocker, LaneFailureClass, |
| 10 | + RuntimeFeatureConfig, RuntimeHookConfig, TaskPacket, WorkerEventKind, WorkerRegistry, |
| 11 | + WorkerStatus, |
| 12 | +}; |
| 13 | +use serde_json::json; |
| 14 | + |
| 15 | +fn temp_dir(prefix: &str) -> PathBuf { |
| 16 | + let nanos = SystemTime::now() |
| 17 | + .duration_since(UNIX_EPOCH) |
| 18 | + .expect("time should be after epoch") |
| 19 | + .as_nanos(); |
| 20 | + std::env::temp_dir().join(format!("runtime-{prefix}-{nanos}")) |
| 21 | +} |
| 22 | + |
| 23 | +#[test] |
| 24 | +fn worker_boot_state_tracks_trust_resolution_and_ready_snapshot() { |
| 25 | + let registry = WorkerRegistry::new(); |
| 26 | + let cwd = "/tmp/runtime-worker-boot-integration"; |
| 27 | + let worker = registry.create(cwd, &[cwd.to_string()], true); |
| 28 | + |
| 29 | + let spawning = registry |
| 30 | + .observe( |
| 31 | + &worker.worker_id, |
| 32 | + "Do you trust the files in this folder?\nAllow and continue", |
| 33 | + ) |
| 34 | + .expect("trust prompt should be observed"); |
| 35 | + assert_eq!(spawning.status, WorkerStatus::Spawning); |
| 36 | + assert!(spawning.trust_gate_cleared); |
| 37 | + |
| 38 | + registry |
| 39 | + .observe(&worker.worker_id, "Ready for your input\n>") |
| 40 | + .expect("ready cue should be observed"); |
| 41 | + |
| 42 | + let snapshot = registry |
| 43 | + .await_ready(&worker.worker_id) |
| 44 | + .expect("snapshot should be available"); |
| 45 | + assert!(snapshot.ready); |
| 46 | + assert!(!snapshot.blocked); |
| 47 | + assert_eq!(snapshot.status, WorkerStatus::ReadyForPrompt); |
| 48 | + assert_eq!(snapshot.last_error, None); |
| 49 | + |
| 50 | + let stored = registry |
| 51 | + .get(&worker.worker_id) |
| 52 | + .expect("worker should exist"); |
| 53 | + let event_kinds: Vec<_> = stored.events.iter().map(|event| event.kind).collect(); |
| 54 | + assert_eq!( |
| 55 | + event_kinds, |
| 56 | + vec![ |
| 57 | + WorkerEventKind::Spawning, |
| 58 | + WorkerEventKind::TrustRequired, |
| 59 | + WorkerEventKind::TrustResolved, |
| 60 | + WorkerEventKind::ReadyForPrompt, |
| 61 | + ] |
| 62 | + ); |
| 63 | +} |
| 64 | + |
| 65 | +#[test] |
| 66 | +fn lane_event_emission_serializes_prompt_misdelivery_failures() { |
| 67 | + let registry = WorkerRegistry::new(); |
| 68 | + let worker = registry.create("/tmp/runtime-lane-event-integration", &[], true); |
| 69 | + registry |
| 70 | + .observe(&worker.worker_id, "Ready for input\n>") |
| 71 | + .expect("worker should become ready"); |
| 72 | + registry |
| 73 | + .send_prompt(&worker.worker_id, Some("Run lane event parity checks")) |
| 74 | + .expect("prompt dispatch should succeed"); |
| 75 | + |
| 76 | + let failed = registry |
| 77 | + .observe( |
| 78 | + &worker.worker_id, |
| 79 | + "% Run lane event parity checks\nzsh: command not found: Run", |
| 80 | + ) |
| 81 | + .expect("prompt misdelivery should be classified"); |
| 82 | + assert_eq!(failed.status, WorkerStatus::ReadyForPrompt); |
| 83 | + let failure = failed |
| 84 | + .last_error |
| 85 | + .as_ref() |
| 86 | + .expect("misdelivery should record a failure"); |
| 87 | + |
| 88 | + let blocker = LaneEventBlocker { |
| 89 | + failure_class: LaneFailureClass::PromptDelivery, |
| 90 | + detail: failure.message.clone(), |
| 91 | + }; |
| 92 | + let emitted = serde_json::to_value( |
| 93 | + LaneEvent::failed("2026-04-04T00:00:00Z", &blocker).with_data(json!({ |
| 94 | + "worker_id": failed.worker_id, |
| 95 | + "attempts": failed.prompt_delivery_attempts, |
| 96 | + "replay_prompt_ready": failed.replay_prompt.is_some(), |
| 97 | + })), |
| 98 | + ) |
| 99 | + .expect("lane event should serialize"); |
| 100 | + |
| 101 | + assert_eq!(emitted["event"], json!("lane.failed")); |
| 102 | + assert_eq!(emitted["status"], json!("failed")); |
| 103 | + assert_eq!(emitted["failureClass"], json!("prompt_delivery")); |
| 104 | + assert_eq!(emitted["data"]["attempts"], json!(1)); |
| 105 | + assert_eq!(emitted["data"]["replay_prompt_ready"], json!(true)); |
| 106 | + assert!(emitted["detail"] |
| 107 | + .as_str() |
| 108 | + .expect("detail should be serialized") |
| 109 | + .contains("worker prompt landed in shell")); |
| 110 | +} |
| 111 | + |
| 112 | +#[test] |
| 113 | +fn hook_merge_runs_deduped_commands_across_hook_stages() { |
| 114 | + let base = RuntimeHookConfig::new( |
| 115 | + vec!["printf 'base-pre'".to_string()], |
| 116 | + vec!["printf 'base-post'".to_string()], |
| 117 | + vec!["printf 'base-failure'".to_string()], |
| 118 | + ); |
| 119 | + let overlay = RuntimeHookConfig::new( |
| 120 | + vec![ |
| 121 | + "printf 'base-pre'".to_string(), |
| 122 | + "printf 'overlay-pre'".to_string(), |
| 123 | + ], |
| 124 | + vec!["printf 'overlay-post'".to_string()], |
| 125 | + vec![ |
| 126 | + "printf 'base-failure'".to_string(), |
| 127 | + "printf 'overlay-failure'".to_string(), |
| 128 | + ], |
| 129 | + ); |
| 130 | + let runner = HookRunner::from_feature_config( |
| 131 | + &RuntimeFeatureConfig::default().with_hooks(base.merged(&overlay)), |
| 132 | + ); |
| 133 | + |
| 134 | + let pre = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#); |
| 135 | + let post = runner.run_post_tool_use("Read", r#"{"path":"README.md"}"#, "ok", false); |
| 136 | + let failure = runner.run_post_tool_use_failure("Read", r#"{"path":"README.md"}"#, "boom"); |
| 137 | + |
| 138 | + assert_eq!( |
| 139 | + pre.messages(), |
| 140 | + &["base-pre".to_string(), "overlay-pre".to_string()] |
| 141 | + ); |
| 142 | + assert_eq!( |
| 143 | + post.messages(), |
| 144 | + &["base-post".to_string(), "overlay-post".to_string()] |
| 145 | + ); |
| 146 | + assert_eq!( |
| 147 | + failure.messages(), |
| 148 | + &["base-failure".to_string(), "overlay-failure".to_string(),] |
| 149 | + ); |
| 150 | +} |
| 151 | + |
| 152 | +#[test] |
| 153 | +fn task_packet_roundtrip_survives_validation_and_registry_storage() { |
| 154 | + let packet = TaskPacket { |
| 155 | + objective: "Ship worker boot integration coverage".to_string(), |
| 156 | + scope: "runtime/tests".to_string(), |
| 157 | + repo: "claw-code-parity".to_string(), |
| 158 | + branch_policy: "origin/main only".to_string(), |
| 159 | + acceptance_tests: vec![ |
| 160 | + "cargo test -p runtime --test runtime_workflows".to_string(), |
| 161 | + "cargo test --workspace".to_string(), |
| 162 | + ], |
| 163 | + commit_policy: "single verified commit".to_string(), |
| 164 | + reporting_contract: "print verification evidence and sha".to_string(), |
| 165 | + escalation_policy: "escalate only on destructive ambiguity".to_string(), |
| 166 | + }; |
| 167 | + |
| 168 | + let serialized = serde_json::to_string(&packet).expect("packet should serialize"); |
| 169 | + let decoded: TaskPacket = serde_json::from_str(&serialized).expect("packet should decode"); |
| 170 | + let validated = validate_packet(decoded).expect("packet should validate"); |
| 171 | + |
| 172 | + let registry = TaskRegistry::new(); |
| 173 | + let task = registry |
| 174 | + .create_from_packet(validated.into_inner()) |
| 175 | + .expect("packet-backed task should be created"); |
| 176 | + registry |
| 177 | + .update(&task.task_id, "Keep runtime hook semantics stable") |
| 178 | + .expect("task update should succeed"); |
| 179 | + registry |
| 180 | + .append_output(&task.task_id, "tests passed") |
| 181 | + .expect("task output should append"); |
| 182 | + |
| 183 | + let stored = registry.get(&task.task_id).expect("task should be stored"); |
| 184 | + assert_eq!(stored.prompt, packet.objective); |
| 185 | + assert_eq!(stored.description.as_deref(), Some("runtime/tests")); |
| 186 | + assert_eq!(stored.task_packet, Some(packet)); |
| 187 | + assert_eq!(stored.messages.len(), 1); |
| 188 | + assert_eq!( |
| 189 | + stored.messages[0].content, |
| 190 | + "Keep runtime hook semantics stable" |
| 191 | + ); |
| 192 | + assert_eq!( |
| 193 | + registry.output(&task.task_id).expect("output should exist"), |
| 194 | + "tests passed" |
| 195 | + ); |
| 196 | +} |
| 197 | + |
| 198 | +#[test] |
| 199 | +fn config_validation_rejects_invalid_hook_shapes_before_runtime_use() { |
| 200 | + let root = temp_dir("config-validation"); |
| 201 | + let cwd = root.join("project"); |
| 202 | + let home = root.join("home").join(".claw"); |
| 203 | + fs::create_dir_all(cwd.join(".claw")).expect("project config dir"); |
| 204 | + fs::create_dir_all(&home).expect("home config dir"); |
| 205 | + |
| 206 | + let bad_settings = cwd.join(".claw").join("settings.local.json"); |
| 207 | + fs::write( |
| 208 | + home.join("settings.json"), |
| 209 | + r#"{"hooks":{"PreToolUse":["printf 'base'"]}}"#, |
| 210 | + ) |
| 211 | + .expect("write base settings"); |
| 212 | + fs::write( |
| 213 | + &bad_settings, |
| 214 | + r#"{"hooks":{"PreToolUse":["printf 'overlay'",42]}}"#, |
| 215 | + ) |
| 216 | + .expect("write invalid local settings"); |
| 217 | + |
| 218 | + let error = ConfigLoader::new(&cwd, &home) |
| 219 | + .load() |
| 220 | + .expect_err("invalid hook shapes should fail validation"); |
| 221 | + let rendered = error.to_string(); |
| 222 | + |
| 223 | + assert!(rendered.contains(&format!( |
| 224 | + "{}: hooks: field PreToolUse must contain only strings", |
| 225 | + bad_settings.display() |
| 226 | + ))); |
| 227 | + assert!(!rendered.contains("merged settings.hooks")); |
| 228 | + |
| 229 | + fs::remove_dir_all(root).expect("cleanup temp dir"); |
| 230 | +} |
0 commit comments