mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
A pane's owner names the workspace allowed to attach to it. The CLI wrote a literal "tty7-cli" there for every pane it made, so a window opening on a CLI-built workspace found none of them attachable: it spawned a fresh shell for each tab, orphaned the live ones, and — because the tree still carried each pane's agent session — greeted the user with a failing `claude --resume <id>` in every one of them. Both spawn paths now pass the workspace id, and restore treats an owner that parses as no workspace as no claim at all, so panes already stamped by an older CLI attach instead of stranding.
829 lines
28 KiB
Rust
829 lines
28 KiB
Rust
use std::io::{BufRead as _, Read as _, Write as _};
|
|
use std::path::PathBuf;
|
|
use std::process::{Child, Command, Output, Stdio};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use tty7_core::client::{ControlClient, PaneClient};
|
|
use tty7_core::daemon::control::ControlHello;
|
|
use tty7_core::daemon::protocol::{DaemonMsg, PROTOCOL_VERSION, ShellSpec, WinSize};
|
|
|
|
const DAEMON_ENV: &str = "TTY7_CLI_E2E_DAEMON";
|
|
const PASTE_AWARE_FIXTURE_ARG: &str = "--tty7-e2e-paste-aware-fixture";
|
|
const PASTE_AWARE_TEXT: &str = "tty7 paste aware input";
|
|
const PASTE_BURST_WINDOW: Duration = Duration::from_millis(120);
|
|
const READY_WITHIN: Duration = Duration::from_secs(30);
|
|
const SETTLE_WITHIN: Duration = Duration::from_secs(60);
|
|
const CLOSE_WITHIN: Duration = Duration::from_secs(5);
|
|
|
|
fn main() {
|
|
if std::env::args().any(|arg| arg == PASTE_AWARE_FIXTURE_ARG) {
|
|
run_paste_aware_fixture();
|
|
return;
|
|
}
|
|
if std::env::var(DAEMON_ENV).as_deref() == Ok("1") {
|
|
if let Err(e) = tty7_core::daemon::server::run_daemon() {
|
|
eprintln!("e2e daemon exited with error: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
return;
|
|
}
|
|
|
|
let tests: &[(&str, fn(&Daemon))] = &[
|
|
("ls_on_an_empty_server", ls_on_an_empty_server),
|
|
(
|
|
"new_builds_a_workspace_with_a_live_pane",
|
|
new_builds_a_workspace_with_a_live_pane,
|
|
),
|
|
(
|
|
"tab_close_terminates_every_pane_in_the_tab",
|
|
tab_close_terminates_every_pane_in_the_tab,
|
|
),
|
|
(
|
|
"every_pane_the_cli_files_names_its_workspace_as_owner",
|
|
every_pane_the_cli_files_names_its_workspace_as_owner,
|
|
),
|
|
(
|
|
"run_streams_output_and_passes_the_exit_code",
|
|
run_streams_output_and_passes_the_exit_code,
|
|
),
|
|
(
|
|
"run_keep_files_the_pane_so_ls_shows_it",
|
|
run_keep_files_the_pane_so_ls_shows_it,
|
|
),
|
|
("send_then_capture_round_trip", send_then_capture_round_trip),
|
|
(
|
|
"send_enter_submits_in_a_paste_aware_raw_mode_tui",
|
|
send_enter_submits_in_a_paste_aware_raw_mode_tui,
|
|
),
|
|
(
|
|
"status_reports_the_live_server",
|
|
status_reports_the_live_server,
|
|
),
|
|
(
|
|
"config_dir_alone_resolves_both_endpoints",
|
|
config_dir_alone_resolves_both_endpoints,
|
|
),
|
|
(
|
|
"events_stream_reports_a_workspace_creation",
|
|
events_stream_reports_a_workspace_creation,
|
|
),
|
|
(
|
|
"a_reader_that_hung_up_ends_the_pipeline_quietly",
|
|
a_reader_that_hung_up_ends_the_pipeline_quietly,
|
|
),
|
|
(
|
|
"capture_plain_returns_text_not_escapes",
|
|
capture_plain_returns_text_not_escapes,
|
|
),
|
|
];
|
|
|
|
let mut failed = 0;
|
|
for (name, test) in tests {
|
|
let daemon = Daemon::start();
|
|
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| test(&daemon)));
|
|
drop(daemon);
|
|
match outcome {
|
|
Ok(()) => println!("test {name} ... ok"),
|
|
Err(_) => {
|
|
failed += 1;
|
|
println!("test {name} ... FAILED");
|
|
}
|
|
}
|
|
}
|
|
if failed > 0 {
|
|
eprintln!("{failed} e2e test(s) failed");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
fn run_paste_aware_fixture() {
|
|
let _raw = raw_mode::enable();
|
|
println!("TTY7_PASTE_AWARE_READY");
|
|
std::io::stdout().flush().expect("flush fixture readiness");
|
|
|
|
let mut input = Vec::new();
|
|
let mut last_text_at = None;
|
|
let mut chunk = [0_u8; 256];
|
|
loop {
|
|
let read = std::io::stdin()
|
|
.read(&mut chunk)
|
|
.expect("read fixture input");
|
|
assert_ne!(read, 0, "fixture input ended before Enter");
|
|
let read_at = Instant::now();
|
|
for &byte in &chunk[..read] {
|
|
if byte == b'\r' {
|
|
let submitted = input == PASTE_AWARE_TEXT.as_bytes()
|
|
&& last_text_at.is_some_and(|at| {
|
|
read_at.saturating_duration_since(at) > PASTE_BURST_WINDOW
|
|
});
|
|
if submitted {
|
|
println!("TTY7_PASTE_AWARE_SUBMITTED");
|
|
} else {
|
|
println!("TTY7_PASTE_AWARE_NOT_SUBMITTED");
|
|
}
|
|
std::io::stdout().flush().expect("flush fixture verdict");
|
|
return;
|
|
}
|
|
input.push(byte);
|
|
last_text_at = Some(read_at);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
mod raw_mode {
|
|
pub struct Guard(libc::termios);
|
|
|
|
pub fn enable() -> Guard {
|
|
let mut original = unsafe { std::mem::zeroed() };
|
|
assert_eq!(
|
|
unsafe { libc::tcgetattr(libc::STDIN_FILENO, &mut original) },
|
|
0,
|
|
"read fixture terminal mode"
|
|
);
|
|
let mut raw = original;
|
|
unsafe { libc::cfmakeraw(&mut raw) };
|
|
assert_eq!(
|
|
unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &raw) },
|
|
0,
|
|
"enable fixture raw mode"
|
|
);
|
|
Guard(original)
|
|
}
|
|
|
|
impl Drop for Guard {
|
|
fn drop(&mut self) {
|
|
unsafe {
|
|
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &self.0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
mod raw_mode {
|
|
use windows_sys::Win32::Foundation::HANDLE;
|
|
use windows_sys::Win32::System::Console::{
|
|
ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT,
|
|
ENABLE_VIRTUAL_TERMINAL_INPUT, GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE,
|
|
SetConsoleMode,
|
|
};
|
|
|
|
pub struct Guard {
|
|
input: HANDLE,
|
|
mode: u32,
|
|
}
|
|
|
|
pub fn enable() -> Guard {
|
|
let input = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
|
|
let mut mode = 0;
|
|
assert_ne!(
|
|
unsafe { GetConsoleMode(input, &mut mode) },
|
|
0,
|
|
"read fixture terminal mode"
|
|
);
|
|
let raw = (mode & !(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT))
|
|
| ENABLE_VIRTUAL_TERMINAL_INPUT;
|
|
assert_ne!(
|
|
unsafe { SetConsoleMode(input, raw) },
|
|
0,
|
|
"enable fixture raw mode"
|
|
);
|
|
Guard { input, mode }
|
|
}
|
|
|
|
impl Drop for Guard {
|
|
fn drop(&mut self) {
|
|
unsafe {
|
|
SetConsoleMode(self.input, self.mode);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Daemon {
|
|
child: Child,
|
|
dir: tempfile::TempDir,
|
|
#[cfg(windows)]
|
|
_job: job::Job,
|
|
}
|
|
|
|
impl Daemon {
|
|
fn start() -> Daemon {
|
|
let dir = tempfile::TempDir::new().expect("a temp dir for the isolated server");
|
|
let own = std::env::current_exe().expect("own test binary path");
|
|
let child = Command::new(own)
|
|
.env(DAEMON_ENV, "1")
|
|
.env("TTY7_CONFIG_DIR", dir.path())
|
|
.env("TTY7_DATA_DIR", dir.path())
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("start the in-test tty7 server");
|
|
#[cfg(windows)]
|
|
let _job = job::Job::kill_on_close(&child);
|
|
let daemon = Daemon {
|
|
child,
|
|
dir,
|
|
#[cfg(windows)]
|
|
_job,
|
|
};
|
|
daemon.await_ready();
|
|
daemon
|
|
}
|
|
|
|
fn control_endpoint(&self) -> PathBuf {
|
|
let file = if cfg!(windows) {
|
|
"control.port"
|
|
} else {
|
|
"control.sock"
|
|
};
|
|
self.dir.path().join(file)
|
|
}
|
|
|
|
fn pane_endpoint(&self) -> PathBuf {
|
|
let file = if cfg!(windows) {
|
|
"daemon.port"
|
|
} else {
|
|
"daemon.sock"
|
|
};
|
|
self.dir.path().join(file)
|
|
}
|
|
|
|
fn await_ready(&self) {
|
|
let hello = ControlHello::host_rpc("e2e-probe", "e2e-probe");
|
|
let deadline = Instant::now() + READY_WITHIN;
|
|
loop {
|
|
let control_up = ControlClient::connect_at(&self.control_endpoint(), &hello).is_ok();
|
|
let panes_up = PaneClient::at(self.pane_endpoint()).version().is_ok();
|
|
if control_up && panes_up {
|
|
return;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"the isolated server did not open its endpoints within {READY_WITHIN:?}"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
}
|
|
|
|
fn cli(&self, args: &[&str]) -> Command {
|
|
let mut cmd = Command::new(env!("CARGO_BIN_EXE_tty7"));
|
|
cmd.args(args)
|
|
.env("TTY7_CONFIG_DIR", self.dir.path())
|
|
.env("TTY7_DATA_DIR", self.dir.path())
|
|
.env_remove("TTY7_PANE")
|
|
.env_remove("TTY7_WS")
|
|
.env_remove("TTY7_SOCKET");
|
|
cmd
|
|
}
|
|
|
|
fn run(&self, args: &[&str]) -> Output {
|
|
self.cli(args)
|
|
.output()
|
|
.unwrap_or_else(|e| panic!("could not run tty7 {args:?}: {e}"))
|
|
}
|
|
|
|
fn run_ok(&self, args: &[&str]) -> String {
|
|
let out = self.run(args);
|
|
assert!(
|
|
out.status.success(),
|
|
"tty7 {args:?} failed ({}): {}{}",
|
|
out.status,
|
|
String::from_utf8_lossy(&out.stdout),
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
String::from_utf8_lossy(&out.stdout).into_owned()
|
|
}
|
|
|
|
fn run_json(&self, args: &[&str]) -> serde_json::Value {
|
|
let mut with_json: Vec<&str> = args.to_vec();
|
|
with_json.push("--json");
|
|
let out = self.run_ok(&with_json);
|
|
serde_json::from_str(&out)
|
|
.unwrap_or_else(|e| panic!("tty7 {args:?} --json printed no JSON ({e}): {out}"))
|
|
}
|
|
}
|
|
|
|
impl Drop for Daemon {
|
|
fn drop(&mut self) {
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
mod job {
|
|
use std::os::windows::io::AsRawHandle as _;
|
|
use std::process::Child;
|
|
|
|
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
|
|
use windows_sys::Win32::System::JobObjects::{
|
|
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
|
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
|
|
SetInformationJobObject,
|
|
};
|
|
|
|
pub struct Job(HANDLE);
|
|
|
|
impl Job {
|
|
pub fn kill_on_close(child: &Child) -> Job {
|
|
unsafe {
|
|
let handle = CreateJobObjectW(std::ptr::null(), std::ptr::null());
|
|
assert!(!handle.is_null(), "CreateJobObjectW failed");
|
|
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
|
|
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
|
assert_ne!(
|
|
SetInformationJobObject(
|
|
handle,
|
|
JobObjectExtendedLimitInformation,
|
|
(&raw const info).cast(),
|
|
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
|
),
|
|
0,
|
|
"SetInformationJobObject failed"
|
|
);
|
|
assert_ne!(
|
|
AssignProcessToJobObject(handle, child.as_raw_handle()),
|
|
0,
|
|
"AssignProcessToJobObject failed"
|
|
);
|
|
Job(handle)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for Job {
|
|
fn drop(&mut self) {
|
|
unsafe {
|
|
CloseHandle(self.0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn workdir() -> String {
|
|
std::env::temp_dir().display().to_string()
|
|
}
|
|
|
|
fn one_shot(command: &str) -> Vec<String> {
|
|
if cfg!(windows) {
|
|
vec!["cmd.exe".into(), "/d".into(), "/c".into(), command.into()]
|
|
} else {
|
|
vec!["/bin/sh".into(), "-c".into(), command.into()]
|
|
}
|
|
}
|
|
|
|
fn ls_on_an_empty_server(daemon: &Daemon) {
|
|
let out = daemon.run_ok(&["ls"]);
|
|
assert!(out.contains("no workspaces"), "{out}");
|
|
}
|
|
|
|
fn new_builds_a_workspace_with_a_live_pane(daemon: &Daemon) {
|
|
let created = daemon.run_json(&["new", &workdir()]);
|
|
let ws_id = created["id"].as_str().expect("new prints the workspace id");
|
|
let pane = created["pane"].as_u64().expect("new prints the pane id");
|
|
assert!(pane >= 1, "the daemon names panes from 1, got {pane}");
|
|
|
|
let listed = daemon.run_json(&["ls"]);
|
|
let workspaces = listed["workspaces"]
|
|
.as_array()
|
|
.expect("ls --json lists workspaces");
|
|
assert_eq!(workspaces.len(), 1, "{listed}");
|
|
assert_eq!(workspaces[0]["id"].as_str(), Some(ws_id), "{listed}");
|
|
assert_eq!(workspaces[0]["panes"].as_u64(), Some(1), "{listed}");
|
|
|
|
let panes = daemon.run_ok(&["pane", "ls"]);
|
|
assert!(panes.contains(&format!("%{pane}")), "{panes}");
|
|
}
|
|
|
|
fn tab_close_terminates_every_pane_in_the_tab(daemon: &Daemon) {
|
|
let created = daemon.run_json(&["new", &workdir()]);
|
|
let ws_id = created["id"].as_str().expect("new prints the workspace id");
|
|
let tab = daemon.run_json(&["tab", "new", ws_id, "--cwd", &workdir()]);
|
|
let tab_id = tab["tab"].as_str().expect("tab new prints the tab id");
|
|
let first = tab["pane"].as_u64().expect("tab new prints the pane id");
|
|
let first_addr = format!("%{first}");
|
|
let split = daemon.run_json(&["split", &first_addr, "--horizontal"]);
|
|
let second = split["pane"]
|
|
.as_u64()
|
|
.expect("split prints the new pane id");
|
|
|
|
let tab_addr = format!("@{tab_id}");
|
|
daemon.run_ok(&["tab", "close", &tab_addr]);
|
|
|
|
let deadline = Instant::now() + CLOSE_WITHIN;
|
|
loop {
|
|
let listed = daemon.run_json(&["pane", "ls", "--all"]);
|
|
let running = listed["panes"]
|
|
.as_array()
|
|
.expect("pane ls --all prints the daemon registry");
|
|
let closed_are_gone = running
|
|
.iter()
|
|
.all(|pane| !matches!(pane["pane"].as_u64(), Some(id) if id == first || id == second));
|
|
if closed_are_gone {
|
|
assert_eq!(listed["orphans"].as_u64(), Some(0), "{listed}");
|
|
return;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"tab close left one of panes %{first} and %{second} live: {listed}"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
}
|
|
|
|
/// A pane's owner is the workspace allowed to attach to it, and the GUI
|
|
/// respawns over anything else — so every way the CLI makes a pane has to
|
|
/// stamp that id, not a name of its own. It used to write a literal
|
|
/// "tty7-cli", which left a CLI-built workspace rebuilt from scratch the
|
|
/// first time a window opened on it: fresh shells, the live ones orphaned.
|
|
fn every_pane_the_cli_files_names_its_workspace_as_owner(daemon: &Daemon) {
|
|
let created = daemon.run_json(&["new", &workdir()]);
|
|
let ws_id = created["id"]
|
|
.as_str()
|
|
.expect("new prints the workspace id")
|
|
.to_string();
|
|
|
|
let tab = daemon.run_json(&["tab", "new", &ws_id, "--cwd", &workdir()]);
|
|
let tabbed = tab["pane"].as_u64().expect("tab new prints the pane id");
|
|
daemon.run_json(&["split", &format!("%{tabbed}"), "--horizontal"]);
|
|
|
|
let listed = daemon.run_json(&["pane", "ls", "--all"]);
|
|
let panes = listed["panes"]
|
|
.as_array()
|
|
.expect("pane ls --all prints the daemon registry");
|
|
let ours: Vec<&serde_json::Value> = panes
|
|
.iter()
|
|
.filter(|p| p["workspace"].as_str() == Some(ws_id.as_str()))
|
|
.collect();
|
|
// `run --keep` files a pane the same way, but its command has to exit for
|
|
// the CLI to return, and the registry drops the pane with it — so the
|
|
// three that outlive their command are what can be read back here.
|
|
assert_eq!(
|
|
ours.len(),
|
|
3,
|
|
"new, tab new and split each filed one pane: {listed}"
|
|
);
|
|
for pane in ours {
|
|
assert_eq!(
|
|
pane["owner"].as_str(),
|
|
Some(ws_id.as_str()),
|
|
"a pane its workspace holds must name that workspace as owner: {pane}"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn run_streams_output_and_passes_the_exit_code(daemon: &Daemon) {
|
|
let echo = one_shot("echo tty7_e2e_run_marker");
|
|
let mut args: Vec<&str> = vec!["run", "--"];
|
|
args.extend(echo.iter().map(String::as_str));
|
|
let out = daemon.run(&args);
|
|
assert!(
|
|
out.status.success(),
|
|
"run failed: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
|
assert!(stdout.contains("tty7_e2e_run_marker"), "{stdout}");
|
|
|
|
let exit = one_shot("exit 7");
|
|
let mut args: Vec<&str> = vec!["run", "--"];
|
|
args.extend(exit.iter().map(String::as_str));
|
|
let out = daemon.run(&args);
|
|
assert_eq!(
|
|
out.status.code(),
|
|
Some(7),
|
|
"the child's exit code must pass through: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
}
|
|
|
|
fn run_keep_files_the_pane_so_ls_shows_it(daemon: &Daemon) {
|
|
let ws = daemon.run_json(&["ws", "new", "runws"]);
|
|
let ws_id = ws["id"].as_str().expect("ws new prints the id").to_string();
|
|
|
|
let mut args: Vec<String> = vec![
|
|
"run".into(),
|
|
"--keep".into(),
|
|
"--ws".into(),
|
|
ws_id.clone(),
|
|
"--".into(),
|
|
];
|
|
args.extend(one_shot("echo tty7_e2e_keep_marker"));
|
|
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
|
|
let out = daemon.run(&arg_refs);
|
|
assert!(
|
|
out.status.success(),
|
|
"run --keep failed ({}): {}",
|
|
out.status,
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
|
|
let listed = daemon.run_json(&["ls"]);
|
|
let workspaces = listed["workspaces"]
|
|
.as_array()
|
|
.expect("ls --json lists workspaces");
|
|
let ours = workspaces
|
|
.iter()
|
|
.find(|w| w["id"].as_str() == Some(ws_id.as_str()))
|
|
.unwrap_or_else(|| panic!("the target workspace is missing from ls: {listed}"));
|
|
assert_eq!(
|
|
ours["panes"].as_u64(),
|
|
Some(1),
|
|
"the kept pane must be filed where every listing sees it: {listed}"
|
|
);
|
|
|
|
let panes = daemon.run_json(&["pane", "ls", &ws_id]);
|
|
let filed = panes["panes"]
|
|
.as_array()
|
|
.expect("pane ls --json lists panes");
|
|
assert_eq!(filed.len(), 1, "{panes}");
|
|
assert!(filed[0]["pane"].as_u64().is_some_and(|p| p >= 1), "{panes}");
|
|
}
|
|
|
|
fn config_dir_alone_resolves_both_endpoints(daemon: &Daemon) {
|
|
let out = Command::new(env!("CARGO_BIN_EXE_tty7"))
|
|
.args(["status", "--json"])
|
|
.env_remove("TTY7_DATA_DIR")
|
|
.env_remove("TTY7_CONTROL_SOCK")
|
|
.env_remove("TTY7_PANE")
|
|
.env_remove("TTY7_WS")
|
|
.env_remove("TTY7_SOCKET")
|
|
.env("TTY7_CONFIG_DIR", daemon.dir.path())
|
|
.output()
|
|
.expect("run tty7 status with only TTY7_CONFIG_DIR");
|
|
assert!(
|
|
out.status.success(),
|
|
"status over TTY7_CONFIG_DIR failed ({}): {}",
|
|
out.status,
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
let status: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
|
|
.expect("status --json prints one JSON object");
|
|
assert_eq!(
|
|
status["pid"].as_u64(),
|
|
Some(u64::from(daemon.child.id())),
|
|
"the answer must come from the daemon TTY7_CONFIG_DIR names: {status}"
|
|
);
|
|
|
|
// The control endpoint alone proves nothing: the bug this pins had `status`
|
|
// working while every pane verb reached the wrong socket, because the two
|
|
// endpoints were derived by different rules. Exercise a pane verb over the
|
|
// same lone variable.
|
|
let out = Command::new(env!("CARGO_BIN_EXE_tty7"))
|
|
.args(["run", "--json", "--", "sh", "-c", "exit 9"])
|
|
.env_remove("TTY7_DATA_DIR")
|
|
.env_remove("TTY7_CONTROL_SOCK")
|
|
.env_remove("TTY7_PANE")
|
|
.env_remove("TTY7_WS")
|
|
.env_remove("TTY7_SOCKET")
|
|
.env("TTY7_CONFIG_DIR", daemon.dir.path())
|
|
.output()
|
|
.expect("run tty7 run with only TTY7_CONFIG_DIR");
|
|
assert_eq!(
|
|
out.status.code(),
|
|
Some(9),
|
|
"a pane verb must reach the same server the control verb did: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
}
|
|
|
|
fn send_then_capture_round_trip(daemon: &Daemon) {
|
|
let created = daemon.run_json(&["new", &workdir()]);
|
|
let pane = created["pane"].as_u64().expect("new prints the pane id");
|
|
let address = format!("%{pane}");
|
|
|
|
daemon.run_ok(&["send", &address, "echo tty7_e2e_capture_marker", "--enter"]);
|
|
|
|
let deadline = Instant::now() + SETTLE_WITHIN;
|
|
loop {
|
|
let seen = daemon.run_ok(&["capture", &address, "--scrollback"]);
|
|
if seen.contains("tty7_e2e_capture_marker") {
|
|
return;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"the sent text never showed up in the capture; last capture:\n{seen}"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(200));
|
|
}
|
|
}
|
|
|
|
fn send_enter_submits_in_a_paste_aware_raw_mode_tui(daemon: &Daemon) {
|
|
let fixture = std::env::current_exe().expect("locate the paste-aware fixture");
|
|
let shell = ShellSpec {
|
|
program: fixture.display().to_string(),
|
|
args: vec![PASTE_AWARE_FIXTURE_ARG.into()],
|
|
args_are_tty7_defaults: false,
|
|
};
|
|
let mut pane = PaneClient::at(daemon.pane_endpoint())
|
|
.spawn(
|
|
None,
|
|
WinSize {
|
|
cols: 80,
|
|
rows: 24,
|
|
cell_w: 8,
|
|
cell_h: 16,
|
|
},
|
|
Some(shell),
|
|
Some("paste-aware-e2e".into()),
|
|
None,
|
|
)
|
|
.expect("spawn the paste-aware raw-mode fixture");
|
|
pane.set_recv_timeout(Some(SETTLE_WITHIN))
|
|
.expect("bound fixture output reads");
|
|
collect_pane_output_until(&mut pane, b"TTY7_PASTE_AWARE_READY");
|
|
|
|
let address = format!("%{}", pane.pane_id());
|
|
daemon.run_ok(&["send", &address, PASTE_AWARE_TEXT, "--enter"]);
|
|
|
|
collect_pane_output_until(&mut pane, b"TTY7_PASTE_AWARE_SUBMITTED");
|
|
}
|
|
|
|
fn collect_pane_output_until(session: &mut tty7_core::client::PaneSession, marker: &[u8]) {
|
|
let mut seen = Vec::new();
|
|
loop {
|
|
match session.recv() {
|
|
Ok(DaemonMsg::Output(bytes)) | Ok(DaemonMsg::Snapshot(bytes)) => {
|
|
seen.extend_from_slice(&bytes);
|
|
if seen.windows(marker.len()).any(|window| window == marker) {
|
|
return;
|
|
}
|
|
}
|
|
Ok(DaemonMsg::Exited { code }) => panic!(
|
|
"paste-aware fixture exited ({code:?}) before {:?}; saw {:?}",
|
|
String::from_utf8_lossy(marker),
|
|
String::from_utf8_lossy(&seen)
|
|
),
|
|
Ok(_) => {}
|
|
Err(error) => panic!(
|
|
"paste-aware fixture ended before {:?}: {error}; saw {:?}",
|
|
String::from_utf8_lossy(marker),
|
|
String::from_utf8_lossy(&seen)
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn status_reports_the_live_server(daemon: &Daemon) {
|
|
let status = daemon.run_json(&["status"]);
|
|
assert!(
|
|
status["pid"].as_u64().is_some_and(|pid| pid > 0),
|
|
"{status}"
|
|
);
|
|
assert_eq!(
|
|
status["protocol_version"].as_u64(),
|
|
Some(u64::from(PROTOCOL_VERSION)),
|
|
"{status}"
|
|
);
|
|
|
|
let human = daemon.run_ok(&["server", "status"]);
|
|
assert!(human.contains("pid"), "{human}");
|
|
}
|
|
|
|
fn events_stream_reports_a_workspace_creation(daemon: &Daemon) {
|
|
let mut watcher = daemon
|
|
.cli(&["events", "--json"])
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("start tty7 events");
|
|
let stdout = watcher.stdout.take().expect("piped stdout");
|
|
let (tx, rx) = std::sync::mpsc::channel::<String>();
|
|
std::thread::spawn(move || {
|
|
let reader = std::io::BufReader::new(stdout);
|
|
for line in reader.lines() {
|
|
let Ok(line) = line else { break };
|
|
if tx.send(line).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
let deadline = Instant::now() + SETTLE_WITHIN;
|
|
let mut seen = Vec::new();
|
|
let verdict = 'outer: loop {
|
|
if Instant::now() >= deadline {
|
|
break false;
|
|
}
|
|
daemon.run_ok(&["ws", "new", "evtws"]);
|
|
let round = Instant::now() + Duration::from_secs(5);
|
|
while let Ok(line) = rx.recv_timeout(round.saturating_duration_since(Instant::now())) {
|
|
let is_event = serde_json::from_str::<serde_json::Value>(&line).is_ok();
|
|
seen.push(line);
|
|
if is_event {
|
|
break 'outer true;
|
|
}
|
|
}
|
|
};
|
|
let _ = watcher.kill();
|
|
let _ = watcher.wait();
|
|
assert!(
|
|
verdict,
|
|
"no event line arrived within {SETTLE_WITHIN:?}; saw {seen:?}"
|
|
);
|
|
}
|
|
|
|
/// `tty7 … | head -1` must end the way `cat … | head -1` ends. Rust ignores
|
|
/// SIGPIPE and `println!` panics on the resulting error, so without the fix
|
|
/// this printed a panic and a backtrace note on a correct invocation.
|
|
///
|
|
/// Both write paths are covered: `ls` goes through the report emitter, `run`
|
|
/// through the loop that streams a child's output. The reader is dropped
|
|
/// immediately, long before either has anything to say, so the very first
|
|
/// write lands on a pipe with no other end — no need to guess at a buffer size.
|
|
fn a_reader_that_hung_up_ends_the_pipeline_quietly(daemon: &Daemon) {
|
|
daemon.run_ok(&["ws", "new", "pipews"]);
|
|
|
|
let printer = one_shot("echo tty7_e2e_pipe_marker");
|
|
let mut streaming: Vec<&str> = vec!["run", "--"];
|
|
streaming.extend(printer.iter().map(String::as_str));
|
|
|
|
for args in [vec!["ls"], streaming] {
|
|
let mut child = daemon
|
|
.cli(&args)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.unwrap_or_else(|e| panic!("could not spawn tty7 {args:?}: {e}"));
|
|
drop(child.stdout.take().expect("stdout was piped"));
|
|
|
|
let mut stderr = String::new();
|
|
child
|
|
.stderr
|
|
.take()
|
|
.expect("stderr was piped")
|
|
.read_to_string(&mut stderr)
|
|
.expect("reading tty7's stderr");
|
|
let status = child.wait().expect("waiting for tty7");
|
|
|
|
assert!(
|
|
!stderr.contains("panicked"),
|
|
"tty7 {args:?} panicked when its reader hung up: {stderr}"
|
|
);
|
|
assert!(
|
|
!stderr.to_lowercase().contains("broken pipe"),
|
|
"a hung-up reader is how a pipeline ends, not something to report: \
|
|
tty7 {args:?} said {stderr}"
|
|
);
|
|
// Unix dies of SIGPIPE, so there is no code at all; Windows exits 0.
|
|
// Either way it must not be the failure exit, which is what the error
|
|
// path used to produce.
|
|
assert_ne!(
|
|
status.code(),
|
|
Some(1),
|
|
"tty7 {args:?} treated a hung-up reader as a failure: {stderr}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `--plain` against a real pane, end to end through a real daemon.
|
|
///
|
|
/// The discriminator is the PTY's own line ending: a terminal ends lines with
|
|
/// CRLF, so every raw capture carries `\r`, and a rendered one carries none —
|
|
/// that CR was an instruction to the grid, not text. It holds whatever the test
|
|
/// machine's shell decorates its prompt with, which a check for escape bytes
|
|
/// would not: the isolated daemon's shell prints no colour at all.
|
|
///
|
|
/// What the grid *does* with those bytes (wraps, overwrites, cursor moves) is
|
|
/// pinned by the unit tests in `screen.rs`, which can craft the byte stream
|
|
/// exactly. This one proves the flag reaches them.
|
|
fn capture_plain_returns_text_not_escapes(daemon: &Daemon) {
|
|
let created = daemon.run_json(&["new", &workdir()]);
|
|
let pane = created["pane"].as_u64().expect("new prints the pane id");
|
|
let address = format!("%{pane}");
|
|
|
|
daemon.run_ok(&["send", &address, "echo tty7_e2e_plain_marker", "--enter"]);
|
|
|
|
let deadline = Instant::now() + SETTLE_WITHIN;
|
|
loop {
|
|
let raw = daemon.run_ok(&["capture", &address, "--scrollback"]);
|
|
let plain = daemon.run_ok(&["capture", &address, "--scrollback", "--plain"]);
|
|
// The marker renders as soon as the shell echoes the typed command,
|
|
// which can be before the pane has seen a single CR — ConPTY repaints
|
|
// the input line in escape-laden bursts, and `raw` is a separate,
|
|
// slightly earlier snapshot besides. The CR is part of what must
|
|
// settle, not something the marker's arrival already proves.
|
|
if plain.contains("tty7_e2e_plain_marker") && raw.contains('\r') {
|
|
assert!(
|
|
!plain.contains('\r'),
|
|
"a carriage return is an instruction to the grid, not text:\n{plain:?}"
|
|
);
|
|
assert!(
|
|
!plain.contains('\u{1b}'),
|
|
"an escape survived the grid:\n{plain:?}"
|
|
);
|
|
return;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"the captures never settled (marker rendered, CRLF in the raw \
|
|
bytes); last plain was:\n{plain}\nlast raw was:\n{raw:?}"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(200));
|
|
}
|
|
}
|