#![cfg(unix)]
use anyhow::{Context, Result, bail};
use chrono::Utc;
use rch_common::binary_hash::compute_binary_hash;
use rch_common::e2e::RustProjectFixture;
use rch_common::remote_compilation::RemoteCompilationTest;
use rch_common::test_change::{TestChangeGuard, TestCodeChange};
use rch_common::types::{WorkerConfig, WorkerId};
use std::path::Path;
use std::time::Duration;
use tempfile::TempDir;
use tokio::process::Command;
use tokio::time::sleep;
use tracing::info;
fn init_test_logging() {
let _ = tracing_subscriber::fmt()
.with_test_writer()
.with_max_level(tracing::Level::DEBUG)
.try_init();
}
async fn build_release(project_dir: &Path) -> Result<()> {
let output = Command::new("cargo")
.arg("build")
.arg("--release")
.current_dir(project_dir)
.env("CARGO_INCREMENTAL", "0")
.output()
.await
.context("Failed to execute cargo build")?;
if !output.status.success() {
bail!(
"Local build failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
fn binary_path(project_dir: &Path, name: &str) -> std::path::PathBuf {
let target_dir = std::env::var_os("CARGO_TARGET_DIR")
.map(std::path::PathBuf::from)
.map(|path| {
if path.is_absolute() {
path
} else {
project_dir.join(path)
}
})
.unwrap_or_else(|| project_dir.join("target"));
target_dir.join("release").join(name)
}
fn load_worker_from_env() -> Option<WorkerConfig> {
let host = std::env::var("RCH_E2E_WORKER_HOST").ok()?;
if host.starts_with("mock://") {
return None;
}
let user = std::env::var("RCH_E2E_WORKER_USER")
.unwrap_or_else(|_| whoami::username().unwrap_or_else(|_| "unknown".to_string()));
let identity_file =
std::env::var("RCH_E2E_WORKER_KEY").unwrap_or_else(|_| "~/.ssh/id_rsa".to_string());
Some(WorkerConfig {
id: WorkerId::new("e2e-worker"),
host,
user,
identity_file,
total_slots: 4,
priority: 100,
tags: Vec::new(),
tools: Vec::new(),
})
}
#[tokio::test]
async fn test_binary_hash_computation_e2e() {
init_test_logging();
info!("[e2e::self_test] TEST START: binary_hash_computation");
let temp_dir = TempDir::new().expect("temp dir");
let project = RustProjectFixture::minimal("hash_test");
project.create_in(temp_dir.path()).expect("create project");
build_release(temp_dir.path()).await.expect("local build");
let bin_path = binary_path(temp_dir.path(), "hash_test");
let hash1 = compute_binary_hash(&bin_path).expect("hash 1");
let hash2 = compute_binary_hash(&bin_path).expect("hash 2");
assert_eq!(hash1.code_hash, hash2.code_hash);
assert!(!hash1.code_hash.is_empty());
info!("[e2e::self_test] TEST PASS: binary_hash_computation");
}
#[tokio::test]
async fn test_code_change_produces_different_hash() {
init_test_logging();
info!("[e2e::self_test] TEST START: code_change_hash_diff");
let suffix = Utc::now().timestamp_nanos_opt().unwrap_or(0);
let project_name = format!("change_test_{}", suffix);
let temp_dir = TempDir::new().expect("temp dir");
let project = RustProjectFixture::minimal(&project_name);
project.create_in(temp_dir.path()).expect("create project");
info!("Building project {} in {:?}", project_name, temp_dir.path());
build_release(temp_dir.path()).await.expect("initial build");
let bin_path = binary_path(temp_dir.path(), &project_name);
let hash1 = compute_binary_hash(&bin_path).expect("hash 1");
let change = TestCodeChange::for_main_rs(temp_dir.path()).expect("test change");
let _guard = TestChangeGuard::new(change).expect("apply test change");
sleep(Duration::from_millis(1100)).await;
let main_rs = temp_dir.path().join("src/main.rs");
let status = Command::new("touch")
.arg(&main_rs)
.status()
.await
.expect("touch command");
assert!(status.success(), "touch failed");
info!("Rebuilding project after change...");
build_release(temp_dir.path())
.await
.expect("rebuild after change");
let hash2 = compute_binary_hash(&bin_path).expect("hash 2");
assert_ne!(hash1.code_hash, hash2.code_hash);
info!("[e2e::self_test] TEST PASS: code_change_hash_diff");
}
#[tokio::test]
async fn test_remote_compilation_verification_e2e() {
init_test_logging();
info!("[e2e::self_test] TEST START: remote_compilation_verification");
let Some(worker) = load_worker_from_env() else {
info!("[e2e::self_test] SKIP: RCH_E2E_WORKER_HOST not set");
return;
};
let temp_dir = TempDir::new().expect("temp dir");
let project = RustProjectFixture::minimal("remote_test");
project.create_in(temp_dir.path()).expect("create project");
let test = RemoteCompilationTest::new(worker, temp_dir.path().to_path_buf());
let result = test.run().await.expect("remote compilation test");
assert!(
result.success,
"remote verification failed: {:?}",
result.error
);
assert_eq!(result.local_hash.code_hash, result.remote_hash.code_hash);
info!("[e2e::self_test] TEST PASS: remote_compilation_verification");
}
#[tokio::test]
async fn test_verify_compilation_on_worker_e2e() {
init_test_logging();
info!("[e2e::self_test] TEST START: verify_compilation_on_worker (br-0r1pg)");
let Some(worker) = load_worker_from_env() else {
info!("[e2e::self_test] SKIP: RCH_E2E_WORKER_HOST not set");
return;
};
let temp_dir = TempDir::new().expect("temp dir");
let project = RustProjectFixture::minimal("worker_verify_test");
project.create_in(temp_dir.path()).expect("create project");
let test = RemoteCompilationTest::new(worker.clone(), temp_dir.path().to_path_buf());
let result = test.run().await.expect("remote compilation test");
assert!(result.success, "compilation failed: {:?}", result.error);
info!(
"[e2e::self_test] dispatched build to worker={} local_hash_prefix={}",
worker.id,
&result.local_hash.code_hash[..8.min(result.local_hash.code_hash.len())]
);
let identity = if let Some(rest) = worker.identity_file.strip_prefix("~/") {
std::env::var("HOME")
.map(|h| format!("{}/{}", h, rest))
.unwrap_or_else(|_| worker.identity_file.clone())
} else {
worker.identity_file.clone()
};
let ssh_target = format!("{}@{}", worker.user, worker.host);
let ssh_probe = Command::new("ssh")
.args([
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=no",
"-i",
])
.arg(&identity)
.arg(&ssh_target)
.arg("find /tmp/rch -name '*.o' 2>/dev/null | head -5")
.output()
.await
.expect("ssh probe");
let stdout = String::from_utf8_lossy(&ssh_probe.stdout);
info!(
"[e2e::self_test] SSH probe stdout len={} status={:?}",
stdout.len(),
ssh_probe.status.code()
);
if ssh_probe.status.success() && !stdout.trim().is_empty() {
info!("[e2e::self_test] worker has object files (non-empty find output)");
} else {
info!("[e2e::self_test] (ssh probe found no obj files; cache may have been cleaned)");
}
info!("[e2e::self_test] TEST PASS: verify_compilation_on_worker");
}
#[tokio::test]
async fn test_complete_self_test_workflow_e2e() {
init_test_logging();
info!("[e2e::self_test] TEST START: complete_self_test_workflow (br-0r1pg, 8-step)");
let Some(worker) = load_worker_from_env() else {
info!("[e2e::self_test] SKIP: RCH_E2E_WORKER_HOST not set");
return;
};
let temp_dir = TempDir::new().expect("temp dir");
let suffix = Utc::now().timestamp_nanos_opt().unwrap_or(0);
let proj_name = format!("complete_workflow_{}", suffix);
let project = RustProjectFixture::minimal(&proj_name);
project.create_in(temp_dir.path()).expect("create project");
info!("[e2e::self_test] step=1 description=initial_build");
build_release(temp_dir.path())
.await
.expect("step 1: initial build");
let bin_path = binary_path(temp_dir.path(), &proj_name);
let initial_hash = compute_binary_hash(&bin_path).expect("step 1: hash");
info!(
"[e2e::self_test] step=1 result_hash_prefix={}",
&initial_hash.code_hash[..8.min(initial_hash.code_hash.len())]
);
info!("[e2e::self_test] step=2 description=inject_code_change");
let change = TestCodeChange::for_main_rs(temp_dir.path()).expect("step 2: change");
let _guard = TestChangeGuard::new(change).expect("step 2: apply");
info!("[e2e::self_test] step=3 description=advance_mtime");
sleep(Duration::from_millis(1100)).await;
let main_rs = temp_dir.path().join("src/main.rs");
let _ = Command::new("touch")
.arg(&main_rs)
.status()
.await
.expect("touch");
info!("[e2e::self_test] step=4 description=rebuild_after_change");
build_release(temp_dir.path())
.await
.expect("step 4: rebuild");
let new_local_hash = compute_binary_hash(&bin_path).expect("step 4: hash");
info!(
"[e2e::self_test] step=4 result_hash_prefix={}",
&new_local_hash.code_hash[..8.min(new_local_hash.code_hash.len())]
);
assert_ne!(
initial_hash.code_hash, new_local_hash.code_hash,
"step 4: rebuild should produce different hash"
);
info!("[e2e::self_test] step=5-7 description=remote_compile_and_verify");
let test = RemoteCompilationTest::new(worker.clone(), temp_dir.path().to_path_buf());
let result = test.run().await.expect("step 5-7: remote test");
assert!(
result.success,
"step 5-7: remote verification failed: {:?}",
result.error
);
info!(
"[e2e::self_test] step=5-7 worker={} local={} remote={}",
worker.id,
&result.local_hash.code_hash[..8.min(result.local_hash.code_hash.len())],
&result.remote_hash.code_hash[..8.min(result.remote_hash.code_hash.len())]
);
info!("[e2e::self_test] step=8 description=hash_equality");
assert_eq!(
result.local_hash.code_hash, result.remote_hash.code_hash,
"step 8: local and remote hashes must match for byte-for-byte transfer"
);
info!("[e2e::self_test] step=8 PASS hashes_match=true");
info!("[e2e::self_test] TEST PASS: complete_self_test_workflow (8 steps)");
}