Files
honey-biscuit-workshop/workshop-engine/tests/engine_integration.rs
T
Catty Steve 50b42e654c refactor(workshop-engine): replace tokio Child with ManagedChild trait
for testability

- Add `ManagedChild` trait abstracting process lifecycle (real
  `TokioChild` + `MockChild` for tests)
- Use Unix process groups (`process_group(0)` + `libc::kill(-pgid,
  SIGKILL)`) for tree-wide termination
- Remove cgroup-related code (`CgroupManager`, `ResourceUsage`,
  `cgroup_path` field)
- Update imports across test files to use `workshop-engine` crate
  directly
2026-04-27 12:02:17 +08:00

268 lines
7.8 KiB
Rust

//! Integration tests for workshop-engine
//!
//! These tests execute real commands and verify results.
//! Run with: cargo test --test engine_integration -p workshop-engine
//!
//! Note: These are TRUE integration tests - they run in a separate test binary
//! and execute actual commands on the system.
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use workshop_engine::{Engine, ExecutionContext};
fn create_executor() -> Engine {
Engine::new()
}
fn create_context() -> ExecutionContext {
ExecutionContext {
task_id: "test-integration-1".to_string(),
pipeline_name: "test-pipeline".to_string(),
username: "testuser".to_string(),
working_dir: std::env::temp_dir(),
env_vars: HashMap::new(),
timeout: Some(Duration::from_secs(30)),
shell: "bash".to_string(),
dry_run: false,
privileged: false,
standalone: false,
}
}
#[tokio::test]
async fn test_execute_echo_outputs_correct_message() {
let executor = create_executor();
let ctx = create_context();
use tokio::process::Command;
let mut cmd = Command::new("echo");
cmd.arg("hello world");
let result = executor.execute(cmd, &ctx, &None).await.unwrap();
assert!(result.success);
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("hello world"));
assert!(result.stderr.is_empty());
}
#[tokio::test]
async fn test_execute_true_returns_zero_exit_code() {
let executor = create_executor();
let ctx = create_context();
use tokio::process::Command;
let cmd = Command::new("true");
let result = executor.execute(cmd, &ctx, &None).await.unwrap();
assert!(result.success);
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_execute_false_returns_nonzero_exit_code() {
let executor = create_executor();
let ctx = create_context();
use tokio::process::Command;
let cmd = Command::new("false");
let result = executor.execute(cmd, &ctx, &None).await;
assert!(result.is_err());
// assert!(!result.success);
// assert_ne!(result.exit_code, 0);
}
#[tokio::test]
async fn test_execute_ls_lists_directory() {
let executor = create_executor();
let ctx = create_context();
use tokio::process::Command;
let mut cmd = Command::new("ls");
cmd.arg("-la");
cmd.arg("/tmp");
let result = executor.execute(cmd, &ctx, &None).await.unwrap();
assert!(result.success);
}
#[tokio::test]
async fn test_execute_date_command() {
let executor = create_executor();
let ctx = create_context();
use tokio::process::Command;
let cmd = Command::new("date");
let result = executor.execute(cmd, &ctx, &None).await.unwrap();
assert!(result.success);
assert!(!result.stdout.trim().is_empty());
}
#[tokio::test]
async fn test_execute_invalid_command_returns_error() {
let executor = create_executor();
let ctx = create_context();
use tokio::process::Command;
let cmd = Command::new("this_command_definitely_does_not_exist_12345");
let result = executor.execute(cmd, &ctx, &None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_execute_passes_custom_env_var() {
let executor = create_executor();
let mut ctx = create_context();
ctx.env_vars
.insert("MY_CUSTOM_VAR".to_string(), "my_value_123".to_string());
use tokio::process::Command;
let cmd = Command::new("env");
let result = executor.execute(cmd, &ctx, &None).await.unwrap();
assert!(result.stdout.contains("MY_CUSTOM_VAR=my_value_123"));
}
#[tokio::test]
async fn test_execute_sets_ws_taskid() {
let executor = create_executor();
let mut ctx = create_context();
ctx.task_id = "task-abc-123".to_string();
ctx.pipeline_name = "pipeline-xyz".to_string();
use tokio::process::Command;
let cmd = Command::new("env");
let result = executor.execute(cmd, &ctx, &None).await.unwrap();
assert!(result.stdout.contains("HBW_TASKID=task-abc-123"));
assert!(result.stdout.contains("HBW_PIPELINE=pipeline-xyz"));
}
#[tokio::test]
async fn test_execute_sets_ws_user_info() {
let executor = create_executor();
let ctx = create_context();
use tokio::process::Command;
let cmd = Command::new("env");
let result = executor.execute(cmd, &ctx, &None).await.unwrap();
assert!(result.stdout.contains("HBW_USERNAME=testuser"));
}
#[tokio::test]
async fn test_execute_respects_working_directory() {
let executor = create_executor();
let ctx = ExecutionContext {
task_id: "test-context-1".to_string(),
pipeline_name: "test-pipeline".to_string(),
username: "testuser".to_string(),
working_dir: PathBuf::from("/tmp"),
env_vars: HashMap::new(),
timeout: Some(Duration::from_secs(30)),
shell: "bash".to_string(),
standalone: false,
..Default::default()
};
use tokio::process::Command;
let cmd = Command::new("pwd");
let result = executor.execute(cmd, &ctx, &None).await.unwrap();
assert!(result.success);
let trimmed = result.stdout.trim();
assert!(trimmed == "/tmp" || trimmed.ends_with("tmp"));
}
#[tokio::test]
async fn test_execute_captures_stderr() {
let executor = create_executor();
let ctx = create_context();
use tokio::process::Command;
let mut cmd = Command::new("bash");
cmd.arg("-c");
cmd.arg("echo error >&2");
let result = executor.execute(cmd, &ctx, &None).await.unwrap();
assert!(result.success);
assert!(result.stderr.contains("error"));
}
/// Test: with `process_group(0)`, kill() propagates to the entire process tree.
///
/// We spawn a bash that creates a background sleep. With process_group(0),
/// bash is the process group leader. When we kill(-PGID), the sleep should die too.
///
/// This test uses tokio::process::Command + process_group(0) directly
/// (bypassing Engine) to verify the kernel-level behavior.
#[tokio::test]
async fn test_process_group_kill_kills_subprocesses() {
use std::os::unix::process::CommandExt;
use tokio::io::AsyncBufReadExt;
use tokio::process::Command;
use std::process::Stdio;
let mut cmd = Command::new("bash");
cmd.args(["-c", "sleep 999 & echo $!"]);
cmd.process_group(0);
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn failed");
let bash_pid = child.id().expect("no pid");
eprintln!("[test] bash PID: {}", bash_pid);
// Read the background sleep's PID from stdout
let stdout = child.stdout.take().expect("stdout pipe");
let mut reader = tokio::io::BufReader::new(stdout);
let mut pid_line = String::new();
reader.read_line(&mut pid_line).await.expect("read pid line");
let sleep_pid: u32 = pid_line.trim().parse().expect("valid sleep PID");
eprintln!("[test] sleep PID: {}", sleep_pid);
// Verify sleep is alive before kill
let alive_before = std::path::Path::new(&format!("/proc/{}", sleep_pid)).exists();
eprintln!("[test] sleep alive before kill: {}", alive_before);
// Kill by sending SIGKILL to the NEGATIVE PGID (entire process group)
unsafe {
libc::kill(-(bash_pid as i32), libc::SIGKILL);
}
child.wait().await.expect("wait failed");
eprintln!("[test] kill+wait done");
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
// Check if sleep survived — it SHOULD be dead now
let alive_after = std::path::Path::new(&format!("/proc/{}", sleep_pid)).exists();
eprintln!("[test] sleep alive after kill: {}", alive_after);
// Cleanup just in case
let _ = std::process::Command::new("kill")
.arg("-9")
.arg(sleep_pid.to_string())
.spawn();
assert!(alive_before, "Test setup: sleep was never created");
assert!(
!alive_after,
"sleep survived process-group kill! process_group(0) fix did not work."
);
}