Skip to main content

lenso_test/
lib.rs

1//! A small App harness with deterministic Driver scheduling and virtual time.
2//!
3//! `TestApp` keeps the immutable Plan and real Host-lowered Adapter catalog.
4//! Child processes, JavaScript timers and external I/O remain real.
5
6use std::{future::Future, time::Duration};
7
8use lenso_app_plan::ResolvedAppPlan;
9use lenso_kernel::{
10    ExecutionAdapterCatalog, Kernel, NativeApp, PluginDependencies, RuntimeFailure, ShutdownOutcome,
11};
12use lenso_native_adapter::{NativePluginFactory, NativePluginRegistry};
13use lenso_plugin_authoring::CapabilityClient;
14
15mod clock;
16mod durable;
17mod entropy;
18mod faults;
19mod receipt;
20mod receipt_diagnostics;
21mod simulator;
22
23pub use clock::TestWallClock;
24pub use durable::{CommitKnowledge, DurableFailure, DurableFailureCause, DurableFaultFacade};
25pub use entropy::TestEntropy;
26pub use faults::{FaultInjector, FaultPointError, ScenarioBoundary, SimulatorFault};
27pub use receipt::{ScenarioReceipt, ScenarioReceiptEvent, ScenarioTerminal, ScenarioTransition};
28pub use receipt_diagnostics::{ReceiptDifference, first_receipt_difference};
29pub use simulator::{SimulatorGate, SimulatorResource, TestSimulator};
30
31/// Builder for one Test App using a deterministic Driver.
32#[derive(Debug)]
33pub struct TestAppBuilder {
34    plan: ResolvedAppPlan,
35    assembly: TestAssembly,
36    simulator: TestSimulator,
37}
38
39#[derive(Debug)]
40enum TestAssembly {
41    Registry(NativePluginRegistry, bool),
42    Catalog(ExecutionAdapterCatalog),
43    Conflicting,
44}
45
46impl TestAppBuilder {
47    /// Starts with an exact immutable Plan.
48    pub fn new(plan: ResolvedAppPlan) -> Self {
49        Self {
50            plan,
51            assembly: TestAssembly::Registry(NativePluginRegistry::new(), false),
52            simulator: TestSimulator::new(),
53        }
54    }
55
56    /// Adds a native factory available to this test Host.
57    #[must_use]
58    pub fn with_factory(self, factory: impl NativePluginFactory) -> Self {
59        self.map_registry(|registry| registry.with_factory(factory))
60    }
61
62    /// Uses an exact Host-owned native factory registry for this test App.
63    ///
64    /// This is primarily for Adapter crates whose real Host composition builds
65    /// a registry containing both Plugin and ingress factories. The immutable
66    /// Plan remains the only source of Plugin dependency selection.
67    #[must_use]
68    pub fn with_registry(self, registry: NativePluginRegistry) -> Self {
69        self.map_registry(|_| registry)
70    }
71
72    /// Adds factories linked into the test binary.
73    #[must_use]
74    pub fn with_linked_factories(self) -> Self {
75        self.map_registry(NativePluginRegistry::with_linked_factories)
76    }
77
78    fn map_registry(
79        mut self,
80        update: impl FnOnce(NativePluginRegistry) -> NativePluginRegistry,
81    ) -> Self {
82        self.assembly = match self.assembly {
83            TestAssembly::Registry(registry, _) => TestAssembly::Registry(update(registry), true),
84            _ => TestAssembly::Conflicting,
85        };
86        self
87    }
88
89    /// Uses the exact Host-lowered catalog, including non-Native Adapters.
90    ///
91    /// Registry/factory inputs and repeated catalogs are rejected by `start`
92    /// before any factory executes. Virtual Driver time does not virtualize
93    /// Bun processes, JavaScript timers or external I/O.
94    #[must_use]
95    pub fn with_catalog(mut self, catalog: ExecutionAdapterCatalog) -> Self {
96        self.assembly = match self.assembly {
97            TestAssembly::Registry(_, false) => TestAssembly::Catalog(catalog),
98            _ => TestAssembly::Conflicting,
99        };
100        self
101    }
102
103    /// Uses one test-private Simulator for this exact App.
104    #[must_use]
105    pub fn with_simulator(mut self, simulator: TestSimulator) -> Self {
106        self.simulator = simulator;
107        self
108    }
109
110    /// Boots the exact Plan through Kernel and the selected Adapter catalog.
111    pub fn start(self) -> Result<TestApp, RuntimeFailure> {
112        let driver = self.simulator.driver();
113        let catalog =
114            match self.assembly {
115                TestAssembly::Registry(registry, _) => ExecutionAdapterCatalog::single(registry),
116                TestAssembly::Catalog(catalog) => catalog,
117                TestAssembly::Conflicting => return Err(RuntimeFailure::InvalidResolvedPlan {
118                    detail:
119                        "test App catalog and registry/factory inputs cannot be mixed or repeated"
120                            .into(),
121                }),
122            };
123        let app = driver.run(Kernel::start(self.plan, driver.clone(), catalog))?;
124        Ok(TestApp {
125            simulator: self.simulator,
126            app,
127        })
128    }
129}
130
131/// A running App that exposes the real Kernel and virtual Driver to tests.
132#[derive(Debug)]
133pub struct TestApp {
134    simulator: TestSimulator,
135    app: NativeApp,
136}
137
138impl TestApp {
139    /// Creates a builder for an exact immutable Plan.
140    pub fn builder(plan: ResolvedAppPlan) -> TestAppBuilder {
141        TestAppBuilder::new(plan)
142    }
143
144    /// Returns the running App for typed generated Client handles and diagnostics.
145    pub fn app(&self) -> &NativeApp {
146        &self.app
147    }
148
149    /// Returns this App's test-private simulated execution environment.
150    pub fn simulator(&self) -> &TestSimulator {
151        &self.simulator
152    }
153
154    /// Connects one generated Client from a consumer Instance's Plan-owned bindings.
155    pub fn client<C>(&self, consumer_instance: &str) -> Result<C, RuntimeFailure>
156    where
157        C: CapabilityClient<Dependencies = PluginDependencies, Error = RuntimeFailure>,
158    {
159        let dependencies = self.app.dependencies(consumer_instance)?;
160        C::from_dependencies(&dependencies)
161    }
162
163    /// Runs one future to completion on deterministic virtual time.
164    pub fn run<F: Future>(&self, future: F) -> F::Output {
165        self.simulator.run(future)
166    }
167
168    /// Advances deterministic virtual time and wakes elapsed timers.
169    pub fn advance(&self, duration: Duration) {
170        self.simulator.advance(duration);
171    }
172
173    /// Shuts down the App and returns the exact cleanup outcome.
174    pub fn shutdown(&self, timeout: Duration) -> ShutdownOutcome {
175        self.simulator.run(self.app.shutdown(timeout))
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use std::{cell::Cell, rc::Rc};
182
183    use futures::{FutureExt, future::Either};
184    use lenso_app_plan::PluginInstancePlan;
185    use lenso_kernel::{ActivateContext, PluginFuture, PluginLifecycle};
186    use lenso_native_adapter::{NativePluginFactoryContext, NativePluginInstance};
187
188    use super::*;
189
190    #[derive(Debug)]
191    struct GateLifecycle {
192        gate: SimulatorGate,
193        completed: Rc<Cell<bool>>,
194        cancelled: Rc<Cell<bool>>,
195    }
196
197    impl PluginLifecycle for GateLifecycle {
198        fn activate(&self, context: ActivateContext) -> PluginFuture {
199            let gate = self.gate.clone();
200            let completed = self.completed.clone();
201            let cancelled = self.cancelled.clone();
202            let cancellation = context.cancellation();
203            let spawned = context.tasks().spawn_local(Box::pin(async move {
204                let gate_wait = gate.wait().fuse();
205                let cancellation_wait = cancellation.cancelled().fuse();
206                futures::pin_mut!(gate_wait, cancellation_wait);
207                match futures::future::select(gate_wait, cancellation_wait).await {
208                    Either::Left(((), _)) => completed.set(true),
209                    Either::Right(((), _)) => cancelled.set(true),
210                }
211            }));
212            Box::pin(async move {
213                spawned
214                    .map(|_| ())
215                    .map_err(|error| RuntimeFailure::PluginFailure {
216                        detail: format!("could not spawn simulator fixture task: {error:?}"),
217                    })
218            })
219        }
220    }
221
222    #[derive(Debug)]
223    struct GateFactory {
224        gate: SimulatorGate,
225        completed: Rc<Cell<bool>>,
226        cancelled: Rc<Cell<bool>>,
227    }
228
229    impl NativePluginFactory for GateFactory {
230        fn package_id(&self) -> &'static str {
231            "test.simulator-gate"
232        }
233
234        fn instantiate(
235            &self,
236            _context: NativePluginFactoryContext<'_>,
237        ) -> Result<NativePluginInstance, RuntimeFailure> {
238            Ok(NativePluginInstance::with_lifecycle(
239                Vec::new(),
240                GateLifecycle {
241                    gate: self.gate.clone(),
242                    completed: self.completed.clone(),
243                    cancelled: self.cancelled.clone(),
244                },
245            ))
246        }
247    }
248
249    fn gate_plan() -> ResolvedAppPlan {
250        ResolvedAppPlan::new(
251            vec![PluginInstancePlan::new("gate", "test.simulator-gate")],
252            vec![],
253        )
254    }
255
256    #[test]
257    fn empty_test_app_starts_ready_and_shuts_down_cleanly() {
258        let app = TestApp::builder(ResolvedAppPlan::empty()).start().unwrap();
259
260        assert!(app.app().is_ready());
261        assert_eq!(app.shutdown(Duration::from_secs(1)), ShutdownOutcome::Clean);
262    }
263
264    #[test]
265    fn plan_selected_plugin_without_a_factory_fails_closed() {
266        let plan = ResolvedAppPlan::new(
267            vec![PluginInstancePlan::new("missing", "test.missing")],
268            vec![],
269        );
270
271        let error = TestApp::builder(plan).start().unwrap_err();
272        assert!(
273            matches!(error, RuntimeFailure::MissingPluginFactory { instance, .. } if instance == "missing")
274        );
275    }
276
277    #[test]
278    fn builder_keeps_the_supplied_simulator_with_the_real_native_app() {
279        let simulator = TestSimulator::new();
280        let app = TestApp::builder(ResolvedAppPlan::empty())
281            .with_simulator(simulator.clone())
282            .start()
283            .unwrap();
284
285        simulator.advance(Duration::from_millis(5));
286        assert_eq!(app.simulator().now(), Duration::from_millis(5));
287        assert_eq!(app.shutdown(Duration::from_secs(1)), ShutdownOutcome::Clean);
288    }
289
290    #[test]
291    fn gate_controls_managed_native_plugin_work_after_app_start() {
292        let simulator = TestSimulator::new();
293        let gate = simulator.gate("fixture.after-activate");
294        let completed = Rc::new(Cell::new(false));
295        let cancelled = Rc::new(Cell::new(false));
296        let app = TestApp::builder(gate_plan())
297            .with_simulator(simulator.clone())
298            .with_factory(GateFactory {
299                gate: gate.clone(),
300                completed: completed.clone(),
301                cancelled: cancelled.clone(),
302            })
303            .start()
304            .unwrap();
305
306        simulator.pump();
307        assert_eq!(gate.reached_count(), 1);
308        assert!(!completed.get());
309
310        assert!(gate.release());
311        simulator.pump();
312        assert!(completed.get());
313        assert!(!cancelled.get());
314        assert_eq!(app.shutdown(Duration::from_secs(1)), ShutdownOutcome::Clean);
315    }
316
317    #[test]
318    fn shutdown_cancels_unreleased_managed_native_plugin_work() {
319        let simulator = TestSimulator::new();
320        let gate = simulator.gate("fixture.awaiting-shutdown");
321        let completed = Rc::new(Cell::new(false));
322        let cancelled = Rc::new(Cell::new(false));
323        let app = TestApp::builder(gate_plan())
324            .with_simulator(simulator.clone())
325            .with_factory(GateFactory {
326                gate: gate.clone(),
327                completed: completed.clone(),
328                cancelled: cancelled.clone(),
329            })
330            .start()
331            .unwrap();
332
333        simulator.pump();
334        assert_eq!(gate.reached_count(), 1);
335        assert!(!completed.get());
336        assert_eq!(app.shutdown(Duration::from_secs(1)), ShutdownOutcome::Clean);
337        assert!(!completed.get());
338        assert!(cancelled.get());
339    }
340}