Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
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(¤t_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()
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -0,0 +1 @@
|
||||
pub use crate::view_components::find::{Find, FindWithinBlockState};
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod assertions;
|
||||
mod step;
|
||||
|
||||
pub use assertions::*;
|
||||
pub use step::*;
|
||||
@@ -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;
|
||||
@@ -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(¬ebook_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(¬ebook_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(¬ebook_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"
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod assertion;
|
||||
pub mod step;
|
||||
|
||||
pub use assertion::*;
|
||||
pub use step::*;
|
||||
@@ -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");
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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(®ex, 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}]"
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod assertion;
|
||||
mod step;
|
||||
|
||||
pub use assertion::*;
|
||||
pub use step::*;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user