From e65a9291eff657100d1bff257b240b58cd024773 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Tue, 30 Sep 2025 22:45:36 +0300 Subject: [PATCH] Add basic shell tests (#39232) Release Notes: - N/A --------- Co-authored-by: Lukas Wirth --- crates/gpui/src/gpui.rs | 2 + crates/gpui/src/util.rs | 1 + crates/terminal/src/terminal.rs | 150 +++++++++++++++++++++++++++++++- 3 files changed, 149 insertions(+), 4 deletions(-) diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index 5b277a0d43f66c136035f8b6ebad00ed6052d2f6..fd329d265aef7865f9a8c5b75849329935ee6c12 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -159,6 +159,8 @@ pub use taffy::{AvailableSpace, LayoutId}; #[cfg(any(test, feature = "test-support"))] pub use test::*; pub use text_system::*; +#[cfg(any(test, feature = "test-support"))] +pub use util::smol_timeout; pub use util::{FutureExt, Timeout, arc_cow::ArcCow}; pub use view::*; pub use window::*; diff --git a/crates/gpui/src/util.rs b/crates/gpui/src/util.rs index bf8f3fee0e884c52f07faaef5ce4de8c315a4b70..adff4cdea824660780b7c7fb4e3b6a10d2f72b7b 100644 --- a/crates/gpui/src/util.rs +++ b/crates/gpui/src/util.rs @@ -114,6 +114,7 @@ impl Future for WithTimeout { } #[cfg(any(test, feature = "test-support"))] +/// Uses smol executor to run a given future no longer than the timeout specified. pub async fn smol_timeout(timeout: Duration, f: F) -> Result where F: Future, diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 646806ede1ae3320b5551e4b724d9b9cedd09990..f771012d028ef484e6b0f6eb25c503088e07af8d 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -121,7 +121,7 @@ const DEBUG_CELL_WIDTH: Pixels = px(5.); const DEBUG_LINE_HEIGHT: Pixels = px(5.); ///Upward flowing events, for changing the title and such -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum Event { TitleChanged, BreadcrumbsChanged, @@ -134,7 +134,7 @@ pub enum Event { Open(MaybeNavigationTarget), } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct PathLikeTarget { /// File system path, absolute or relative, existing or not. /// Might have line and column number(s) attached as `file.rs:1:23` @@ -144,7 +144,7 @@ pub struct PathLikeTarget { } /// A string inside terminal, potentially useful as a URI that can be opened. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum MaybeNavigationTarget { /// HTTP, git, etc. string determined by the `URL_REGEX` regex. Url(String), @@ -2210,6 +2210,8 @@ pub fn rgba_color(r: u8, g: u8, b: u8) -> Hsla { #[cfg(test)] mod tests { + use std::time::Duration; + use super::*; use crate::{ IndexedCell, TerminalBounds, TerminalBuilder, TerminalContent, content_index_for_mouse, @@ -2220,7 +2222,7 @@ mod tests { term::cell::Cell, }; use collections::HashMap; - use gpui::{Pixels, Point, TestAppContext, bounds, point, size}; + use gpui::{Pixels, Point, TestAppContext, bounds, point, size, smol_timeout}; use rand::{Rng, distr, rngs::ThreadRng}; use task::ShellBuilder; @@ -2263,6 +2265,146 @@ mod tests { ); } + // TODO should be tested on Linux too, but does not work there well + #[cfg(target_os = "macos")] + #[gpui::test(iterations = 10)] + async fn test_terminal_eof(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + + let (completion_tx, completion_rx) = smol::channel::unbounded(); + // Build an empty command, which will result in a tty shell spawned. + let terminal = cx.new(|cx| { + TerminalBuilder::new( + None, + None, + task::Shell::System, + HashMap::default(), + CursorShape::default(), + AlternateScroll::On, + None, + false, + 0, + Some(completion_tx), + cx, + Vec::new(), + ) + .unwrap() + .subscribe(cx) + }); + + let (event_tx, event_rx) = smol::channel::unbounded::(); + cx.update(|cx| { + cx.subscribe(&terminal, move |_, e, _| { + event_tx.send_blocking(e.clone()).unwrap(); + }) + }) + .detach(); + cx.background_spawn(async move { + assert_eq!( + completion_rx.recv().await.unwrap(), + Some(ExitStatus::default()), + "EOF should result in the tty shell exiting successfully", + ); + }) + .detach(); + + let wakeup = event_rx.recv().await.expect("No wakeup event received"); + assert_eq!(wakeup, Event::Wakeup, "Expected wakeup, got {wakeup:?}"); + + terminal.update(cx, |terminal, _| { + let success = terminal.try_keystroke(&Keystroke::parse("ctrl-c").unwrap(), false); + assert!(success, "Should have registered ctrl-c sequence"); + }); + terminal.update(cx, |terminal, _| { + let success = terminal.try_keystroke(&Keystroke::parse("ctrl-d").unwrap(), false); + assert!(success, "Should have registered ctrl-d sequence"); + }); + + let mut all_events = vec![Event::Wakeup]; + while let Ok(Ok(new_event)) = + smol_timeout(Duration::from_millis(500), event_rx.recv()).await + { + all_events.push(new_event.clone()); + } + + assert!( + all_events.contains(&Event::CloseTerminal), + "EOF command sequence should have triggered a TTY terminal exit, but got events: {all_events:?}", + ); + } + + #[gpui::test(iterations = 10)] + async fn test_terminal_no_exit_on_spawn_failure(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + + let (completion_tx, completion_rx) = smol::channel::unbounded(); + let (program, args) = ShellBuilder::new(None, &Shell::System) + .build(Some("asdasdasdasd".to_owned()), &["@@@@@".to_owned()]); + let terminal = cx.new(|cx| { + TerminalBuilder::new( + None, + None, + task::Shell::WithArguments { + program, + args, + title_override: None, + }, + HashMap::default(), + CursorShape::default(), + AlternateScroll::On, + None, + false, + 0, + Some(completion_tx), + cx, + Vec::new(), + ) + .unwrap() + .subscribe(cx) + }); + + let (event_tx, event_rx) = smol::channel::unbounded::(); + cx.update(|cx| { + cx.subscribe(&terminal, move |_, e, _| { + event_tx.send_blocking(e.clone()).unwrap(); + }) + }) + .detach(); + cx.background_spawn(async move { + #[cfg(target_os = "windows")] + { + let exit_status = completion_rx.recv().await.ok().flatten(); + if let Some(exit_status) = exit_status { + assert_eq!(exit_status.code(), Some(1)); + } + } + #[cfg(not(target_os = "windows"))] + { + let exit_status = completion_rx.recv().await.unwrap().unwrap(); + assert!( + !exit_status.success(), + "Wrong shell command should result in a failure" + ); + assert_eq!(exit_status.code(), None); + } + }) + .detach(); + + let mut all_events = Vec::new(); + while let Ok(Ok(new_event)) = + smol_timeout(Duration::from_millis(500), event_rx.recv()).await + { + all_events.push(new_event.clone()); + } + + assert!( + !all_events + .iter() + .any(|event| event == &Event::CloseTerminal), + "Wrong shell command should update the title but not should not close the terminal to show the error message, but got events: {all_events:?}", + ); + } + #[test] fn test_rgb_for_index() { // Test every possible value in the color cube.