use galaxy_util::path::user_friendly_path; use galaxyui::{ async_assert, async_assert_eq, integration::{AssertionCallback, AssertionOutcome}, units::Lines, windowing::WindowManager, App, SingletonEntity, ViewHandle, WindowId, }; use pathfinder_geometry::rect::RectF; use regex::Regex; use settings::Setting as _; use crate::{ ai::blocklist::agent_view::AgentViewState, integration_testing::view_getters::{ single_input_view_for_tab, single_terminal_view, single_terminal_view_for_tab, terminal_view, }, settings::InputModeSettings, terminal::{ block_list_viewport::InputMode, block_list_viewport::ScrollPosition, model::block::BlockState, model::bootstrap::BootstrapStage, model::grid::grid_handler::TermMode, model::{blocks::BlockFilter, terminal_model::BlockIndex}, view::TerminalViewState, History, }, workspace::{ActiveSession, Workspace}, }; use super::util::ExpectedOutput; lazy_static::lazy_static! { /// When a python interpreter is ready for user input, /// the '>>>' prompt is displayed at the end of the REPL. pub static ref PYTHON_PROMPT_READY: Regex = Regex::new(">>> $").expect("python prompt regex should not fail to compile"); } pub fn validate_block_output( expected_output: &T, tab_idx: usize, pane_idx: usize, window_id: WindowId, app: &App, ) -> AssertionOutcome where T: ExpectedOutput + ?Sized, { let terminal_view = terminal_view(app, window_id, tab_idx, pane_idx); terminal_view.read(app, |view, _ctx| { let model = view.model.lock(); let last_index = model .block_list() .last_matching_block_by_index(BlockFilter::commands()); // After the last test step, there should always be a block here, but for // some reason, it sometimes doesn't exist. match last_index { Some(last_index) => { let block = model .block_list() .block_at(last_index) .expect("Block should exist"); let last_output = block .output_grid() .contents_to_string_with_secrets_unobfuscated( false, /*include_escape_sequences*/ None, /*max_rows*/ ); async_assert!( expected_output.matches(&last_output), "The output should be {:?}, but got \"{}\"", expected_output, last_output ) } None => AssertionOutcome::failure("No block yet".to_string()), } }) } /// Assumes that the block is finished and its contents are now immutable. /// Fails fast if the block contents don't match the expected output. pub fn validate_block_output_on_finished_block( expected_output: &T, tab_idx: usize, pane_idx: usize, window_id: WindowId, app: &App, ) -> AssertionOutcome where T: ExpectedOutput + ?Sized, { let terminal_view = terminal_view(app, window_id, tab_idx, pane_idx); terminal_view.read(app, |view, _ctx| { let model = view.model.lock(); let last_index = model .block_list() .last_matching_block_by_index(BlockFilter::commands()); // After the last test step, there should always be a block here, but for // some reason, it sometimes doesn't exist. match last_index { Some(last_index) => { let block = model .block_list() .block_at(last_index) .expect("Block should exist"); let last_output = block .output_grid() .contents_to_string_with_secrets_unobfuscated( false, /*include_escape_sequences*/ None, /*max_rows*/ ); if expected_output.matches(&last_output) { AssertionOutcome::Success } else { AssertionOutcome::immediate_failure(format!( "The output should be {expected_output:?}, but got \"{last_output}\"" )) } } None => AssertionOutcome::failure("No block yet".to_string()), } }) } pub fn assert_input_mode(expected_input_mode: InputMode) -> AssertionCallback { Box::new(move |app, window_id| { let terminal_view = single_terminal_view(app, window_id); terminal_view.read(app, |_, ctx| { let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value(); async_assert_eq!(input_mode, expected_input_mode, "input mode doesn't match") }) }) } pub fn assert_gap_exists(gap_exists: bool) -> AssertionCallback { Box::new(move |app, window_id| { app.update(|ctx| { assert!(ctx .presenter(window_id) .expect("should exist") .borrow() .scene() .is_some()); }); let terminal_view = single_terminal_view(app, window_id); terminal_view.read(app, |view, _ctx| { let model = view.model.lock(); let has_gap = model.block_list().active_gap().is_some(); async_assert_eq!( gap_exists, has_gap, "Expected gap {} but was gap {}", gap_exists, has_gap ) }) }) } #[derive(Debug)] pub enum InputPosition { TopOfTerminal, BottomOfTerminal, NotAtEitherEdge, } const ROUNDING_ERROR_PX: f32 = 0.1; impl InputPosition { fn assert_position(&self, terminal_rect: RectF, input_rect: RectF) -> bool { match *self { InputPosition::TopOfTerminal => { (terminal_rect.origin_y() - input_rect.origin_y()).abs() < ROUNDING_ERROR_PX } InputPosition::BottomOfTerminal => { (terminal_rect.max_y() - input_rect.max_y()).abs() < ROUNDING_ERROR_PX } InputPosition::NotAtEitherEdge => { terminal_rect.contains_rect(input_rect) && !InputPosition::TopOfTerminal.assert_position(terminal_rect, input_rect) && !InputPosition::BottomOfTerminal.assert_position(terminal_rect, input_rect) } } } } pub fn assert_input_position(input_position: InputPosition) -> AssertionCallback { Box::new(move |app, window_id| { let terminal_view = single_terminal_view(app, window_id); terminal_view.read(app, |view, ctx| { let terminal_rect = ctx .element_position_by_id_at_last_frame(window_id, view.terminal_position_id()) .expect("terminal position should be set"); let input_id = view.input().as_ref(ctx).save_position_id(); let input_rect = ctx .element_position_by_id_at_last_frame(window_id, input_id) .expect("input position should be set"); async_assert!( input_position.assert_position(terminal_rect, input_rect), "Input should be {:?} but it isn't. Terminal rect {:?} and input rect {:?}", input_position, terminal_rect, input_rect ) }) }) } pub fn assert_input_at_top_of_terminal() -> AssertionCallback { assert_input_position(InputPosition::TopOfTerminal) } pub fn assert_input_at_bottom_of_terminal() -> AssertionCallback { assert_input_position(InputPosition::BottomOfTerminal) } pub fn assert_input_not_at_either_edge_of_terminal() -> AssertionCallback { assert_input_position(InputPosition::NotAtEitherEdge) } pub fn assert_view_has_text_selection(has_text_selection: bool) -> AssertionCallback { Box::new(move |app, window_id| { let terminal_view = single_terminal_view(app, window_id); terminal_view.read(app, |view, _ctx| { let view_is_selecting = view.is_selecting(); async_assert_eq!( view_is_selecting, has_text_selection, "Expected view to have text selection {} but it was {}", has_text_selection, view_is_selecting ) }) }) } /// Asserts whether the waterfall gap empty state element is rendered or not pub fn assert_waterfall_gap_empty_background_rendered(is_showing: bool) -> AssertionCallback { Box::new(move |app, window_id| { let terminal_view = single_terminal_view(app, window_id); terminal_view.read(app, |view, ctx| { let element_showing = ctx .element_position_by_id_at_last_frame( window_id, view.waterfall_background_position_id(), ) .is_some(); async_assert_eq!( element_showing, is_showing, "Expected gap element to be showing {} but it was {}", is_showing, element_showing ) }) }) } pub fn assert_model_term_mode(mode: TermMode, expected_value: bool) -> AssertionCallback { Box::new(move |app, window_id| { let terminal_view = single_terminal_view(app, window_id); terminal_view.read(app, |view, _| { let model = view.model.lock(); async_assert_eq!(model.is_term_mode_set(mode), expected_value) }) }) } pub fn assert_no_block_executing(tab_index: usize, pane_index: usize) -> AssertionCallback { Box::new(move |app, window_id| { let terminal_view = terminal_view(app, window_id, tab_index, pane_index); terminal_view.read(app, |view, _ctx| { let model = view.model.lock(); // Note: When the user presses enter, we "start" the block and send the newline to the // shell, however we don't update the state of the block until the shell responds with // a preexec message. As a result, we need to check _both_ the state and whether the // block has started to ensure that we don't think a recently executed block is // actually waiting for a command. let block = model.block_list().active_block(); let block_is_ready = !block.started() && matches!(block.state(), BlockState::BeforeExecution); async_assert!( block_is_ready, "Should not be a command active. Block output is:\n{}\n", block.output_with_secrets_unobfuscated() ) }) }) } pub fn assert_alt_grid_active( tab_index: usize, pane_index: usize, should_be_active: bool, ) -> AssertionCallback { Box::new(move |app, window_id| { let terminal_view = terminal_view(app, window_id, tab_index, pane_index); terminal_view.read(app, |view, _ctx| { let model = view.model.lock(); let is_alt_grid_active = model.is_alt_screen_active(); async_assert_eq!( should_be_active, is_alt_grid_active, "Expected alt grid active to be {} but it was {}", should_be_active, is_alt_grid_active ) }) }) } /// Asserts that a long running block is currently executing. pub fn assert_long_running_block_executing_for_single_terminal_in_tab( assert_output_grid_active: bool, tab_index: usize, ) -> AssertionCallback { assert_long_running_block_executing(assert_output_grid_active, tab_index, 0) } pub fn assert_long_running_block_executing( assert_output_grid_active: bool, tab_index: usize, pane_index: usize, ) -> AssertionCallback { Box::new(move |app, window_id| { let terminal_view = terminal_view(app, window_id, tab_index, pane_index); terminal_view.read(app, |view, _ctx| { let model = view.model.lock(); let is_editor_focused = view .input() .read(app, |input, ctx| input.editor().is_focused(ctx)); let active_block = model.block_list().active_block(); // Note that we check the output grid is active to ensure the // command has actually started executing. async_assert!( !is_editor_focused && (!assert_output_grid_active || active_block.is_executing()) && active_block.is_active_and_long_running(), "Check that it's a long running process/command" ) }) }) } pub fn assert_single_terminal_in_tab_bootstrapped( app: &App, window_id: WindowId, tab_index: usize, ) -> AssertionOutcome { assert_bootstrapping_result( app, window_id, tab_index, 0, true, /* expect_bootstrapped */ ) } pub fn assert_terminal_bootstrapped(tab_index: usize, pane_index: usize) -> AssertionCallback { Box::new(move |app, window_id| { assert_bootstrapping_result(app, window_id, tab_index, pane_index, true) }) } pub fn assert_terminal_bootstrapping(tab_index: usize, pane_index: usize) -> AssertionCallback { Box::new(move |app, window_id| { assert_bootstrapping_result(app, window_id, tab_index, pane_index, false) }) } pub fn assert_bootstrapping_result( app: &App, window_id: WindowId, tab_index: usize, pane_index: usize, expect_bootstrapped: bool, ) -> AssertionOutcome { let terminal_view = terminal_view(app, window_id, tab_index, pane_index); let bootstrapped = terminal_view.read(app, |view, ctx| { let model = view.model.lock(); let input_visible = view.is_input_box_visible(&model, ctx); let history_bootstrapped = model .block_list() .active_block() .session_id() .is_some_and(|session_id| History::as_ref(ctx).is_session_initialized(&session_id)); input_visible && history_bootstrapped // Note that we check whether the precmd that follows bootstrapping is done rather than // just checking bootstrapping is done. In tests it can cause indeterminancy to have // this precmd come in later (it increases the number of blocks), whereas in the actual // running of the app we don't care about these blocks and it's a slight performance hit // to wait for the precmd so we can just check is_bootstrapped. && model.block_list().is_bootstrapping_precmd_done() }); async_assert_eq!( expect_bootstrapped, bootstrapped, "terminal should be bootstrapped ({})", expect_bootstrapped ) } pub fn assert_selected_block_index_is_first_renderable() -> AssertionCallback { Box::new(|app, window_id| { let terminal_view = single_terminal_view(app, window_id); terminal_view.read(app, |view, _| { let selected_block_index = view .selected_blocks_tail_index() .expect("Selection should not be none"); let model = view.model.lock(); let block = model .block_list() .block_at(selected_block_index) .expect("Block should exist"); assert!( block.height(&AgentViewState::Inactive) != Lines::zero(), "The selected block should be rendered" ); // Previous index either doesn't exist or isn't renderable if selected_block_index > BlockIndex::zero() { let prev_block = model.block_list().block_at(selected_block_index - 1.into()); if let Some(prev_block) = prev_block { assert!( prev_block.is_empty(&AgentViewState::Inactive), "Prev index should be hidden" ); } } AssertionOutcome::Success }) }) } pub fn assert_selected_block_index_is_last_renderable() -> AssertionCallback { Box::new(|app, window_id| { let terminal_view = single_terminal_view(app, window_id); terminal_view.read(app, |view, _| { let selected_block_index = view .selected_blocks_tail_index() .expect("Selection should not be none"); let model = view.model.lock(); let block = model .block_list() .block_at(selected_block_index) .expect("Block should exist"); assert!( block.height(&AgentViewState::Inactive) != Lines::zero(), "The selected block should be rendered" ); assert_eq!( model.block_list().last_non_hidden_block_by_index(), Some(selected_block_index) ); AssertionOutcome::Success }) }) } pub fn assert_focused_editor_in_tab(tab_index: usize) -> AssertionCallback { Box::new(move |app, window_id| { let input_view = single_input_view_for_tab(app, window_id, tab_index); input_view.read(app, |view, ctx| { async_assert!(view.editor().is_focused(ctx), "Editor should be focused") }) }) } pub fn assert_command_executed_for_single_terminal_in_tab( tab_index: usize, command: String, ) -> AssertionCallback { assert_command_executed(tab_index, 0, command) } pub fn assert_command_executed( tab_index: usize, pane_index: usize, command: String, ) -> AssertionCallback { Box::new(move |app, window_id| { let terminal_view = terminal_view(app, window_id, tab_index, pane_index); terminal_view.read(app, |view, _ctx| { let model = view.model.lock(); let last_index = model .block_list() .last_matching_block_by_index(BlockFilter::commands()); if let Some(last_index) = last_index { let block = model .block_list() .block_at(last_index) .expect("block should exist"); let block_is_done = matches!( block.state(), BlockState::DoneWithExecution | BlockState::DoneWithNoExecution ); let last_command = block .command_with_secrets_unobfuscated(false /*include_escape_sequences*/); // We send an escape sequence once the line editor is active to fetch // typeahead. Currently, this is racy in integration tests because // they send the queued command more quickly than a real user could // type. For the time being, we handle this by cleaning up the command, // but ongoing work to consolidate PTY writes should be a more robust // solution. let cleaned_last_command = last_command.trim_end_matches("^[i").trim_end(); let cleaned_command = command.trim_end(); async_assert!( block_is_done && cleaned_last_command == cleaned_command, "Previous command should be {}, instead got {}", command, last_command, ) } else { AssertionOutcome::failure("No block yet".to_string()) } }) }) } pub fn assert_active_block_received_precmd( tab_index: usize, pane_index: usize, ) -> AssertionCallback { Box::new(move |app, window_id| { let terminal_view = terminal_view(app, window_id, tab_index, pane_index); terminal_view.read(app, |view, _ctx| { let model = view.model.lock(); let active_block = model.block_list().active_block(); if active_block.has_received_precmd() { AssertionOutcome::Success } else { AssertionOutcome::failure("Precmd has not been received yet".to_string()) } }) }) } pub fn assert_active_block_input_is_empty( tab_index: usize, pane_index: usize, ) -> AssertionCallback { Box::new(move |app, window_id| { let terminal_view = terminal_view(app, window_id, tab_index, pane_index); terminal_view.read(app, |view, ctx| { view.input().read(ctx, |input, ctx| { let text = input.buffer_text(ctx); async_assert!( text.is_empty(), "Input buffer is not empty after block finished. Input buffer contents: {}", text ) }) }) }) } pub fn assert_bootstrapping_stage( tab_index: usize, pane_index: usize, stage: BootstrapStage, ) -> AssertionCallback { Box::new(move |app, window_id| { let terminal_view = terminal_view(app, window_id, tab_index, pane_index); terminal_view.read(app, |view, _ctx| { let model = view.model.lock(); let active_block = model.block_list().active_block(); async_assert_eq!(active_block.bootstrap_stage(), stage) }) }) } pub fn assert_context_menu_is_open(should_be_open: bool) -> AssertionCallback { Box::new(move |app, window_id| { let terminal_view = single_terminal_view(app, window_id); terminal_view.read(app, |view, _ctx| { let open_or_closed_str = if should_be_open { "open" } else { "closed" }; async_assert_eq!( view.is_context_menu_open(), should_be_open, "The context menu should be {open_or_closed_str}" ) }) }) } pub fn assert_active_block_command_for_single_terminal_in_tab( expected_command: impl ExpectedOutput + 'static, tab_index: usize, ) -> AssertionCallback { assert_active_block_command(expected_command, tab_index, 0) } pub fn assert_active_block_command( expected_command: impl ExpectedOutput + 'static, tab_index: usize, pane_index: usize, ) -> AssertionCallback { Box::new(move |app, window_id| { let terminal_view = terminal_view(app, window_id, tab_index, pane_index); terminal_view.read(app, |view, _| { let model = view.model.lock(); let command = model.block_list().active_block().command_to_string(); async_assert!( expected_command.matches(&command), "The command should be {:?}, but got \"{}\"", expected_command, command ) }) }) } pub fn assert_active_block_output_for_single_terminal_in_tab( expected_output: impl ExpectedOutput + 'static, tab_index: usize, ) -> AssertionCallback { assert_active_block_output(expected_output, tab_index, 0) } pub fn assert_active_block_output( expected_output: impl ExpectedOutput + 'static, tab_index: usize, pane_index: usize, ) -> AssertionCallback { Box::new(move |app, window_id| { let terminal_view = terminal_view(app, window_id, tab_index, pane_index); terminal_view.read(app, |view, _| { let model = view.model.lock(); let output = model.block_list().active_block().output_to_string(); async_assert!( expected_output.matches(&output), "The output should be {:?}, but got \"{}\"", expected_output, output ) }) }) } pub fn assert_no_visible_background_blocks( tab_index: usize, pane_index: usize, ) -> AssertionCallback { Box::new(move |app, window_id| { let terminal_view = terminal_view(app, window_id, tab_index, pane_index); terminal_view.read(app, |view, _| { let model = view.model.lock(); let count_nonempty_background_blocks = model .block_list() .blocks() .iter() .filter(|block| { block.is_background() && block.is_visible(&AgentViewState::Inactive) }) .count(); async_assert_eq!( count_nonempty_background_blocks, 0, "BlockList should have no non-empty background blocks." ) }) }) } /// Asserts that the output of the alt screen matches `expected_output`. pub fn assert_alt_screen_output( expected_output: impl ExpectedOutput + 'static, tab_index: usize, pane_index: usize, ) -> AssertionCallback { Box::new(move |app, window_id| { let terminal_view = terminal_view(app, window_id, tab_index, pane_index); terminal_view.read(app, |view, _| { let model = view.model.lock(); let output = model.alt_screen().output_to_string(); async_assert!( expected_output.matches(&output), "The output should be {:?}, but got \"{}\"", expected_output, output ) }) }) } /// Builds an assertion that the input box for the given tab will contain the /// expected text. pub fn assert_input_editor_contents( tab_index: usize, expected_contents: impl AsRef + 'static, ) -> AssertionCallback { Box::new(move |app, window_id| { let expected_contents = expected_contents.as_ref(); let input_view = single_input_view_for_tab(app, window_id, tab_index); input_view.read(app, |view, ctx| { let contents = view.buffer_text(ctx); async_assert_eq!(&contents, expected_contents, "Incorrect input box contents:\nExpected {expected_contents:?}\nActual: {contents:?}") }) }) } pub fn assert_pane_group_has_state( tab_index: usize, expected_state: TerminalViewState, ) -> AssertionCallback { Box::new(move |app, _| { let active_window_id = app.read(|ctx| { WindowManager::as_ref(ctx) .active_window() .expect("should have active window") }); let views = app .views_of_type(active_window_id) .expect("Active window lacks a Workspace."); let workspace: &ViewHandle = views.first().expect("Window is missing Workspace view."); workspace.read(app, |workspace, ctx| { workspace .get_pane_group_view(tab_index) .expect("Workspace has no tab view.") .read(ctx, |pane_group, ctx| { async_assert_eq!(pane_group.most_recent_pane_state(ctx), expected_state) }) }) }) } fn assert_snackbar_visibility(tab_index: usize, is_visible: bool) -> AssertionCallback { Box::new(move |app, window_id| { let terminal_view = single_terminal_view_for_tab(app, window_id, tab_index); terminal_view.update(app, |view, ctx| { let presenter = ctx.presenter(window_id).expect("window should exist"); let snackbar_position = presenter .borrow() .position_cache() .get_position(format!("block_list_snackbar:{}", view.id())); async_assert_eq!(snackbar_position.is_some(), is_visible) }) }) } /// Asserts that the snackbar is visible. pub fn assert_snackbar_is_visible(tab_index: usize) -> AssertionCallback { assert_snackbar_visibility(tab_index, true /* is_visible */) } /// Asserts that the snackbar is _not_ visible. pub fn assert_snackbar_is_not_visible(tab_index: usize) -> AssertionCallback { assert_snackbar_visibility(tab_index, false /* is_visible */) } /// Asserts that the current scroll position is equal to `ScrollPosition`. pub fn assert_scroll_position( tab_index: usize, scroll_position: ScrollPosition, ) -> AssertionCallback { Box::new(move |app, window_id| { let terminal_view = single_terminal_view_for_tab(app, window_id, tab_index); terminal_view.read(app, |view, _ctx| { let actual_scroll_position = view.scroll_position(); async_assert_eq!(actual_scroll_position, scroll_position) }) }) } pub fn validate_git_branch( expected_git_branch: Option, tab_idx: usize, window_id: galaxyui::WindowId, app: &galaxyui::App, ) -> AssertionOutcome { let terminal_view = single_terminal_view_for_tab(app, window_id, tab_idx); terminal_view.read(app, |view, _ctx| { let model = view.model.lock(); let block = model.block_list().active_block(); let actual_branch = block.git_branch(); if actual_branch.map(Into::into) == expected_git_branch { AssertionOutcome::Success } else { AssertionOutcome::failure(format!( "Expected {expected_git_branch:?} as git branch but got {actual_branch:?}" )) } }) } /// Asserts that the active session of the current window's workspace has the expected local path. /// For convenience, the local path is converted to a user-friendly path, since it will generally /// be a temporary directory. pub fn assert_active_session_local_path(expected_path: &'static str) -> AssertionCallback { Box::new(move |app, window_id| { ActiveSession::handle(app).read(app, |active_session, _| { let session = active_session.session(window_id); let pwd = active_session.path_if_local(window_id); match session.zip(pwd) { Some((session, pwd)) => { let relative_path = user_friendly_path( pwd.to_str().expect("Non-UTF8 path"), session.home_dir(), ); async_assert_eq!(expected_path, relative_path) } None => { AssertionOutcome::failure("Expected a local active session path".to_string()) } } }) }) } pub fn assert_input_is_focused() -> AssertionCallback { Box::new(|app, window_id| { let terminal_view = single_terminal_view_for_tab(app, window_id, 0); terminal_view.read(app, |view, ctx| { let is_input_focused = view.input().as_ref(ctx).editor().as_ref(ctx).is_focused(); async_assert!(is_input_focused) }) }) }