mod common;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Output, Stdio};
use tempfile::TempDir;
fn has_network() -> bool {
Command::new("curl")
.args(["-fsSL", "--connect-timeout", "5", "https://api.github.com"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
fn has_bash() -> bool {
Command::new("bash")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
fn run_installer(temp_dir: &TempDir, args: &[&str], env_vars: HashMap<&str, &str>) -> Output {
let install_script = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("install.sh");
let dest_dir = temp_dir.path().join("bin");
fs::create_dir_all(&dest_dir).expect("Failed to create dest dir");
let mut cmd = Command::new("bash");
cmd.arg(&install_script);
cmd.args(args);
cmd.env("HOME", temp_dir.path());
cmd.env("DEST", &dest_dir);
cmd.env("NO_GUM", "1"); cmd.current_dir(temp_dir.path());
cmd.env_remove("BR_INSTALL_DIR");
cmd.env_remove("VERSION");
for (key, value) in env_vars {
cmd.env(key, value);
}
cmd.output().expect("Failed to run installer")
}
fn run_installer_function(temp_dir: &TempDir, _function_name: &str, function_call: &str) -> Output {
let install_script = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("install.sh");
let script = format!(
r#"
set +e
source "{}" 2>/dev/null || true
{}
"#,
install_script.display(),
function_call
);
Command::new("bash")
.args(["-c", &script])
.env("HOME", temp_dir.path())
.env("NO_GUM", "1")
.env("QUIET", "1")
.current_dir(temp_dir.path())
.output()
.expect("Failed to run installer function")
}
fn install_script_contents() -> String {
let install_script = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("install.sh");
fs::read_to_string(install_script).expect("read install.sh")
}
fn shell_function_section<'a>(script: &'a str, function_name: &str) -> &'a str {
let marker = format!("{function_name}() {{");
let start = script.find(&marker).expect("function marker") + marker.len();
let tail = &script[start..];
let end = tail
.find("\n# ============================================================================")
.unwrap_or(tail.len());
&tail[..end]
}
#[test]
fn e2e_installer_platform_detection_linux_x64() {
if !has_bash() {
eprintln!("Skipping test: bash not available");
return;
}
let temp = TempDir::new().expect("temp dir");
let output = run_installer_function(&temp, "detect_platform", "detect_platform");
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let platform = stdout.trim();
assert!(
platform.contains('_'),
"Platform should be os[_libc]_arch format, got: {platform}"
);
let parts: Vec<&str> = platform.split('_').collect();
assert!(
parts.len() == 2 || parts.len() == 3,
"Platform should have 2 or 3 parts, got {parts:?}"
);
let valid_os = ["linux", "darwin", "windows"];
let valid_libc = ["musl"];
let valid_arch = ["amd64", "arm64", "armv7"];
assert!(
valid_os.contains(&parts[0]),
"Invalid OS: {}, expected one of {:?}",
parts[0],
valid_os
);
if parts.len() == 3 {
assert_eq!(parts[0], "linux", "libc segment is only valid on Linux");
assert!(
valid_libc.contains(&parts[1]),
"Invalid libc: {}, expected one of {:?}",
parts[1],
valid_libc
);
assert!(
valid_arch.contains(&parts[2]),
"Invalid arch: {}, expected one of {:?}",
parts[2],
valid_arch
);
} else {
assert!(
valid_arch.contains(&parts[1]),
"Invalid arch: {}, expected one of {:?}",
parts[1],
valid_arch
);
}
}
}
#[test]
fn e2e_installer_detects_system_platform() {
if !has_bash() {
eprintln!("Skipping test: bash not available");
return;
}
let temp = TempDir::new().expect("temp dir");
let os_output = Command::new("uname").arg("-s").output().expect("uname -s");
let arch_output = Command::new("uname").arg("-m").output().expect("uname -m");
let os_raw = String::from_utf8_lossy(&os_output.stdout)
.trim()
.to_lowercase();
let arch_raw = String::from_utf8_lossy(&arch_output.stdout)
.trim()
.to_lowercase();
let expected_os = match os_raw.as_str() {
s if s.starts_with("linux") => "linux",
s if s.starts_with("darwin") => "darwin",
s if s.contains("mingw") || s.contains("msys") || s.contains("cygwin") => "windows",
_ => &os_raw,
};
let expected_arch = match arch_raw.as_str() {
"x86_64" | "amd64" => "amd64",
"aarch64" | "arm64" => "arm64",
s if s.starts_with("armv7") => "armv7",
_ => &arch_raw,
};
let output = run_installer_function(&temp, "detect_platform", "detect_platform");
if output.status.success() {
let detected = String::from_utf8_lossy(&output.stdout).trim().to_string();
let two_part = format!("{expected_os}_{expected_arch}");
let three_part_musl = format!("{expected_os}_musl_{expected_arch}");
assert!(
detected == two_part || detected == three_part_musl,
"Platform detection mismatch: got {detected}, expected {two_part} or {three_part_musl}"
);
}
}
#[test]
fn e2e_installer_version_resolution_explicit() {
if !has_bash() {
eprintln!("Skipping test: bash not available");
return;
}
let temp = TempDir::new().expect("temp dir");
let output = run_installer(&temp, &["--version", "v0.1.0", "--help"], HashMap::new());
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stdout.contains("br installer") || stdout.contains("install") || stderr.contains("br"),
"Help output should mention br installer"
);
}
#[test]
fn e2e_installer_uses_tagless_release_asset_names() {
if !has_bash() {
eprintln!("Skipping test: bash not available");
return;
}
let temp = TempDir::new().expect("temp dir");
let output = run_installer_function(
&temp,
"release_asset_version",
r#"
printf 'asset_from_tag=%s\n' "$(release_asset_version "v0.2.10")"
printf 'asset_from_plain=%s\n' "$(release_asset_version "0.2.10")"
printf 'tag_from_tag=%s\n' "$(release_download_tag "v0.2.10")"
printf 'tag_from_plain=%s\n' "$(release_download_tag "0.2.10")"
trap - EXIT
exit 0
"#,
);
assert!(
output.status.success(),
"version helper probe failed: stdout={}, stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("asset_from_tag=0.2.10"));
assert!(stdout.contains("asset_from_plain=0.2.10"));
assert!(stdout.contains("tag_from_tag=v0.2.10"));
assert!(stdout.contains("tag_from_plain=v0.2.10"));
let script = install_script_contents();
let download_release = shell_function_section(&script, "download_release");
assert!(
download_release
.contains(r#"archive_name="br-${asset_version}-${platform}.${archive_ext}""#)
);
assert!(download_release.contains(r"/releases/download/${release_tag}/${archive_name}"));
assert!(!download_release.contains(r#"archive_name="br-${VERSION}-${platform}"#));
}
#[test]
#[ignore = "requires network access"]
fn e2e_installer_version_resolution_github_api() {
if !has_bash() || !has_network() {
eprintln!("Skipping test: bash or network not available");
return;
}
let temp = TempDir::new().expect("temp dir");
let script = r#"
QUIET=1
NO_GUM=1
MAX_RETRIES=1
resolve_version
echo "VERSION=$VERSION"
"#;
let install_script = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("install.sh");
let full_script = format!(
r#"source "{}" 2>/dev/null; {}"#,
install_script.display(),
script
);
let output = Command::new("bash")
.args(["-c", &full_script])
.env("HOME", temp.path())
.env("NO_GUM", "1")
.output()
.expect("Failed to run version resolution");
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.contains("VERSION=v") {
let version = stdout
.lines()
.find(|l| l.starts_with("VERSION="))
.and_then(|l| l.strip_prefix("VERSION="))
.unwrap_or("");
assert!(
version.starts_with('v'),
"Version should start with 'v', got: {version}"
);
}
}
}
#[test]
fn e2e_installer_checksum_verification_sha256sum() {
if !has_bash() {
eprintln!("Skipping test: bash not available");
return;
}
let temp = TempDir::new().expect("temp dir");
let test_file = temp.path().join("test_file.txt");
fs::write(&test_file, "hello world\n").expect("write test file");
let sha_output = Command::new("sha256sum").arg(&test_file).output();
if sha_output.is_err() {
let sha_output = Command::new("shasum")
.args(["-a", "256"])
.arg(&test_file)
.output();
if sha_output.is_err() {
eprintln!("Skipping test: no sha256sum or shasum available");
return;
}
}
let sha_output = Command::new("sh")
.args([
"-c",
&format!(
"sha256sum '{}' 2>/dev/null || shasum -a 256 '{}'",
test_file.display(),
test_file.display()
),
])
.output()
.expect("sha256sum");
let expected_hash = String::from_utf8_lossy(&sha_output.stdout)
.split_whitespace()
.next()
.unwrap_or("")
.to_string();
assert!(!expected_hash.is_empty(), "Should get a hash");
assert_eq!(expected_hash.len(), 64, "SHA256 should be 64 chars");
}
#[test]
fn e2e_installer_checksum_mismatch_fails() {
if !has_bash() {
eprintln!("Skipping test: bash not available");
return;
}
let temp = TempDir::new().expect("temp dir");
let archive = temp.path().join("br-0.0.0-linux_amd64.tar.gz");
fs::write(&archive, b"not a release archive").expect("write fake archive");
let bad_checksum = "0000000000000000000000000000000000000000000000000000000000000000";
let artifact_url = format!("file://{}", archive.display());
let output = run_installer(
&temp,
&[
"--version",
"v0.0.0",
"--artifact-url",
&artifact_url,
"--checksum",
bad_checksum,
"--quiet",
"--skip-skills",
],
HashMap::new(),
);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!output.status.success(),
"installer must fail closed on checksum mismatch; stdout={stdout}, stderr={stderr}"
);
assert!(
stderr.contains("Checksum mismatch"),
"stderr should explain the checksum mismatch; stdout={stdout}, stderr={stderr}"
);
assert!(
stderr.contains("Release artifact verification failed"),
"stderr should not fall back to source build after verification failure; stdout={stdout}, stderr={stderr}"
);
let binary_path = temp.path().join("bin").join("br");
assert!(
!binary_path.exists(),
"checksum mismatch must not install a binary"
);
}
#[test]
fn e2e_installer_idempotent_runs_twice() {
if !has_bash() {
eprintln!("Skipping test: bash not available");
return;
}
let temp = TempDir::new().expect("temp dir");
let dest = temp.path().join("bin");
fs::create_dir_all(&dest).expect("create dest");
let fake_binary = dest.join("br");
fs::write(&fake_binary, "#!/bin/sh\necho 'br 0.0.1'").expect("write fake");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&fake_binary, fs::Permissions::from_mode(0o755)).expect("chmod");
}
let output1 = run_installer(&temp, &["--quiet"], HashMap::new());
let stderr1 = String::from_utf8_lossy(&output1.stderr);
let output2 = run_installer(&temp, &["--quiet"], HashMap::new());
let stderr2 = String::from_utf8_lossy(&output2.stderr);
assert!(
!stderr1.contains("panic") && !stderr2.contains("panic"),
"Installer should not panic on repeated runs: stderr1={stderr1}, stderr2={stderr2}"
);
}
#[test]
fn e2e_installer_creates_dest_directory() {
if !has_bash() {
eprintln!("Skipping test: bash not available");
return;
}
let temp = TempDir::new().expect("temp dir");
let nested_dest = temp.path().join("deeply").join("nested").join("bin");
let mut env = HashMap::new();
env.insert("DEST", nested_dest.to_str().unwrap());
let _output = run_installer(&temp, &["--quiet"], env);
}
#[test]
fn e2e_installer_lock_prevents_concurrent() {
if !has_bash() {
eprintln!("Skipping test: bash not available");
return;
}
let temp = TempDir::new().expect("temp dir");
let lock_dir = PathBuf::from("/tmp/br-install.lock.d");
let _ = fs::remove_dir_all(&lock_dir);
fs::create_dir_all(&lock_dir).expect("create lock dir");
fs::write(lock_dir.join("pid"), "999999999").expect("write stale pid");
let output = run_installer(&temp, &["--help"], HashMap::new());
let _ = fs::remove_dir_all(&lock_dir);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("br installer") || stdout.contains("--version"),
"Should print help after recovering stale lock"
);
}
#[test]
fn e2e_installer_uninstall_removes_binary() {
if !has_bash() {
eprintln!("Skipping test: bash not available");
return;
}
let temp = TempDir::new().expect("temp dir");
let dest = temp.path().join("bin");
fs::create_dir_all(&dest).expect("create dest");
let binary_path = dest.join("br");
fs::write(&binary_path, "#!/bin/sh\necho 'br 0.0.1'").expect("write fake");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&binary_path, fs::Permissions::from_mode(0o755)).expect("chmod");
}
assert!(binary_path.exists(), "Binary should exist before uninstall");
let mut env = HashMap::new();
env.insert("DEST", dest.to_str().unwrap());
let output = run_installer(&temp, &["--uninstall"], env);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!binary_path.exists(),
"Binary should be removed after uninstall"
);
assert!(
stderr.contains("uninstalled") || stderr.contains("Removed"),
"Should report successful uninstall"
);
}
#[test]
fn e2e_installer_respects_br_install_dir() {
if !has_bash() {
eprintln!("Skipping test: bash not available");
return;
}
let temp = TempDir::new().expect("temp dir");
let custom_dir = temp.path().join("custom_install_location");
let mut env = HashMap::new();
env.insert("BR_INSTALL_DIR", custom_dir.to_str().unwrap());
let _output = run_installer(&temp, &["--quiet"], env);
}
#[test]
fn e2e_installer_no_gum_disables_fancy_output() {
if !has_bash() {
eprintln!("Skipping test: bash not available");
return;
}
let temp = TempDir::new().expect("temp dir");
let mut env = HashMap::new();
env.insert("NO_GUM", "1");
let output = run_installer(&temp, &["--help"], env);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("br installer") || stdout.contains("Usage"),
"Should print help in plain format"
);
}
#[test]
fn e2e_installer_from_source_flag_accepted() {
if !has_bash() {
eprintln!("Skipping test: bash not available");
return;
}
let temp = TempDir::new().expect("temp dir");
let output = run_installer(&temp, &["--from-source", "--help"], HashMap::new());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("br installer") || stdout.contains("--from-source"),
"Should accept --from-source flag"
);
}
#[test]
fn e2e_installer_source_build_preflight_does_not_touch_shared_cargo_state() {
let script = install_script_contents();
let prepare_for_build = shell_function_section(&script, "prepare_for_build");
assert!(
!prepare_for_build.contains("pkill"),
"source-build preflight must not kill unrelated cargo processes"
);
for forbidden_path in [
"~/.cargo/.package-cache",
"~/.cargo/registry/.crate-cache.lock",
"~/.cargo/registry/cache",
"/tmp/cargo-target",
] {
assert!(
!prepare_for_build.contains(forbidden_path),
"source-build preflight must not delete shared Cargo state: {forbidden_path}"
);
}
}
#[test]
#[ignore = "requires network access and takes time"]
fn e2e_installer_full_install_and_verify() {
if !has_bash() || !has_network() {
eprintln!("Skipping test: bash or network not available");
return;
}
let temp = TempDir::new().expect("temp dir");
let dest = temp.path().join("bin");
let mut env = HashMap::new();
env.insert("DEST", dest.to_str().unwrap());
let output = run_installer(&temp, &["--verify"], env);
let _stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.success() {
let binary_path = dest.join("br");
assert!(binary_path.exists(), "Binary should exist after install");
let version_output = Command::new(&binary_path)
.arg("--version")
.output()
.expect("run installed br");
assert!(
version_output.status.success(),
"Installed binary should run: {}",
String::from_utf8_lossy(&version_output.stderr)
);
let version_str = String::from_utf8_lossy(&version_output.stdout);
assert!(
version_str.contains("br") || version_str.contains("0."),
"Should report version: {version_str}"
);
} else {
eprintln!("Install failed (may be network issue):\n{stderr}");
}
}
#[test]
fn e2e_installer_proxy_env_vars_accepted() {
if !has_bash() {
eprintln!("Skipping test: bash not available");
return;
}
let temp = TempDir::new().expect("temp dir");
let mut env = HashMap::new();
env.insert("HTTPS_PROXY", "http://localhost:8888");
env.insert("HTTP_PROXY", "http://localhost:8888");
let output = run_installer(&temp, &["--help"], env);
let stdout = String::from_utf8_lossy(&output.stdout);
let _stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stdout.contains("br installer") || stdout.contains("HTTPS_PROXY"),
"Should mention HTTPS_PROXY in help. stdout={stdout}"
);
}
#[test]
fn e2e_installer_help_shows_all_options() {
if !has_bash() {
eprintln!("Skipping test: bash not available");
return;
}
let temp = TempDir::new().expect("temp dir");
let output = run_installer(&temp, &["--help"], HashMap::new());
let stdout = String::from_utf8_lossy(&output.stdout);
let expected_options = [
"--version",
"--dest",
"--system",
"--easy-mode",
"--verify",
"--from-source",
"--quiet",
"--uninstall",
];
for option in expected_options {
assert!(stdout.contains(option), "Help should document {option}");
}
}
#[test]
fn e2e_installer_shows_supported_platforms() {
if !has_bash() {
eprintln!("Skipping test: bash not available");
return;
}
let temp = TempDir::new().expect("temp dir");
let output = run_installer(&temp, &["--help"], HashMap::new());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Linux") || stdout.contains("linux"),
"Help should mention Linux support"
);
assert!(
stdout.contains("macOS") || stdout.contains("darwin") || stdout.contains("Mac"),
"Help should mention macOS support"
);
}