use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; use tty7_core::client::{ControlClient, PaneClient}; use tty7_core::core::machine::{LayoutDelta, PaneSeed}; use tty7_core::daemon::control::{ControlEvent, ControlHello, ControlRequest, ReplyOk, feature}; use tty7_core::daemon::protocol::{DaemonMsg, PROTOCOL_VERSION, ShellSpec, WinSize}; const READY_WITHIN: Duration = Duration::from_secs(30); const STREAM_WITHIN: Duration = Duration::from_secs(30); struct Daemon { child: Child, dir: tempfile::TempDir, } impl Daemon { fn start() -> Daemon { let dir = tempfile::TempDir::new().unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_tty7-server")) .arg("--daemon") .arg("--config-dir") .arg(dir.path()) .env("TTY7_DATA_DIR", dir.path()) .env("TTY7_CONTROL_SOCK", dir.path().join("control.sock")) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .expect("start tty7-server --daemon"); let daemon = Daemon { child, dir }; daemon.await_ready(); daemon } fn pane_endpoint(&self) -> PathBuf { let file = if cfg!(windows) { "daemon.port" } else { "daemon.sock" }; self.dir.path().join(file) } fn control_endpoint(&self) -> PathBuf { let file = if cfg!(windows) { "control.port" } else { "control.sock" }; self.dir.path().join(file) } fn panes(&self) -> PaneClient { PaneClient::at(self.pane_endpoint()) } fn control(&self, name: &str) -> ControlClient { ControlClient::connect_at(&self.control_endpoint(), &hello(name)) .expect("control handshake with the spawned server") } fn await_ready(&self) { let deadline = Instant::now() + READY_WITHIN; loop { let control_up = ControlClient::connect_at(&self.control_endpoint(), &hello("probe")).is_ok(); let panes_up = self.panes().version().is_ok(); if control_up && panes_up { return; } assert!( Instant::now() < deadline, "tty7-server did not open its endpoints within {READY_WITHIN:?}" ); std::thread::sleep(Duration::from_millis(50)); } } } impl Drop for Daemon { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); } } fn hello(name: &str) -> ControlHello { ControlHello::host_rpc(name, name) } fn size() -> WinSize { WinSize { cols: 100, rows: 30, cell_w: 8, cell_h: 16, } } fn one_shot_shell(command: &str) -> ShellSpec { if cfg!(windows) { ShellSpec { program: "cmd.exe".into(), args: vec!["/d".into(), "/c".into(), command.into()], args_are_tty7_defaults: false, } } else { ShellSpec { program: "/bin/sh".into(), args: vec!["-c".into(), command.into()], args_are_tty7_defaults: false, } } } fn interactive_shell() -> ShellSpec { if cfg!(windows) { ShellSpec { program: "cmd.exe".into(), args: vec!["/d".into()], args_are_tty7_defaults: false, } } else { ShellSpec { program: "/bin/sh".into(), args: Vec::new(), args_are_tty7_defaults: false, } } } fn seed(pane: u64) -> PaneSeed { PaneSeed { pane, cwd: Some("/home/me/proj".into()), ssh_spec: None, agent: None, } } fn collect_until( session: &mut tty7_core::client::PaneSession, marker: &[u8], ) -> (Vec, Option>) { let mut seen: Vec = Vec::new(); loop { match session.recv() { Ok(DaemonMsg::Output(bytes)) | Ok(DaemonMsg::Snapshot(bytes)) => { seen.extend_from_slice(&bytes); if windows_contain(&seen, marker) { return (seen, None); } } Ok(DaemonMsg::Exited { code }) => return (seen, Some(code)), Ok(_) => {} Err(e) => panic!( "pane stream ended early: {e}; saw {:?}", String::from_utf8_lossy(&seen) ), } } } fn drain_until_exit(session: &mut tty7_core::client::PaneSession) -> Vec { let mut seen: Vec = Vec::new(); loop { match session.recv() { Ok(DaemonMsg::Output(bytes)) | Ok(DaemonMsg::Snapshot(bytes)) => { seen.extend_from_slice(&bytes); } Ok(DaemonMsg::Exited { .. }) => return seen, Ok(_) => {} Err(e) => panic!( "pane stream ended before Exited: {e}; saw {:?}", String::from_utf8_lossy(&seen) ), } } } fn windows_contain(haystack: &[u8], needle: &[u8]) -> bool { haystack.windows(needle.len()).any(|w| w == needle) } #[test] fn the_pane_daemon_reports_its_dialect() { let daemon = Daemon::start(); let version = daemon.panes().version().expect("query the version"); assert_eq!(version.protocol, PROTOCOL_VERSION); } #[test] fn control_requests_build_the_tree_and_events_reach_the_other_client() { let daemon = Daemon::start(); let writer = daemon.control("writer"); assert!( writer.hello().has_feature(feature::MACHINE_TREE), "features were {:?}", writer.hello().features ); let watcher = daemon.control("watcher"); watcher .request(ControlRequest::Ping) .expect("the watcher is live before the writer acts"); let ws = match writer .request(ControlRequest::WorkspaceCreate { name: Some("api".into()), workspace: None, }) .expect("create a workspace") { ReplyOk::WorkspaceTree(ws) => *ws, other => panic!("expected WorkspaceTree, got {other:?}"), }; match writer .request(ControlRequest::TabCreate { workspace: ws.id, at: None, pane: seed(1), tab: None, }) .expect("create a tab") { ReplyOk::TabTree(_) => {} other => panic!("expected TabTree, got {other:?}"), } let machine = match writer .request(ControlRequest::MachineGet) .expect("fetch the machine tree") { ReplyOk::MachineTree(m) => *m, other => panic!("expected MachineTree, got {other:?}"), }; assert_eq!(machine.workspaces.len(), 1); assert_eq!(machine.workspaces[0].tabs[0].root.pane_ids(), vec![1]); let key = ws.id.to_string(); let deadline = Instant::now() + STREAM_WITHIN; loop { let remaining = deadline.saturating_duration_since(Instant::now()); assert!( !remaining.is_zero(), "the watcher never saw the WorkspaceCreated delta for {key}" ); match watcher.next_event(remaining) { Some(ControlEvent::Layout { workspace, delta }) if workspace == key && matches!(delta, LayoutDelta::WorkspaceCreated { .. }) => { break; } Some(_) => {} None => {} } } } #[test] fn a_spawned_pane_streams_its_output_and_its_exit() { let daemon = Daemon::start(); let mut session = daemon .panes() .spawn( None, size(), Some(one_shot_shell("echo tty7_pane_roundtrip")), Some("client-lib-test".into()), None, ) .expect("spawn a one-shot pane"); assert_ne!(session.pane_id(), 0, "the daemon must name the pane"); session .set_recv_timeout(Some(STREAM_WITHIN)) .expect("bound the stream reads"); let seen = drain_until_exit(&mut session); assert!( windows_contain(&seen, b"tty7_pane_roundtrip"), "output was {:?}", String::from_utf8_lossy(&seen) ); } #[test] fn a_one_shot_pane_reports_its_real_exit_code() { let daemon = Daemon::start(); let mut session = daemon .panes() .spawn( None, size(), Some(one_shot_shell("exit 5")), Some("client-lib-test".into()), None, ) .expect("spawn a failing one-shot pane"); session .set_recv_timeout(Some(STREAM_WITHIN)) .expect("bound the stream reads"); loop { match session.recv() { Ok(DaemonMsg::Exited { code }) => { assert_eq!(code, Some(5), "the child's code must ride the Exited frame"); break; } Ok(_) => {} Err(e) => panic!("pane stream ended before Exited: {e}"), } } } #[test] fn input_reaches_the_shell_and_a_reattach_replays_it() { let daemon = Daemon::start(); let panes = daemon.panes(); let mut session = panes .spawn(None, size(), Some(interactive_shell()), None, None) .expect("spawn an interactive pane"); let pane_id = session.pane_id(); session .set_recv_timeout(Some(STREAM_WITHIN)) .expect("bound the stream reads"); session .input(b"echo tty7_attach_replay\r") .expect("type into the pane"); let (_, exit) = collect_until(&mut session, b"tty7_attach_replay"); assert!(exit.is_none(), "the shell must still be running"); session.detach().expect("detach"); let listed = panes.list().expect("list panes"); let entry = listed .iter() .find(|p| p.pane_id == pane_id) .expect("the detached pane is still listed"); assert!(entry.alive, "the detached pane is still alive"); let mut reattached = panes.attach(pane_id, size()).expect("reattach"); reattached .set_recv_timeout(Some(STREAM_WITHIN)) .expect("bound the replay reads"); let (_, exit) = collect_until(&mut reattached, b"tty7_attach_replay"); assert!(exit.is_none(), "the replayed pane is still running"); let refused = panes .attach(u64::MAX, size()) .expect_err("attaching to a pane that never existed must fail"); assert!( refused.to_string().contains("no such pane"), "the refusal was {refused}" ); // This test covers attach replay rather than the streaming connection's // Kill command. Close that connection explicitly, then use the one-shot // PaneClient path for deterministic cleanup on loaded Windows runners. drop(reattached); panes.kill(pane_id).expect("kill the pane"); let deadline = Instant::now() + STREAM_WITHIN; loop { let listed = panes.list().expect("list panes after the kill"); let gone = !listed.iter().any(|p| p.pane_id == pane_id && p.alive); if gone { break; } assert!( Instant::now() < deadline, "pane {pane_id} was still listed alive after Kill: {listed:?}" ); std::thread::sleep(Duration::from_millis(50)); } }