Skip to main content

traverse_embedder/
test_double.rs

1//! Deterministic in-memory test double for the embedder boundary
2//! (spec 068 FR-006).
3
4use crate::{
5    CompatibleLifecycleOutcome, CompatibleStartOutcome, EMBEDDED_TRACE_API_VERSION,
6    EmbeddedTraceApi, EmbeddedTraceApiError, EmbeddedTraceDetail, EmbeddedTraceOutcome,
7    EmbeddedTracePage, EmbeddedTracePhase, EmbeddedTraceRecordInput, EmbeddedTraceSelectedTarget,
8    EmbedderCore, EmbedderError, EmbedderErrorCode, EventCallback, InstanceState, ShutdownOutcome,
9    SubmitOutcome, SubmitStatus, TraverseEmbedderApi,
10};
11use serde_json::{Value, json};
12use std::collections::BTreeMap;
13
14/// A scripted submit result for one target id.
15#[derive(Debug, Clone)]
16enum ScriptedResult {
17    Output(Value),
18    Error { code: String, message: String },
19}
20
21/// Deterministic test double implementing [`TraverseEmbedderApi`].
22///
23/// The double shares the production embedder's event envelope, identifier
24/// scheme, compatible-capability lifecycle, and shutdown semantics through
25/// the same internal core; only capability execution is replaced with
26/// scripted results. It contains no business logic and never replaces
27/// runtime-owned behavior in production code paths.
28pub struct EmbedderTestDouble {
29    core: EmbedderCore,
30    scripted: BTreeMap<String, ScriptedResult>,
31}
32
33impl EmbedderTestDouble {
34    /// Creates a double with the given identity, no scripted targets, and
35    /// no compatible capabilities.
36    #[must_use]
37    pub fn new(
38        workspace_id: impl Into<String>,
39        app_id: impl Into<String>,
40        app_version: impl Into<String>,
41        platform: impl Into<String>,
42    ) -> Self {
43        Self {
44            core: EmbedderCore::new(
45                workspace_id.into(),
46                app_id.into(),
47                app_version.into(),
48                platform.into(),
49                BTreeMap::new(),
50            ),
51            scripted: BTreeMap::new(),
52        }
53    }
54
55    /// Scripts `submit(target_id, _)` to succeed with `output`.
56    #[must_use]
57    pub fn with_target_output(mut self, target_id: impl Into<String>, output: Value) -> Self {
58        self.scripted
59            .insert(target_id.into(), ScriptedResult::Output(output));
60        self
61    }
62
63    /// Scripts `submit(target_id, _)` to fail with a runtime-shaped error.
64    #[must_use]
65    pub fn with_target_error(
66        mut self,
67        target_id: impl Into<String>,
68        code: impl Into<String>,
69        message: impl Into<String>,
70    ) -> Self {
71        self.scripted.insert(
72            target_id.into(),
73            ScriptedResult::Error {
74                code: code.into(),
75                message: message.into(),
76            },
77        );
78        self
79    }
80
81    /// Declares a compatible-mode capability with a platform allowlist.
82    #[must_use]
83    pub fn with_compatible_target(
84        mut self,
85        capability_id: impl Into<String>,
86        platforms: Vec<String>,
87    ) -> Self {
88        self.core
89            .compatible_targets
90            .insert(capability_id.into(), platforms);
91        self
92    }
93}
94
95impl TraverseEmbedderApi for EmbedderTestDouble {
96    fn submit(&mut self, target_id: &str, input: &Value) -> SubmitOutcome {
97        let _ = input;
98        if self.core.stopped {
99            let error = crate::runtime_stopped_error();
100            return self.core.rejected_submit(target_id, error);
101        }
102        let Some(result) = self.scripted.get(target_id).cloned() else {
103            let error = EmbedderError::new(
104                EmbedderErrorCode::TargetNotFound,
105                format!("'{target_id}' is neither a bundled workflow nor a bundled capability"),
106            );
107            return self.core.rejected_submit(target_id, error);
108        };
109
110        let session_id = self.core.next_session_id();
111        let request_id = self.core.next_request_id();
112        let execution_id = format!("exec_{request_id}");
113        let trace_input = match &result {
114            ScriptedResult::Output(_) => EmbeddedTraceRecordInput {
115                execution_id: execution_id.clone(),
116                target_id: target_id.to_string(),
117                outcome: EmbeddedTraceOutcome::Completed,
118                phases: vec![EmbeddedTracePhase {
119                    code: "completed".to_string(),
120                }],
121                selected_target: Some(EmbeddedTraceSelectedTarget {
122                    target_id: target_id.to_string(),
123                    target_version: Some("1.0.0".to_string()),
124                }),
125                placement: None,
126                failure_code: None,
127                state_machine_valid: Some(true),
128            },
129            ScriptedResult::Error { code, .. } => EmbeddedTraceRecordInput {
130                execution_id: execution_id.clone(),
131                target_id: target_id.to_string(),
132                outcome: EmbeddedTraceOutcome::Error,
133                phases: vec![EmbeddedTracePhase {
134                    code: "error".to_string(),
135                }],
136                selected_target: Some(EmbeddedTraceSelectedTarget {
137                    target_id: target_id.to_string(),
138                    target_version: Some("1.0.0".to_string()),
139                }),
140                placement: None,
141                failure_code: Some(code.clone()),
142                state_machine_valid: Some(true),
143            },
144        };
145        self.core.record_trace(trace_input);
146        self.core.emit(
147            "capability_invoked",
148            Some(&session_id),
149            json!({
150                "execution_id": execution_id,
151                "capability_id": target_id,
152                "capability_version": "1.0.0",
153            }),
154        );
155        match result {
156            ScriptedResult::Output(output) => {
157                self.core.emit(
158                    "capability_result",
159                    Some(&session_id),
160                    json!({
161                        "execution_id": execution_id,
162                        "capability_id": target_id,
163                        "status": "completed",
164                        "output": output,
165                    }),
166                );
167            }
168            ScriptedResult::Error { code, message } => {
169                self.core.emit(
170                    "error",
171                    Some(&session_id),
172                    json!({
173                        "execution_id": execution_id,
174                        "capability_id": target_id,
175                        "status": "error",
176                        "error": { "code": code, "message": message, "details": {} },
177                    }),
178                );
179            }
180        }
181        SubmitOutcome {
182            session_id: Some(session_id),
183            status: SubmitStatus::Accepted,
184            error: None,
185        }
186    }
187
188    fn subscribe(&mut self, callback: EventCallback) {
189        self.core.subscribe(callback);
190    }
191
192    fn start_compatible(&mut self, capability_id: &str, input: &Value) -> CompatibleStartOutcome {
193        self.core.start_compatible(capability_id, input)
194    }
195
196    fn stop_compatible(
197        &mut self,
198        capability_id: &str,
199        instance_id: Option<&str>,
200    ) -> CompatibleLifecycleOutcome {
201        self.core
202            .transition_compatible(capability_id, instance_id, InstanceState::Stopped)
203    }
204
205    fn kill_compatible(
206        &mut self,
207        capability_id: &str,
208        instance_id: Option<&str>,
209    ) -> CompatibleLifecycleOutcome {
210        self.core
211            .transition_compatible(capability_id, instance_id, InstanceState::Killed)
212    }
213
214    fn shutdown(&mut self) -> ShutdownOutcome {
215        self.core.shutdown()
216    }
217
218    fn release_evidence(&self) -> Value {
219        self.core.evidence("test-double", json!([]))
220    }
221}
222
223impl EmbeddedTraceApi for EmbedderTestDouble {
224    fn embedded_trace_api_version(&self) -> &'static str {
225        EMBEDDED_TRACE_API_VERSION
226    }
227
228    fn trace_list(
229        &self,
230        requested_version: &str,
231        page_size: usize,
232        cursor: Option<&str>,
233    ) -> Result<EmbeddedTracePage, EmbeddedTraceApiError> {
234        self.core.trace_list(requested_version, page_size, cursor)
235    }
236
237    fn trace_get(
238        &self,
239        requested_version: &str,
240        trace_id: &str,
241    ) -> Result<EmbeddedTraceDetail, EmbeddedTraceApiError> {
242        self.core.trace_get(requested_version, trace_id)
243    }
244}