1use std::collections::BTreeMap;
14use std::fmt;
15use std::sync::Arc;
16
17use rust_decimal::Decimal;
18use serde_json::{Value, json, to_string};
19
20use ironflow_core::error::{AgentError, OperationError};
21use ironflow_core::provider::{AgentConfig, AgentOutput, AgentProvider, InvokeFuture};
22
23use crate::config::{ApprovalConfig, HttpConfig, HumanInputConfig, ShellConfig, StepConfig};
24use crate::error::EngineError;
25use crate::executor::{
26 ApprovalOutcome, HumanInputOutcome, SignalOutcome, StepArtifacts, StepInterceptor, StepOutput,
27};
28
29const MISSING_AGENT_PROVIDER: &str = "TestEngine has no agent provider: call with_mock_agent(...), with_recorded_agent(...) or \
31 with_agent_provider(...)";
32
33#[derive(Debug, Clone, PartialEq, Eq, Default)]
47pub struct MockShellOutput {
48 pub stdout: String,
50 pub stderr: String,
52 pub exit_code: i32,
54}
55
56impl MockShellOutput {
57 pub fn ok(stdout: &str) -> Self {
69 Self {
70 stdout: stdout.to_string(),
71 ..Self::default()
72 }
73 }
74
75 pub fn failed(exit_code: i32, stderr: &str) -> Self {
86 Self {
87 stdout: String::new(),
88 stderr: stderr.to_string(),
89 exit_code,
90 }
91 }
92
93 pub(crate) fn into_step_result(
100 self,
101 exit_code_as_output: bool,
102 ) -> Result<StepOutput, EngineError> {
103 if self.exit_code != 0 && !exit_code_as_output {
104 return Err(EngineError::Operation(OperationError::Shell {
105 exit_code: self.exit_code,
106 stderr: self.stderr,
107 }));
108 }
109
110 Ok(StepOutput {
111 output: json!({
112 "stdout": self.stdout,
113 "stderr": self.stderr,
114 "exit_code": self.exit_code,
115 }),
116 duration_ms: 0,
117 cost_usd: Decimal::ZERO,
118 input_tokens: None,
119 cache_read_input_tokens: None,
120 cache_creation_input_tokens: None,
121 output_tokens: None,
122 model: None,
123 debug_messages: None,
124 artifacts: StepArtifacts::default(),
125 account_id: None,
126 })
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct MockHttpResponse {
150 pub status: u16,
152 pub headers: Vec<(String, String)>,
154 pub body: String,
156}
157
158impl Default for MockHttpResponse {
159 fn default() -> Self {
160 Self {
161 status: 200,
162 headers: Vec::new(),
163 body: String::new(),
164 }
165 }
166}
167
168impl MockHttpResponse {
169 pub fn ok(body: &Value) -> Self {
182 Self::json(200, body)
183 }
184
185 pub fn json(status: u16, body: &Value) -> Self {
197 Self {
198 status,
199 headers: Vec::new(),
200 body: to_string(body).unwrap_or_else(|_| body.to_string()),
202 }
203 }
204
205 pub fn text(status: u16, body: &str) -> Self {
216 Self {
217 status,
218 headers: Vec::new(),
219 body: body.to_string(),
220 }
221 }
222
223 pub fn header(mut self, name: &str, value: &str) -> Self {
234 self.headers.push((name.to_string(), value.to_string()));
235 self
236 }
237
238 pub(crate) fn into_step_output(self) -> StepOutput {
241 let headers: BTreeMap<String, String> = self.headers.into_iter().collect();
242 StepOutput {
243 output: json!({
244 "status": self.status,
245 "headers": headers,
246 "body": self.body,
247 }),
248 duration_ms: 0,
249 cost_usd: Decimal::ZERO,
250 input_tokens: None,
251 cache_read_input_tokens: None,
252 cache_creation_input_tokens: None,
253 output_tokens: None,
254 model: None,
255 debug_messages: None,
256 artifacts: StepArtifacts::default(),
257 account_id: None,
258 }
259 }
260}
261
262pub type ShellMock =
264 Arc<dyn Fn(&ShellConfig) -> Result<MockShellOutput, OperationError> + Send + Sync>;
265
266pub type HttpMock =
268 Arc<dyn Fn(&HttpConfig) -> Result<MockHttpResponse, OperationError> + Send + Sync>;
269
270pub type HumanInputMock = Arc<dyn Fn(&str, &HumanInputConfig) -> HumanInputOutcome + Send + Sync>;
272
273pub type SignalMock = Arc<dyn Fn(&str, &str, &str) -> SignalOutcome + Send + Sync>;
275
276pub type AgentMock = Arc<dyn Fn(&AgentConfig) -> Result<AgentOutput, AgentError> + Send + Sync>;
278
279#[derive(Clone, Default)]
301pub struct MockInterceptor {
302 shell: Option<ShellMock>,
303 http: Option<HttpMock>,
304 approval: Option<ApprovalOutcome>,
305 human_input: Option<HumanInputMock>,
306 signal: Option<SignalMock>,
307}
308
309impl MockInterceptor {
310 pub fn new() -> Self {
324 Self::default()
325 }
326
327 pub fn shell(
339 mut self,
340 f: impl Fn(&ShellConfig) -> Result<MockShellOutput, OperationError> + Send + Sync + 'static,
341 ) -> Self {
342 self.shell = Some(Arc::new(f));
343 self
344 }
345
346 pub fn http(
359 mut self,
360 f: impl Fn(&HttpConfig) -> Result<MockHttpResponse, OperationError> + Send + Sync + 'static,
361 ) -> Self {
362 self.http = Some(Arc::new(f));
363 self
364 }
365
366 pub fn approval(mut self, outcome: ApprovalOutcome) -> Self {
377 self.approval = Some(outcome);
378 self
379 }
380
381 pub fn human_input(
394 mut self,
395 f: impl Fn(&str, &HumanInputConfig) -> HumanInputOutcome + Send + Sync + 'static,
396 ) -> Self {
397 self.human_input = Some(Arc::new(f));
398 self
399 }
400
401 pub fn signal(
416 mut self,
417 f: impl Fn(&str, &str, &str) -> SignalOutcome + Send + Sync + 'static,
418 ) -> Self {
419 self.signal = Some(Arc::new(f));
420 self
421 }
422}
423
424impl fmt::Debug for MockInterceptor {
425 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
426 f.debug_struct("MockInterceptor")
428 .field("shell", &self.shell.is_some())
429 .field("http", &self.http.is_some())
430 .field("approval", &self.approval)
431 .field("human_input", &self.human_input.is_some())
432 .field("signal", &self.signal.is_some())
433 .finish()
434 }
435}
436
437impl StepInterceptor for MockInterceptor {
438 fn intercept(&self, config: &StepConfig) -> Option<Result<StepOutput, EngineError>> {
439 match config {
440 StepConfig::Shell(cfg) => {
441 let mock = self.shell.as_ref()?;
442 Some(match mock(cfg) {
443 Ok(out) => out.into_step_result(cfg.exit_code_as_output),
444 Err(err) => Err(EngineError::Operation(err)),
445 })
446 }
447 StepConfig::Http(cfg) => {
448 let mock = self.http.as_ref()?;
449 Some(match mock(cfg) {
450 Ok(res) => Ok(res.into_step_output()),
451 Err(err) => Err(EngineError::Operation(err)),
452 })
453 }
454 _ => None,
456 }
457 }
458
459 fn intercept_approval(&self, _name: &str, _config: &ApprovalConfig) -> Option<ApprovalOutcome> {
460 self.approval.clone()
461 }
462
463 fn intercept_human_input(
464 &self,
465 name: &str,
466 config: &HumanInputConfig,
467 _schema: &Value,
468 ) -> Option<HumanInputOutcome> {
469 self.human_input.as_ref().map(|f| f(name, config))
470 }
471
472 fn intercept_signal(
473 &self,
474 name: &str,
475 signal_name: &str,
476 key: &str,
477 _schema: &Value,
478 ) -> Option<SignalOutcome> {
479 self.signal.as_ref().map(|f| f(name, signal_name, key))
480 }
481}
482
483pub struct MockAgentProvider {
499 f: AgentMock,
500}
501
502impl MockAgentProvider {
503 pub fn new(
516 f: impl Fn(&AgentConfig) -> Result<AgentOutput, AgentError> + Send + Sync + 'static,
517 ) -> Self {
518 Self { f: Arc::new(f) }
519 }
520}
521
522impl fmt::Debug for MockAgentProvider {
523 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
524 f.debug_struct("MockAgentProvider").finish_non_exhaustive()
525 }
526}
527
528impl AgentProvider for MockAgentProvider {
529 fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a> {
530 let result = (self.f)(config);
531 Box::pin(async move { result })
532 }
533}
534
535#[derive(Debug, Default, Clone, Copy)]
553pub struct MissingAgentProvider;
554
555impl AgentProvider for MissingAgentProvider {
556 fn invoke<'a>(&'a self, _config: &'a AgentConfig) -> InvokeFuture<'a> {
557 Box::pin(async move {
558 Err(AgentError::ProcessFailed {
559 exit_code: -1,
560 stderr: MISSING_AGENT_PROVIDER.to_string(),
561 })
562 })
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569
570 use crate::config::AgentStepConfig;
571
572 #[test]
573 fn shell_ok_maps_to_the_real_executor_output_shape() {
574 let output = MockShellOutput::ok("hello\n")
575 .into_step_result(false)
576 .expect("exit code 0 succeeds");
577
578 assert_eq!(output.output["stdout"], "hello\n");
579 assert_eq!(output.output["stderr"], "");
580 assert_eq!(output.output["exit_code"], 0);
581 assert_eq!(output.cost_usd, Decimal::ZERO);
582 }
583
584 #[test]
585 fn shell_default_is_an_empty_success() {
586 let default = MockShellOutput::default();
587 assert_eq!(default.exit_code, 0);
588 assert!(default.stdout.is_empty());
589 assert!(default.stderr.is_empty());
590 }
591
592 #[test]
593 fn shell_non_zero_exit_is_an_operation_error() {
594 let err = MockShellOutput::failed(2, "x")
595 .into_step_result(false)
596 .expect_err("a non-zero exit code fails the step");
597
598 match err {
599 EngineError::Operation(OperationError::Shell { exit_code, stderr }) => {
600 assert_eq!(exit_code, 2);
601 assert_eq!(stderr, "x");
602 }
603 other => panic!("expected a shell operation error, got {other}"),
604 }
605 }
606
607 #[test]
608 fn shell_non_zero_exit_with_option_is_an_output() {
609 let output = MockShellOutput::failed(2, "x")
610 .into_step_result(true)
611 .expect("the option turns a non-zero exit into an output");
612
613 assert_eq!(output.output["exit_code"], 2);
614 assert_eq!(output.output["stderr"], "x");
615 assert!(!output.is_success());
616 }
617
618 #[test]
619 fn intercept_shell_non_zero_with_option_completes() {
620 let interceptor = MockInterceptor::new().shell(|_| Ok(MockShellOutput::failed(2, "x")));
621 let config = StepConfig::Shell(ShellConfig::new("x").exit_code_as_output());
622
623 let output = interceptor
624 .intercept(&config)
625 .expect("the shell mock answers")
626 .expect("the step completes");
627 assert_eq!(output.exit_code(), Some(2));
628
629 let plain = StepConfig::Shell(ShellConfig::new("x"));
630 assert!(
631 interceptor
632 .intercept(&plain)
633 .expect("the shell mock answers")
634 .is_err()
635 );
636 }
637
638 #[test]
639 fn http_json_carries_status_body_and_headers() {
640 let output = MockHttpResponse::json(201, &json!({"id": 7}))
641 .header("location", "/things/7")
642 .into_step_output();
643
644 assert_eq!(output.output["status"], 201);
645 assert_eq!(output.output["body"], r#"{"id":7}"#);
646 assert_eq!(output.output["headers"]["location"], "/things/7");
647 }
648
649 #[test]
650 fn http_default_is_an_empty_200() {
651 let default = MockHttpResponse::default();
652 assert_eq!(default.status, 200);
653 assert!(default.body.is_empty());
654 assert!(default.headers.is_empty());
655 }
656
657 #[test]
658 fn http_non_2xx_is_still_an_output() {
659 let output = MockHttpResponse::text(500, "boom").into_step_output();
660 assert_eq!(output.status(), Some(500));
661 assert_eq!(output.body(), "boom");
662 }
663
664 #[test]
665 fn intercept_declines_agent_steps() {
666 let interceptor = MockInterceptor::new().shell(|_| Ok(MockShellOutput::ok("x")));
667 let config = StepConfig::Agent(AgentStepConfig::new("review this"));
668
669 assert!(interceptor.intercept(&config).is_none());
670 }
671
672 #[test]
673 fn intercept_declines_shell_steps_without_a_shell_mock() {
674 let interceptor = MockInterceptor::new();
675 let config = StepConfig::Shell(ShellConfig::new("echo hi"));
676
677 assert!(interceptor.intercept(&config).is_none());
678 }
679
680 #[test]
681 fn intercept_approval_returns_the_configured_outcome() {
682 let interceptor = MockInterceptor::new().approval(ApprovalOutcome::reject("nope"));
683 let config = ApprovalConfig::new("Approve?");
684
685 assert_eq!(
686 interceptor.intercept_approval("gate", &config),
687 Some(ApprovalOutcome::reject("nope"))
688 );
689 assert_eq!(
690 MockInterceptor::new().intercept_approval("gate", &config),
691 None
692 );
693 }
694
695 #[test]
696 fn intercept_human_input_returns_the_mocked_answer() {
697 let interceptor = MockInterceptor::new().human_input(|name, cfg| {
698 HumanInputOutcome::Provided(json!({"step": name, "message": cfg.message()}))
699 });
700 let config = HumanInputConfig::new("Answer?");
701 let answer = json!({"step": "clarify", "message": "Answer?"});
702
703 assert_eq!(
704 interceptor.intercept_human_input("clarify", &config, &json!({})),
705 Some(HumanInputOutcome::Provided(answer))
706 );
707 }
708
709 #[test]
710 fn intercept_human_input_declines_without_a_mock() {
711 let config = HumanInputConfig::new("Answer?");
712
713 assert_eq!(
714 MockInterceptor::new().intercept_human_input("clarify", &config, &json!({})),
715 None
716 );
717 }
718
719 #[test]
720 fn intercept_signal_returns_the_mocked_outcome() {
721 let interceptor = MockInterceptor::new().signal(|step, name, key| {
722 SignalOutcome::Received(json!({"step": step, "name": name, "key": key}))
723 });
724 let expected = json!({"step": "wait-ci", "name": "ci.done", "key": "abc"});
725
726 assert_eq!(
727 interceptor.intercept_signal("wait-ci", "ci.done", "abc", &json!({})),
728 Some(SignalOutcome::Received(expected))
729 );
730 assert_eq!(
731 MockInterceptor::new().intercept_signal("wait-ci", "ci.done", "abc", &json!({})),
732 None
733 );
734 }
735
736 #[test]
737 fn debug_reports_which_seams_are_mocked() {
738 let interceptor = MockInterceptor::new().http(|_| Ok(MockHttpResponse::default()));
739 let rendered = format!("{interceptor:?}");
740
741 assert!(rendered.contains("shell: false"));
742 assert!(rendered.contains("http: true"));
743 }
744
745 #[tokio::test]
746 async fn missing_agent_provider_names_the_three_constructors() {
747 let config = AgentConfig::new("anything");
748 let err = MissingAgentProvider
749 .invoke(&config)
750 .await
751 .expect_err("no agent backend is configured");
752
753 let message = err.to_string();
754 assert!(message.contains("with_mock_agent"));
755 assert!(message.contains("with_recorded_agent"));
756 assert!(message.contains("with_agent_provider"));
757 }
758
759 #[tokio::test]
760 async fn mock_agent_provider_runs_the_closure() {
761 let provider = MockAgentProvider::new(|cfg| {
762 let echoed = json!({"echoed": cfg.prompt.clone()});
763 Ok(AgentOutput::new(echoed))
764 });
765 let config = AgentConfig::new("say hi");
766
767 let output = provider.invoke(&config).await.expect("the mock succeeded");
768
769 assert_eq!(output.value["echoed"], "say hi");
770 }
771}