1use crate::{
34 FerroHeadlessPlatform, FerroHeadlessPlatformExtensions, FerroHeadlessPlatformOptions, FerroTestApplicationAttribute,
35 FerroTestIsolationAttribute, FerroTestIsolationLevel,
36};
37use ferroui_base::media::{FontManager, MediaContext};
38use ferroui_base::reactive::IDisposable;
39use ferroui_base::threading::{
40 CancellationToken, CancellationTokenSource, Dispatcher, DispatcherFrame, FerroSynchronizationContext,
41 OperationCanceledError,
42};
43use ferroui_base::{FerroLocator, LocatorExtensions};
44use ferroui_controls::{AppBuilder, Application, Control, IToolTipService};
45use ferroui_harfbuzz::HarfBuzzApplicationExtensions;
46use std::any::Any;
47use std::cell::RefCell;
48use std::collections::{HashMap, VecDeque};
49use std::future::Future;
50use std::panic::{catch_unwind, resume_unwind, AssertUnwindSafe};
51use std::sync::mpsc;
52use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock, PoisonError};
53use std::thread::{self, JoinHandle};
54use std::time::Duration;
55
56type Exception = Box<dyn Any + Send + 'static>;
58
59#[derive(Debug)]
64pub struct FerroTestAssembly {
65 pub name: &'static str,
68 pub test_application: Option<FerroTestApplicationAttribute>,
70 pub test_isolation: Option<FerroTestIsolationAttribute>,
73}
74
75enum TaskState<T> {
78 Running,
79 RanToCompletion(Option<T>),
80 Faulted(Option<Exception>),
81 Canceled,
82}
83
84struct TaskShared<T> {
85 state: Mutex<TaskState<T>>,
86 finished: Condvar,
87}
88
89impl<T> TaskShared<T> {
90 fn lock(&self) -> MutexGuard<'_, TaskState<T>> {
91 self.state.lock().unwrap_or_else(PoisonError::into_inner)
92 }
93
94 fn try_set(&self, state: TaskState<T>) -> bool {
95 let mut current = self.lock();
96 if !matches!(*current, TaskState::Running) {
97 return false;
98 }
99 *current = state;
100 self.finished.notify_all();
101 true
102 }
103}
104
105struct TaskCompletionSource<T>(Arc<TaskShared<T>>);
108
109impl<T> TaskCompletionSource<T> {
110 fn new() -> (Self, HeadlessUnitTestTask<T>) {
111 let shared = Arc::new(TaskShared { state: Mutex::new(TaskState::Running), finished: Condvar::new() });
112 (Self(shared.clone()), HeadlessUnitTestTask { shared })
113 }
114
115 fn try_set_result(&self, result: T) -> bool {
116 self.0.try_set(TaskState::RanToCompletion(Some(result)))
117 }
118
119 fn try_set_exception(&self, exception: Exception) -> bool {
120 self.0.try_set(TaskState::Faulted(Some(exception)))
121 }
122
123 fn try_set_canceled(&self) -> bool {
124 self.0.try_set(TaskState::Canceled)
125 }
126}
127
128impl<T> Drop for TaskCompletionSource<T> {
129 fn drop(&mut self) {
130 self.try_set_canceled();
131 }
132}
133
134pub struct HeadlessUnitTestTask<T> {
137 shared: Arc<TaskShared<T>>,
138}
139
140impl<T> HeadlessUnitTestTask<T> {
141 pub fn is_completed(&self) -> bool {
143 !matches!(*self.shared.lock(), TaskState::Running)
144 }
145
146 pub fn wait(self) -> Result<T, OperationCanceledError> {
154 let mut state = self.shared.lock();
155 while matches!(*state, TaskState::Running) {
156 state = self.shared.finished.wait(state).unwrap_or_else(PoisonError::into_inner);
157 }
158 Self::take(&mut state)
159 }
160
161 pub fn wait_timeout(self, timeout: Duration) -> Result<T, OperationCanceledError> {
168 let state = self.shared.lock();
169 let (mut state, _) = self
170 .shared
171 .finished
172 .wait_timeout_while(state, timeout, |state| matches!(*state, TaskState::Running))
173 .unwrap_or_else(PoisonError::into_inner);
174 if matches!(*state, TaskState::Running) {
175 drop(state);
176 panic!("The operation has timed out.");
177 }
178 Self::take(&mut state)
179 }
180
181 fn take(state: &mut TaskState<T>) -> Result<T, OperationCanceledError> {
182 match state {
183 TaskState::RanToCompletion(result) => match result.take() {
184 Some(result) => Ok(result),
185 None => panic!("The result of the task has already been taken."),
186 },
187 TaskState::Faulted(exception) => match exception.take() {
188 Some(exception) => resume_unwind(exception),
189 None => panic!("The failure of the task has already been observed."),
190 },
191 TaskState::Canceled => Err(OperationCanceledError),
192 TaskState::Running => unreachable!("the task has finished"),
193 }
194 }
195}
196
197struct SessionThread {
202 app_builder: AppBuilder,
203 shared_context: RefCell<Option<Arc<FerroSynchronizationContext>>>,
205}
206
207type WorkItem = Box<dyn FnOnce(&SessionThread) + Send + 'static>;
208
209#[derive(Default)]
210struct QueueState {
211 items: VecDeque<WorkItem>,
212 adding_completed: bool,
213}
214
215#[derive(Default)]
217struct BlockingQueue {
218 state: Mutex<QueueState>,
219 changed: Condvar,
220}
221
222impl BlockingQueue {
223 fn lock(&self) -> MutexGuard<'_, QueueState> {
224 self.state.lock().unwrap_or_else(PoisonError::into_inner)
225 }
226
227 fn add(&self, item: WorkItem) {
230 let mut state = self.lock();
231 if state.adding_completed {
232 drop(state);
233 panic!("The collection has been marked as complete with regards to additions.");
234 }
235 state.items.push_back(item);
236 self.changed.notify_all();
237 }
238
239 fn take(&self, cancellation_token_source: &CancellationTokenSource) -> Option<WorkItem> {
241 let mut state = self.lock();
242 loop {
243 if cancellation_token_source.is_cancellation_requested() {
244 return None;
245 }
246 if let Some(item) = state.items.pop_front() {
247 return Some(item);
248 }
249 state = self.changed.wait(state).unwrap_or_else(PoisonError::into_inner);
250 }
251 }
252
253 fn wake(&self) {
255 let _state = self.lock();
256 self.changed.notify_all();
257 }
258
259 fn complete_adding(&self) {
260 let dropped = {
261 let mut state = self.lock();
262 state.adding_completed = true;
263 std::mem::take(&mut state.items)
264 };
265 drop(dropped);
267 }
268}
269
270pub struct HeadlessUnitTestSession {
277 cancellation_token_source: CancellationTokenSource,
278 queue: Arc<BlockingQueue>,
279 dispatch_task: Mutex<Option<JoinHandle<()>>>,
280 isolated: bool,
281}
282
283fn sessions() -> &'static Mutex<HashMap<&'static str, Arc<HeadlessUnitTestSession>>> {
284 static SESSIONS: OnceLock<Mutex<HashMap<&'static str, Arc<HeadlessUnitTestSession>>>> = OnceLock::new();
285 SESSIONS.get_or_init(Default::default)
286}
287
288static HEADLESS_ASSEMBLY: FerroTestAssembly =
291 FerroTestAssembly { name: env!("CARGO_PKG_NAME"), test_application: None, test_isolation: None };
292
293struct CancelOnDrop(CancellationTokenSource);
296
297impl Drop for CancelOnDrop {
298 fn drop(&mut self) {
299 self.0.cancel();
300 }
301}
302
303struct ReleaseCompositorOnDrop;
306
307impl Drop for ReleaseCompositorOnDrop {
308 fn drop(&mut self) {
309 crate::ferro_headless_platform::FerroHeadlessPlatform::release_compositor();
310 }
311}
312
313impl HeadlessUnitTestSession {
314 pub fn dispatch(
317 &self,
318 action: impl FnOnce() + Send + 'static,
319 cancellation_token: CancellationToken,
320 ) -> HeadlessUnitTestTask<()> {
321 self.dispatch_core(
322 move || {
323 action();
324 std::future::ready(())
325 },
326 cancellation_token,
327 )
328 }
329
330 pub fn dispatch_result<TResult: Send + 'static>(
333 &self,
334 action: impl FnOnce() -> TResult + Send + 'static,
335 cancellation_token: CancellationToken,
336 ) -> HeadlessUnitTestTask<TResult> {
337 self.dispatch_core(move || std::future::ready(action()), cancellation_token)
338 }
339
340 pub fn dispatch_task<F, Fut>(&self, action: F, cancellation_token: CancellationToken) -> HeadlessUnitTestTask<Fut::Output>
352 where
353 F: FnOnce() -> Fut + Send + 'static,
354 Fut: Future + 'static,
355 Fut::Output: Send + 'static,
356 {
357 self.dispatch_core(action, cancellation_token)
358 }
359
360 fn dispatch_core<F, Fut>(&self, action: F, cancellation_token: CancellationToken) -> HeadlessUnitTestTask<Fut::Output>
361 where
362 F: FnOnce() -> Fut + Send + 'static,
363 Fut: Future + 'static,
364 Fut::Output: Send + 'static,
365 {
366 if self.cancellation_token_source.is_cancellation_requested() {
367 panic!("Cannot access a disposed object.\nObject name: 'Session was already disposed.'.");
368 }
369
370 let token = self.cancellation_token_source.token();
371 let isolated = self.isolated;
372
373 let (tcs, task) = TaskCompletionSource::<Fut::Output>::new();
374 self.queue.add(Box::new(move |session: &SessionThread| {
375 let cts = CancellationTokenSource::new();
376 let global_cts = {
377 let cts = cts.clone();
378 token.register(move || cts.cancel())
379 };
380 let local_cts = {
381 let cts = cts.clone();
382 cancellation_token.register(move || cts.cancel())
383 };
384
385 let application = match catch_unwind(AssertUnwindSafe(|| {
386 if isolated {
387 session.ensure_isolated_application()
388 } else {
389 session.ensure_shared_application()
390 }
391 })) {
392 Ok(application) => application,
393 Err(ex) => {
394 tcs.try_set_exception(ex);
395 local_cts.dispose();
396 global_cts.dispose();
397 return; }
399 };
400
401 let mut should_cancel = false;
402 let mut caught: Option<Exception> = None;
403 let mut result: Option<Fut::Output> = None;
404
405 match catch_unwind(AssertUnwindSafe(|| {
406 let future = action();
410 let guard = CancelOnDrop(cts.clone());
411 let task = Dispatcher::ui_thread().to_task_scheduler().start_local(async move {
412 let _guard = guard;
413 future.await
414 });
415 if task.is_completed() {
416 return match task.result() {
417 Ok(result) => Some(result),
418 Err(canceled) => std::panic::panic_any(canceled),
419 };
420 }
421
422 if cts.is_cancellation_requested() {
423 should_cancel = true;
424 None
425 } else {
426 let frame = DispatcherFrame::new();
427 let inner_cts = {
428 let frame = frame.clone();
429 cts.token().register(move || frame.set_continue(false))
430 };
431 Dispatcher::ui_thread().push_frame(&frame);
432 inner_cts.dispose();
433 match task.result() {
434 Ok(result) => Some(result),
435 Err(canceled) => std::panic::panic_any(canceled),
436 }
437 }
438 })) {
439 Ok(value) => result = value,
440 Err(ex) => caught = Some(ex),
441 }
442
443 if let Err(ex) = catch_unwind(AssertUnwindSafe(|| application.dispose())) {
444 caught = Some(ex);
447 }
448
449 local_cts.dispose();
450 global_cts.dispose();
451
452 if let Some(caught) = caught {
453 tcs.try_set_exception(caught);
454 } else if should_cancel {
455 tcs.try_set_canceled();
456 } else if let Some(result) = result {
457 tcs.try_set_result(result);
458 }
459 }));
460 task
461 }
462
463 pub fn dispose(&self) {
464 self.cancellation_token_source.cancel();
465 self.queue.wake();
466 let dispatch_task = self.dispatch_task.lock().unwrap_or_else(PoisonError::into_inner).take();
467 if let Some(dispatch_task) = dispatch_task {
468 if let Err(ex) = dispatch_task.join() {
470 resume_unwind(ex);
471 }
472 }
473 self.queue.complete_adding();
474 }
475
476 pub async fn dispose_async(&self) {
480 self.dispose();
481 }
482
483 pub fn start_new(entry_point_type: fn() -> AppBuilder) -> Arc<HeadlessUnitTestSession> {
490 Self::start_new_with_isolation(entry_point_type, FerroTestIsolationLevel::PerTest)
491 }
492
493 pub fn start_new_with_isolation(
502 entry_point_type: fn() -> AppBuilder,
503 isolation_level: FerroTestIsolationLevel,
504 ) -> Arc<HeadlessUnitTestSession> {
505 let (tcs, started) = mpsc::channel::<Result<(), Exception>>();
506 let cancellation_token_source = CancellationTokenSource::new();
507 let queue = Arc::new(BlockingQueue::default());
508
509 let task = {
510 let cancellation_token_source = cancellation_token_source.clone();
511 let queue = queue.clone();
512 thread::Builder::new()
513 .name("HeadlessUnitTestSession".to_owned())
514 .spawn(move || {
515 let _dispatcher = Dispatcher::unit_test_scope();
518 let _compositor = ReleaseCompositorOnDrop;
520 let session = match catch_unwind(|| {
521 let mut app_builder = entry_point_type();
522
523 if app_builder.windowing_subsystem_name().as_deref() != Some("Headless") {
525 app_builder = app_builder.use_headless(FerroHeadlessPlatformOptions::default());
526 }
527
528 if app_builder.text_shaping_subsystem_initializer().is_none() {
529 app_builder = app_builder.use_harfbuzz();
530 }
531
532 SessionThread { app_builder, shared_context: RefCell::new(None) }
533 }) {
534 Ok(session) => {
535 let _ = tcs.send(Ok(()));
536 session
537 }
538 Err(e) => {
539 let _ = tcs.send(Err(e));
540 return;
541 }
542 };
543
544 while !cancellation_token_source.is_cancellation_requested() {
545 if let Some(action) = queue.take(&cancellation_token_source) {
547 action(&session);
548 }
549 }
550 })
551 .expect("the thread of the headless unit test session")
552 };
553
554 match started.recv() {
555 Ok(Ok(())) => {}
556 Ok(Err(e)) => resume_unwind(e),
557 Err(_) => panic!("The thread of the headless unit test session ended before the session was created."),
558 }
559
560 let run_isolated = isolation_level == FerroTestIsolationLevel::PerTest;
561 Arc::new(HeadlessUnitTestSession {
562 cancellation_token_source,
563 queue,
564 dispatch_task: Mutex::new(Some(task)),
565 isolated: run_isolated,
566 })
567 }
568
569 pub fn get_or_start_for_assembly(assembly: Option<&FerroTestAssembly>) -> Arc<HeadlessUnitTestSession> {
575 let assembly = assembly.unwrap_or(&HEADLESS_ASSEMBLY);
576
577 let mut sessions = sessions().lock().unwrap_or_else(PoisonError::into_inner);
578 if let Some(session) = sessions.get(assembly.name) {
579 return session.clone();
580 }
581
582 let app_builder_entry_point_type =
583 assembly.test_application.map(|attribute| attribute.app_builder_entry_point_type());
584
585 let isolation_level = assembly
586 .test_isolation
587 .map_or(FerroTestIsolationLevel::PerTest, |attribute| attribute.isolation_level());
588
589 let session = match app_builder_entry_point_type {
590 Some(app_builder_entry_point_type) => {
591 Self::start_new_with_isolation(app_builder_entry_point_type, isolation_level)
592 }
593 None => Self::start_new_with_isolation(AppBuilder::configure::<Application>, isolation_level),
594 };
595
596 sessions.insert(assembly.name, session.clone());
597
598 session
599 }
600}
601
602impl IDisposable for HeadlessUnitTestSession {
603 fn dispose(&self) {
604 HeadlessUnitTestSession::dispose(self)
605 }
606}
607
608fn finish_composition_in_flight() {
629 let Some(media_context) = FerroLocator::current().get_service::<MediaContext>() else {
630 return;
631 };
632 for _ in 0..10 {
633 if !media_context.has_composition_batches_in_flight() {
634 return;
635 }
636 FerroHeadlessPlatform::force_render_timer_tick(1);
637 Dispatcher::ui_thread().run_jobs(None);
638 }
639}
640
641impl SessionThread {
642 fn ensure_shared_application(&self) -> Box<dyn IDisposable> {
643 let old_context = FerroSynchronizationContext::current();
644 if Application::current().is_none() {
645 self.app_builder.setup_unsafe();
646 *self.shared_context.borrow_mut() = FerroSynchronizationContext::current();
647 } else {
648 FerroSynchronizationContext::set_current(self.shared_context.borrow().clone());
649 }
650
651 Box::new(Cleanup::new(move || {
652 let jobs = catch_unwind(|| {
653 Dispatcher::ui_thread().run_jobs(None);
654 finish_composition_in_flight();
655 });
656 FerroSynchronizationContext::set_current(old_context);
657 if let Err(ex) = jobs {
658 resume_unwind(ex);
659 }
660 }))
661 }
662
663 fn ensure_isolated_application(&self) -> Box<dyn IDisposable> {
664 let scope = FerroLocator::enter_scope();
665 let old_context = FerroSynchronizationContext::current();
666 if let Err(ex) = catch_unwind(AssertUnwindSafe(|| {
667 Dispatcher::reset_before_unit_tests();
668 self.app_builder.setup_unsafe();
669 })) {
670 scope.dispose();
671 resume_unwind(ex);
672 }
673
674 Box::new(Cleanup::new(move || {
675 let services = catch_unwind(|| {
676 let tool_tip_service = FerroLocator::current().get_service::<dyn IToolTipService>();
677 if let Some(tool_tip_service) =
678 tool_tip_service.as_ref().and_then(|service| service.as_tool_tip_service())
679 {
680 tool_tip_service.dispose();
681 }
682 if let Some(font_manager) = FerroLocator::current().get_service::<FontManager>() {
683 font_manager.dispose();
684 }
685 Dispatcher::reset_for_unit_tests();
686 });
687
688 let scope = catch_unwind(AssertUnwindSafe(|| scope.dispose()));
690 Dispatcher::reset_before_unit_tests();
691 FerroSynchronizationContext::set_current(old_context);
692 Control::reset_loaded_queue_for_unit_tests();
696
697 if let Err(ex) = scope {
699 resume_unwind(ex);
700 }
701 if let Err(ex) = services {
702 resume_unwind(ex);
703 }
704 }))
705 }
706}
707
708struct Cleanup(RefCell<Option<Box<dyn FnOnce()>>>);
710
711impl Cleanup {
712 fn new(action: impl FnOnce() + 'static) -> Self {
713 Self(RefCell::new(Some(Box::new(action))))
714 }
715}
716
717impl IDisposable for Cleanup {
718 fn dispose(&self) {
719 let action = self.0.borrow_mut().take();
720 if let Some(action) = action {
721 action();
722 }
723 }
724}