mod common;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use futures_util::StreamExt;
use heyo_sdk::{
Sandbox, SandboxCreateOptions, SandboxSize, ShellEvent, ShellOptions, ShellReconnectOptions,
};
use tokio::time::{sleep, timeout};
#[tokio::test]
#[ignore]
async fn shell_protocol_round_trip() {
common::load_dotenv();
let Some(opts) = common::client_options() else {
eprintln!("[shell] skipping — HEYO_API_KEY not set");
return;
};
let image =
std::env::var("HEYO_SHELL_IMAGE").unwrap_or_else(|_| "ubuntu:24.04".to_string());
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
println!("[shell] creating sandbox ({})…", image);
let sandbox = Sandbox::create(
SandboxCreateOptions {
name: Some(format!("sdk-rs-shell-{}", stamp)),
image: Some(image),
size_class: Some(SandboxSize::Micro),
ttl_seconds: Some(600),
..Default::default()
},
opts,
)
.await
.expect("create");
let result = run_shell_exercise(&sandbox).await;
println!("[shell] killing sandbox…");
let _ = sandbox.kill().await;
result.expect("shell exercise");
}
async fn run_shell_exercise(sandbox: &Sandbox) -> Result<(), String> {
println!("[shell] opening session…");
let shell = sandbox
.shell(ShellOptions {
cols: 100,
rows: 30,
env: Some({
let mut m = std::collections::HashMap::new();
m.insert("HEYO_TEST".into(), "yes".into());
m
}),
cwd: Some("/".into()),
reconnect: Some(ShellReconnectOptions {
max_retries: 5,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(2),
}),
})
.await
.map_err(|e| format!("open: {}", e))?;
let session_id = shell
.session_id()
.await
.ok_or_else(|| "session_id should be set after open()".to_string())?;
println!("[shell] ready (session_id={})", session_id);
use std::sync::{Arc, Mutex};
let buffer: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
let collector_buf = buffer.clone();
let mut output = shell.output();
let collector = tokio::spawn(async move {
while let Some(chunk) = output.next().await {
if let Ok(text) = std::str::from_utf8(&chunk) {
let cleaned = strip_ansi(text);
let mut b = collector_buf.lock().unwrap();
b.push_str(&cleaned);
}
}
});
let mut events = shell.events();
let events_task = tokio::spawn(async move {
while let Some(e) = events.next().await {
match &e {
ShellEvent::Reconnecting { attempt, delay } => {
println!("[shell] ↻ reconnecting attempt={} delay={:?}", attempt, delay);
}
ShellEvent::Reconnected => {
println!("[shell] ↻ reconnected");
}
ShellEvent::Closed { exit_code } => {
println!("[shell] ✗ closed (exit={:?})", exit_code);
break;
}
ShellEvent::Error(msg) => {
println!("[shell] ⚠ error: {}", msg);
}
}
}
});
sleep(Duration::from_millis(500)).await;
drain(&buffer);
println!("[shell] step: state preservation (cd /tmp + pwd)");
write_line(&shell, "cd /tmp").await?;
sleep(Duration::from_millis(400)).await;
drain(&buffer);
write_line(&shell, "pwd").await?;
sleep(Duration::from_millis(800)).await;
let pwd = drain(&buffer);
if !pwd.contains("/tmp") {
return Err(format!("pwd after cd should include /tmp; got: {:?}", pwd));
}
println!("[shell] ✓ cwd persisted");
println!("[shell] step: env preservation");
write_line(&shell, "export FOO=bar123").await?;
sleep(Duration::from_millis(300)).await;
drain(&buffer);
write_line(&shell, "echo FOO=$FOO").await?;
sleep(Duration::from_millis(800)).await;
let foo = drain(&buffer);
if !foo.contains("FOO=bar123") {
return Err(format!("env var should persist; got: {:?}", foo));
}
println!("[shell] ✓ env persisted");
write_line(&shell, "echo INIT=$HEYO_TEST").await?;
sleep(Duration::from_millis(800)).await;
let init = drain(&buffer);
if !init.contains("INIT=yes") {
return Err(format!("init env should be honored; got: {:?}", init));
}
println!("[shell] ✓ init env honored");
println!("[shell] step: resize(120, 40)");
shell.resize(120, 40).await.map_err(|e| e.to_string())?;
sleep(Duration::from_millis(300)).await;
drain(&buffer);
write_line(&shell, "tput cols").await?;
sleep(Duration::from_millis(800)).await;
let cols = drain(&buffer);
println!("[shell] ℹ tput cols after resize: {:?}", cols.trim());
println!("[shell] step: graceful close");
shell.close().await.map_err(|e| e.to_string())?;
let _ = timeout(Duration::from_secs(5), async {
while !shell.is_closed() {
sleep(Duration::from_millis(50)).await;
}
})
.await;
if !shell.is_closed() {
return Err("session did not close within 5s".into());
}
println!(
"[shell] ✓ closed (exit={:?})",
shell.exit_code().await
);
collector.abort();
events_task.abort();
let _ = collector.await;
let _ = events_task.await;
Ok(())
}
async fn write_line(shell: &heyo_sdk::ShellSession, cmd: &str) -> Result<(), String> {
let mut bytes = cmd.as_bytes().to_vec();
bytes.push(b'\n');
shell.write(&bytes).await.map_err(|e| e.to_string())
}
fn drain(buf: &std::sync::Arc<std::sync::Mutex<String>>) -> String {
let mut g = buf.lock().unwrap();
let s = std::mem::take(&mut *g);
s
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if let Some(&next) = chars.peek() {
if next == '[' {
chars.next();
while let Some(d) = chars.next() {
if d.is_ascii_alphabetic() {
break;
}
}
continue;
} else if next == ']' {
chars.next();
while let Some(d) = chars.next() {
if d == 0x07 as char {
break;
}
}
continue;
}
}
continue;
}
out.push(c);
}
out
}