Skip to main content

lenso_test/
lib.rs

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