Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
use crate::integration_testing::agent_mode::util::get_base_server_url;
use anyhow::Result;
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize)]
pub struct LLMGenerateRequest {
pub prompt: String,
pub user_messages: Vec<String>,
/// These are model IDs internal to warp-server.
/// See warp-server/logic/ai/llm/llm.go
pub model_id: String,
}
#[derive(Debug, Deserialize)]
pub struct ExactTokenUsage {
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_reads: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_writes: Option<i32>,
pub total_input: i32,
pub output: i32,
}
#[derive(Debug, Deserialize)]
pub struct LLMGenerateResponse {
pub content: String,
pub token_usage: ExactTokenUsage,
}
pub fn generate_llm_response(
client: &Client,
request: LLMGenerateRequest,
) -> Result<LLMGenerateResponse> {
let url = format!("{}/agent-mode-evals/llm_generate", get_base_server_url());
let response = client.post(&url).json(&request).send()?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"Failed to generate LLM response: {}",
response.status()
));
}
let response = response.json::<LLMGenerateResponse>()?;
Ok(response)
}
@@ -0,0 +1,271 @@
pub mod llm_generate;
use anyhow::Result;
use llm_generate::LLMGenerateRequest;
use reqwest::blocking::Client;
use serde::Deserialize;
use warp_multi_agent_api::{
apply_file_diffs_result::success::UpdatedFileContent, message, Message,
};
use crate::ai::agent::conversation::AIConversation;
#[derive(Clone, Debug)]
pub struct LLMJudgeConfig {
pub prompt: String,
pub model: &'static str,
}
/// The LLM judge's response content will be directly unmarshalled into this struct.
/// Each judge's prompt must instruct the LLM to output its response in this format.
#[derive(Debug, Deserialize)]
pub struct LLMJudgeResult {
pub pass: bool,
pub critique: String,
}
pub struct LLMJudge {
config: LLMJudgeConfig,
client: Client,
}
impl LLMJudge {
pub fn new(config: LLMJudgeConfig) -> Self {
Self {
config,
client: Client::new(),
}
}
/// Format the conversation history to pass to the LLM judge.
/// TODO: support optional different formatting for different judges
fn format_conversation_history_for_llm_judge(conversation: &AIConversation) -> Result<String> {
let messages = conversation.all_linearized_messages();
// Filter out tool call result contents from the task's messages.
let filtered_messages = filter_tool_call_results_from_messages(messages.into_iter());
// Use debug format for now since we can't serialize protos to JSON.
let task_json = format!("{filtered_messages:?}");
Ok(task_json)
}
pub fn judge(&self, conversation: &AIConversation) -> Result<LLMJudgeResult> {
let formatted_conversation_history =
Self::format_conversation_history_for_llm_judge(conversation)?;
let request = LLMGenerateRequest {
prompt: self.config.prompt.to_owned(),
user_messages: vec![formatted_conversation_history],
model_id: self.config.model.to_owned(),
};
let response = llm_generate::generate_llm_response(&self.client, request)?;
// Parse the response content as JSON
let result: LLMJudgeResult = serde_json::from_str(&response.content)?;
Ok(result)
}
}
/// Filter out tool call result contents while preserving structure and success/error status
pub fn filter_tool_call_result(result: &message::ToolCallResult) -> message::ToolCallResult {
use message::tool_call_result::Result as ToolResult;
use warp_multi_agent_api::*;
let filtered_result = match &result.result {
Some(ToolResult::RunShellCommand(cmd_result)) =>
{
#[allow(deprecated)]
Some(ToolResult::RunShellCommand(RunShellCommandResult {
command: cmd_result.command.clone(),
output: Default::default(),
exit_code: Default::default(),
result: Some(
warp_multi_agent_api::run_shell_command_result::Result::CommandFinished(
warp_multi_agent_api::ShellCommandFinished {
command_id: "command_id".to_string(),
output: "[OUTPUT OMITTED]".to_string(),
exit_code: cmd_result.exit_code,
},
),
),
}))
}
Some(ToolResult::ReadFiles(read_result)) => {
use read_files_result::Result as ReadResult;
let filtered_read_result = match &read_result.result {
Some(ReadResult::TextFilesSuccess(success)) => {
// Keep file paths and line ranges but remove content
let filtered_files = success
.files
.iter()
.map(|file| FileContent {
file_path: file.file_path.clone(),
line_range: file.line_range,
content: "[CONTENT OMITTED]".to_string(),
})
.collect();
Some(ReadResult::TextFilesSuccess(
read_files_result::TextFilesSuccess {
files: filtered_files,
},
))
}
Some(ReadResult::AnyFilesSuccess(success)) => {
// Keep file paths and line ranges but remove content
let filtered_files = success
.files
.iter()
.map(|file| match &file.content {
Some(any_file_content::Content::TextContent(text_content)) => {
AnyFileContent {
content: Some(any_file_content::Content::TextContent(
warp_multi_agent_api::FileContent {
file_path: text_content.file_path.clone(),
content: "[CONTENT OMITTED]".to_string(),
line_range: text_content.line_range,
},
)),
}
}
Some(any_file_content::Content::BinaryContent(binary_content)) => {
AnyFileContent {
content: Some(any_file_content::Content::BinaryContent(
warp_multi_agent_api::BinaryFileContent {
file_path: binary_content.file_path.clone(),
data: vec![],
},
)),
}
}
None => unreachable!(
"AnyFileContent should always contain TextContent or BinaryContent"
),
})
.collect();
Some(ReadResult::AnyFilesSuccess(
read_files_result::AnyFilesSuccess {
files: filtered_files,
},
))
}
Some(ReadResult::Error(err)) => Some(ReadResult::Error(err.clone())),
None => None,
};
Some(ToolResult::ReadFiles(ReadFilesResult {
result: filtered_read_result,
}))
}
Some(ToolResult::SearchCodebase(search_result)) => {
use search_codebase_result::Result as SearchResult;
let filtered_search_result = match &search_result.result {
Some(SearchResult::Success(success)) => {
// Keep file paths and line ranges but remove file contents
let filtered_files = success
.files
.iter()
.map(|file| FileContent {
file_path: file.file_path.clone(),
line_range: file.line_range,
content: "[CONTENT OMITTED]".to_string(),
})
.collect();
Some(SearchResult::Success(search_codebase_result::Success {
files: filtered_files,
}))
}
Some(SearchResult::Error(err)) => Some(SearchResult::Error(err.clone())),
None => None,
};
Some(ToolResult::SearchCodebase(SearchCodebaseResult {
result: filtered_search_result,
}))
}
Some(ToolResult::ApplyFileDiffs(diff_result)) => {
use apply_file_diffs_result::Result as DiffResult;
let filtered_diff_result = match &diff_result.result {
Some(DiffResult::Success(success)) => {
// Keep file paths and line ranges but remove file contents
let filtered_files = success
.updated_files_v2
.iter()
.map(|file| UpdatedFileContent {
file: file.file.as_ref().map(|file_content| FileContent {
file_path: file_content.file_path.clone(),
line_range: file_content.line_range,
content: "[CONTENT OMITTED]".to_string(),
}),
was_edited_by_user: file.was_edited_by_user,
})
.collect();
Some(DiffResult::Success(apply_file_diffs_result::Success {
updated_files_v2: filtered_files,
..Default::default()
}))
}
Some(DiffResult::Error(err)) => Some(DiffResult::Error(err.clone())),
None => None,
};
Some(ToolResult::ApplyFileDiffs(ApplyFileDiffsResult {
result: filtered_diff_result,
}))
}
Some(ToolResult::Grep(grep_result)) => {
use grep_result::Result as GrepResult;
let filtered_grep_result = match &grep_result.result {
Some(GrepResult::Success(success)) => {
// Keep only file paths, remove all matched content and line numbers
let filtered_files = success
.matched_files
.iter()
.map(|file| {
grep_result::success::GrepFileMatch {
file_path: file.file_path.clone(),
// These can be very long so they're filtered, but we can't replace with a placeholder.
// The prompt should address this.
matched_lines: vec![],
}
})
.collect();
Some(GrepResult::Success(grep_result::Success {
matched_files: filtered_files,
}))
}
Some(GrepResult::Error(err)) => Some(GrepResult::Error(err.clone())),
None => None,
};
Some(ToolResult::Grep(warp_multi_agent_api::GrepResult {
result: filtered_grep_result,
}))
}
other => other.clone(),
};
message::ToolCallResult {
tool_call_id: result.tool_call_id.clone(),
context: None, // Remove context to reduce noise
result: filtered_result,
}
}
/// Filter out tool call result contents while preserving structure
pub fn filter_tool_call_results_from_messages<'a>(
messages: impl Iterator<Item = &'a Message>,
) -> Vec<Message> {
messages
.map(|msg| {
let mut filtered_msg = msg.clone();
if let Some(message::Message::ToolCallResult(ref tool_result)) = &msg.message {
filtered_msg.message = Some(message::Message::ToolCallResult(
filter_tool_call_result(tool_result),
));
}
filtered_msg
})
.collect()
}
@@ -0,0 +1,306 @@
mod assertions;
pub mod llm_judge;
mod step;
mod user_defaults;
mod util;
use std::collections::HashSet;
use std::fs::File;
use std::io::Write;
use crate::ai::agent::{AIAgentOutputStatus, FinishedAIAgentOutput};
pub use crate::ai::blocklist::agent_view::AgentViewState;
use crate::BlocklistAIHistoryModel;
use crate::{ai::agent::AIAgentActionType, integration_testing::view_getters::terminal_view};
pub use assertions::*;
pub use step::*;
pub use user_defaults::*;
pub use util::*;
use warpui::integration::PersistedDataMap;
pub use warpui::integration::RUNTIME_TAG_FAILURE_REASON;
use warpui::{App, SingletonEntity as _, WindowId};
pub const TOTAL_REQUEST_COST_PREFIX: &str = "Total request cost: ";
pub const TOTAL_EXCHANGES_PREFIX: &str = "Total number of exchanges: ";
pub const TOTAL_TOKEN_USAGE_PREFIX: &str = "Total token usage: ";
pub const RUNTIME_TAG_TOTAL_REQUEST_COST: &str = "total_request_cost";
pub const RUNTIME_TAG_TOTAL_EXCHANGES: &str = "total_exchanges";
pub const RUNTIME_TAG_TOKEN_USAGE_PREFIX: &str = "token_usage.";
const CODE_DIFF_OUTPUT_FILE_ENV_VAR: &str = "CODE_DIFF_OUTPUT_FILE";
pub fn output_code_diff_with_base_commit(
base_commit: &str,
working_dir: &str,
test_files_str: &str,
) {
use command::blocking::Command;
let Some(mut output_file) = open_debug_file_from_env(CODE_DIFF_OUTPUT_FILE_ENV_VAR) else {
log::error!("Could not open debug file from env");
return;
};
// Clear the test files from the diff, because we are not interested in seeing those.
log::debug!(
"[GIT OPERATION] mod.rs output_code_diff_with_base_commit git checkout {base_commit} -- {test_files_str}"
);
let _ = Command::new("git")
.args(["checkout", base_commit, "--", test_files_str])
.current_dir(working_dir)
.output();
log::debug!(
"[GIT OPERATION] mod.rs output_code_diff_with_base_commit git --no-pager diff {base_commit}"
);
let git_diff_output = Command::new("git")
.args(["--no-pager", "diff", base_commit])
.current_dir(working_dir)
.output();
write_git_diff_output_to_file(
git_diff_output,
&mut output_file,
&std::env::var(CODE_DIFF_OUTPUT_FILE_ENV_VAR)
.expect("Could not find diff output file env var"),
)
}
pub fn output_code_diff_debug_info(app: &mut App, window_id: WindowId) {
let terminal_view = terminal_view(app, window_id, 0, 0);
let Some(current_dir) = terminal_view.read(app, |terminal_view, _| terminal_view.pwd()) else {
log::error!("Could not get current directory");
return;
};
BlocklistAIHistoryModel::handle(app).update(app, |history_model, _| {
let Some(conversation) = history_model.active_conversation(terminal_view.id()) else {
return;
};
let mut edited_files = HashSet::new();
for exchange in conversation.all_exchanges().into_iter() {
let AIAgentOutputStatus::Finished { finished_output } = &exchange.output_status else {
continue;
};
if let FinishedAIAgentOutput::Success { output } = finished_output {
let agent_output = output.get();
for action in agent_output.actions() {
if let AIAgentActionType::RequestFileEdits { file_edits, .. } = &action.action {
for edit in file_edits {
if let Some(file) = edit.file() {
edited_files.insert(file.to_owned());
}
}
}
}
}
}
let mut output_file = open_debug_file_from_env(CODE_DIFF_OUTPUT_FILE_ENV_VAR);
if let Some(output_file) = &mut output_file {
use command::blocking::Command;
use std::io::Write;
if edited_files.is_empty() {
writeln!(output_file, "No files were edited for this test")
.expect("Failed to write to code diff file");
} else {
for file_name in edited_files {
log::debug!(
"[GIT OPERATION] mod.rs output_code_diff_debug_info git diff -- {file_name}"
);
let output = Command::new("git")
.args(["diff", "--", &file_name])
.current_dir(&current_dir)
.output();
write_git_diff_output_to_file(output, output_file, &file_name);
}
}
}
});
}
fn write_git_diff_output_to_file(
diff_output: std::io::Result<std::process::Output>,
file: &mut File,
file_name: &str,
) {
match diff_output {
Ok(output) if output.status.success() => {
let diff = String::from_utf8_lossy(&output.stdout);
writeln!(file, "Diff for file: {file_name}\n{diff}\n")
.expect("Failed to write diff to code diff file");
}
Ok(output) => {
let err = String::from_utf8_lossy(&output.stderr);
writeln!(
file,
"Failed to get diff for file: {file_name}\nGit error:\n{err}\n"
)
.expect("Failed to write error to code diff file");
}
Err(e) => {
writeln!(
file,
"Failed to run git diff for file: {file_name}\nError: {e}\n"
)
.expect("Failed to write command error to code diff file");
}
}
}
pub fn output_conversation_debug_info(
app: &mut App,
window_id: WindowId,
persisted_data: &mut PersistedDataMap,
) {
let terminal_view = terminal_view(app, window_id, 0, 0);
BlocklistAIHistoryModel::handle(app).update(app, |history_model, _| {
let Some(conversation) = history_model.active_conversation(terminal_view.id()) else {
return;
};
let mut output_file = open_debug_file_from_env("DEBUG_OUTPUT_FILE");
// Create a function to handle output
let mut write_to_debug_file = |text: &str| {
if let Some(file) = &mut output_file {
use std::io::Write;
writeln!(file, "{text}").expect("Failed to write to debug output file");
} else {
println!("{text}");
}
};
// Add timestamp to the header
let current_time = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ");
write_to_debug_file(&format!(
"========Conversation Debug Info (Generated: {current_time})========"
));
let debug_link = conversation
.server_conversation_token()
.map(|token| {
// The debug link within the container will be using host.docker.internal, but we're opening
// from outside the container.
// The server is configured to always write debug data to GCS instead of locally when run for evals, so we replace
// with staging.warp.dev instead of localhost:8080.
token
.debug_link()
.replace("host.docker.internal:8080", "staging.warp.dev")
})
.unwrap_or("unavailable".to_owned());
write_to_debug_file(&format!("Conversation Debug Link: {debug_link}"));
let total_request_cost = conversation.total_request_cost();
let total_exchanges = conversation.all_exchanges().len();
let token_usage = conversation.total_token_usage();
// Populate runtime tags with conversation data
persisted_data.insert(
RUNTIME_TAG_TOTAL_REQUEST_COST.to_string(),
total_request_cost.to_string(),
);
persisted_data.insert(
RUNTIME_TAG_TOTAL_EXCHANGES.to_string(),
total_exchanges.to_string(),
);
// Add token usage as separate runtime tags
for usage in token_usage.iter() {
persisted_data.insert(
format!(
"{}{}.total_input",
RUNTIME_TAG_TOKEN_USAGE_PREFIX, usage.model_id
),
usage.total_input.to_string(),
);
persisted_data.insert(
format!(
"{}{}.output",
RUNTIME_TAG_TOKEN_USAGE_PREFIX, usage.model_id
),
usage.output.to_string(),
);
persisted_data.insert(
format!(
"{}{}.input_cache_read",
RUNTIME_TAG_TOKEN_USAGE_PREFIX, usage.model_id
),
usage.input_cache_read.to_string(),
);
persisted_data.insert(
format!(
"{}{}.input_cache_write",
RUNTIME_TAG_TOKEN_USAGE_PREFIX, usage.model_id
),
usage.input_cache_write.to_string(),
);
persisted_data.insert(
format!(
"{}{}.cost_in_cents",
RUNTIME_TAG_TOKEN_USAGE_PREFIX, usage.model_id
),
usage.cost_in_cents.to_string(),
);
}
// Write to debug file for backward compatibility
write_to_debug_file(&format!(
"{TOTAL_REQUEST_COST_PREFIX}{total_request_cost}"
));
write_to_debug_file(&format!("{TOTAL_EXCHANGES_PREFIX}{total_exchanges}"));
write_to_debug_file(&format!(
"{TOTAL_TOKEN_USAGE_PREFIX}{}",
token_usage
.iter()
.map(|usage| format!(
"model_id={},total_input={},output={},input_cache_read={},input_cache_write={},cost_in_cents={}",
usage.model_id,
usage.total_input,
usage.output,
usage.input_cache_read,
usage.input_cache_write,
usage.cost_in_cents
))
.collect::<Vec<_>>()
.join("|")
));
write_to_debug_file("\nConversation Exchanges:\n");
for (i, exchange) in conversation.all_exchanges().into_iter().enumerate() {
// Add timestamp for each exchange
let exchange_time = chrono::DateTime::<chrono::Utc>::from(exchange.start_time)
.format("%Y-%m-%dT%H:%M:%S%.3fZ");
write_to_debug_file(&format!(
"\n--- Exchange {} (Started: {}) ---",
i + 1,
exchange_time
));
for input in &exchange.input {
write_to_debug_file(&format!("\nInput:\n\n{input}\n"));
}
write_to_debug_file(&format!("Output:\n{}", &exchange.output_status))
}
// Add completion timestamp
let completion_time = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ");
write_to_debug_file(&format!(
"\n========Debug Info Complete ({completion_time})=========="
));
})
}
// Get debug output file path from environment
pub fn open_debug_file_from_env(env_var: &str) -> Option<File> {
let file_path = std::env::var(env_var).ok();
if let Some(file_path) = &file_path {
// Clear the file if it exists
let _ = std::fs::remove_file(file_path);
Some(
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(file_path)
.expect("Failed to open debug output file"),
)
} else {
None
}
}
@@ -0,0 +1,251 @@
use std::{fs::read, io::Cursor, path::Path, time::Duration};
use prost::Message;
use warpui::{async_assert, integration::TestStep, SingletonEntity};
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::ai::execution_profiles::ActionPermission;
use crate::ai::llms::{LLMId, LLMPreferences};
use crate::integration_testing::agent_mode::ConversationTarget;
use crate::integration_testing::{
agent_mode::{assert_latest_task_succeeds_or_blocked, assert_task_is_blocked},
step::{new_step_with_default_assertions, new_step_with_default_assertions_for_pane},
terminal::assert_input_is_focused,
view_getters::terminal_view,
};
pub const AGENT_MODE_RUNNING_STEP_GROUP_NAME: &str = "Agent mode running";
use super::hydrate_ai_conversation_assertion;
/// Assumes that the terminal input is currently not in AI input mode.
pub fn enter_agent_view() -> TestStep {
new_step_with_default_assertions("Enter Agent View")
.with_keystrokes(&["ctrl-shift-enter"])
.add_named_assertion(
"Assert that we are in Agent View and AI input mode",
move |app, window_id| {
let terminal_view = terminal_view(app, window_id, 0, 0);
terminal_view.read(app, |terminal_view, app| {
let is_ai_input_mode = terminal_view
.input()
.read(app, |input, app| input.input_type(app).is_ai());
let agent_view_state = {
let model = terminal_view.model.lock();
model.block_list().agent_view_state().clone()
};
async_assert!(
is_ai_input_mode && agent_view_state.is_active(),
"Expected fullscreen Agent View + AI input mode, got agent_view_state={agent_view_state:?}, is_ai_input_mode={is_ai_input_mode}"
)
})
},
)
}
/// Assumes that the terminal input is currently in AI input mode.
pub fn exit_agent_view() -> TestStep {
new_step_with_default_assertions("Exit Agent View")
.with_keystrokes(&["escape"])
.add_named_assertion(
"Assert that we exited Agent View and are not in AI input mode",
move |app, window_id| {
let terminal_view = terminal_view(app, window_id, 0, 0);
terminal_view.read(app, |terminal_view, app| {
let is_ai_input_mode = terminal_view
.input()
.read(app, |input, app| input.input_type(app).is_ai());
let agent_view_state = {
let model = terminal_view.model.lock();
model.block_list().agent_view_state().clone()
};
async_assert!(
!is_ai_input_mode && !agent_view_state.is_active(),
"Expected inactive Agent View + non-AI input mode, got agent_view_state={agent_view_state:?}, is_ai_input_mode={is_ai_input_mode}"
)
})
},
)
}
/// Hydrates a conversation from a protobuf file.
/// File should be generated into the `input_data` directory.
/// See the agent_mode_eval README for more details.
pub fn hydrate_ai_conversation(file_name: &str) -> TestStep {
let file_bytes = get_input_data(file_name);
let Ok(request) = warp_multi_agent_api::Request::decode(file_bytes) else {
panic!("Failed to decode request from protobuf");
};
let tasks = request
.task_context
.map(|ctx| ctx.tasks)
.unwrap_or_default();
new_step_with_default_assertions("Hydrate AI conversation").add_named_assertion(
"Assert that conversation was hydrated successfully",
hydrate_ai_conversation_assertion(tasks),
)
}
/// Attach the latest block in the blocklist (command + output) to the AI query.
pub fn attach_recent_block_as_context() -> TestStep {
TestStep::new("Attach last block as context").add_named_assertion(
"Attach last block as context",
|app, window_id| {
let terminal_view = terminal_view(app, window_id, 0, 0);
terminal_view.update(app, |view, ctx| {
let last_index = {
let model = view.model.lock();
model.block_list().last_non_hidden_block_by_index()
};
if let Some(idx) = last_index {
view.integration_test_change_block_selection_to_single(idx, ctx);
}
});
terminal_view.read(app, |view, ctx| {
let count = view
.ai_context_model()
.as_ref(ctx)
.pending_context_block_ids()
.len();
async_assert!(
count == 1,
"Expected exactly 1 attached context block, got {count}"
)
})
},
)
}
// This will fail immediately on any error responses.
pub fn submit_ai_query_and_wait_until_done(query: &str, timeout: Duration) -> TestStep {
submit_ai_query(query, timeout)
.add_named_assertion(
"Assert the agent task is complete",
assert_latest_task_succeeds_or_blocked(ConversationTarget::Active, None),
)
.add_named_assertion(
"Assert that that input has been returned to the user",
assert_input_is_focused(),
)
}
/// Submits an AI query and waits until the task is blocked (waiting for user approval).
/// This is useful for tests where auto-execution is disabled and you want to verify
/// the command that would be executed without actually running it.
pub fn submit_ai_query_and_wait_until_blocked(query: &str, timeout: Duration) -> TestStep {
submit_ai_query(query, timeout).add_named_assertion(
"Assert the agent task is blocked",
assert_task_is_blocked(ConversationTarget::Active),
)
}
// Runs an AI query without waiting for anything.
// This is useful if you expect a specific sequence of responses (e.g. expect a certain command to be requested immediately),
// since it lets you make assertions on responses as they become ready and fail early instead of waiting for the agent to finish all its turns.
pub fn submit_ai_query(query: &str, timeout: Duration) -> TestStep {
new_step_with_default_assertions_for_pane(&format!("Enter AI query: {query}"), 0, 0)
.set_timeout(timeout)
.set_step_group_name(AGENT_MODE_RUNNING_STEP_GROUP_NAME)
.with_typed_characters(&[query])
.with_keystrokes(&["enter"])
.add_named_assertion(
"Print conversation ID to stdout",
print_conversation_id_assertion(),
)
}
/// Returns an assertion that prints the conversation ID to stdout once available.
/// This assertion will poll until the conversation token is received from the server.
fn print_conversation_id_assertion(
) -> impl FnMut(&mut warpui::App, warpui::WindowId) -> warpui::integration::AssertionOutcome {
|app, window_id| {
use crate::BlocklistAIHistoryModel;
use warpui::integration::AssertionOutcome;
let terminal_view = terminal_view(app, window_id, 0, 0);
BlocklistAIHistoryModel::handle(app).read(app, |history_model, _| {
if let Some(conversation) = history_model.active_conversation(terminal_view.id()) {
if let Some(token) = conversation.server_conversation_token() {
// The debug link within the container will be using host.docker.internal, but we're opening
// from outside the container.
let debug_link = token
.debug_link()
.replace("host.docker.internal", "localhost");
println!("Conversation ID (debug link): {debug_link}");
return AssertionOutcome::Success;
}
}
// If we don't have a conversation token yet, keep polling
AssertionOutcome::failure("Waiting for conversation token to be available".to_string())
})
}
}
/// Sets the preferred agent mode LLM. This is the base model for agent and inline AI conversations.
pub fn set_preferred_agent_mode_llm(llm_id: &str) -> TestStep {
let llm_id = LLMId::from(llm_id);
TestStep::new(&format!("Set preferred agent mode LLM to {llm_id}")).add_named_assertion(
"Update preferred agent mode LLM",
move |app, window_id| {
let llm_id = llm_id.clone();
let terminal_view_id = terminal_view(app, window_id, 0, 0).id();
LLMPreferences::handle(app).update(app, |llm_preferences, ctx| {
// Validate that the LLM ID is actually available. We only do this
// for the base model, since the coding and planning models are
// currently unused in the product.
assert!(
llm_preferences.is_available_agent_mode_llm(&llm_id),
"LLM ID '{llm_id}' is not a valid agent mode LLM",
);
llm_preferences.update_preferred_agent_mode_llm(&llm_id, terminal_view_id, ctx);
});
async_assert!(true, "Successfully updated preferred agent mode LLM")
},
)
}
/// Sets the preferred coding LLM. Note that the server currently ignores this.
pub fn set_preferred_coding_llm(llm_id: &str) -> TestStep {
let llm_id = LLMId::from(llm_id);
TestStep::new(&format!("Set preferred coding LLM to {llm_id}")).add_named_assertion(
"Update preferred coding LLM",
move |app, window_id| {
let llm_id = llm_id.clone();
let terminal_view_id = terminal_view(app, window_id, 0, 0).id();
LLMPreferences::handle(app).update(app, |llm_preferences, ctx| {
llm_preferences.update_preferred_coding_llm(&llm_id, Some(terminal_view_id), ctx);
});
async_assert!(true, "Successfully updated preferred coding LLM")
},
)
}
fn get_input_data(file_name: &str) -> Cursor<Vec<u8>> {
let input_data_dir = std::env::var("INPUT_DATA_DIR").expect(
"INPUT_DATA_DIR is not set. This is needed to hydrate conversations from eval tests.",
);
let path = Path::new(&input_data_dir).join(file_name);
Cursor::new(read(&path).expect("Failed to read binary input data"))
}
/// Sets the execution profile to not auto-execute commands.
/// This changes the `execute_commands` permission from `AlwaysAllow` to `AlwaysAsk`,
/// which means commands will be proposed but not automatically executed.
pub fn set_execution_profile_no_auto_execute() -> TestStep {
TestStep::new("Set execution profile to not auto-execute commands").add_named_assertion(
"Update execution profile",
|app, _window_id| {
AIExecutionProfilesModel::handle(app).update(app, |profiles, ctx| {
let default_profile_id = *profiles.default_profile(ctx).id();
profiles.set_execute_commands(
default_profile_id,
&ActionPermission::AlwaysAsk,
ctx,
);
});
async_assert!(true, "Successfully updated execution profile")
},
)
}
@@ -0,0 +1,46 @@
use std::collections::HashMap;
// User default keys
const IS_ACTIVE_AI_ENABLED: &str = "IsActiveAIEnabled";
const INTELLIGENT_AUTOSUGGESTIONS_ENABLED: &str = "IntelligentAutosuggestionsEnabled";
const NATURAL_LANGUAGE_AUTOSUGGESTIONS_ENABLED: &str = "NaturalLanguageAutosuggestionsEnabled";
const AGENT_MODE_QUERY_SUGGESTIONS_ENABLED: &str = "AgentModeQuerySuggestionsEnabled";
const CODE_SUGGESTIONS_ENABLED: &str = "CodeSuggestionsEnabled";
pub fn user_defaults_map_with_active_ai(enabled: bool) -> HashMap<String, String> {
HashMap::from_iter([
(
INTELLIGENT_AUTOSUGGESTIONS_ENABLED.to_owned(),
enabled.to_string(),
),
(
AGENT_MODE_QUERY_SUGGESTIONS_ENABLED.to_owned(),
enabled.to_string(),
),
(CODE_SUGGESTIONS_ENABLED.to_owned(), enabled.to_string()),
(
NATURAL_LANGUAGE_AUTOSUGGESTIONS_ENABLED.to_owned(),
enabled.to_string(),
),
(IS_ACTIVE_AI_ENABLED.to_owned(), enabled.to_string()),
])
}
/// User defaults for predictable AI input behavior needed in evals.
///
/// This allows tests to more reliably enter and exit AI input mode.
///
/// * UDI is enabled
/// * Natural language detection is disabled
pub fn user_defaults_map_for_ai_input() -> HashMap<String, String> {
HashMap::from_iter([
(
"AIAutoDetectionEnabled".to_owned(),
serde_json::to_string(&false).unwrap(),
),
(
"InputBoxTypeSetting".to_owned(),
serde_json::to_string("Universal").unwrap(),
),
])
}
@@ -0,0 +1,28 @@
/// Check that a CLI command contains a sequence of tokens in order.
/// Useful to ignore flags that may be present between tokens.
pub fn command_contains_sequence(cmd: &str, sequence: &[&str]) -> bool {
let mut required_index = 0;
for token in cmd.split_whitespace() {
if token == sequence[required_index] {
required_index += 1;
if required_index == sequence.len() {
return true;
}
}
}
false
}
pub fn is_running_in_docker() -> bool {
std::path::Path::new("/.dockerenv").exists()
}
pub fn get_base_server_url() -> String {
if is_running_in_docker() {
"http://host.docker.internal:8080".to_string()
} else {
"http://localhost:8080".to_string()
}
}
+172
View File
@@ -0,0 +1,172 @@
use crate::{
cloud_object::{
model::persistence::CloudModel, CloudObjectEventEntrypoint, CloudObjectLocation, Space,
},
network::{NetworkStatus, NetworkStatusKind},
server::{
cloud_objects::{listener::Listener, update_manager::UpdateManager},
ids::ClientId,
},
util::bindings::keybinding_name_to_display_string,
workflows::workflow::Workflow,
workspaces::{team::Team, user_workspaces::UserWorkspaces, workspace::Workspace},
};
use warpui::{async_assert, async_assert_eq, integration::TestStep, SingletonEntity};
fn set_and_assert_network_status(status: NetworkStatusKind) -> TestStep {
TestStep::new("Set and assert network status")
.with_action(move |app, _, _| {
NetworkStatus::handle(app).update(app, |network_status, ctx| {
if matches!(status, NetworkStatusKind::Online) {
network_status.reachability_changed(true, ctx);
} else {
network_status.reachability_changed(false, ctx);
}
});
})
.add_assertion(move |app, _| {
NetworkStatus::handle(app).read(app, |network_status, _| {
async_assert!(
network_status.status() == status,
"network status is correct"
)
})
})
}
pub fn go_offline() -> TestStep {
set_and_assert_network_status(NetworkStatusKind::Offline)
}
pub fn go_online() -> TestStep {
set_and_assert_network_status(NetworkStatusKind::Online)
}
pub fn join_a_workspace() -> TestStep {
TestStep::new("Join a Warp Drive workspace")
.with_action(move |app, _, _| {
UserWorkspaces::handle(app).update(app, |user_workspaces, ctx| {
let workspace_uid = "workspace_uid123456789".to_string().into();
let teams: Vec<Team> = vec![Team {
uid: "team_uid12345678912345".try_into().expect("ID is valid"),
name: "My Team".to_string(),
invite_code: Default::default(),
members: Default::default(),
pending_email_invites: Default::default(),
invite_link_domain_restrictions: Default::default(),
billing_metadata: Default::default(),
stripe_customer_id: None,
organization_settings: Default::default(),
is_eligible_for_discovery: false,
has_billing_history: false,
}];
let workspaces: Vec<Workspace> = vec![Workspace {
uid: workspace_uid,
name: "My Workspace".to_string(),
stripe_customer_id: None,
teams: teams.clone(),
billing_metadata: Default::default(),
bonus_grants_purchased_this_month: Default::default(),
has_billing_history: false,
settings: Default::default(),
invite_code: Default::default(),
invite_link_domain_restrictions: Default::default(),
pending_email_invites: Default::default(),
is_eligible_for_discovery: false,
members: Default::default(),
total_requests_used_since_last_refresh: 0,
}];
user_workspaces.update_workspaces(workspaces, ctx);
user_workspaces.set_current_workspace_uid(workspace_uid, ctx)
});
})
.add_assertion(move |app, _| {
UserWorkspaces::handle(app).read(app, |user_workspaces, _| {
async_assert!(user_workspaces.has_teams(), "user is on a team")
})
})
.add_assertion(move |app, _| {
UserWorkspaces::handle(app).read(app, |user_workspaces, _| {
async_assert!(user_workspaces.has_workspaces(), "user is on a workspace")
})
})
}
pub fn create_a_personal_workflow() -> TestStep {
TestStep::new("Create a personal workflow")
.with_action(move |app, _, _| {
UpdateManager::handle(app).update(app, |update_manager, ctx| {
update_manager.create_workflow(
Workflow::new("My first workflow", "ls"),
UserWorkspaces::as_ref(ctx)
.personal_drive(ctx)
.expect("User UID must be set in tests"),
None,
ClientId::default(),
CloudObjectEventEntrypoint::ManagementUI,
true,
ctx,
)
})
})
.add_assertion(move |app, _| {
CloudModel::handle(app).read(app, |cloud_model, ctx| {
async_assert!(
cloud_model
.active_cloud_objects_in_location_without_descendents(
CloudObjectLocation::Space(Space::Personal),
ctx,
)
.count()
> 0,
"cloud objects exist"
)
})
})
}
pub fn assert_binding_display_string(
binding: &'static str,
display_string: Option<&'static str>,
) -> TestStep {
TestStep::new("Assert a binding's display string").add_named_assertion(
format!("Binding {binding} should have display string {display_string:?}"),
move |app, _| {
app.update(|ctx| {
async_assert_eq!(
keybinding_name_to_display_string(binding, ctx).as_deref(),
display_string
)
})
},
)
}
pub fn assert_websocket_has_started() -> TestStep {
TestStep::new("Assert a websocket has started").add_named_assertion(
"subscription abort handle should exist",
move |app, _| {
Listener::handle(app).read(app, |listener, _| {
async_assert!(
listener.has_current_subscription_abort_handle(),
"subscription has started"
)
})
},
)
}
pub fn assert_websocket_has_not_started() -> TestStep {
TestStep::new("Assert a websocket has not started").add_named_assertion(
"subscription abort handle should not exist",
move |app, _| {
Listener::handle(app).read(app, |listener, _| {
async_assert!(
!listener.has_current_subscription_abort_handle(),
"subscription has not started"
)
})
},
)
}
@@ -0,0 +1,222 @@
use settings::Setting as _;
use warpui::{
async_assert, async_assert_eq,
integration::{AssertionCallback, AssertionOutcome},
units::{IntoPixels, Lines},
AppContext, SingletonEntity, WindowId,
};
use crate::{
integration_testing::view_getters::single_terminal_view,
terminal::block_list_viewport::ViewportState,
};
use crate::{
integration_testing::{
terminal::util::ExpectedOutput, view_getters::single_terminal_view_for_tab,
},
terminal::view::BlockVisibilityMode,
};
use crate::{
settings::InputModeSettings,
terminal::{heights_approx_eq, model::terminal_model::BlockIndex, TerminalModel, TerminalView},
};
/// Specfies a block position either directly by index, or by whether it's first or
/// last
#[derive(Debug, Copy, Clone)]
pub enum BlockPosition {
/// The block at the given index
AtIndex(BlockIndex),
/// The first block in the blocklist
FirstBlock,
/// The last block in the blocklist
LastBlock,
}
impl BlockPosition {
fn block_index(&self, model: &TerminalModel) -> BlockIndex {
match *self {
BlockPosition::AtIndex(index) => index,
BlockPosition::FirstBlock => model
.block_list()
.first_non_hidden_block_by_index()
.expect("no first block"),
BlockPosition::LastBlock => model
.block_list()
.last_non_hidden_block_by_index()
.expect("no last block"),
}
}
}
/// Specifies a line position either directly by number of lines, or by where it is
/// in the viewport
#[derive(Debug, Copy, Clone)]
pub enum LinePosition {
/// At a specific scroll top in lines
AtLines(Lines),
/// At the scroll top of the viewport
AtScrollTop,
/// Directly above the input
AtTopOfInput,
}
impl LinePosition {
fn calculate_lines(
&self,
window_id: WindowId,
view: &TerminalView,
viewport: &ViewportState,
ctx: &AppContext,
) -> Lines {
let top_of_view_in_lines = ctx
.element_position_by_id_at_last_frame(window_id, view.terminal_position_id())
.expect("terminal rendered")
.min_y()
.into_pixels()
.to_lines(view.size_info().cell_height_px());
match *self {
LinePosition::AtLines(lines) => lines,
LinePosition::AtScrollTop => viewport.scroll_top_in_lines(),
LinePosition::AtTopOfInput => {
ctx.element_position_by_id_at_last_frame(
window_id,
view.input().as_ref(ctx).save_position_id(),
)
.expect("input rendered")
.min_y()
.into_pixels()
.to_lines(view.size_info().cell_height_px())
+ viewport.scroll_top_in_lines()
- top_of_view_in_lines
}
}
}
}
/// Asserts there are exactly num_blocks in the model
pub fn assert_num_blocks_in_model(num_blocks: usize) -> AssertionCallback {
Box::new(move |app, window_id| {
let terminal_view = single_terminal_view(app, window_id);
terminal_view.read(app, |view, _ctx| {
let model = view.model.lock();
async_assert_eq!(
num_blocks,
model.block_list().blocks().len(),
"Block list should have {} block but it has {}",
num_blocks,
model.block_list().blocks().len()
)
})
})
}
/// Asserts a block is visible with the given position and visibility mode
pub fn assert_block_visible(
block_position: BlockPosition,
visibility_mode: BlockVisibilityMode,
visible: bool,
) -> AssertionCallback {
Box::new(move |app, window_id| {
let terminal_view = single_terminal_view(app, window_id);
terminal_view.read(app, move |view, ctx| {
let model = view.model.lock();
let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value();
let viewport = view.viewport_state(model.block_list(), input_mode, ctx);
let block_index = block_position.block_index(&model);
let is_block_visible = viewport.is_block_in_view(block_index, visibility_mode);
async_assert_eq!(
visible,
is_block_visible,
"Expected block to be visible {} at index {:?} but was {}",
visible,
block_index,
is_block_visible
)
})
})
}
/// Asserts the top of a block is at the given line position
pub fn assert_top_of_block_approx_at(
block_position: BlockPosition,
line_position: LinePosition,
) -> AssertionCallback {
Box::new(move |app, window_id| {
let terminal_view = single_terminal_view(app, window_id);
terminal_view.read(app, move |view, ctx| {
let model = view.model.lock();
let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value();
let viewport = view.viewport_state(model.block_list(), input_mode, ctx);
let block_index = block_position.block_index(&model);
let top_of_block_in_lines = viewport.top_of_block_in_lines(block_index);
let lines = line_position.calculate_lines(window_id, view, &viewport, ctx);
async_assert!(
heights_approx_eq(top_of_block_in_lines, lines),
"Expected top of block at {:?} ({}) but was {}",
line_position,
lines,
top_of_block_in_lines,
)
})
})
}
/// Asserts the bottom of a block is at the given line position
pub fn assert_bottom_of_block_approx_at(
block_position: BlockPosition,
line_position: LinePosition,
) -> AssertionCallback {
Box::new(move |app, window_id| {
let terminal_view = single_terminal_view(app, window_id);
terminal_view.read(app, move |view, ctx| {
let model = view.model.lock();
let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value();
let viewport = view.viewport_state(model.block_list(), input_mode, ctx);
let block_index = block_position.block_index(&model);
let bottom_of_block_in_lines = viewport.bottom_of_block_in_lines(block_index);
let lines = line_position.calculate_lines(window_id, view, &viewport, ctx);
async_assert!(
heights_approx_eq(bottom_of_block_in_lines, lines),
"Expected bottom of block {:?} ({}) but was {}",
line_position,
lines,
bottom_of_block_in_lines,
)
})
})
}
/// Assertion that the last background output block exists and contains the
/// expected output.
pub fn assert_background_output(
tab_index: usize,
expected_output: impl ExpectedOutput + 'static,
) -> AssertionCallback {
Box::new(move |app, window_id| {
let terminal = single_terminal_view_for_tab(app, window_id, tab_index);
terminal.read(app, |view, _ctx| {
let model = view.model.lock();
let background_block = model
.block_list()
.blocks()
.iter()
.rev()
.find(|block| block.is_background() && !block.finished());
match background_block {
Some(block) => {
let output = block.output_to_string();
async_assert!(
expected_output.matches(&output),
"The background output should be {expected_output:?}, but got {output:?}"
)
}
None => AssertionOutcome::failure("No active background block".into()),
}
})
})
}
+3
View File
@@ -0,0 +1,3 @@
mod assertions;
pub use assertions::*;
@@ -0,0 +1,3 @@
mod step;
pub use step::*;
@@ -0,0 +1,363 @@
use crate::integration_testing::step::new_step_with_default_assertions;
use crate::integration_testing::terminal::assert_long_running_block_executing_for_single_terminal_in_tab;
use crate::integration_testing::terminal::execute_command_for_single_terminal_in_tab;
use crate::integration_testing::terminal::execute_long_running_command;
use crate::integration_testing::terminal::util::ExpectedExitStatus;
use crate::integration_testing::view_getters::single_terminal_view_for_tab;
use crate::terminal::model::terminal_model::BlockIndex;
use std::time::Duration;
use warpui::integration::AssertionCallback;
use warpui::integration::TestStep;
use warpui::{async_assert, async_assert_eq};
/// This test case covers the creates the following output grid:
/// -----------
/// line 0 even
/// line 1 odd
/// line 2 even
/// -----------
///
/// And applies the filter query "odd" so that the resulting output grid becomes:
/// ----------
/// line 1 odd
/// ----------
pub struct SimpleTestCase;
impl SimpleTestCase {
const OUTPUT_LINE_1: &'static str = "line 0 even";
const OUTPUT_LINE_2: &'static str = "line 1 odd";
const OUTPUT_LINE_3: &'static str = "line 2 even";
const OUTPUT_LINES: &'static str = "line 0 even\nline 1 odd\nline 2 even";
const FILTER_QUERY: &'static str = "odd";
pub fn execute_command() -> TestStep {
execute_command_for_single_terminal_in_tab(
0,
format!(
"echo \"{}\"; echo \"{}\"; echo \"{}\";",
Self::OUTPUT_LINE_1,
Self::OUTPUT_LINE_2,
Self::OUTPUT_LINE_3
),
ExpectedExitStatus::Success,
Self::OUTPUT_LINES,
)
}
pub fn perform_filter_query() -> TestStep {
new_step_with_default_assertions("Perform filter query")
.with_typed_characters(&[Self::FILTER_QUERY])
.add_named_assertion(
"Assert that 1 line is left after filtering",
Self::assert_filter_is_applied(),
)
}
pub fn assert_filter_is_applied() -> 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 model = view.model.lock();
let displayed_output_rows = model
.block_list()
.last_non_hidden_block()
.expect("No last non-hidden block found.")
.displayed_output_rows()
.expect("No displayed output rows found.")
.collect::<Vec<_>>();
async_assert_eq!(displayed_output_rows, vec![1])
})
})
}
}
/// This test case covers the creates the following output grid.
/// Note that 123-456-7890 is the secret.
/// -----------
/// line 0 even
/// line 1 (phone number is 123-456-7890) odd
/// line 2 even
/// -----------
///
/// And applies the filter query "odd" so that the resulting output grid becomes:
/// ----------
/// line 1 (phone number is 123-456-7890) odd
/// ----------
pub struct SecretTestCase;
impl SecretTestCase {
const OUTPUT_LINE_1: &'static str = "line 0 even";
const OUTPUT_LINE_2: &'static str = "line 1 (phone number is 123-456-7890) odd";
const OUTPUT_LINE_3: &'static str = "line 2 even";
const OUTPUT_LINES: &'static str =
"line 0 even\nline 1 (phone number is 123-456-7890) odd\nline 2 even";
const FILTER_QUERY: &'static str = "odd";
pub fn execute_command() -> TestStep {
execute_command_for_single_terminal_in_tab(
0,
format!(
"echo \"{}\"; echo \"{}\"; echo \"{}\";",
Self::OUTPUT_LINE_1,
Self::OUTPUT_LINE_2,
Self::OUTPUT_LINE_3
),
ExpectedExitStatus::Success,
Self::OUTPUT_LINES,
)
}
pub fn perform_filter_query() -> TestStep {
new_step_with_default_assertions("Perform filter query")
.with_typed_characters(&[Self::FILTER_QUERY])
.add_named_assertion(
"Assert that 1 line are left after filtering",
Self::assert_filter_is_applied(),
)
}
pub fn assert_filter_is_applied() -> 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 model = view.model.lock();
let displayed_output_rows = model
.block_list()
.last_non_hidden_block()
.expect("No last non-hidden block found.")
.displayed_output_rows()
.expect("No displayed output rows found.")
.collect::<Vec<_>>();
async_assert_eq!(displayed_output_rows, vec![1])
})
})
}
}
/// This test case covers the creates the following output grid.
/// Note that https://google.com is the URL.
/// -----------
/// line 0 even
/// line 1 https://google.com odd
/// line 2 even
/// -----------
///
/// And applies the filter query "odd" so that the resulting output grid becomes:
/// ----------
/// line 1 https://google.com odd
/// ----------
pub struct URLTestCase;
impl URLTestCase {
const OUTPUT_LINE_1: &'static str = "line 0 even";
const OUTPUT_LINE_2: &'static str = "line 1 https://google.com odd";
const OUTPUT_LINE_3: &'static str = "line 2 even";
const OUTPUT_LINES: &'static str = "line 0 even\nline 1 https://google.com odd\nline 2 even";
const FILTER_QUERY: &'static str = "odd";
pub fn execute_command() -> TestStep {
execute_command_for_single_terminal_in_tab(
0,
format!(
"echo \"{}\"; echo \"{}\"; echo \"{}\";",
Self::OUTPUT_LINE_1,
Self::OUTPUT_LINE_2,
Self::OUTPUT_LINE_3
),
ExpectedExitStatus::Success,
Self::OUTPUT_LINES,
)
}
pub fn perform_filter_query() -> TestStep {
new_step_with_default_assertions("Perform filter query")
.with_typed_characters(&[Self::FILTER_QUERY])
.add_named_assertion(
"Assert that 1 line is left after filtering",
Self::assert_filter_is_applied(),
)
}
pub fn assert_filter_is_applied() -> 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 model = view.model.lock();
let displayed_output_rows = model
.block_list()
.last_non_hidden_block()
.expect("No last non-hidden block found.")
.displayed_output_rows()
.expect("No displayed output rows found.")
.collect::<Vec<_>>();
async_assert_eq!(displayed_output_rows, vec![1])
})
})
}
}
/// This test case covers the creates the following output grid in a long running command:
/// -----------
/// line 0 even
/// line 0 even
/// line 1 odd
/// line 1 odd
/// -----------
///
/// And applies the filter query "odd" so that the resulting output grid becomes:
/// ----------
/// line 1 odd
/// line 1 odd
/// ----------
pub struct LongRunningCommandTestCase;
impl LongRunningCommandTestCase {
const OUTPUT_LINE_1: &'static str = "line 0 even";
const OUTPUT_LINE_2: &'static str = "line 1 odd";
const FILTER_QUERY: &'static str = "odd";
pub fn enter_input_into_cat() -> Vec<TestStep> {
let enter_output_line_1_step = TestStep::new("Type in output line 1")
.add_assertion(assert_long_running_block_executing_for_single_terminal_in_tab(false, 0))
.with_typed_characters(&[Self::OUTPUT_LINE_1])
.with_keystrokes(&["enter"])
.add_named_assertion(
"Wait for output line has been printed back",
|app, window_id| {
let terminal_view = single_terminal_view_for_tab(app, window_id, 0);
terminal_view.read(app, |view, _ctx| {
let model = view.model.lock();
let output_grid = model.block_list().active_block().output_grid();
// There should be 3 lines present, including "line 0 even" twice and the cursor line.
async_assert_eq!(output_grid.len(), 3)
})
},
);
let enter_output_line_2_step = TestStep::new("Type in output line 2")
.add_assertion(assert_long_running_block_executing_for_single_terminal_in_tab(false, 0))
.with_typed_characters(&[Self::OUTPUT_LINE_2])
.with_keystrokes(&["enter"])
.add_named_assertion(
"Check that output line has been printed back",
|app, window_id| {
let terminal_view = single_terminal_view_for_tab(app, window_id, 0);
terminal_view.read(app, |view, _ctx| {
let model = view.model.lock();
let output_grid = model.block_list().active_block().output_grid();
// There should be 5 lines present, including "line 0 even" twice, "line 1 odd" twice,
// and the cursor line.
async_assert_eq!(output_grid.len(), 5)
})
},
);
vec![
execute_long_running_command(0, "cat".to_string()),
enter_output_line_1_step,
enter_output_line_2_step,
]
}
pub fn perform_filter_query() -> TestStep {
TestStep::new("Perform filter query")
.with_typed_characters(&[Self::FILTER_QUERY])
.add_named_assertion(
"Assert that 2 filtered lines and cursor line is left after filtering",
Self::assert_filter_is_applied(),
)
}
pub fn assert_filter_is_applied() -> 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 model = view.model.lock();
let displayed_output_rows = model
.block_list()
.last_non_hidden_block()
.expect("No last non-hidden block found.")
.displayed_output_rows()
.expect("No displayed output rows found.")
.collect::<Vec<_>>();
async_assert_eq!(displayed_output_rows, vec![2, 3, 4])
})
})
}
pub fn exit_long_running_command() -> TestStep {
TestStep::new("Check ctrl-c terminates the command")
.with_click_on_saved_position("block_index:0")
.with_keystrokes(&["ctrl-c"])
.set_timeout(Duration::from_secs(10))
.add_assertion(|app, window_id| {
let terminal_view = single_terminal_view_for_tab(app, window_id, 0);
terminal_view.read(app, |view, _ctx| {
let model = view.model.lock();
async_assert!(
!model
.block_list()
.active_block()
.is_active_and_long_running(),
"Check if the command has terminated"
)
})
})
}
}
pub fn open_block_filter_editor() -> TestStep {
new_step_with_default_assertions("Open block filter editor")
.with_hover_over_saved_position("block_index:0")
.with_click_on_saved_position("filter_button_for_block_0")
.add_named_assertion("Assert that block filter is open", |app, window_id| {
let terminal_view = single_terminal_view_for_tab(app, window_id, 0);
terminal_view.read(app, |view, _ctx| {
async_assert_eq!(
view.active_filter_editor_block_index(),
Some(BlockIndex::zero())
)
})
})
}
pub fn open_block_filter_editor_for_long_running_command() -> TestStep {
TestStep::new("Open block filter editor")
.with_hover_over_saved_position("block_index:0")
.with_click_on_saved_position("filter_button_for_block_0")
.add_named_assertion("Assert that block filter is open", |app, window_id| {
let terminal_view = single_terminal_view_for_tab(app, window_id, 0);
terminal_view.read(app, |view, _ctx| {
async_assert_eq!(
view.active_filter_editor_block_index(),
Some(BlockIndex::zero())
)
})
})
}
pub fn open_block_filter_editor_via_keybinding() -> TestStep {
new_step_with_default_assertions("Open block filter editor via keybinding")
.with_keystrokes(&["shift-alt-F"])
.add_named_assertion("Assert that block filter is open", |app, window_id| {
let terminal_view = single_terminal_view_for_tab(app, window_id, 0);
terminal_view.read(app, |view, _ctx| {
async_assert_eq!(
view.active_filter_editor_block_index(),
Some(BlockIndex::zero())
)
})
})
}
pub fn open_block_filter_editor_via_keybinding_long_running_command() -> TestStep {
TestStep::new("Open block filter editor via keybinding")
.with_keystrokes(&["shift-alt-F"])
.add_named_assertion("Assert that block filter is open", |app, window_id| {
let terminal_view = single_terminal_view_for_tab(app, window_id, 0);
terminal_view.read(app, |view, _ctx| {
async_assert_eq!(
view.active_filter_editor_block_index(),
Some(BlockIndex::zero())
)
})
})
}
@@ -0,0 +1,13 @@
use warpui::{async_assert_eq, integration::AssertionCallback};
pub fn assert_clipboard_contains_string(string: String) -> AssertionCallback {
Box::new(move |app, _window_id| {
let clipboard = app.update(|ctx| ctx.clipboard().read());
let content = match clipboard.paths {
Some(paths) => paths.join(" "),
None => clipboard.plain_text,
};
async_assert_eq!(content, string)
})
}
@@ -0,0 +1,5 @@
mod assertion;
mod step;
pub use assertion::*;
pub use step::*;
@@ -0,0 +1,17 @@
use super::assert_clipboard_contains_string;
use warpui::{clipboard::ClipboardContent, integration::TestStep};
pub fn write_to_clipboard(text: String) -> TestStep {
let expected = text.clone();
TestStep::new("Write text to the clipboard")
.with_action(move |app, _, _| {
app.update(|app| {
app.clipboard()
.write(ClipboardContent::plain_text(text.clone()))
})
})
.add_named_assertion(
"Ensure the clipboard contains the text",
assert_clipboard_contains_string(expected),
)
}
@@ -0,0 +1,36 @@
use warpui::{async_assert, integration::AssertionCallback};
use crate::{
cloud_object::{model::persistence::CloudModel, CloudModelType, GenericCloudObject, Revision},
server::ids::{HashableId, ServerId, SyncId, ToServerId},
};
/// Asserts metadata exists for the object with the given key and that the revision in that
/// metadata matches the given expected revision.
pub fn assert_metadata_revision<K, M>(id: &str, expected_revision: i64) -> AssertionCallback
where
K: HashableId + ToServerId + std::fmt::Debug + Into<String> + Clone + 'static,
M: CloudModelType<IdType = K, CloudObjectType = GenericCloudObject<K, M>> + 'static,
{
let id = SyncId::ServerId(ServerId::try_from(id).expect("ID is invalid"));
Box::new(move |app, _window_id| {
let revision =
app.get_singleton_model_handle::<CloudModel>()
.read(app, |cloud_model, _| {
let object = cloud_model
.get_object_of_type::<K, M>(&id)
.expect("object should exist");
object
.metadata
.revision
.clone()
.expect("revision should exist")
});
async_assert!(
revision
== Revision::from_unix_timestamp_micros(expected_revision)
.expect("revison should parse"),
"Expected revision to be:{expected_revision:?}\nBut got:\n{revision:?}"
)
})
}
@@ -0,0 +1,37 @@
mod assertion;
pub use assertion::*;
use futures::{future::join_all, FutureExt};
use itertools::Itertools;
use std::future::Future;
use std::pin::Pin;
use warpui::{App, SingletonEntity};
use crate::{
cloud_object::{model::persistence::CloudModel, Space},
server::cloud_objects::update_manager::UpdateManager,
};
/// Clears the cloud model of all non-welcome objects in the user's personal space.
/// Returns a future that resolves when the cloud model is cleared.
pub fn clear_cloud_model(app: &mut App) -> Pin<Box<dyn Future<Output = ()> + Send>> {
let object_ids_to_delete = CloudModel::handle(app).read(app, |cloud_model, ctx| {
cloud_model
.active_non_welcome_cloud_objects_in_space(Space::Personal, ctx)
.map(|object| object.cloud_object_type_and_id())
.collect_vec()
});
let mut futures = Vec::new();
for object_id in object_ids_to_delete {
UpdateManager::handle(app).update(app, |update_manager, ctx| {
update_manager.delete_object_by_user(object_id, ctx);
if let Some(future_id) = update_manager.spawned_futures().last() {
let future = ctx.await_spawned_future(*future_id);
futures.push(future);
}
});
}
Box::pin(join_all(futures).map(|_| ()))
}
@@ -0,0 +1,222 @@
use std::path::{Path, PathBuf};
use warpui::{
async_assert,
integration::{AssertionCallback, AssertionOutcome, TestStep},
App, ViewHandle, WindowId,
};
use crate::code_review::code_review_view::{CodeReviewView, CodeReviewVisibleAnchorForTest};
/// Expected scroll region type for assertions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollRegion {
Header,
CurrentLine,
RemovedLine,
Footer,
}
fn try_single_code_review_view(
app: &App,
window_id: WindowId,
) -> Option<ViewHandle<CodeReviewView>> {
let views = app.views_of_type::<CodeReviewView>(window_id)?;
if views.len() == 1 {
Some(views[0].clone())
} else {
None
}
}
fn single_code_review_view(app: &App, window_id: WindowId) -> ViewHandle<CodeReviewView> {
try_single_code_review_view(app, window_id)
.expect("expected exactly one code review view in the window")
}
pub fn assert_code_review_loaded() -> AssertionCallback {
Box::new(|app, window_id| {
let Some(code_review_view) = try_single_code_review_view(app, window_id) else {
return AssertionOutcome::failure(
"code review view not yet available in the window".to_string(),
);
};
code_review_view.read(app, |code_review_view, _| {
async_assert!(
code_review_view.has_file_states()
&& code_review_view.all_editors_loaded_for_test(),
"expected code review to have loaded file states and editor buffers"
)
})
})
}
pub fn assert_code_review_anchor(
expected_file_path: impl Into<PathBuf>,
expected_text: impl Into<String>,
expected_line_number: Option<usize>,
) -> AssertionCallback {
let expected_file_path = expected_file_path.into();
let expected_text = expected_text.into();
Box::new(move |app, window_id| {
let Some(code_review_view) = try_single_code_review_view(app, window_id) else {
return AssertionOutcome::failure(
"code review view not yet available in the window".to_string(),
);
};
code_review_view.read(app, |code_review_view, ctx| {
let Some(anchor) = code_review_view.visible_anchor_for_test(ctx) else {
return AssertionOutcome::failure(
"expected a visible code review anchor but none was available".to_string(),
);
};
assert_anchor(
&anchor,
expected_file_path.as_path(),
&expected_text,
expected_line_number,
)
})
})
}
pub fn scroll_code_review_to_line(file_path: impl Into<PathBuf>, line_number: usize) -> TestStep {
let file_path = file_path.into();
TestStep::new("Scroll code review to a file line").with_action(move |app, window_id, _| {
let code_review_view = single_code_review_view(app, window_id);
code_review_view.update(app, |code_review_view, ctx| {
let _ = code_review_view.scroll_to_line_for_test(&file_path, line_number, ctx);
});
})
}
pub fn assert_code_review_line_text(
expected_file_path: impl Into<PathBuf>,
line_number: usize,
expected_text: impl Into<String>,
) -> AssertionCallback {
let expected_file_path = expected_file_path.into();
let expected_text = expected_text.into();
Box::new(move |app, window_id| {
let Some(code_review_view) = try_single_code_review_view(app, window_id) else {
return AssertionOutcome::failure(
"code review view not yet available in the window".to_string(),
);
};
code_review_view.read(app, |code_review_view, ctx| {
let Some(line_text) =
code_review_view.line_text_for_test(expected_file_path.as_path(), line_number, ctx)
else {
return AssertionOutcome::failure(format!(
"expected code review line {line_number} for {:?} to be available",
expected_file_path
));
};
if line_text != expected_text {
return AssertionOutcome::failure(format!(
"expected line {line_number} in {:?} to be {expected_text:?}, got {line_text:?}",
expected_file_path
));
}
AssertionOutcome::Success
})
})
}
fn assert_anchor(
anchor: &CodeReviewVisibleAnchorForTest,
expected_file_path: &Path,
expected_text: &str,
expected_line_number: Option<usize>,
) -> AssertionOutcome {
if anchor.file_path != expected_file_path {
return AssertionOutcome::failure(format!(
"expected anchor file to be {:?}, got {:?}",
expected_file_path, anchor.file_path
));
}
if anchor.line_text != expected_text {
return AssertionOutcome::failure(format!(
"expected anchor text to be {expected_text:?}, got {:?}",
anchor.line_text
));
}
if let Some(expected_line_number) = expected_line_number {
if anchor.line_number != expected_line_number {
return AssertionOutcome::failure(format!(
"expected anchor line number to be {expected_line_number}, got {}",
anchor.line_number
));
}
}
AssertionOutcome::Success
}
pub fn scroll_code_review_to_header(file_path: impl Into<PathBuf>) -> TestStep {
let file_path = file_path.into();
TestStep::new("Scroll code review to header region").with_action(move |app, window_id, _| {
let code_review_view = single_code_review_view(app, window_id);
code_review_view.update(app, |code_review_view, ctx| {
code_review_view.scroll_to_header_for_test(&file_path, ctx);
});
})
}
pub fn scroll_code_review_to_footer(file_path: impl Into<PathBuf>) -> TestStep {
let file_path = file_path.into();
TestStep::new("Scroll code review to footer region").with_action(move |app, window_id, _| {
let code_review_view = single_code_review_view(app, window_id);
code_review_view.update(app, |code_review_view, ctx| {
code_review_view.scroll_to_footer_for_test(&file_path, ctx);
});
})
}
pub fn scroll_code_review_to_deleted_range(
file_path: impl Into<PathBuf>,
near_line: usize,
) -> TestStep {
let file_path = file_path.into();
TestStep::new("Scroll code review to deleted range").with_action(move |app, window_id, _| {
let code_review_view = single_code_review_view(app, window_id);
code_review_view.update(app, |code_review_view, ctx| {
code_review_view.scroll_to_deleted_range_for_test(&file_path, near_line, ctx);
});
})
}
pub fn assert_code_review_scroll_region(expected_region: ScrollRegion) -> AssertionCallback {
let expected_str = match expected_region {
ScrollRegion::Header => "header",
ScrollRegion::CurrentLine => "current_line",
ScrollRegion::RemovedLine => "removed_line",
ScrollRegion::Footer => "footer",
};
Box::new(move |app, window_id| {
let Some(code_review_view) = try_single_code_review_view(app, window_id) else {
return AssertionOutcome::failure(
"code review view not yet available in the window".to_string(),
);
};
code_review_view.read(app, |code_review_view, ctx| {
let actual = code_review_view.scroll_region_for_test(ctx);
if actual != expected_str {
return AssertionOutcome::failure(format!(
"expected scroll region to be {expected_str:?}, got {actual:?}"
));
}
AssertionOutcome::Success
})
})
}
@@ -0,0 +1,3 @@
pub mod step;
pub use step::*;
@@ -0,0 +1,91 @@
use std::{path::PathBuf, time::Duration};
use ai::index::full_source_code_embedding::manager::CodebaseIndexManager;
use settings::Setting;
use warpui::{
async_assert,
integration::{AssertionOutcome, StepData, TestStep},
App, ReadModel, SingletonEntity, UpdateModel, WindowId,
};
use crate::{
integration_testing::step::new_step_with_default_assertions, settings::CodeSettings,
workspace::ActiveSession,
};
const SYNC_DEFAULT_TIMEOUT: Duration = Duration::from_secs(15 * 60);
const CWD_DATA_KEY: &str = "cwd";
fn active_session_cwd(app: &App, window_id: WindowId) -> Option<PathBuf> {
app.read_model(&ActiveSession::handle(app), |active_session, _ctx| {
active_session
.path_if_local(window_id)
.map(|path| path.to_path_buf())
})
}
/// Attempts to sync the git repo at the current working directory of the active
/// session.
///
/// Assumes that the active session is in a git repo. If not, the step will
/// pass.
pub fn sync_current_codebase_index() -> TestStep {
new_step_with_default_assertions("Sync current codebase index")
.set_timeout(SYNC_DEFAULT_TIMEOUT)
.add_assertion(move |app, _window_id| {
app.read_model(&CodeSettings::handle(app), |code_settings, _ctx| {
async_assert!(
*code_settings.codebase_context_enabled.value(),
"Codebase context should be enabled"
)
})
})
.add_assertion(move |app, window_id| {
let Some(cwd) = active_session_cwd(app, window_id) else {
return AssertionOutcome::failure(
"Expected active session to have a cwd".to_string(),
);
};
let Ok(canonicalized_path) = dunce::canonicalize(&cwd) else {
return AssertionOutcome::failure(format!(
"Failed to canonicalize repo path: {}",
cwd.display()
));
};
// Kick off codebase indexing at the current directory.
app.update_model(&CodebaseIndexManager::handle(app), |manager, ctx| {
manager.index_directory(canonicalized_path.clone(), ctx);
});
AssertionOutcome::SuccessWithData(StepData::new(
CWD_DATA_KEY.to_string(),
canonicalized_path,
))
})
.add_named_assertion_with_data_from_prior_step(
"Assert that codebase index has been synced",
|app, _window_id, step_data_map| {
let cwd = step_data_map
.get::<String, PathBuf>(CWD_DATA_KEY.into())
.expect("No cwd");
let status = app.read_model(&CodebaseIndexManager::handle(app), |manager, ctx| {
manager.get_codebase_index_status_for_path(cwd, ctx)
});
match status {
Some(status) => {
async_assert!(
status.has_synced_version()
&& status.last_sync_successful().unwrap_or(false),
"Codebase index for {} should be synced and successful",
cwd.display()
)
}
// If the index hasn't been created, then we are not in a git repo.
// Mark as success to avoid failing evals that are not in git repos.
None => AssertionOutcome::Success,
}
},
)
}
@@ -0,0 +1,45 @@
use crate::integration_testing::view_getters::{command_palette_view, workspace_view};
use warpui::async_assert;
use warpui::integration::AssertionCallback;
/// Asserts that the command palette is currently open.
pub fn assert_command_palette_is_open() -> AssertionCallback {
Box::new(move |app, window_id| {
let workspace = workspace_view(app, window_id);
workspace.read(app, |workspace, _| {
async_assert!(
workspace.is_palette_open(),
"Expected palette to be open, but it was closed"
)
})
})
}
/// Asserts that the command palette is currently closed.
pub fn assert_command_palette_is_closed() -> AssertionCallback {
Box::new(move |app, window_id| {
let workspace = workspace_view(app, window_id);
workspace.read(app, |workspace, _| {
async_assert!(
!workspace.is_palette_open(),
"Expected palette to be closed, but it was open"
)
})
})
}
/// Asserts that the command palette currently has at least one search result.
pub fn assert_command_palette_has_results() -> AssertionCallback {
Box::new(move |app, window_id| {
let palette = command_palette_view(app, window_id);
palette.read(app, |palette, ctx| {
async_assert!(
palette.search_results(ctx).next().is_some(),
"Expected command palette to have results, but it was empty"
)
})
})
}
@@ -0,0 +1,5 @@
mod assertions;
mod step;
pub use assertions::*;
pub use step::*;
@@ -0,0 +1,69 @@
use crate::integration_testing::command_palette::assertions::{
assert_command_palette_has_results, assert_command_palette_is_closed,
assert_command_palette_is_open,
};
use crate::util::bindings::cmd_or_ctrl_shift;
use warpui::integration::{AssertionOutcome, TestStep};
use warpui::{App, WindowId};
/// Extension trait for `Vec<TestStep>` that allows chaining assertions onto the last step.
pub trait TestStepsExt {
fn add_assertion<F>(self, callback: F) -> Self
where
F: FnMut(&mut App, WindowId) -> AssertionOutcome + 'static;
fn add_named_assertion<N, F>(self, name: N, callback: F) -> Self
where
N: Into<String>,
F: FnMut(&mut App, WindowId) -> AssertionOutcome + 'static;
}
impl TestStepsExt for Vec<TestStep> {
fn add_assertion<F>(mut self, callback: F) -> Self
where
F: FnMut(&mut App, WindowId) -> AssertionOutcome + 'static,
{
let last = self.pop().expect("steps should not be empty");
self.push(last.add_assertion(callback));
self
}
fn add_named_assertion<N, F>(mut self, name: N, callback: F) -> Self
where
N: Into<String>,
F: FnMut(&mut App, WindowId) -> AssertionOutcome + 'static,
{
let last = self.pop().expect("steps should not be empty");
self.push(last.add_named_assertion(name, callback));
self
}
}
pub fn open_command_palette() -> TestStep {
TestStep::new("Open Command Palette")
.with_keystrokes(&[cmd_or_ctrl_shift("p")])
.add_assertion(assert_command_palette_is_open())
}
/// Test steps to run an `action` within the command palette.
///
/// Returns two steps: the first opens the palette and types the action text, waiting for
/// search results to appear (needed because async data sources like file search may delay
/// result delivery). The second presses Enter to execute the selected action.
pub fn open_command_palette_and_run_action(action: &str) -> Vec<TestStep> {
vec![
TestStep::new(format!("Type {action} in command palette").as_str())
.with_keystrokes(&[cmd_or_ctrl_shift("p")])
.with_typed_characters(&[action])
.add_assertion(assert_command_palette_has_results()),
TestStep::new(format!("Run {action} in command palette").as_str())
.with_keystrokes(&["enter"]),
]
}
/// Test step to close the command palette.
pub fn close_command_palette() -> TestStep {
TestStep::new("Close command Palette")
.with_keystrokes(&["escape"])
.add_assertion(assert_command_palette_is_closed())
}
@@ -0,0 +1,38 @@
use warpui::{async_assert, async_assert_eq, integration::AssertionCallback};
use crate::{
integration_testing::view_getters::{command_search_view, workspace_view},
search::QueryFilter,
};
pub fn assert_command_search_is_open() -> AssertionCallback {
Box::new(move |app, window_id| {
let workspace_view = workspace_view(app, window_id);
workspace_view.read(app, |workspace, _ctx| {
async_assert!(workspace.is_command_search_open())
})
})
}
pub fn assert_history_filter_is_active() -> AssertionCallback {
Box::new(move |app, window_id| {
let command_search_view = command_search_view(app, window_id);
command_search_view.read(app, |command_search_view, ctx| {
let search_bar = command_search_view.search_bar();
async_assert_eq!(
search_bar.as_ref(ctx).active_query_filter(ctx),
Some(QueryFilter::History)
)
})
})
}
pub fn assert_query(query: impl AsRef<str> + 'static) -> AssertionCallback {
Box::new(move |app, window_id| {
let command_search_view = command_search_view(app, window_id);
command_search_view.read(app, |command_search_view, ctx| {
let search_bar = command_search_view.search_bar();
async_assert_eq!(search_bar.as_ref(ctx).query(ctx).as_str(), query.as_ref())
})
})
}
@@ -0,0 +1,3 @@
mod assertion;
pub use assertion::*;
@@ -0,0 +1,22 @@
use crate::context_chips::ContextChipKind;
use crate::integration_testing::view_getters::single_terminal_view_for_tab;
use warpui::async_assert;
use warpui::integration::AssertionCallback;
/// Assertion that the working dir chip is present in the current prompt.
pub fn assert_working_dir_is_present(tab_index: usize) -> 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 prompt = view.current_prompt();
prompt.read(ctx, |prompt, ctx| {
async_assert!(
prompt
.latest_chip_value(&ContextChipKind::WorkingDirectory, ctx)
.is_some(),
"Working dir chip doesn't have a value"
)
})
})
})
}
@@ -0,0 +1,3 @@
mod assertions;
pub use assertions::*;
+1
View File
@@ -0,0 +1 @@
pub use crate::view_components::find::{Find, FindWithinBlockState};
+103
View File
@@ -0,0 +1,103 @@
use warpui::{
async_assert, async_assert_eq, integration::AssertionCallback, App, ViewHandle, WindowId,
};
use crate::code::editor::goto_line::view::GoToLineView;
use crate::code::editor::view::CodeEditorView;
use warp_editor::content::buffer::ToBufferPoint;
fn file_code_editor_view(app: &App, window_id: WindowId) -> ViewHandle<CodeEditorView> {
let views = app
.views_of_type::<CodeEditorView>(window_id)
.expect("should have CodeEditorView");
views
.iter()
.find(|v| {
v.read(app, |editor, ctx| {
editor.model.as_ref(ctx).line_count(ctx) > 1
})
})
.cloned()
.unwrap_or_else(|| {
views
.first()
.expect("should have at least one CodeEditorView")
.clone()
})
}
pub fn open_goto_line_dialog(app: &mut App, window_id: WindowId) {
let editor = file_code_editor_view(app, window_id);
editor.update(app, |view, ctx| {
view.open_goto_line_for_test(ctx);
});
}
pub fn goto_line_confirm(app: &mut App, window_id: WindowId, input: &str) {
let editor = file_code_editor_view(app, window_id);
let input_owned = input.to_string();
editor.update(app, |view, ctx| {
view.goto_line_confirm_for_test(&input_owned, ctx);
});
}
pub fn assert_goto_line_dialog_is_open(expected: bool) -> AssertionCallback {
Box::new(move |app, window_id| {
let views = app.views_of_type::<GoToLineView>(window_id);
let Some(views) = views else {
return async_assert!(
!expected,
"No GoToLineView found but expected open={expected}"
);
};
let is_open = views
.iter()
.any(|v| v.read(app, |view, _ctx| view.is_open()));
async_assert_eq!(
is_open,
expected,
"Expected GoToLineView is_open={expected}, got {is_open}"
)
})
}
pub fn assert_cursor_at_line(expected_line: usize) -> AssertionCallback {
Box::new(move |app, window_id| {
let editor = file_code_editor_view(app, window_id);
let (cursor_row, raw_row) = editor.read(app, |editor, ctx| {
let selection_model = editor.model.as_ref(ctx).buffer_selection_model();
let head = selection_model.as_ref(ctx).first_selection_head();
let buffer = editor.model.as_ref(ctx).buffer().as_ref(ctx);
let point = head.to_buffer_point(buffer);
(point.row as usize, point.row)
});
async_assert_eq!(
cursor_row,
expected_line,
"Expected cursor at line {expected_line}, got raw_row={raw_row} (cursor_row={cursor_row})"
)
})
}
pub fn assert_cursor_at_line_and_column(
expected_line: usize,
expected_column: usize,
) -> AssertionCallback {
Box::new(move |app, window_id| {
let editor = file_code_editor_view(app, window_id);
let (cursor_row, cursor_col) = editor.read(app, |editor, ctx| {
let selection_model = editor.model.as_ref(ctx).buffer_selection_model();
let head = selection_model.as_ref(ctx).first_selection_head();
let buffer = editor.model.as_ref(ctx).buffer().as_ref(ctx);
let point = head.to_buffer_point(buffer);
(point.row as usize, point.column as usize)
});
let line_match = cursor_row == expected_line;
let col_match = cursor_col == expected_column;
async_assert!(
line_match && col_match,
"Expected cursor at line {expected_line} col {expected_column}, got line {cursor_row} col {cursor_col}",
)
})
}
@@ -0,0 +1,129 @@
use warpui::{async_assert, async_assert_eq, integration::AssertionCallback};
use crate::{
integration_testing::view_getters::{input_view, single_input_view_for_tab},
terminal::input::InputSuggestionsMode,
};
pub fn assert_workflow_info_box_is_open(tab_idx: usize, pane_idx: usize) -> AssertionCallback {
Box::new(move |app, window_id| {
let input = input_view(app, window_id, tab_idx, pane_idx);
input.read(app, |input, _ctx| {
async_assert!(input.is_workflows_info_box_open())
})
})
}
pub fn input_editor_is_focused(tab_idx: usize) -> AssertionCallback {
Box::new(move |app, window_id| {
let input = single_input_view_for_tab(app, window_id, tab_idx);
input.read(app, |input, ctx| {
async_assert!(
input.editor().is_focused(ctx),
"Input editor should be focused"
)
})
})
}
pub fn input_editor_is_not_focused(tab_idx: usize) -> AssertionCallback {
Box::new(move |app, window_id| {
let input = single_input_view_for_tab(app, window_id, tab_idx);
input.read(app, |input, ctx| {
async_assert!(
!input.editor().is_focused(ctx),
"Input editor should not be focused"
)
})
})
}
pub fn input_contains_string(tab_idx: usize, string: String) -> AssertionCallback {
Box::new(move |app, window_id| {
let input = single_input_view_for_tab(app, window_id, tab_idx);
input.read(app, |view, ctx| {
async_assert_eq!(
view.buffer_text(ctx),
string,
"Input should contain string {string}"
)
})
})
}
pub fn input_is_empty(tab_idx: usize) -> AssertionCallback {
Box::new(move |app, window_id| {
let input = single_input_view_for_tab(app, window_id, tab_idx);
input.read(app, |view, ctx| {
async_assert!(view.buffer_text(ctx).is_empty(), "Input should be empty")
})
})
}
pub fn tab_completions_menu_is_open(tab_idx: usize, is_opened: bool) -> AssertionCallback {
Box::new(move |app, window_id| {
let input = single_input_view_for_tab(app, window_id, tab_idx);
input.read(app, |view, ctx| {
let assertion = if is_opened {
matches!(
view.suggestions_mode_model().as_ref(ctx).mode(),
InputSuggestionsMode::CompletionSuggestions { .. }
)
} else {
matches!(
view.suggestions_mode_model().as_ref(ctx).mode(),
InputSuggestionsMode::Closed
)
};
async_assert!(assertion)
})
})
}
pub fn latest_buffer_operations_are_empty(
tab_idx: usize,
should_be_empty: bool,
) -> AssertionCallback {
Box::new(move |app, window_id| {
let input = single_input_view_for_tab(app, window_id, tab_idx);
input.read(app, |view, _ctx| {
if should_be_empty {
async_assert!(view.latest_buffer_operations().count() == 0)
} else {
async_assert!(view.latest_buffer_operations().count() > 0)
}
})
})
}
#[derive(Clone)]
pub enum AutosuggestionState {
/// The autosuggestion is inactive.
Closed,
/// The autosuggestion is active with _some_ text.
Active,
/// The autosuggestion is active and is specifically some text.
ActiveWithText(String),
}
pub fn assert_autosuggestion_state(
tab_idx: usize,
state: AutosuggestionState,
) -> AssertionCallback {
Box::new(move |app, window_id| {
let input = single_input_view_for_tab(app, window_id, tab_idx);
let state = state.clone();
input.read(app, move |view, ctx| {
let autosuggestion = view.editor().as_ref(ctx).current_autosuggestion_text();
let assertion = match state {
AutosuggestionState::Closed => autosuggestion.is_none(),
AutosuggestionState::Active => autosuggestion.is_some(),
AutosuggestionState::ActiveWithText(expected) => {
autosuggestion.is_some_and(|s| expected.as_str() == s)
}
};
async_assert!(assertion)
})
})
}
+5
View File
@@ -0,0 +1,5 @@
mod assertions;
mod step;
pub use assertions::*;
pub use step::*;
+31
View File
@@ -0,0 +1,31 @@
use pathfinder_geometry::vector::Vector2F;
use warpui::integration::TestStep;
use warpui::{windowing::WindowManager, SingletonEntity};
use crate::{
integration_testing::{
step::new_step_with_default_assertions, terminal::assert_context_menu_is_open,
view_getters::single_terminal_view,
},
terminal::view::TerminalAction,
};
pub fn open_input_context_menu() -> TestStep {
new_step_with_default_assertions("Open input context menu")
.with_action(move |app, _, _| {
let window_id = app.read(|ctx| {
WindowManager::as_ref(ctx)
.active_window()
.expect("no active window")
});
let terminal_view_id = single_terminal_view(app, window_id).id();
app.dispatch_typed_action(
window_id,
&[terminal_view_id],
&TerminalAction::OpenInputContextMenu {
position: Vector2F::new(8.5, 8.5),
},
);
})
.add_assertion(assert_context_menu_is_open(true))
}
@@ -0,0 +1,2 @@
#[cfg(not(test))]
pub use crate::keyboard::keybinding_file_path;
@@ -0,0 +1 @@
pub use crate::user_config::launch_configs_dir;
+66
View File
@@ -0,0 +1,66 @@
use std::borrow::Cow;
use warpui::{App, AssetProvider, View, ViewHandle, WindowId};
pub mod agent_mode;
pub mod assertions;
pub mod block;
pub mod block_filtering;
pub mod clipboard;
pub mod cloud_object;
pub mod code_review;
pub mod codebase_context;
pub mod command_palette;
pub mod command_search;
pub mod context_chips;
pub mod find;
pub mod goto_line;
pub mod input;
pub mod keybindings;
pub mod launch_configs;
pub mod navigation_palette;
pub mod notebook;
pub mod pane_group;
pub mod persistence;
#[cfg(target_os = "macos")]
pub mod preview_config_migration;
pub mod rules;
pub mod secret_redaction;
pub mod settings;
pub mod step;
pub mod subshell;
pub mod tab;
pub mod terminal;
pub mod themes;
pub mod type_getters;
pub mod view_getters;
pub mod warp_drive;
pub mod window;
pub mod workflow;
pub mod workspace;
pub fn view_of_type<T: View>(app: &App, window_id: WindowId, tab_index: usize) -> ViewHandle<T> {
app.views_of_type(window_id)
.expect("should be views for window")
.get(tab_index)
.expect("should be an input view at index")
.clone()
}
pub fn create_file_from_assets(
assets: impl AssetProvider,
asset_src: &str,
dest_path: &std::path::Path,
) {
let bytes = assets
.get(asset_src)
.expect("Should be able to retrieve file");
create_file_with_contents(<Cow<'_, [u8]> as AsRef<[u8]>>::as_ref(&bytes), dest_path);
}
pub fn create_file_with_contents(contents: impl AsRef<[u8]>, file_path: &std::path::Path) {
let mut file =
crate::util::file::create_file(file_path).expect("Should be able to create file");
std::io::Write::write_all(&mut file, contents.as_ref())
.expect("Should be able to write to file");
}
@@ -0,0 +1,99 @@
use warpui::integration::AssertionCallback;
use warpui::{async_assert, integration::AssertionOutcome, App, ViewHandle, WindowId};
use crate::integration_testing::view_getters::workspace_view;
use crate::palette::PaletteMode;
use crate::pane_group::{PaneId, PaneView};
use crate::{
integration_testing::view_getters::command_palette_view,
search::{command_palette::ItemSummary, QueryFilter},
terminal::TerminalView,
};
/// Used to determine which session should be the most recent in Navigation Palette integration tests.
pub enum RecentSession {
First,
Second,
}
/// Asserts that the navigation filter is currently enabled within the command palette.
pub fn assert_navigation_mode_enabled_in_command_palette() -> AssertionCallback {
Box::new(move |app, window_id| {
let workspace = workspace_view(app, window_id);
workspace.read(app, |workspace, ctx| {
async_assert!(
workspace.is_palette_mode_enabled(PaletteMode::Navigation, ctx),
"Expected navigation palette to be enabled"
)
})
})
}
/// Asserts that one of `first_pane_view` and `second_pane_view` is the most recent in the
/// command palette, depending on the value of [`RecentSession`].
pub fn check_recency(
first_pane_view: ViewHandle<PaneView<TerminalView>>,
second_pane_view: ViewHandle<PaneView<TerminalView>>,
recency_test: RecentSession,
app: &App,
window_id: WindowId,
) -> AssertionOutcome {
let command_palette = command_palette_view(app, window_id);
command_palette.read(app, |palette, app| {
assert_eq!(
palette.active_query_filter(app),
Some(QueryFilter::Sessions),
"Sessions query filter is not applied"
);
let mut search_results = palette.search_results(app);
let first_item = search_results
.next()
.expect("first item doesn't exist in search results")
.accept_result()
.to_summary();
let second_item = search_results
.next()
.expect("second item doesn't exist in search results")
.accept_result()
.to_summary();
let ItemSummary::Session {
pane_view_locator: recent_session,
} = first_item
else {
return AssertionOutcome::failure(
"First item in command palette is not a session".to_string(),
);
};
let ItemSummary::Session {
pane_view_locator: previous_session,
} = second_item
else {
return AssertionOutcome::failure(
"second item in command palette is not a session".to_string(),
);
};
match recency_test {
RecentSession::First => {
async_assert!(
recent_session.pane_id == PaneId::from_terminal_pane_view(&first_pane_view)
&& previous_session.pane_id
== PaneId::from_terminal_pane_view(&second_pane_view),
"First session is not most recent., "
)
}
RecentSession::Second => {
async_assert!(
recent_session.pane_id == PaneId::from_terminal_pane_view(&second_pane_view)
&& previous_session.pane_id
== PaneId::from_terminal_pane_view(&first_pane_view),
"Second session is not most recent."
)
}
}
})
}
@@ -0,0 +1,5 @@
mod assertion;
mod step;
pub use assertion::*;
pub use step::*;
@@ -0,0 +1,31 @@
use warpui::{async_assert, integration::TestStep, ViewHandle};
use crate::integration_testing::command_palette::assert_command_palette_is_open;
use crate::integration_testing::navigation_palette::assert_navigation_mode_enabled_in_command_palette;
use crate::util::bindings::cmd_or_ctrl_shift;
use crate::{integration_testing::step::new_step_with_default_assertions, workspace::Workspace};
pub fn open_navigation_palette_step() -> TestStep {
new_step_with_default_assertions("Open Navigation Palette")
.with_keystrokes(&[cmd_or_ctrl_shift("p")])
.with_typed_characters(&["s"])
.with_keystrokes(&["tab"])
.add_assertion(assert_command_palette_is_open())
.add_assertion(assert_navigation_mode_enabled_in_command_palette())
}
pub fn navigate_to_other_session_step() -> TestStep {
new_step_with_default_assertions("Navigate to original tab using Navigation Palette.")
.with_keystrokes(&["down", "enter"])
.add_assertion(move |app, window_id| {
let views: Vec<ViewHandle<Workspace>> =
app.views_of_type(window_id).expect("No workspace found");
let workspace = views.first().expect("No workspace in views");
workspace.read(app, |view, _| {
async_assert!(
!view.is_palette_open(),
"Palette should be closed after hitting enter"
)
})
})
}
@@ -0,0 +1,189 @@
use itertools::Itertools;
use string_offset::CharOffset;
use warp_editor::render::model::BlockItem;
use warpui::{
async_assert, async_assert_eq,
integration::{AssertionCallback, AssertionOutcome, AssertionWithDataCallback},
App, ViewHandle,
};
use crate::{
cloud_object::model::{generic_string_model::GenericStringObjectId, persistence::CloudModel},
integration_testing::{
cloud_object::assert_metadata_revision,
terminal::util::ExpectedOutput,
view_getters::{notebook_view, terminal_view},
},
notebooks::{notebook::NotebookView, CloudNotebookModel, NotebookId},
pane_group::PaneGroup,
server::ids::SyncId,
settings::{CloudPreferenceModel, Preference},
};
/// Asserts that the notebook in the given pane has the expected Markdown content.
pub fn assert_notebook_contents(
tab_index: usize,
pane_index: usize,
expected_contents: impl ExpectedOutput + 'static,
) -> AssertionCallback {
Box::new(move |app, window_id| {
let notebook = notebook_view(app, window_id, tab_index, pane_index);
notebook.read(app, |notebook, ctx| {
let contents = notebook.content(ctx);
async_assert!(
expected_contents.matches(&contents),
"Expected notebook contents for window_id={window_id}, tab_index={tab_index}, pane_index={pane_index} to match:\n{expected_contents:?}\nBut got:\n{contents}")
})
})
}
/// Asserts that there is a json preference object in the SQLite db with the given contents.
pub fn assert_cloud_preference_exists(expected_preference: Preference) -> AssertionCallback {
Box::new(move |app, _window_id| {
let stored_preference =
app.get_singleton_model_handle::<CloudModel>()
.read(app, |cloud_model, _| {
let object = cloud_model
.get_all_objects_of_type::<GenericStringObjectId, CloudPreferenceModel>()
.find(|p| p.model().string_model == expected_preference)
.expect("Expected to find a matching preference object");
object.model().string_model.clone()
});
async_assert!(
expected_preference == stored_preference,
"Expected json object contents to match:\n{expected_preference:?}\nBut got:\n{stored_preference:?}"
)
})
}
/// Asserts metadata exists for the notebook with the given key and that the revision in that
/// metadata matches the given expected revision.
pub fn assert_notebook_metadata_revision(
id: impl AsRef<str>,
expected_revision: i64,
) -> AssertionCallback {
assert_metadata_revision::<NotebookId, CloudNotebookModel>(id.as_ref(), expected_revision)
}
/// Asserts that a pane has the given notebook open.
pub fn assert_notebook_id(
tab_index: usize,
pane_index: usize,
expected_id_key: impl Into<String>,
) -> AssertionWithDataCallback {
let expected_id_key = expected_id_key.into();
Box::new(move |app, window_id, data| {
let expected_id = data.get(&expected_id_key).expect("No saved notebook ID");
let notebook = notebook_view(app, window_id, tab_index, pane_index);
notebook.read(app, |notebook, ctx| {
let id = notebook.notebook_id(ctx);
async_assert_eq!(
id, Some(*expected_id),
"Expected window_id={window_id}, tab_index={tab_index}, pane_index={pane_index} to contain {expected_id:?}, but got {id:?}")
})
})
}
/// Asserts that a notebook is open exactly once in the app.
pub fn assert_notebook_open(notebook_id_key: impl Into<String>) -> AssertionWithDataCallback {
let notebook_id_key = notebook_id_key.into();
Box::new(move |app, _, data| {
let notebook_id = data.get(&notebook_id_key).expect("No saved notebook ID");
let open_notebooks = notebook_views(app, *notebook_id)
.filter(|view| !is_notebook_in_hidden_pane(app, view))
.collect_vec();
assert_eq!(
open_notebooks.len(),
1,
"Expected exactly one open notebook for {notebook_id:?}, but found: {open_notebooks:?}"
);
AssertionOutcome::Success
})
}
/// Asserts that a notebook is not open anywhere in the app.
pub fn assert_notebook_not_open(notebook_id_key: impl Into<String>) -> AssertionWithDataCallback {
let notebook_id_key = notebook_id_key.into();
Box::new(move |app, _, data| {
let notebook_id = data.get(&notebook_id_key).expect("No saved notebook ID");
if let Some(notebook_view) =
notebook_views(app, *notebook_id).find(|view| !is_notebook_in_hidden_pane(app, view))
{
panic!("Expected {notebook_id:?} to be closed, but was open in {notebook_view:?}");
}
AssertionOutcome::Success
})
}
/// Checks if a notebook view is in a pane that's hidden for close.
fn is_notebook_in_hidden_pane(app: &App, notebook_view: &ViewHandle<NotebookView>) -> bool {
for window_id in app.window_ids() {
if let Some(pane_groups) = app.views_of_type::<PaneGroup>(window_id) {
for pane_group in pane_groups {
let is_hidden = pane_group.read(app, |pg, ctx| {
for pane_id in pg.pane_ids() {
if let Some(notebook_pane) = pg.notebook_pane_by_pane_id(Some(pane_id)) {
if notebook_pane.notebook_view(ctx).id() == notebook_view.id() {
return pg.is_pane_hidden_for_close(pane_id);
}
}
}
false
});
if is_hidden {
return true;
}
}
}
}
false
}
/// Finds all notebook views with the given notebook open.
fn notebook_views(app: &App, id: SyncId) -> impl Iterator<Item = ViewHandle<NotebookView>> + '_ {
app.window_ids()
.into_iter()
.flat_map(|window_id| app.views_of_type::<NotebookView>(window_id))
.flatten()
.filter(move |view| view.read(app, |view, ctx| view.notebook_id(ctx)) == Some(id))
}
pub fn assert_open_in_warp_banner_open(tab_index: usize, pane_index: usize) -> AssertionCallback {
Box::new(move |app, window_id| {
let terminal = terminal_view(app, window_id, tab_index, pane_index);
terminal.read(app, |view, _ctx| {
async_assert!(
view.is_open_in_warp_banner_open(),
"Expected the 'Open in Warp' banner to be open"
)
})
})
}
pub fn assert_notebook_renders_mermaid_diagram(
tab_index: usize,
pane_index: usize,
mermaid_block_start: usize,
) -> AssertionCallback {
Box::new(move |app, window_id| {
let editor = notebook_view(app, window_id, tab_index, pane_index)
.read(app, |notebook, _ctx| notebook.input_editor());
editor.read(app, |editor, ctx| {
let is_mermaid_diagram = editor
.model()
.as_ref(ctx)
.render_state()
.as_ref(ctx)
.content()
.block_at_offset(CharOffset::from(mermaid_block_start))
.is_some_and(|block| matches!(&block.item, BlockItem::MermaidDiagram { .. }));
async_assert!(
is_mermaid_diagram,
"Expected notebook editor to render Mermaid in editable mode"
)
})
})
}
@@ -0,0 +1,5 @@
mod assertion;
mod step;
pub use assertion::*;
pub use step::*;
@@ -0,0 +1,125 @@
use std::sync::Arc;
use string_offset::CharOffset;
use warp_editor::model::CoreEditorModel;
use warpui::{
async_assert, integration::TestStep, windowing::WindowManager, App, SingletonEntity,
ViewHandle, WindowId,
};
use crate::{
cloud_object::{model::persistence::CloudModel, CloudObjectEventEntrypoint, Space},
drive::OpenWarpDriveObjectSettings,
integration_testing::view_getters::{notebook_view, workspace_view},
notebooks::manager::NotebookSource,
server::{
cloud_objects::update_manager::UpdateManager,
ids::{ClientId, SyncId},
},
workspaces::user_workspaces::UserWorkspaces,
};
fn notebook_editor(
app: &App,
window_id: WindowId,
tab_index: usize,
pane_index: usize,
) -> ViewHandle<crate::notebooks::editor::view::RichTextEditorView> {
notebook_view(app, window_id, tab_index, pane_index)
.read(app, |notebook, _ctx| notebook.input_editor())
}
/// Create a personal notebook and save its sync ID into the step data.
pub fn create_a_personal_notebook(key: impl Into<String>, title: impl Into<String>) -> TestStep {
let key = key.into();
let title = Arc::new(title.into());
TestStep::new("Create a personal notebook")
.with_action(move |app, _, data| {
let client_id = ClientId::new();
let sync_id = SyncId::ClientId(client_id);
UpdateManager::handle(app).update(app, |update_manager, ctx| {
update_manager.create_notebook(
client_id,
UserWorkspaces::as_ref(ctx)
.personal_drive(ctx)
.expect("User UID must be set in tests"),
None,
Default::default(),
CloudObjectEventEntrypoint::ManagementUI,
true,
ctx,
);
// Set a title so that the notebook is not considered empty.
update_manager.update_notebook_title(title.clone(), sync_id, ctx);
});
data.insert(key.clone(), sync_id);
})
.add_assertion(move |app, _| {
CloudModel::handle(app).read(app, |cloud_model, ctx| {
async_assert!(
cloud_model
.active_cloud_objects_in_space(Space::Personal, ctx)
.count()
> 0,
"Notebook exists"
)
})
})
}
/// Open the notebook saved at `notebook_key` in the active tab of the window saved at `window_key`
pub fn open_notebook(window_key: impl Into<String>, notebook_key: impl Into<String>) -> TestStep {
let window_key = window_key.into();
let notebook_key = notebook_key.into();
TestStep::new("Open notebook").with_action(move |app, _, data| {
let notebook_id: &SyncId = data.get(&notebook_key).expect("No saved notebook ID");
let window_id: &WindowId = data.get(&window_key).expect("No saved window ID");
workspace_view(app, *window_id).update(app, |workspace, ctx| {
// If the notebook isn't open yet, opening it won't focus the window (we only change
// focus if switching to an already-open window). Since the user wouldn't be able to
// open a notebook in an unfocused window, switch focus explicitly here.
WindowManager::as_ref(ctx).show_window_and_focus_app(*window_id);
workspace.open_notebook(
&NotebookSource::Existing(*notebook_id),
&OpenWarpDriveObjectSettings::default(),
ctx,
true,
);
})
})
}
pub fn enter_notebook_edit_mode_and_set_markdown(
tab_index: usize,
pane_index: usize,
markdown: impl Into<String>,
) -> TestStep {
let markdown = markdown.into();
TestStep::new("Enter notebook edit mode and set Markdown").with_action(
move |app, window_id, _| {
let notebook = notebook_view(app, window_id, tab_index, pane_index);
notebook.update(app, |notebook, ctx| notebook.toggle_mode(ctx));
let editor = notebook_editor(app, window_id, tab_index, pane_index);
editor.update(app, |editor, ctx| {
editor.reset_with_markdown(&markdown, ctx);
});
},
)
}
pub fn move_notebook_cursor_to_offset(
tab_index: usize,
pane_index: usize,
offset: usize,
) -> TestStep {
TestStep::new("Move notebook cursor to offset").with_action(move |app, window_id, _| {
let editor = notebook_editor(app, window_id, tab_index, pane_index);
editor.update(app, |editor, ctx| {
editor.model().update(ctx, |model, ctx| {
model.cursor_at(CharOffset::from(offset), ctx)
});
});
})
}
@@ -0,0 +1,58 @@
use warpui::{
async_assert_eq,
integration::{AssertionCallback, AssertionOutcome},
};
use crate::integration_testing::view_getters::pane_group_view;
pub fn assert_num_shared_sessions_in_pane_group(
tab_index: usize,
num_shared_sessions: usize,
) -> AssertionCallback {
Box::new(move |app, window_id| {
let pane_group = pane_group_view(app, window_id, tab_index);
pane_group.read(app, |view, ctx| {
async_assert_eq!(view.number_of_shared_sessions(ctx), num_shared_sessions)
})
})
}
pub fn assert_num_panes_in_tab(tab_index: usize, num_panes: usize) -> AssertionCallback {
Box::new(move |app, window_id| {
let pane_group = pane_group_view(app, window_id, tab_index);
pane_group.read(app, |view, _| {
async_assert_eq!(view.pane_count(), num_panes)
})
})
}
pub fn assert_focused_pane_index(tab_index: usize, pane_index: usize) -> AssertionCallback {
Box::new(move |app, window_id| {
let pane_group = pane_group_view(app, window_id, tab_index);
pane_group.read(app, |view, ctx| {
let Some(pane_id) = view.pane_id_from_index(pane_index) else {
return AssertionOutcome::failure(format!("no pane at pane_index {pane_index}"));
};
async_assert_eq!(view.focused_pane_id(ctx), pane_id)
})
})
}
pub fn assert_pane_header_overlay_is_open(
should_be_open: bool,
tab_index: usize,
pane_index: usize,
) -> AssertionCallback {
Box::new(move |app, window_id| {
pane_group_view(app, window_id, tab_index).read(app, |pane_group, app| {
pane_group
.terminal_pane_view_at_pane_index(pane_index)
.unwrap()
.read(app, |pane_view, _| {
pane_view.header().read(app, |pane_header, _| {
async_assert_eq!(pane_header.is_overlay_open(), should_be_open)
})
})
})
})
}
@@ -0,0 +1,5 @@
mod assertions;
mod step;
pub use assertions::*;
pub use step::*;
@@ -0,0 +1,55 @@
use warpui::integration::TestStep;
use crate::integration_testing::view_getters::pane_group_view;
use crate::pane_group::tree::Direction;
/// Close a specific pane by its index within a tab.
pub fn close_pane_by_index(tab_index: usize, pane_index: usize) -> TestStep {
TestStep::new("Close pane by index").with_action(move |app, _, _| {
let active_window = app
.read(|ctx| ctx.windows().active_window())
.expect("no active window");
let pg = pane_group_view(app, active_window, tab_index);
let pane_id = pg.read(app, |pane_group, _ctx| {
pane_group
.pane_id_from_index(pane_index)
.expect("missing pane index")
});
pg.update(app, |pane_group, ctx| {
pane_group.close_pane(pane_id, ctx);
});
})
}
/// Move one pane relative to another by pane indices within a tab.
pub fn move_pane_by_indices(
tab_index: usize,
from_pane_index: usize,
to_pane_index: usize,
direction: Direction,
) -> TestStep {
TestStep::new("Move pane by indices").with_action(move |app, _, _| {
let active_window = app
.read(|ctx| ctx.windows().active_window())
.expect("window exists");
let pg = pane_group_view(app, active_window, tab_index);
let (from_id, to_id) = pg.read(app, |pane_group, _ctx| {
let a = pane_group
.pane_id_from_index(from_pane_index)
.expect("missing from pane index");
let b = pane_group
.pane_id_from_index(to_pane_index)
.expect("missing to pane index");
(a, b)
});
pg.update(app, |pane_group, ctx| {
pane_group.move_pane(from_id, to_id, direction, ctx);
});
})
}
@@ -0,0 +1,2 @@
#[cfg(feature = "local_fs")]
pub use crate::persistence::database_file_path;
@@ -0,0 +1,17 @@
//! Integration-testing helpers for the Preview config directory migration.
//!
//! The production entry point (`migrate_preview_config_dir_if_needed`) checks
//! `ChannelState::channel() == Channel::Preview` before doing anything, so it
//! cannot be used directly in integration tests (which run under
//! `Channel::Integration`). This helper exposes the inner path-based migration
//! so tests can drive it with explicit directories.
use std::path::Path;
/// Runs the core symlink-based migration from `old_dir` into `new_dir`.
///
/// Thin wrapper around the internal
/// [`crate::preview_config_migration::migrate_config_dir_via_symlinks`].
pub fn run_config_dir_symlink_migration(old_dir: &Path, new_dir: &Path) {
crate::preview_config_migration::migrate_config_dir_via_symlinks(old_dir, new_dir);
}
@@ -0,0 +1,72 @@
use warpui::{
async_assert, async_assert_eq,
integration::{AssertionCallback, AssertionWithDataCallback},
AppContext, SingletonEntity,
};
use crate::{
ai::facts::{view::AIFactPage, CloudAIFactModel},
cloud_object::model::{generic_string_model::GenericStringObjectId, persistence::CloudModel},
integration_testing::view_getters::workspace_view,
server::ids::SyncId,
};
/// Assert that a specific AI fact exists with the given content
pub fn assert_rule_exists(
expected_id_key: impl Into<String>,
expected_content: impl Into<String>,
) -> AssertionWithDataCallback {
let expected_id_key = expected_id_key.into();
let expected_content = expected_content.into();
Box::new(move |app, _window_id, data| {
let sync_id: &SyncId = data.get(&expected_id_key).expect("No saved AI fact ID");
CloudModel::handle(app).read(app, |cloud_model, _| {
if let Some(ai_fact) =
cloud_model.get_object_of_type::<GenericStringObjectId, CloudAIFactModel>(sync_id)
{
let content = match &ai_fact.model().string_model {
crate::ai::facts::AIFact::Memory(memory) => &memory.content,
};
async_assert_eq!(content, &expected_content, "AI fact content should match")
} else {
async_assert!(false, "AI fact should exist")
}
})
})
}
/// Assert that the total number of AI facts matches the expected count
pub fn assert_rule_count(expected_count: usize) -> AssertionCallback {
Box::new(move |app, _| {
CloudModel::handle(app).read(app, |cloud_model, ctx| {
let count = rule_count(cloud_model, ctx);
async_assert_eq!(count, expected_count, "Rule count should match")
})
})
}
/// Helper function to count AI facts in the cloud model
pub fn rule_count(cloud_model: &CloudModel, _ctx: &AppContext) -> usize {
cloud_model
.get_all_objects_of_type::<GenericStringObjectId, CloudAIFactModel>()
.count()
}
pub fn assert_rule_pane_open(key: impl Into<String>) -> AssertionWithDataCallback {
let key = key.into();
Box::new(move |app, window_id, data| {
workspace_view(app, window_id).read(app, |workspace, _ctx| {
let sync_id: &SyncId = data.get(&key).expect("No saved AI fact ID");
workspace.ai_fact_view().read(app, |ai_fact_view, _ctx| {
let current_page = ai_fact_view.current_page();
async_assert_eq!(
current_page,
AIFactPage::RuleEditor {
sync_id: Some(*sync_id)
},
"Rule pane should be open"
)
})
})
})
}
+5
View File
@@ -0,0 +1,5 @@
pub mod assertion;
pub mod step;
pub use assertion::*;
pub use step::*;
+103
View File
@@ -0,0 +1,103 @@
use std::sync::Arc;
use warpui::{
async_assert, integration::TestStep, windowing::WindowManager, SingletonEntity, WindowId,
};
use crate::{
ai::facts::{view::AIFactPage, AIMemory},
cloud_object::{model::persistence::CloudModel, Space},
integration_testing::view_getters::workspace_view,
server::{
cloud_objects::update_manager::UpdateManager,
ids::{ClientId, SyncId},
},
workspaces::user_workspaces::UserWorkspaces,
};
/// Create a personal rule and save its sync ID into the step data.
pub fn create_a_personal_rule(
key: impl Into<String>,
name: impl Into<String>,
content: impl Into<String>,
) -> TestStep {
let key = key.into();
let name = Arc::new(Some(name.into()));
let content = Arc::new(content.into());
TestStep::new("Create a personal rule")
.with_action(move |app, _, data| {
let client_id = ClientId::new();
let sync_id = SyncId::ClientId(client_id);
UpdateManager::handle(app).update(app, |update_manager, ctx| {
let ai_fact = crate::ai::facts::AIFact::Memory(AIMemory {
is_autogenerated: false,
name: name.as_ref().clone(),
content: content.as_ref().clone(),
suggested_logging_id: None,
});
update_manager.create_ai_fact(
ai_fact,
client_id,
UserWorkspaces::as_ref(ctx)
.personal_drive(ctx)
.expect("User UID must be set in tests"),
ctx,
);
});
data.insert(key.clone(), sync_id);
})
.add_assertion(move |app, _| {
CloudModel::handle(app).read(app, |cloud_model, ctx| {
async_assert!(
cloud_model
.active_cloud_objects_in_space(Space::Personal, ctx)
.count()
> 0,
"Rule exists"
)
})
})
}
/// Open the rule pane saved at `key` in the active tab of the window saved at `window_key`
pub fn open_rule_pane(window_key: impl Into<String>, key: impl Into<String>) -> TestStep {
let window_key = window_key.into();
let key = key.into();
TestStep::new("Open rule pane").with_action(move |app, _, data| {
let window_id: &WindowId = data.get(&window_key).expect("No saved window ID");
let fact_id: &SyncId = data.get(&key).expect("No saved rule ID");
workspace_view(app, *window_id).update(app, |workspace, ctx| {
// Focus the window first
WindowManager::as_ref(ctx).show_window_and_focus_app(*window_id);
// Open the AI facts pane
let page = AIFactPage::RuleEditor {
sync_id: Some(*fact_id),
};
workspace.open_ai_fact_collection_pane(None, Some(page), ctx);
})
})
}
/// Update a rule's content
pub fn update_rule_content(
fact_key: impl Into<String>,
new_content: impl Into<String>,
) -> TestStep {
let fact_key = fact_key.into();
let new_content = Arc::new(new_content.into());
TestStep::new("Update rule content").with_action(move |app, _, data| {
let sync_id: &SyncId = data.get(&fact_key).expect("No saved rule ID");
UpdateManager::handle(app).update(app, |update_manager, ctx| {
let ai_fact = crate::ai::facts::AIFact::Memory(AIMemory {
is_autogenerated: false,
name: None,
content: new_content.as_ref().clone(),
suggested_logging_id: None,
});
update_manager.update_ai_fact(ai_fact, *sync_id, None, ctx);
});
})
}
@@ -0,0 +1,79 @@
use warpui::{
async_assert_eq,
integration::{AssertionCallback, AssertionOutcome},
};
use crate::{
ai::{
agent::redaction::redact_secrets, blocklist::block::secret_redaction::find_secrets_in_text,
},
integration_testing::view_getters::single_terminal_view,
terminal::safe_mode_settings::get_secret_obfuscation_mode,
};
pub fn assert_secret_tooltip_open(open: bool) -> AssertionCallback {
Box::new(move |app, window_id| {
let terminal_view = single_terminal_view(app, window_id);
let error_message = if open {
"The secret tooltip should be open"
} else {
"The secret tooltip should not be open"
};
terminal_view.read(app, |view, _ctx| {
async_assert_eq!(view.is_secret_tooltip_open(), open, "{}", error_message)
})
})
}
/// Assert that secrets are properly redacted for AI conversations in both modes
pub fn assert_secrets_redacted_for_ai(
test_text: String,
expected_phone_redaction: String,
expected_api_key_redaction: String,
original_phone: String,
original_api_key: String,
) -> AssertionCallback {
Box::new(move |app, window_id| {
let terminal_view = single_terminal_view(app, window_id);
terminal_view.read(app, |_view, ctx| {
let secret_redaction_mode = get_secret_obfuscation_mode(ctx);
// Test that we properly detect secrets in the input
let detected_secrets = find_secrets_in_text(&test_text);
if detected_secrets.is_empty() {
return AssertionOutcome::failure(format!(
"Should detect secrets in test text: {test_text}"
));
}
// Test that redaction works for both modes when sending to AI
if secret_redaction_mode.should_redact_secret() {
let mut redacted_text = test_text.clone();
redact_secrets(&mut redacted_text);
if !redacted_text.contains(&expected_phone_redaction) {
return AssertionOutcome::failure(format!(
"Phone number should be redacted in text sent to AI: {redacted_text}"
));
}
if !redacted_text.contains(&expected_api_key_redaction) {
return AssertionOutcome::failure(format!(
"API key should be redacted in text sent to AI: {redacted_text}"
));
}
if redacted_text.contains(&original_phone) {
return AssertionOutcome::failure(format!(
"Original phone number should not appear in redacted text: {redacted_text}"
));
}
if redacted_text.contains(&original_api_key) {
return AssertionOutcome::failure(format!(
"Original API key should not appear in redacted text: {redacted_text}"
));
}
}
AssertionOutcome::Success
})
})
}
@@ -0,0 +1,3 @@
mod assertion;
pub use assertion::*;
@@ -0,0 +1,3 @@
mod step;
pub use step::*;
@@ -0,0 +1,77 @@
use settings::Setting;
use warpui::{async_assert, integration::TestStep, windowing::WindowManager, SingletonEntity};
use crate::{
integration_testing::{
step::new_step_with_default_assertions, view_getters::theme_chooser_view,
},
settings_view::SettingsAction,
window_settings::WindowSettings,
workspace::{Workspace, WorkspaceAction},
};
/// Builds a step that will toggle a setting by [`SettingsAction`]. This can
/// only update settings with a corresponding action on the settings view.
pub fn toggle_setting(action: SettingsAction) -> TestStep {
new_step_with_default_assertions(&format!("Toggle setting: {action:?}")).with_action(
move |app, _, _| {
let window_id = app.read(|ctx| {
WindowManager::as_ref(ctx)
.active_window()
.expect("no active window")
});
let workspace_view_id = app
.views_of_type::<Workspace>(window_id)
.and_then(|views| views.first().map(|view| view.id()))
.expect("no workspace view");
app.dispatch_typed_action(
window_id,
&[workspace_view_id],
&WorkspaceAction::DispatchToSettingsTab(action.clone()),
);
},
)
}
pub fn assert_theme_chooser_contains(theme_name: &'static str, count: usize) -> TestStep {
TestStep::new("Assert the theme chooser contents match our expectations").add_named_assertion(
format!("The theme chooser contains {count} theme(s) named \"{theme_name}\""),
move |app, window_id| {
let theme_chooser = theme_chooser_view(app, window_id);
let result: usize = theme_chooser.read(app, |theme_chooser, _| {
theme_chooser
.themes()
.filter(|theme| theme.matches(theme_name))
.count()
});
async_assert!(
result == count,
"Should have exactly {count} theme(s) named test theme. Instead had {result}"
)
},
)
}
/// Set a custom size for new windows. This updates:
/// * The boolean setting for whether or not to use the custom size
/// * The setting for the window width in rows
/// * The setting for the window height in columns
pub fn set_window_custom_size(rows: u16, columns: u16) -> TestStep {
TestStep::new("Set custom size for new windows").with_action(move |app, _, _| {
WindowSettings::handle(app).update(app, |settings, ctx| {
settings
.open_windows_at_custom_size
.set_value(true, ctx)
.expect("Could not enable custom window sizes");
settings
.new_windows_num_rows
.set_value(rows, ctx)
.expect("Could not set window width");
settings
.new_windows_num_columns
.set_value(columns, ctx)
.expect("Could not set window height");
})
})
}
+50
View File
@@ -0,0 +1,50 @@
use warpui::{
async_assert,
integration::{AssertionCallback, TestStep},
};
use crate::integration_testing::view_getters::terminal_view;
use super::terminal::assert_no_block_executing;
pub fn new_step_with_default_assertions(name: &str) -> TestStep {
new_step_with_default_assertions_for_pane(name, 0, 0)
}
pub fn new_step_with_default_assertions_for_pane(
name: &str,
tab_index: usize,
pane_index: usize,
) -> TestStep {
// Add global assertions here
TestStep::new(name)
.add_named_assertion(
"no pending model events",
assert_no_pending_model_events_for_pane(tab_index, pane_index),
)
.add_named_assertion(
"no block executing",
assert_no_block_executing(tab_index, pane_index),
)
}
pub fn assert_no_pending_model_events() -> AssertionCallback {
assert_no_pending_model_events_for_pane(0, 0)
}
pub fn assert_no_pending_model_events_for_pane(
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();
log::info!("events pending {}", model.are_any_events_pending());
async_assert!(
!model.are_any_events_pending(),
"Should not be any pending model events",
)
})
})
}
@@ -0,0 +1,4 @@
mod step;
pub mod util;
pub use step::*;
@@ -0,0 +1,151 @@
use regex::Regex;
use std::time::Duration;
use warpui::{
async_assert, async_assert_eq,
integration::{AssertionOutcome, TestStep},
};
use crate::{
integration_testing::{
step::assert_no_pending_model_events,
terminal::{
assert_long_running_block_executing_for_single_terminal_in_tab,
execute_command_for_single_terminal_in_tab, util::ExpectedExitStatus,
validate_block_output, wait_until_bootstrapped_pane,
},
view_getters::{single_terminal_view, terminal_view},
},
terminal::{model::rich_content::RichContentType, view::WithinBlockBanner},
};
use super::util::{ssh_command, user_host};
/// Sets environment variables needed by the Google Cloud SDK.
pub fn setup_gcloud_sdk() -> TestStep {
execute_command_for_single_terminal_in_tab(
0,
"export CLOUDSDK_CONFIG=\"$ORIGINAL_HOME/.config/gcloud\"".into(),
ExpectedExitStatus::Success,
(),
)
}
/// Initiates an SSH connection, executing the necessary command and then
/// waiting briefly (to avoid any subsequent steps performing assertions about
/// long-running command state faster than the ssh command can fail).
pub fn enter_ssh_command(shell: &str) -> TestStep {
let ssh_command = ssh_command(shell, true);
TestStep::new(&format!("Start ssh connection with remote shell '{shell}'"))
.with_typed_characters(&[&ssh_command])
.with_keystrokes(&["enter"])
.set_post_step_pause(Duration::from_millis(250))
}
pub fn enter_remote_subshell_command(shell: &str) -> TestStep {
let ssh_command = ssh_command(shell, false);
TestStep::new(&format!("Start ssh connection with remote shell '{shell}'"))
.with_typed_characters(&[&ssh_command])
.with_keystrokes(&["enter"])
.set_post_step_pause(Duration::from_millis(250))
}
/// Waits for a password prompt.
pub fn wait_for_password_prompt(tab_index: usize, shell: &str) -> TestStep {
let user_host = user_host(shell);
let regex = Regex::new(&format!("{user_host}'s password:[\\s]*$"))
.expect("regex should not fail to compile");
TestStep::new("Wait for password prompt")
// Wait up to 40 seconds for the password prompt to appear. This is
// intended to reduce flakiness due to still-not-understood delays
// in the password prompt appearing.
.set_timeout(Duration::from_secs(40))
.add_assertion(assert_long_running_block_executing_for_single_terminal_in_tab(true, 0))
.add_assertion(move |app, window_id| {
validate_block_output(&regex, tab_index, 0, window_id, app)
})
}
/// Enters the password for the user in the SSH testing VM.
pub fn enter_ssh_password() -> TestStep {
TestStep::new("Enter ssh password").with_typed_characters(&["password\n"])
}
pub fn enter_local_subshell_command(shell: &str) -> TestStep {
TestStep::new(&format!("Enter local subshell command for {shell}"))
.with_input_string(shell, Some(&["enter"]))
// Wait for shell line editor to become active before moving to next test step.
.set_post_step_pause(Duration::from_millis(50))
}
pub fn assert_subshell_banner_is_showing() -> TestStep {
TestStep::new("Assert the Warpify banner is visible")
.add_assertion(move |app, window_id| {
let terminal_view = single_terminal_view(app, window_id);
terminal_view.read(app, |view, _ctx| {
async_assert!(matches!(
view.model
.lock()
.block_list_mut()
.active_block()
.block_banner(),
Some(WithinBlockBanner::WarpifyBanner(..))
))
})
})
// Wait for outstanding model events to finish before moving to the next step
.add_named_assertion("no pending model events", assert_no_pending_model_events())
.set_post_step_pause(Duration::from_millis(50))
}
pub fn trigger_subshell_bootstrap() -> TestStep {
TestStep::new("Trigger subshell bootstrap").with_keystrokes(&["ctrl-i"])
}
pub fn assert_subshell_is_bootstrapped(tab_index: usize, pane_index: usize) -> TestStep {
wait_until_bootstrapped_pane(tab_index, pane_index).add_named_assertion(
"Subshell info block was displayed and no extraneous blocks added",
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 Some((success_block_index, rich_content_type)) = model
.block_list()
.last_non_hidden_rich_content_block_after_block(None)
.map(|(success_block_index, block)| (success_block_index, block.content_type))
else {
return AssertionOutcome::failure("No rich content block found!".to_owned());
};
match rich_content_type {
Some(RichContentType::WarpifySuccessBlock) => {}
_ => {
return AssertionOutcome::failure(
"Warpify success block wasn't added to the blocklist".to_owned(),
);
}
}
let success_block_index: usize = success_block_index.into();
// Make sure there are no non-in-band-generator blocks added to the blocklist in
// between the static block and the active block (which is not yet finished).
async_assert_eq!(
model.block_list().blocks()[success_block_index + 1..]
.iter()
.filter(|block| !block.is_in_band_command_block() && block.finished())
.count(),
0,
"Added extraneous blocks to the block list.",
)
})
},
)
}
pub fn accept_tmux_install() -> TestStep {
TestStep::new("Accept tmux install").with_keystrokes(&["enter"])
}
pub fn run_exit_command() -> TestStep {
TestStep::new("Run exit command").with_keystrokes(&["e", "x", "i", "t", "enter"])
}
@@ -0,0 +1,24 @@
/// The command used to proxy ssh requests through GCP's Identity-Aware Proxy.
const PROXY_COMMAND: &str = "gcloud compute start-iap-tunnel ubuntu-14-04 25784 --listen-on-stdin --project=warp-ssh-integration-testing --zone=us-east4-a";
/// Produces a user/host pair for testing a given remote shell.
pub fn user_host(shell: &str) -> String {
format!("{shell}@ubuntu-14-04")
}
/// Produces the full ssh command to run to ssh into a given remote shell.
pub fn ssh_command(shell: &str, should_use_ssh_wrapper: bool) -> String {
[
if should_use_ssh_wrapper {
"ssh"
} else {
"command ssh"
},
&user_host(shell),
"-p 25784",
&format!("-o ProxyCommand=\"{PROXY_COMMAND}\""),
"-o StrictHostKeyChecking=no",
"-o UserKnownHostsFile=/dev/null",
]
.join(" ")
}
@@ -0,0 +1,41 @@
use warpui::{async_assert, integration::AssertionCallback};
use crate::integration_testing::{terminal::util::ExpectedOutput, view_getters::pane_group_view};
/// Asserts that the tab has a pane at the given index with the expected title.
pub fn assert_pane_title(
tab_index: usize,
pane_index: usize,
expected_title: impl ExpectedOutput + 'static,
) -> AssertionCallback {
Box::new(move |app, window_id| {
let pane_group = pane_group_view(app, window_id, tab_index);
pane_group.read(app, |pane_group, ctx| {
match pane_group.pane_by_index(pane_index) {
Some(pane) => {
let pane_title = pane.pane_configuration().as_ref(ctx).title().to_owned();
async_assert!(
expected_title.matches(&pane_title),
"Expected title for window_id={window_id}, tab_index={tab_index}, pane_index={pane_index} to be [{expected_title:?}], but got [{pane_title:?}]"
)
},
None => panic!("pane should exist for window_id={window_id}, tab_index={tab_index}, pane_index={pane_index}")
}
})
})
}
/// Asserts that the active pane of the tab has an expected title.
pub fn assert_tab_title(
tab_index: usize,
expected_title: impl ExpectedOutput + 'static,
) -> AssertionCallback {
Box::new(move |app, window_id| {
let pane_group = pane_group_view(app, window_id, tab_index);
let title = pane_group.read(app, |pane_group, ctx| pane_group.display_title(ctx));
async_assert!(
expected_title.matches(&title),
"Expected title of tab {tab_index} to match [{expected_title:?}], but was [{title}]"
)
})
}
+5
View File
@@ -0,0 +1,5 @@
mod assertion;
mod step;
pub use assertion::*;
pub use step::*;
+11
View File
@@ -0,0 +1,11 @@
use warpui::integration::TestStep;
use crate::integration_testing::{step::new_step_with_default_assertions, tab::assert_tab_title};
/// Checks whether the current tab has an expected title.
/// #Panics if any of the assertions fail (including if the tab title doesn't match
/// `expected_tab_title`)
pub fn tab_title_step(assertion_name: &str, expected_tab_title: String) -> TestStep {
new_step_with_default_assertions(assertion_name)
.add_assertion(assert_tab_title(0, expected_tab_title))
}
@@ -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,
}
}
}
@@ -0,0 +1,5 @@
mod step;
pub use step::*;
pub use crate::user_config::themes_dir;
@@ -0,0 +1,19 @@
use crate::integration_testing::command_palette::open_command_palette_and_run_action;
use crate::integration_testing::view_getters::workspace_view;
use warpui::async_assert;
use warpui::integration::TestStep;
pub fn open_theme_picker() -> Vec<TestStep> {
let mut steps = open_command_palette_and_run_action("Open Theme Picker");
let last = steps.pop().expect("steps should not be empty");
steps.push(last.add_assertion(|app, window_id| {
let workspace = workspace_view(app, window_id);
workspace.read(app, |workspace, _| {
async_assert!(
workspace.is_theme_chooser_open(),
"Theme chooser should be open"
)
})
}));
steps
}
@@ -0,0 +1,5 @@
use crate::server::telemetry::LaunchConfigUiLocation;
pub fn get_launch_config_ui_location() -> LaunchConfigUiLocation {
LaunchConfigUiLocation::Uri
}
+265
View File
@@ -0,0 +1,265 @@
//! Various view getters with cardinality assumptions to reduce boilerplate
//! needed to get a view from the view tree.
//! We should migrate to these view getters because to make it easier to work
//! with tabs and panes. The old view getters use `tab_idx` without considering
//! how many panes are in each tab.
//! See https://github.com/warpdotdev/warp-internal/pull/4785#issue-1634862270
use crate::view_components::find::FindEvent;
use crate::view_components::find::FindModel;
use crate::{
ai_assistant::panel::AIAssistantPanelView,
input_suggestions::InputSuggestions,
notebooks::notebook::NotebookView,
pane_group::{PaneGroup, PaneView},
root_view::RootView,
search::{
command_palette::{self},
command_search::view::CommandSearchView,
},
settings_view::keybindings::KeybindingsView,
terminal::{input::Input, TerminalView},
themes::theme_chooser::ThemeChooser,
view_components::find::Find,
workflows::{workflow_view::WorkflowView, CategoriesView},
workspace::Workspace,
};
use warpui::Entity;
use warpui::{async_assert, integration::AssertionCallback, App, View, ViewHandle, WindowId};
/// This identifier is useful when you'd like to weakly identify a terminal view
/// without actually grabbing a handle to it. Often useful when writing reusable assertions.
#[derive(Copy, Clone, Debug)]
pub enum TerminalViewIdentifier {
/// There is only one terminal view in the entire window.
Singleton,
/// There is only one terminal view in the given tab index.
SingleInTab { tab_index: usize },
/// Fully-identified terminal view.
Custom { tab_index: usize, pane_index: usize },
}
impl TerminalViewIdentifier {
pub fn to_terminal_view(&self, app: &App, window_id: WindowId) -> ViewHandle<TerminalView> {
use TerminalViewIdentifier::*;
match self {
Singleton => single_terminal_view(app, window_id),
SingleInTab { tab_index } => single_terminal_view_for_tab(app, window_id, *tab_index),
Custom {
tab_index,
pane_index,
} => terminal_view(app, window_id, *tab_index, *pane_index),
}
}
pub fn to_input_view(&self, app: &App, window_id: WindowId) -> ViewHandle<Input> {
self.to_terminal_view(app, window_id)
.read(app, |terminal, _ctx| terminal.input().to_owned())
}
}
/// Panics if there isn't a single input suggestions view in the entire view hierarchy.
pub fn single_input_suggestions_view(
app: &App,
window_id: WindowId,
) -> ViewHandle<InputSuggestions> {
singleton_view_of_type(app, window_id)
}
pub fn single_input_suggestions_view_for_tab(
app: &App,
window_id: WindowId,
tab_index: usize,
) -> ViewHandle<InputSuggestions> {
single_input_view_for_tab(app, window_id, tab_index)
.read(app, |input, _| input.input_suggestions().to_owned())
}
/// Panics if there isn't a single find view in the entire view hierarchy.
pub fn single_find_view<T: FindModel + Entity<Event = FindEvent>>(
app: &App,
window_id: WindowId,
) -> ViewHandle<Find<T>> {
singleton_view_of_type(app, window_id)
}
/// Panics if there isn't a single input view in the entire view hierarchy.
pub fn single_input_view(app: &App, window_id: WindowId) -> ViewHandle<Input> {
singleton_view_of_type(app, window_id)
}
/// Panics if there isn't a single input view in the given tab.
pub fn single_input_view_for_tab(
app: &App,
window_id: WindowId,
tab_index: usize,
) -> ViewHandle<Input> {
single_terminal_view_for_tab(app, window_id, tab_index)
.read(app, |terminal, _ctx| terminal.input().to_owned())
}
/// Panics if there isn't a single terminal view in the entire view hierarchy.
pub fn single_terminal_view(app: &App, window_id: WindowId) -> ViewHandle<TerminalView> {
singleton_view_of_type(app, window_id)
}
/// Panics if there isn't a single terminal view in the given tab.
pub fn single_terminal_view_for_tab(
app: &App,
window_id: WindowId,
tab_index: usize,
) -> ViewHandle<TerminalView> {
pane_group_view(app, window_id, tab_index).read(app, |pane_group, ctx| {
let num_terminal_views = pane_group.terminal_pane_ids().count();
assert_eq!(num_terminal_views, 1, "window_id={window_id}, tab_index={tab_index} doesn't have a single terminal view. Has {num_terminal_views} terminal views instead");
pane_group.terminal_view_at_pane_index(0, ctx).unwrap().to_owned()
})
}
/// Panics if there isn't a single terminal pane view in the given tab.
pub fn single_terminal_pane_view_for_tab(
app: &App,
window_id: WindowId,
tab_index: usize,
) -> ViewHandle<PaneView<TerminalView>> {
pane_group_view(app, window_id, tab_index).read(app, |pane_group, _ctx| {
let num_terminal_views = pane_group.terminal_pane_ids().count();
assert_eq!(num_terminal_views, 1, "window_id={window_id}, tab_index={tab_index} doesn't have a single terminal pane view. Has {num_terminal_views} pane views instead");
pane_group.terminal_pane_view_at_pane_index(0).unwrap().to_owned()
})
}
/// Panics if there isn't a single terminal view in the given tab and pane index.
pub fn terminal_view(
app: &App,
window_id: WindowId,
tab_index: usize,
pane_index: usize,
) -> ViewHandle<TerminalView> {
pane_group_view(app, window_id, tab_index).read(app, |pane_group, ctx| {
pane_group.terminal_view_at_pane_index(pane_index, ctx).unwrap_or_else(|| panic!("terminal_view should exist for window_id={window_id}, tab_index={tab_index}, pane_index={pane_index}")).to_owned()
})
}
/// Panics if there isn't a single terminal view in the given tab and pane index.
pub fn input_view(
app: &App,
window_id: WindowId,
tab_index: usize,
pane_index: usize,
) -> ViewHandle<Input> {
terminal_view(app, window_id, tab_index, pane_index)
.read(app, |terminal_view, _| terminal_view.input().to_owned())
}
/// Panics if there isn't a notebook view at the given tab and pane index.
pub fn notebook_view(
app: &App,
window_id: WindowId,
tab_index: usize,
pane_index: usize,
) -> ViewHandle<NotebookView> {
pane_group_view(app, window_id, tab_index).read(
app,
|pane_group, ctx| match pane_group.notebook_view_at_pane_index(pane_index, ctx) {
Some(pane) => pane.clone(),
None => panic!("notebook view should exist for window_id={window_id}, tab_index={tab_index}, pane_index={pane_index}")
},
)
}
/// Panics if there isn't a workflow view at the given tab and pane index.
pub fn workflow_view(
app: &App,
window_id: WindowId,
tab_index: usize,
pane_index: usize,
) -> ViewHandle<WorkflowView> {
pane_group_view(app, window_id, tab_index).read(
app,
|pane_group, ctx| match pane_group.workflow_view_at_pane_index(pane_index, ctx) {
Some(pane) => pane.clone(),
None => panic!("workflow view should exist for window_id={window_id}, tab_index={tab_index}, pane_index={pane_index}")
},
)
}
/// Panics if there isn't a single pane group for the given tab.
pub fn pane_group_view(app: &App, window_id: WindowId, tab_index: usize) -> ViewHandle<PaneGroup> {
workspace_view(app, window_id).read(app, |workspace, _ctx| {
workspace
.get_pane_group_view(tab_index)
.unwrap_or_else(|| {
panic!(
"pane_group view should exist for window_id={window_id}, tab_index={tab_index}"
)
})
.to_owned()
})
}
/// Panics if there isn't a single theme chooser view in the view hierarchy.
pub fn theme_chooser_view(app: &App, window_id: WindowId) -> ViewHandle<ThemeChooser> {
singleton_view_of_type(app, window_id)
}
/// Panics if there isn't a single single keybindings view in the view hierarchy.
pub fn keybindings_view(app: &App, window_id: WindowId) -> ViewHandle<KeybindingsView> {
singleton_view_of_type(app, window_id)
}
/// Panics if there isn't a single workflows view in the view hierarchy.
pub fn workflow_categories_view(app: &App, window_id: WindowId) -> ViewHandle<CategoriesView> {
singleton_view_of_type(app, window_id)
}
/// Panics if there isn't a single ai assistant panel view in the view hierarchy.
pub fn ai_assistant_panel_view(app: &App, window_id: WindowId) -> ViewHandle<AIAssistantPanelView> {
singleton_view_of_type(app, window_id)
}
/// Panics if there isn't a single workspace view in the view hierarchy.
pub fn workspace_view(app: &App, window_id: WindowId) -> ViewHandle<Workspace> {
root_view(app, window_id).read(app, |root_view, _ctx| {
root_view.workspace_view().cloned().unwrap_or_else(|| {
panic!("root_view should have a workspace view for window_id={window_id}")
})
})
}
/// Panics if there isn't a single root view in the view hierarchy.
pub fn root_view(app: &App, window_id: WindowId) -> ViewHandle<RootView> {
app.root_view(window_id)
.unwrap_or_else(|| panic!("root view for window_id={window_id} does not exist"))
}
/// Returns a [`ViewHandle`] to the command palette contained within `WindowId`.
/// #Panics if there isn't a command palette view in the view hierarchy.
pub fn command_palette_view(app: &App, window_id: WindowId) -> ViewHandle<command_palette::View> {
let workspace = singleton_view_of_type::<Workspace>(app, window_id);
workspace.read(app, |workspace, _ctx| workspace.command_palette_view())
}
/// Returns a [`ViewHandle`] to the command search view contained within `WindowId`.
/// #Panics if there isn't a command search view view in the view hierarchy.
/// Note that the command search view is implemented at the workspace level, so there should only
/// be one in a given workspace view/window.
pub fn command_search_view(app: &App, window_id: WindowId) -> ViewHandle<CommandSearchView> {
singleton_view_of_type(app, window_id)
}
pub fn assert_no_views_of_type<T: View>() -> AssertionCallback {
Box::new(move |app, window_id| async_assert!(app.views_of_type::<T>(window_id).is_none()))
}
// Useful helper to get a singleton view (per window) without having to expose
// getters on Workspace, etc. Instead of using this directly in a test, write a helper
// to extract the view you want for better ergonomics.
fn singleton_view_of_type<T: View>(app: &App, window_id: WindowId) -> ViewHandle<T> {
let views_of_type = app
.views_of_type(window_id)
.expect("there's at least one view of type");
let num_views_of_type = views_of_type.len();
assert_eq!(num_views_of_type, 1, "window_id={window_id} doesn't have a single view of type T. Has {num_views_of_type} views instead");
views_of_type.first().unwrap().clone()
}
@@ -0,0 +1,68 @@
use crate::integration_testing::view_getters::workspace_view;
use warpui::async_assert;
use warpui::integration::AssertionCallback;
pub fn assert_workflow_modal_is_open() -> AssertionCallback {
Box::new(move |app, window_id| {
let workspace = workspace_view(app, window_id);
workspace.read(app, |workspace, _| {
async_assert!(
workspace.is_workflow_modal_open(),
"Expected workflow modal to be open, but it was closed"
)
})
})
}
pub fn assert_workflow_modal_is_closed() -> AssertionCallback {
Box::new(move |app, window_id| {
let workspace = workspace_view(app, window_id);
workspace.read(app, |workspace, _| {
async_assert!(
!workspace.is_workflow_modal_open(),
"Expected workflow modal to be closed, but it was open"
)
})
})
}
pub fn assert_warp_drive_is_open() -> AssertionCallback {
Box::new(move |app, window_id| {
let workspace = workspace_view(app, window_id);
workspace.read(app, |workspace, _| {
async_assert!(
workspace.is_warp_drive_open(),
"Expected Warp Drive to be open, but it was closed"
)
})
})
}
pub fn assert_warp_drive_is_closed() -> AssertionCallback {
Box::new(move |app, window_id| {
let workspace = workspace_view(app, window_id);
workspace.read(app, |workspace, _| {
async_assert!(
!workspace.is_warp_drive_open(),
"Expected Warp Drive to be closed, but it was open"
)
})
})
}
pub fn assert_is_left_panel_open() -> AssertionCallback {
Box::new(move |app, window_id| {
let workspace = workspace_view(app, window_id);
workspace.read(app, |workspace, ctx| {
async_assert!(
workspace.is_left_panel_open(ctx),
"Expected left panel to be open, but it was closed"
)
})
})
}
@@ -0,0 +1,3 @@
mod assertion;
pub use assertion::*;
@@ -0,0 +1,26 @@
use warpui::{
async_assert_eq,
integration::{AssertionCallback, AssertionOutcome, StepData},
windowing::WindowManager,
SingletonEntity,
};
/// Saves the active window id with the given step data key.
pub fn save_active_window_id<K>(window_key: K) -> AssertionCallback
where
K: Into<String>,
{
let window_key = window_key.into();
Box::new(move |app, _| {
let window_id = app.read(|ctx| WindowManager::as_ref(ctx).active_window());
AssertionOutcome::SuccessWithData(StepData::new(
window_key.clone(),
window_id.expect("window id present"),
))
})
}
/// Asserts the number of windows that are open.
pub fn assert_num_windows_open(num_windows: usize) -> AssertionCallback {
Box::new(move |app, _| async_assert_eq!(app.window_ids().len(), num_windows))
}
@@ -0,0 +1,5 @@
mod assertion;
mod step;
pub use assertion::*;
pub use step::*;
@@ -0,0 +1,91 @@
use pathfinder_geometry::rect::RectF;
use warpui::{
async_assert_eq, integration::TestStep, platform::TerminationMode, windowing::WindowManager,
SingletonEntity,
};
use crate::integration_testing::step::new_step_with_default_assertions;
/// Adds a window and verifies that the new number of windows is as expected
pub fn add_window(expected_num_windows: usize) -> TestStep {
new_step_with_default_assertions("Add a window")
.with_action(|app, _, _| {
app.dispatch_global_action("root_view:open_new", ());
})
.add_assertion(move |app, _| async_assert_eq!(app.window_ids().len(), expected_num_windows))
}
/// Adds a window and saves its ID into the step data.
pub fn add_and_save_window(window_key: impl Into<String>) -> TestStep {
let window_key = window_key.into();
TestStep::new("Add a window").with_action(move |app, _, data| {
let prev_window = app.read(|ctx| {
WindowManager::as_ref(ctx)
.active_window()
.expect("Should be an active window")
});
app.dispatch_global_action("root_view:open_new", ());
let active_window = app.read(|ctx| {
WindowManager::as_ref(ctx)
.active_window()
.expect("Should be an active window")
});
assert_ne!(
prev_window, active_window,
"Should have activated new window"
);
data.insert(window_key.clone(), active_window);
})
}
/// Adds a window and checks that the window bounds are equal to the bounds of the window with the
/// given step data key
pub fn add_window_and_check_bounds<K>(expected_num_windows: usize, bounds_key: K) -> TestStep
where
K: Into<String>,
{
let bounds_key = bounds_key.into();
new_step_with_default_assertions("Add a window")
.with_action(move |app, _, data_map| {
app.dispatch_global_action("root_view:open_new", ());
let target_window_bounds: RectF =
*data_map.get(&bounds_key).expect("bounds should be defined");
let active_window = app.read(|ctx| {
WindowManager::as_ref(ctx)
.active_window()
.expect("Should be an active window")
});
let active_window_bounds = app
.window_bounds(&active_window)
.expect("active window bounds defined");
// Note that we do the assert immediately after adding the window
// because the OS may move or resize the window, changing the bounds if we do
// it async.
assert_eq!(
target_window_bounds, active_window_bounds,
"Expected first window bounds {target_window_bounds:?} to be equal to third window bounds {active_window_bounds:?}"
);
})
.add_assertion(move |app, _| async_assert_eq!(app.window_ids().len(), expected_num_windows))
}
/// Closes the window with the given step data key (corresponding to a WindowId).
pub fn close_window<K>(window_key: K, expected_num_windows: usize) -> TestStep
where
K: Into<String>,
{
let window_key = window_key.into();
new_step_with_default_assertions("Close a window")
.with_action(move |app, _, data_map| {
let window_id = data_map
.get(&window_key)
.expect("Expected window id to be in data map");
app.update(|ctx| {
WindowManager::as_ref(ctx)
.close_window(*window_id, TerminationMode::ForceTerminate);
});
})
.add_assertion(move |app, _| async_assert_eq!(app.window_ids().len(), expected_num_windows))
}
@@ -0,0 +1,98 @@
use warpui::{
async_assert, async_assert_eq,
integration::{AssertionCallback, AssertionWithDataCallback},
App, ViewHandle,
};
use crate::{
integration_testing::{cloud_object::assert_metadata_revision, view_getters::workflow_view},
server::ids::SyncId,
workflows::{workflow_view::WorkflowView, CloudWorkflowModel, WorkflowId},
};
/// Asserts metadata exists for the workflow with the given key and that the revision in that
/// metadata matches the given expected revision.
pub fn assert_workflow_metadata_revision(
id: impl AsRef<str>,
expected_revision: i64,
) -> AssertionCallback {
assert_metadata_revision::<WorkflowId, CloudWorkflowModel>(id.as_ref(), expected_revision)
}
/// Asserts that a pane has the given workflow open.
pub fn assert_workflow_id(
tab_index: usize,
pane_index: usize,
expected_id_key: impl Into<String>,
) -> AssertionWithDataCallback {
let expected_id_key = expected_id_key.into();
Box::new(move |app, window_id, data| {
let expected_id = data.get(&expected_id_key).expect("No saved workflow ID");
let workflow = workflow_view(app, window_id, tab_index, pane_index);
workflow.read(app, |workflow, _ctx| {
let id = workflow.workflow_id();
async_assert_eq!(
id, *expected_id,
"Expected window_id={window_id}, tab_index={tab_index}, pane_index={pane_index} to contain {expected_id:?}, but got {id:?}")
})
})
}
pub fn assert_no_workflow_pane_open() -> AssertionCallback {
Box::new(move |app, _| {
let count = get_all_open_workflows(app).len();
async_assert!(count == 0, "Expected no workflow panes to be open")
})
}
pub fn assert_no_team_workflow_pane_open() -> AssertionCallback {
Box::new(move |app, _| {
let count = get_all_open_workflows(app)
.iter()
.filter(|view| view.read(app, |v, _| v.is_team_workflow()))
.count();
async_assert!(count == 0, "Expected no workflow panes to be open")
})
}
pub fn assert_open_workflow_pane_count_equals(num: usize) -> AssertionCallback {
Box::new(move |app, _| {
let count = get_all_open_workflows(app).len();
async_assert!(
count == num,
"Expected number of open workflow panes to be: {num}. Found {count} instead"
)
})
}
pub fn assert_open_team_workflow_pane_count_equals(num: usize) -> AssertionCallback {
Box::new(move |app, _| {
let count = get_all_open_workflows(app)
.iter()
.filter(|view| view.read(app, |v, _| v.is_team_workflow()))
.count();
async_assert!(
count == num,
"Expected number of open workflow panes to be: {num}. Found {count} instead"
)
})
}
/// Find number of workflows that are open by id
pub fn open_workflow_count(app: &App, id: SyncId) -> usize {
app.window_ids()
.into_iter()
.flat_map(|window_id| app.views_of_type::<WorkflowView>(window_id))
.flatten()
.filter(move |view| view.read(app, |view, _ctx| view.workflow_id()) == id)
.count()
}
fn get_all_open_workflows(app: &mut App) -> Vec<ViewHandle<WorkflowView>> {
app.window_ids()
.into_iter()
.flat_map(|window_id| app.views_of_type::<WorkflowView>(window_id))
.flatten()
.collect()
}
@@ -0,0 +1,7 @@
mod assertion;
mod step;
pub use assertion::*;
pub use step::*;
pub use crate::user_config::workflows_dir;
@@ -0,0 +1,87 @@
use warpui::{
async_assert, integration::TestStep, windowing::WindowManager, SingletonEntity, WindowId,
};
use crate::{
cloud_object::{model::persistence::CloudModel, CloudObjectEventEntrypoint, Space},
drive::OpenWarpDriveObjectSettings,
integration_testing::view_getters::workspace_view,
server::{
cloud_objects::update_manager::UpdateManager,
ids::{ClientId, SyncId},
},
workflows::{manager::WorkflowOpenSource, workflow::Workflow, WorkflowViewMode},
workspaces::user_workspaces::UserWorkspaces,
};
use super::open_workflow_count;
/// Create a personal workflow and save its sync ID into the step data.
pub fn create_a_personal_workflow(key: impl Into<String>) -> TestStep {
let key = key.into();
let workflow = Workflow::new("personal workflow", "echo 'name'");
TestStep::new("Create a personal workflow")
.with_action(move |app, _, data| {
let client_id = ClientId::new();
let sync_id = SyncId::ClientId(client_id);
UpdateManager::handle(app).update(app, |update_manager, ctx| {
update_manager.create_workflow(
workflow.clone(),
UserWorkspaces::as_ref(ctx)
.personal_drive(ctx)
.expect("User UID must be set in tests"),
None,
client_id,
CloudObjectEventEntrypoint::ManagementUI,
true,
ctx,
);
});
data.insert(key.clone(), sync_id);
})
.add_assertion(move |app, _| {
CloudModel::handle(app).read(app, |cloud_model, ctx| {
async_assert!(
cloud_model
.active_cloud_objects_in_space(Space::Personal, ctx)
.count()
> 0,
"Workflow exists"
)
})
})
}
/// Open the workflow saved at `workflow_key` in the active tab of the window saved at `window_key`
pub fn open_workflow(window_key: impl Into<String>, workflow_key: impl Into<String>) -> TestStep {
let window_key = window_key.into();
let workflow_key = workflow_key.into();
let workflow_other_key = workflow_key.clone();
TestStep::new("Open workflow")
.with_action(move |app, _, data| {
let workflow_id: &SyncId = data.get(&workflow_key).expect("No saved workflow ID");
let window_id: &WindowId = data.get(&window_key).expect("No saved window ID");
workspace_view(app, *window_id).update(app, |workspace, ctx| {
// If the workflow isn't open yet, opening it won't focus the window (we only change
// focus if switching to an already-open window). Since the user wouldn't be able to
// open a workflow in an unfocused window, switch focus explicitly here.
WindowManager::as_ref(ctx).show_window_and_focus_app(*window_id);
workspace.open_workflow_in_pane(
&WorkflowOpenSource::Existing(*workflow_id),
&OpenWarpDriveObjectSettings::default(),
WorkflowViewMode::View,
ctx,
);
})
})
.add_named_assertion_with_data_from_prior_step(
"Check workflow is open",
move |app, _, data| {
let workflow_id: &SyncId =
data.get(&workflow_other_key).expect("No workflow ID found");
async_assert!(open_workflow_count(app, *workflow_id) == 1)
},
)
}
@@ -0,0 +1,29 @@
use warpui::{async_assert_eq, integration::AssertionCallback};
use crate::integration_testing::view_getters::workspace_view;
pub fn assert_focused_tab_index(tab_index: usize) -> AssertionCallback {
Box::new(move |app, window_id| {
let workspace = workspace_view(app, window_id);
workspace.read(app, |view, _ctx| {
async_assert_eq!(view.active_tab_index(), tab_index)
})
})
}
/// Assert that there are a particular number of tabs in the workspace.
pub fn assert_tab_count(tab_count: usize) -> AssertionCallback {
Box::new(move |app, window_id| {
let workspace = workspace_view(app, window_id);
workspace.read(app, |view, _ctx| {
let actual_tab_count = view.tab_count();
async_assert_eq!(
actual_tab_count,
tab_count,
"Expected {} tabs, but there were {}",
tab_count,
actual_tab_count
)
})
})
}
@@ -0,0 +1,5 @@
mod assertions;
mod step;
pub use assertions::*;
pub use step::*;
@@ -0,0 +1,45 @@
use warpui::{async_assert, integration::TestStep, SingletonEntity};
use crate::{
integration_testing::view_getters::workspace_view, undo_close::UndoCloseStack,
workspace::Workspace,
};
/// Mock pressing a button on the Warp-native quit modal. Note that this modal is currently only
/// used on Linux, not macOS.
pub fn press_native_modal_button(button_index: usize) -> TestStep {
TestStep::new("Press a native modal button")
.with_action(move |app, _, _data| {
let active_window = app
.read(|ctx| ctx.windows().active_window())
.expect("no active window");
let workspace = workspace_view(app, active_window);
app.update(|ctx| {
assert!(
workspace.as_ref(ctx).is_native_quit_modal_open(ctx),
"Native modal should be open"
);
Workspace::press_native_modal_button(&workspace, button_index, ctx);
});
})
.add_assertion(|app, window_id| {
let workspace = workspace_view(app, window_id);
workspace.read(app, |workspace, ctx| {
async_assert!(
!workspace.is_native_quit_modal_open(ctx),
"Native modal is still open"
)
})
})
}
/// Trigger undo close (restore closed pane/tab/window) action.
pub fn trigger_undo_close() -> TestStep {
TestStep::new("Trigger undo close").with_action(move |app, _, _data| {
app.update(|ctx| {
UndoCloseStack::handle(ctx).update(ctx, |stack, model_ctx| {
stack.undo_close(model_ctx);
});
});
})
}