integration_tests.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. //! Integration tests for cross-module wiring.
  2. //!
  3. //! These tests verify that adjacent modules in the runtime crate actually
  4. //! connect correctly — catching wiring gaps that unit tests miss.
  5. use std::time::Duration;
  6. use runtime::green_contract::{GreenContract, GreenContractOutcome, GreenLevel};
  7. use runtime::{
  8. apply_policy, BranchFreshness, DiffScope, LaneBlocker, LaneContext, PolicyAction,
  9. PolicyCondition, PolicyEngine, PolicyRule, ReconcileReason, ReviewStatus, StaleBranchAction,
  10. StaleBranchPolicy,
  11. };
  12. /// stale_branch + policy_engine integration:
  13. /// When a branch is detected stale, does it correctly flow through
  14. /// PolicyCondition::StaleBranch to generate the expected action?
  15. #[test]
  16. fn stale_branch_detection_flows_into_policy_engine() {
  17. // given — a stale branch context (2 hours behind main, threshold is 1 hour)
  18. let stale_context = LaneContext::new(
  19. "stale-lane",
  20. 0,
  21. Duration::from_secs(2 * 60 * 60), // 2 hours stale
  22. LaneBlocker::None,
  23. ReviewStatus::Pending,
  24. DiffScope::Full,
  25. false,
  26. );
  27. let engine = PolicyEngine::new(vec![PolicyRule::new(
  28. "stale-merge-forward",
  29. PolicyCondition::StaleBranch,
  30. PolicyAction::MergeForward,
  31. 10,
  32. )]);
  33. // when
  34. let actions = engine.evaluate(&stale_context);
  35. // then
  36. assert_eq!(actions, vec![PolicyAction::MergeForward]);
  37. }
  38. /// stale_branch + policy_engine: Fresh branch does NOT trigger stale rules
  39. #[test]
  40. fn fresh_branch_does_not_trigger_stale_policy() {
  41. let fresh_context = LaneContext::new(
  42. "fresh-lane",
  43. 0,
  44. Duration::from_secs(30 * 60), // 30 min stale — under 1 hour threshold
  45. LaneBlocker::None,
  46. ReviewStatus::Pending,
  47. DiffScope::Full,
  48. false,
  49. );
  50. let engine = PolicyEngine::new(vec![PolicyRule::new(
  51. "stale-merge-forward",
  52. PolicyCondition::StaleBranch,
  53. PolicyAction::MergeForward,
  54. 10,
  55. )]);
  56. let actions = engine.evaluate(&fresh_context);
  57. assert!(actions.is_empty());
  58. }
  59. /// green_contract + policy_engine integration:
  60. /// A lane that meets its green contract should be mergeable
  61. #[test]
  62. fn green_contract_satisfied_allows_merge() {
  63. let contract = GreenContract::new(GreenLevel::Workspace);
  64. let satisfied = contract.is_satisfied_by(GreenLevel::Workspace);
  65. assert!(satisfied);
  66. let exceeded = contract.is_satisfied_by(GreenLevel::MergeReady);
  67. assert!(exceeded);
  68. let insufficient = contract.is_satisfied_by(GreenLevel::Package);
  69. assert!(!insufficient);
  70. }
  71. /// green_contract + policy_engine:
  72. /// Lane with green level below contract requirement gets blocked
  73. #[test]
  74. fn green_contract_unsatisfied_blocks_merge() {
  75. let context = LaneContext::new(
  76. "partial-green-lane",
  77. 1, // GreenLevel::Package as u8
  78. Duration::from_secs(0),
  79. LaneBlocker::None,
  80. ReviewStatus::Pending,
  81. DiffScope::Full,
  82. false,
  83. );
  84. // This is a conceptual test — we need a way to express "requires workspace green"
  85. // Currently LaneContext has raw green_level: u8, not a contract
  86. // For now we just verify the policy condition works
  87. let engine = PolicyEngine::new(vec![PolicyRule::new(
  88. "workspace-green-required",
  89. PolicyCondition::GreenAt { level: 3 }, // GreenLevel::Workspace
  90. PolicyAction::MergeToDev,
  91. 10,
  92. )]);
  93. let actions = engine.evaluate(&context);
  94. assert!(actions.is_empty()); // level 1 < 3, so no merge
  95. }
  96. /// reconciliation + policy_engine integration:
  97. /// A reconciled lane should be handled by reconcile rules, not generic closeout
  98. #[test]
  99. fn reconciled_lane_matches_reconcile_condition() {
  100. let context = LaneContext::reconciled("reconciled-lane");
  101. let engine = PolicyEngine::new(vec![
  102. PolicyRule::new(
  103. "reconcile-first",
  104. PolicyCondition::LaneReconciled,
  105. PolicyAction::Reconcile {
  106. reason: ReconcileReason::AlreadyMerged,
  107. },
  108. 5,
  109. ),
  110. PolicyRule::new(
  111. "generic-closeout",
  112. PolicyCondition::LaneCompleted,
  113. PolicyAction::CloseoutLane,
  114. 30,
  115. ),
  116. ]);
  117. let actions = engine.evaluate(&context);
  118. // Both rules fire — reconcile (priority 5) first, then closeout (priority 30)
  119. assert_eq!(
  120. actions,
  121. vec![
  122. PolicyAction::Reconcile {
  123. reason: ReconcileReason::AlreadyMerged,
  124. },
  125. PolicyAction::CloseoutLane,
  126. ]
  127. );
  128. }
  129. /// stale_branch module: apply_policy generates correct actions
  130. #[test]
  131. fn stale_branch_apply_policy_produces_rebase_action() {
  132. let stale = BranchFreshness::Stale {
  133. commits_behind: 5,
  134. missing_fixes: vec!["fix-123".to_string()],
  135. };
  136. let action = apply_policy(&stale, StaleBranchPolicy::AutoRebase);
  137. assert_eq!(action, StaleBranchAction::Rebase);
  138. }
  139. #[test]
  140. fn stale_branch_apply_policy_produces_merge_forward_action() {
  141. let stale = BranchFreshness::Stale {
  142. commits_behind: 3,
  143. missing_fixes: vec![],
  144. };
  145. let action = apply_policy(&stale, StaleBranchPolicy::AutoMergeForward);
  146. assert_eq!(action, StaleBranchAction::MergeForward);
  147. }
  148. #[test]
  149. fn stale_branch_apply_policy_warn_only() {
  150. let stale = BranchFreshness::Stale {
  151. commits_behind: 2,
  152. missing_fixes: vec!["fix-456".to_string()],
  153. };
  154. let action = apply_policy(&stale, StaleBranchPolicy::WarnOnly);
  155. match action {
  156. StaleBranchAction::Warn { message } => {
  157. assert!(message.contains("2 commit(s) behind main"));
  158. assert!(message.contains("fix-456"));
  159. }
  160. _ => panic!("expected Warn action, got {:?}", action),
  161. }
  162. }
  163. #[test]
  164. fn stale_branch_fresh_produces_noop() {
  165. let fresh = BranchFreshness::Fresh;
  166. let action = apply_policy(&fresh, StaleBranchPolicy::AutoRebase);
  167. assert_eq!(action, StaleBranchAction::Noop);
  168. }
  169. /// Combined flow: stale detection + policy + action
  170. #[test]
  171. fn end_to_end_stale_lane_gets_merge_forward_action() {
  172. // Simulating what a harness would do:
  173. // 1. Detect branch freshness
  174. // 2. Build lane context from freshness + other signals
  175. // 3. Run policy engine
  176. // 4. Return actions
  177. // given: detected stale state
  178. let _freshness = BranchFreshness::Stale {
  179. commits_behind: 5,
  180. missing_fixes: vec!["fix-123".to_string()],
  181. };
  182. // when: build context and evaluate policy
  183. let context = LaneContext::new(
  184. "lane-9411",
  185. 3, // Workspace green
  186. Duration::from_secs(5 * 60 * 60), // 5 hours stale, definitely over threshold
  187. LaneBlocker::None,
  188. ReviewStatus::Approved,
  189. DiffScope::Scoped,
  190. false,
  191. );
  192. let engine = PolicyEngine::new(vec![
  193. // Priority 5: Check if stale first
  194. PolicyRule::new(
  195. "auto-merge-forward-if-stale-and-approved",
  196. PolicyCondition::And(vec![
  197. PolicyCondition::StaleBranch,
  198. PolicyCondition::ReviewPassed,
  199. ]),
  200. PolicyAction::MergeForward,
  201. 5,
  202. ),
  203. // Priority 10: Normal stale handling
  204. PolicyRule::new(
  205. "stale-warning",
  206. PolicyCondition::StaleBranch,
  207. PolicyAction::Notify {
  208. channel: "#build-status".to_string(),
  209. },
  210. 10,
  211. ),
  212. ]);
  213. let actions = engine.evaluate(&context);
  214. // then: both rules should fire (stale + approved matches both)
  215. assert_eq!(
  216. actions,
  217. vec![
  218. PolicyAction::MergeForward,
  219. PolicyAction::Notify {
  220. channel: "#build-status".to_string(),
  221. },
  222. ]
  223. );
  224. }
  225. /// Fresh branch with approved review should merge (not stale-blocked)
  226. #[test]
  227. fn fresh_approved_lane_gets_merge_action() {
  228. let context = LaneContext::new(
  229. "fresh-approved-lane",
  230. 3, // Workspace green
  231. Duration::from_secs(30 * 60), // 30 min — under 1 hour threshold = fresh
  232. LaneBlocker::None,
  233. ReviewStatus::Approved,
  234. DiffScope::Scoped,
  235. false,
  236. );
  237. let engine = PolicyEngine::new(vec![PolicyRule::new(
  238. "merge-if-green-approved-not-stale",
  239. PolicyCondition::And(vec![
  240. PolicyCondition::GreenAt { level: 3 },
  241. PolicyCondition::ReviewPassed,
  242. // NOT PolicyCondition::StaleBranch — fresh lanes bypass this
  243. ]),
  244. PolicyAction::MergeToDev,
  245. 5,
  246. )]);
  247. let actions = engine.evaluate(&context);
  248. assert_eq!(actions, vec![PolicyAction::MergeToDev]);
  249. }
  250. /// worker_boot + recovery_recipes + policy_engine integration:
  251. /// When a session completes with a provider failure, does the worker
  252. /// status transition trigger the correct recovery recipe, and does
  253. /// the resulting recovery state feed into policy decisions?
  254. #[test]
  255. fn worker_provider_failure_flows_through_recovery_to_policy() {
  256. use runtime::recovery_recipes::{
  257. attempt_recovery, FailureScenario, RecoveryContext, RecoveryResult, RecoveryStep,
  258. };
  259. use runtime::worker_boot::{WorkerFailureKind, WorkerRegistry, WorkerStatus};
  260. // given — a worker that encounters a provider failure during session completion
  261. let registry = WorkerRegistry::new();
  262. let worker = registry.create("/tmp/repo-recovery-test", &[], true);
  263. // Worker reaches ready state
  264. registry
  265. .observe(&worker.worker_id, "Ready for your input\n>")
  266. .expect("ready observe should succeed");
  267. registry
  268. .send_prompt(&worker.worker_id, Some("Run analysis"))
  269. .expect("prompt send should succeed");
  270. // Session completes with provider failure (finish="unknown", tokens=0)
  271. let failed_worker = registry
  272. .observe_completion(&worker.worker_id, "unknown", 0)
  273. .expect("completion observe should succeed");
  274. assert_eq!(failed_worker.status, WorkerStatus::Failed);
  275. let failure = failed_worker
  276. .last_error
  277. .expect("worker should have recorded error");
  278. assert_eq!(failure.kind, WorkerFailureKind::Provider);
  279. // Bridge: WorkerFailureKind -> FailureScenario
  280. let scenario = FailureScenario::from_worker_failure_kind(failure.kind);
  281. assert_eq!(scenario, FailureScenario::ProviderFailure);
  282. // Recovery recipe lookup and execution
  283. let mut ctx = RecoveryContext::new();
  284. let result = attempt_recovery(&scenario, &mut ctx);
  285. // then — recovery should recommend RestartWorker step
  286. assert!(
  287. matches!(result, RecoveryResult::Recovered { steps_taken: 1 }),
  288. "provider failure should recover via single RestartWorker step, got: {result:?}"
  289. );
  290. assert!(
  291. ctx.events().iter().any(|e| {
  292. matches!(
  293. e,
  294. runtime::recovery_recipes::RecoveryEvent::RecoveryAttempted {
  295. result: RecoveryResult::Recovered { steps_taken: 1 },
  296. ..
  297. }
  298. )
  299. }),
  300. "recovery should emit structured attempt event"
  301. );
  302. // Policy integration: recovery success + green status = merge-ready
  303. // (Simulating the policy check that would happen after successful recovery)
  304. let recovery_success = matches!(result, RecoveryResult::Recovered { .. });
  305. let green_level = 3; // Workspace green
  306. let not_stale = Duration::from_secs(30 * 60); // 30 min — fresh
  307. let post_recovery_context = LaneContext::new(
  308. "recovered-lane",
  309. green_level,
  310. not_stale,
  311. LaneBlocker::None,
  312. ReviewStatus::Approved,
  313. DiffScope::Scoped,
  314. false,
  315. );
  316. let policy_engine = PolicyEngine::new(vec![
  317. // Rule: if recovered from failure + green + approved -> merge
  318. PolicyRule::new(
  319. "merge-after-successful-recovery",
  320. PolicyCondition::And(vec![
  321. PolicyCondition::GreenAt { level: 3 },
  322. PolicyCondition::ReviewPassed,
  323. ]),
  324. PolicyAction::MergeToDev,
  325. 10,
  326. ),
  327. ]);
  328. // Recovery success is a pre-condition; policy evaluates post-recovery context
  329. assert!(
  330. recovery_success,
  331. "recovery must succeed for lane to proceed"
  332. );
  333. let actions = policy_engine.evaluate(&post_recovery_context);
  334. assert_eq!(
  335. actions,
  336. vec![PolicyAction::MergeToDev],
  337. "post-recovery green+approved lane should be merge-ready"
  338. );
  339. }