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