use std::process::Command;
#[test]
fn prints_version() {
let output = Command::new(env!("CARGO_BIN_EXE_cellix"))
.arg("--version")
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
assert_eq!(
stdout.trim(),
format!("cellix {}", env!("CARGO_PKG_VERSION"))
);
}
fn git(dir: &std::path::Path, args: &[&str]) -> String {
let output = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap();
assert!(output.status.success(), "git {args:?}");
String::from_utf8(output.stdout).unwrap()
}
#[test]
fn the_prompt_hook_checkpoints_without_holding_the_prompt() {
use std::io::Write;
use std::time::{Duration, Instant};
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
git(root, &["init", "-q"]);
std::fs::write(root.join("kept.txt"), "kept\n").unwrap();
git(root, &["add", "kept.txt"]);
git(
root,
&[
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-qm",
"init",
],
);
std::fs::write(root.join("notes.txt"), "small\n").unwrap();
std::fs::write(root.join("dump.bin"), vec![b'x'; 12 * 1024 * 1024]).unwrap();
let input = format!(
r#"{{"prompt":"Fix the bug","cwd":{:?},"session_id":"s"}}"#,
root.to_str().unwrap()
);
let started = Instant::now();
let mut child = Command::new(env!("CARGO_BIN_EXE_cellix"))
.args(["hook", "prompt"])
.current_dir(root)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
assert!(child.wait().unwrap().success());
assert!(
started.elapsed() < Duration::from_secs(10),
"{:?}",
started.elapsed()
);
let deadline = Instant::now() + Duration::from_secs(30);
let turns = loop {
let turns = git(
root,
&[
"for-each-ref",
"--format=%(objectname)",
"refs/cellix/turns/",
],
);
if !turns.trim().is_empty() || Instant::now() > deadline {
break turns;
}
std::thread::sleep(Duration::from_millis(50));
};
let commit = turns
.lines()
.next()
.expect("the prompt hook made no checkpoint");
let files = git(root, &["ls-tree", "-r", "--name-only", commit]);
assert!(files.lines().any(|f| f == "notes.txt"), "{files}");
assert!(!files.lines().any(|f| f == "dump.bin"), "{files}");
}
type CellFrame = (u16, u16, Vec<char>);
struct KillOnDrop(std::process::Child);
impl Drop for KillOnDrop {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn decode_cells(line: &str) -> CellFrame {
use base64::Engine;
let mut parts = line.split(' ');
assert_eq!(parts.next(), Some("cells"));
let columns: u16 = parts.next().unwrap().parse().unwrap();
let rows: u16 = parts.next().unwrap().parse().unwrap();
let bytes = base64::engine::general_purpose::STANDARD
.decode(parts.next().unwrap())
.unwrap();
assert_eq!(bytes.len(), columns as usize * rows as usize * 12);
let chars = bytes
.chunks(12)
.map(|cell| {
let code = u32::from_le_bytes(cell[..4].try_into().unwrap());
assert!(code <= 0xFFFF, "outside the BMP: {code:#x}");
let c = char::from_u32(code).unwrap();
assert!(!c.is_control(), "a control character: {code:#x}");
c
})
.collect();
(columns, rows, chars)
}
#[test]
fn without_pictures_the_pane_sends_cells_and_takes_input_in_batches() {
use std::io::BufRead;
use std::process::Stdio;
use std::time::{Duration, Instant};
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("work");
let home = dir.path().join("home");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&home).unwrap();
std::fs::write(root.join("plan.md"), "Cells cross the wire\n").unwrap();
let cellix = || {
let mut command = Command::new(env!("CARGO_BIN_EXE_cellix"));
command
.current_dir(&root)
.env("HOME", &home)
.env("XDG_CONFIG_HOME", home.join(".config"))
.env("XDG_CACHE_HOME", home.join(".cache"))
.env("XDG_DATA_HOME", home.join(".local/share"))
.env("CELLIX_MOD", cellix::pane::PROTOCOL)
.env("CELLIX_TIER", "cells")
.env("SSH_CONNECTION", "10.0.0.1 50000 10.0.0.2 22");
command
};
let mut pane = KillOnDrop(
cellix()
.arg("pane")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap(),
);
let stdout = pane.0.stdout.take().unwrap();
let (sender, lines) = std::sync::mpsc::channel();
std::thread::spawn(move || {
for line in std::io::BufReader::new(stdout).lines() {
let Ok(line) = line else { break };
if sender.send(line).is_err() {
break;
}
}
});
let next_frame = |what: &dyn Fn(&CellFrame) -> bool| {
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
let Ok(line) = lines.recv_timeout(Duration::from_millis(200)) else {
continue;
};
assert!(
!line.starts_with("frame ") && !line.starts_with("png "),
"a picture"
);
if line.starts_with("cells ") {
let frame = decode_cells(&line);
if what(&frame) {
return Some(frame);
}
}
}
None
};
let first = next_frame(&|_| true).expect("a first frame of cells");
assert_eq!((first.0, first.1), (100, 40));
let batch = format!(
r#"[{{"type":"resize","columns":72,"rows":24}},{{"type":"pointer","kind":"move","x":3,"y":3}},{{"type":"pointer","kind":"move","x":4,"y":3}},{{"type":"open","path":{:?}}}]"#,
root.join("plan.md").to_str().unwrap()
);
let deadline = Instant::now() + Duration::from_secs(10);
while !cellix()
.args(["pane-send", &batch])
.status()
.unwrap()
.success()
{
assert!(Instant::now() < deadline, "pane-send never got through");
std::thread::sleep(Duration::from_millis(100));
}
let shown = next_frame(&|(columns, rows, chars)| {
let text: String = chars.iter().collect();
(*columns, *rows) == (72, 24) && text.contains("Cells cross the wire")
});
assert!(
shown.is_some(),
"the batch was taken and the file shown, as cells"
);
}
#[test]
fn a_mod_left_by_an_upgrade_is_replaced_and_told_to_restart() {
let dir = tempfile::tempdir().unwrap();
let data = dir.path().join("data");
let done = Command::new(env!("CARGO_BIN_EXE_cellix"))
.arg("pane")
.current_dir(dir.path())
.env("HOME", dir.path())
.env("XDG_DATA_HOME", &data)
.env_remove("CELLIX_MOD")
.stdin(std::process::Stdio::null())
.output()
.unwrap();
assert!(!done.status.success());
let said = String::from_utf8_lossy(&done.stderr);
assert!(said.contains("Restart Claude Code"), "{said}");
assert!(done.stdout.is_empty(), "no lines an old mod could misread");
let written =
std::fs::read_to_string(data.join("cellix/claude-code/hooks/register.tsx")).unwrap();
assert!(written.contains(&format!("const PROTOCOL = '{}'", cellix::pane::PROTOCOL)));
}
fn named_rows(line: &str) -> (Vec<String>, Vec<String>) {
let mut parts = line.split(' ');
assert_eq!(parts.next(), Some("rows"));
parts.nth(1);
let names = parts
.next()
.unwrap()
.split(',')
.map(str::to_string)
.collect();
let pictures = parts
.map(|part| {
let (name, png) = part.split_once(':').unwrap();
assert!(png.starts_with("iVBORw0KGgo"), "a whole PNG");
name.to_string()
})
.collect();
(names, pictures)
}
#[test]
fn over_ssh_the_pane_sends_a_picture_a_row_and_only_what_changed() {
use std::io::BufRead;
use std::process::Stdio;
use std::time::{Duration, Instant};
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("work");
let home = dir.path().join("home");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&home).unwrap();
std::fs::write(
root.join("plan.md"),
"Pictures cross the wire\n\nA second line\n",
)
.unwrap();
let cellix = || {
let mut command = Command::new(env!("CARGO_BIN_EXE_cellix"));
command
.current_dir(&root)
.env("HOME", &home)
.env("XDG_CONFIG_HOME", home.join(".config"))
.env("XDG_CACHE_HOME", home.join(".cache"))
.env("XDG_DATA_HOME", home.join(".local/share"))
.env("CELLIX_MOD", cellix::pane::PROTOCOL)
.env_remove("TMUX")
.env_remove("CELLIX_TIER")
.env("SSH_CONNECTION", "10.0.0.1 50000 10.0.0.2 22");
command
};
let mut pane = KillOnDrop(
cellix()
.arg("pane")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap(),
);
let stdout = pane.0.stdout.take().unwrap();
let (sender, lines) = std::sync::mpsc::channel();
std::thread::spawn(move || {
for line in std::io::BufReader::new(stdout).lines() {
let Ok(line) = line else { break };
if sender.send(line).is_err() {
break;
}
}
});
let next_rows = |what: &dyn Fn(&str) -> bool| {
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
let Ok(line) = lines.recv_timeout(Duration::from_millis(200)) else {
continue;
};
assert!(
!line.starts_with("cells ") && !line.starts_with("frame "),
"{line:.40}"
);
if line.starts_with("rows ") && what(&line) {
return Some(line);
}
}
None
};
let send = |batch: &str| {
let deadline = Instant::now() + Duration::from_secs(10);
while !cellix()
.args(["pane-send", batch])
.status()
.unwrap()
.success()
{
assert!(Instant::now() < deadline, "pane-send never got through");
std::thread::sleep(Duration::from_millis(100));
}
};
let until = Instant::now() + Duration::from_secs(3);
while Instant::now() < until {
if let Ok(line) = lines.recv_timeout(Duration::from_millis(100)) {
assert!(!line.starts_with("rows "), "a frame at a guessed size");
}
}
let open = format!(
r#"[{{"type":"resize","columns":72,"rows":24}},{{"type":"open","path":{:?}}}]"#,
root.join("plan.md").to_str().unwrap()
);
send(&open);
let first = next_rows(&|line| line.starts_with("rows 72 24 ")).expect("a first frame");
let (names, pictures) = named_rows(&first);
assert_eq!(names.len(), 24);
let distinct: std::collections::HashSet<&String> = names.iter().collect();
assert_eq!(
pictures.len(),
distinct.len(),
"a picture for each row it shows, once"
);
while lines.recv_timeout(Duration::from_millis(600)).is_ok() {}
send(r#"[{"type":"key","key":"x"}]"#);
let typed = next_rows(&|_| true).expect("the typed letter");
let (_, changed) = named_rows(&typed);
assert!(
!changed.is_empty() && changed.len() <= 4,
"a letter costs a row or so, not the pane: {changed:?}"
);
}