feat: added e2e (#5)

* feat: added e2e

* fix: try fix

* fix: try fix

* fix: try fix

* fix: added more observability

* ci: fixed double trigger

* feat: partial e2e test with mock vm

* feat: more monkey tests

* feat: added coverage
This commit is contained in:
Finn Sheng
2026-02-08 18:50:35 -05:00
committed by GitHub
parent eafa229542
commit f6678e7069
10 changed files with 1090 additions and 12 deletions

View File

@@ -10,7 +10,6 @@ on:
- "README.zh.md"
- "docs/**"
- "install"
pull_request:
concurrency:
group: ci-${{ github.ref }}
@@ -20,6 +19,8 @@ jobs:
fmt:
name: Format
runs-on: macos-latest
env:
RUST_BACKTRACE: "1"
steps:
- uses: actions/checkout@v6
- name: Install Rust
@@ -34,6 +35,8 @@ jobs:
clippy:
name: Clippy
runs-on: macos-latest
env:
RUST_BACKTRACE: "1"
steps:
- uses: actions/checkout@v6
- name: Install Rust
@@ -48,6 +51,8 @@ jobs:
build:
name: Build
runs-on: macos-latest
env:
RUST_BACKTRACE: "1"
steps:
- uses: actions/checkout@v6
- name: Install Rust
@@ -60,6 +65,8 @@ jobs:
test:
name: Test
runs-on: macos-latest
env:
RUST_BACKTRACE: "full"
steps:
- uses: actions/checkout@v6
- name: Install Rust
@@ -68,3 +75,24 @@ jobs:
uses: Swatinem/rust-cache@v2
- name: cargo test
run: cargo test --locked
coverage:
name: Coverage
runs-on: macos-latest
needs: [fmt, clippy, build, test]
env:
RUST_BACKTRACE: "full"
steps:
- uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- name: Cache Rust
uses: Swatinem/rust-cache@v2
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@v2
with:
tool: cargo-llvm-cov
- name: cargo llvm-cov
run: cargo llvm-cov --locked --features mock-vm --tests -- --nocapture

75
Cargo.lock generated
View File

@@ -82,6 +82,21 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "assert_cmd"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514"
dependencies = [
"anstyle",
"bstr",
"libc",
"predicates",
"predicates-core",
"predicates-tree",
"wait-timeout",
]
[[package]]
name = "backtrace"
version = "0.3.76"
@@ -112,6 +127,17 @@ dependencies = [
"objc2",
]
[[package]]
name = "bstr"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
dependencies = [
"memchr",
"regex-automata",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.19.1"
@@ -321,6 +347,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "difflib"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
[[package]]
name = "dispatch2"
version = "0.3.0"
@@ -737,6 +769,33 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "predicates"
version = "3.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
dependencies = [
"anstyle",
"difflib",
"predicates-core",
]
[[package]]
name = "predicates-core"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
[[package]]
name = "predicates-tree"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
dependencies = [
"predicates-core",
"termtree",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -1007,6 +1066,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "termtree"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
[[package]]
name = "thiserror"
version = "2.0.18"
@@ -1240,6 +1305,7 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
name = "vibebox"
version = "0.2.2"
dependencies = [
"assert_cmd",
"block2",
"clap",
"color-eyre",
@@ -1261,6 +1327,15 @@ dependencies = [
"uuid",
]
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"

View File

@@ -42,3 +42,10 @@ ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dialoguer = "0.12.0"
[dev-dependencies]
assert_cmd = "2"
tempfile = "3"
[features]
mock-vm = []

6
scripts/commit-check.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --all
cargo build --all-targets

View File

@@ -21,6 +21,7 @@ use crate::{
};
const SSH_KEY_NAME: &str = "ssh_key";
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
pub(crate) const VM_ROOT_LOG_NAME: &str = "vm_root.log";
pub(crate) const STATUS_FILE_NAME: &str = "status.txt";
pub(crate) const DEFAULT_SSH_USER: &str = "vibecoder";
@@ -217,6 +218,7 @@ fn generate_password() -> String {
Uuid::now_v7().simple().to_string()
}
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
let mut current = String::new();
let mut best: Option<String> = None;
@@ -383,6 +385,7 @@ fn run_ssh_session(
Ok(())
}
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
fn is_ipv4_candidate(candidate: &str) -> bool {
let parts: Vec<&str> = candidate.split('.').collect();
if parts.len() != 4 {

View File

@@ -103,17 +103,37 @@ pub fn run_manager(
let project_root = env::current_dir()?;
tracing::info!(root = %project_root.display(), "vm manager starting");
let _pid_guard = ensure_pid_file(&project_root)?;
run_manager_with(
&project_root,
args,
auto_shutdown_ms,
&RealVmExecutor,
ManagerOptions {
ensure_signed: true,
detach: true,
prepare_vm: true,
},
)
#[cfg(feature = "mock-vm")]
tracing::info!("vm manager using mock executor");
let executor: &dyn VmExecutor = {
#[cfg(feature = "mock-vm")]
{
&MockVmExecutor
}
#[cfg(not(feature = "mock-vm"))]
{
&RealVmExecutor
}
};
let options = {
#[cfg(feature = "mock-vm")]
{
ManagerOptions {
ensure_signed: false,
detach: true,
prepare_vm: false,
}
}
#[cfg(not(feature = "mock-vm"))]
{
ManagerOptions {
ensure_signed: true,
detach: true,
prepare_vm: true,
}
}
};
run_manager_with(&project_root, args, auto_shutdown_ms, executor, options)
}
fn spawn_manager_process(
@@ -468,6 +488,7 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
}
}
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
fn spawn_manager_io(
config: Arc<Mutex<InstanceConfig>>,
instance_dir: PathBuf,
@@ -555,6 +576,7 @@ trait VmExecutor {
) -> Result<(), Box<dyn std::error::Error>>;
}
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
struct RealVmExecutor;
impl VmExecutor for RealVmExecutor {
@@ -586,6 +608,39 @@ impl VmExecutor for RealVmExecutor {
}
}
#[cfg(feature = "mock-vm")]
struct MockVmExecutor;
#[cfg(feature = "mock-vm")]
impl VmExecutor for MockVmExecutor {
fn run_vm(
&self,
_args: vm::VmArg,
_extra_login_actions: Vec<LoginAction>,
_extra_shares: Vec<DirectoryShare>,
_config: Arc<Mutex<InstanceConfig>>,
_instance_dir: PathBuf,
vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
) -> Result<(), Box<dyn std::error::Error>> {
let (tx, rx) = mpsc::channel::<VmInput>();
*vm_input_tx.lock().unwrap() = Some(tx);
tracing::info!("mock vm executor running");
while let Ok(input) = rx.recv() {
match input {
VmInput::Shutdown => break,
VmInput::Bytes(bytes) => {
let text = String::from_utf8_lossy(&bytes);
if text.contains("systemctl poweroff") {
break;
}
}
}
}
tracing::info!("mock vm executor exiting");
Ok(())
}
}
fn run_manager_with(
project_root: &Path,
mut args: vm::VmArg,

58
tests/e2e_cli.rs Normal file
View File

@@ -0,0 +1,58 @@
use assert_cmd::cargo::cargo_bin_cmd;
use tempfile::TempDir;
#[test]
fn cli_version_shows_binary_name() {
let output = cargo_bin_cmd!("vibebox").arg("--version").output().unwrap();
print_output("e2e_cli", &output);
assert!(
output.status.success(),
"expected success, got status: {}",
output.status
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("vibebox"),
"expected --version output to contain 'vibebox', got: {}",
stdout
);
}
#[test]
fn list_reports_no_sessions_when_empty() {
let temp = TempDir::new().unwrap();
let home = temp.path().join("home");
let project = temp.path().join("project");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&project).unwrap();
let output = cargo_bin_cmd!("vibebox")
.current_dir(&project)
.env("HOME", &home)
.arg("list")
.output()
.unwrap();
print_output("e2e_cli", &output);
assert!(
output.status.success(),
"expected success, got status: {}",
output.status
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("No sessions were found."),
"expected empty sessions message, got: {}",
stdout
);
}
fn print_output(prefix: &str, output: &std::process::Output) {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
println!("[{}] {}", prefix, line);
}
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stderr.lines() {
eprintln!("[{}] {}", prefix, line);
}
}

278
tests/e2e_vm.rs Normal file
View File

@@ -0,0 +1,278 @@
use std::{
fs,
io::{BufRead, BufReader, Read},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
thread,
time::{Duration, Instant},
};
use tempfile::TempDir;
#[cfg(target_os = "macos")]
#[test]
#[ignore]
fn vm_boots_and_runs_command() {
if std::env::var("VIBEBOX_E2E_VM").as_deref() != Ok("1") {
eprintln!("skipping: set VIBEBOX_E2E_VM=1 to run this test");
return;
}
if !virtualization_available() {
eprintln!("[e2e_vm] skipping: virtualization not available on this hardware");
return;
}
let temp = TempDir::new().unwrap();
let home = temp.path().join("home");
let cache_home = temp.path().join("cache");
let project = temp.path().join("project");
fs::create_dir_all(&home).unwrap();
fs::create_dir_all(&cache_home).unwrap();
fs::create_dir_all(&project).unwrap();
write_config(&project);
let mut child = Command::new(assert_cmd::cargo_bin!("vibebox-supervisor"))
.current_dir(&project)
.env("HOME", &home)
.env("XDG_CACHE_HOME", &cache_home)
.env("VIBEBOX_INTERNAL", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
if let Some(stdout) = child.stdout.take() {
spawn_prefix_reader("e2e_vm", "stdout", stdout);
}
if let Some(stderr) = child.stderr.take() {
spawn_prefix_reader("e2e_vm", "stderr", stderr);
}
let _child_guard = ChildGuard::new(child);
log_line("e2e_vm", "waiting for vm manager socket");
let socket_path = project.join(".vibebox").join("vm.sock");
let _socket_guard = wait_for_socket(&socket_path, Duration::from_secs(30));
log_line("e2e_vm", "vm manager socket ready");
log_line("e2e_vm", "waiting for vm ipv4");
let instance_path = project.join(".vibebox").join("instance.toml");
let (ip, user) = wait_for_vm_ip(&instance_path, Duration::from_secs(180));
log_line("e2e_vm", &format!("vm ipv4={ip} user={user}"));
let ssh_key = project.join(".vibebox").join("ssh_key");
wait_for_file(&ssh_key, Duration::from_secs(30));
log_line("e2e_vm", "ssh key ready");
let output = wait_for_ssh_command(&ssh_key, &user, &ip, Duration::from_secs(90));
print_output("e2e_vm", &output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Linux"),
"expected ssh command output to contain 'Linux', got: {}",
stdout
);
}
#[cfg(not(target_os = "macos"))]
#[test]
#[ignore]
fn vm_boots_and_runs_command() {
eprintln!("skipping: vm e2e test requires macOS virtualization");
}
struct ChildGuard {
child: Option<Child>,
}
impl ChildGuard {
fn new(child: Child) -> Self {
Self { child: Some(child) }
}
}
impl Drop for ChildGuard {
fn drop(&mut self) {
if let Some(child) = &mut self.child {
let _ = child.kill();
let _ = child.wait();
}
}
}
struct SocketGuard {
_path: PathBuf,
_stream: std::os::unix::net::UnixStream,
}
fn write_config(project: &Path) {
let config = r#"[box]
cpu_count = 2
ram_mb = 2048
disk_gb = 5
mounts = []
[supervisor]
auto_shutdown_ms = 120000
"#;
fs::write(project.join("vibebox.toml"), config).unwrap();
}
fn wait_for_socket(path: &Path, timeout: Duration) -> SocketGuard {
let start = Instant::now();
loop {
if let Ok(stream) = std::os::unix::net::UnixStream::connect(path) {
return SocketGuard {
_path: path.to_path_buf(),
_stream: stream,
};
}
if start.elapsed() > timeout {
panic!(
"timed out waiting for vm manager socket at {}",
path.display()
);
}
thread::sleep(Duration::from_millis(200));
}
}
fn wait_for_vm_ip(instance_path: &Path, timeout: Duration) -> (String, String) {
let start = Instant::now();
loop {
if let Ok(raw) = fs::read_to_string(instance_path)
&& let Ok(value) = toml::from_str::<toml::Value>(&raw)
{
let ip = value
.get("vm_ipv4")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(ip) = ip {
let user = value
.get("ssh_user")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "vibecoder".to_string());
return (ip, user);
}
}
if start.elapsed() > timeout {
panic!(
"timed out waiting for vm_ipv4 in {}",
instance_path.display()
);
}
thread::sleep(Duration::from_millis(500));
}
}
fn wait_for_file(path: &Path, timeout: Duration) {
let start = Instant::now();
loop {
if path.exists() {
return;
}
if start.elapsed() > timeout {
panic!("timed out waiting for file {}", path.display());
}
thread::sleep(Duration::from_millis(200));
}
}
fn wait_for_ssh_command(
ssh_key: &Path,
user: &str,
ip: &str,
timeout: Duration,
) -> std::process::Output {
let start = Instant::now();
loop {
let output = Command::new("ssh")
.args([
"-i",
ssh_key.to_str().unwrap_or(".vibebox/ssh_key"),
"-o",
"IdentitiesOnly=yes",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"GlobalKnownHostsFile=/dev/null",
"-o",
"PasswordAuthentication=no",
"-o",
"BatchMode=yes",
"-o",
"LogLevel=ERROR",
"-o",
"ConnectTimeout=5",
&format!("{user}@{ip}"),
"uname -s",
])
.output()
.unwrap();
if output.status.success() {
return output;
}
if start.elapsed() > timeout {
let stderr = String::from_utf8_lossy(&output.stderr);
panic!("ssh command failed and timed out: {}", stderr);
}
thread::sleep(Duration::from_millis(1000));
}
}
fn spawn_prefix_reader(
label: &'static str,
stream: &'static str,
reader: impl Read + Send + 'static,
) {
thread::spawn(move || {
let buf = BufReader::new(reader);
for line in buf.lines() {
match line {
Ok(line) => {
println!("[{}][{}] {}", label, stream, line);
}
Err(err) => {
eprintln!("[{}][{}] read error: {}", label, stream, err);
break;
}
}
}
});
}
fn log_line(prefix: &str, message: &str) {
println!("[{}] {}", prefix, message);
}
fn print_output(prefix: &str, output: &std::process::Output) {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
println!("[{}] {}", prefix, line);
}
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stderr.lines() {
eprintln!("[{}] {}", prefix, line);
}
}
fn virtualization_available() -> bool {
let output = Command::new("sysctl")
.args(["-n", "kern.hv_support"])
.output();
match output {
Ok(output) if output.status.success() => {
let value = String::from_utf8_lossy(&output.stdout);
match value.trim() {
"1" => true,
"0" => false,
_ => true,
}
}
_ => true,
}
}

455
tests/e2e_vm_mock.rs Normal file
View File

@@ -0,0 +1,455 @@
#![cfg(all(feature = "mock-vm", target_os = "macos"))]
use std::{
fs,
io::{BufRead, BufReader, Read},
os::unix::net::UnixStream,
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::mpsc,
thread,
time::{Duration, Instant},
};
use tempfile::TempDir;
#[test]
fn mock_vm_allows_refcount_concurrency() {
let temp = TempDir::new().unwrap();
let mut supervisor = spawn_supervisor(&temp, 0, 1200, "e2e_vm_mock".to_string());
supervisor.clients = connect_clients(
&supervisor.socket_path,
12,
Duration::from_secs(2),
true,
"e2e_vm_mock",
);
log_line("e2e_vm_mock", "connected 12 clients");
assert_manager_alive_for(
&mut supervisor.child,
Duration::from_millis(900),
"vm manager exited while clients active",
);
let remaining = supervisor.clients.split_off(6);
supervisor.clients = remaining;
log_line("e2e_vm_mock", "dropped 6 clients");
assert_manager_alive_for(
&mut supervisor.child,
Duration::from_millis(900),
"vm manager exited while clients active",
);
supervisor.clients.clear();
log_line("e2e_vm_mock", "dropped remaining clients");
wait_for_exit(&mut supervisor.child, Duration::from_secs(10));
let status = supervisor.child.wait().unwrap();
assert!(status.success(), "vm manager exited with {status}");
}
#[test]
fn mock_vm_many_managers_many_clients() {
let temp = TempDir::new().unwrap();
let mut supervisors = Vec::new();
for idx in 0..3 {
supervisors.push(spawn_supervisor(
&temp,
idx + 1,
1400,
format!("e2e_vm_mock_{idx}"),
));
}
for supervisor in &mut supervisors {
supervisor.clients = connect_clients(
&supervisor.socket_path,
8,
Duration::from_secs(2),
true,
"e2e_vm_mock",
);
}
log_line("e2e_vm_mock", "connected 8 clients per manager");
for supervisor in &mut supervisors {
assert_manager_alive_for(
&mut supervisor.child,
Duration::from_millis(900),
"manager exited while clients active",
);
}
supervisors[0].clients.clear();
log_line("e2e_vm_mock", "dropped all clients for manager 0");
wait_for_exit(&mut supervisors[0].child, Duration::from_secs(10));
let status = supervisors[0].child.wait().unwrap();
assert!(status.success(), "manager 0 exited with {status}");
for supervisor in supervisors.iter_mut().skip(1) {
assert_manager_alive_for(
&mut supervisor.child,
Duration::from_millis(900),
"another manager exited early",
);
}
for supervisor in supervisors.iter_mut().skip(1) {
supervisor.clients.clear();
}
log_line("e2e_vm_mock", "dropped remaining clients");
for supervisor in supervisors.iter_mut().skip(1) {
wait_for_exit(&mut supervisor.child, Duration::from_secs(10));
let status = supervisor.child.wait().unwrap();
assert!(status.success(), "manager exited with {status}");
}
}
#[test]
fn mock_vm_monkey_processes() {
let temp = TempDir::new().unwrap();
let mut rng = Lcg::new(0x5eed_f00d_dead_beef);
let mut supervisors = Vec::new();
let mut next_id = 0usize;
let max_supervisors = 4usize;
let steps = 25usize;
supervisors.push(spawn_supervisor(
&temp,
next_id,
1200,
format!("e2e_vm_monkey_{next_id}"),
));
next_id += 1;
for step in 0..steps {
prune_exited_supervisors(&mut supervisors, "e2e_vm_monkey");
let roll = rng.gen_range(100);
log_line(
"e2e_vm_monkey",
&format!("step {step} roll={roll} supervisors={}", supervisors.len()),
);
if roll < 20 && supervisors.len() < max_supervisors {
supervisors.push(spawn_supervisor(
&temp,
next_id,
1200,
format!("e2e_vm_monkey_{next_id}"),
));
log_line("e2e_vm_monkey", &format!("spawned supervisor {next_id}"));
next_id += 1;
} else if roll < 45 && !supervisors.is_empty() {
let idx = rng.gen_range(supervisors.len());
let mut supervisor = supervisors.swap_remove(idx);
log_line(
"e2e_vm_monkey",
&format!("killing supervisor {}", supervisor.label),
);
kill_supervisor(&mut supervisor, Duration::from_secs(5));
} else if roll < 80 && !supervisors.is_empty() {
let idx = rng.gen_range(supervisors.len());
let burst = 1 + rng.gen_range(3);
let new_clients = connect_clients(
&supervisors[idx].socket_path,
burst,
Duration::from_secs(1),
false,
"e2e_vm_monkey",
);
supervisors[idx].clients.extend(new_clients);
log_line(
"e2e_vm_monkey",
&format!("connected {burst} clients to {}", supervisors[idx].label),
);
} else if !supervisors.is_empty() {
let idx = rng.gen_range(supervisors.len());
if !supervisors[idx].clients.is_empty() {
let len = supervisors[idx].clients.len();
let drop_count = 1 + rng.gen_range(len);
supervisors[idx].clients.drain(0..drop_count.min(len));
log_line(
"e2e_vm_monkey",
&format!(
"dropped {drop_count} clients from {}",
supervisors[idx].label
),
);
}
}
thread::sleep(Duration::from_millis(200));
}
log_line("e2e_vm_monkey", "final cleanup");
for supervisor in supervisors.iter_mut() {
shutdown_supervisor(supervisor, Duration::from_secs(10));
}
}
#[test]
fn mock_vm_exits_without_clients() {
let temp = TempDir::new().unwrap();
let mut supervisor = spawn_supervisor(&temp, 99, 300, "e2e_vm_no_clients".to_string());
wait_for_exit(&mut supervisor.child, Duration::from_secs(5));
let status = supervisor.child.wait().unwrap();
assert!(status.success(), "vm manager exited with {status}");
}
#[test]
fn mock_vm_reconnect_resets_shutdown() {
let temp = TempDir::new().unwrap();
let mut supervisor = spawn_supervisor(&temp, 100, 800, "e2e_vm_reconnect".to_string());
supervisor.clients = connect_clients(
&supervisor.socket_path,
1,
Duration::from_secs(2),
true,
"e2e_vm_reconnect",
);
supervisor.clients.clear();
thread::sleep(Duration::from_millis(400));
supervisor.clients = connect_clients(
&supervisor.socket_path,
1,
Duration::from_secs(2),
true,
"e2e_vm_reconnect",
);
assert_manager_alive_for(
&mut supervisor.child,
Duration::from_millis(600),
"vm manager exited despite reconnect",
);
supervisor.clients.clear();
wait_for_exit(&mut supervisor.child, Duration::from_secs(10));
let status = supervisor.child.wait().unwrap();
assert!(status.success(), "vm manager exited with {status}");
}
struct Supervisor {
child: Child,
socket_path: PathBuf,
clients: Vec<UnixStream>,
label: String,
}
fn write_config(project: &Path, auto_shutdown_ms: u64) {
let config = format!(
r#"[box]
cpu_count = 2
ram_mb = 2048
disk_gb = 5
mounts = []
[supervisor]
auto_shutdown_ms = {auto_shutdown_ms}
"#
);
fs::write(project.join("vibebox.toml"), config).unwrap();
}
fn spawn_supervisor(
temp: &TempDir,
idx: usize,
auto_shutdown_ms: u64,
label: String,
) -> Supervisor {
let home = temp.path().join(format!("home-{idx}"));
let cache_home = temp.path().join(format!("cache-{idx}"));
let project = temp.path().join(format!("project-{idx}"));
fs::create_dir_all(&home).unwrap();
fs::create_dir_all(&cache_home).unwrap();
fs::create_dir_all(&project).unwrap();
write_config(&project, auto_shutdown_ms);
let mut child = Command::new(assert_cmd::cargo_bin!("vibebox-supervisor"))
.current_dir(&project)
.env("HOME", &home)
.env("XDG_CACHE_HOME", &cache_home)
.env("VIBEBOX_INTERNAL", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
if let Some(stdout) = child.stdout.take() {
spawn_prefix_reader(label.clone(), "stdout", stdout);
}
if let Some(stderr) = child.stderr.take() {
spawn_prefix_reader(label.clone(), "stderr", stderr);
}
let socket_path = project.join(".vibebox").join("vm.sock");
wait_for_socket(&socket_path, Duration::from_secs(10));
Supervisor {
child,
socket_path,
clients: Vec::new(),
label,
}
}
fn connect_clients(
socket_path: &Path,
count: usize,
timeout: Duration,
require_all: bool,
label: &str,
) -> Vec<UnixStream> {
let (tx, rx) = mpsc::channel();
let mut handles = Vec::with_capacity(count);
for _ in 0..count {
let path = socket_path.to_path_buf();
let tx = tx.clone();
handles.push(thread::spawn(move || {
let stream = connect_client_with_retry(&path, timeout);
let _ = tx.send(stream);
}));
}
drop(tx);
let mut clients = Vec::with_capacity(count);
for stream in rx.into_iter().flatten() {
clients.push(stream);
}
for handle in handles {
handle.join().unwrap();
}
if require_all && clients.len() != count {
panic!(
"client count mismatch: expected {count} got {}",
clients.len()
);
}
if !require_all && clients.len() != count {
log_line(
label,
&format!("connected {} of {count} clients", clients.len()),
);
}
clients
}
fn connect_client_with_retry(path: &Path, timeout: Duration) -> Option<UnixStream> {
let start = Instant::now();
loop {
match UnixStream::connect(path) {
Ok(stream) => return Some(stream),
Err(_) => {
if start.elapsed() > timeout {
return None;
}
thread::sleep(Duration::from_millis(50));
}
}
}
}
fn wait_for_socket(path: &Path, timeout: Duration) {
let start = Instant::now();
loop {
if UnixStream::connect(path).is_ok() {
return;
}
if start.elapsed() > timeout {
panic!("timed out waiting for socket {}", path.display());
}
thread::sleep(Duration::from_millis(100));
}
}
fn assert_manager_alive(child: &mut Child, message: &str) {
assert!(child.try_wait().unwrap().is_none(), "{message}");
}
fn assert_manager_alive_for(child: &mut Child, duration: Duration, message: &str) {
let start = Instant::now();
while start.elapsed() < duration {
assert_manager_alive(child, message);
thread::sleep(Duration::from_millis(100));
}
}
fn kill_supervisor(supervisor: &mut Supervisor, timeout: Duration) {
supervisor.clients.clear();
let _ = supervisor.child.kill();
wait_for_exit(&mut supervisor.child, timeout);
let _ = supervisor.child.wait();
}
fn prune_exited_supervisors(supervisors: &mut Vec<Supervisor>, label: &str) {
supervisors.retain_mut(|supervisor| {
if supervisor.child.try_wait().unwrap().is_some() {
log_line(label, &format!("removed exited {}", supervisor.label));
false
} else {
true
}
});
}
fn shutdown_supervisor(supervisor: &mut Supervisor, timeout: Duration) {
supervisor.clients.clear();
wait_for_exit(&mut supervisor.child, timeout);
if supervisor.child.try_wait().unwrap().is_none() {
let _ = supervisor.child.kill();
let _ = supervisor.child.wait();
}
}
fn wait_for_exit(child: &mut Child, timeout: Duration) {
let start = Instant::now();
loop {
if child.try_wait().unwrap().is_some() {
return;
}
if start.elapsed() > timeout {
panic!("timed out waiting for mock vm supervisor exit");
}
thread::sleep(Duration::from_millis(200));
}
}
fn spawn_prefix_reader(label: String, stream: &'static str, reader: impl Read + Send + 'static) {
thread::spawn(move || {
let buf = BufReader::new(reader);
for line in buf.lines() {
match line {
Ok(line) => println!("[{}][{}] {}", label, stream, line),
Err(err) => {
eprintln!("[{}][{}] read error: {}", label, stream, err);
break;
}
}
}
});
}
fn log_line(prefix: &str, message: &str) {
println!("[{}] {}", prefix, message);
}
struct Lcg {
state: u64,
}
impl Lcg {
fn new(seed: u64) -> Self {
Self { state: seed }
}
fn next_u32(&mut self) -> u32 {
self.state = self.state.wrapping_mul(6364136223846793005).wrapping_add(1);
(self.state >> 32) as u32
}
fn gen_range(&mut self, upper: usize) -> usize {
if upper == 0 {
return 0;
}
(self.next_u32() as usize) % upper
}
}

View File

@@ -0,0 +1,113 @@
use std::{env, ffi::OsString, fs, path::Path, sync::Mutex};
use tempfile::TempDir;
use vibebox::session_manager::INSTANCE_DIR_NAME;
use vibebox::{config, explain};
static ENV_MUTEX: Mutex<()> = Mutex::new(());
struct EnvGuard {
key: &'static str,
previous: Option<OsString>,
}
impl EnvGuard {
fn set(key: &'static str, value: &Path) -> Self {
let previous = env::var_os(key);
unsafe {
env::set_var(key, value);
}
Self { key, previous }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.previous {
Some(value) => unsafe {
env::set_var(self.key, value);
},
None => unsafe {
env::remove_var(self.key);
},
}
}
}
#[test]
fn build_mount_rows_includes_defaults_and_custom_mounts() {
let _lock = ENV_MUTEX.lock().unwrap();
let temp = TempDir::new().unwrap();
let home = temp.path().join("home");
let project = home.join("project");
let cache_home = home.join("cache");
fs::create_dir_all(&project).unwrap();
fs::create_dir_all(&cache_home).unwrap();
let _home_guard = EnvGuard::set("HOME", &home);
let _cache_guard = EnvGuard::set("XDG_CACHE_HOME", &cache_home);
let box_cfg = config::BoxConfig {
mounts: vec!["data:~/data:read-only".to_string()],
..Default::default()
};
let cfg = config::Config {
box_cfg,
supervisor: config::SupervisorConfig::default(),
};
let rows = explain::build_mount_rows(&project, &cfg).unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].host, "~/project");
assert_eq!(rows[0].guest, "~/project");
assert_eq!(rows[0].mode, "read-write");
assert_eq!(rows[0].default_mount, "yes");
assert_eq!(rows[1].host, "~/cache/vibebox/.guest-mise-cache");
assert_eq!(rows[1].guest, "/root/.local/share/mise");
assert_eq!(rows[1].mode, "read-write");
assert_eq!(rows[1].default_mount, "yes");
assert_eq!(rows[2].host, "~/project/data");
assert_eq!(rows[2].guest, "~/data");
assert_eq!(rows[2].mode, "read-only");
assert_eq!(rows[2].default_mount, "no");
}
#[test]
fn build_network_rows_pending_without_instance_file() {
let temp = TempDir::new().unwrap();
let project = temp.path().join("project");
fs::create_dir_all(&project).unwrap();
let rows = explain::build_network_rows(&project).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].network_type, "NAT");
assert_eq!(rows[0].vm_ip, "-");
assert_eq!(rows[0].host_to_vm, "ssh: <pending>:22");
assert_eq!(rows[0].vm_to_host, "none");
}
#[test]
fn build_network_rows_uses_instance_vm_ip() {
let temp = TempDir::new().unwrap();
let project = temp.path().join("project");
let instance_dir = project.join(INSTANCE_DIR_NAME);
fs::create_dir_all(&instance_dir).unwrap();
fs::write(
instance_dir.join("instance.toml"),
"vm_ipv4 = \"10.1.2.3\"\n",
)
.unwrap();
let rows = explain::build_network_rows(&project).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].network_type, "NAT");
assert_eq!(rows[0].vm_ip, "10.1.2.3");
assert_eq!(rows[0].host_to_vm, "ssh: 10.1.2.3:22");
assert_eq!(rows[0].vm_to_host, "none");
}