diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1a5357..e9749e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 4a35ad5..66c65ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 4195826..9f52131 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = [] diff --git a/scripts/commit-check.sh b/scripts/commit-check.sh new file mode 100755 index 0000000..0c4c608 --- /dev/null +++ b/scripts/commit-check.sh @@ -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 diff --git a/src/instance.rs b/src/instance.rs index eae80bd..e160c3a 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -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 { let mut current = String::new(); let mut best: Option = 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 { diff --git a/src/vm_manager.rs b/src/vm_manager.rs index 9c9c14d..91ba4ff 100644 --- a/src/vm_manager.rs +++ b/src/vm_manager.rs @@ -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 { } } +#[cfg_attr(feature = "mock-vm", allow(dead_code))] fn spawn_manager_io( config: Arc>, instance_dir: PathBuf, @@ -555,6 +576,7 @@ trait VmExecutor { ) -> Result<(), Box>; } +#[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, + _extra_shares: Vec, + _config: Arc>, + _instance_dir: PathBuf, + vm_input_tx: Arc>>>, + ) -> Result<(), Box> { + let (tx, rx) = mpsc::channel::(); + *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, diff --git a/tests/e2e_cli.rs b/tests/e2e_cli.rs new file mode 100644 index 0000000..f88fc5b --- /dev/null +++ b/tests/e2e_cli.rs @@ -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); + } +} diff --git a/tests/e2e_vm.rs b/tests/e2e_vm.rs new file mode 100644 index 0000000..1673b7c --- /dev/null +++ b/tests/e2e_vm.rs @@ -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, +} + +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::(&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, + } +} diff --git a/tests/e2e_vm_mock.rs b/tests/e2e_vm_mock.rs new file mode 100644 index 0000000..fde324c --- /dev/null +++ b/tests/e2e_vm_mock.rs @@ -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, + 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 { + 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 { + 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, 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 + } +} diff --git a/tests/explain_integration.rs b/tests/explain_integration.rs new file mode 100644 index 0000000..d380b47 --- /dev/null +++ b/tests/explain_integration.rs @@ -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, +} + +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: :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"); +}