ironflow_engine/testing/mod.rs
1//! In-memory test harness for workflow handlers.
2//!
3//! [`TestEngine`] runs a real [`Engine`](crate::engine::Engine) against a real
4//! [`InMemoryStore`](ironflow_store::memory::InMemoryStore): the run, the steps,
5//! the FSM transitions and the persistence are the production ones. Only the
6//! outside world is swapped out -- shell commands, HTTP requests, agent
7//! invocations, approval gates and human inputs are answered from closures instead of
8//! spawning processes, opening sockets or waiting for a human.
9//!
10//! What it does *not* start: no HTTP server, no background worker, no Postgres.
11//! A run executes inline, in the calling task, and finishes before
12//! [`run`](TestEngine::run) returns.
13//!
14//! # Examples
15//!
16//! ```no_run
17//! use ironflow_engine::prelude::*;
18//! use ironflow_engine::testing::{ApprovalOutcome, MockShellOutput, TestEngine};
19//! use ironflow_store::models::RunStatus;
20//! use serde_json::json;
21//!
22//! # struct Deploy;
23//! # impl WorkflowHandler for Deploy {
24//! # fn name(&self) -> &str { "deploy" }
25//! # fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
26//! # Box::pin(async move { ctx.shell("deploy", ShellConfig::new("./deploy.sh")).await?; Ok(()) })
27//! # }
28//! # }
29//! # async fn example() -> Result<(), EngineError> {
30//! let result = TestEngine::new()
31//! .with_handler(Deploy)
32//! .with_mock_shell(|_cfg| Ok(MockShellOutput::ok(r#"{"version":"1.2.3"}"#)))
33//! .with_mock_approval(ApprovalOutcome::Approved)
34//! .run(json!({"env": "prod"}))
35//! .await?;
36//!
37//! assert_eq!(result.status(), RunStatus::Completed);
38//! assert_eq!(result.step("deploy").step_output().stdout(), r#"{"version":"1.2.3"}"#);
39//! # Ok(())
40//! # }
41//! ```
42//!
43//! # What the harness covers
44//!
45//! | Step | How it is mocked |
46//! |------|------------------|
47//! | `ctx.shell` | [`TestEngine::with_mock_shell`] |
48//! | `ctx.http` | [`TestEngine::with_mock_http`] |
49//! | `ctx.agent` | [`TestEngine::with_mock_agent`] or [`TestEngine::with_recorded_agent`] |
50//! | `ctx.approval` | [`TestEngine::with_mock_approval`], or [`TestEngine::resume`] |
51//! | `ctx.human_input` | [`TestEngine::with_mock_human_input`], or [`TestEngine::resume`] after writing the answer on the step |
52//! | `ctx.wait_for_signal` | [`TestEngine::with_mock_signal`]: [`SignalOutcome::Received`] or [`SignalOutcome::TimedOut`] |
53//! | `ctx.secrets`, secrets read by operations | `TestEngine::with_secret` (`secret-store` feature) |
54//! | `ctx.parallel`, `ctx.workflow`, `on_error` | the mocks above apply to the steps inside them |
55//!
56//! # Limitations
57//!
58//! * Custom operations ([`ctx.operation`](crate::context::WorkflowContext::operation))
59//! are not intercepted. Mock one by passing a test-double
60//! [`Operation`](crate::operation::Operation) to the handler.
61//! * [`ctx.delay`](crate::context::WorkflowContext::delay) is not intercepted: a
62//! non-zero delay still suspends the run with
63//! [`RunStatus::Sleeping`](ironflow_store::models::RunStatus::Sleeping). The run
64//! resumes once [`RunWaker::tick`](crate::wake::RunWaker::tick) runs after its
65//! `scheduled_at`, or right away with [`TestEngine::resume`].
66//! * A step that suspends inside a `ctx.workflow` child suspends the whole
67//! chain. Resume it with [`TestEngine::resume`] on the child run id: like in
68//! production, the root run is resumed and re-enters the child.
69//! * [`ctx.decision`](crate::context::WorkflowContext::decision) needs a real
70//! [`DecisionProvider`](ironflow_core::decision::DecisionProvider), wired with
71//! [`TestEngine::with_decision_provider`].
72
73mod engine;
74mod mocks;
75mod result;
76
77pub use engine::TestEngine;
78pub use mocks::{
79 AgentMock, HttpMock, HumanInputMock, MissingAgentProvider, MockAgentProvider, MockHttpResponse,
80 MockInterceptor, MockShellOutput, ShellMock, SignalMock,
81};
82pub use result::{TestResult, TestStep};
83
84// Re-exported so test code has a single import path for everything the harness
85// needs.
86pub use crate::executor::{ApprovalOutcome, HumanInputOutcome, SignalOutcome};