shadow_terminal/
steppable_terminal.rs

1//! A steppable terminal, useful for doing end to end testing of TUI applications.
2
3use std::fmt::Write as _;
4use std::sync::Arc;
5
6use snafu::{OptionExt as _, ResultExt as _};
7use tracing::Instrument as _;
8
9/// The default time to wait looking for terminal screen content.
10const DEFAULT_TIMEOUT: u32 = 500;
11
12/// Handle various kinds of input.
13///
14/// Simulating STDIN has actually been quite hard. For one, it seems like terminal input parsers
15/// depend on delays to seperate key presses and ANSI escape code commands? For example, what's the
16/// difference between typing `^[` and beginning a sequence that inputs mouse movement: `^[<64;14;2M`?
17/// So these variants help when you know that you want to send a character or a known ANSI
18/// sequence.
19#[non_exhaustive]
20pub enum Input {
21    /// For sending 1 or few charactes. If you want to send a lot of characters, it's better to
22    /// use and ANSI "paste", where everything gets sent at once. In which case you'd use `Event`.
23    Characters(String),
24    /// For sending known ANSI sequences, like mouse movement, bracketed paste, etc.
25    Event(String),
26}
27
28/// This Steppable Terminal is likely more useful for running end to end tests.
29///
30/// It doesn't run [`ShadowTerminal`] in a loop and so requires calling certain methods manually to advance the
31/// terminal frontend. It also exposes the underyling [`Wezterm`] terminal that has a wealth of useful methods
32/// for interacting with it.
33#[non_exhaustive]
34pub struct SteppableTerminal {
35    /// The [`ShadowTerminal`] frontend combines a PTY process and a [`Wezterm`] terminal instance.
36    pub shadow_terminal: crate::shadow_terminal::ShadowTerminal,
37    /// The underlying PTY's Tokio task handle.
38    pub pty_task_handle: std::sync::Arc<
39        tokio::sync::Mutex<tokio::task::JoinHandle<Result<(), crate::errors::PTYError>>>,
40    >,
41    /// A Tokio channel that forwards bytes to the underlying PTY's STDIN.
42    pub pty_input_tx: tokio::sync::mpsc::Sender<crate::pty::BytesFromSTDIN>,
43}
44
45impl SteppableTerminal {
46    /// Starts the terminal. Waits for first output before returning.
47    ///
48    /// # Errors
49    /// If it doesn't receive any output in time.
50    #[inline]
51    pub async fn start(
52        config: crate::shadow_terminal::Config,
53    ) -> Result<Self, crate::errors::SteppableTerminalError> {
54        let (surface_output_tx, _) = tokio::sync::mpsc::channel(1);
55        let mut shadow_terminal =
56            crate::shadow_terminal::ShadowTerminal::new(config, surface_output_tx);
57
58        let (pty_input_tx, pty_input_rx) = tokio::sync::mpsc::channel(2048);
59        let pty_task_handle = shadow_terminal.start(pty_input_rx);
60
61        let mut steppable = Self {
62            shadow_terminal,
63            pty_task_handle: std::sync::Arc::new(tokio::sync::Mutex::new(pty_task_handle)),
64            pty_input_tx,
65        };
66
67        for i in 0i8..=100 {
68            if i == 100 {
69                snafu::whatever!("Shadow Terminal didn't start in time.");
70            }
71            steppable
72                .render_all_output()
73                .await
74                .with_whatever_context(|err| format!("Couldn't render output: {err:?}"))?;
75            let mut screen = steppable.screen_as_string()?;
76            screen.retain(|character| !character.is_whitespace());
77            if !screen.is_empty() {
78                break;
79            }
80            tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
81        }
82
83        Ok(steppable)
84    }
85
86    /// Broadcast the shutdown signal. This should exit both the underlying PTY process and the
87    /// main `ShadowTerminal` loop.
88    ///
89    /// # Errors
90    /// If the `End` messaage could not be sent.
91    #[inline]
92    pub fn kill(&self) -> Result<(), crate::errors::SteppableTerminalError> {
93        tracing::info!("Killing Steppable Terminal...");
94        self.shadow_terminal.kill().with_whatever_context(|err| {
95            format!("Couldn't call `ShadowTerminal.kill()` from SteppableTerminal: {err:?}")
96        })?;
97
98        let current_span = tracing::Span::current();
99        let pty_handle_arc = Arc::clone(&self.pty_task_handle);
100        let tokio_runtime = tokio::runtime::Handle::current();
101        let result = std::thread::spawn(move || {
102            tokio_runtime.block_on(
103                async {
104                    tracing::trace!("Starting manual loop to wait for PTY task handle to finish");
105                    let pty_handle = pty_handle_arc.lock().await;
106                    for i in 0i64..=100 {
107                        tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
108                        if i == 100 {
109                            tracing::error!(
110                                "Couldn't leave ShadowTerminal handle in 100 iterations"
111                            );
112                            break;
113                        }
114                        if pty_handle.is_finished() {
115                            tracing::trace!("`pty_handle.finished()` returned `true`");
116                            break;
117                        }
118                    }
119                }
120                .instrument(current_span),
121            );
122        })
123        .join();
124        if let Err(error) = result {
125            snafu::whatever!("Error in thread that spawns PTY handle waiter: {error:?}");
126        }
127
128        Ok(())
129    }
130
131    /// Send input directly into the underlying PTY process. This doesn't go through the
132    /// shadow terminal's "frontend".
133    ///
134    /// For some reason this function is unreliable when sending more than one character. It is
135    /// better to send larger strings using the OSC Paste mode. See [`self.paste_string()`]
136    ///
137    /// # Errors
138    /// If sending the string fails
139    #[inline]
140    pub fn send_input(&self, input: Input) -> Result<(), crate::errors::PTYError> {
141        match input {
142            Input::Characters(characters) => {
143                for char in characters.chars() {
144                    let mut buffer: crate::pty::BytesFromSTDIN = [0; 128];
145                    char.encode_utf8(&mut buffer);
146
147                    self.pty_input_tx
148                        .try_send(buffer)
149                        .with_whatever_context(|err| {
150                            format!("Couldn't send character input ({char}): {err:?}")
151                        })?;
152
153                    std::thread::sleep(std::time::Duration::from_millis(1));
154                }
155            }
156
157            Input::Event(event) => {
158                for chunk in event.as_bytes().chunks(128) {
159                    let mut buffer: crate::pty::BytesFromSTDIN = [0; 128];
160                    crate::pty::PTY::add_bytes_to_buffer(&mut buffer, chunk)?;
161
162                    self.pty_input_tx
163                        .try_send(buffer)
164                        .with_whatever_context(|err| {
165                            format!("Couldn't send input event ({event:?}): {err:?}")
166                        })?;
167                }
168
169                std::thread::sleep(std::time::Duration::from_millis(1));
170            }
171        }
172
173        Ok(())
174    }
175
176    /// Send a command to the terminal REPL. This pastes the command body, then sends a single
177    /// newline to tell the TTY to run the command.
178    ///
179    /// # Errors
180    /// If sending the string fails
181    #[inline]
182    pub fn send_command(&self, command: &str) -> Result<(), crate::errors::PTYError> {
183        self.send_input(Input::Characters(format!("{command}\n")))?;
184
185        Ok(())
186    }
187
188    /// Send a command using an OSC paste event.
189    ///
190    /// This is generally the preferred way to send commands. It's just more efficient then sending
191    /// each character separately. However, for some reason, that I haven't been able to get to the
192    /// bottom of, some PTY processes parse out the OSC paste ANSI codes and some don't. I didn't
193    /// even know that PTY's had anything to do with parsing ANSI, so I could well be mistaken and
194    /// would appreciate being corrected.
195    ///
196    /// So if you have problems with this function then just use `send_command()` instead.
197    ///
198    /// @tombh July 2025.
199    ///
200    /// # Errors
201    /// If sending the string fails
202    #[inline]
203    pub fn send_command_with_osc_paste(
204        &self,
205        command: &str,
206    ) -> Result<(), crate::errors::PTYError> {
207        self.paste_string(command)?;
208        self.send_input(Input::Characters("\n".to_owned()))?;
209
210        Ok(())
211    }
212
213    /// Use OSC Paste codes to send a large amount of text at once to the terminal.
214    ///
215    /// # Errors
216    /// If sending the string fails
217    #[inline]
218    pub fn paste_string(&self, string: &str) -> Result<(), crate::errors::PTYError> {
219        let paste_start = "\x1b[200~";
220        let paste_end = "\x1b[201~";
221        let pastable_string = format!("{paste_start}{string}{paste_end}");
222
223        self.send_input(Input::Event(pastable_string))?;
224
225        Ok(())
226    }
227
228    /// Consume all the new output from the underlying PTY and have Wezterm render it in the shadow
229    /// terminal.
230    ///
231    /// Warning: this function could block if there is no end to the output from the PTY.
232    ///
233    /// # Errors
234    /// If PTY output can't be handled.
235    #[inline]
236    pub async fn render_all_output(&mut self) -> Result<(), crate::errors::PTYError> {
237        loop {
238            let result = self.shadow_terminal.channels.output_rx.try_recv();
239            match result {
240                Ok(bytes) => {
241                    self.shadow_terminal
242                        .accumulated_pty_output
243                        .append(&mut bytes.to_vec());
244
245                    Box::pin(self.shadow_terminal.handle_pty_output())
246                        .await
247                        .with_whatever_context(|err| {
248                            format!("Couldn't handle PTY output: {err:?}")
249                        })?;
250                    tracing::trace!("Wezterm shadow terminal advanced {} bytes", bytes.len());
251                }
252                Err(_) => break,
253            }
254        }
255
256        Ok(())
257    }
258
259    /// Get the position of the top of the screen in the scrollback history.
260    ///
261    /// # Errors
262    /// If it can't convert the position from `isize` to `usize`
263    #[inline]
264    pub fn get_scrollback_position(
265        &mut self,
266    ) -> Result<usize, crate::errors::SteppableTerminalError> {
267        let screen = self.shadow_terminal.terminal.screen();
268        let scrollback_position: usize = screen
269            .phys_to_stable_row_index(0)
270            .try_into()
271            .with_whatever_context(|err| format!("Couldn't scrollback position to usize: {err}"))?;
272
273        Ok(scrollback_position)
274    }
275
276    /// Convert the current Wezterm shadow terminal screen to a plain string.
277    ///
278    /// # Errors
279    /// If it can't write into the output string
280    #[inline]
281    pub fn screen_as_string(&mut self) -> Result<String, crate::errors::SteppableTerminalError> {
282        let size = self.shadow_terminal.terminal.get_size();
283        let mut screen = self.shadow_terminal.terminal.screen().clone();
284        let mut output = String::new();
285
286        for y in 0..size.rows {
287            for x in 0..size.cols {
288                let maybe_cell = screen.get_cell(
289                    x,
290                    y.try_into().with_whatever_context(|err| {
291                        format!("Couldn't convert cell index to i64: {err}")
292                    })?,
293                );
294                if let Some(cell) = maybe_cell {
295                    write!(output, "{}", cell.str())
296                        .with_whatever_context(|_| "Couldn't write screen output")?;
297                }
298            }
299            writeln!(output).with_whatever_context(|_| "Couldn't write screen output")?;
300        }
301
302        Ok(output)
303    }
304
305    /// Return the screen coordinates of a matching cell's contents.
306    ///
307    /// # Errors
308    /// If it can't write into the output string
309    #[inline]
310    pub fn get_coords_of_cell_by_content(&mut self, content: &str) -> Option<(usize, usize)> {
311        let size = self.shadow_terminal.terminal.get_size();
312        let mut screen = self.shadow_terminal.terminal.screen().clone();
313        for y_usize in 0..size.rows {
314            let result = y_usize.try_into();
315
316            #[expect(
317                clippy::unreachable,
318                reason = "I assume that get_size() wouldn't return anything thet get_cell can't consume"
319            )]
320            let Ok(y) = result
321            else {
322                unreachable!()
323            };
324            for x in 0..size.cols {
325                let maybe_cell = screen.get_cell(x, y);
326                if let Some(cell) = maybe_cell {
327                    if cell.str() == content {
328                        return Some((x, y_usize));
329                    }
330                }
331            }
332        }
333
334        None
335    }
336
337    /// Get the [`wezterm_term::Cell`] at the given coordinates.
338    ///
339    /// # Errors
340    /// If the cell at the given coordinates cannot be fetched.
341    #[inline]
342    pub fn get_cell_at(
343        &mut self,
344        x: usize,
345        y: usize,
346    ) -> Result<Option<wezterm_term::Cell>, crate::errors::SteppableTerminalError> {
347        let size = self.shadow_terminal.terminal.get_size();
348        let mut screen = self.shadow_terminal.terminal.screen().clone();
349        let scrollback = self.get_scrollback_position()?;
350        for row in scrollback..size.rows {
351            for col in 0..size.cols {
352                if !(x == col && y == row - scrollback) {
353                    continue;
354                }
355
356                let maybe_cell = screen.get_cell(
357                    col,
358                    row.try_into().with_whatever_context(|err| {
359                        format!("Couldn't convert cell index to i64: {err}")
360                    })?,
361                );
362
363                if let Some(cell) = maybe_cell {
364                    return Ok(Some(cell.clone()));
365                }
366            }
367        }
368
369        Ok(None)
370    }
371
372    /// Get the string, of the given length, at the given coordinates.
373    ///
374    /// # Errors
375    /// If any of the cells at the given coordinates cannot be fetched.
376    #[inline]
377    pub fn get_string_at(
378        &mut self,
379        x: usize,
380        y: usize,
381        length: usize,
382    ) -> Result<String, crate::errors::SteppableTerminalError> {
383        let mut string = String::new();
384        for col in x..(x + length) {
385            let maybe_cell = self.get_cell_at(col, y)?;
386            if let Some(cell) = maybe_cell {
387                string = format!("{string}{}", cell.str());
388            }
389        }
390
391        Ok(string)
392    }
393
394    /// Prints the contents of the current screen to STDERR
395    ///
396    /// # Errors
397    /// If it can't get the screen output.
398    #[expect(clippy::print_stderr, reason = "This is a debugging function")]
399    #[inline]
400    pub fn dump_screen(&mut self) -> Result<(), crate::errors::SteppableTerminalError> {
401        let size = self.shadow_terminal.terminal.get_size();
402        let current_screen = self.screen_as_string()?;
403        eprintln!("Current Tattoy screen ({}x{})", size.cols, size.rows);
404        eprintln!("{current_screen}");
405        Ok(())
406    }
407
408    /// Get the prompt as a string. Useful for reproducibility as prompts can change between
409    /// machines.
410    ///
411    /// # Errors
412    /// * If a steppable terminal can't be created.
413    /// * If the terminal's screen can't be parsed.
414    #[tracing::instrument(name = "get_prompt")]
415    #[inline]
416    pub async fn get_prompt_string(
417        command: Vec<std::ffi::OsString>,
418    ) -> Result<String, crate::errors::SteppableTerminalError> {
419        tracing::info!("Starting `get_prompt` terminal instance...");
420        let config = crate::shadow_terminal::Config {
421            width: 30,
422            height: 10,
423            command,
424            ..crate::shadow_terminal::Config::default()
425        };
426        let mut stepper = Box::pin(Self::start(config)).await?;
427        let mut output = stepper.screen_as_string()?;
428        tracing::info!("Finished `get_prompt` terminal instance.");
429
430        output.retain(|character| !character.is_whitespace());
431        Ok(output)
432    }
433
434    // TODO: Make the timeout configurable.
435    //
436    /// Wait for the screen to change in any way.
437    ///
438    /// # Errors
439    /// * If it can't get the screen contents.
440    /// * If no change is found within a certain time.
441    #[inline]
442    pub async fn wait_for_any_change(
443        &mut self,
444    ) -> Result<(), crate::errors::SteppableTerminalError> {
445        let initial_screen = self.screen_as_string()?;
446        for i in 0..=DEFAULT_TIMEOUT {
447            if i == DEFAULT_TIMEOUT {
448                snafu::whatever!("No change detected in {DEFAULT_TIMEOUT} milliseconds.");
449            }
450            self.render_all_output()
451                .await
452                .with_whatever_context(|err| format!("Couldn't render output: {err:?}"))?;
453            let current_screen = self.screen_as_string()?;
454            if initial_screen != current_screen {
455                break;
456            }
457            tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
458        }
459
460        self.dump_screen()?;
461
462        Ok(())
463    }
464
465    /// Wait for the given string to appear anywhere in the screen.
466    ///
467    /// # Errors
468    /// * If it can't get the screen contents.
469    /// * If no change is found within a certain time.
470    #[inline]
471    pub async fn wait_for_string(
472        &mut self,
473        string: &str,
474        maybe_timeout: Option<u32>,
475    ) -> Result<(), crate::errors::SteppableTerminalError> {
476        let timeout = maybe_timeout.map_or(DEFAULT_TIMEOUT, |ms| ms);
477
478        for i in 0u32..=timeout {
479            self.render_all_output()
480                .await
481                .with_whatever_context(|err| format!("Couldn't render output: {err:?}"))?;
482            let current_screen = self.screen_as_string()?;
483            if current_screen.contains(string) {
484                break;
485            }
486            if i == timeout {
487                self.dump_screen()?;
488                snafu::whatever!("'{string}' not found after {timeout} milliseconds.");
489            }
490            tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
491        }
492
493        Ok(())
494    }
495
496    /// Wait for the given string to appear at the given coordinates.
497    ///
498    /// # Errors
499    /// * If it can't get the screen contents.
500    /// * If no change is found within a certain time.
501    #[inline]
502    pub async fn wait_for_string_at(
503        &mut self,
504        string_to_find: &str,
505        x: usize,
506        y: usize,
507        maybe_timeout: Option<u32>,
508    ) -> Result<(), crate::errors::SteppableTerminalError> {
509        let timeout = maybe_timeout.map_or(DEFAULT_TIMEOUT, |ms| ms);
510
511        for i in 0u32..=timeout {
512            self.render_all_output()
513                .await
514                .with_whatever_context(|err| format!("Couldn't render output: {err:?}"))?;
515            let found_string = self.get_string_at(x, y, string_to_find.chars().count())?;
516            if found_string == string_to_find {
517                break;
518            }
519            if i == timeout {
520                self.dump_screen()?;
521                snafu::whatever!(
522                    "'{string_to_find}' not found at {x}x{y} after {timeout} milliseconds."
523                );
524            }
525            tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
526        }
527
528        Ok(())
529    }
530
531    /// Wait for.the given colout at the given coordinates.
532    #[inline]
533    async fn wait_for_color_at(
534        &mut self,
535        maybe_colour: Option<(f32, f32, f32, f32)>,
536        is_fg_colour: bool,
537        x: usize,
538        y: usize,
539        maybe_timeout: Option<u32>,
540    ) -> Result<(), crate::errors::SteppableTerminalError> {
541        let timeout = maybe_timeout.map_or(DEFAULT_TIMEOUT, |ms| ms);
542        let colour = match maybe_colour {
543            Some(colour) => Self::make_colour_attribute(colour.0, colour.1, colour.2, colour.3),
544            None => termwiz::color::ColorAttribute::Default,
545        };
546
547        for i in 0u32..=timeout {
548            self.render_all_output()
549                .await
550                .with_whatever_context(|err| format!("Couldn't render output: {err:?}"))?;
551            let cell = self.get_cell_at(x, y)?;
552            let attributes = cell
553                .clone()
554                .with_whatever_context(|| format!("Couldn't find cell at: {x}x{y}"))?
555                .attrs()
556                .clone();
557
558            if is_fg_colour && attributes.foreground() == colour {
559                break;
560            }
561            if !is_fg_colour && attributes.background() == colour {
562                break;
563            }
564            if i == timeout {
565                self.dump_screen()?;
566                snafu::whatever!(
567                    "'{colour:?}' not found in cell ({:?}) at {x}x{y} after {timeout} milliseconds.",
568                    cell
569                );
570            }
571            tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
572        }
573
574        Ok(())
575    }
576
577    /// Wait for the given background colour at the given coordinates
578    ///
579    /// # Errors
580    /// * If it can't get the screen contents.
581    /// * If it no cell is found at the coords
582    #[inline]
583    pub async fn wait_for_bg_color_at(
584        &mut self,
585        maybe_colour: Option<(f32, f32, f32, f32)>,
586        x: usize,
587        y: usize,
588        maybe_timeout: Option<u32>,
589    ) -> Result<(), crate::errors::SteppableTerminalError> {
590        self.wait_for_color_at(maybe_colour, false, x, y, maybe_timeout)
591            .await
592    }
593
594    /// Wait for the given foreground colour at the given coordinates
595    ///
596    /// # Errors
597    /// * If it can't get the screen contents.
598    /// * If it no cell is found at the coords
599    #[inline]
600    pub async fn wait_for_fg_color_at(
601        &mut self,
602        maybe_colour: Option<(f32, f32, f32, f32)>,
603        x: usize,
604        y: usize,
605        maybe_timeout: Option<u32>,
606    ) -> Result<(), crate::errors::SteppableTerminalError> {
607        self.wait_for_color_at(maybe_colour, true, x, y, maybe_timeout)
608            .await
609    }
610
611    /// Wait for the given foreground and background colour at the given coordinates
612    ///
613    /// # Errors
614    /// * If it can't get the screen contents.
615    /// * If it no cell is found at the coords
616    #[inline]
617    pub async fn wait_for_colors_at(
618        &mut self,
619        background_colour: Option<(f32, f32, f32, f32)>,
620        foreground_colour: Option<(f32, f32, f32, f32)>,
621        x: usize,
622        y: usize,
623        maybe_timeout: Option<u32>,
624    ) -> Result<(), crate::errors::SteppableTerminalError> {
625        self.wait_for_color_at(foreground_colour, true, x, y, maybe_timeout)
626            .await?;
627        self.wait_for_color_at(background_colour, false, x, y, maybe_timeout)
628            .await?;
629
630        Ok(())
631    }
632
633    /// Get the colour of a cell from its colour attribute.
634    #[inline]
635    #[must_use]
636    pub const fn extract_colour(
637        colour_attribute: termwiz::color::ColorAttribute,
638    ) -> Option<termwiz::color::SrgbaTuple> {
639        match colour_attribute {
640            termwiz::color::ColorAttribute::TrueColorWithPaletteFallback(srgba_tuple, _)
641            | termwiz::color::ColorAttribute::TrueColorWithDefaultFallback(srgba_tuple) => {
642                Some(srgba_tuple)
643            }
644            termwiz::color::ColorAttribute::PaletteIndex(_)
645            | termwiz::color::ColorAttribute::Default => None,
646        }
647    }
648
649    /// Convenience function for making Termwiz colours.
650    const fn make_colour_attribute(
651        red: f32,
652        green: f32,
653        blue: f32,
654        alpha: f32,
655    ) -> termwiz::color::ColorAttribute {
656        termwiz::color::ColorAttribute::TrueColorWithDefaultFallback(termwiz::color::SrgbaTuple(
657            red, green, blue, alpha,
658        ))
659    }
660}
661
662impl Drop for SteppableTerminal {
663    #[inline]
664    fn drop(&mut self) {
665        tracing::trace!("Running SteppableTerminal.drop()");
666        let result = self.kill();
667        if let Err(error) = result {
668            tracing::error!("{error:?}");
669        }
670    }
671}
672
673#[cfg(test)]
674mod test {
675
676    /// Setup logging
677    fn setup_logging() {
678        tracing_subscriber::fmt()
679            .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
680            .without_time()
681            .init();
682    }
683
684    #[cfg(not(target_os = "windows"))]
685    #[tokio::test(flavor = "multi_thread")]
686    async fn basic_interactivity() {
687        let mut stepper = Box::pin(crate::tests::helpers::run(None, None)).await;
688
689        stepper.send_command("nano --version").unwrap();
690        stepper.wait_for_string("GNU nano", None).await.unwrap();
691        let output = stepper.screen_as_string().unwrap();
692        assert!(output.contains("GNU nano, version"));
693    }
694
695    #[cfg(not(target_os = "windows"))]
696    #[tokio::test(flavor = "multi_thread")]
697    async fn resizing() {
698        let mut stepper = Box::pin(crate::tests::helpers::run(None, None)).await;
699        stepper.send_command("nano --restricted").unwrap();
700        stepper.wait_for_string("GNU nano", None).await.unwrap();
701
702        let size = stepper.shadow_terminal.terminal.get_size();
703        let bottom = size.rows - 1;
704        let right = size.cols - 1;
705        let menu_item_paste = stepper.get_string_at(right - 10, bottom, 5).unwrap();
706        assert_eq!(menu_item_paste, "Paste");
707
708        stepper
709            .shadow_terminal
710            .resize(
711                u16::try_from(size.cols + 3).unwrap(),
712                u16::try_from(size.rows + 3).unwrap(),
713            )
714            .unwrap();
715        let resized_size = stepper.shadow_terminal.terminal.get_size();
716        let resized_bottom = resized_size.rows - 1;
717        let resized_right = resized_size.cols - 1;
718        stepper
719            .wait_for_string_at("^X Exit", 0, resized_bottom, Some(1000))
720            .await
721            .unwrap();
722        let resized_menu_item_paste = stepper
723            .get_string_at(resized_right - 10, resized_bottom, 5)
724            .unwrap();
725        assert_eq!(resized_menu_item_paste, "Paste");
726    }
727
728    #[cfg(not(target_os = "windows"))]
729    #[tokio::test(flavor = "multi_thread")]
730    async fn cursor_position_response() {
731        let mut stepper = Box::pin(crate::tests::helpers::run(Some(100), None)).await;
732
733        // TODO: this should work pretty easily with Powershell, it's just a matter of finding the
734        // right commands.
735        let command = "sleep 0.1; echo -en \"\\E[6n\"; read -sdR CURPOS; echo ${CURPOS#*[}";
736
737        stepper.send_command(command).unwrap();
738
739        stepper.wait_for_string("1;0", None).await.unwrap();
740    }
741
742    #[cfg(not(target_os = "windows"))]
743    #[tokio::test(flavor = "multi_thread")]
744    async fn wide_characters() {
745        setup_logging();
746
747        let mut stepper = Box::pin(crate::tests::helpers::run(Some(100), None)).await;
748        let columns = stepper.shadow_terminal.terminal.get_size().cols;
749        let full_row = "😀".repeat(columns.div_euclid(2));
750
751        let command = format!("echo {full_row}");
752        stepper.send_command(command.as_str()).unwrap();
753
754        let raw_with_spaces = full_row
755            .chars()
756            .map(|character| character.to_string())
757            .collect::<Vec<String>>()
758            .join(" ");
759
760        stepper
761            .wait_for_string(&raw_with_spaces, None)
762            .await
763            .unwrap();
764    }
765}