Skip to main content

ironflow_engine/testing/
mocks.rs

1//! Canned step results and the closure-backed doubles that serve them.
2//!
3//! [`MockInterceptor`] answers shell and HTTP steps from closures; agent steps
4//! are answered one seam lower, by [`MockAgentProvider`] or
5//! [`MissingAgentProvider`]. Everything here mirrors the shape the real
6//! executors persist, so a handler cannot tell the difference.
7//!
8//! The value types are defined locally rather than reusing
9//! [`ShellOutput`](ironflow_core::operations::shell::ShellOutput) and
10//! [`HttpOutput`](ironflow_core::operations::http::HttpOutput): those have
11//! private fields and no public constructor.
12
13use 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, StepArtifacts, StepInterceptor, StepOutput,
27};
28
29/// Message carried by [`MissingAgentProvider`] failures.
30const MISSING_AGENT_PROVIDER: &str = "TestEngine has no agent provider: call with_mock_agent(...), with_recorded_agent(...) or \
31     with_agent_provider(...)";
32
33/// Canned result of a mocked shell step.
34///
35/// # Examples
36///
37/// ```
38/// use ironflow_engine::testing::MockShellOutput;
39///
40/// let ok = MockShellOutput::ok("built 3 crates");
41/// assert_eq!(ok.exit_code, 0);
42///
43/// let ko = MockShellOutput::failed(2, "linker not found");
44/// assert_eq!(ko.stderr, "linker not found");
45/// ```
46#[derive(Debug, Clone, PartialEq, Eq, Default)]
47pub struct MockShellOutput {
48    /// Standard output the step reports.
49    pub stdout: String,
50    /// Standard error the step reports.
51    pub stderr: String,
52    /// Process exit code. Anything but `0` fails the step.
53    pub exit_code: i32,
54}
55
56impl MockShellOutput {
57    /// A successful command that printed `stdout`.
58    ///
59    /// # Examples
60    ///
61    /// ```
62    /// use ironflow_engine::testing::MockShellOutput;
63    ///
64    /// let output = MockShellOutput::ok("ok\n");
65    /// assert_eq!(output.stdout, "ok\n");
66    /// assert!(output.stderr.is_empty());
67    /// ```
68    pub fn ok(stdout: &str) -> Self {
69        Self {
70            stdout: stdout.to_string(),
71            ..Self::default()
72        }
73    }
74
75    /// A command that exited with `exit_code` after printing `stderr`.
76    ///
77    /// # Examples
78    ///
79    /// ```
80    /// use ironflow_engine::testing::MockShellOutput;
81    ///
82    /// let output = MockShellOutput::failed(127, "command not found");
83    /// assert_eq!(output.exit_code, 127);
84    /// ```
85    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    /// Convert to what the step lifecycle expects.
94    ///
95    /// Mirrors [`ShellExecutor`](crate::executor::ShellExecutor): a non-zero
96    /// exit code is an error, not an output. `allow_failure`, step retry
97    /// policies and run failure all key off that error.
98    pub(crate) fn into_step_result(self) -> Result<StepOutput, EngineError> {
99        if self.exit_code != 0 {
100            return Err(EngineError::Operation(OperationError::Shell {
101                exit_code: self.exit_code,
102                stderr: self.stderr,
103            }));
104        }
105
106        Ok(StepOutput {
107            output: json!({
108                "stdout": self.stdout,
109                "stderr": self.stderr,
110                "exit_code": self.exit_code,
111            }),
112            duration_ms: 0,
113            cost_usd: Decimal::ZERO,
114            input_tokens: None,
115            cache_read_input_tokens: None,
116            cache_creation_input_tokens: None,
117            output_tokens: None,
118            model: None,
119            debug_messages: None,
120            artifacts: StepArtifacts::default(),
121        })
122    }
123}
124
125/// Canned response of a mocked HTTP step.
126///
127/// A non-2xx status is *not* an error, exactly like the real
128/// [`HttpExecutor`](crate::executor::HttpExecutor): the status lands in the
129/// step output. A transport failure is expressed by returning
130/// `Err(OperationError::Http { status: None, .. })` from the mock closure.
131///
132/// # Examples
133///
134/// ```
135/// use ironflow_engine::testing::MockHttpResponse;
136/// use serde_json::json;
137///
138/// let created = MockHttpResponse::json(201, &json!({"id": 7}))
139///     .header("location", "/things/7");
140/// assert_eq!(created.status, 201);
141/// assert_eq!(created.headers, vec![("location".to_string(), "/things/7".to_string())]);
142/// ```
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct MockHttpResponse {
145    /// HTTP status code the step reports.
146    pub status: u16,
147    /// Response headers, in insertion order.
148    pub headers: Vec<(String, String)>,
149    /// Raw response body.
150    pub body: String,
151}
152
153impl Default for MockHttpResponse {
154    fn default() -> Self {
155        Self {
156            status: 200,
157            headers: Vec::new(),
158            body: String::new(),
159        }
160    }
161}
162
163impl MockHttpResponse {
164    /// A `200 OK` carrying `body` serialized as JSON.
165    ///
166    /// # Examples
167    ///
168    /// ```
169    /// use ironflow_engine::testing::MockHttpResponse;
170    /// use serde_json::json;
171    ///
172    /// let response = MockHttpResponse::ok(&json!({"ok": true}));
173    /// assert_eq!(response.status, 200);
174    /// assert_eq!(response.body, r#"{"ok":true}"#);
175    /// ```
176    pub fn ok(body: &Value) -> Self {
177        Self::json(200, body)
178    }
179
180    /// A response with the given status carrying `body` serialized as JSON.
181    ///
182    /// # Examples
183    ///
184    /// ```
185    /// use ironflow_engine::testing::MockHttpResponse;
186    /// use serde_json::json;
187    ///
188    /// let response = MockHttpResponse::json(404, &json!({"error": "not found"}));
189    /// assert_eq!(response.status, 404);
190    /// ```
191    pub fn json(status: u16, body: &Value) -> Self {
192        Self {
193            status,
194            headers: Vec::new(),
195            // `Value` always serializes; the fallback keeps the mock infallible.
196            body: to_string(body).unwrap_or_else(|_| body.to_string()),
197        }
198    }
199
200    /// A response with the given status carrying a raw text body.
201    ///
202    /// # Examples
203    ///
204    /// ```
205    /// use ironflow_engine::testing::MockHttpResponse;
206    ///
207    /// let response = MockHttpResponse::text(503, "upstream is down");
208    /// assert_eq!(response.body, "upstream is down");
209    /// ```
210    pub fn text(status: u16, body: &str) -> Self {
211        Self {
212            status,
213            headers: Vec::new(),
214            body: body.to_string(),
215        }
216    }
217
218    /// Add a response header.
219    ///
220    /// # Examples
221    ///
222    /// ```
223    /// use ironflow_engine::testing::MockHttpResponse;
224    ///
225    /// let response = MockHttpResponse::text(200, "pong").header("x-trace", "abc");
226    /// assert_eq!(response.headers.len(), 1);
227    /// ```
228    pub fn header(mut self, name: &str, value: &str) -> Self {
229        self.headers.push((name.to_string(), value.to_string()));
230        self
231    }
232
233    /// Convert to the exact shape [`HttpExecutor`](crate::executor::HttpExecutor)
234    /// persists.
235    pub(crate) fn into_step_output(self) -> StepOutput {
236        let headers: BTreeMap<String, String> = self.headers.into_iter().collect();
237        StepOutput {
238            output: json!({
239                "status": self.status,
240                "headers": headers,
241                "body": self.body,
242            }),
243            duration_ms: 0,
244            cost_usd: Decimal::ZERO,
245            input_tokens: None,
246            cache_read_input_tokens: None,
247            cache_creation_input_tokens: None,
248            output_tokens: None,
249            model: None,
250            debug_messages: None,
251            artifacts: StepArtifacts::default(),
252        }
253    }
254}
255
256/// Closure answering a shell step from its config.
257pub type ShellMock =
258    Arc<dyn Fn(&ShellConfig) -> Result<MockShellOutput, OperationError> + Send + Sync>;
259
260/// Closure answering an HTTP step from its config.
261pub type HttpMock =
262    Arc<dyn Fn(&HttpConfig) -> Result<MockHttpResponse, OperationError> + Send + Sync>;
263
264/// Closure answering a human input step from its name and config.
265pub type HumanInputMock = Arc<dyn Fn(&str, &HumanInputConfig) -> HumanInputOutcome + Send + Sync>;
266
267/// Closure answering an agent invocation from its config.
268pub type AgentMock = Arc<dyn Fn(&AgentConfig) -> Result<AgentOutput, AgentError> + Send + Sync>;
269
270/// A [`StepInterceptor`] built from closures.
271///
272/// Built by [`TestEngine`](crate::testing::TestEngine); a step kind with no
273/// mock attached falls through to the real executor.
274///
275/// # Examples
276///
277/// ```
278/// use ironflow_engine::config::{ShellConfig, StepConfig};
279/// use ironflow_engine::executor::StepInterceptor;
280/// use ironflow_engine::testing::{MockInterceptor, MockShellOutput};
281///
282/// let interceptor = MockInterceptor::new().shell(|_cfg| Ok(MockShellOutput::ok("mocked")));
283/// let config = StepConfig::Shell(ShellConfig::new("./deploy.sh"));
284///
285/// let output = interceptor
286///     .intercept(&config)
287///     .expect("shell steps are mocked")
288///     .expect("the mock succeeded");
289/// assert_eq!(output.stdout(), "mocked");
290/// ```
291#[derive(Clone, Default)]
292pub struct MockInterceptor {
293    shell: Option<ShellMock>,
294    http: Option<HttpMock>,
295    approval: Option<ApprovalOutcome>,
296    human_input: Option<HumanInputMock>,
297}
298
299impl MockInterceptor {
300    /// An interceptor that mocks nothing.
301    ///
302    /// # Examples
303    ///
304    /// ```
305    /// use ironflow_engine::config::{ShellConfig, StepConfig};
306    /// use ironflow_engine::executor::StepInterceptor;
307    /// use ironflow_engine::testing::MockInterceptor;
308    ///
309    /// let interceptor = MockInterceptor::new();
310    /// let config = StepConfig::Shell(ShellConfig::new("echo hi"));
311    /// assert!(interceptor.intercept(&config).is_none());
312    /// ```
313    pub fn new() -> Self {
314        Self::default()
315    }
316
317    /// Answer every shell step with `f`.
318    ///
319    /// # Examples
320    ///
321    /// ```
322    /// use ironflow_engine::testing::{MockInterceptor, MockShellOutput};
323    ///
324    /// let interceptor = MockInterceptor::new()
325    ///     .shell(|cfg| Ok(MockShellOutput::ok(&format!("ran {}", cfg.command))));
326    /// # let _ = interceptor;
327    /// ```
328    pub fn shell(
329        mut self,
330        f: impl Fn(&ShellConfig) -> Result<MockShellOutput, OperationError> + Send + Sync + 'static,
331    ) -> Self {
332        self.shell = Some(Arc::new(f));
333        self
334    }
335
336    /// Answer every HTTP step with `f`.
337    ///
338    /// # Examples
339    ///
340    /// ```
341    /// use ironflow_engine::testing::{MockHttpResponse, MockInterceptor};
342    /// use serde_json::json;
343    ///
344    /// let interceptor = MockInterceptor::new()
345    ///     .http(|_cfg| Ok(MockHttpResponse::ok(&json!({"ok": true}))));
346    /// # let _ = interceptor;
347    /// ```
348    pub fn http(
349        mut self,
350        f: impl Fn(&HttpConfig) -> Result<MockHttpResponse, OperationError> + Send + Sync + 'static,
351    ) -> Self {
352        self.http = Some(Arc::new(f));
353        self
354    }
355
356    /// Resolve every approval gate with `outcome`.
357    ///
358    /// # Examples
359    ///
360    /// ```
361    /// use ironflow_engine::testing::{ApprovalOutcome, MockInterceptor};
362    ///
363    /// let interceptor = MockInterceptor::new().approval(ApprovalOutcome::Approved);
364    /// # let _ = interceptor;
365    /// ```
366    pub fn approval(mut self, outcome: ApprovalOutcome) -> Self {
367        self.approval = Some(outcome);
368        self
369    }
370
371    /// Answer every human input step with `f`.
372    ///
373    /// # Examples
374    ///
375    /// ```
376    /// use ironflow_engine::testing::{HumanInputOutcome, MockInterceptor};
377    /// use serde_json::json;
378    ///
379    /// let interceptor = MockInterceptor::new()
380    ///     .human_input(|_name, _cfg| HumanInputOutcome::Provided(json!({"answers": ["yes"]})));
381    /// # let _ = interceptor;
382    /// ```
383    pub fn human_input(
384        mut self,
385        f: impl Fn(&str, &HumanInputConfig) -> HumanInputOutcome + Send + Sync + 'static,
386    ) -> Self {
387        self.human_input = Some(Arc::new(f));
388        self
389    }
390}
391
392impl fmt::Debug for MockInterceptor {
393    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
394        // Closures are not `Debug`: report which seams are mocked instead.
395        f.debug_struct("MockInterceptor")
396            .field("shell", &self.shell.is_some())
397            .field("http", &self.http.is_some())
398            .field("approval", &self.approval)
399            .field("human_input", &self.human_input.is_some())
400            .finish()
401    }
402}
403
404impl StepInterceptor for MockInterceptor {
405    fn intercept(&self, config: &StepConfig) -> Option<Result<StepOutput, EngineError>> {
406        match config {
407            StepConfig::Shell(cfg) => {
408                let mock = self.shell.as_ref()?;
409                Some(match mock(cfg) {
410                    Ok(out) => out.into_step_result(),
411                    Err(err) => Err(EngineError::Operation(err)),
412                })
413            }
414            StepConfig::Http(cfg) => {
415                let mock = self.http.as_ref()?;
416                Some(match mock(cfg) {
417                    Ok(res) => Ok(res.into_step_output()),
418                    Err(err) => Err(EngineError::Operation(err)),
419                })
420            }
421            // Agent steps are mocked at the provider seam instead.
422            _ => None,
423        }
424    }
425
426    fn intercept_approval(&self, _name: &str, _config: &ApprovalConfig) -> Option<ApprovalOutcome> {
427        self.approval.clone()
428    }
429
430    fn intercept_human_input(
431        &self,
432        name: &str,
433        config: &HumanInputConfig,
434        _schema: &Value,
435    ) -> Option<HumanInputOutcome> {
436        self.human_input.as_ref().map(|f| f(name, config))
437    }
438}
439
440/// An [`AgentProvider`] backed by a closure.
441///
442/// # Examples
443///
444/// ```
445/// use ironflow_core::provider::AgentOutput;
446/// use ironflow_engine::testing::MockAgentProvider;
447/// use serde_json::json;
448///
449/// let provider = MockAgentProvider::new(|cfg| {
450///     assert!(cfg.prompt.contains("review"));
451///     Ok(AgentOutput::new(json!({"score": 9})))
452/// });
453/// # let _ = provider;
454/// ```
455pub struct MockAgentProvider {
456    f: AgentMock,
457}
458
459impl MockAgentProvider {
460    /// Answer every invocation with `f`.
461    ///
462    /// # Examples
463    ///
464    /// ```
465    /// use ironflow_core::provider::AgentOutput;
466    /// use ironflow_engine::testing::MockAgentProvider;
467    /// use serde_json::json;
468    ///
469    /// let provider = MockAgentProvider::new(|_cfg| Ok(AgentOutput::new(json!("done"))));
470    /// # let _ = provider;
471    /// ```
472    pub fn new(
473        f: impl Fn(&AgentConfig) -> Result<AgentOutput, AgentError> + Send + Sync + 'static,
474    ) -> Self {
475        Self { f: Arc::new(f) }
476    }
477}
478
479impl fmt::Debug for MockAgentProvider {
480    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
481        f.debug_struct("MockAgentProvider").finish_non_exhaustive()
482    }
483}
484
485impl AgentProvider for MockAgentProvider {
486    fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a> {
487        let result = (self.f)(config);
488        Box::pin(async move { result })
489    }
490}
491
492/// The provider a [`TestEngine`](crate::testing::TestEngine) uses when no agent
493/// backend was configured.
494///
495/// Every invocation fails with an explanation instead of reaching the Claude
496/// CLI, so a forgotten `with_mock_agent` is a loud test failure, not a silent
497/// network call.
498///
499/// # Examples
500///
501/// ```
502/// use ironflow_engine::testing::MissingAgentProvider;
503///
504/// // Usable anywhere an `AgentProvider` is expected, including as the inner
505/// // provider of a `RecordReplayProvider` in replay mode.
506/// let provider = MissingAgentProvider;
507/// assert_eq!(format!("{provider:?}"), "MissingAgentProvider");
508/// ```
509#[derive(Debug, Default, Clone, Copy)]
510pub struct MissingAgentProvider;
511
512impl AgentProvider for MissingAgentProvider {
513    fn invoke<'a>(&'a self, _config: &'a AgentConfig) -> InvokeFuture<'a> {
514        Box::pin(async move {
515            Err(AgentError::ProcessFailed {
516                exit_code: -1,
517                stderr: MISSING_AGENT_PROVIDER.to_string(),
518            })
519        })
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    use crate::config::AgentStepConfig;
528
529    #[test]
530    fn shell_ok_maps_to_the_real_executor_output_shape() {
531        let output = MockShellOutput::ok("hello\n")
532            .into_step_result()
533            .expect("exit code 0 succeeds");
534
535        assert_eq!(output.output["stdout"], "hello\n");
536        assert_eq!(output.output["stderr"], "");
537        assert_eq!(output.output["exit_code"], 0);
538        assert_eq!(output.cost_usd, Decimal::ZERO);
539    }
540
541    #[test]
542    fn shell_default_is_an_empty_success() {
543        let default = MockShellOutput::default();
544        assert_eq!(default.exit_code, 0);
545        assert!(default.stdout.is_empty());
546        assert!(default.stderr.is_empty());
547    }
548
549    #[test]
550    fn shell_non_zero_exit_is_an_operation_error() {
551        let err = MockShellOutput::failed(2, "x")
552            .into_step_result()
553            .expect_err("a non-zero exit code fails the step");
554
555        match err {
556            EngineError::Operation(OperationError::Shell { exit_code, stderr }) => {
557                assert_eq!(exit_code, 2);
558                assert_eq!(stderr, "x");
559            }
560            other => panic!("expected a shell operation error, got {other}"),
561        }
562    }
563
564    #[test]
565    fn http_json_carries_status_body_and_headers() {
566        let output = MockHttpResponse::json(201, &json!({"id": 7}))
567            .header("location", "/things/7")
568            .into_step_output();
569
570        assert_eq!(output.output["status"], 201);
571        assert_eq!(output.output["body"], r#"{"id":7}"#);
572        assert_eq!(output.output["headers"]["location"], "/things/7");
573    }
574
575    #[test]
576    fn http_default_is_an_empty_200() {
577        let default = MockHttpResponse::default();
578        assert_eq!(default.status, 200);
579        assert!(default.body.is_empty());
580        assert!(default.headers.is_empty());
581    }
582
583    #[test]
584    fn http_non_2xx_is_still_an_output() {
585        let output = MockHttpResponse::text(500, "boom").into_step_output();
586        assert_eq!(output.status(), Some(500));
587        assert_eq!(output.body(), "boom");
588    }
589
590    #[test]
591    fn intercept_declines_agent_steps() {
592        let interceptor = MockInterceptor::new().shell(|_| Ok(MockShellOutput::ok("x")));
593        let config = StepConfig::Agent(AgentStepConfig::new("review this"));
594
595        assert!(interceptor.intercept(&config).is_none());
596    }
597
598    #[test]
599    fn intercept_declines_shell_steps_without_a_shell_mock() {
600        let interceptor = MockInterceptor::new();
601        let config = StepConfig::Shell(ShellConfig::new("echo hi"));
602
603        assert!(interceptor.intercept(&config).is_none());
604    }
605
606    #[test]
607    fn intercept_approval_returns_the_configured_outcome() {
608        let interceptor = MockInterceptor::new().approval(ApprovalOutcome::reject("nope"));
609        let config = ApprovalConfig::new("Approve?");
610
611        assert_eq!(
612            interceptor.intercept_approval("gate", &config),
613            Some(ApprovalOutcome::reject("nope"))
614        );
615        assert_eq!(
616            MockInterceptor::new().intercept_approval("gate", &config),
617            None
618        );
619    }
620
621    #[test]
622    fn intercept_human_input_returns_the_mocked_answer() {
623        let interceptor = MockInterceptor::new().human_input(|name, cfg| {
624            HumanInputOutcome::Provided(json!({"step": name, "message": cfg.message()}))
625        });
626        let config = HumanInputConfig::new("Answer?");
627        let answer = json!({"step": "clarify", "message": "Answer?"});
628
629        assert_eq!(
630            interceptor.intercept_human_input("clarify", &config, &json!({})),
631            Some(HumanInputOutcome::Provided(answer))
632        );
633    }
634
635    #[test]
636    fn intercept_human_input_declines_without_a_mock() {
637        let config = HumanInputConfig::new("Answer?");
638
639        assert_eq!(
640            MockInterceptor::new().intercept_human_input("clarify", &config, &json!({})),
641            None
642        );
643    }
644
645    #[test]
646    fn debug_reports_which_seams_are_mocked() {
647        let interceptor = MockInterceptor::new().http(|_| Ok(MockHttpResponse::default()));
648        let rendered = format!("{interceptor:?}");
649
650        assert!(rendered.contains("shell: false"));
651        assert!(rendered.contains("http: true"));
652    }
653
654    #[tokio::test]
655    async fn missing_agent_provider_names_the_three_constructors() {
656        let config = AgentConfig::new("anything");
657        let err = MissingAgentProvider
658            .invoke(&config)
659            .await
660            .expect_err("no agent backend is configured");
661
662        let message = err.to_string();
663        assert!(message.contains("with_mock_agent"));
664        assert!(message.contains("with_recorded_agent"));
665        assert!(message.contains("with_agent_provider"));
666    }
667
668    #[tokio::test]
669    async fn mock_agent_provider_runs_the_closure() {
670        let provider = MockAgentProvider::new(|cfg| {
671            let echoed = json!({"echoed": cfg.prompt.clone()});
672            Ok(AgentOutput::new(echoed))
673        });
674        let config = AgentConfig::new("say hi");
675
676        let output = provider.invoke(&config).await.expect("the mock succeeded");
677
678        assert_eq!(output.value["echoed"], "say hi");
679    }
680}