Skip to main content

orbit_runtime/
testing.rs

1//! In-process workflow simulation, used by `orbit test` and `orbit simulate`.
2//!
3//! The [`Simulator`] runs the real [`Engine`] over an in-memory store with a
4//! manual clock. It acts as the worker for every task (asking a
5//! [`TaskHandler`] for each result), starts child workflows like the server
6//! does, sends signals as soon as the execution waits for them, and advances
7//! durable time (sleeps, retry backoff, timeouts) straight to the next
8//! wake-up whenever nothing else can happen, so simulations finish instantly
9//! and deterministically.
10
11use crate::engine::{ContractAnnouncement, RegisterWorker, TaskAssignment};
12use crate::model::*;
13use crate::{Engine, EngineConfig, Store};
14use orbit_ir::testing::{value_matches, Expectation, MockOutcome, TestCase};
15use orbit_ir::{eval, WORKFLOW_CONTRACT_PREFIX};
16use orbit_ir::{Action, Scope, SourceLocation, TaskContract, Type, WorkflowIr};
17use serde::Serialize;
18use serde_json::{json, Value};
19use std::collections::{BTreeMap, BTreeSet, HashMap};
20use std::sync::atomic::{AtomicU64, Ordering};
21use std::sync::Arc;
22
23/// Simulated time starts at 2026-01-01T00:00:00Z.
24pub const SIMULATION_EPOCH_MS: u64 = 1_767_225_600_000;
25/// Id of the execution a simulation starts (children get derived ids).
26pub const ROOT_EXECUTION_ID: &str = "sim";
27/// Upper bound on scheduling rounds, so a runaway simulation always ends.
28const MAX_ROUNDS: usize = 100_000;
29/// Runtime announced by the simulated worker that takes steps without `using`.
30const DEFAULT_RUNTIME: &str = "simulator";
31/// Simulated time never runs further than this past the epoch (ten years).
32const MAX_SIMULATED_MS: u64 = 10 * 365 * 24 * 3600 * 1000;
33
34/// What the simulated worker does with one task.
35#[derive(Debug, Clone, PartialEq)]
36pub enum TaskOutcome {
37    Complete(Value),
38    Fail(TaskError),
39    /// Never answer; the attempt's timeout (if any) decides what happens.
40    Hang,
41    /// The simulation cannot continue (e.g. a test did not mock this task).
42    Abort(String),
43}
44
45/// One task handed to the simulated worker.
46pub struct TaskCall<'a> {
47    pub assignment: &'a TaskAssignment,
48    pub contract: Option<&'a TaskContract>,
49    /// 1 for the first time this unit (step, element or compensation) is
50    /// dispatched, 2 for the second, and so on.
51    pub call: u32,
52}
53
54pub trait TaskHandler {
55    fn handle(&mut self, call: &TaskCall) -> TaskOutcome;
56}
57
58/// One execution of a finished simulation.
59#[derive(Debug, Clone, Serialize)]
60pub struct SimulatedExecution {
61    pub state: ExecutionState,
62    pub history: Vec<HistoryEvent>,
63}
64
65#[derive(Debug, Clone, Serialize)]
66pub struct Simulation {
67    /// The execution the simulation started.
68    pub root: SimulatedExecution,
69    /// Child workflow executions, by id.
70    pub children: Vec<SimulatedExecution>,
71    /// Why the simulation stopped before the execution finished, or other
72    /// things that did not go as the caller asked (e.g. unsent signals).
73    pub problems: Vec<String>,
74    /// Signals that were never sent because nothing waited for them.
75    pub unsent_signals: Vec<String>,
76    /// Simulated time from start to the end of the simulation.
77    pub elapsed_ms: u64,
78}
79
80impl Simulation {
81    pub fn finished(&self) -> bool {
82        self.root.state.status.is_terminal()
83    }
84}
85
86pub struct Simulator {
87    engine: Engine,
88    clock: Arc<AtomicU64>,
89    /// (runtime, session id) of the simulated workers.
90    sessions: Vec<(String, String)>,
91    contracts: HashMap<String, TaskContract>,
92}
93
94impl Simulator {
95    /// Register `workflows` (children before parents) with a fresh engine.
96    pub fn new(workflows: &[WorkflowIr]) -> Result<Simulator, String> {
97        let clock = Arc::new(AtomicU64::new(SIMULATION_EPOCH_MS));
98        let c = clock.clone();
99        let config = EngineConfig {
100            // The simulated workers never go missing, however far time jumps.
101            session_timeout_ms: 1 << 50,
102            heartbeat_interval_ms: 1 << 40,
103            // Built-in tasks are handed to the simulator instead of run.
104            http_adapter: true,
105            ..EngineConfig::default()
106        };
107        let store = Store::in_memory().map_err(|e| e.to_string())?;
108        let mut engine = Engine::open(store, config, Box::new(move || c.load(Ordering::SeqCst)))
109            .map_err(|e| e.to_string())?;
110        // Register children first: a parent pins its children's definitions.
111        let mut pending: Vec<&WorkflowIr> = workflows.iter().collect();
112        while !pending.is_empty() {
113            let mut next = vec![];
114            let mut last_error = None;
115            for wf in &pending {
116                match engine.register_definition((*wf).clone()) {
117                    Ok(_) => {}
118                    Err(e) if e.code == "CHILD_NOT_REGISTERED" => {
119                        last_error = Some(e.message.clone());
120                        next.push(*wf);
121                    }
122                    Err(e) => return Err(format!("{}@{}: {}", wf.name, wf.version, e.message)),
123                }
124            }
125            if next.len() == pending.len() {
126                return Err(last_error.unwrap_or_default());
127            }
128            pending = next;
129        }
130        // One simulated worker per language that steps ask for (`using`),
131        // plus one for steps that do not care, all serving every task.
132        let mut contracts = HashMap::new();
133        let mut runtimes = BTreeSet::from([DEFAULT_RUNTIME.to_string()]);
134        for wf in workflows {
135            for c in wf.contracts.values() {
136                if !c.name.starts_with(WORKFLOW_CONTRACT_PREFIX) {
137                    contracts.insert(c.name.clone(), c.clone());
138                }
139            }
140            for s in &wf.steps {
141                for inv in s.invocation().into_iter().chain(s.compensation.as_ref()) {
142                    if let Some(l) = &inv.language {
143                        runtimes.insert(l.to_lowercase());
144                    }
145                }
146            }
147        }
148        let announced: BTreeSet<(String, String)> = contracts
149            .values()
150            .filter(|c| !c.builtin)
151            .map(|c| (c.name.clone(), c.version.clone()))
152            .collect();
153        let mut sessions = vec![];
154        if !announced.is_empty() {
155            for runtime in runtimes {
156                let reg = engine
157                    .register_worker(RegisterWorker {
158                        worker_id: if runtime == DEFAULT_RUNTIME {
159                            "simulator".to_string()
160                        } else {
161                            format!("simulator-{runtime}")
162                        },
163                        runtime: runtime.clone(),
164                        sdk: Some("orbit-simulator".into()),
165                        contracts: announced
166                            .iter()
167                            .map(|(n, v)| ContractAnnouncement {
168                                task_name: n.clone(),
169                                contract_version: v.clone(),
170                            })
171                            .collect(),
172                    })
173                    .map_err(|e| e.message)?;
174                sessions.push((runtime, reg.worker_session_id));
175            }
176        }
177        Ok(Simulator {
178            engine,
179            clock,
180            sessions,
181            contracts,
182        })
183    }
184
185    fn now(&self) -> u64 {
186        self.clock.load(Ordering::SeqCst)
187    }
188
189    /// Run one execution to completion (or until it cannot make progress).
190    pub fn run(
191        mut self,
192        workflow: &str,
193        version: Option<&str>,
194        input: Value,
195        signals: &[(String, Value)],
196        handler: &mut dyn TaskHandler,
197    ) -> Result<Simulation, String> {
198        let root = ROOT_EXECUTION_ID.to_string();
199        self.engine
200            .start(workflow, version, input, Some(root.clone()))
201            .map_err(|e| e.message)?;
202        let mut calls: HashMap<(String, String), u32> = HashMap::new();
203        let mut problems = vec![];
204        let mut next_signal = 0;
205        let mut rounds = 0;
206        let mut seen_progress = 0;
207        'run: loop {
208            rounds += 1;
209            if rounds > MAX_ROUNDS {
210                problems.push(format!(
211                    "the simulation did not finish within {MAX_ROUNDS} rounds"
212                ));
213                break;
214            }
215            let mut work = self.engine.tick().map_err(|e| e.message)?;
216            for (_, session) in self.sessions.clone() {
217                while let Some(a) = self.engine.poll(&session).map_err(|e| e.message)? {
218                    work.push(a);
219                }
220            }
221            for a in work {
222                let n = calls
223                    .entry((a.execution_id.clone(), a.step_id.clone()))
224                    .or_default();
225                *n += 1;
226                let call = TaskCall {
227                    assignment: &a,
228                    contract: self.contracts.get(&a.task_name),
229                    call: *n,
230                };
231                match handler.handle(&call) {
232                    TaskOutcome::Complete(v) => {
233                        self.engine.complete(&a.token, v).map_err(|e| e.message)?;
234                    }
235                    TaskOutcome::Fail(e) => {
236                        self.engine.fail(&a.token, e).map_err(|e| e.message)?;
237                    }
238                    TaskOutcome::Hang => {}
239                    TaskOutcome::Abort(msg) => {
240                        problems.push(msg);
241                        break 'run;
242                    }
243                }
244            }
245            let state = self.engine.execution(&root).map_err(|e| e.message)?;
246            if state.status.is_terminal() {
247                break;
248            }
249            // Anything recorded this round (results, children started or
250            // finished, timers fired) may enable more work right away.
251            let progress = self.progress()?;
252            if progress != seen_progress {
253                seen_progress = progress;
254                continue;
255            }
256            // Send the next signal once the execution waits for it.
257            if let Some((name, payload)) = signals.get(next_signal) {
258                if self.awaiting(&state).contains(name.as_str()) {
259                    self.engine
260                        .signal(&root, name, payload.clone())
261                        .map_err(|e| e.message)?;
262                    next_signal += 1;
263                    continue;
264                }
265            }
266            // Nothing can happen now: jump to the next durable wake-up.
267            match self.next_wake_up()? {
268                Some(at) if at - SIMULATION_EPOCH_MS <= MAX_SIMULATED_MS => {
269                    self.clock.store(at, Ordering::SeqCst);
270                }
271                Some(_) => {
272                    problems.push("the simulation ran past ten years of simulated time".into());
273                    break;
274                }
275                None => {
276                    problems.push(self.stuck_reason(&state, signals.get(next_signal)));
277                    break;
278                }
279            }
280        }
281        let unsent_signals: Vec<String> = signals[next_signal.min(signals.len())..]
282            .iter()
283            .map(|(n, _)| n.clone())
284            .collect();
285        let mut children = vec![];
286        for s in self
287            .engine
288            .list_executions(None, None, 1000)
289            .map_err(|e| e.message)?
290        {
291            if s.execution_id != root {
292                children.push(self.collect(&s.execution_id)?);
293            }
294        }
295        children.sort_by(|a, b| a.state.id.cmp(&b.state.id));
296        let root = self.collect(&root)?;
297        let end = root.state.finished_at_ms.unwrap_or(self.now());
298        Ok(Simulation {
299            elapsed_ms: end.saturating_sub(root.state.created_at_ms),
300            root,
301            children,
302            problems,
303            unsent_signals,
304        })
305    }
306
307    /// Total number of events recorded across all executions.
308    fn progress(&self) -> Result<u64, String> {
309        let mut total = 0;
310        for s in self
311            .engine
312            .list_executions(None, None, 1000)
313            .map_err(|e| e.message)?
314        {
315            total += 1 + self
316                .engine
317                .execution(&s.execution_id)
318                .map_err(|e| e.message)?
319                .last_seq;
320        }
321        Ok(total)
322    }
323
324    fn collect(&self, id: &str) -> Result<SimulatedExecution, String> {
325        Ok(SimulatedExecution {
326            state: self.engine.execution(id).map_err(|e| e.message)?,
327            history: self.engine.history(id).map_err(|e| e.message)?,
328        })
329    }
330
331    fn definition(&self, state: &ExecutionState) -> Option<Arc<WorkflowIr>> {
332        self.engine
333            .definition(&state.workflow, Some(&state.version))
334    }
335
336    /// Signals the execution's waiting `await` steps want.
337    fn awaiting(&self, state: &ExecutionState) -> BTreeSet<String> {
338        let Some(def) = self.definition(state) else {
339            return BTreeSet::new();
340        };
341        state
342            .units
343            .iter()
344            .filter(|(_, u)| u.status() == UnitStatus::Waiting)
345            .filter_map(|(key, _)| awaited(&def, key).map(str::to_string))
346            .collect()
347    }
348
349    /// The earliest future time at which a timer, retry, deadline or timeout
350    /// of any unfinished execution fires.
351    fn next_wake_up(&self) -> Result<Option<u64>, String> {
352        let now = self.now();
353        let mut best: Option<u64> = None;
354        let mut consider = |t: u64| {
355            if t > now && best.is_none_or(|b| t < b) {
356                best = Some(t);
357            }
358        };
359        for s in self
360            .engine
361            .list_executions(None, None, 1000)
362            .map_err(|e| e.message)?
363        {
364            let state = self
365                .engine
366                .execution(&s.execution_id)
367                .map_err(|e| e.message)?;
368            if state.status.is_terminal() {
369                continue;
370            }
371            let def = self.definition(&state);
372            if let (Some(def), None) = (&def, &state.abort) {
373                if let Some(t) = def.timeout_ms {
374                    consider(state.created_at_ms + t);
375                }
376            }
377            for (key, u) in &state.units {
378                match u.status() {
379                    UnitStatus::Waiting | UnitStatus::RetryWait => {
380                        if let Some(t) = u.wake_at_ms {
381                            consider(t);
382                        }
383                    }
384                    UnitStatus::Dispatched => {
385                        if let Some(t) = u.deadline_ms {
386                            consider(t);
387                        }
388                    }
389                    UnitStatus::Scheduled => {
390                        let qt = def
391                            .as_ref()
392                            .and_then(|d| crate::decide::invocation(d, key))
393                            .and_then(|i| i.queue_timeout_ms);
394                        if let (Some(qt), Some(at), false) = (qt, u.scheduled_at_ms, u.redelivery) {
395                            consider(at + qt);
396                        }
397                    }
398                    _ => {}
399                }
400            }
401        }
402        Ok(best)
403    }
404
405    fn stuck_reason(&self, state: &ExecutionState, next: Option<&(String, Value)>) -> String {
406        let def = self.definition(state);
407        let mut waits = vec![];
408        for (key, u) in &state.units {
409            match u.status() {
410                UnitStatus::Waiting => {
411                    if let Some(sig) = def.as_ref().and_then(|d| awaited(d, key)) {
412                        let detail = match next {
413                            Some((n, _)) if n != sig => format!(
414                                "signal `{sig}`, but the next signal to send is `{n}` (signals are sent in order)"
415                            ),
416                            Some(_) => format!("signal `{sig}`"),
417                            None => format!("signal `{sig}`, and no signal is left to send"),
418                        };
419                        waits.push(format!("step `{key}` waits for {detail}"));
420                    }
421                }
422                UnitStatus::Dispatched => {
423                    let task = def
424                        .as_ref()
425                        .and_then(|d| crate::decide::invocation(d, key))
426                        .map(|i| i.task.clone())
427                        .unwrap_or_default();
428                    if !task.starts_with(WORKFLOW_CONTRACT_PREFIX) {
429                        waits.push(format!(
430                            "step `{key}` waits for task `{task}`, which never answers and has no timeout"
431                        ));
432                    }
433                }
434                _ => {}
435            }
436        }
437        if waits.is_empty() {
438            format!(
439                "the execution stopped making progress while {}",
440                state.status.as_str()
441            )
442        } else {
443            format!("the execution cannot make progress: {}", waits.join("; "))
444        }
445    }
446}
447
448/// The signal an `await` unit waits for.
449fn awaited<'a>(def: &'a WorkflowIr, key: &str) -> Option<&'a str> {
450    let k = parse_key(key);
451    if k.compensation {
452        return None;
453    }
454    match &def.step(k.step)?.action {
455        Action::Await { signal, .. } => Some(signal),
456        _ => None,
457    }
458}
459
460/// Run `workflow` once with `handler` acting as every worker.
461pub fn simulate(
462    workflows: &[WorkflowIr],
463    workflow: &str,
464    version: Option<&str>,
465    input: Value,
466    signals: &[(String, Value)],
467    handler: &mut dyn TaskHandler,
468) -> Result<Simulation, String> {
469    Simulator::new(workflows)?.run(workflow, version, input, signals, handler)
470}
471
472// ------------------------------------------------------------------- samples
473
474/// A deterministic, type-valid sample value (used by `orbit simulate` for
475/// tasks without a mock). `name` is the field the value is for.
476///
477/// | Type        | Sample                                   |
478/// |-------------|------------------------------------------|
479/// | `String`    | `"<name>-sample"`                        |
480/// | `Int`       | `1`                                      |
481/// | `Float`     | `1.0`                                    |
482/// | `Bool`      | `true`                                   |
483/// | `Bytes`     | `"c2FtcGxl"` (base64 of `sample`)        |
484/// | `Duration`  | `1000` (one second)                      |
485/// | `Timestamp` | `"2026-01-01T00:00:00Z"`                 |
486/// | `UUID`      | `"00000000-0000-4000-8000-000000000001"` |
487/// | `JSON`      | `{}`                                     |
488/// | `List<T>`   | one sample element                       |
489/// | `Map<String, T>` | `{ "key": <sample T> }`             |
490/// | `T?`        | `null`                                   |
491/// | record      | every field: its default, else a sample  |
492pub fn sample_value(ty: &Type, name: &str) -> Value {
493    match ty {
494        Type::String => json!(format!("{name}-sample")),
495        Type::Int => json!(1),
496        Type::Float => json!(1.0),
497        Type::Bool => json!(true),
498        Type::Bytes => json!("c2FtcGxl"),
499        Type::Duration => json!(1000),
500        Type::Timestamp => json!("2026-01-01T00:00:00Z"),
501        Type::Uuid => json!("00000000-0000-4000-8000-000000000001"),
502        Type::Json => json!({}),
503        Type::Null | Type::Optional { .. } => Value::Null,
504        Type::List { item } => json!([sample_value(item, name)]),
505        Type::Map { value, .. } => json!({ "key": sample_value(value, name) }),
506        Type::Record { fields, .. } => Value::Object(
507            fields
508                .iter()
509                .map(|f| {
510                    let v = f
511                        .default
512                        .clone()
513                        .unwrap_or_else(|| sample_value(&f.ty, &f.name));
514                    (f.name.clone(), v)
515                })
516                .collect(),
517        ),
518    }
519}
520
521/// The worker used by `orbit simulate`: fixed results and failures by task
522/// name, and a generated sample for every other task.
523#[derive(Debug, Clone, Default)]
524pub struct SampleHandler {
525    /// Task name -> output to return.
526    pub results: BTreeMap<String, Value>,
527    /// Task name -> error code to fail with (permanently).
528    pub failures: BTreeMap<String, String>,
529}
530
531impl TaskHandler for SampleHandler {
532    fn handle(&mut self, call: &TaskCall) -> TaskOutcome {
533        let task = &call.assignment.task_name;
534        if let Some(code) = self.failures.get(task) {
535            return TaskOutcome::Fail(TaskError::new(
536                code,
537                format!("simulated failure of `{task}` (--fail)"),
538                false,
539            ));
540        }
541        if let Some(v) = self.results.get(task) {
542            return TaskOutcome::Complete(v.clone());
543        }
544        TaskOutcome::Complete(
545            call.contract
546                .map(|c| sample_value(&c.output, "value"))
547                .unwrap_or_else(|| json!({})),
548        )
549    }
550}
551
552// --------------------------------------------------------------------- tests
553
554/// The worker used by `orbit test`: answers from the test's mocks.
555pub struct MockHandler<'a> {
556    pub test: &'a TestCase,
557}
558
559struct TaskScope<'a> {
560    input: &'a Value,
561    assignment: &'a TaskAssignment,
562}
563
564impl Scope for TaskScope<'_> {
565    fn input(&self) -> &Value {
566        self.input
567    }
568    fn step_output(&self, _: &str) -> Option<&Value> {
569        None
570    }
571    fn execution_field(&self, field: &str) -> Option<Value> {
572        Some(Value::String(match field {
573            "id" => self.assignment.execution_id.clone(),
574            "workflow" => self.assignment.workflow.clone(),
575            "version" => self.assignment.workflow_version.clone(),
576            _ => return None,
577        }))
578    }
579}
580
581impl TaskHandler for MockHandler<'_> {
582    fn handle(&mut self, call: &TaskCall) -> TaskOutcome {
583        let a = call.assignment;
584        let Some(behaviours) = self.test.mocks.get(&a.task_name) else {
585            return TaskOutcome::Abort(format!(
586                "task `{}` (step `{}`) is not mocked; add `mock \"{}\" returns {{ ... }}` to the test",
587                a.task_name, a.step_id, a.task_name
588            ));
589        };
590        let mut covered = 0u32;
591        let mut chosen = behaviours.last();
592        for b in behaviours {
593            match b.times {
594                Some(n) => {
595                    covered = covered.saturating_add(n);
596                    if call.call <= covered {
597                        chosen = Some(b);
598                        break;
599                    }
600                }
601                None => {
602                    chosen = Some(b);
603                    break;
604                }
605            }
606        }
607        match chosen.map(|b| &b.outcome) {
608            Some(MockOutcome::Returns { output }) => {
609                let scope = TaskScope {
610                    input: &a.input,
611                    assignment: a,
612                };
613                match eval(output, &scope) {
614                    Ok(v) => TaskOutcome::Complete(v),
615                    Err(e) => TaskOutcome::Abort(format!(
616                        "the mock of `{}` (step `{}`) failed to evaluate: {e}",
617                        a.task_name, a.step_id
618                    )),
619                }
620            }
621            Some(MockOutcome::Fails {
622                code,
623                message,
624                retryable,
625            }) => TaskOutcome::Fail(TaskError::new(code, message.clone(), *retryable)),
626            Some(MockOutcome::Hangs) => TaskOutcome::Hang,
627            None => TaskOutcome::Abort(format!("the mock of `{}` has no behaviour", a.task_name)),
628        }
629    }
630}
631
632/// The outcome of one test.
633#[derive(Debug, Clone, Serialize)]
634pub struct TestResult {
635    pub name: String,
636    pub workflow: String,
637    pub version: String,
638    pub location: SourceLocation,
639    pub passed: bool,
640    /// One message per failed expectation (or why the test could not run).
641    pub failures: Vec<String>,
642    #[serde(skip)]
643    pub simulation: Option<Simulation>,
644}
645
646/// Run one compiled test against `workflows` (which must include the tested
647/// workflow and its children).
648pub fn run_test(workflows: &[WorkflowIr], test: &TestCase) -> TestResult {
649    let mut result = TestResult {
650        name: test.name.clone(),
651        workflow: test.workflow.clone(),
652        version: test.version.clone(),
653        location: test.location.clone(),
654        passed: false,
655        failures: vec![],
656        simulation: None,
657    };
658    let signals: Vec<(String, Value)> = test
659        .signals
660        .iter()
661        .map(|s| (s.name.clone(), s.payload.clone()))
662        .collect();
663    let mut handler = MockHandler { test };
664    let sim = match simulate(
665        workflows,
666        &test.workflow,
667        Some(&test.version),
668        test.input.clone(),
669        &signals,
670        &mut handler,
671    ) {
672        Ok(s) => s,
673        Err(e) => {
674            result
675                .failures
676                .push(format!("could not start the execution: {e}"));
677            return result;
678        }
679    };
680    result.failures.extend(sim.problems.iter().cloned());
681    if sim.finished() {
682        for e in &test.expectations {
683            if let Some(f) = check(e, &sim.root.state) {
684                result.failures.push(f);
685            }
686        }
687        for s in &sim.unsent_signals {
688            result.failures.push(format!(
689                "signal `{s}` was never sent: the execution finished without waiting for it"
690            ));
691        }
692    }
693    result.passed = result.failures.is_empty();
694    result.simulation = Some(sim);
695    result
696}
697
698fn compact(v: &Value) -> String {
699    serde_json::to_string(v).unwrap_or_default()
700}
701
702fn status_name(s: UnitStatus) -> String {
703    serde_json::to_value(s)
704        .ok()
705        .and_then(|v| v.as_str().map(str::to_string))
706        .unwrap_or_default()
707}
708
709fn describe_end(state: &ExecutionState) -> String {
710    match &state.error {
711        Some(e) => format!("{} ({}: {})", state.status.as_str(), e.code, e.message),
712        None => state.status.as_str().to_string(),
713    }
714}
715
716/// `None` if the expectation holds, else what went wrong.
717fn check(e: &Expectation, state: &ExecutionState) -> Option<String> {
718    let unit = |step: &str| state.units.get(step).cloned().unwrap_or_default();
719    match e {
720        Expectation::Status { status } => (state.status.as_str() != status)
721            .then(|| format!("expected status {status}, got {}", describe_end(state))),
722        Expectation::Output { expected } => {
723            let actual = state.output.clone().unwrap_or(Value::Null);
724            (!value_matches(expected, &actual)).then(|| {
725                if state.output.is_none() {
726                    format!(
727                        "expected output {}, but the workflow produced none (it ended {})",
728                        compact(expected),
729                        describe_end(state)
730                    )
731                } else {
732                    format!(
733                        "expected output {}, got {}",
734                        compact(expected),
735                        compact(&actual)
736                    )
737                }
738            })
739        }
740        Expectation::Error { code } => match &state.error {
741            Some(err) if err.code == *code => None,
742            Some(err) => Some(format!(
743                "expected error {code:?}, got {:?} ({})",
744                err.code, err.message
745            )),
746            None => Some(format!(
747                "expected error {code:?}, but the workflow ended {} without an error",
748                state.status.as_str()
749            )),
750        },
751        Expectation::StepStatus { step, status } => {
752            let actual = status_name(unit(step).status());
753            (actual != *status).then(|| {
754                let u = unit(step);
755                let why = u
756                    .error
757                    .as_ref()
758                    .map(|e| format!(" ({}: {})", e.code, e.message))
759                    .unwrap_or_default();
760                format!("expected step `{step}` to be {status}, got {actual}{why}")
761            })
762        }
763        Expectation::StepOutput { step, expected } => {
764            let u = unit(step);
765            let actual = u.output.clone().unwrap_or(Value::Null);
766            (!value_matches(expected, &actual)).then(|| {
767                format!(
768                    "expected step `{step}` output {}, got {} (step is {})",
769                    compact(expected),
770                    compact(&actual),
771                    status_name(u.status())
772                )
773            })
774        }
775        Expectation::StepAttempts { step, attempts } => {
776            let actual = unit(step).attempt;
777            (actual != *attempts).then(|| {
778                format!("expected step `{step}` to take {attempts} attempt(s), took {actual}")
779            })
780        }
781        Expectation::Compensated { step } => {
782            let element = format!("{}[", compensation_key(step));
783            let units: Vec<(&String, &UnitState)> = state
784                .units
785                .iter()
786                .filter(|(k, _)| **k == compensation_key(step) || k.starts_with(&element))
787                .collect();
788            if units.is_empty() {
789                return Some(format!(
790                    "expected step `{step}` to be compensated, but its compensation never ran"
791                ));
792            }
793            units
794                .iter()
795                .find(|(_, u)| u.status() != UnitStatus::Succeeded)
796                .map(|(k, u)| {
797                    let why = u
798                        .error
799                        .as_ref()
800                        .map(|e| format!(" ({}: {})", e.code, e.message))
801                        .unwrap_or_default();
802                    format!(
803                        "expected step `{step}` to be compensated, but `{k}` is {}{why}",
804                        status_name(u.status())
805                    )
806                })
807        }
808    }
809}