ref:main
//! Real execution tests for backend lifecycle.
//!
//! These tests spawn actual shell processes via PlainShellBackend,
//! verify I/O works, and confirm clean destruction.
use huorn_minecraft::backend::{BackendConfig, BackendRegistry, TerminalSession};
use std::io::ErrorKind;
fn plain_config() -> BackendConfig {
BackendConfig {
cols: 80,
rows: 24,
font_size: 14.0,
shell: "/bin/bash".to_string(),
working_dir: "/tmp".to_string(),
image: None,
memory_limit: None,
cpu_limit: None,
network_enabled: None,
}
}
// ==================== BackendRegistry ====================
#[test]
fn registry_contains_plain_and_docker() {
let reg = BackendRegistry::new();
assert!(reg.get("plain").is_some(), "plain backend missing");
assert!(reg.get("docker").is_some(), "docker backend missing");
assert!(reg.get("nonexistent").is_none());
}
#[test]
fn registry_plain_is_available() {
let reg = BackendRegistry::new();
let plain = reg.get("plain").unwrap();
assert!(plain.is_available().unwrap(), "plain should be available");
assert_eq!(plain.name(), "plain");
}
#[test]
fn registry_available_backends_includes_plain() {
let reg = BackendRegistry::new();
let available = reg.available_backends();
assert!(available.contains(&"plain"), "plain should be in available list");
}
// ==================== PlainShellBackend: spawn + is_alive ====================
#[test]
fn plain_spawn_creates_alive_session() {
let reg = BackendRegistry::new();
let plain = reg.get("plain").unwrap();
let session = plain.spawn(&plain_config()).expect("spawn failed");
assert!(session.is_alive(), "session should be alive after spawn");
}
#[test]
fn plain_spawn_with_explicit_shell() {
let reg = BackendRegistry::new();
let plain = reg.get("plain").unwrap();
let mut config = plain_config();
config.shell = "/bin/sh".to_string();
let session = plain.spawn(&config).expect("spawn with /bin/sh failed");
assert!(session.is_alive());
}
// ==================== PlainShellBackend: write + read ====================
#[test]
fn plain_write_and_read_real_output() {
let reg = BackendRegistry::new();
let plain = reg.get("plain").unwrap();
let mut session = plain.spawn(&plain_config()).unwrap();
// Write a command that produces known output
session.write(b"echo HUORN_TEST_OUTPUT\n").unwrap();
// Give the shell time to process
std::thread::sleep(std::time::Duration::from_millis(200));
// Read output — should contain our marker
let mut buf = [0u8; 4096];
let mut total_output = Vec::new();
loop {
match session.read(&mut buf) {
Ok(0) => break,
Ok(n) => total_output.extend_from_slice(&buf[..n]),
Err(e) if e.kind() == ErrorKind::WouldBlock => break,
Err(e) => panic!("read error: {}", e),
}
}
let output = String::from_utf8_lossy(&total_output);
assert!(
output.contains("HUORN_TEST_OUTPUT"),
"expected 'HUORN_TEST_OUTPUT' in output, got: {}",
output
);
}
#[test]
fn plain_multiple_writes_and_reads() {
let reg = BackendRegistry::new();
let plain = reg.get("plain").unwrap();
let mut session = plain.spawn(&plain_config()).unwrap();
// Send multiple commands
session.write(b"echo FIRST\n").unwrap();
session.write(b"echo SECOND\n").unwrap();
std::thread::sleep(std::time::Duration::from_millis(300));
let mut buf = [0u8; 8192];
let mut total_output = Vec::new();
loop {
match session.read(&mut buf) {
Ok(0) => break,
Ok(n) => total_output.extend_from_slice(&buf[..n]),
Err(e) if e.kind() == ErrorKind::WouldBlock => break,
Err(e) => panic!("read error: {}", e),
}
}
let output = String::from_utf8_lossy(&total_output);
assert!(output.contains("FIRST"), "missing FIRST in: {}", output);
assert!(output.contains("SECOND"), "missing SECOND in: {}", output);
}
// ==================== PlainShellBackend: resize ====================
#[test]
fn plain_resize_does_not_crash() {
let reg = BackendRegistry::new();
let plain = reg.get("plain").unwrap();
let mut session = plain.spawn(&plain_config()).unwrap();
assert!(session.is_alive());
// Resize to various dimensions
session.resize(120, 40).expect("resize to 120x40 failed");
assert!(session.is_alive(), "session died after resize");
session.resize(40, 12).expect("resize to 40x12 failed");
assert!(session.is_alive(), "session died after second resize");
// Resize to 1x1 (edge case)
session.resize(1, 1).expect("resize to 1x1 failed");
assert!(session.is_alive(), "session died after 1x1 resize");
}
#[test]
fn plain_resize_then_io_still_works() {
let reg = BackendRegistry::new();
let plain = reg.get("plain").unwrap();
let mut session = plain.spawn(&plain_config()).unwrap();
session.resize(40, 12).unwrap();
session.write(b"echo AFTER_RESIZE\n").unwrap();
std::thread::sleep(std::time::Duration::from_millis(200));
let mut buf = [0u8; 4096];
let mut total_output = Vec::new();
loop {
match session.read(&mut buf) {
Ok(0) => break,
Ok(n) => total_output.extend_from_slice(&buf[..n]),
Err(e) if e.kind() == ErrorKind::WouldBlock => break,
Err(e) => panic!("read error: {}", e),
}
}
let output = String::from_utf8_lossy(&total_output);
assert!(
output.contains("AFTER_RESIZE"),
"I/O should work after resize: {}",
output
);
}
// ==================== PlainShellBackend: kill + destroy ====================
#[test]
fn plain_kill_marks_session_dead() {
let reg = BackendRegistry::new();
let plain = reg.get("plain").unwrap();
let mut session = plain.spawn(&plain_config()).unwrap();
assert!(session.is_alive());
session.kill().expect("kill failed");
assert!(!session.is_alive(), "session should be dead after kill");
}
#[test]
fn plain_kill_then_write_fails_gracefully() {
let reg = BackendRegistry::new();
let plain = reg.get("plain").unwrap();
let mut session = plain.spawn(&plain_config()).unwrap();
session.kill().unwrap();
// Writing to a killed session — should error or at least not panic
let result = session.write(b"echo should_fail\n");
// We accept either an error or a successful write to a dead pipe
// The important thing is no panic
let _ = result;
}
#[test]
fn plain_drop_cleans_up_child_process() {
let reg = BackendRegistry::new();
let plain = reg.get("plain").unwrap();
// Spawn and immediately drop — child process should be cleaned up
{
let _session = plain.spawn(&plain_config()).unwrap();
// session drops here
}
// If the child process leaked, it would show up as a zombie
// We can't easily check for zombies in a test, but at least verify
// no panic occurs during drop
}
#[test]
fn plain_exit_command_kills_session() {
let reg = BackendRegistry::new();
let plain = reg.get("plain").unwrap();
let mut session = plain.spawn(&plain_config()).unwrap();
assert!(session.is_alive());
// Tell the shell to exit
session.write(b"exit\n").unwrap();
// Poll repeatedly — EOF detection may take multiple read cycles
let mut buf = [0u8; 1024];
let mut dead = false;
for _ in 0..20 {
std::thread::sleep(std::time::Duration::from_millis(100));
match session.read(&mut buf) {
Ok(0) => {
dead = true;
break;
}
Ok(_) => continue,
Err(e) if e.kind() == ErrorKind::WouldBlock => continue,
Err(_) => {
dead = true;
break;
}
}
}
assert!(
dead || !session.is_alive(),
"session should be dead after 'exit' command (waited 2s)"
);
}
// ==================== PlainShellBackend: concurrent sessions ====================
#[test]
fn plain_multiple_concurrent_sessions() {
let reg = BackendRegistry::new();
let plain = reg.get("plain").unwrap();
let mut sessions: Vec<Box<dyn TerminalSession>> = Vec::new();
for i in 0..4 {
let mut config = plain_config();
config.working_dir = format!("/tmp");
let mut session = plain
.spawn(&config)
.unwrap_or_else(|e| panic!("spawn {} failed: {}", i, e));
session
.write(format!("echo SESSION_{}\n", i).as_bytes())
.unwrap();
sessions.push(session);
}
std::thread::sleep(std::time::Duration::from_millis(300));
// Each session should be alive and have output
for (i, session) in sessions.iter_mut().enumerate() {
assert!(session.is_alive(), "session {} should be alive", i);
let mut buf = [0u8; 4096];
let mut output = Vec::new();
loop {
match session.read(&mut buf) {
Ok(0) => break,
Ok(n) => output.extend_from_slice(&buf[..n]),
Err(e) if e.kind() == ErrorKind::WouldBlock => break,
Err(e) => panic!("session {} read error: {}", i, e),
}
}
let text = String::from_utf8_lossy(&output);
assert!(
text.contains(&format!("SESSION_{}", i)),
"session {} missing marker in: {}",
i,
text
);
}
// Kill all
for (i, session) in sessions.iter_mut().enumerate() {
session
.kill()
.unwrap_or_else(|e| panic!("kill session {} failed: {}", i, e));
assert!(!session.is_alive(), "session {} should be dead", i);
}
}
// ==================== DockerBackend ====================
#[test]
fn docker_backend_name() {
let reg = BackendRegistry::new();
let docker = reg.get("docker").unwrap();
assert_eq!(docker.name(), "docker");
}
#[test]
fn docker_is_available_does_not_panic() {
let reg = BackendRegistry::new();
let docker = reg.get("docker").unwrap();
// Should return Ok(true) or Ok(false) depending on whether Docker is installed
let result = docker.is_available();
assert!(result.is_ok(), "is_available should not error");
}
#[test]
fn docker_spawn_succeeds_or_fails_based_on_availability() {
let reg = BackendRegistry::new();
let docker = reg.get("docker").unwrap();
let mut config = plain_config();
config.image = Some("ubuntu:24.04".to_string());
let result = docker.spawn(&config);
if docker.is_available().unwrap_or(false) {
// Docker is running — spawn should succeed
assert!(result.is_ok(), "docker spawn should succeed when available: {:?}", result.err());
let mut session = result.unwrap();
session.kill().unwrap();
} else {
// Docker not running — spawn should fail with a meaningful error
assert!(result.is_err(), "docker spawn should fail when unavailable");
}
}
// ==================== TerminalState through backend ====================
#[test]
fn terminal_state_with_plain_backend() {
use huorn_minecraft::terminal::TerminalState;
let mut term = TerminalState::new(80, 24, 14.0, "/bin/bash", "/tmp", "plain")
.expect("TerminalState::new with plain backend failed");
assert!(term.is_alive(), "terminal should be alive");
// Send a command
term.send_text("echo TERMINAL_STATE_TEST\n");
// Poll PTY a few times to process output
for _ in 0..10 {
term.poll_pty();
std::thread::sleep(std::time::Duration::from_millis(50));
}
// Render should produce pixels
term.render();
let pixels = term.pixel_buffer();
assert!(!pixels.is_empty(), "pixel buffer should not be empty");
let dims = term.dimensions();
assert!(dims[0] > 0, "pixel width should be positive");
assert!(dims[1] > 0, "pixel height should be positive");
}
#[test]
fn terminal_state_invalid_backend_fails() {
use huorn_minecraft::terminal::TerminalState;
let result = TerminalState::new(80, 24, 14.0, "", "", "nonexistent");
match result {
Ok(_) => panic!("invalid backend should fail"),
Err(err) => assert!(
err.contains("Unknown backend") || err.contains("unknown") || err.contains("not found"),
"error should mention unknown backend: {}",
err
),
}
}
#[test]
fn terminal_state_docker_backend_behavior() {
use huorn_minecraft::terminal::TerminalState;
let result = TerminalState::new(80, 24, 14.0, "/bin/bash", "/", "docker");
let reg = BackendRegistry::new();
let docker = reg.get("docker").unwrap();
if docker.is_available().unwrap_or(false) {
// Docker available — terminal should work
assert!(result.is_ok(), "docker terminal should work when Docker is available");
// Clean up
drop(result);
} else {
// Docker not available — should fail gracefully
assert!(result.is_err(), "docker terminal should fail when Docker is unavailable");
}
}