fangorn/huorn-minecraft
public
ref:main
//! End-to-end Docker backend tests.
//!
//! These tests create REAL Docker containers, write commands,
//! read output, resize, and verify clean destruction.
//! Requires Docker to be running on the host.
//!
//! Tests are marked #[ignore] by default — run with:
//! cargo test --test docker_e2e_test -- --ignored
use huorn_minecraft::backend::{BackendConfig, BackendRegistry, TerminalSession};
use huorn_minecraft::terminal::TerminalState;
use std::io::ErrorKind;
fn docker_config() -> BackendConfig {
BackendConfig {
cols: 80,
rows: 24,
font_size: 14.0,
shell: "/bin/bash".to_string(),
working_dir: "/".to_string(),
image: Some("ubuntu:24.04".to_string()),
memory_limit: Some("128m".to_string()),
cpu_limit: Some(0.5),
network_enabled: Some(false),
}
}
fn skip_if_no_docker() -> bool {
let reg = BackendRegistry::new();
let docker = reg.get("docker").unwrap();
!docker.is_available().unwrap_or(false)
}
// ==================== Docker container lifecycle ====================
#[test]
#[ignore]
fn docker_spawn_creates_running_container() {
if skip_if_no_docker() {
eprintln!("SKIP: Docker not available");
return;
}
let reg = BackendRegistry::new();
let docker = reg.get("docker").unwrap();
let session = docker.spawn(&docker_config()).expect("docker spawn failed");
assert!(session.is_alive(), "docker session should be alive");
// Drop cleans up the container
}
#[test]
#[ignore]
fn docker_write_and_read_real_output() {
if skip_if_no_docker() {
return;
}
let reg = BackendRegistry::new();
let docker = reg.get("docker").unwrap();
let mut session = docker.spawn(&docker_config()).expect("spawn failed");
// Write a command that produces known output
session
.write(b"echo DOCKER_E2E_MARKER_42\n")
.expect("write failed");
// Read output — may need multiple attempts
std::thread::sleep(std::time::Duration::from_millis(500));
let mut buf = [0u8; 4096];
let mut total_output = Vec::new();
for _ in 0..10 {
match session.read(&mut buf) {
Ok(0) => break,
Ok(n) => total_output.extend_from_slice(&buf[..n]),
Err(e) if e.kind() == ErrorKind::WouldBlock => {
std::thread::sleep(std::time::Duration::from_millis(100));
continue;
}
Err(e) => panic!("read error: {}", e),
}
}
let output = String::from_utf8_lossy(&total_output);
assert!(
output.contains("DOCKER_E2E_MARKER_42"),
"expected marker in docker output, got: {}",
output
);
}
#[test]
#[ignore]
fn docker_container_is_isolated() {
if skip_if_no_docker() {
return;
}
let reg = BackendRegistry::new();
let docker = reg.get("docker").unwrap();
let mut session = docker.spawn(&docker_config()).expect("spawn failed");
// Verify we're inside a container, not on the host
session
.write(b"cat /proc/1/cgroup 2>/dev/null || echo IN_CONTAINER\n")
.expect("write failed");
session
.write(b"hostname\n")
.expect("write hostname failed");
std::thread::sleep(std::time::Duration::from_millis(500));
let mut buf = [0u8; 4096];
let mut total_output = Vec::new();
for _ in 0..10 {
match session.read(&mut buf) {
Ok(0) => break,
Ok(n) => total_output.extend_from_slice(&buf[..n]),
Err(e) if e.kind() == ErrorKind::WouldBlock => {
std::thread::sleep(std::time::Duration::from_millis(100));
continue;
}
Err(_) => break,
}
}
let output = String::from_utf8_lossy(&total_output);
// Hostname in a Docker container is the short container ID (12 hex chars)
// It should NOT be the host's hostname
assert!(
!output.is_empty(),
"should have received output from container"
);
}
#[test]
#[ignore]
fn docker_resize() {
if skip_if_no_docker() {
return;
}
let reg = BackendRegistry::new();
let docker = reg.get("docker").unwrap();
let mut session = docker.spawn(&docker_config()).expect("spawn failed");
// Resize should not crash
session.resize(120, 40).expect("resize to 120x40 failed");
assert!(session.is_alive(), "session should survive resize");
// Write after resize should work
session
.write(b"echo AFTER_DOCKER_RESIZE\n")
.expect("write after resize failed");
std::thread::sleep(std::time::Duration::from_millis(300));
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(_) => break,
}
}
let text = String::from_utf8_lossy(&output);
assert!(
text.contains("AFTER_DOCKER_RESIZE"),
"I/O should work after resize: {}",
text
);
}
#[test]
#[ignore]
fn docker_kill_and_cleanup() {
if skip_if_no_docker() {
return;
}
let reg = BackendRegistry::new();
let docker = reg.get("docker").unwrap();
let mut session = docker.spawn(&docker_config()).expect("spawn failed");
assert!(session.is_alive());
session.kill().expect("kill failed");
assert!(!session.is_alive(), "session should be dead after kill");
// Container is cleaned up on drop — verify no panic
drop(session);
}
#[test]
#[ignore]
fn docker_exit_command() {
if skip_if_no_docker() {
return;
}
let reg = BackendRegistry::new();
let docker = reg.get("docker").unwrap();
let mut session = docker.spawn(&docker_config()).expect("spawn failed");
session.write(b"exit\n").expect("write exit failed");
// Poll until EOF
let mut buf = [0u8; 1024];
for _ in 0..20 {
std::thread::sleep(std::time::Duration::from_millis(100));
match session.read(&mut buf) {
Ok(0) => break,
Ok(_) => continue,
Err(e) if e.kind() == ErrorKind::WouldBlock => continue,
Err(_) => break,
}
}
assert!(
!session.is_alive(),
"session should be dead after 'exit'"
);
}
#[test]
#[ignore]
fn docker_multiple_concurrent_containers() {
if skip_if_no_docker() {
return;
}
let reg = BackendRegistry::new();
let docker = reg.get("docker").unwrap();
// Spawn 3 containers simultaneously
let mut sessions: Vec<Box<dyn TerminalSession>> = Vec::new();
for i in 0..3 {
let mut session = docker
.spawn(&docker_config())
.unwrap_or_else(|e| panic!("container {} failed: {}", i, e));
session
.write(format!("echo CONTAINER_{}\n", i).as_bytes())
.unwrap();
sessions.push(session);
}
std::thread::sleep(std::time::Duration::from_millis(500));
// Each container should have its own output
for (i, session) in sessions.iter_mut().enumerate() {
assert!(session.is_alive(), "container {} 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(_) => break,
}
}
let text = String::from_utf8_lossy(&output);
assert!(
text.contains(&format!("CONTAINER_{}", i)),
"container {} missing marker: {}",
i,
text
);
}
// Kill all — all containers should be cleaned up
for (i, session) in sessions.iter_mut().enumerate() {
session
.kill()
.unwrap_or_else(|e| panic!("kill container {} failed: {}", i, e));
}
}
// ==================== Full terminal pipeline through Docker ====================
#[test]
#[ignore]
fn docker_terminal_state_full_pipeline() {
if skip_if_no_docker() {
return;
}
// This is THE test: TerminalState (VTE + renderer + glyph cache)
// backed by a Docker container instead of a local PTY.
let mut term = TerminalState::new(80, 24, 14.0, "/bin/bash", "/", "docker")
.expect("TerminalState with docker backend failed");
assert!(term.is_alive(), "docker terminal should be alive");
// Send a command
term.send_text("echo FULL_PIPELINE_DOCKER_TEST\n");
// Poll and render
for _ in 0..20 {
term.poll_pty();
std::thread::sleep(std::time::Duration::from_millis(100));
}
term.render();
// Verify text appears in terminal grid
let content = term.get_content();
assert!(
content.contains("FULL_PIPELINE_DOCKER_TEST"),
"docker terminal content should contain our marker, got: {}",
&content[..content.len().min(500)]
);
// Verify pixel buffer is non-trivial
let pixels = term.pixel_buffer();
assert!(!pixels.is_empty(), "pixel buffer should not be empty");
let dims = term.dimensions();
assert!(dims[0] > 0 && dims[1] > 0, "dimensions should be positive");
// Resize
term.resize(120, 40);
let new_dims = term.dimensions();
assert!(
new_dims[0] > dims[0],
"width should increase after resize"
);
// Send another command after resize
term.send_text("echo AFTER_RESIZE_IN_DOCKER\n");
for _ in 0..10 {
term.poll_pty();
std::thread::sleep(std::time::Duration::from_millis(100));
}
let content_after = term.get_content();
assert!(
content_after.contains("AFTER_RESIZE_IN_DOCKER"),
"should see output after resize in docker"
);
// Clean destroy — container should be removed
drop(term);
}
#[test]
#[ignore]
fn docker_resource_limits_enforced() {
if skip_if_no_docker() {
return;
}
let reg = BackendRegistry::new();
let docker = reg.get("docker").unwrap();
let mut config = docker_config();
config.memory_limit = Some("64m".to_string());
config.cpu_limit = Some(0.25);
let mut session = docker.spawn(&config).expect("spawn with limits failed");
// Verify limits are applied by checking cgroup
session
.write(b"cat /sys/fs/cgroup/memory.max 2>/dev/null || cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null || echo LIMITS_CHECK\n")
.expect("write failed");
std::thread::sleep(std::time::Duration::from_millis(500));
let mut buf = [0u8; 4096];
let mut output = Vec::new();
for _ in 0..5 {
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(_) => break,
}
}
let text = String::from_utf8_lossy(&output);
// 64MB = 67108864 bytes
assert!(
text.contains("67108864") || text.contains("LIMITS_CHECK"),
"should see memory limit or at least not crash: {}",
text
);
}
#[test]
#[ignore]
fn docker_no_containers_leaked_after_test() {
// This test runs AFTER other docker tests. It verifies that no
// huorn containers are left running.
if skip_if_no_docker() {
return;
}
// List containers with our image
let output = std::process::Command::new("docker")
.args(["ps", "-q", "--filter", "ancestor=ubuntu:24.04"])
.output()
.expect("failed to run docker ps");
let running = String::from_utf8_lossy(&output.stdout);
// We can't assert zero here (other tests may be running),
// but we can log for manual inspection
if !running.trim().is_empty() {
eprintln!(
"WARNING: {} ubuntu:24.04 containers still running",
running.trim().lines().count()
);
}
}