use git2::{Repository, Signature, Time};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use tempfile::TempDir;
pub async fn run_cli_with_timeout(
binary_path: &Path,
args: &[&str],
repo_path: &Path,
timeout_duration: Duration,
) -> Result<std::process::Output, String> {
let binary_path = binary_path.to_path_buf();
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let repo_path = repo_path.to_path_buf();
let command_future = tokio::task::spawn_blocking(move || {
let mut cmd = Command::new(&binary_path);
cmd.args(&args)
.current_dir(&repo_path)
.env("RUST_LOG", "info")
.env("_CC_COMPLETE", "") .env("COMPLETE", "");
if cfg!(windows) {
cmd.env("HOME", std::env::var("USERPROFILE").unwrap_or_default())
.env("TERM", "xterm"); }
cmd.output()
});
match tokio::time::timeout(timeout_duration, command_future).await {
Ok(task_result) => match task_result {
Ok(io_result) => match io_result {
Ok(output) => Ok(output),
Err(e) => Err(format!("Command execution failed: {e}")),
},
Err(e) => Err(format!("Task panicked: {e}")),
},
Err(_) => Err(format!("Command timed out after {timeout_duration:?}")),
}
}
pub async fn create_test_git_repo() -> (TempDir, PathBuf) {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
let mut git_commands = vec![
vec!["init"],
vec!["config", "user.name", "Test User"],
vec!["config", "user.email", "test@example.com"],
vec!["config", "init.defaultBranch", "main"],
vec!["config", "core.autocrlf", "false"], vec!["config", "core.filemode", "false"], ];
if cfg!(windows) {
git_commands.extend(vec![
vec!["config", "core.longpaths", "true"], vec!["config", "core.preloadindex", "true"], vec!["config", "core.fscache", "true"], ]);
}
for cmd_args in &git_commands {
let output = Command::new("git")
.args(cmd_args)
.current_dir(&repo_path)
.output()
.expect("Git command should succeed");
if !output.status.success() {
panic!(
"Git command failed: git {}\nStderr: {}",
cmd_args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
}
}
std::fs::write(repo_path.join("README.md"), "# Test Repository").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&repo_path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(&repo_path)
.output()
.unwrap();
(temp_dir, repo_path)
}
#[allow(dead_code)]
pub async fn create_test_cascade_repo(bitbucket_url: Option<String>) -> (TempDir, PathBuf) {
let (temp_dir, repo_path) = create_test_git_repo().await;
let url = bitbucket_url.unwrap_or_else(|| "https://test.bitbucket.com".to_string());
for attempt in 1..=3 {
match cascade_cli::config::initialize_repo(&repo_path, Some(url.clone())) {
Ok(_) => break,
Err(e) if attempt == 3 => panic!("Cascade initialization failed after 3 attempts: {e}"),
Err(e) => {
eprintln!("Cascade initialization attempt {attempt} failed: {e}, retrying...");
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
}
(temp_dir, repo_path)
}
#[allow(dead_code)]
pub async fn create_test_commits(repo_path: &PathBuf, count: u32, prefix: &str) {
for i in 1..=count {
let filename = format!("{prefix}-{i}.txt");
let content = format!(
"Content for {prefix} file {i}\nCreated at: {}",
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S")
);
std::fs::write(repo_path.join(&filename), content).unwrap();
Command::new("git")
.args(["add", &filename])
.current_dir(repo_path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", &format!("{prefix}: Add file {i}")])
.current_dir(repo_path)
.output()
.unwrap();
}
}
#[allow(dead_code)]
pub fn assert_cli_success(output: &std::process::Output, operation: &str) {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
panic!(
"{operation} failed:\nExit code: {}\nStderr: {stderr}\nStdout: {stdout}",
output.status.code().unwrap_or(-1)
);
}
}
#[allow(dead_code)]
pub fn assert_cli_error_contains(
output: &std::process::Output,
operation: &str,
expected_error: &str,
) {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
panic!("{operation} unexpectedly succeeded. Stdout: {stdout}");
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stderr.contains(expected_error) || stdout.contains(expected_error),
"{operation} failed but didn't contain expected error '{expected_error}'.\nStderr: {stderr}\nStdout: {stdout}"
);
}
#[allow(dead_code)]
pub fn assert_output_contains(
output: &std::process::Output,
expected_content: &str,
context: &str,
) {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stderr.contains(expected_content) || stdout.contains(expected_content),
"{context}: Expected to find '{expected_content}' in output.\nStderr: {stderr}\nStdout: {stdout}"
);
}
pub fn get_binary_path() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let current_dir = PathBuf::from(manifest_dir);
let release_binary = current_dir
.join("target/release")
.join(cascade_cli::utils::platform::executable_name("ca"));
let debug_binary = current_dir
.join("target/debug")
.join(cascade_cli::utils::platform::executable_name("ca"));
if release_binary.exists() && cascade_cli::utils::platform::is_executable(&release_binary) {
release_binary
} else if debug_binary.exists() && cascade_cli::utils::platform::is_executable(&debug_binary) {
debug_binary
} else {
panic!(
"No executable binary found. Tried:\n - {}\n - {}\n\nRun 'cargo build --release' first.",
release_binary.display(),
debug_binary.display()
);
}
}
#[allow(dead_code)]
pub async fn retry_operation<F, T, E>(
mut operation: F,
max_attempts: u32,
base_delay: Duration,
operation_name: &str,
) -> Result<T, E>
where
F: FnMut() -> Result<T, E>,
E: std::fmt::Debug,
{
for attempt in 1..=max_attempts {
match operation() {
Ok(result) => return Ok(result),
Err(e) => {
if attempt == max_attempts {
eprintln!("{operation_name} failed after {max_attempts} attempts: {e:?}");
return Err(e);
}
let delay = base_delay * 2_u32.pow(attempt - 1);
let jitter = Duration::from_millis(fastrand::u64(0..100));
let total_delay = delay + jitter;
eprintln!(
"{operation_name} attempt {attempt} failed: {e:?}. Retrying in {total_delay:?}..."
);
tokio::time::sleep(total_delay).await;
}
}
}
unreachable!()
}
pub async fn run_parallel_operations<F, T>(
operations: Vec<F>,
operation_name: String,
) -> Vec<Result<T, String>>
where
F: FnOnce() -> Result<T, String> + Send + 'static,
T: Send + 'static,
{
let max_concurrency = std::env::var("INTEGRATION_TEST_CONCURRENCY")
.unwrap_or_else(|_| {
if cfg!(windows) {
"1".to_string() } else {
"2".to_string() }
})
.parse::<usize>()
.unwrap_or(1);
let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrency));
let mut handles = Vec::new();
for (i, operation) in operations.into_iter().enumerate() {
let semaphore = semaphore.clone();
let operation_name = operation_name.clone();
let handle = tokio::spawn(async move {
let _permit = semaphore
.acquire()
.await
.expect("Semaphore should not be closed");
let jitter_max = if cfg!(windows) {
150 } else {
50 };
let jitter = Duration::from_millis(fastrand::u64(0..jitter_max));
tokio::time::sleep(jitter).await;
let result = tokio::task::spawn_blocking(operation).await;
match result {
Ok(inner_result) => inner_result,
Err(join_error) => Err(format!("{operation_name}[{i}] panicked: {join_error}")),
}
});
handles.push(handle);
}
let timeout_duration = Duration::from_secs(
std::env::var("TEST_TIMEOUT")
.unwrap_or_else(|_| "120".to_string())
.parse::<u64>()
.unwrap_or(120),
);
let mut results = Vec::new();
for (i, handle) in handles.into_iter().enumerate() {
match tokio::time::timeout(timeout_duration, handle).await {
Ok(Ok(result)) => results.push(result),
Ok(Err(join_error)) => {
results.push(Err(format!(
"{operation_name}[{i}] join error: {join_error}"
)));
}
Err(_) => {
results.push(Err(format!(
"{operation_name}[{i}] timed out after {timeout_duration:?}"
)));
}
}
}
results
}
pub struct TestFixture {
#[allow(dead_code)]
pub temp_dir: TempDir,
pub repo_path: PathBuf,
pub binary_path: PathBuf,
}
impl TestFixture {
pub async fn new() -> Self {
let (temp_dir, repo_path) = create_test_git_repo().await;
let binary_path = get_binary_path();
Self {
temp_dir,
repo_path,
binary_path,
}
}
#[allow(dead_code)]
pub async fn new_with_bitbucket_url(url: String) -> Self {
let (temp_dir, repo_path) = create_test_cascade_repo(Some(url)).await;
let binary_path = get_binary_path();
Self {
temp_dir,
repo_path,
binary_path,
}
}
#[allow(dead_code)]
pub async fn run_cli(&self, args: &[&str]) -> std::process::Output {
let timeout = Duration::from_secs(
std::env::var("TEST_TIMEOUT")
.unwrap_or_else(|_| "60".to_string())
.parse::<u64>()
.unwrap_or(60),
);
run_cli_with_timeout(&self.binary_path, args, &self.repo_path, timeout)
.await
.unwrap_or_else(|e| panic!("CLI command failed: {e}"))
}
#[allow(dead_code)]
pub async fn run_cli_expect_success(
&self,
args: &[&str],
operation: &str,
) -> std::process::Output {
let output = self.run_cli(args).await;
assert_cli_success(&output, operation);
output
}
#[allow(dead_code)]
pub async fn run_cli_expect_error(
&self,
args: &[&str],
operation: &str,
expected_error: &str,
) -> std::process::Output {
let output = self.run_cli(args).await;
assert_cli_error_contains(&output, operation, expected_error);
output
}
#[allow(dead_code)]
pub async fn create_commits(&self, count: u32, prefix: &str) {
create_test_commits(&self.repo_path, count, prefix).await;
}
}
#[allow(dead_code)]
pub fn create_test_repo_with_commits(
repo_path: &Path,
commit_count: usize,
) -> Result<(Repository, Vec<String>), String> {
let repo =
Repository::init(repo_path).map_err(|e| format!("Failed to initialize repository: {e}"))?;
let mut config = repo
.config()
.map_err(|e| format!("Failed to get repository config: {e}"))?;
config
.set_str("user.name", "Test User")
.map_err(|e| format!("Failed to set user.name: {e}"))?;
config
.set_str("user.email", "test@example.com")
.map_err(|e| format!("Failed to set user.email: {e}"))?;
let signature = Signature::new("Test User", "test@example.com", &Time::new(1234567890, 0))
.map_err(|e| format!("Failed to create signature: {e}"))?;
let mut commit_oids = Vec::new();
let mut parent_oid: Option<git2::Oid> = None;
for i in 0..commit_count {
let filename = format!("test-file-{}.txt", i + 1);
let filepath = repo_path.join(&filename);
let content = format!("Content for commit {}\nTimestamp: {}", i + 1, i * 1000);
std::fs::write(&filepath, content)
.map_err(|e| format!("Failed to write test file: {e}"))?;
let mut index = repo
.index()
.map_err(|e| format!("Failed to get repository index: {e}"))?;
index
.add_path(std::path::Path::new(&filename))
.map_err(|e| format!("Failed to add file to index: {e}"))?;
index
.write()
.map_err(|e| format!("Failed to write index: {e}"))?;
let tree_oid = index
.write_tree()
.map_err(|e| format!("Failed to write tree: {e}"))?;
let tree = repo
.find_tree(tree_oid)
.map_err(|e| format!("Failed to find tree: {e}"))?;
let message = format!("Test commit {}", i + 1);
let parents: Vec<git2::Commit> = if let Some(parent_oid) = parent_oid {
vec![repo
.find_commit(parent_oid)
.map_err(|e| format!("Failed to find parent commit: {e}"))?]
} else {
vec![] };
let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
let commit_oid = repo
.commit(
Some("HEAD"),
&signature,
&signature,
&message,
&tree,
&parent_refs,
)
.map_err(|e| format!("Failed to create commit: {e}"))?;
commit_oids.push(commit_oid.to_string());
parent_oid = Some(commit_oid);
}
Ok((repo, commit_oids))
}
#[allow(dead_code)]
pub async fn run_cc_with_timeout(args: &[&str], timeout_ms: u64) -> std::process::Output {
let binary_path = get_binary_path();
let current_dir = std::env::current_dir().expect("Failed to get current directory");
let timeout = Duration::from_millis(timeout_ms);
match run_cli_with_timeout(&binary_path, args, ¤t_dir, timeout).await {
Ok(output) => output,
Err(e) => panic!("CLI command failed: {e}"),
}
}
#[allow(dead_code)]
pub async fn run_cc_with_timeout_in_dir(
args: &[&str],
timeout_ms: u64,
repo_path: &Path,
) -> std::process::Output {
let binary_path = get_binary_path();
let timeout = Duration::from_millis(timeout_ms);
match run_cli_with_timeout(&binary_path, args, repo_path, timeout).await {
Ok(output) => output,
Err(e) => panic!("CLI command failed: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_fixture_creation() {
let fixture = TestFixture::new().await;
assert!(fixture.repo_path.exists());
assert!(fixture.binary_path.exists());
let output = Command::new("git")
.args(["config", "user.name"])
.current_dir(&fixture.repo_path)
.output()
.unwrap();
let username = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(username, "Test User");
let output = Command::new("git")
.args(["log", "--oneline"])
.current_dir(&fixture.repo_path)
.output()
.unwrap();
assert!(!output.stdout.is_empty());
}
#[tokio::test]
async fn test_timeout_wrapper() {
let fixture = TestFixture::new().await;
let result = run_cli_with_timeout(
&fixture.binary_path,
&["--help"],
&fixture.repo_path,
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "Help command should succeed");
let result = run_cli_with_timeout(
&fixture.binary_path,
&["stacks", "list"], &fixture.repo_path,
Duration::from_millis(10), )
.await;
if let Err(error_msg) = result {
assert!(error_msg.contains("timed out"));
}
}
#[tokio::test]
async fn test_parallel_operations() {
let operations: Vec<Box<dyn FnOnce() -> Result<String, String> + Send>> = (0..3)
.map(|i| {
let closure: Box<dyn FnOnce() -> Result<String, String> + Send> =
Box::new(move || {
std::thread::sleep(Duration::from_millis(10));
Ok(format!("result-{i}"))
});
closure
})
.collect();
let results = run_parallel_operations(operations, "test_parallel".to_string()).await;
assert_eq!(results.len(), 3);
for (i, result) in results.iter().enumerate() {
assert!(result.is_ok(), "Operation {i} should succeed");
assert_eq!(result.as_ref().unwrap(), &format!("result-{i}"));
}
}
}