repositories / smith
smith
There are many coding harnesses - but this one is fast
owned by admin
smith-cli/tests/e2e_observer_tui.rs
Raw//! Built binary attached to active writer via a native PTY (`SMH-SPEC-SPEC0001`, Observer mode).
use assert_cmd::cargo::cargo_bin;
use portable_pty::{Child as PtyChild, CommandBuilder, MasterPty, PtySize, native_pty_system};
use serde_json::Value;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{self, Receiver};
use std::time::{Duration, Instant};
const DEADLINE: Duration = Duration::from_secs(15);
const PTY_SIZE: PtySize = PtySize {
rows: 24,
cols: 120,
pixel_width: 0,
pixel_height: 0,
};
const OUTPUT_LIMIT: usize = 65_536;
fn completion_markers(lines: &Receiver<String>, output: &mut String, markers: &[&str]) {
assert!(!markers.is_empty());
let mut found = vec![false; markers.len()];
let end = Instant::now() + DEADLINE;
loop {
let mut last_completion = None;
for (index, marker) in markers.iter().enumerate() {
if !found[index]
&& let Some(at) = output.find(marker)
{
found[index] = true;
last_completion = Some(last_completion.unwrap_or(0).max(at + marker.len()));
}
}
if found.iter().all(|found| *found) {
output.drain(..last_completion.unwrap());
return;
}
let chunk = lines
.recv_timeout(end.saturating_duration_since(Instant::now()))
.unwrap_or_else(|e| {
panic!(
"waiting for {markers:?}: {e}; tail={}",
output
.chars()
.rev()
.take(1400)
.collect::<String>()
.chars()
.rev()
.collect::<String>()
)
});
output.push_str(&chunk);
if output.len() > OUTPUT_LIMIT {
let suffix = markers.iter().map(|marker| marker.len()).max().unwrap() - 1;
let keep_from = output.len().saturating_sub(suffix);
let at = output
.char_indices()
.find_map(|(at, _)| (at >= keep_from).then_some(at))
.unwrap_or(output.len());
output.drain(..at);
}
}
}
fn marker(lines: &Receiver<String>, output: &mut String, marker: &str) {
completion_markers(lines, output, &[marker]);
}
fn plain(bytes: &[u8], csi: &mut u8) -> String {
let mut output = Vec::with_capacity(bytes.len());
for &byte in bytes {
match *csi {
0 if byte == 0x1b => *csi = 1,
1 if byte == b'[' => *csi = 2,
1 => *csi = 0,
2 if (0x40..=0x7e).contains(&byte) => *csi = 0,
2 => {}
_ => output.push(byte),
}
}
String::from_utf8_lossy(&output).into_owned()
}
fn pty_output(mut output: Box<dyn Read + Send>) -> Receiver<String> {
let (tx, rx) = mpsc::sync_channel(64);
std::thread::spawn(move || {
let mut buffer = [0; 4096];
let mut csi = 0;
while let Ok(read) = output.read(&mut buffer) {
if read == 0 || tx.send(plain(&buffer[..read], &mut csi)).is_err() {
break;
}
}
});
rx
}
struct Process(Child);
impl Drop for Process {
fn drop(&mut self) {
if self.0.try_wait().ok().flatten().is_none() {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
}
struct Pty {
master: Box<dyn MasterPty + Send>,
child: Option<Box<dyn PtyChild + Send + Sync>>,
keys: Option<Box<dyn Write + Send>>,
}
impl Pty {
fn write_all(&mut self, bytes: &[u8]) {
self.keys.as_mut().unwrap().write_all(bytes).unwrap();
}
fn close_input(&mut self) {
self.keys.take();
}
fn wait(&mut self) {
let mut child = self.child.take().unwrap();
let mut killer = child.clone_killer();
let (tx, rx) = mpsc::sync_channel(1);
std::thread::spawn(move || {
let _ = tx.send(child.wait());
});
let status = rx.recv_timeout(DEADLINE).unwrap_or_else(|error| {
let _ = killer.kill();
panic!("waiting for observer exit: {error}")
});
let status = status.unwrap();
assert!(status.success(), "observer exited: {status}");
}
}
impl Drop for Pty {
fn drop(&mut self) {
if let Some(child) = &mut self.child
&& child.try_wait().ok().flatten().is_none()
{
let _ = child.kill();
let _ = child.wait();
}
}
}
#[cfg(unix)]
fn terminal_state(master: &dyn MasterPty) -> String {
format!(
"{:?}",
master.get_termios().expect("PTY termios unavailable")
)
}
fn observer(home: &Path, id: &str, export: Option<&Path>) -> (Pty, Receiver<String>) {
let system = native_pty_system();
let pair = system.openpty(PTY_SIZE).unwrap();
let output = pair.master.try_clone_reader().unwrap();
let keys = pair.master.take_writer().unwrap();
let mut command = CommandBuilder::new(cargo_bin("smith"));
command.args(["observe", id]);
if let Some(export) = export {
command.arg("--export");
command.arg(export);
}
command.env("XDG_DATA_HOME", home);
command.env("TERM", "xterm-256color");
command.env_remove("SMITH_PROVIDER");
command.env_remove("OPENAI_API_KEY");
command.env_remove("ANTHROPIC_API_KEY");
let child = pair.slave.spawn_command(command).unwrap();
(
Pty {
master: pair.master,
child: Some(child),
keys: Some(keys),
},
pty_output(output),
)
}
fn session(home: &Path, prompt: &str) -> String {
let eval = Command::new(cargo_bin("smith"))
.args(["eval", prompt, "--mock"])
.env("XDG_DATA_HOME", home)
.output()
.unwrap();
assert!(
eval.status.success(),
"{}",
String::from_utf8_lossy(&eval.stderr)
);
let listed = Command::new(cargo_bin("smith"))
.args(["sessions", "list", "--json"])
.env("XDG_DATA_HOME", home)
.output()
.unwrap();
serde_json::from_slice::<Value>(&listed.stdout).unwrap()["session"]
.as_str()
.unwrap()
.to_owned()
}
fn rpc_writer(home: &Path, id: &str) -> (Process, std::process::ChildStdin, Receiver<String>) {
let mut writer = Process(
Command::new(cargo_bin("smith"))
.args(["rpc", "--session", id])
.env("XDG_DATA_HOME", home)
.env_remove("SMITH_PROVIDER")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap(),
);
let input = writer.0.stdin.take().unwrap();
let output = writer.0.stdout.take().unwrap();
let (tx, rx) = mpsc::sync_channel(64);
std::thread::spawn(move || {
for line in BufReader::new(output).lines() {
if tx.send(line.unwrap()).is_err() {
break;
}
}
});
(writer, input, rx)
}
fn rpc_line(lines: &Receiver<String>) -> Value {
serde_json::from_str(&lines.recv_timeout(DEADLINE).unwrap()).unwrap()
}
#[test]
fn active_writer_observer_disables_submission_follows_exports_and_forks() {
let home = std::env::temp_dir().join(format!("smith-observer-tui-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
let id = session(&home, "initial prompt");
let path = home.join("smith/sessions/run").join(format!("{id}.smh"));
let (mut writer, mut writer_in, ready_rx) = rpc_writer(&home, &id);
writer_in
.write_all(b"{\"id\":1,\"method\":\"ping\"}\n")
.unwrap();
assert_eq!(rpc_line(&ready_rx)["pong"], true);
let before_denied = std::fs::read(&path).unwrap();
let denied = Command::new(cargo_bin("smith"))
.args(["eval", "must not run", "--session", &id, "--mock"])
.env("XDG_DATA_HOME", &home)
.output()
.unwrap();
assert!(!denied.status.success());
assert!(String::from_utf8_lossy(&denied.stderr).contains("SESSION_READ_ONLY"));
let rpc_denied = Command::new(cargo_bin("smith"))
.args(["rpc", "--session", &id])
.env("XDG_DATA_HOME", &home)
.env_remove("SMITH_PROVIDER")
.output()
.unwrap();
assert!(!rpc_denied.status.success());
assert!(String::from_utf8_lossy(&rpc_denied.stderr).contains("SESSION_READ_ONLY"));
assert_eq!(std::fs::read(&path).unwrap(), before_denied);
let export = home.join("transcript.jsonl");
let (mut ui, rx) = observer(&home, &id, Some(&export));
#[cfg(unix)]
let before_terminal = terminal_state(ui.master.as_ref());
let mut seen = String::new();
marker(&rx, &mut seen, "READONLY");
marker(&rx, &mut seen, "ctive");
let original = std::fs::read(&path).unwrap();
ui.write_all(b"not a prompt\r");
marker(&rx, &mut seen, "READ ONLY: prompt");
assert_eq!(std::fs::read(&path).unwrap(), original);
ui.write_all(b"f");
marker(&rx, &mut seen, "FOLLOWLIVE");
for (request, input) in ["filler"; 12]
.into_iter()
.chain(["new live entry"])
.enumerate()
{
let request = request + 2;
writer_in
.write_all(
format!(
"{{\"id\":{request},\"method\":\"prompt\",\"params\":{{\"input\":\"{input}\"}}}}\n"
)
.as_bytes(),
)
.unwrap();
loop {
if rpc_line(&ready_rx)["event"] == "end" {
break;
}
}
}
marker(&rx, &mut seen, "newliveentry");
ui.write_all(b"b");
marker(&rx, &mut seen, "paused");
// Export supersedes this pending navigation fetch.
// Wait for independent export and current-snapshot completions in either order.
ui.write_all(b"e");
completion_markers(&rx, &mut seen, &["Exported to", "initial"]);
assert!(
std::fs::read_to_string(&export)
.unwrap()
.contains("new live entry")
);
let before_fork = std::fs::read(&path).unwrap();
ui.write_all(b"k");
marker(&rx, &mut seen, "Fork crea");
assert_eq!(std::fs::read(&path).unwrap(), before_fork);
ui.write_all(b"q");
ui.close_input();
ui.wait();
#[cfg(unix)]
assert_eq!(terminal_state(ui.master.as_ref()), before_terminal);
writer_in
.write_all(b"{\"id\":15,\"method\":\"ping\"}\n")
.unwrap();
assert_eq!(rpc_line(&ready_rx)["pong"], true);
drop(writer_in);
assert!(writer.0.wait().unwrap().success());
let files: Vec<_> = std::fs::read_dir(home.join("smith/sessions/run"))
.unwrap()
.filter_map(|e| {
let p = e.unwrap().path();
(p.extension().is_some_and(|ext| ext == "smh")).then_some(p)
})
.collect();
assert_eq!(files.len(), 2);
let _ = std::fs::remove_dir_all(home);
}
#[test]
fn completion_markers_accept_export_then_snapshot() {
let (tx, rx) = mpsc::channel();
tx.send("Exported to transcript".into()).unwrap();
tx.send(" initial later".into()).unwrap();
drop(tx);
let mut output = String::new();
completion_markers(&rx, &mut output, &["Exported to", "initial"]);
assert_eq!(output, " later");
}
#[test]
fn completion_markers_accept_snapshot_then_split_export() {
let (tx, rx) = mpsc::channel();
tx.send("ini".into()).unwrap();
tx.send("tial Exported".into()).unwrap();
tx.send(" to later".into()).unwrap();
drop(tx);
let mut output = String::new();
completion_markers(&rx, &mut output, &["Exported to", "initial"]);
assert_eq!(output, " later");
}
#[test]
fn completion_markers_require_fresh_snapshot_after_consumed_stale_marker() {
let (tx, rx) = mpsc::channel();
tx.send("initial stale".into()).unwrap();
tx.send(" Exported to transcript initial fresh later".into())
.unwrap();
drop(tx);
let mut output = String::new();
marker(&rx, &mut output, "initial");
completion_markers(&rx, &mut output, &["Exported to", "initial"]);
assert_eq!(output, " fresh later");
}
#[test]
fn ctrl_c_exits_observer_and_restores_terminal() {
let home = std::env::temp_dir().join(format!("smith-observer-ctrl-c-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
let id = session(&home, "ctrl-c session");
let (mut ui, rx) = observer(&home, &id, None);
#[cfg(unix)]
let before_terminal = terminal_state(ui.master.as_ref());
let mut seen = String::new();
marker(&rx, &mut seen, "READONLY");
ui.write_all(&[3]);
ui.close_input();
ui.wait();
#[cfg(unix)]
assert_eq!(terminal_state(ui.master.as_ref()), before_terminal);
let _ = std::fs::remove_dir_all(home);
}