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.paste_string(command)?;
184        self.send_input(Input::Characters("\n".to_owned()))?;
185
186        Ok(())
187    }
188
189    /// Use OSC Paste codes to send a large amount of text at once to the terminal.
190    ///
191    /// # Errors
192    /// If sending the string fails
193    #[inline]
194    pub fn paste_string(&self, string: &str) -> Result<(), crate::errors::PTYError> {
195        let paste_start = "\x1b[200~";
196        let paste_end = "\x1b[201~";
197        let pastable_string = format!("{paste_start}{string}{paste_end}");
198
199        self.send_input(Input::Event(pastable_string))?;
200
201        Ok(())
202    }
203
204    /// Consume all the new output from the underlying PTY and have Wezterm render it in the shadow
205    /// terminal.
206    ///
207    /// Warning: this function could block if there is no end to the output from the PTY.
208    ///
209    /// # Errors
210    /// If PTY output can't be handled.
211    #[inline]
212    pub async fn render_all_output(&mut self) -> Result<(), crate::errors::PTYError> {
213        loop {
214            let result = self.shadow_terminal.channels.output_rx.try_recv();
215            match result {
216                Ok(bytes) => {
217                    self.shadow_terminal
218                        .accumulated_pty_output
219                        .append(&mut bytes.to_vec());
220
221                    Box::pin(self.shadow_terminal.handle_pty_output())
222                        .await
223                        .with_whatever_context(|err| {
224                            format!("Couldn't handle PTY output: {err:?}")
225                        })?;
226                    tracing::trace!("Wezterm shadow terminal advanced {} bytes", bytes.len());
227                }
228                Err(_) => break,
229            }
230        }
231
232        Ok(())
233    }
234
235    /// Get the position of the top of the screen in the scrollback history.
236    ///
237    /// # Errors
238    /// If it can't convert the position from `isize` to `usize`
239    #[inline]
240    pub fn get_scrollback_position(
241        &mut self,
242    ) -> Result<usize, crate::errors::SteppableTerminalError> {
243        let screen = self.shadow_terminal.terminal.screen();
244        let scrollback_position: usize = screen
245            .phys_to_stable_row_index(0)
246            .try_into()
247            .with_whatever_context(|err| format!("Couldn't scrollback position to usize: {err}"))?;
248
249        Ok(scrollback_position)
250    }
251
252    /// Convert the current Wezterm shadow terminal screen to a plain string.
253    ///
254    /// # Errors
255    /// If it can't write into the output string
256    #[inline]
257    pub fn screen_as_string(&mut self) -> Result<String, crate::errors::SteppableTerminalError> {
258        let size = self.shadow_terminal.terminal.get_size();
259        let mut screen = self.shadow_terminal.terminal.screen().clone();
260        let mut output = String::new();
261
262        for y in 0..size.rows {
263            for x in 0..size.cols {
264                let maybe_cell = screen.get_cell(
265                    x,
266                    y.try_into().with_whatever_context(|err| {
267                        format!("Couldn't convert cell index to i64: {err}")
268                    })?,
269                );
270                if let Some(cell) = maybe_cell {
271                    write!(output, "{}", cell.str())
272                        .with_whatever_context(|_| "Couldn't write screen output")?;
273                }
274            }
275            writeln!(output).with_whatever_context(|_| "Couldn't write screen output")?;
276        }
277
278        Ok(output)
279    }
280
281    /// Return the screen coordinates of a matching cell's contents.
282    ///
283    /// # Errors
284    /// If it can't write into the output string
285    #[inline]
286    pub fn get_coords_of_cell_by_content(&mut self, content: &str) -> Option<(usize, usize)> {
287        let size = self.shadow_terminal.terminal.get_size();
288        let mut screen = self.shadow_terminal.terminal.screen().clone();
289        for y_usize in 0..size.rows {
290            let result = y_usize.try_into();
291
292            #[expect(
293                clippy::unreachable,
294                reason = "I assume that get_size() wouldn't return anything thet get_cell can't consume"
295            )]
296            let Ok(y) = result
297            else {
298                unreachable!()
299            };
300            for x in 0..size.cols {
301                let maybe_cell = screen.get_cell(x, y);
302                if let Some(cell) = maybe_cell {
303                    if cell.str() == content {
304                        return Some((x, y_usize));
305                    }
306                }
307            }
308        }
309
310        None
311    }
312
313    /// Get the [`wezterm_term::Cell`] at the given coordinates.
314    ///
315    /// # Errors
316    /// If the cell at the given coordinates cannot be fetched.
317    #[inline]
318    pub fn get_cell_at(
319        &mut self,
320        x: usize,
321        y: usize,
322    ) -> Result<Option<wezterm_term::Cell>, crate::errors::SteppableTerminalError> {
323        let size = self.shadow_terminal.terminal.get_size();
324        let mut screen = self.shadow_terminal.terminal.screen().clone();
325        let scrollback = self.get_scrollback_position()?;
326        for row in scrollback..size.rows {
327            for col in 0..size.cols {
328                if !(x == col && y == row - scrollback) {
329                    continue;
330                }
331
332                let maybe_cell = screen.get_cell(
333                    col,
334                    row.try_into().with_whatever_context(|err| {
335                        format!("Couldn't convert cell index to i64: {err}")
336                    })?,
337                );
338
339                if let Some(cell) = maybe_cell {
340                    return Ok(Some(cell.clone()));
341                }
342            }
343        }
344
345        Ok(None)
346    }
347
348    /// Get the string, of the given length, at the given coordinates.
349    ///
350    /// # Errors
351    /// If any of the cells at the given coordinates cannot be fetched.
352    #[inline]
353    pub fn get_string_at(
354        &mut self,
355        x: usize,
356        y: usize,
357        length: usize,
358    ) -> Result<String, crate::errors::SteppableTerminalError> {
359        let mut string = String::new();
360        for col in x..(x + length) {
361            let maybe_cell = self.get_cell_at(col, y)?;
362            if let Some(cell) = maybe_cell {
363                string = format!("{string}{}", cell.str());
364            }
365        }
366
367        Ok(string)
368    }
369
370    /// Prints the contents of the current screen to STDERR
371    ///
372    /// # Errors
373    /// If it can't get the screen output.
374    #[expect(clippy::print_stderr, reason = "This is a debugging function")]
375    #[inline]
376    pub fn dump_screen(&mut self) -> Result<(), crate::errors::SteppableTerminalError> {
377        let size = self.shadow_terminal.terminal.get_size();
378        let current_screen = self.screen_as_string()?;
379        eprintln!("Current Tattoy screen ({}x{})", size.cols, size.rows);
380        eprintln!("{current_screen}");
381        Ok(())
382    }
383
384    /// Get the prompt as a string. Useful for reproducibility as prompts can change between
385    /// machines.
386    ///
387    /// # Errors
388    /// * If a steppable terminal can't be created.
389    /// * If the terminal's screen can't be parsed.
390    #[tracing::instrument(name = "get_prompt")]
391    #[inline]
392    pub async fn get_prompt_string(
393        command: Vec<std::ffi::OsString>,
394    ) -> Result<String, crate::errors::SteppableTerminalError> {
395        tracing::info!("Starting `get_prompt` terminal instance...");
396        let config = crate::shadow_terminal::Config {
397            width: 30,
398            height: 10,
399            command,
400            ..crate::shadow_terminal::Config::default()
401        };
402        let mut stepper = Box::pin(Self::start(config)).await?;
403        let mut output = stepper.screen_as_string()?;
404        tracing::info!("Finished `get_prompt` terminal instance.");
405
406        output.retain(|character| !character.is_whitespace());
407        Ok(output)
408    }
409
410    // TODO: Make the timeout configurable.
411    //
412    /// Wait for the screen to change in any way.
413    ///
414    /// # Errors
415    /// * If it can't get the screen contents.
416    /// * If no change is found within a certain time.
417    #[inline]
418    pub async fn wait_for_any_change(
419        &mut self,
420    ) -> Result<(), crate::errors::SteppableTerminalError> {
421        let initial_screen = self.screen_as_string()?;
422        for i in 0..=DEFAULT_TIMEOUT {
423            if i == DEFAULT_TIMEOUT {
424                snafu::whatever!("No change detected in {DEFAULT_TIMEOUT} milliseconds.");
425            }
426            self.render_all_output()
427                .await
428                .with_whatever_context(|err| format!("Couldn't render output: {err:?}"))?;
429            let current_screen = self.screen_as_string()?;
430            if initial_screen != current_screen {
431                break;
432            }
433            tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
434        }
435
436        Ok(())
437    }
438
439    /// Wait for the given string to appear anywhere in the screen.
440    ///
441    /// # Errors
442    /// * If it can't get the screen contents.
443    /// * If no change is found within a certain time.
444    #[inline]
445    pub async fn wait_for_string(
446        &mut self,
447        string: &str,
448        maybe_timeout: Option<u32>,
449    ) -> Result<(), crate::errors::SteppableTerminalError> {
450        let timeout = maybe_timeout.map_or(DEFAULT_TIMEOUT, |ms| ms);
451
452        for i in 0u32..=timeout {
453            self.render_all_output()
454                .await
455                .with_whatever_context(|err| format!("Couldn't render output: {err:?}"))?;
456            let current_screen = self.screen_as_string()?;
457            if current_screen.contains(string) {
458                break;
459            }
460            if i == timeout {
461                self.dump_screen()?;
462                snafu::whatever!("'{string}' not found after {timeout} milliseconds.");
463            }
464            tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
465        }
466
467        Ok(())
468    }
469
470    /// Wait for the given string to appear at the given coordinates.
471    ///
472    /// # Errors
473    /// * If it can't get the screen contents.
474    /// * If no change is found within a certain time.
475    #[inline]
476    pub async fn wait_for_string_at(
477        &mut self,
478        string_to_find: &str,
479        x: usize,
480        y: usize,
481        maybe_timeout: Option<u32>,
482    ) -> Result<(), crate::errors::SteppableTerminalError> {
483        let timeout = maybe_timeout.map_or(DEFAULT_TIMEOUT, |ms| ms);
484
485        for i in 0u32..=timeout {
486            self.render_all_output()
487                .await
488                .with_whatever_context(|err| format!("Couldn't render output: {err:?}"))?;
489            let found_string = self.get_string_at(x, y, string_to_find.chars().count())?;
490            if found_string == string_to_find {
491                break;
492            }
493            if i == timeout {
494                self.dump_screen()?;
495                snafu::whatever!(
496                    "'{string_to_find}' not found at {x}x{y} after {timeout} milliseconds."
497                );
498            }
499            tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
500        }
501
502        Ok(())
503    }
504
505    /// Wait for.the given colout at the given coordinates.
506    #[inline]
507    async fn wait_for_color_at(
508        &mut self,
509        maybe_colour: Option<(f32, f32, f32, f32)>,
510        is_fg_colour: bool,
511        x: usize,
512        y: usize,
513        maybe_timeout: Option<u32>,
514    ) -> Result<(), crate::errors::SteppableTerminalError> {
515        let timeout = maybe_timeout.map_or(DEFAULT_TIMEOUT, |ms| ms);
516        let colour = match maybe_colour {
517            Some(colour) => Self::make_colour_attribute(colour.0, colour.1, colour.2, colour.3),
518            None => termwiz::color::ColorAttribute::Default,
519        };
520
521        for i in 0u32..=timeout {
522            self.render_all_output()
523                .await
524                .with_whatever_context(|err| format!("Couldn't render output: {err:?}"))?;
525            let cell = self.get_cell_at(x, y)?;
526            let attributes = cell
527                .clone()
528                .with_whatever_context(|| format!("Couldn't find cell at: {x}x{y}"))?
529                .attrs()
530                .clone();
531
532            if is_fg_colour && attributes.foreground() == colour {
533                break;
534            }
535            if !is_fg_colour && attributes.background() == colour {
536                break;
537            }
538            if i == timeout {
539                self.dump_screen()?;
540                snafu::whatever!(
541                    "'{colour:?}' not found in cell ({:?}) at {x}x{y} after {timeout} milliseconds.",
542                    cell
543                );
544            }
545            tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
546        }
547
548        Ok(())
549    }
550
551    /// Wait for the given background colour at the given coordinates
552    ///
553    /// # Errors
554    /// * If it can't get the screen contents.
555    /// * If it no cell is found at the coords
556    #[inline]
557    pub async fn wait_for_bg_color_at(
558        &mut self,
559        maybe_colour: Option<(f32, f32, f32, f32)>,
560        x: usize,
561        y: usize,
562        maybe_timeout: Option<u32>,
563    ) -> Result<(), crate::errors::SteppableTerminalError> {
564        self.wait_for_color_at(maybe_colour, false, x, y, maybe_timeout)
565            .await
566    }
567
568    /// Wait for the given foreground colour at the given coordinates
569    ///
570    /// # Errors
571    /// * If it can't get the screen contents.
572    /// * If it no cell is found at the coords
573    #[inline]
574    pub async fn wait_for_fg_color_at(
575        &mut self,
576        maybe_colour: Option<(f32, f32, f32, f32)>,
577        x: usize,
578        y: usize,
579        maybe_timeout: Option<u32>,
580    ) -> Result<(), crate::errors::SteppableTerminalError> {
581        self.wait_for_color_at(maybe_colour, true, x, y, maybe_timeout)
582            .await
583    }
584
585    /// Wait for the given foreground and background colour at the given coordinates
586    ///
587    /// # Errors
588    /// * If it can't get the screen contents.
589    /// * If it no cell is found at the coords
590    #[inline]
591    pub async fn wait_for_colors_at(
592        &mut self,
593        background_colour: Option<(f32, f32, f32, f32)>,
594        foreground_colour: Option<(f32, f32, f32, f32)>,
595        x: usize,
596        y: usize,
597        maybe_timeout: Option<u32>,
598    ) -> Result<(), crate::errors::SteppableTerminalError> {
599        self.wait_for_color_at(foreground_colour, true, x, y, maybe_timeout)
600            .await?;
601        self.wait_for_color_at(background_colour, false, x, y, maybe_timeout)
602            .await?;
603
604        Ok(())
605    }
606
607    /// Get the colour of a cell from its colour attribute.
608    #[inline]
609    #[must_use]
610    pub const fn extract_colour(
611        colour_attribute: termwiz::color::ColorAttribute,
612    ) -> Option<termwiz::color::SrgbaTuple> {
613        match colour_attribute {
614            termwiz::color::ColorAttribute::TrueColorWithPaletteFallback(srgba_tuple, _)
615            | termwiz::color::ColorAttribute::TrueColorWithDefaultFallback(srgba_tuple) => {
616                Some(srgba_tuple)
617            }
618            termwiz::color::ColorAttribute::PaletteIndex(_)
619            | termwiz::color::ColorAttribute::Default => None,
620        }
621    }
622
623    /// Convenience function for making Termwiz colours.
624    const fn make_colour_attribute(
625        red: f32,
626        green: f32,
627        blue: f32,
628        alpha: f32,
629    ) -> termwiz::color::ColorAttribute {
630        termwiz::color::ColorAttribute::TrueColorWithDefaultFallback(termwiz::color::SrgbaTuple(
631            red, green, blue, alpha,
632        ))
633    }
634}
635
636impl Drop for SteppableTerminal {
637    #[inline]
638    fn drop(&mut self) {
639        tracing::trace!("Running SteppableTerminal.drop()");
640        let result = self.kill();
641        if let Err(error) = result {
642            tracing::error!("{error:?}");
643        }
644    }
645}
646
647#[cfg(test)]
648mod test {
649
650    /// Setup logging
651    fn setup_logging() {
652        tracing_subscriber::fmt()
653            .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
654            .without_time()
655            .init();
656    }
657
658    #[cfg(not(target_os = "windows"))]
659    #[tokio::test(flavor = "multi_thread")]
660    async fn basic_interactivity() {
661        let mut stepper = Box::pin(crate::tests::helpers::run(None, None)).await;
662
663        stepper.send_command("nano --version").unwrap();
664        stepper.wait_for_string("GNU nano", None).await.unwrap();
665        let output = stepper.screen_as_string().unwrap();
666        assert!(output.contains("GNU nano, version"));
667    }
668
669    #[cfg(not(target_os = "windows"))]
670    #[tokio::test(flavor = "multi_thread")]
671    async fn resizing() {
672        let mut stepper = Box::pin(crate::tests::helpers::run(None, None)).await;
673        stepper.send_command("nano --restricted").unwrap();
674        stepper.wait_for_string("GNU nano", None).await.unwrap();
675
676        let size = stepper.shadow_terminal.terminal.get_size();
677        let bottom = size.rows - 1;
678        let right = size.cols - 1;
679        let menu_item_paste = stepper.get_string_at(right - 10, bottom, 5).unwrap();
680        assert_eq!(menu_item_paste, "Paste");
681
682        stepper
683            .shadow_terminal
684            .resize(
685                u16::try_from(size.cols + 3).unwrap(),
686                u16::try_from(size.rows + 3).unwrap(),
687            )
688            .unwrap();
689        let resized_size = stepper.shadow_terminal.terminal.get_size();
690        let resized_bottom = resized_size.rows - 1;
691        let resized_right = resized_size.cols - 1;
692        stepper
693            .wait_for_string_at("^X Exit", 0, resized_bottom, Some(1000))
694            .await
695            .unwrap();
696        let resized_menu_item_paste = stepper
697            .get_string_at(resized_right - 10, resized_bottom, 5)
698            .unwrap();
699        assert_eq!(resized_menu_item_paste, "Paste");
700    }
701
702    #[cfg(not(target_os = "windows"))]
703    #[tokio::test(flavor = "multi_thread")]
704    async fn cursor_position_response() {
705        let mut stepper = Box::pin(crate::tests::helpers::run(Some(100), None)).await;
706
707        // TODO: this should work pretty easily with Powershell, it's just a matter of finding the
708        // right commands.
709        let command = "sleep 0.1; echo -en \"\\E[6n\"; read -sdR CURPOS; echo ${CURPOS#*[}";
710
711        stepper.send_command(command).unwrap();
712
713        stepper.wait_for_string("1;0", None).await.unwrap();
714    }
715
716    #[cfg(not(target_os = "windows"))]
717    #[tokio::test(flavor = "multi_thread")]
718    async fn wide_characters() {
719        setup_logging();
720
721        let mut stepper = Box::pin(crate::tests::helpers::run(Some(100), None)).await;
722        let columns = stepper.shadow_terminal.terminal.get_size().cols;
723        let full_row = "😀".repeat(columns.div_euclid(2));
724
725        let command = format!("echo {full_row}");
726        stepper.send_command(command.as_str()).unwrap();
727
728        let raw_with_spaces = full_row
729            .chars()
730            .map(|character| character.to_string())
731            .collect::<Vec<String>>()
732            .join(" ");
733
734        stepper
735            .wait_for_string(&raw_with_spaces, None)
736            .await
737            .unwrap();
738    }
739}