mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-02-20 07:37:26 -05:00
361 lines
9.2 KiB
Rust
361 lines
9.2 KiB
Rust
//! Cargo test subprocess runner implementation
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::process::Stdio;
|
|
use std::time::{Duration, Instant};
|
|
use tempfile::TempDir;
|
|
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
|
|
use tokio::process::{Child, Command};
|
|
use tokio::time::interval;
|
|
|
|
/// A single subprocess in a cargo test-based test
|
|
pub struct TestProcess {
|
|
pub name: String,
|
|
pub test_function_name: String,
|
|
pub data_dir: TempDir,
|
|
pub child: Option<Child>,
|
|
pub output: String,
|
|
}
|
|
|
|
/// Cargo test-based multi-process test runner
|
|
pub struct CargoTestRunner {
|
|
processes: Vec<TestProcess>,
|
|
global_timeout: Duration,
|
|
test_file_name: String,
|
|
test_binary_path: Option<PathBuf>,
|
|
}
|
|
|
|
impl CargoTestRunner {
|
|
/// Create a new cargo test runner
|
|
pub fn new() -> Self {
|
|
Self {
|
|
processes: Vec::new(),
|
|
global_timeout: Duration::from_secs(60),
|
|
test_file_name: "test_core_pairing".to_string(),
|
|
test_binary_path: None,
|
|
}
|
|
}
|
|
|
|
/// Create a new cargo test runner for a specific test file
|
|
pub fn for_test_file(test_file_name: impl Into<String>) -> Self {
|
|
Self {
|
|
processes: Vec::new(),
|
|
global_timeout: Duration::from_secs(60),
|
|
test_file_name: test_file_name.into(),
|
|
test_binary_path: None,
|
|
}
|
|
}
|
|
|
|
/// Set global timeout for all operations
|
|
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
|
self.global_timeout = timeout;
|
|
self
|
|
}
|
|
|
|
/// Add a subprocess with a test function name
|
|
pub fn add_subprocess(
|
|
mut self,
|
|
name: impl Into<String>,
|
|
test_function_name: impl Into<String>,
|
|
) -> Self {
|
|
let name = name.into();
|
|
let test_function_name = test_function_name.into();
|
|
let data_dir = TempDir::new().expect("Failed to create temp dir");
|
|
|
|
let process = TestProcess {
|
|
name,
|
|
test_function_name,
|
|
data_dir,
|
|
child: None,
|
|
output: String::new(),
|
|
};
|
|
|
|
self.processes.push(process);
|
|
self
|
|
}
|
|
|
|
/// Build the test binary once and cache the path
|
|
async fn build_test_binary(&mut self) -> Result<PathBuf, String> {
|
|
// Return cached path if already built
|
|
if let Some(ref path) = self.test_binary_path {
|
|
return Ok(path.clone());
|
|
}
|
|
|
|
println!("Building test binary for {}...", self.test_file_name);
|
|
|
|
// Run cargo test --no-run to build the test binary
|
|
let output = Command::new("cargo")
|
|
.args(&[
|
|
"test",
|
|
"--no-run",
|
|
"--test",
|
|
&self.test_file_name,
|
|
"--message-format=json",
|
|
])
|
|
.output()
|
|
.await
|
|
.map_err(|e| format!("Failed to run cargo test --no-run: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
return Err(format!(
|
|
"cargo test --no-run failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
));
|
|
}
|
|
|
|
// Parse JSON output to find the test binary
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
for line in stdout.lines() {
|
|
if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
|
|
if json["reason"] == "compiler-artifact"
|
|
&& json["target"]["kind"]
|
|
.as_array()
|
|
.map(|arr| arr.iter().any(|v| v == "test"))
|
|
.unwrap_or(false)
|
|
&& json["target"]["name"] == self.test_file_name
|
|
{
|
|
if let Some(executable) = json["executable"].as_str() {
|
|
let path = PathBuf::from(executable);
|
|
println!("Test binary built: {}", path.display());
|
|
self.test_binary_path = Some(path.clone());
|
|
return Ok(path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(format!(
|
|
"Could not find test binary path in cargo output for {}",
|
|
self.test_file_name
|
|
))
|
|
}
|
|
|
|
/// Run all subprocesses and wait until success condition is met
|
|
pub async fn run_until_success<F>(&mut self, condition: F) -> Result<(), String>
|
|
where
|
|
F: Fn(&HashMap<String, String>) -> bool,
|
|
{
|
|
// Spawn all subprocesses
|
|
self.spawn_all_processes().await?;
|
|
|
|
// Wait for success condition
|
|
self.wait_until_condition(condition).await?;
|
|
|
|
// Cleanup
|
|
self.kill_all().await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Spawn a single subprocess by name
|
|
pub async fn spawn_single_process(&mut self, name: &str) -> Result<(), String> {
|
|
// Build the test binary once (or use cached path)
|
|
let binary_path = self.build_test_binary().await?;
|
|
|
|
let process = self
|
|
.processes
|
|
.iter_mut()
|
|
.find(|p| p.name == name)
|
|
.ok_or_else(|| format!("Process '{}' not found", name))?;
|
|
|
|
// Execute the test binary directly instead of running cargo test
|
|
let mut command = Command::new(&binary_path);
|
|
command
|
|
.args(&[
|
|
&process.test_function_name,
|
|
"--nocapture",
|
|
"--ignored", // Run ignored tests
|
|
])
|
|
.env("TEST_ROLE", &process.name)
|
|
.env("TEST_DATA_DIR", process.data_dir.path().to_str().unwrap());
|
|
|
|
// Forward RUST_LOG from parent or default to debug for subprocess visibility
|
|
if let Ok(rust_log) = std::env::var("RUST_LOG") {
|
|
command.env("RUST_LOG", rust_log);
|
|
} else {
|
|
command.env("RUST_LOG", "debug");
|
|
}
|
|
|
|
let child = command
|
|
.stdout(Stdio::inherit())
|
|
.stderr(Stdio::inherit())
|
|
.spawn()
|
|
.map_err(|e| format!("Failed to spawn process '{}': {}", process.name, e))?;
|
|
|
|
process.child = Some(child);
|
|
println!(
|
|
"Spawned test process: {} (test: {})",
|
|
process.name, process.test_function_name
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Wait for success condition without spawning processes
|
|
pub async fn wait_for_success<F>(&mut self, condition: F) -> Result<(), String>
|
|
where
|
|
F: Fn(&HashMap<String, String>) -> bool,
|
|
{
|
|
// Wait for success condition
|
|
self.wait_until_condition(condition).await?;
|
|
|
|
// Cleanup
|
|
self.kill_all().await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Spawn all subprocesses using the test binary
|
|
async fn spawn_all_processes(&mut self) -> Result<(), String> {
|
|
// Build the test binary once (or use cached path)
|
|
let binary_path = self.build_test_binary().await?;
|
|
|
|
for process in &mut self.processes {
|
|
// Execute the test binary directly instead of running cargo test
|
|
let mut command = Command::new(&binary_path);
|
|
command
|
|
.args(&[
|
|
&process.test_function_name,
|
|
"--nocapture",
|
|
"--ignored", // Run ignored tests
|
|
])
|
|
.env("TEST_ROLE", &process.name)
|
|
.env("TEST_DATA_DIR", process.data_dir.path().to_str().unwrap());
|
|
|
|
// Forward RUST_LOG from parent or default to debug for subprocess visibility
|
|
if let Ok(rust_log) = std::env::var("RUST_LOG") {
|
|
command.env("RUST_LOG", rust_log);
|
|
} else {
|
|
command.env("RUST_LOG", "debug");
|
|
}
|
|
|
|
let child = command
|
|
.stdout(Stdio::inherit())
|
|
.stderr(Stdio::inherit())
|
|
.spawn()
|
|
.map_err(|e| format!("Failed to spawn process '{}': {}", process.name, e))?;
|
|
|
|
process.child = Some(child);
|
|
println!(
|
|
"Spawned test process: {} (test: {})",
|
|
process.name, process.test_function_name
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Wait until the success condition is met
|
|
async fn wait_until_condition<F>(&mut self, condition: F) -> Result<(), String>
|
|
where
|
|
F: Fn(&HashMap<String, String>) -> bool,
|
|
{
|
|
let mut check_interval = interval(Duration::from_millis(100));
|
|
let start_time = Instant::now();
|
|
|
|
loop {
|
|
tokio::select! {
|
|
_ = check_interval.tick() => {
|
|
// Read output from all processes
|
|
self.read_all_output().await;
|
|
|
|
// Build output map for condition check
|
|
let outputs: HashMap<String, String> = self.processes.iter()
|
|
.map(|p| (p.name.clone(), p.output.clone()))
|
|
.collect();
|
|
|
|
// Check condition
|
|
if condition(&outputs) {
|
|
println!("Success condition met after {:?}", start_time.elapsed());
|
|
return Ok(());
|
|
}
|
|
|
|
// Check for timeout
|
|
if start_time.elapsed() > self.global_timeout {
|
|
return Err("Timeout waiting for success condition".to_string());
|
|
}
|
|
|
|
// Check for failed processes
|
|
self.check_process_health()?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read output from all running processes
|
|
async fn read_all_output(&mut self) {
|
|
// Output is handled via stdio inheritance - just track what we see in output
|
|
for process in &mut self.processes {
|
|
if let Some(child) = &mut process.child {
|
|
// Check if process has exited to capture final output
|
|
if let Ok(Some(_)) = child.try_wait() {
|
|
// Process has exited, mark its output as complete
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if any processes have failed
|
|
fn check_process_health(&mut self) -> Result<(), String> {
|
|
for process in &mut self.processes {
|
|
if let Some(child) = &mut process.child {
|
|
if let Ok(Some(exit_status)) = child.try_wait() {
|
|
if !exit_status.success() {
|
|
return Err(format!(
|
|
"Process '{}' exited with failure: {:?}",
|
|
process.name,
|
|
exit_status.code()
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Kill all processes
|
|
pub async fn kill_all(&mut self) {
|
|
for process in &mut self.processes {
|
|
if let Some(mut child) = process.child.take() {
|
|
let _ = child.kill().await;
|
|
let _ = child.wait().await;
|
|
}
|
|
}
|
|
println!("Killed all cargo test processes");
|
|
}
|
|
|
|
/// Get output from a specific process
|
|
pub fn get_output(&self, name: &str) -> Option<&str> {
|
|
self.processes
|
|
.iter()
|
|
.find(|p| p.name == name)
|
|
.map(|p| p.output.as_str())
|
|
}
|
|
|
|
/// Get all outputs as a map
|
|
pub fn get_all_outputs(&self) -> HashMap<String, String> {
|
|
self.processes
|
|
.iter()
|
|
.map(|p| (p.name.clone(), p.output.clone()))
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
impl Drop for CargoTestRunner {
|
|
fn drop(&mut self) {
|
|
// Best effort cleanup
|
|
for process in &mut self.processes {
|
|
if let Some(mut child) = process.child.take() {
|
|
let _ = child.start_kill();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for CargoTestRunner {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|