Skip to main content

tui_test/
runtime.rs

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/// A lazy query resolved against the current terminal grid before every read,
78/// wait, or action.
79#[derive(Clone)]
80pub struct Locator {
81    target: LocatorTarget,
82    query: LocatorQuery,
83}
84
85/// Keep candidates containing `has` and containing no `has_not` matches.
86#[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    /// Intersect selected cells and form contiguous per-row runs.
112    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    /// Union selected cells and form contiguous per-row runs.
121    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    /// Require every cell of each current match to have this link.
146    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}