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, SignalOutcome, 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, unless the step set
97    /// `exit_code_as_output`. `allow_failure`, step retry policies and run
98    /// failure all key off that error.
99    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/// Canned response of a mocked HTTP step.
131///
132/// A non-2xx status is *not* an error, exactly like the real
133/// [`HttpExecutor`](crate::executor::HttpExecutor): the status lands in the
134/// step output. A transport failure is expressed by returning
135/// `Err(OperationError::Http { status: None, .. })` from the mock closure.
136///
137/// # Examples
138///
139/// ```
140/// use ironflow_engine::testing::MockHttpResponse;
141/// use serde_json::json;
142///
143/// let created = MockHttpResponse::json(201, &json!({"id": 7}))
144///     .header("location", "/things/7");
145/// assert_eq!(created.status, 201);
146/// assert_eq!(created.headers, vec![("location".to_string(), "/things/7".to_string())]);
147/// ```
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct MockHttpResponse {
150    /// HTTP status code the step reports.
151    pub status: u16,
152    /// Response headers, in insertion order.
153    pub headers: Vec<(String, String)>,
154    /// Raw response body.
155    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    /// A `200 OK` carrying `body` serialized as JSON.
170    ///
171    /// # Examples
172    ///
173    /// ```
174    /// use ironflow_engine::testing::MockHttpResponse;
175    /// use serde_json::json;
176    ///
177    /// let response = MockHttpResponse::ok(&json!({"ok": true}));
178    /// assert_eq!(response.status, 200);
179    /// assert_eq!(response.body, r#"{"ok":true}"#);
180    /// ```
181    pub fn ok(body: &Value) -> Self {
182        Self::json(200, body)
183    }
184
185    /// A response with the given status carrying `body` serialized as JSON.
186    ///
187    /// # Examples
188    ///
189    /// ```
190    /// use ironflow_engine::testing::MockHttpResponse;
191    /// use serde_json::json;
192    ///
193    /// let response = MockHttpResponse::json(404, &json!({"error": "not found"}));
194    /// assert_eq!(response.status, 404);
195    /// ```
196    pub fn json(status: u16, body: &Value) -> Self {
197        Self {
198            status,
199            headers: Vec::new(),
200            // `Value` always serializes; the fallback keeps the mock infallible.
201            body: to_string(body).unwrap_or_else(|_| body.to_string()),
202        }
203    }
204
205    /// A response with the given status carrying a raw text body.
206    ///
207    /// # Examples
208    ///
209    /// ```
210    /// use ironflow_engine::testing::MockHttpResponse;
211    ///
212    /// let response = MockHttpResponse::text(503, "upstream is down");
213    /// assert_eq!(response.body, "upstream is down");
214    /// ```
215    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    /// Add a response header.
224    ///
225    /// # Examples
226    ///
227    /// ```
228    /// use ironflow_engine::testing::MockHttpResponse;
229    ///
230    /// let response = MockHttpResponse::text(200, "pong").header("x-trace", "abc");
231    /// assert_eq!(response.headers.len(), 1);
232    /// ```
233    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    /// Convert to the exact shape [`HttpExecutor`](crate::executor::HttpExecutor)
239    /// persists.
240    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
262/// Closure answering a shell step from its config.
263pub type ShellMock =
264    Arc<dyn Fn(&ShellConfig) -> Result<MockShellOutput, OperationError> + Send + Sync>;
265
266/// Closure answering an HTTP step from its config.
267pub type HttpMock =
268    Arc<dyn Fn(&HttpConfig) -> Result<MockHttpResponse, OperationError> + Send + Sync>;
269
270/// Closure answering a human input step from its name and config.
271pub type HumanInputMock = Arc<dyn Fn(&str, &HumanInputConfig) -> HumanInputOutcome + Send + Sync>;
272
273/// Closure answering a signal step from its step name, signal name and key.
274pub type SignalMock = Arc<dyn Fn(&str, &str, &str) -> SignalOutcome + Send + Sync>;
275
276/// Closure answering an agent invocation from its config.
277pub type AgentMock = Arc<dyn Fn(&AgentConfig) -> Result<AgentOutput, AgentError> + Send + Sync>;
278
279/// A [`StepInterceptor`] built from closures.
280///
281/// Built by [`TestEngine`](crate::testing::TestEngine); a step kind with no
282/// mock attached falls through to the real executor.
283///
284/// # Examples
285///
286/// ```
287/// use ironflow_engine::config::{ShellConfig, StepConfig};
288/// use ironflow_engine::executor::StepInterceptor;
289/// use ironflow_engine::testing::{MockInterceptor, MockShellOutput};
290///
291/// let interceptor = MockInterceptor::new().shell(|_cfg| Ok(MockShellOutput::ok("mocked")));
292/// let config = StepConfig::Shell(ShellConfig::new("./deploy.sh"));
293///
294/// let output = interceptor
295///     .intercept(&config)
296///     .expect("shell steps are mocked")
297///     .expect("the mock succeeded");
298/// assert_eq!(output.stdout(), "mocked");
299/// ```
300#[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    /// An interceptor that mocks nothing.
311    ///
312    /// # Examples
313    ///
314    /// ```
315    /// use ironflow_engine::config::{ShellConfig, StepConfig};
316    /// use ironflow_engine::executor::StepInterceptor;
317    /// use ironflow_engine::testing::MockInterceptor;
318    ///
319    /// let interceptor = MockInterceptor::new();
320    /// let config = StepConfig::Shell(ShellConfig::new("echo hi"));
321    /// assert!(interceptor.intercept(&config).is_none());
322    /// ```
323    pub fn new() -> Self {
324        Self::default()
325    }
326
327    /// Answer every shell step with `f`.
328    ///
329    /// # Examples
330    ///
331    /// ```
332    /// use ironflow_engine::testing::{MockInterceptor, MockShellOutput};
333    ///
334    /// let interceptor = MockInterceptor::new()
335    ///     .shell(|cfg| Ok(MockShellOutput::ok(&format!("ran {}", cfg.command))));
336    /// # let _ = interceptor;
337    /// ```
338    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    /// Answer every HTTP step with `f`.
347    ///
348    /// # Examples
349    ///
350    /// ```
351    /// use ironflow_engine::testing::{MockHttpResponse, MockInterceptor};
352    /// use serde_json::json;
353    ///
354    /// let interceptor = MockInterceptor::new()
355    ///     .http(|_cfg| Ok(MockHttpResponse::ok(&json!({"ok": true}))));
356    /// # let _ = interceptor;
357    /// ```
358    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    /// Resolve every approval gate with `outcome`.
367    ///
368    /// # Examples
369    ///
370    /// ```
371    /// use ironflow_engine::testing::{ApprovalOutcome, MockInterceptor};
372    ///
373    /// let interceptor = MockInterceptor::new().approval(ApprovalOutcome::Approved);
374    /// # let _ = interceptor;
375    /// ```
376    pub fn approval(mut self, outcome: ApprovalOutcome) -> Self {
377        self.approval = Some(outcome);
378        self
379    }
380
381    /// Answer every human input step with `f`.
382    ///
383    /// # Examples
384    ///
385    /// ```
386    /// use ironflow_engine::testing::{HumanInputOutcome, MockInterceptor};
387    /// use serde_json::json;
388    ///
389    /// let interceptor = MockInterceptor::new()
390    ///     .human_input(|_name, _cfg| HumanInputOutcome::Provided(json!({"answers": ["yes"]})));
391    /// # let _ = interceptor;
392    /// ```
393    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    /// Resolve every signal step with `f`.
402    ///
403    /// `f` receives the step name, the signal name and the key.
404    ///
405    /// # Examples
406    ///
407    /// ```
408    /// use ironflow_engine::testing::{MockInterceptor, SignalOutcome};
409    /// use serde_json::json;
410    ///
411    /// let interceptor = MockInterceptor::new()
412    ///     .signal(|_step, _name, _key| SignalOutcome::Received(json!({"status": "success"})));
413    /// # let _ = interceptor;
414    /// ```
415    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        // Closures are not `Debug`: report which seams are mocked instead.
427        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            // Agent steps are mocked at the provider seam instead.
455            _ => 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
483/// An [`AgentProvider`] backed by a closure.
484///
485/// # Examples
486///
487/// ```
488/// use ironflow_core::provider::AgentOutput;
489/// use ironflow_engine::testing::MockAgentProvider;
490/// use serde_json::json;
491///
492/// let provider = MockAgentProvider::new(|cfg| {
493///     assert!(cfg.prompt.contains("review"));
494///     Ok(AgentOutput::new(json!({"score": 9})))
495/// });
496/// # let _ = provider;
497/// ```
498pub struct MockAgentProvider {
499    f: AgentMock,
500}
501
502impl MockAgentProvider {
503    /// Answer every invocation with `f`.
504    ///
505    /// # Examples
506    ///
507    /// ```
508    /// use ironflow_core::provider::AgentOutput;
509    /// use ironflow_engine::testing::MockAgentProvider;
510    /// use serde_json::json;
511    ///
512    /// let provider = MockAgentProvider::new(|_cfg| Ok(AgentOutput::new(json!("done"))));
513    /// # let _ = provider;
514    /// ```
515    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/// The provider a [`TestEngine`](crate::testing::TestEngine) uses when no agent
536/// backend was configured.
537///
538/// Every invocation fails with an explanation instead of reaching the Claude
539/// CLI, so a forgotten `with_mock_agent` is a loud test failure, not a silent
540/// network call.
541///
542/// # Examples
543///
544/// ```
545/// use ironflow_engine::testing::MissingAgentProvider;
546///
547/// // Usable anywhere an `AgentProvider` is expected, including as the inner
548/// // provider of a `RecordReplayProvider` in replay mode.
549/// let provider = MissingAgentProvider;
550/// assert_eq!(format!("{provider:?}"), "MissingAgentProvider");
551/// ```
552#[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}