1use std::collections::{HashMap, VecDeque};
2use std::path::PathBuf;
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::{Arc, Mutex, MutexGuard, OnceLock, RwLock, Weak};
5
6use sha2::{Digest, Sha256};
7
8use crate::api::{
9 LinkSelector, LocatorDirection, LocatorQuery, LocatorSelector, MatchOccurrence, MouseOptions,
10 OpenOptions, OpenResult, Operation, OperationResult, RunOptions, StyleSelector, TextMatch,
11 TextSelector, TuiTestError,
12};
13use crate::diagnostics::ExecutionContext;
14use crate::engine::Engine;
15use crate::logger::Logger;
16
17const MAX_COMPLETED_RECORDINGS: usize = 1024;
18
19#[derive(Clone)]
20pub struct Session {
21 name: Arc<str>,
22 engine: Arc<Engine>,
23 context: ExecutionContext,
24}
25
26#[derive(Debug, Clone, Copy)]
27pub struct LocatorClickOptions {
28 pub mouse: MouseOptions,
29 pub clicks: u8,
30 pub timeout_ms: Option<u64>,
31}
32
33impl Default for LocatorClickOptions {
34 fn default() -> Self {
35 Self {
36 mouse: MouseOptions::default(),
37 clicks: 1,
38 timeout_ms: None,
39 }
40 }
41}
42
43#[derive(Debug, Clone, Copy, Default)]
44pub struct LocatorExpectOptions {
45 pub not: bool,
46 pub timeout_ms: Option<u64>,
47}
48
49#[derive(Clone)]
50enum LocatorTarget {
51 Session(Session),
52 Handle(SessionHandle),
53}
54
55impl LocatorTarget {
56 fn same_owner(&self, other: &Self) -> bool {
57 match (self, other) {
58 (Self::Session(left), Self::Session(right)) => Arc::ptr_eq(&left.engine, &right.engine),
59 (Self::Handle(left), Self::Handle(right)) => {
60 Arc::ptr_eq(&left.registry.inner, &right.registry.inner) && left.name == right.name
61 }
62 _ => false,
63 }
64 }
65 fn execute(
66 &self,
67 operation_name: &'static str,
68 operation: Operation,
69 ) -> Result<OperationResult, TuiTestError> {
70 match self {
71 Self::Session(session) => session.execute_named(operation_name, operation),
72 Self::Handle(session) => session.execute_named(operation_name, operation),
73 }
74 }
75}
76
77#[derive(Clone)]
80pub struct Locator {
81 target: LocatorTarget,
82 query: LocatorQuery,
83}
84
85#[derive(Clone, Default)]
87pub struct LocatorFilterOptions {
88 pub has: Option<Locator>,
89 pub has_not: Option<Locator>,
90}
91
92impl Locator {
93 fn new(target: LocatorTarget, query: LocatorQuery) -> Self {
94 Self { target, query }
95 }
96
97 pub fn query(&self) -> &LocatorQuery {
98 &self.query
99 }
100
101 fn require_same_owner(&self, other: &Self) -> Result<(), TuiTestError> {
102 if self.target.same_owner(&other.target) {
103 Ok(())
104 } else {
105 Err(TuiTestError::usage(
106 "locator operands must belong to the same terminal owner",
107 ))
108 }
109 }
110
111 pub fn and(&self, other: &Self) -> Result<Self, TuiTestError> {
113 self.require_same_owner(other)?;
114 Ok(Self::new(
115 self.target.clone(),
116 self.query.clone().and(other.query.clone()),
117 ))
118 }
119
120 pub fn or(&self, other: &Self) -> Result<Self, TuiTestError> {
122 self.require_same_owner(other)?;
123 Ok(Self::new(
124 self.target.clone(),
125 self.query.clone().or(other.query.clone()),
126 ))
127 }
128
129 pub fn filter(&self, options: LocatorFilterOptions) -> Result<Self, TuiTestError> {
130 if options.has.is_none() && options.has_not.is_none() {
131 return Err(TuiTestError::usage("filter requires has or hasNot"));
132 }
133 for locator in options.has.iter().chain(options.has_not.iter()) {
134 self.require_same_owner(locator)?;
135 }
136 Ok(Self::new(
137 self.target.clone(),
138 self.query.clone().filter(
139 options.has.map(|locator| locator.query),
140 options.has_not.map(|locator| locator.query),
141 ),
142 ))
143 }
144
145 pub fn get_by_link(&self, selector: impl Into<LinkSelector>) -> Self {
147 self.get_by_link_relative(selector, LocatorDirection::Within)
148 }
149
150 pub fn get_by_link_relative(
151 &self,
152 selector: impl Into<LinkSelector>,
153 direction: LocatorDirection,
154 ) -> Self {
155 Self::new(
156 self.target.clone(),
157 LocatorQuery {
158 within: Some(Box::new(self.query.clone())),
159 direction,
160 ..LocatorQuery::link(selector)
161 },
162 )
163 }
164
165 pub fn get_by_text(&self, selector: impl Into<TextSelector>) -> Self {
166 self.get_by_text_relative(selector, LocatorDirection::Within)
167 }
168
169 pub fn get_by_text_relative(
170 &self,
171 selector: impl Into<TextSelector>,
172 direction: LocatorDirection,
173 ) -> Self {
174 Self {
175 target: self.target.clone(),
176 query: LocatorQuery {
177 selector: LocatorSelector::Text(selector.into()),
178 occurrence: MatchOccurrence::Any,
179 within: Some(Box::new(self.query.clone())),
180 direction,
181 style: Default::default(),
182 },
183 }
184 }
185
186 pub fn get_by_style(&self, selector: impl Into<StyleSelector>) -> Self {
187 self.get_by_style_relative(selector, LocatorDirection::Within)
188 }
189
190 pub fn get_by_style_relative(
191 &self,
192 selector: impl Into<StyleSelector>,
193 direction: LocatorDirection,
194 ) -> Self {
195 Self {
196 target: self.target.clone(),
197 query: LocatorQuery {
198 selector: LocatorSelector::Style(selector.into()),
199 occurrence: MatchOccurrence::Any,
200 within: Some(Box::new(self.query.clone())),
201 direction,
202 style: Default::default(),
203 },
204 }
205 }
206
207 pub fn any(&self) -> Self {
208 self.with_occurrence(MatchOccurrence::Any)
209 }
210
211 pub fn unique(&self) -> Self {
212 self.with_occurrence(MatchOccurrence::Unique)
213 }
214
215 pub fn first(&self) -> Self {
216 self.with_occurrence(MatchOccurrence::First)
217 }
218
219 pub fn last(&self) -> Self {
220 self.with_occurrence(MatchOccurrence::Last)
221 }
222
223 pub fn nth(&self, index: usize) -> Self {
224 self.with_occurrence(MatchOccurrence::Nth(index))
225 }
226
227 fn with_occurrence(&self, occurrence: MatchOccurrence) -> Self {
228 let mut locator = self.clone();
229 locator.query.occurrence = occurrence;
230 locator
231 }
232
233 pub fn all(&self) -> Result<Vec<Self>, TuiTestError> {
234 let matches = self.locations()?;
235 if self.query.occurrence == MatchOccurrence::Any {
236 Ok((0..matches.len()).map(|index| self.nth(index)).collect())
237 } else {
238 Ok(matches.into_iter().map(|_| self.clone()).collect())
239 }
240 }
241
242 pub fn count(&self) -> Result<usize, TuiTestError> {
243 self.locations().map(|matches| matches.len())
244 }
245
246 pub fn locations(&self) -> Result<Vec<TextMatch>, TuiTestError> {
247 match self.target.execute(
248 "locator.find",
249 Operation::FindLocator {
250 query: self.query.clone(),
251 },
252 )? {
253 OperationResult::Matches(matches) => Ok(matches),
254 _ => Err(TuiTestError::internal(
255 "locator locations returned an unexpected result type",
256 )),
257 }
258 }
259
260 pub fn location(&self) -> Result<TextMatch, TuiTestError> {
261 match self.target.execute(
262 "locator.location",
263 Operation::ResolveLocator {
264 query: self.query.clone(),
265 },
266 )? {
267 OperationResult::Matches(mut matches) if matches.len() == 1 => Ok(matches.remove(0)),
268 OperationResult::Matches(_) => Err(TuiTestError::internal(
269 "locator location returned an invalid match count",
270 )),
271 _ => Err(TuiTestError::internal(
272 "locator location returned an unexpected result type",
273 )),
274 }
275 }
276
277 pub fn wait(&self) -> Result<(), TuiTestError> {
278 self.wait_with_timeout(None)
279 }
280
281 pub fn wait_with_timeout(&self, timeout_ms: Option<u64>) -> Result<(), TuiTestError> {
282 self.wait_for(false, timeout_ms)
283 }
284
285 pub fn wait_hidden(&self, timeout_ms: Option<u64>) -> Result<(), TuiTestError> {
286 self.wait_for(true, timeout_ms)
287 }
288
289 fn wait_for(&self, not: bool, timeout_ms: Option<u64>) -> Result<(), TuiTestError> {
290 self.target
291 .execute(
292 "locator.wait",
293 Operation::WaitLocator {
294 query: self.query.clone(),
295 not,
296 timeout_ms,
297 },
298 )
299 .map(|_| ())
300 }
301
302 pub fn click(&self) -> Result<(), TuiTestError> {
303 self.click_with(LocatorClickOptions::default())
304 }
305
306 pub fn click_with(&self, options: LocatorClickOptions) -> Result<(), TuiTestError> {
307 self.target
308 .execute(
309 "locator.click",
310 Operation::ClickLocator {
311 query: self.query.clone(),
312 options: options.mouse,
313 clicks: options.clicks,
314 timeout_ms: options.timeout_ms,
315 },
316 )
317 .map(|_| ())
318 }
319
320 pub fn highlight(&self) -> Result<(), TuiTestError> {
321 self.highlight_with_timeout(None)
322 }
323
324 pub fn highlight_with_timeout(&self, timeout_ms: Option<u64>) -> Result<(), TuiTestError> {
325 self.target
326 .execute(
327 "locator.highlight",
328 Operation::HighlightLocator {
329 query: self.query.clone(),
330 timeout_ms,
331 },
332 )
333 .map(|_| ())
334 }
335
336 pub fn expect(&self) -> Result<(), TuiTestError> {
337 self.expect_with(LocatorExpectOptions::default())
338 }
339
340 pub fn expect_with(&self, options: LocatorExpectOptions) -> Result<(), TuiTestError> {
341 self.target
342 .execute(
343 "locator.expect",
344 Operation::WaitLocator {
345 query: self.query.clone(),
346 not: options.not,
347 timeout_ms: options.timeout_ms,
348 },
349 )
350 .map(|_| ())
351 }
352}
353
354impl Session {
355 pub fn get_by_link(&self, selector: impl Into<LinkSelector>) -> Locator {
356 Locator::new(
357 LocatorTarget::Session(self.clone()),
358 LocatorQuery::link(selector),
359 )
360 }
361 pub fn new(name: impl Into<String>) -> Self {
362 let name = name.into();
363 let recording_path = native_recording_path(&name);
364 Self {
365 name: Arc::from(name.as_str()),
366 engine: Arc::new(Engine::new(
367 name,
368 Arc::new(Logger::disabled()),
369 recording_path,
370 )),
371 context: ExecutionContext::default(),
372 }
373 }
374
375 pub fn name(&self) -> &str {
376 &self.name
377 }
378
379 pub fn get_by_text(&self, selector: impl Into<TextSelector>) -> Locator {
380 Locator::new(
381 LocatorTarget::Session(self.clone()),
382 LocatorQuery::text(selector),
383 )
384 }
385
386 pub fn get_by_style(&self, selector: impl Into<StyleSelector>) -> Locator {
387 Locator::new(
388 LocatorTarget::Session(self.clone()),
389 LocatorQuery::style(selector),
390 )
391 }
392
393 pub fn execute(&self, operation: Operation) -> Result<OperationResult, TuiTestError> {
394 self.engine
395 .execute_with_context(operation, self.context.clone())
396 }
397
398 pub fn execute_with_context(
399 &self,
400 operation: Operation,
401 context: ExecutionContext,
402 ) -> Result<OperationResult, TuiTestError> {
403 self.engine.execute_with_context(operation, context)
404 }
405
406 pub fn with_execution_context(&self, context: ExecutionContext) -> Self {
407 Self {
408 name: self.name.clone(),
409 engine: self.engine.clone(),
410 context,
411 }
412 }
413
414 fn execute_named(
415 &self,
416 operation_name: &'static str,
417 operation: Operation,
418 ) -> Result<OperationResult, TuiTestError> {
419 let context = self.context.clone().with_operation(operation_name);
420 self.engine.execute_with_context(operation, context)
421 }
422
423 pub fn open(&self, options: OpenOptions) -> Result<OpenResult, TuiTestError> {
424 match self.execute(Operation::Open(options))? {
425 OperationResult::Open(result) => Ok(result),
426 _ => Err(TuiTestError::internal(
427 "open returned an unexpected result type",
428 )),
429 }
430 }
431
432 pub fn run(&self, options: RunOptions) -> Result<OpenResult, TuiTestError> {
433 match self.execute(Operation::Run(options))? {
434 OperationResult::Open(result) => Ok(result),
435 _ => Err(TuiTestError::internal(
436 "run returned an unexpected result type",
437 )),
438 }
439 }
440
441 pub fn close(&self) -> Result<(), TuiTestError> {
442 self.execute(Operation::Close).map(|_| ())
443 }
444
445 pub fn interrupt(&self) {
446 self.engine.interrupt();
447 }
448
449 pub fn is_open(&self) -> bool {
450 self.engine.is_open()
451 }
452
453 pub fn recording_path(&self) -> Option<PathBuf> {
454 self.engine.recording_path()
455 }
456
457 pub fn recording(&self) -> std::io::Result<String> {
458 let path = self.recording_path().ok_or_else(|| {
459 std::io::Error::new(
460 std::io::ErrorKind::NotFound,
461 "automatic recording is disabled",
462 )
463 })?;
464 self.engine
465 .flush_recording()
466 .map_err(tui_test_error_to_io_error)?;
467 std::fs::read_to_string(path)
468 }
469
470 fn retained_recording_path(&self) -> Option<PathBuf> {
471 self.engine.retained_recording_path()
472 }
473}
474
475#[derive(Clone)]
476pub struct SessionHandle {
477 name: Arc<str>,
478 registry: SessionRegistry,
479 context: ExecutionContext,
480}
481
482impl SessionHandle {
483 pub fn get_by_link(&self, selector: impl Into<LinkSelector>) -> Locator {
484 Locator::new(
485 LocatorTarget::Handle(self.clone()),
486 LocatorQuery::link(selector),
487 )
488 }
489 pub fn name(&self) -> &str {
490 &self.name
491 }
492
493 pub fn get_by_text(&self, selector: impl Into<TextSelector>) -> Locator {
494 Locator::new(
495 LocatorTarget::Handle(self.clone()),
496 LocatorQuery::text(selector),
497 )
498 }
499
500 pub fn get_by_style(&self, selector: impl Into<StyleSelector>) -> Locator {
501 Locator::new(
502 LocatorTarget::Handle(self.clone()),
503 LocatorQuery::style(selector),
504 )
505 }
506
507 pub fn execute(&self, operation: Operation) -> Result<OperationResult, TuiTestError> {
508 self.registry
509 .execute_with_context(&self.name, operation, self.context.clone())
510 }
511
512 pub fn execute_with_context(
513 &self,
514 operation: Operation,
515 context: ExecutionContext,
516 ) -> Result<OperationResult, TuiTestError> {
517 self.registry
518 .execute_with_context(&self.name, operation, context)
519 }
520
521 pub fn with_execution_context(&self, context: ExecutionContext) -> Self {
522 Self {
523 name: self.name.clone(),
524 registry: self.registry.clone(),
525 context,
526 }
527 }
528
529 fn execute_named(
530 &self,
531 operation_name: &'static str,
532 operation: Operation,
533 ) -> Result<OperationResult, TuiTestError> {
534 let context = self.context.clone().with_operation(operation_name);
535 self.registry
536 .execute_with_context(&self.name, operation, context)
537 }
538
539 pub fn open(&self, options: OpenOptions) -> Result<OpenResult, TuiTestError> {
540 match self.execute(Operation::Open(options))? {
541 OperationResult::Open(result) => Ok(result),
542 _ => Err(TuiTestError::internal(
543 "open returned an unexpected result type",
544 )),
545 }
546 }
547
548 pub fn run(&self, options: RunOptions) -> Result<OpenResult, TuiTestError> {
549 match self.execute(Operation::Run(options))? {
550 OperationResult::Open(result) => Ok(result),
551 _ => Err(TuiTestError::internal(
552 "run returned an unexpected result type",
553 )),
554 }
555 }
556
557 pub fn close(&self) -> Result<(), TuiTestError> {
558 self.execute(Operation::Close).map(|_| ())
559 }
560
561 pub fn recording(&self) -> std::io::Result<String> {
562 self.registry.recording(&self.name)
563 }
564}
565
566#[derive(Clone)]
567pub struct SessionRegistry {
568 inner: Arc<RegistryInner>,
569}
570
571struct RegistryInner {
572 sessions: Mutex<HashMap<String, Session>>,
573 recordings: Mutex<CompletedRecordings>,
574 generations: Mutex<HashMap<String, Weak<Mutex<()>>>>,
575 lifecycle: RwLock<()>,
576}
577
578#[derive(Default)]
579struct CompletedRecordings {
580 paths: HashMap<String, PathBuf>,
581 order: VecDeque<String>,
582}
583
584impl Default for SessionRegistry {
585 fn default() -> Self {
586 Self {
587 inner: Arc::new(RegistryInner {
588 sessions: Mutex::new(HashMap::new()),
589 recordings: Mutex::new(CompletedRecordings::default()),
590 generations: Mutex::new(HashMap::new()),
591 lifecycle: RwLock::new(()),
592 }),
593 }
594 }
595}
596
597impl SessionRegistry {
598 pub fn session(&self, name: impl Into<String>) -> SessionHandle {
599 let name = name.into();
600 SessionHandle {
601 name: Arc::from(name),
602 registry: self.clone(),
603 context: ExecutionContext::default(),
604 }
605 }
606
607 fn get_or_create_locked(&self, name: String) -> Session {
608 let mut sessions = self.lock_sessions();
609 sessions
610 .entry(name.clone())
611 .or_insert_with(|| Session::new(name))
612 .clone()
613 }
614
615 pub fn execute(
616 &self,
617 name: &str,
618 operation: Operation,
619 ) -> Result<OperationResult, TuiTestError> {
620 self.execute_with_context(name, operation, ExecutionContext::default())
621 }
622
623 pub fn execute_with_context(
624 &self,
625 name: &str,
626 operation: Operation,
627 context: ExecutionContext,
628 ) -> Result<OperationResult, TuiTestError> {
629 let generation = self.generation(name);
630 let _generation = generation
631 .lock()
632 .unwrap_or_else(std::sync::PoisonError::into_inner);
633 match operation {
634 Operation::Open(_) | Operation::Run(_) => {
635 let _lifecycle = self
636 .inner
637 .lifecycle
638 .read()
639 .unwrap_or_else(std::sync::PoisonError::into_inner);
640 self.get_or_create_locked(name.to_string())
641 .execute_with_context(operation, context)
642 }
643 Operation::Restart { .. } => {
644 let _lifecycle = self
645 .inner
646 .lifecycle
647 .read()
648 .unwrap_or_else(std::sync::PoisonError::into_inner);
649 let session = self.lock_sessions().get(name).cloned();
650 session
651 .ok_or_else(TuiTestError::no_restart_metadata)?
652 .execute_with_context(operation, context)
653 }
654 Operation::Close => self
655 .close_locked(name, context)
656 .map(|_| OperationResult::Unit),
657 other => {
658 let session = {
659 let _lifecycle = self
660 .inner
661 .lifecycle
662 .read()
663 .unwrap_or_else(std::sync::PoisonError::into_inner);
664 self.lock_sessions().get(name).cloned()
665 };
666 session
667 .ok_or_else(TuiTestError::no_session)?
668 .execute_with_context(other, context)
669 }
670 }
671 }
672
673 pub fn sessions(&self) -> Vec<String> {
674 let sessions = self
675 .lock_sessions()
676 .iter()
677 .map(|(name, session)| (name.clone(), session.clone()))
678 .collect::<Vec<_>>();
679 let mut names = sessions
680 .into_iter()
681 .filter_map(|(name, session)| session.is_open().then_some(name))
682 .collect::<Vec<_>>();
683 names.sort();
684 names
685 }
686
687 pub fn close(&self, name: &str) -> Result<(), TuiTestError> {
688 let generation = self.generation(name);
689 let _generation = generation
690 .lock()
691 .unwrap_or_else(std::sync::PoisonError::into_inner);
692 self.close_locked(name, ExecutionContext::default())
693 }
694
695 pub fn close_all(&self) {
696 let mut removed = Vec::new();
697 {
698 let _lifecycle = self
699 .inner
700 .lifecycle
701 .write()
702 .unwrap_or_else(std::sync::PoisonError::into_inner);
703 let sessions = std::mem::take(&mut *self.lock_sessions());
704 for session in sessions.values() {
705 session.interrupt();
706 }
707 let mut recordings = self.lock_recordings();
708 for (name, session) in sessions {
709 let _ = session.close();
710 removed.extend(Self::replace_recording(
711 &mut recordings,
712 name,
713 session.retained_recording_path(),
714 ));
715 }
716 }
717 Self::remove_recording_files(removed);
718 }
719
720 pub fn recording(&self, name: &str) -> std::io::Result<String> {
721 let generation = self.generation(name);
722 let _generation = generation
723 .lock()
724 .unwrap_or_else(std::sync::PoisonError::into_inner);
725 let (session, completed) = {
726 let _lifecycle = self
727 .inner
728 .lifecycle
729 .read()
730 .unwrap_or_else(std::sync::PoisonError::into_inner);
731 let recordings = self.lock_recordings();
732 let session = self.lock_sessions().get(name).cloned();
733 let completed = recordings.paths.get(name).cloned();
734 (session, completed)
735 };
736 if let Some(session) = session {
737 return session.recording();
738 }
739 let path = completed.ok_or_else(|| {
740 std::io::Error::new(std::io::ErrorKind::NotFound, "unknown native session")
741 })?;
742 std::fs::read_to_string(path)
743 }
744
745 fn close_locked(&self, name: &str, context: ExecutionContext) -> Result<(), TuiTestError> {
746 let _lifecycle = self
747 .inner
748 .lifecycle
749 .read()
750 .unwrap_or_else(std::sync::PoisonError::into_inner);
751 let Some(session) = self.lock_sessions().remove(name) else {
752 return Ok(());
753 };
754 let result = session
755 .execute_with_context(Operation::Close, context)
756 .map(|_| ());
757 let removed = Self::replace_recording(
758 &mut self.lock_recordings(),
759 name.to_string(),
760 session.retained_recording_path(),
761 );
762 Self::remove_recording_files(removed);
763 result
764 }
765
766 fn lock_sessions(&self) -> MutexGuard<'_, HashMap<String, Session>> {
767 self.inner
768 .sessions
769 .lock()
770 .unwrap_or_else(std::sync::PoisonError::into_inner)
771 }
772
773 fn lock_recordings(&self) -> MutexGuard<'_, CompletedRecordings> {
774 self.inner
775 .recordings
776 .lock()
777 .unwrap_or_else(std::sync::PoisonError::into_inner)
778 }
779
780 fn generation(&self, name: &str) -> Arc<Mutex<()>> {
781 let mut generations = self
782 .inner
783 .generations
784 .lock()
785 .unwrap_or_else(std::sync::PoisonError::into_inner);
786 generations.retain(|_, generation| generation.strong_count() > 0);
787 if let Some(generation) = generations.get(name).and_then(Weak::upgrade) {
788 return generation;
789 }
790 let generation = Arc::new(Mutex::new(()));
791 generations.insert(name.to_string(), Arc::downgrade(&generation));
792 generation
793 }
794
795 #[cfg(test)]
796 fn remember_recording(&self, name: String, path: PathBuf) {
797 let removed = Self::cache_recording(&mut self.lock_recordings(), name, path);
798 Self::remove_recording_files(removed);
799 }
800
801 fn cache_recording(
802 recordings: &mut CompletedRecordings,
803 name: String,
804 path: PathBuf,
805 ) -> Vec<PathBuf> {
806 let mut removed = Vec::new();
807 if let Some(previous) = recordings.paths.insert(name.clone(), path.clone()) {
808 if previous != path {
809 removed.push(previous);
810 }
811 recordings.order.retain(|entry| entry != &name);
812 }
813 recordings.order.push_back(name);
814 while recordings.paths.len() > MAX_COMPLETED_RECORDINGS {
815 let Some(oldest) = recordings.order.pop_front() else {
816 break;
817 };
818 if let Some(path) = recordings.paths.remove(&oldest) {
819 removed.push(path);
820 }
821 }
822 removed
823 }
824
825 fn replace_recording(
826 recordings: &mut CompletedRecordings,
827 name: String,
828 path: Option<PathBuf>,
829 ) -> Vec<PathBuf> {
830 match path {
831 Some(path) => Self::cache_recording(recordings, name, path),
832 None => {
833 recordings.order.retain(|entry| entry != &name);
834 recordings.paths.remove(&name).into_iter().collect()
835 }
836 }
837 }
838
839 fn remove_recording_files(paths: Vec<PathBuf>) {
840 for path in paths {
841 let _ = std::fs::remove_file(path);
842 }
843 }
844}
845
846pub fn global_registry() -> &'static SessionRegistry {
847 static REGISTRY: OnceLock<SessionRegistry> = OnceLock::new();
848 REGISTRY.get_or_init(SessionRegistry::default)
849}
850
851fn native_recording_path(name: &str) -> PathBuf {
852 static RECORDING_SEQUENCE: AtomicU64 = AtomicU64::new(0);
853 let digest = format!("{:x}", Sha256::digest(name.as_bytes()));
854 let sequence = RECORDING_SEQUENCE.fetch_add(1, Ordering::Relaxed);
855 dirs::cache_dir()
856 .unwrap_or_else(std::env::temp_dir)
857 .join("tui-test")
858 .join("native")
859 .join(std::process::id().to_string())
860 .join(format!(
861 "{}-{}-{sequence}.cast",
862 &digest[..16],
863 std::process::id()
864 ))
865}
866
867fn tui_test_error_to_io_error(error: TuiTestError) -> std::io::Error {
868 let kind = if error.kind == crate::api::ErrorKind::NoSession {
869 std::io::ErrorKind::NotFound
870 } else {
871 std::io::ErrorKind::Other
872 };
873 std::io::Error::new(kind, error)
874}
875
876#[cfg(test)]
877mod tests {
878 use super::*;
879 use crate::api::{ErrorKind, Operation};
880
881 #[test]
882 fn registry_reuses_names_and_lists_only_open_sessions() {
883 let registry = SessionRegistry::default();
884 let first = registry.get_or_create_locked("same".to_string());
885 let second = registry.get_or_create_locked("same".to_string());
886 assert!(Arc::ptr_eq(&first.engine, &second.engine));
887 assert!(registry.sessions().is_empty());
888 }
889
890 #[test]
891 fn closed_session_operations_report_no_session() {
892 let registry = SessionRegistry::default();
893 let error = registry.execute("missing", Operation::State).unwrap_err();
894 assert_eq!(error.kind, ErrorKind::NoSession);
895 }
896
897 #[test]
898 fn completed_recordings_are_bounded() {
899 let registry = SessionRegistry::default();
900 let root =
901 std::env::temp_dir().join(format!("tui-test-recording-cache-{}", std::process::id()));
902 std::fs::create_dir_all(&root).unwrap();
903
904 for index in 0..=MAX_COMPLETED_RECORDINGS {
905 let name = format!("session-{index}");
906 let path = root.join(format!("{index}.cast"));
907 std::fs::write(&path, index.to_string()).unwrap();
908 registry.remember_recording(name, path);
909 }
910
911 assert_eq!(
912 registry.lock_recordings().paths.len(),
913 MAX_COMPLETED_RECORDINGS
914 );
915 assert!(registry.recording("session-0").is_err());
916 assert_eq!(
917 registry
918 .recording(&format!("session-{MAX_COMPLETED_RECORDINGS}"))
919 .unwrap(),
920 MAX_COMPLETED_RECORDINGS.to_string()
921 );
922 assert!(!root.join("0.cast").exists());
923 let _ = std::fs::remove_dir_all(root);
924 }
925
926 #[test]
927 fn missing_operations_do_not_hide_completed_recordings() {
928 let registry = SessionRegistry::default();
929 let path = std::env::temp_dir().join(format!(
930 "tui-test-retained-recording-{}.cast",
931 std::process::id()
932 ));
933 std::fs::write(&path, "retained").unwrap();
934 registry.remember_recording("retained".to_string(), path.clone());
935
936 assert_eq!(
937 registry
938 .execute("retained", Operation::State)
939 .unwrap_err()
940 .kind,
941 ErrorKind::NoSession
942 );
943 assert_eq!(registry.recording("retained").unwrap(), "retained");
944 assert!(registry.sessions().is_empty());
945
946 let _ = std::fs::remove_file(path);
947 }
948
949 #[test]
950 fn missing_restarts_do_not_create_sessions_or_hide_completed_recordings() {
951 let registry = SessionRegistry::default();
952 let path = std::env::temp_dir().join(format!(
953 "tui-test-restart-retained-recording-{}.cast",
954 std::process::id()
955 ));
956 std::fs::write(&path, "retained").unwrap();
957 registry.remember_recording("retained".to_string(), path.clone());
958
959 for _ in 0..3 {
960 let error = registry
961 .execute(
962 "retained",
963 Operation::Restart {
964 graceful_timeout_ms: 10,
965 },
966 )
967 .unwrap_err();
968 assert_eq!(error.kind, ErrorKind::NoSession);
969 assert!(error.message.contains("no restart metadata"));
970 assert!(!registry.lock_sessions().contains_key("retained"));
971 assert_eq!(registry.recording("retained").unwrap(), "retained");
972 assert!(path.exists());
973 }
974
975 assert!(registry.lock_sessions().is_empty());
976 let _ = std::fs::remove_file(path);
977 }
978
979 #[test]
980 fn closing_never_opened_names_does_not_evict_recordings() {
981 let registry = SessionRegistry::default();
982 let path = std::env::temp_dir().join(format!(
983 "tui-test-valid-recording-{}.cast",
984 std::process::id()
985 ));
986 std::fs::write(&path, "valid").unwrap();
987 registry.remember_recording("valid".to_string(), path.clone());
988
989 for index in 0..=MAX_COMPLETED_RECORDINGS {
990 registry.close(&format!("empty-{index}")).unwrap();
991 }
992
993 assert_eq!(registry.recording("valid").unwrap(), "valid");
994 assert_eq!(registry.lock_recordings().paths.len(), 1);
995 let _ = std::fs::remove_file(path);
996 }
997
998 #[test]
999 fn active_session_does_not_fall_back_to_prior_recording() {
1000 let registry = SessionRegistry::default();
1001 let path = std::env::temp_dir().join(format!(
1002 "tui-test-prior-recording-{}.cast",
1003 std::process::id()
1004 ));
1005 std::fs::write(&path, "prior").unwrap();
1006 registry.remember_recording("same".to_string(), path.clone());
1007 registry.get_or_create_locked("same".to_string());
1008
1009 assert!(registry.recording("same").is_err());
1010 let _ = std::fs::remove_file(path);
1011 }
1012}