merge: CLI review fixes — CI coverage, kept-pane filing, endpoint honesty

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
This commit is contained in:
thomas
2026-07-31 11:03:24 +08:00
co-authored by Claude Fable 5
10 changed files with 438 additions and 34 deletions
Generated
+1
View File
@@ -9698,6 +9698,7 @@ dependencies = [
"serde_json",
"tempfile",
"tty7-core",
"windows-sys 0.59.0",
]
[[package]]
+3 -3
View File
@@ -201,10 +201,10 @@ workspace = true
# ---- caches are shared and versions stay aligned. ----
[workspace]
members = ["crates/*"]
# The root `tty7` package is a member implicitly; naming all three here is what
# makes a bare `cargo build` / `cargo test` at the root cover the whole
# The root `tty7` package is a member implicitly; naming every crate here is
# what makes a bare `cargo build` / `cargo test` at the root cover the whole
# workspace, which is how CI invokes them.
default-members = [".", "crates/tty7-core", "crates/tty7-server"]
default-members = [".", "crates/tty7-core", "crates/tty7-server", "crates/tty7-cli"]
[workspace.package]
edition = "2024"
+10 -2
View File
@@ -1,8 +1,7 @@
# The thin console CLI from docs/cli-design.md: the `tty7` on the user's PATH.
# A control/pane-protocol client of tty7-server — the GUI is never required.
# Kept apart from the GUI binary on purpose (subsystem, upgrade file locks,
# millisecond cold start); until the GUI bin is renamed, build this package
# alone (`cargo build -p tty7-cli`) so the two `tty7` bins never collide.
# millisecond cold start).
[package]
name = "tty7-cli"
version.workspace = true
@@ -35,6 +34,15 @@ libc = "0.2"
[dev-dependencies]
tempfile = "3"
# The e2e harness puts its throwaway daemon in a Job Object wired to
# KILL_ON_JOB_CLOSE, so a hard-killed test run cannot leak servers.
[target.'cfg(windows)'.dev-dependencies]
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_System_JobObjects",
] }
# The end-to-end suite drives the compiled tty7.exe against a real isolated
# server; its own main doubles as that server process, so no libtest harness.
[[test]]
+13 -3
View File
@@ -30,7 +30,9 @@ pub trait Backend {
fn attach_pane(&mut self, pane: u64) -> Result<()>;
fn run(&mut self, spec: RunSpec) -> Result<i32>;
fn run_spawn(&mut self, spec: RunSpec) -> Result<u64>;
fn run_wait(&mut self) -> Result<Option<i32>>;
fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()>;
}
@@ -61,6 +63,7 @@ pub mod mock {
pub procs_calls: Vec<u64>,
pub procs_reply: PaneProcs,
pub runs: Vec<RunSpec>,
pub run_exit: Option<i32>,
pub events: Vec<ControlEvent>,
}
@@ -78,6 +81,7 @@ pub mod mock {
procs_calls: Vec::new(),
procs_reply: PaneProcs::default(),
runs: Vec::new(),
run_exit: Some(0),
events: Vec::new(),
}
}
@@ -140,9 +144,15 @@ pub mod mock {
Err(anyhow!("the mock backend cannot attach"))
}
fn run(&mut self, spec: RunSpec) -> Result<i32> {
fn run_spawn(&mut self, spec: RunSpec) -> Result<u64> {
self.runs.push(spec);
Ok(0)
let id = self.next_spawn_id;
self.next_spawn_id += 1;
Ok(id)
}
fn run_wait(&mut self) -> Result<Option<i32>> {
Ok(self.run_exit)
}
fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()> {
+82 -11
View File
@@ -1,10 +1,10 @@
use std::io::Write as _;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context as _, Result, anyhow, bail};
use serde_json::json;
use tty7_core::client::{ControlClient, PaneClient};
use tty7_core::client::{ControlClient, PaneClient, PaneSession};
use tty7_core::core::session::WorkspaceId;
use tty7_core::daemon::control::{
ControlEvent, ControlHello, ControlHelloOk, ControlRequest, ReplyOk, RouteInfo,
@@ -29,18 +29,27 @@ const NOT_RUNNING: &str =
pub struct RealBackend {
machine: Option<String>,
socket: Option<String>,
route: Option<RouteTarget>,
control: Option<ControlClient>,
panes: Option<PaneClient>,
running: Option<RunningCommand>,
}
struct RunningCommand {
session: PaneSession,
keep: bool,
}
impl RealBackend {
pub fn new(machine: Option<String>) -> RealBackend {
pub fn new(machine: Option<String>, socket: Option<String>) -> RealBackend {
RealBackend {
machine,
socket,
route: None,
control: None,
panes: None,
running: None,
}
}
@@ -48,6 +57,18 @@ impl RealBackend {
ControlHello::host_rpc(format!("tty7-cli-{}", std::process::id()), hostname())
}
fn local_control(&self, hello: &ControlHello) -> Result<ControlClient> {
match &self.socket {
Some(path) => ControlClient::connect_at(Path::new(path), hello).with_context(|| {
format!(
"connecting to the server at {}={path}",
crate::address::ENV_SOCKET
)
}),
None => ControlClient::connect(hello).context(NOT_RUNNING),
}
}
fn route(&mut self) -> Result<Option<RouteTarget>> {
let Some(name) = self.machine.clone() else {
return Ok(None);
@@ -55,7 +76,7 @@ impl RealBackend {
if let Some(target) = &self.route {
return Ok(Some(target.clone()));
}
let local = ControlClient::connect(&Self::hello_msg()).context(NOT_RUNNING)?;
let local = self.local_control(&Self::hello_msg())?;
let routes = match local
.request(ControlRequest::Routes)
.context("asking the local server for its machine links")?
@@ -79,7 +100,7 @@ impl RealBackend {
self.machine.as_deref().unwrap_or_default()
)
})?,
None => ControlClient::connect(&hello).context(NOT_RUNNING)?,
None => self.local_control(&hello)?,
};
self.control = Some(client);
}
@@ -90,7 +111,10 @@ impl RealBackend {
if self.panes.is_none() {
let client = match self.route()? {
Some(target) => PaneClient::routed(target),
None => PaneClient::local(),
None => match &self.socket {
Some(path) => PaneClient::at(pane_endpoint_for(Path::new(path))),
None => PaneClient::local(),
},
};
self.panes = Some(client);
}
@@ -98,6 +122,15 @@ impl RealBackend {
}
}
fn pane_endpoint_for(control: &Path) -> PathBuf {
let file = if cfg!(windows) {
"daemon.port"
} else {
"daemon.sock"
};
control.with_file_name(file)
}
impl Backend for RealBackend {
fn control(&mut self, req: ControlRequest) -> Result<ReplyOk> {
let reply = self.control_client()?.request(req)?;
@@ -171,7 +204,7 @@ impl Backend for RealBackend {
bail!("interactive `tty7 attach %pane` is not wired yet — it lands in the next slice")
}
fn run(&mut self, spec: RunSpec) -> Result<i32> {
fn run_spawn(&mut self, spec: RunSpec) -> Result<u64> {
let (program, args) = spec
.command
.split_first()
@@ -181,7 +214,7 @@ impl Backend for RealBackend {
args: args.to_vec(),
args_are_tty7_defaults: false,
};
let mut session = self
let session = self
.pane_client()?
.spawn(
spec.cwd.map(PathBuf::from),
@@ -191,6 +224,19 @@ impl Backend for RealBackend {
spec.workspace.map(|ws| ws.to_string()),
)
.with_context(|| format!("spawning `{program}`"))?;
let pane = session.pane_id();
self.running = Some(RunningCommand {
session,
keep: spec.keep,
});
Ok(pane)
}
fn run_wait(&mut self) -> Result<Option<i32>> {
let RunningCommand { mut session, keep } = self
.running
.take()
.ok_or_else(|| anyhow!("run_wait without a spawned command"))?;
let mut stdout = std::io::stdout().lock();
let code = loop {
match session.recv() {
@@ -198,12 +244,12 @@ impl Backend for RealBackend {
stdout.write_all(&bytes)?;
stdout.flush()?;
}
Ok(DaemonMsg::Exited { code }) => break code.unwrap_or(1),
Ok(DaemonMsg::Exited { code }) => break code,
Ok(_) => {}
Err(e) => return Err(anyhow!(e).context("streaming the command's output")),
}
};
if spec.keep {
if keep {
session.detach()?;
} else {
session.kill()?;
@@ -236,6 +282,12 @@ fn hostname() -> String {
fn resolve_route(name: &str, routes: &[RouteInfo]) -> Result<RouteTarget> {
let matches: Vec<&RouteInfo> = routes.iter().filter(|r| route_matches(name, r)).collect();
match matches.as_slice() {
[one] if !one.connected => bail!(
"the link to machine '{}' is down — the CLI will not dial a fresh connection of \
its own (that would guess at auth instead of using the profile's credentials); \
reconnect the link from the GUI or its SSH profile, then retry",
one.key
),
[one] => target_for(one),
[] if routes.is_empty() => bail!(
"the local server holds no machine links — connect one from the GUI first \
@@ -320,7 +372,7 @@ mod tests {
fn a_machine_resolves_by_full_key_or_bare_host() {
let routes = vec![
route("me@build-box:22", "ssh", true),
route("me@web-box:2222", "ssh", false),
route("me@web-box:2222", "ssh", true),
];
for name in ["me@build-box:22", "build-box"] {
let RouteTarget::Ssh(spec) = resolve_route(name, &routes).unwrap() else {
@@ -353,6 +405,25 @@ mod tests {
assert!(err.contains("no machine links"), "{err}");
}
#[test]
fn a_down_link_is_refused_instead_of_dialed_fresh() {
let routes = vec![route("me@build-box:22", "ssh", false)];
let err = resolve_route("build-box", &routes).unwrap_err().to_string();
assert!(err.contains("down"), "{err}");
assert!(err.contains("reconnect"), "{err}");
assert!(err.contains("me@build-box:22"), "{err}");
}
#[test]
fn the_pane_endpoint_is_the_control_endpoints_sibling() {
let (control, pane) = if cfg!(windows) {
("C:\\cfg\\tty7\\control.port", "C:\\cfg\\tty7\\daemon.port")
} else {
("/run/user/1000/tty7/control.sock", "/run/user/1000/tty7/daemon.sock")
};
assert_eq!(pane_endpoint_for(Path::new(control)), PathBuf::from(pane));
}
#[test]
fn chained_keys_are_refused_with_the_reason() {
let routes = vec![route("me@inner:22|jump:me@bastion:22", "ssh", true)];
+9 -3
View File
@@ -60,7 +60,7 @@ pub enum Command {
#[command(about = "Type text into a pane")]
Send(SendArgs),
#[command(about = "Print what a pane is showing")]
#[command(about = "Print a pane's raw output (ANSI bytes): the newest scrollback segment by default")]
Capture(CaptureArgs),
#[command(about = "Processes and listening ports inside a pane")]
@@ -108,7 +108,8 @@ pub struct RunArgs {
#[arg(
long,
value_name = "WORKSPACE",
help = "Workspace the pane belongs to; defaults to $TTY7_WS inside a tty7 shell"
help = "Sets the pane's TTY7_WS and, with --keep, the workspace the kept pane is \
filed into; defaults to $TTY7_WS inside a tty7 shell"
)]
pub ws: Option<String>,
@@ -154,7 +155,12 @@ pub struct CaptureArgs {
#[arg(value_name = "%PANE", help = "Pane to read; defaults to $TTY7_PANE inside a tty7 shell")]
pub target: Option<String>,
#[arg(long, help = "Include the server-side scrollback ring, not just the screen")]
#[arg(
long,
help = "Print the whole scrollback ring, raw ANSI bytes; the ring splits into \
segments on resize, and without this flag only the last segment is \
printed (for a never-resized pane the two are identical)"
)]
pub scrollback: bool,
}
+172 -8
View File
@@ -28,6 +28,10 @@ pub enum Outcome {
Exit(i32),
}
pub const EXIT_CODE_UNKNOWN: &str =
"the command exited but its real exit code could not be determined — exiting 1 as a \
stand-in, not as the command's own code";
fn report(human: impl Into<String>, json: Value) -> Result<Outcome> {
Ok(Outcome::Report(Report {
human: human.into(),
@@ -37,6 +41,7 @@ fn report(human: impl Into<String>, json: Value) -> Result<Outcome> {
pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result<Outcome> {
let json_mode = cli.json;
let machine = cli.machine.clone();
match cli.command {
None => launch_gui(cli.path),
Some(Command::Ls) | Some(Command::Ws(WsCmd::Ls)) => ws_ls(backend),
@@ -77,14 +82,37 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result<Out
"managing machine links from the CLI is not implemented yet — \
use the GUI's connection manager for now"
),
Some(Command::Server(ServerCmd::Start)) => crate::server::start(),
Some(Command::Server(ServerCmd::Stop)) => crate::server::stop(),
Some(Command::Server(ServerCmd::Restart)) => crate::server::restart(),
Some(Command::Server(ServerCmd::Logs)) => crate::server::logs(),
Some(Command::Server(ServerCmd::Start)) => {
local_server(machine.as_deref(), "start", crate::server::start)
}
Some(Command::Server(ServerCmd::Stop)) => {
local_server(machine.as_deref(), "stop", crate::server::stop)
}
Some(Command::Server(ServerCmd::Restart)) => {
local_server(machine.as_deref(), "restart", crate::server::restart)
}
Some(Command::Server(ServerCmd::Logs)) => {
local_server(machine.as_deref(), "logs", crate::server::logs)
}
Some(Command::Doctor) => doctor(ctx, backend),
}
}
fn local_server(
machine: Option<&str>,
verb: &str,
act: fn() -> Result<Outcome>,
) -> Result<Outcome> {
if let Some(machine) = machine {
bail!(
"`tty7 server {verb}` manages only the server on THIS machine — with -m {machine} \
it would still have acted on the LOCAL server, so it was refused; a remote \
machine's server lifecycle is handled by the install/reconnect flows, not the CLI"
);
}
act()
}
fn launch_gui(path: Option<String>) -> Result<Outcome> {
match path {
Some(p) => bail!("launching the GUI is not wired up yet (would open {p})"),
@@ -224,7 +252,7 @@ fn attach(target: &str, backend: &mut dyn Backend) -> Result<Outcome> {
match address::parse(target)? {
Address::Pane(pane) => {
backend.attach_pane(pane)?;
report("", json!({ "detached_from": pane }))
report("", json!({ "attached": pane }))
}
Address::Workspace(addr) => ws_attach(addr, backend),
Address::Tab(_) => bail!("attach takes a %pane or a workspace, not a tab"),
@@ -239,13 +267,39 @@ fn run(args: RunArgs, ctx: &Context, backend: &mut dyn Backend) -> Result<Outcom
}
None => ctx.ws.as_deref().and_then(|v| v.parse::<WorkspaceId>().ok()),
};
let code = backend.run(RunSpec {
if args.keep && workspace.is_none() {
bail!(
"`run --keep` keeps the pane alive, so it must be filed into a workspace — \
pass --ws, or run inside a tty7 shell where $TTY7_WS names one"
);
}
let pane = backend.run_spawn(RunSpec {
workspace,
cwd: args.cwd,
cwd: args.cwd.clone(),
command: args.cmd,
keep: args.keep,
})?;
Ok(Outcome::Exit(code))
if args.keep {
let workspace = workspace.expect("checked above: --keep requires a workspace");
backend.control(ControlRequest::TabCreate {
workspace,
at: None,
pane: PaneSeed {
pane,
cwd: args.cwd,
ssh_spec: None,
agent: None,
},
tab: None,
})?;
}
match backend.run_wait()? {
Some(code) => Ok(Outcome::Exit(code)),
None => {
eprintln!("tty7: {EXIT_CODE_UNKNOWN}");
Ok(Outcome::Exit(1))
}
}
}
fn pane_split(args: SplitArgs, ctx: &Context, backend: &mut dyn Backend) -> Result<Outcome> {
@@ -993,6 +1047,116 @@ mod tests {
assert!(matches!(out, Outcome::Exit(0)), "run's outcome is the child's exit code");
}
#[test]
fn run_keep_spawns_first_then_files_the_pane_into_the_workspace() {
let mut backend = mock();
let api = backend.machine.workspaces[0].id;
let out = execute(
cli(&[
"tty7", "run", "--keep", "--ws", "api", "--cwd", "C:\\proj", "--", "cargo",
"watch",
]),
&Context::default(),
&mut backend,
)
.unwrap();
assert_eq!(
backend.control_calls,
vec![
ControlRequest::MachineGet,
ControlRequest::TabCreate {
workspace: api,
at: None,
pane: PaneSeed {
pane: 6,
cwd: Some("C:\\proj".into()),
ssh_spec: None,
agent: None,
},
tab: None,
},
],
"the daemon-assigned pane id (6) lands in the tree op, so the spawn came first"
);
assert_eq!(
backend.runs,
vec![RunSpec {
workspace: Some(api),
cwd: Some("C:\\proj".into()),
command: vec!["cargo".into(), "watch".into()],
keep: true,
}]
);
assert!(matches!(out, Outcome::Exit(0)));
}
#[test]
fn run_without_keep_files_nothing() {
let mut backend = mock();
let out = execute(
cli(&["tty7", "run", "--", "cargo", "test"]),
&Context::default(),
&mut backend,
)
.unwrap();
assert!(
backend.control_calls.is_empty(),
"a reaped pane must not be filed into the tree"
);
assert_eq!(backend.runs.len(), 1);
assert!(matches!(out, Outcome::Exit(0)));
}
#[test]
fn run_keep_without_a_workspace_names_the_fix() {
let mut backend = mock();
let err = execute(
cli(&["tty7", "run", "--keep", "--", "make"]),
&Context::default(),
&mut backend,
)
.expect_err("a kept pane with no workspace would be an unlisted orphan");
assert!(err.to_string().contains("--ws"), "{err}");
assert!(backend.runs.is_empty(), "nothing must be spawned");
}
#[test]
fn a_missing_exit_code_still_exits_nonzero_via_the_note_path() {
let mut backend = mock();
backend.run_exit = None;
let out = execute(
cli(&["tty7", "run", "--", "make"]),
&Context::default(),
&mut backend,
)
.unwrap();
assert!(
matches!(out, Outcome::Exit(1)),
"an unknown exit code is still a failure"
);
assert!(EXIT_CODE_UNKNOWN.contains("could not be determined"));
}
#[test]
fn a_machine_flag_refuses_the_local_server_verbs() {
for verb in ["start", "stop", "restart", "logs"] {
let mut backend = mock();
let err = execute(
cli(&["tty7", "-m", "devbox", "server", verb]),
&Context::default(),
&mut backend,
)
.expect_err("server lifecycle verbs are local-only");
let msg = err.to_string();
assert!(msg.contains("LOCAL"), "{msg}");
assert!(msg.contains(verb), "{msg}");
assert!(
backend.control_calls.is_empty(),
"the refusal must come before any dial"
);
}
}
#[test]
fn the_still_missing_verbs_say_so_without_touching_the_wire() {
for (args, needle) in [
+1 -1
View File
@@ -15,7 +15,7 @@ fn main() -> std::process::ExitCode {
let json = cli.json;
let quiet = cli.quiet;
let ctx = address::Context::from_env();
let mut backend = backend::RealBackend::new(cli.machine.clone());
let mut backend = backend::RealBackend::new(cli.machine.clone(), ctx.socket.clone());
match commands::execute(cli, &ctx, &mut backend) {
Ok(commands::Outcome::Exit(code)) => std::process::exit(code),
Ok(commands::Outcome::Report(report)) => {
+17 -2
View File
@@ -44,15 +44,30 @@ pub fn start() -> Result<Outcome> {
.stdout(Stdio::null())
.stderr(Stdio::null());
detach(&mut cmd);
let child = cmd
let mut child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("could not start {}: {e}", exe.display()))?;
let pid = child.id();
let deadline = Instant::now() + START_TIMEOUT;
while !running() {
if Instant::now() >= deadline {
let fate = match child.try_wait() {
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
"it was still running and has been killed"
}
Ok(Some(status)) => {
if status.success() {
"it had already exited cleanly"
} else {
"it had already exited with an error"
}
}
Err(_) => "its state could not be checked, so it was left alone",
};
bail!(
"{} (pid {pid}) did not open its endpoints within {START_TIMEOUT:?}",
"{} (pid {pid}) did not open its endpoints within {START_TIMEOUT:?} — {fate}",
exe.display()
);
}
+130 -1
View File
@@ -24,8 +24,10 @@ fn main() {
("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),
("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),
("status_reports_the_live_server", status_reports_the_live_server),
("status_answers_over_tty7_socket_alone", status_answers_over_tty7_socket_alone),
("events_stream_reports_a_workspace_creation", events_stream_reports_a_workspace_creation),
];
@@ -51,6 +53,8 @@ fn main() {
struct Daemon {
child: Child,
dir: tempfile::TempDir,
#[cfg(windows)]
_job: job::Job,
}
impl Daemon {
@@ -67,7 +71,14 @@ impl Daemon {
.stderr(Stdio::null())
.spawn()
.expect("start the in-test tty7 server");
let daemon = Daemon { child, dir };
#[cfg(windows)]
let _job = job::Job::kill_on_close(&child);
let daemon = Daemon {
child,
dir,
#[cfg(windows)]
_job,
};
daemon.await_ready();
daemon
}
@@ -153,6 +164,56 @@ impl Drop for Daemon {
}
}
#[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()
}
@@ -211,6 +272,74 @@ fn run_streams_output_and_passes_the_exit_code(daemon: &Daemon) {
);
}
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 status_answers_over_tty7_socket_alone(daemon: &Daemon) {
let out = Command::new(env!("CARGO_BIN_EXE_tty7"))
.args(["status", "--json"])
.env_remove("TTY7_CONFIG_DIR")
.env_remove("TTY7_DATA_DIR")
.env_remove("TTY7_CONTROL_SOCK")
.env_remove("TTY7_PANE")
.env_remove("TTY7_WS")
.env("TTY7_SOCKET", daemon.control_endpoint())
.output()
.expect("run tty7 status with only TTY7_SOCKET");
assert!(
out.status.success(),
"status over TTY7_SOCKET 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 isolated daemon TTY7_SOCKET points at: {status}"
);
}
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");