1use 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#[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 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 #[must_use]
58 pub fn with_factory(self, factory: impl NativePluginFactory) -> Self {
59 self.map_registry(|registry| registry.with_factory(factory))
60 }
61
62 #[must_use]
68 pub fn with_registry(self, registry: NativePluginRegistry) -> Self {
69 self.map_registry(|_| registry)
70 }
71
72 #[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 #[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 #[must_use]
105 pub fn with_simulator(mut self, simulator: TestSimulator) -> Self {
106 self.simulator = simulator;
107 self
108 }
109
110 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#[derive(Debug)]
133pub struct TestApp {
134 simulator: TestSimulator,
135 app: NativeApp,
136}
137
138impl TestApp {
139 pub fn builder(plan: ResolvedAppPlan) -> TestAppBuilder {
141 TestAppBuilder::new(plan)
142 }
143
144 pub fn app(&self) -> &NativeApp {
146 &self.app
147 }
148
149 pub fn simulator(&self) -> &TestSimulator {
151 &self.simulator
152 }
153
154 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 pub fn run<F: Future>(&self, future: F) -> F::Output {
165 self.simulator.run(future)
166 }
167
168 pub fn advance(&self, duration: Duration) {
170 self.simulator.advance(duration);
171 }
172
173 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}