Skip to main content

textual_rs/testing/
mod.rs

1//! Headless testing infrastructure for automated UI tests.
2
3pub mod assertions;
4pub mod pilot;
5
6use ratatui::backend::TestBackend;
7use ratatui::Terminal;
8
9use crate::app::App;
10use crate::event::AppEvent;
11use crate::widget::context::AppContext;
12use crate::widget::Widget;
13
14pub use pilot::Pilot;
15
16/// Headless test harness. Creates an App with TestBackend for automated testing.
17///
18/// `TestApp` does NOT run the full async event loop. Instead, events are processed
19/// synchronously via `process_event`, allowing tests to control timing precisely.
20///
21/// # Example
22/// ```ignore
23/// use textual_rs::testing::TestApp;
24/// let test_app = TestApp::new(80, 24, || Box::new(MyScreen));
25/// ```
26pub struct TestApp {
27    pub(crate) app: App,
28    pub(crate) terminal: Terminal<TestBackend>,
29    /// Kept alive so the channel remains open; used by tests that want to inject events.
30    #[allow(dead_code)]
31    pub(crate) tx: flume::Sender<AppEvent>,
32    pub(crate) rx: flume::Receiver<AppEvent>,
33}
34
35impl TestApp {
36    /// Create a new TestApp with the given root screen factory.
37    ///
38    /// Initializes the reactive runtime (safe to call multiple times), mounts the
39    /// root screen, and renders the initial frame.
40    pub fn new(cols: u16, rows: u16, factory: impl FnOnce() -> Box<dyn Widget> + 'static) -> Self {
41        // Init reactive runtime — safe to call multiple times, subsequent calls are no-ops.
42        let _ = any_spawner::Executor::init_tokio();
43
44        let mut app = App::new_bare(factory);
45        // Skip animations in tests for deterministic rendering
46        app.set_skip_animations(true);
47
48        let (tx, rx) = flume::unbounded::<AppEvent>();
49        app.set_event_tx(tx.clone());
50
51        let backend = TestBackend::new(cols, rows);
52        let mut terminal = Terminal::new(backend).expect("failed to create TestBackend terminal");
53
54        // Mount root screen and render initial frame
55        app.mount_root_screen();
56        app.render_to_terminal(&mut terminal)
57            .expect("failed initial render");
58
59        TestApp {
60            app,
61            terminal,
62            tx,
63            rx,
64        }
65    }
66
67    /// Create a TestApp WITH built-in CSS (for tests that need proper widget layout).
68    pub fn new_styled(
69        cols: u16,
70        rows: u16,
71        css: &str,
72        factory: impl FnOnce() -> Box<dyn Widget> + 'static,
73    ) -> Self {
74        let _ = any_spawner::Executor::init_tokio();
75        let mut app = App::new(factory).with_css(css);
76        app.set_skip_animations(true);
77        let (tx, rx) = flume::unbounded::<AppEvent>();
78        app.set_event_tx(tx.clone());
79        let backend = TestBackend::new(cols, rows);
80        let mut terminal = Terminal::new(backend).expect("failed to create TestBackend terminal");
81        app.mount_root_screen();
82        app.render_to_terminal(&mut terminal)
83            .expect("failed initial render");
84        TestApp {
85            app,
86            terminal,
87            tx,
88            rx,
89        }
90    }
91
92    /// Get a Pilot for sending simulated input events.
93    pub fn pilot(&mut self) -> Pilot<'_> {
94        Pilot::new(self)
95    }
96
97    /// Access the AppContext for state assertions (focus, widget ids, etc.).
98    pub fn ctx(&self) -> &AppContext {
99        self.app.ctx()
100    }
101
102    /// Access the rendered buffer for row-level assertions.
103    pub fn buffer(&self) -> &ratatui::buffer::Buffer {
104        self.terminal.backend().buffer()
105    }
106
107    /// Access the TestBackend directly (implements Display for insta snapshots).
108    pub fn backend(&self) -> &TestBackend {
109        self.terminal.backend()
110    }
111
112    /// Inject a key event without draining the message queue.
113    ///
114    /// Used in tests that need to inspect the raw message queue immediately after
115    /// key dispatch (e.g., to verify a specific message was posted before bubbling).
116    pub fn inject_key_event(&mut self, key: crossterm::event::KeyEvent) {
117        self.app.handle_key_event(key);
118    }
119
120    /// Drain the message queue explicitly (e.g., after inject_key_event).
121    pub fn drain_messages(&self) {
122        self.app.drain_message_queue();
123    }
124
125    /// Process a single event synchronously and re-render.
126    ///
127    /// Used by Pilot to inject input. Can also be called directly for precise control.
128    pub fn process_event(&mut self, event: AppEvent) {
129        match &event {
130            AppEvent::Key(k) if k.kind == crossterm::event::KeyEventKind::Press => {
131                let k = *k;
132                self.app.handle_key_event(k);
133            }
134            AppEvent::Mouse(m) => {
135                let m = *m;
136                self.app.handle_mouse_event(m);
137            }
138            AppEvent::RenderRequest | AppEvent::Resize(_, _) => {
139                // Handled by re-render below
140            }
141            _ => {}
142        }
143        self.app.drain_message_queue();
144        self.app.process_deferred_screens();
145        self.app
146            .render_to_terminal(&mut self.terminal)
147            .expect("failed to render in process_event");
148    }
149}