Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,826 @@
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use regex::Regex;
|
||||
use settings::Setting as _;
|
||||
use warp_util::path::user_friendly_path;
|
||||
use warpui::{
|
||||
async_assert, async_assert_eq,
|
||||
integration::{AssertionCallback, AssertionOutcome},
|
||||
units::Lines,
|
||||
windowing::WindowManager,
|
||||
App, SingletonEntity, ViewHandle, WindowId,
|
||||
};
|
||||
|
||||
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<T>(
|
||||
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<T>(
|
||||
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<str> + '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<Workspace> =
|
||||
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<String>,
|
||||
tab_idx: usize,
|
||||
window_id: warpui::WindowId,
|
||||
app: &warpui::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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod assertion;
|
||||
mod step;
|
||||
pub mod util;
|
||||
|
||||
pub use assertion::*;
|
||||
pub use step::*;
|
||||
@@ -0,0 +1,474 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use warpui::{
|
||||
async_assert,
|
||||
integration::{AssertionOutcome, TestStep},
|
||||
Event, SingletonEntity,
|
||||
};
|
||||
|
||||
use crate::integration_testing::terminal::{
|
||||
assert_context_menu_is_open, assert_long_running_block_executing,
|
||||
};
|
||||
use crate::integration_testing::view_getters::single_terminal_view_for_tab;
|
||||
use crate::integration_testing::{
|
||||
block::assert_num_blocks_in_model, terminal::assert_active_block_input_is_empty,
|
||||
};
|
||||
use crate::terminal::model::terminal_model::BlockIndex;
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::{
|
||||
cmd_or_ctrl_shift, integration_testing::terminal::validate_block_output_on_finished_block,
|
||||
};
|
||||
use crate::{
|
||||
integration_testing::command_palette::open_command_palette_and_run_action,
|
||||
settings::PrivacySettings,
|
||||
};
|
||||
use crate::{
|
||||
integration_testing::{
|
||||
step::{
|
||||
assert_no_pending_model_events, new_step_with_default_assertions,
|
||||
new_step_with_default_assertions_for_pane,
|
||||
},
|
||||
view_getters::{single_input_view_for_tab, terminal_view},
|
||||
},
|
||||
terminal::input::InputSuggestionsMode,
|
||||
};
|
||||
|
||||
use super::{
|
||||
assert_active_block_output_for_single_terminal_in_tab, assert_active_block_received_precmd,
|
||||
assert_alt_grid_active, assert_command_executed,
|
||||
assert_long_running_block_executing_for_single_terminal_in_tab, assert_terminal_bootstrapped,
|
||||
util::{current_shell_starter_and_version, nonce, ExpectedExitStatus, ExpectedOutput},
|
||||
validate_block_output, PYTHON_PROMPT_READY,
|
||||
};
|
||||
|
||||
pub fn wait_until_bootstrapped_single_pane_for_tab(tab_index: usize) -> TestStep {
|
||||
wait_until_bootstrapped_pane(tab_index, 0)
|
||||
}
|
||||
|
||||
pub fn initialize_secret_regexes() -> TestStep {
|
||||
new_step_with_default_assertions("Initialize default secret regexes").with_action(
|
||||
move |app, _, _| {
|
||||
let privacy_settings = PrivacySettings::handle(app);
|
||||
privacy_settings.update(app, |me, ctx| {
|
||||
me.initialize_default_regexes_once(ctx);
|
||||
});
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn wait_until_bootstrapped_pane(tab_index: usize, pane_index: usize) -> TestStep {
|
||||
new_step_with_default_assertions("Wait for bootstrapping")
|
||||
.add_named_assertion(
|
||||
"waiting for bootstrapping",
|
||||
assert_terminal_bootstrapped(tab_index, pane_index),
|
||||
)
|
||||
.set_timeout(Duration::from_secs(20))
|
||||
.set_on_failure_handler("bootstrapping failed, bail on the test", move |_, _| {
|
||||
let (starter, version) = current_shell_starter_and_version();
|
||||
if matches!(&starter.shell_type(), &ShellType::Bash) && version.starts_with('3') {
|
||||
// There's a bug in older versions of bash that causes bootstrapping
|
||||
// to occasionally fail.
|
||||
AssertionOutcome::PreconditionFailed("bash flaked on startup".to_owned())
|
||||
} else {
|
||||
AssertionOutcome::failure("failed to bootstrap".to_owned())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open_context_menu_for_selected_block() -> Vec<TestStep> {
|
||||
let mut steps = open_command_palette_and_run_action("Open Block Context Menu");
|
||||
let last = steps.pop().expect("steps should not be empty");
|
||||
steps.push(last.add_assertion(assert_context_menu_is_open(true)));
|
||||
steps
|
||||
}
|
||||
|
||||
/// Runs the completer with the given input text, waiting up to 2 seconds for the completer to return
|
||||
/// with results.
|
||||
pub fn run_completer(tab_index: usize, input_text: impl Into<String>) -> TestStep {
|
||||
let input_text = input_text.into();
|
||||
new_step_with_default_assertions(&format!("Type {} and hit tab", &input_text))
|
||||
.with_typed_characters(&[&input_text])
|
||||
.with_keystrokes(&["tab"])
|
||||
.set_timeout(Duration::from_secs(2))
|
||||
.add_assertion(move |app, window_id| {
|
||||
let input_view = single_input_view_for_tab(app, window_id, tab_index);
|
||||
|
||||
input_view.read(app, |input, ctx| {
|
||||
let buffer_text = input.buffer_text(ctx);
|
||||
// There are 2 possible outcomes that can signify the completer has finished:
|
||||
// 1: TabCompletion mode is now active.
|
||||
// 2: InputSuggestionsMode is `Closed`, but the buffer text has changed. This is the
|
||||
// case when there is a single completion result that we insert directly into the
|
||||
// buffer.
|
||||
async_assert!(
|
||||
matches!(
|
||||
input.suggestions_mode_model().as_ref(ctx).mode(),
|
||||
InputSuggestionsMode::CompletionSuggestions { .. }
|
||||
) || (buffer_text != input_text
|
||||
&& matches!(
|
||||
input.suggestions_mode_model().as_ref(ctx).mode(),
|
||||
InputSuggestionsMode::Closed
|
||||
)),
|
||||
"Completions did not finish"
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Executes a given command and verifies it is executing.
|
||||
pub fn execute_long_running_command(tab_idx: usize, command: String) -> TestStep {
|
||||
execute_long_running_command_for_pane(tab_idx, 0 /* pane_idx */, command)
|
||||
}
|
||||
|
||||
/// Executes a given command for a specific pane and tab and verifies it is executing.
|
||||
pub fn execute_long_running_command_for_pane(
|
||||
tab_idx: usize,
|
||||
pane_idx: usize,
|
||||
command: impl AsRef<str>,
|
||||
) -> TestStep {
|
||||
let command = command.as_ref();
|
||||
TestStep::new(&format!("Run '{command}' and verify block is running"))
|
||||
.add_named_assertion("no pending model events", assert_no_pending_model_events())
|
||||
.with_typed_characters(&[command])
|
||||
.with_keystrokes(&["enter"])
|
||||
.set_timeout(Duration::from_secs(10))
|
||||
.add_named_assertion(
|
||||
format!("assert '{command}' is running"),
|
||||
assert_long_running_block_executing(
|
||||
true, /* output_grid_active */
|
||||
tab_idx, pane_idx,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Executes a python3 interpreter and leaves it running in the active block.
|
||||
pub fn execute_python_interpreter_in_tab(tab_idx: usize) -> TestStep {
|
||||
TestStep::new("Run python3 interpreter")
|
||||
.add_named_assertion("no pending model events", assert_no_pending_model_events())
|
||||
.with_typed_characters(&["python3"])
|
||||
.with_keystrokes(&["enter"])
|
||||
.add_assertion(assert_active_block_output_for_single_terminal_in_tab(
|
||||
&*PYTHON_PROMPT_READY,
|
||||
0,
|
||||
))
|
||||
.add_named_assertion(
|
||||
"assert python3 is running",
|
||||
assert_long_running_block_executing_for_single_terminal_in_tab(
|
||||
true, /* output_grid_active */
|
||||
tab_idx,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Runs an alt-grid program followed by a series of steps and then
|
||||
/// runs a command to exit the alt grid and asserts it's no longer active.
|
||||
///
|
||||
/// The terminal view at tab_index, pane_index is expected to be focused to run the program (this
|
||||
/// step asserts the alt screen is active on the corresponding TerminalView).
|
||||
pub fn run_alt_grid_program(
|
||||
command: &str,
|
||||
tab_index: usize,
|
||||
pane_index: usize,
|
||||
exit_step: TestStep,
|
||||
steps_before_exiting: Vec<TestStep>,
|
||||
) -> Vec<TestStep> {
|
||||
let mut steps = vec![];
|
||||
|
||||
let run_step = TestStep::new(&format!("Run '{command}' and then exit with exit step"))
|
||||
.add_named_assertion("no pending model events", assert_no_pending_model_events())
|
||||
.with_typed_characters(&[command])
|
||||
.with_keystrokes(&["enter"])
|
||||
.set_timeout(Duration::from_secs(10))
|
||||
.add_named_assertion(
|
||||
"alt grid should be active",
|
||||
assert_alt_grid_active(tab_index, pane_index, true),
|
||||
);
|
||||
|
||||
steps.push(run_step);
|
||||
steps.extend(steps_before_exiting);
|
||||
steps.push(exit_step);
|
||||
steps.push(
|
||||
new_step_with_default_assertions("return to block list").add_named_assertion(
|
||||
"alt grid should not be active",
|
||||
assert_alt_grid_active(tab_index, pane_index, false),
|
||||
),
|
||||
);
|
||||
|
||||
steps
|
||||
}
|
||||
|
||||
/// Executes a given command and verifies it's executed.
|
||||
/// Asserts the exit code of the command is the same as the expected exit code.
|
||||
/// #Panics if the execution failed for some reason.
|
||||
pub fn execute_command_for_single_terminal_in_tab(
|
||||
tab_idx: usize,
|
||||
command: String,
|
||||
expected_exit_code: ExpectedExitStatus,
|
||||
expected_output: impl ExpectedOutput + 'static,
|
||||
) -> TestStep {
|
||||
execute_command(tab_idx, 0, command, expected_exit_code, expected_output)
|
||||
}
|
||||
|
||||
pub fn execute_command_successfully(command: &str) -> TestStep {
|
||||
execute_command(0, 0, command.to_owned(), ExpectedExitStatus::Success, ())
|
||||
}
|
||||
|
||||
pub fn assert_execute_command_successfully(
|
||||
command: &str,
|
||||
expected_output: impl ExpectedOutput + 'static,
|
||||
) -> TestStep {
|
||||
execute_command(
|
||||
0,
|
||||
0,
|
||||
command.to_owned(),
|
||||
ExpectedExitStatus::Success,
|
||||
expected_output,
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates an event function that saves whether AI mode is active, switches to terminal
|
||||
/// input mode if needed, and returns a `TypedCharacters` event for the given command.
|
||||
fn switch_to_terminal_mode_and_type_command(
|
||||
tab_idx: usize,
|
||||
pane_idx: usize,
|
||||
was_ai_mode: Arc<AtomicBool>,
|
||||
command: String,
|
||||
) -> impl Fn(&mut warpui::App, warpui::WindowId) -> Event + 'static {
|
||||
move |app, window_id| {
|
||||
let tv = terminal_view(app, window_id, tab_idx, pane_idx);
|
||||
let is_ai = tv.read(app, |view, ctx| {
|
||||
view.input()
|
||||
.read(ctx, |input, ctx| input.input_type(ctx).is_ai())
|
||||
});
|
||||
was_ai_mode.store(is_ai, Ordering::SeqCst);
|
||||
if is_ai {
|
||||
tv.update(app, |view, ctx| {
|
||||
view.input().update(ctx, |input, ctx| {
|
||||
input.set_input_mode_terminal(false, ctx);
|
||||
});
|
||||
});
|
||||
}
|
||||
Event::TypedCharacters {
|
||||
chars: command.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores AI input mode if it was previously active (as recorded in `was_ai_mode`).
|
||||
///
|
||||
/// This is an action (not an assertion) so it always runs before assertions,
|
||||
/// ensuring the input mode is restored even if a subsequent assertion fails.
|
||||
fn restore_ai_mode_if_needed(
|
||||
tab_idx: usize,
|
||||
pane_idx: usize,
|
||||
was_ai_mode: Arc<AtomicBool>,
|
||||
) -> impl Fn(&mut warpui::App, warpui::WindowId) + 'static {
|
||||
move |app, window_id| {
|
||||
if was_ai_mode.load(Ordering::SeqCst) {
|
||||
let tv = terminal_view(app, window_id, tab_idx, pane_idx);
|
||||
tv.update(app, |view, ctx| {
|
||||
view.input().update(ctx, |input, ctx| {
|
||||
input.set_input_mode_agent(false, ctx);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared implementation for executing a shell command in the terminal.
|
||||
///
|
||||
/// If the input is currently in AI mode, this automatically switches to terminal input
|
||||
/// mode before typing the command, and restores AI mode after the command completes.
|
||||
/// This allows callers to run shell commands without manually toggling input mode.
|
||||
fn execute_command_step(
|
||||
tab_idx: usize,
|
||||
pane_idx: usize,
|
||||
command: String,
|
||||
validate_output_fn: impl FnMut(&mut warpui::App, warpui::WindowId) -> AssertionOutcome + 'static,
|
||||
) -> TestStep {
|
||||
let was_ai_mode = Arc::new(AtomicBool::new(false));
|
||||
let was_ai_mode_for_restore = was_ai_mode.clone();
|
||||
let command_for_event = command.clone();
|
||||
|
||||
new_step_with_default_assertions_for_pane(
|
||||
&format!("Run '{command}' and verify block exists"),
|
||||
tab_idx,
|
||||
pane_idx,
|
||||
)
|
||||
.with_event_fn(switch_to_terminal_mode_and_type_command(
|
||||
tab_idx,
|
||||
pane_idx,
|
||||
was_ai_mode,
|
||||
command_for_event,
|
||||
))
|
||||
.with_keystrokes(&["enter"])
|
||||
.set_timeout(Duration::from_secs(10))
|
||||
.with_action({
|
||||
let restore = restore_ai_mode_if_needed(tab_idx, pane_idx, was_ai_mode_for_restore);
|
||||
move |app, window_id, _| restore(app, window_id)
|
||||
})
|
||||
.add_named_assertion(
|
||||
format!("assert '{command}' ran"),
|
||||
assert_command_executed(tab_idx, pane_idx, command),
|
||||
)
|
||||
.add_named_assertion("assert command output", validate_output_fn)
|
||||
.add_named_assertion(
|
||||
"wait for precmd so we have metadata for the next block",
|
||||
assert_active_block_received_precmd(tab_idx, pane_idx),
|
||||
)
|
||||
}
|
||||
|
||||
/// Executes a given command and verifies it ran successfully.
|
||||
/// Asserts the exit code matches `expected_exit_code` and validates the output.
|
||||
///
|
||||
/// If the input is in AI mode, this automatically switches to terminal input mode
|
||||
/// before running the command and restores AI mode afterward.
|
||||
pub fn execute_command(
|
||||
tab_idx: usize,
|
||||
pane_idx: usize,
|
||||
command: String,
|
||||
expected_exit_code: ExpectedExitStatus,
|
||||
expected_output: impl ExpectedOutput + 'static,
|
||||
) -> TestStep {
|
||||
execute_command_step(tab_idx, pane_idx, command, move |app, window_id| {
|
||||
validate_block_output_on_finished_block(&expected_output, tab_idx, pane_idx, window_id, app)
|
||||
})
|
||||
.add_named_assertion("assert exit code", move |app, window_id| {
|
||||
let terminal_view = terminal_view(app, window_id, tab_idx, pane_idx);
|
||||
terminal_view.read(app, |view, _ctx| {
|
||||
let model = view.model.lock();
|
||||
// After the last test step, there should always be a block here, but for
|
||||
// some reason, it sometimes doesn't exist.
|
||||
let last_block = model
|
||||
.block_list()
|
||||
.last_non_hidden_block()
|
||||
.expect("Block should exist");
|
||||
match expected_exit_code {
|
||||
ExpectedExitStatus::Success => {
|
||||
if last_block.exit_code().value() != 0 {
|
||||
return AssertionOutcome::immediate_failure(format!(
|
||||
"Expected exit code 0, but got {}. Block output:\n{}\n",
|
||||
last_block.exit_code().value(),
|
||||
last_block
|
||||
.output_grid()
|
||||
.contents_to_string_with_secrets_unobfuscated(
|
||||
false, /*include_escape_sequences*/
|
||||
None, /*max_rows*/
|
||||
)
|
||||
));
|
||||
}
|
||||
}
|
||||
ExpectedExitStatus::Failure => {
|
||||
if last_block.exit_code().value() == 0 {
|
||||
return AssertionOutcome::immediate_failure(format!(
|
||||
"Expected non-zero exit code, but got 0. Block output:\n{}\n",
|
||||
last_block
|
||||
.output_grid()
|
||||
.contents_to_string_with_secrets_unobfuscated(
|
||||
false, /*include_escape_sequences*/
|
||||
None, /*max_rows*/
|
||||
)
|
||||
));
|
||||
}
|
||||
}
|
||||
ExpectedExitStatus::ExactCode(code) => {
|
||||
if last_block.exit_code() != code {
|
||||
return AssertionOutcome::immediate_failure(format!(
|
||||
"Expected exit code {}, but got {}",
|
||||
code.value(),
|
||||
last_block.exit_code().value()
|
||||
));
|
||||
}
|
||||
}
|
||||
ExpectedExitStatus::Any => (),
|
||||
};
|
||||
AssertionOutcome::Success
|
||||
})
|
||||
})
|
||||
.add_named_assertion(
|
||||
"check that input is empty",
|
||||
assert_active_block_input_is_empty(tab_idx, pane_idx),
|
||||
)
|
||||
}
|
||||
|
||||
/// Executes a given command and validates its output, without asserting the exit code.
|
||||
///
|
||||
/// If the input is in AI mode, this automatically switches to terminal input mode
|
||||
/// before running the command and restores AI mode afterward.
|
||||
pub fn execute_command_without_expected_exit_code(
|
||||
tab_idx: usize,
|
||||
pane_idx: usize,
|
||||
command: String,
|
||||
expected_output: impl ExpectedOutput + 'static,
|
||||
) -> TestStep {
|
||||
execute_command_step(tab_idx, pane_idx, command, move |app, window_id| {
|
||||
validate_block_output(&expected_output, tab_idx, pane_idx, window_id, app)
|
||||
})
|
||||
}
|
||||
|
||||
// Executes an echo with a random nonce and verifies it's executed.
|
||||
// The purpose of the nonce is to distinguish between distinct command executions.
|
||||
pub fn execute_echo(tab_idx: usize) -> TestStep {
|
||||
let rand = nonce();
|
||||
let command = format!("echo {rand}");
|
||||
execute_command_for_single_terminal_in_tab(tab_idx, command, ExpectedExitStatus::Success, rand)
|
||||
}
|
||||
|
||||
// Executes an echo with the specified string.
|
||||
pub fn execute_echo_str(tab_idx: usize, str: &str) -> TestStep {
|
||||
let command = format!("echo \"{str}\"");
|
||||
execute_command_for_single_terminal_in_tab(
|
||||
tab_idx,
|
||||
command,
|
||||
ExpectedExitStatus::Success,
|
||||
str.to_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Runs a performance test on a given tab idx.
|
||||
/// # Arguments
|
||||
/// * `tab_idx` - id number of the tab the step should be executed on;
|
||||
/// * `test_file` is a path to the bash file that will execute the test, and needs to be available
|
||||
/// for the test itself (use MockUserData structure to ensure it);
|
||||
/// * `repetitions` denotes how many times a test should be repeated;
|
||||
/// #Panics if the execution failed for some reason.
|
||||
pub fn performance_test(tab_idx: usize, test_file: &str, repetitions: usize) -> TestStep {
|
||||
execute_command_for_single_terminal_in_tab(
|
||||
tab_idx,
|
||||
format!("multitime -n {repetitions} bash {test_file}"),
|
||||
ExpectedExitStatus::Success,
|
||||
(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Clears the blocklist so that when we create a new block, its block index is 0.
|
||||
/// Otherwise, its index within the blocklist will be dependent on the shell bootstrapped.
|
||||
///
|
||||
/// NOTE: Call this step after bootstrapping and before running any commands
|
||||
/// to ensure that the next block created has `BlockIndex::zero()`. Also, this function
|
||||
/// assumes that there is only one terminal view in tab 0.
|
||||
pub fn clear_blocklist_to_remove_bootstrapped_blocks() -> TestStep {
|
||||
new_step_with_default_assertions("Clear blocklist")
|
||||
.with_keystrokes(&[cmd_or_ctrl_shift("k")])
|
||||
.set_timeout(Duration::from_secs(10))
|
||||
.add_assertion(assert_num_blocks_in_model(1))
|
||||
}
|
||||
|
||||
pub fn hover_over_block_zero() -> TestStep {
|
||||
new_step_with_default_assertions("Hover over the recently created block")
|
||||
.with_hover_over_saved_position("block_index:0")
|
||||
.add_assertion(|app, window_id| {
|
||||
let terminal_view = single_terminal_view_for_tab(app, window_id, 0);
|
||||
terminal_view.read(app, |view, _ctx| {
|
||||
assert_eq!(
|
||||
Some(BlockIndex::from(0)),
|
||||
view.hovered_block_index(),
|
||||
"Expected first block to be hovered over, but got block index {:?}",
|
||||
view.hovered_block_index()
|
||||
);
|
||||
});
|
||||
AssertionOutcome::Success
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
use async_io::block_on;
|
||||
use command::blocking::Command;
|
||||
use std::borrow::Cow;
|
||||
use std::iter;
|
||||
use std::path::{Path, PathBuf};
|
||||
use warp_core::command::ExitCode;
|
||||
#[cfg(windows)]
|
||||
use warp_core::paths::base_config_dir;
|
||||
|
||||
use rand::Rng;
|
||||
use rand::{distributions::Alphanumeric, thread_rng};
|
||||
use regex::Regex;
|
||||
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::terminal::{
|
||||
local_tty::shell::{DirectShellStarter, ShellStarter, ShellStarterSource},
|
||||
shell,
|
||||
};
|
||||
|
||||
/// Returns the shell starter along with the version of the shell about to be run.
|
||||
pub fn current_shell_starter_and_version() -> (DirectShellStarter, String) {
|
||||
let shell_starter_or_wsl_name = ShellStarter::init(Default::default())
|
||||
.expect("Could not create a shell starter or wsl name");
|
||||
let shell_starter_source =
|
||||
block_on(async { shell_starter_or_wsl_name.to_shell_starter_source().await })
|
||||
.expect("Could not create a shell starter source");
|
||||
let starter = match shell_starter_source {
|
||||
ShellStarterSource::Override(starter) => match starter {
|
||||
ShellStarter::Direct(direct_shell_starter) => direct_shell_starter,
|
||||
ShellStarter::Wsl(_) => {
|
||||
// TODO(CORE-2302): Support integration tests on Windows (including WSL).
|
||||
todo!("We don't yet support integration tests for WSL shells")
|
||||
}
|
||||
// TODO(CORE-2302): Support integration tests on Windows (including WSL).
|
||||
ShellStarter::MSYS2(_) => {
|
||||
todo!("We don't yet support integration tests for MSYS2")
|
||||
}
|
||||
ShellStarter::DockerSandbox(_) => {
|
||||
todo!("We don't yet support integration tests for Docker sandbox shells")
|
||||
}
|
||||
},
|
||||
ShellStarterSource::Environment(starter)
|
||||
| ShellStarterSource::UserDefault(starter)
|
||||
| ShellStarterSource::Fallback { starter, .. } => starter,
|
||||
};
|
||||
let version = match starter.shell_type() {
|
||||
shell::ShellType::Zsh => {
|
||||
let stdout = Command::new(starter.logical_shell_path())
|
||||
.args(["-c", "echo $ZSH_VERSION"])
|
||||
.output()
|
||||
.expect("version command should run")
|
||||
.stdout;
|
||||
String::from_utf8_lossy(&stdout).into_owned()
|
||||
}
|
||||
shell::ShellType::Bash => {
|
||||
let stdout = Command::new(starter.logical_shell_path())
|
||||
.args(["-c", "echo $BASH_VERSION"])
|
||||
.output()
|
||||
.expect("version command should run")
|
||||
.stdout;
|
||||
String::from_utf8_lossy(&stdout).into_owned()
|
||||
}
|
||||
shell::ShellType::Fish => {
|
||||
let stdout = Command::new(starter.logical_shell_path())
|
||||
.args(["-c", "echo $FISH_VERSION"])
|
||||
.output()
|
||||
.expect("version command should run")
|
||||
.stdout;
|
||||
String::from_utf8_lossy(&stdout).into_owned()
|
||||
}
|
||||
shell::ShellType::PowerShell => {
|
||||
let stdout = Command::new(starter.logical_shell_path())
|
||||
.args(["-Version"])
|
||||
.output()
|
||||
.expect("version command should run")
|
||||
.stdout;
|
||||
String::from_utf8_lossy(&stdout).into_owned()
|
||||
}
|
||||
};
|
||||
assert!(!version.is_empty());
|
||||
(starter, version)
|
||||
}
|
||||
|
||||
/// Returns the directory for the default histfile location for the ShellType in this
|
||||
/// ShellStarter based on the given user `home_dir`.
|
||||
pub fn default_histfile_directory(shell: &ShellType, home_dir: &Path) -> PathBuf {
|
||||
match shell {
|
||||
ShellType::Fish => home_dir.join(".local/share/fish"),
|
||||
#[cfg(not(windows))]
|
||||
ShellType::PowerShell => home_dir.join(".local/share/powershell/PSReadLine"),
|
||||
#[cfg(windows)]
|
||||
ShellType::PowerShell => base_config_dir().join("Microsoft/Windows/PowerShell/PSReadLine"),
|
||||
_ => home_dir.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a random nonce to distinguish between commands.
|
||||
pub fn nonce() -> String {
|
||||
let mut rng = thread_rng();
|
||||
iter::repeat(())
|
||||
.map(|()| rng.sample(Alphanumeric))
|
||||
.map(char::from)
|
||||
.take(7)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Different options for asserting the value of the exit code.
|
||||
pub enum ExpectedExitStatus {
|
||||
/// Checks code == 0
|
||||
Success,
|
||||
/// Checks code != 0
|
||||
Failure,
|
||||
/// Checks code == expected
|
||||
ExactCode(ExitCode),
|
||||
/// Any exit status is considered valid.
|
||||
Any,
|
||||
}
|
||||
|
||||
/// A representation of the expected output from running a command.
|
||||
pub trait ExpectedOutput: std::fmt::Debug {
|
||||
/// Returns whether the given result matches the expected output.
|
||||
fn matches(&self, result: &str) -> bool;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ExactLine<'a>(Cow<'a, str>);
|
||||
|
||||
impl<'a, T: Into<Cow<'a, str>>> From<T> for ExactLine<'a> {
|
||||
fn from(value: T) -> Self {
|
||||
ExactLine(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl ExpectedOutput for str {
|
||||
fn matches(&self, result: &str) -> bool {
|
||||
self == result
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ExpectedOutput + ?Sized> ExpectedOutput for &T {
|
||||
fn matches(&self, result: &str) -> bool {
|
||||
(*self).matches(result)
|
||||
}
|
||||
}
|
||||
|
||||
impl ExpectedOutput for String {
|
||||
fn matches(&self, result: &str) -> bool {
|
||||
self == result
|
||||
}
|
||||
}
|
||||
|
||||
impl ExpectedOutput for ExactLine<'_> {
|
||||
fn matches(&self, result: &str) -> bool {
|
||||
result.lines().any(|line| line == self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl ExpectedOutput for Regex {
|
||||
fn matches(&self, result: &str) -> bool {
|
||||
self.is_match(result)
|
||||
}
|
||||
}
|
||||
|
||||
impl ExpectedOutput for Path {
|
||||
fn matches(&self, result: &str) -> bool {
|
||||
self.to_str() == Some(result)
|
||||
}
|
||||
}
|
||||
|
||||
impl ExpectedOutput for PathBuf {
|
||||
fn matches(&self, result: &str) -> bool {
|
||||
self.as_path().matches(result)
|
||||
}
|
||||
}
|
||||
|
||||
impl ExpectedOutput for () {
|
||||
fn matches(&self, _result: &str) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ExpectedOutput> ExpectedOutput for Option<T> {
|
||||
fn matches(&self, result: &str) -> bool {
|
||||
match self {
|
||||
Some(expected) => expected.matches(result),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct JsonEq(pub serde_json::Value);
|
||||
|
||||
impl ExpectedOutput for JsonEq {
|
||||
fn matches(&self, result: &str) -> bool {
|
||||
match serde_json::from_str::<serde_json::Value>(result) {
|
||||
Ok(actual) => actual == self.0,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user