diff --git a/src/app.rs b/src/app.rs index 540d2d1..115af38 100644 --- a/src/app.rs +++ b/src/app.rs @@ -30,7 +30,6 @@ use crate::{ Command, Event, Key, Lifecycle, Mods, MouseBtn, MouseKind, ScreenView, ScrollAction, TaskView, }, - supervisor::Supervisor, transport::{ExitIntent, SocketTransport, ThreadTransport, Transport}, ui, }; @@ -199,7 +198,7 @@ pub struct App { term_signal: Arc, should_quit: bool, /// How to leave when `should_quit` fires: `q`/Ctrl-C/signals disconnect - /// (daemon + jobs survive), `Q` quits and kills. Defaults to the safe + /// (daemon + tasks survive), `Q` quits and kills. Defaults to the safe /// `Disconnect` so an unexpected exit never reaps the daemon. exit_intent: ExitIntent, /// Whether the client currently captures terminal mouse events. @@ -254,7 +253,7 @@ fn bucket(v: &TaskView) -> u8 { impl App { /// Default client: connect to the daemon (autostarting it if needed) and - /// complete the hello handshake, so jobs outlive the UI and run under + /// complete the hello handshake, so tasks outlive the UI and run under /// *this* client's env. The core lives in `fleetcom --daemon`, reached over /// the socket. pub fn connect(rows: u16, cols: u16) -> io::Result { @@ -300,10 +299,7 @@ impl App { /// non-daemon escape hatch, and the deterministic target the UI harnesses use. pub fn new_foreground(rows: u16, cols: u16) -> App { App::assemble(rows, cols, |pr, c, wait_tx| { - Box::new(ThreadTransport::spawn( - Supervisor::new(pr, c, crate::supervisor::resolve_scrollback()), - wait_tx, - )) + Box::new(ThreadTransport::foreground(pr, c, wait_tx)) }) } @@ -646,7 +642,7 @@ impl App { } if self.term_signal.load(Ordering::Relaxed) { - // A terminating signal detaches: the daemon keeps the jobs. + // A terminating signal detaches: the daemon keeps the tasks. self.exit_intent = ExitIntent::Disconnect; self.should_quit = true; } @@ -888,7 +884,7 @@ impl App { // Any key dismisses a lingering save/load notice. self.status = None; // Global escape hatch, except while attached (Ctrl-C belongs to the child). - // Ctrl-C disconnects: it leaves the daemon and jobs running. + // Ctrl-C disconnects: it leaves the daemon and tasks running. if self.mode != Mode::Attached && k.code == KeyCode::Char('c') && k.modifiers.contains(KeyModifiers::CONTROL) @@ -924,7 +920,7 @@ impl App { fn on_key_dashboard(&mut self, k: KeyEvent) { match k.code { - // `q` detaches (daemon + jobs live on); `Q` kills all and stops it. + // `q` detaches (daemon + tasks live on); `Q` kills all and stops it. KeyCode::Char('q') => { self.exit_intent = ExitIntent::Disconnect; self.should_quit = true; @@ -1355,12 +1351,12 @@ impl App { } /// Leave, per `exit_intent`: `Disconnect` detaches and the daemon keeps the - /// jobs running; `Quit` group-kills every job and stops the daemon. Against + /// tasks running; `Quit` group-kills every task and stops the daemon. Against /// an in-process core (`--foreground`) both kill everything: there's no /// daemon to outlive the UI. fn shutdown(&mut self) { // Blocks until the transport has acted on the intent. On `Quit` the - // jobs are dead before `main` restores the terminal; on `Disconnect` the + // tasks are dead before `main` restores the terminal; on `Disconnect` the // daemon keeps running. self.transport.shutdown(self.exit_intent); } @@ -1485,1665 +1481,5 @@ fn list_dirs(base: &Path, partial: &str) -> Vec { } #[cfg(test)] -mod tests { - use super::*; - use crate::{ - testutil::{temp, wait_until}, - transport::LocalTransport, - ui::scroll_window, - }; - - impl App { - /// A synchronous App: the supervisor ticks inline on `poll`, so `send` - /// then `pump` is deterministic with no core-thread timing to race. - /// Uses this process's launch context. - fn new_local(rows: u16, cols: u16) -> App { - App::assemble(rows, cols, |pr, c, _wait_tx| { - let mut sup = Supervisor::new(pr, c, 2000); - sup.set_launch_context(crate::protocol::LaunchContext::here()); - Box::new(LocalTransport::new(sup)) - }) - } - - /// `new_local` with an explicit launch context, for tests that must pin - /// the core's session root instead of inheriting this process's env. - fn new_local_with_ctx(rows: u16, cols: u16, ctx: crate::protocol::LaunchContext) -> App { - App::assemble(rows, cols, move |pr, c, _wait_tx| { - let mut sup = Supervisor::new(pr, c, 2000); - sup.set_launch_context(ctx); - Box::new(LocalTransport::new(sup)) - }) - } - - /// Drive one core sync so `views` reflects the latest spawns and reaps: - /// the test-side equivalent of one run-loop tick. - fn pump(&mut self) { - self.sync(); - } - - fn spawn_in(&mut self, cmd: &str, cwd: PathBuf) { - self.spawn_checked(cmd, cwd, None); - } - - fn spawn_grouped(&mut self, cmd: &str, cwd: PathBuf, group: &str) { - self.spawn_checked(cmd, cwd, Some(group)); - } - - /// Send a Spawn and confirm the task landed, retrying a transient - /// refusal. Under parallel-suite PTY churn, openpty itself can fail - /// (observed on macOS as ENXIO); the supervisor answers with a Status - /// notice and no task, and every assertion after that fails without - /// naming the cause. Ids stay stable across retries: the supervisor - /// advances `next_id` only on success. - fn spawn_checked(&mut self, cmd: &str, cwd: PathBuf, group: Option<&str>) { - self.pump(); - let want = self.views.len() + 1; - for attempt in 0u64..5 { - if attempt > 0 { - std::thread::sleep(std::time::Duration::from_millis(25 << attempt)); - } - self.transport.send(Command::Spawn { - command: cmd.to_string(), - cwd: cwd.clone(), - group: group.map(str::to_string), - }); - self.pump(); - if self.views.len() == want { - // Drop the refusal notice a failed attempt left behind so - // status assertions see only their own test's traffic. - if attempt > 0 { - self.status = None; - } - return; - } - } - panic!("spawn never landed: {:?}", self.status); - } - - /// Return section labels with task ids instead of `views` indices. - fn section_ids(&self) -> Vec<(String, Vec)> { - self.sections() - .into_iter() - .map(|(l, idxs)| (l, idxs.into_iter().map(|i| self.views[i].id).collect())) - .collect() - } - } - - /// Selection is bound to a task id, so a reorder (here: tagging a task into - /// the "In use" bucket) must not move the highlight to a different task. - #[test] - fn selection_follows_task_across_reorder() { - let mut app = App::new_local(30, 100); - let dir = app.invocation_dir.clone(); - app.spawn_in("sleep 5", dir.clone()); // id 1 - app.spawn_in("sleep 5", dir); // id 2 - app.pump(); - app.resolve_selection(); - assert_eq!(app.selected_id, Some(1)); - - // Tag id 2 -> it sorts into the "In use" bucket, ahead of id 1. - app.transport.send(Command::Tag { id: 2, on: true }); - app.pump(); - - let order = app.display_order(); - assert_eq!(app.views[order[0]].id, 2, "tagged task should sort first"); - - // Still on id 1, even though it is now the second row. - assert_eq!(app.selected_id, Some(1)); - assert_eq!(app.views[app.selected_task().unwrap()].id, 1); - } - - /// Dir mode makes one section per distinct cwd (invocation dir first); state - /// mode collapses them back into the state buckets. - #[test] - fn dir_mode_groups_by_cwd() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_in("sleep 5", inv); // id 1, invocation dir - app.spawn_in("sleep 5", PathBuf::from("/tmp")); // id 2, /tmp - app.pump(); - - app.group_mode = GroupMode::State; - let s = app.sections(); - assert_eq!(s.len(), 1); - assert_eq!(s[0].0, "Running"); - assert_eq!(s[0].1.len(), 2); - - app.group_mode = GroupMode::Dir; - let s = app.sections(); - assert_eq!(s.len(), 2, "one section per distinct cwd"); - assert_eq!(s[0].0, app.invocation_label, "invocation dir sorts first"); - assert_eq!(s[1].0, "/tmp"); - } - - /// `s` cycles through all grouping modes. - #[test] - fn group_mode_cycles_state_dir_custom() { - assert_eq!(GroupMode::State.next(), GroupMode::Dir); - assert_eq!(GroupMode::Dir.next(), GroupMode::Custom); - assert_eq!(GroupMode::Custom.next(), GroupMode::State); - - let mut app = App::new_local(30, 100); - assert_eq!(app.group_mode, GroupMode::State); - for expect in [GroupMode::Dir, GroupMode::Custom, GroupMode::State] { - app.on_key_dashboard(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); - assert_eq!(app.group_mode, expect); - } - } - - /// Custom mode sorts named sections alphabetically and Unassigned last. - #[test] - fn custom_mode_groups_by_name_with_unassigned_last() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_grouped("sleep 5", inv.clone(), "beta"); // id 1 - app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 2 - app.spawn_in("sleep 5", inv); // id 3, no group - app.pump(); - - app.group_mode = GroupMode::Custom; - assert_eq!( - app.section_ids(), - vec![ - ("alpha".to_string(), vec![2]), - ("beta".to_string(), vec![1]), - ("Unassigned".to_string(), vec![3]), - ] - ); - } - - /// Custom mode does not emit empty group sections. - #[test] - fn custom_mode_unassigned_tracks_membership() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 - app.pump(); - app.group_mode = GroupMode::Custom; - assert_eq!(app.section_ids(), vec![("alpha".to_string(), vec![1])]); - - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_in("sleep 5", inv); // id 1, no group - app.pump(); - app.group_mode = GroupMode::Custom; - assert_eq!(app.section_ids(), vec![("Unassigned".to_string(), vec![1])]); - } - - /// In Custom mode, tagged tasks sort first within their existing group. - #[test] - fn custom_mode_tag_floats_within_group() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 - app.spawn_grouped("sleep 5", inv, "alpha"); // id 2 - app.pump(); - app.group_mode = GroupMode::Custom; - assert_eq!(app.section_ids(), vec![("alpha".to_string(), vec![1, 2])]); - - app.transport.send(Command::Tag { id: 2, on: true }); - app.pump(); - assert_eq!( - app.section_ids(), - vec![("alpha".to_string(), vec![2, 1])], - "tag floats id 2 to the top of alpha, not into an In use section" - ); - } - - /// Group reassignment can reorder sections without changing the selected id. - #[test] - fn custom_mode_selection_survives_group_move() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 - app.spawn_grouped("sleep 5", inv, "beta"); // id 2 - app.pump(); - app.group_mode = GroupMode::Custom; - app.resolve_selection(); - assert_eq!(app.selected_id, Some(1)); - - // Move id 1 from the first section to the last. - app.transport.send(Command::SetGroup { - id: 1, - group: Some("zeta".to_string()), - }); - app.pump(); - assert_eq!( - app.section_ids(), - vec![("beta".to_string(), vec![2]), ("zeta".to_string(), vec![1]),] - ); - - // Selection remains on id 1 in its new section. - assert_eq!(app.selected_id, Some(1)); - assert_eq!(app.views[app.selected_task().unwrap()].id, 1); - } - - /// Within a group, tasks cluster by directory and then by spawn order. - #[test] - fn custom_mode_clusters_by_dir_within_group() { - let mut app = App::new_local(30, 100); - let base = temp("app_cg"); - let (dir_a, dir_b) = (base.join("a"), base.join("b")); - std::fs::create_dir_all(&dir_a).unwrap(); - std::fs::create_dir_all(&dir_b).unwrap(); - - app.spawn_grouped("sleep 5", dir_b.clone(), "alpha"); // id 1, dir b - app.spawn_grouped("sleep 5", dir_a.clone(), "alpha"); // id 2, dir a - app.spawn_grouped("sleep 5", dir_a, "alpha"); // id 3, dir a - app.pump(); - - app.group_mode = GroupMode::Custom; - assert_eq!( - app.section_ids(), - vec![("alpha".to_string(), vec![2, 3, 1])], - "dir a's tasks cluster (in id order) ahead of dir b's" - ); - let _ = std::fs::remove_dir_all(&base); - } - - /// A manual tag must pull a task out of Completed into In use, even after it - /// has exited. - #[test] - fn tagging_a_finished_task_moves_it_to_in_use() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_in("true", inv); // exits ~immediately - wait_until(Duration::from_secs(5), || { - app.pump(); - app.views - .first() - .map(|v| matches!(v.lifecycle, Lifecycle::Ok | Lifecycle::Failed)) - .unwrap_or(false) - }); - assert!(matches!( - app.views[0].lifecycle, - Lifecycle::Ok | Lifecycle::Failed - )); - assert_eq!(app.sections()[0].0, "Completed"); - - let id = app.views[0].id; - app.transport.send(Command::Tag { id, on: true }); - app.pump(); - assert_eq!(app.sections()[0].0, "In use"); - } - - /// A parked live task gets its own "Idle" section between "Running" and - /// "Completed". `parked` is flipped on the local snapshot here (and in the - /// tests below): the core's 10 s quiet window is exactly what a test must - /// not wait out, and `sections` is pure over `views`: the flip must come - /// after the last pump, or a fresh snapshot overwrites it. - #[test] - fn parked_task_lands_in_idle_between_running_and_completed() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_in("sleep 5", inv.clone()); // id 1: running - app.spawn_in("sleep 5", inv.clone()); // id 2: parked below - app.spawn_in("sleep 5", inv.clone()); // id 3: tagged below - app.spawn_in("true", inv); // id 4: exits ~immediately - wait_until(Duration::from_secs(5), || { - app.pump(); - app.views - .iter() - .any(|v| v.id == 4 && matches!(v.lifecycle, Lifecycle::Ok | Lifecycle::Failed)) - }); - app.transport.send(Command::Tag { id: 3, on: true }); - app.pump(); - - let i = app.views.iter().position(|v| v.id == 2).unwrap(); - app.views[i].parked = true; - - assert_eq!( - app.section_ids(), - vec![ - ("In use".to_string(), vec![3]), - ("Running".to_string(), vec![1]), - ("Idle".to_string(), vec![2]), - ("Completed".to_string(), vec![4]), - ] - ); - } - - /// Tagged beats parked: a tagged task stays in "In use" even while parked. - #[test] - fn tagged_parked_task_stays_in_use() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_in("sleep 5", inv.clone()); // id 1: tagged + parked - app.spawn_in("sleep 5", inv); // id 2: running - app.pump(); - app.transport.send(Command::Tag { id: 1, on: true }); - app.pump(); - - let i = app.views.iter().position(|v| v.id == 1).unwrap(); - app.views[i].parked = true; - - assert_eq!( - app.section_ids(), - vec![ - ("In use".to_string(), vec![1]), - ("Running".to_string(), vec![2]), - ] - ); - } - - /// One window drives both signals, so a live core ships `Lifecycle::Idle` - /// and `parked` together: an idle-glyph task lands in the "Idle" section - /// under state grouping. Glyph and placement agree. - #[test] - fn idle_glyph_task_lands_in_idle_section() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_in("sleep 5", inv); // id 1 - app.pump(); - - let i = app.views.iter().position(|v| v.id == 1).unwrap(); - app.views[i].lifecycle = Lifecycle::Idle; - app.views[i].parked = true; - - assert_eq!(app.section_ids(), vec![("Idle".to_string(), vec![1])]); - } - - /// Selection is bound to a task id, so a `parked` flip (re-bucketing the - /// row from "Running" into "Idle") must not move the highlight to a - /// different task. - #[test] - fn selection_follows_task_across_parked_rebucket() { - let mut app = App::new_local(30, 100); - let dir = app.invocation_dir.clone(); - app.spawn_in("sleep 5", dir.clone()); // id 1 - app.spawn_in("sleep 5", dir); // id 2 - app.pump(); - app.resolve_selection(); - assert_eq!(app.selected_id, Some(1)); - - // Park id 1 -> it sinks into "Idle", below id 2's "Running". - let i = app.views.iter().position(|v| v.id == 1).unwrap(); - app.views[i].parked = true; - - let order = app.display_order(); - assert_eq!(app.views[order[0]].id, 2, "running task should sort first"); - - // Still on id 1, even though it is now the second row. - assert_eq!(app.selected_id, Some(1)); - assert_eq!(app.views[app.selected_task().unwrap()].id, 1); - } - - /// `bucket` doubles as the within-group tiebreak, so a parked task sinks - /// below a running one inside a Custom group too, not only in State mode. - #[test] - fn custom_mode_parked_sinks_within_group() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 - app.spawn_grouped("sleep 5", inv, "alpha"); // id 2 - app.pump(); - app.group_mode = GroupMode::Custom; - assert_eq!(app.section_ids(), vec![("alpha".to_string(), vec![1, 2])]); - - let i = app.views.iter().position(|v| v.id == 1).unwrap(); - app.views[i].parked = true; - assert_eq!( - app.section_ids(), - vec![("alpha".to_string(), vec![2, 1])], - "parked id 1 sinks below running id 2 within alpha" - ); - } - - /// `r` sends `Restart` only for a finished selection. On a running task - /// the key is a client-side no-op: nothing crosses the transport, so no - /// supervisor complaint lands in the status line. On a finished one the - /// same row (same id) comes back to life and the command re-executes. - #[test] - fn rerun_key_is_gated_to_finished_tasks() { - let mut app = App::new_local(30, 100); - let dir = temp("app_rerun"); - let marker = dir.join("marker"); - app.spawn_in("sleep 30", dir.clone()); // id 1: stays running - app.spawn_in(&format!("echo run >> {}", marker.display()), dir.clone()); // id 2 - wait_until(Duration::from_secs(5), || { - app.pump(); - app.views - .iter() - .any(|v| v.id == 2 && matches!(v.lifecycle, Lifecycle::Ok)) - }); - - // Running selection: `r` must send nothing (and thus kill nothing). - app.selected_id = Some(1); - app.on_key_dashboard(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); - app.pump(); - assert!(app.status.is_none(), "no Restart should have been sent"); - assert!( - app.views - .iter() - .any(|v| v.id == 1 && matches!(v.lifecycle, Lifecycle::Active | Lifecycle::Idle)), - "the running task must be untouched" - ); - - // Finished selection: `r` reruns it under the same id. - app.selected_id = Some(2); - app.on_key_dashboard(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); - wait_until(Duration::from_secs(5), || { - app.pump(); - std::fs::read_to_string(&marker) - .map(|s| s.lines().count() == 2) - .unwrap_or(false) - }); - assert_eq!( - std::fs::read_to_string(&marker).unwrap().lines().count(), - 2, - "rerun must re-execute the command" - ); - assert!( - app.views.iter().any(|v| v.id == 2), - "rerun must keep the id" - ); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Scratch config dir with the given pre-written (empty) session recipes. - fn session_scratch(tag: &str, names: &[&str]) -> PathBuf { - let dir = temp(&format!("app_{tag}")); - std::fs::create_dir_all(dir.join("sessions")).unwrap(); - for n in names { - std::fs::write(dir.join("sessions").join(format!("{n}.json")), "{}").unwrap(); - } - dir - } - - /// An App whose core's session root is pinned to `dir` via the launch - /// context, so these tests never read this process's real config dir. - fn app_with_config_dir(dir: &Path) -> App { - App::new_local_with_ctx( - 30, - 100, - crate::protocol::LaunchContext { - env: vec![( - "FLEETCOM_CONFIG_DIR".into(), - dir.to_path_buf().into_os_string(), - )], - cwd: dir.to_path_buf(), - }, - ) - } - - /// `o` never touches the local filesystem: it sends `ListSessions`, opens - /// the picker empty, and the core's `Sessions` reply fills it. - #[test] - fn o_key_round_trips_the_session_list_through_the_core() { - let dir = session_scratch("sess_list", &["b", "a"]); - let mut app = app_with_config_dir(&dir); - app.session_sel = 3; // stale from a previous picker visit - - app.on_key_dashboard(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); - assert!(matches!(app.mode, Mode::LoadSession)); - assert!( - app.session_names.is_empty(), - "the picker opens empty until the reply lands" - ); - assert_eq!( - app.session_sel, 0, - "opening the picker resets the selection" - ); - - app.pump(); - assert_eq!( - app.session_names, - vec!["a".to_string(), "b".to_string()], - "the Sessions reply populates the picker, sorted" - ); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A shorter list arriving while the picker is open clamps the selection so - /// Enter cannot index past the new end. - #[test] - fn session_selection_clamps_when_a_shorter_list_arrives() { - let dir = session_scratch("sess_clamp", &["a", "b", "c"]); - let mut app = app_with_config_dir(&dir); - app.on_key_dashboard(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); - app.pump(); - assert_eq!(app.session_names.len(), 3); - app.session_sel = 2; - - // Two recipes vanish; a refresh lands while the picker is still open. - std::fs::remove_file(dir.join("sessions").join("b.json")).unwrap(); - std::fs::remove_file(dir.join("sessions").join("c.json")).unwrap(); - app.transport.send(Command::ListSessions); - app.pump(); - assert_eq!(app.session_names, vec!["a".to_string()]); - assert_eq!(app.session_sel, 0, "selection must clamp to the new length"); - - // The empty list parks the selection at 0 too. - std::fs::remove_file(dir.join("sessions").join("a.json")).unwrap(); - app.transport.send(Command::ListSessions); - app.pump(); - assert!(app.session_names.is_empty()); - assert_eq!(app.session_sel, 0); - let _ = std::fs::remove_dir_all(&dir); - } - - /// The `@` recent list is the distinct task cwds, newest first. - #[test] - fn recent_dirs_are_distinct_and_newest_first() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_in("sleep 5", PathBuf::from("/tmp")); // id 1 /tmp - app.spawn_in("sleep 5", inv.clone()); // id 2 invocation - app.spawn_in("sleep 5", PathBuf::from("/tmp")); // id 3 /tmp (dup) - app.pump(); - - let dirs = app.in_use_dirs(); - assert_eq!(dirs.len(), 2, "duplicate dirs collapse"); - assert_eq!(dirs[0], PathBuf::from("/tmp"), "newest first"); - assert_eq!(dirs[1], inv); - } - - #[test] - fn picker_puts_current_dir_first_and_selected() { - let mut app = App::new_local(30, 100); - app.dir_input.clear(); - app.refresh_dir_candidates(); - assert_eq!(app.dir_sel, 0, "current dir selected by default"); - assert_eq!(app.dir_candidates[0].kind, DirKind::Use); - assert_eq!(app.dir_candidates[0].path, app.invocation_dir); - } - - /// Focus is by id, so it points at the same task even after the list shifts - /// (a lower-id task is removed) and reports gone once it's removed. - #[test] - fn focus_by_id_survives_index_shift() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_in("sleep 5", inv.clone()); // id 1 - app.spawn_in("sleep 5", inv); // id 2 - app.pump(); - app.focused_id = Some(2); - assert_eq!(app.views[app.focused_task().unwrap()].id, 2); - - app.transport.send(Command::Remove { id: 1 }); // id 2 slides to index 0 - app.pump(); - assert_eq!(app.views[app.focused_task().unwrap()].id, 2); - - app.transport.send(Command::Remove { id: 2 }); - app.pump(); - assert!(app.focused_task().is_none()); - } - - /// The flat row list interleaves each section header with its tasks, in - /// section order: what the dashboard's scroll window slides over. - #[test] - fn rows_interleave_headers_and_tasks() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_in("a", inv.clone()); // id 1, invocation dir - app.spawn_in("b", PathBuf::from("/tmp")); // id 2, /tmp - app.pump(); - - app.group_mode = GroupMode::Dir; - let rows = app.list_rows(); - assert_eq!(rows.len(), 4, "two sections, one task each"); - assert_eq!(rows[0], Row::Section(app.invocation_label.clone())); - assert!(matches!(rows[1], Row::Task(i) if app.views[i].id == 1)); - assert_eq!(rows[2], Row::Section("/tmp".to_string())); - assert!(matches!(rows[3], Row::Task(i) if app.views[i].id == 2)); - } - - /// A fleet taller than the list region must keep the selected row inside - /// the scroll window at every step: the dashboard equivalent of the picker - /// guarantee, over the composed header+task row list. - #[test] - fn dashboard_selection_stays_in_scroll_window() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - for _ in 0..8 { - app.spawn_in("sleep 5", inv.clone()); - } - app.pump(); - app.resolve_selection(); - - // A 4-row window over 9 rows (1 header + 8 tasks): walking the whole - // list down and back up must never let the selection leave the window. - let height = 4; - for step in 0..10 { - app.select_down(); - let rows = app.list_rows(); - let sel = app.selected_row(&rows).expect("selection always resolves"); - let (start, count) = scroll_window(sel, rows.len(), height); - assert!( - sel >= start && sel < start + count, - "step {step}: row {sel} outside window ({start}, {count})" - ); - } - for step in 0..10 { - app.select_up(); - let rows = app.list_rows(); - let sel = app.selected_row(&rows).expect("selection always resolves"); - let (start, count) = scroll_window(sel, rows.len(), height); - assert!( - sel >= start && sel < start + count, - "step {step}: row {sel} outside window ({start}, {count})" - ); - } - } - - /// Selection wraps at the list edges: up from the first task lands on the - /// last and down from the last lands on the first, across the section - /// boundary. `display_order` is flat, so headers never trap the cursor. - #[test] - fn selection_wraps_at_list_edges() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_in("sleep 5", inv.clone()); // id 1 - app.spawn_in("sleep 5", inv.clone()); // id 2 - app.spawn_in("sleep 5", inv); // id 3 - app.pump(); - - // Tag id 2 -> it sorts into a leading "In use" section, so the wrap - // below crosses a section boundary. - app.transport.send(Command::Tag { id: 2, on: true }); - app.pump(); - app.resolve_selection(); - assert_eq!(app.section_ids().len(), 2, "tag splits the list in two"); - - let order = app.display_order(); - let first = app.views[order[0]].id; - let last = app.views[*order.last().unwrap()].id; - assert_eq!(first, 2, "tagged task sorts first"); - assert_eq!(app.selected_id, Some(first)); - - app.select_up(); - assert_eq!( - app.selected_id, - Some(last), - "up from the first wraps to the last" - ); - app.select_down(); - assert_eq!( - app.selected_id, - Some(first), - "down from the last wraps to the first" - ); - } - - /// With a single task, wrap degrades to a no-op in both directions. - #[test] - fn selection_wrap_is_noop_with_one_task() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_in("sleep 5", inv); - app.pump(); - app.resolve_selection(); - assert_eq!(app.selected_id, Some(1)); - - app.select_up(); - assert_eq!(app.selected_id, Some(1)); - app.select_down(); - assert_eq!(app.selected_id, Some(1)); - } - - /// Build two two-task sections: In use [3, 4] and Running [1, 2]. - fn app_with_two_sections() -> App { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - for _ in 0..4 { - app.spawn_in("sleep 5", inv.clone()); - } - app.pump(); - app.transport.send(Command::Tag { id: 3, on: true }); - app.transport.send(Command::Tag { id: 4, on: true }); - app.pump(); - assert_eq!( - app.section_ids(), - vec![ - ("In use".to_string(), vec![3, 4]), - ("Running".to_string(), vec![1, 2]), - ], - "tags split the list into two two-task sections" - ); - app - } - - /// Next-section navigation selects its first task and wraps forward. - #[test] - fn tab_jumps_to_next_section_first_task() { - let mut app = app_with_two_sections(); - - // From In use, select Running's first task. - app.selected_id = Some(4); - app.select_next_section(); - assert_eq!( - app.selected_id, - Some(1), - "next section's first, not same offset" - ); - - // From the last section, wrap to the first task in In use. - app.selected_id = Some(2); - app.select_next_section(); - assert_eq!(app.selected_id, Some(3), "forward from last section wraps"); - } - - /// Previous-section navigation selects its first task and wraps backward. - #[test] - fn backtab_jumps_to_previous_section_first_task() { - let mut app = app_with_two_sections(); - - // From Running, select In use's first task. - app.selected_id = Some(2); - app.select_prev_section(); - assert_eq!( - app.selected_id, - Some(3), - "previous section's first, not current's" - ); - - // From the first section, wrap to Running's first task. - app.selected_id = Some(3); - app.select_prev_section(); - assert_eq!( - app.selected_id, - Some(1), - "backward from first section wraps" - ); - } - - /// Section navigation keeps a single task selected. - #[test] - fn section_nav_is_noop_with_one_task() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_in("sleep 5", inv); - app.pump(); - app.resolve_selection(); - assert_eq!(app.selected_id, Some(1)); - - app.select_next_section(); - assert_eq!(app.selected_id, Some(1)); - app.select_prev_section(); - assert_eq!(app.selected_id, Some(1)); - } - - /// Without a selection, navigation chooses the boundary section. - #[test] - fn section_nav_defaults_without_selection() { - let mut app = app_with_two_sections(); - - app.selected_id = None; - app.select_next_section(); - assert_eq!( - app.selected_id, - Some(3), - "no selection: first section's first" - ); - - app.selected_id = None; - app.select_prev_section(); - assert_eq!( - app.selected_id, - Some(1), - "no selection: last section's first" - ); - - let mut empty = App::new_local(30, 100); - empty.select_next_section(); - assert_eq!(empty.selected_id, None); - empty.select_prev_section(); - assert_eq!(empty.selected_id, None); - } - - /// Supported crossterm keys and modifiers map to their wire representation. - #[test] - fn key_event_maps_to_semantic_key() { - assert_eq!( - key_event_to_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)), - Some((Key::Char('a'), Mods::default())) - ); - assert_eq!( - key_event_to_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)), - Some((Key::F(5), Mods::default())) - ); - assert_eq!( - key_event_to_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SHIFT)), - Some(( - Key::Char('a'), - Mods { - shift: true, - ..Mods::default() - } - )) - ); - assert_eq!( - key_event_to_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)), - Some(( - Key::Char('a'), - Mods { - ctrl: true, - ..Mods::default() - } - )) - ); - assert_eq!( - key_event_to_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT)), - Some(( - Key::Char('a'), - Mods { - alt: true, - ..Mods::default() - } - )) - ); - assert_eq!( - key_event_to_key(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT)), - Some(( - Key::Left, - Mods { - alt: true, - ..Mods::default() - } - )) - ); - } - - /// Unsupported modifier bits are ignored; unsupported key codes are dropped. - #[test] - fn unencodable_modifiers_and_keys_are_dropped() { - assert_eq!( - key_event_to_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER)), - Some((Key::Char('a'), Mods::default())) - ); - assert_eq!( - key_event_to_key(KeyEvent::new(KeyCode::CapsLock, KeyModifiers::NONE)), - None - ); - assert_eq!( - key_event_to_key(KeyEvent::new(KeyCode::Null, KeyModifiers::NONE)), - None - ); - } - - /// An oversized attached paste is refused before it closes the connection. - #[test] - fn oversized_paste_is_refused_with_a_notice() { - let mut app = App::new_local(30, 100); - app.mode = Mode::Attached; - app.focused_id = Some(1); - app.on_paste(&"x".repeat(MAX_PASTE + 1)); - let status = app.status.clone().unwrap_or_default(); - assert!(status.contains("paste dropped"), "status was {status:?}"); - // The boundary value is accepted. - app.on_paste(&"x".repeat(MAX_PASTE)); - assert!(app.status.is_none(), "boundary paste must not be refused"); - } - - /// A paste into a text-entry mode lands as one string with control - /// characters stripped: a multi-line clipboard must not fake the Enter - /// press that would launch a half-pasted command. - #[test] - fn paste_into_text_entry_strips_controls() { - let mut app = App::new_local(30, 100); - app.mode = Mode::Spawn; - app.on_paste("cargo\ttest\r\n --all"); - assert_eq!(app.input.as_str(), "cargotest --all"); - assert!(app.mode == Mode::Spawn, "paste must not submit"); - } - - /// Select mouse capture by screen type. - #[test] - fn input_modes_match_screen_type() { - let screen = |wants_mouse, alt_screen, alt_scroll| ScreenView { - id: 1, - lines: Vec::new(), - formatted: Vec::new(), - cursor: (0, 0), - hide_cursor: false, - wants_mouse, - alt_screen, - alt_scroll, - scrollback: 0, - }; - // No attached screen: keep native selection available. - assert!(!desired_mouse_capture(None, false)); - // Mouse-aware child: capture. - assert!(desired_mouse_capture( - Some(&screen(true, true, true)), - false - )); - assert!(desired_mouse_capture( - Some(&screen(true, false, false)), - false - )); - // Full-screen child with alternate scroll enabled. - assert!(!desired_mouse_capture( - Some(&screen(false, true, true)), - false - )); - // Full-screen child with alternate scroll disabled. - assert!(desired_mouse_capture( - Some(&screen(false, true, false)), - false - )); - // Inline child: capture wheel-up to enter scrollback. - assert!(desired_mouse_capture( - Some(&screen(false, false, false)), - false - )); - // The scroll view overrides everything: the wheel must scroll it. - assert!(desired_mouse_capture( - Some(&screen(false, false, false)), - true - )); - assert!(desired_mouse_capture(None, true)); - } - - /// Wheel-up enters scrollback for inline children, but forwards for - /// mouse-aware children. - #[test] - fn wheel_up_enters_scroll_view_for_inline_children() { - let mut app = App::new_local(30, 100); - let dir = app.invocation_dir.clone(); - app.spawn_in("sleep 5", dir); - app.pump(); - app.resolve_selection(); - app.attach(); - let id = app.focused_id.expect("attached"); - let screen = |wants_mouse| ScreenView { - id, - lines: Vec::new(), - formatted: Vec::new(), - cursor: (0, 0), - hide_cursor: false, - wants_mouse, - alt_screen: false, - alt_scroll: false, - scrollback: 0, - }; - let wheel_up = MouseEvent { - kind: MouseEventKind::ScrollUp, - column: 0, - row: 0, - modifiers: KeyModifiers::NONE, - }; - - // Inline child: wheel-up enters scrollback. - app.focused_screen = Some(screen(false)); - app.on_mouse(wheel_up); - assert!(app.view_scroll, "wheel-up must open the scroll view"); - - // Mouse-aware child: wheel-up forwards instead. - app.view_scroll = false; - app.focused_screen = Some(screen(true)); - app.on_mouse(wheel_up); - assert!(!app.view_scroll, "mouse-aware children keep their wheel"); - } - - /// Attached wheel input follows the child's DECSET 1007 state. - #[test] - fn attached_wheel_honors_the_childs_1007_veto() { - let dir = temp("app_1007"); - - let wheel_up = MouseEvent { - kind: MouseEventKind::ScrollUp, - column: 0, - row: 0, - modifiers: KeyModifiers::NONE, - }; - // Send one wheel notch and return the first `take` bytes read by the child. - let run = |veto: bool, take: usize, out: PathBuf| -> Vec { - let mut app = App::new_local(30, 100); - let cwd = app.invocation_dir.clone(); - let modes = if veto { - "\\033[?1049h\\033[?1007l" - } else { - "\\033[?1049h" - }; - // Noncanonical input lets `head` read arrow sequences without a newline. - let cmd = format!( - "stty -icanon -echo min 1 time 0; printf '{modes}'; head -c {take} > {}", - out.display() - ); - app.spawn_in(&cmd, cwd); - app.pump(); - app.resolve_selection(); - app.attach(); - let id = app.focused_id.expect("attached"); - app.set_watch(Some(id)); - // Wait for the child's terminal modes to reach the client. - assert!( - wait_until(Duration::from_secs(5), || { - app.pump(); - matches!( - app.screen_for(id), - Some(s) if s.alt_screen && s.alt_scroll != veto - ) - }), - "gate state (veto: {veto}) never reached the client" - ); - // Disabled alternate scroll captures the wheel; enabled does not. - assert_eq!(desired_mouse_capture(app.screen_for(id), false), veto); - app.on_mouse(wheel_up); - if veto { - // The sentinel follows the wheel on the writer queue. - app.transport.send(Command::Input { - id, - bytes: b"zzz".to_vec(), - }); - } - let mut got = Vec::new(); - wait_until(Duration::from_secs(5), || { - got = std::fs::read(&out).unwrap_or_default(); - got.len() >= take - }); - got - }; - - // Disabled alternate scroll suppresses the wheel bytes. - assert_eq!(run(true, 3, dir.join("veto")), b"zzz".to_vec()); - // Enabled alternate scroll emits three arrows per notch. - assert_eq!( - run(false, 9, dir.join("dflt")), - b"\x1b[A\x1b[A\x1b[A".to_vec() - ); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Scrollback opens with modified PageUp and closes on Esc or typing. - #[test] - fn scroll_view_entry_and_exit() { - let mut app = App::new_local(30, 100); - let dir = app.invocation_dir.clone(); - app.spawn_in("sleep 5", dir); - app.pump(); - app.resolve_selection(); - app.attach(); - assert!(app.mode == Mode::Attached); - let mut out = io::stdout(); - - let key = |code| KeyEvent::new(code, KeyModifiers::NONE); - app.on_key_attached( - &mut out, - KeyEvent::new(KeyCode::PageUp, KeyModifiers::SHIFT), - ) - .unwrap(); - assert!(app.view_scroll, "Shift+PageUp must enter the scroll view"); - app.on_key_attached(&mut out, key(KeyCode::Esc)).unwrap(); - assert!(!app.view_scroll, "Esc must return to live"); - - app.on_key_attached( - &mut out, - KeyEvent::new(KeyCode::PageUp, KeyModifiers::CONTROL), - ) - .unwrap(); - assert!(app.view_scroll, "Ctrl+PageUp is an entry fallback"); - app.on_key_attached(&mut out, key(KeyCode::Char('x'))) - .unwrap(); - assert!(!app.view_scroll, "typing must snap back to live"); - - // Plain PageUp is forwarded to the child. - app.on_key_attached(&mut out, key(KeyCode::PageUp)).unwrap(); - assert!(!app.view_scroll); - } - - /// A wheel notch on the dashboard moves the selection like an arrow key. - #[test] - fn wheel_moves_dashboard_selection() { - let mut app = App::new_local(30, 100); - let dir = app.invocation_dir.clone(); - app.spawn_in("sleep 5", dir.clone()); // id 1 - app.spawn_in("sleep 5", dir); // id 2 - app.pump(); - app.resolve_selection(); - assert_eq!(app.selected_id, Some(1)); - - let wheel = |kind| MouseEvent { - kind, - column: 0, - row: 0, - modifiers: KeyModifiers::NONE, - }; - app.on_mouse(wheel(MouseEventKind::ScrollDown)); - assert_eq!(app.selected_id, Some(2)); - app.on_mouse(wheel(MouseEventKind::ScrollUp)); - assert_eq!(app.selected_id, Some(1)); - } - - // --- `g` group picker ------------------------------------------------- - - fn key(code: KeyCode) -> KeyEvent { - KeyEvent::new(code, KeyModifiers::NONE) - } - - fn ctrl(code: KeyCode) -> KeyEvent { - KeyEvent::new(code, KeyModifiers::CONTROL) - } - - /// `g` opens the picker only when a task is selected, pinning the target - /// to that task's id. - #[test] - fn group_picker_opens_on_g_only_with_a_selection() { - let mut app = App::new_local(30, 100); - app.on_key_dashboard(key(KeyCode::Char('g'))); - assert!(app.mode == Mode::Dashboard, "no selection: g must no-op"); - - let inv = app.invocation_dir.clone(); - app.spawn_in("sleep 5", inv); - app.pump(); - app.resolve_selection(); - app.on_key_dashboard(key(KeyCode::Char('g'))); - assert!(app.mode == Mode::PickGroup); - assert_eq!(app.group_target, Some(1)); - } - - /// Picker candidates are distinct byte-sorted groups after Unassigned, with - /// the target's assignment marked "(current)". - #[test] - fn group_candidates_are_distinct_sorted_and_marked() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_grouped("sleep 5", inv.clone(), "beta"); // id 1 - app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 2 - app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 3, dup group - app.spawn_in("sleep 5", inv); // id 4, no group - app.pump(); - - app.selected_id = Some(1); // group "beta" - app.on_key_dashboard(key(KeyCode::Char('g'))); - let labels: Vec<&str> = app - .group_candidates - .iter() - .map(|c| c.label.as_str()) - .collect(); - assert_eq!(labels, vec!["Unassigned", "alpha", "beta (current)"]); - let groups: Vec> = app - .group_candidates - .iter() - .map(|c| c.group.as_deref()) - .collect(); - assert_eq!(groups, vec![None, Some("alpha"), Some("beta")]); - assert_eq!(app.group_sel, 0, "nothing typed keeps the clear row"); - - // An ungrouped target marks the Unassigned row instead. - app.on_key_pickgroup(key(KeyCode::Esc)); - app.selected_id = Some(4); - app.on_key_dashboard(key(KeyCode::Char('g'))); - assert_eq!(app.group_candidates[0].label, "Unassigned (current)"); - } - - /// Typing applies a case-insensitive prefix filter and selects the first - /// match; Backspace expands the candidate set again. - #[test] - fn group_filter_narrows_and_preselects_the_first_match() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 - app.spawn_grouped("sleep 5", inv, "beta"); // id 2 - app.pump(); - app.resolve_selection(); - app.on_key_dashboard(key(KeyCode::Char('g'))); - assert_eq!(app.group_candidates.len(), 3); - - app.on_key_pickgroup(key(KeyCode::Char('B'))); - let labels: Vec<&str> = app - .group_candidates - .iter() - .map(|c| c.label.as_str()) - .collect(); - assert_eq!( - labels, - vec!["Unassigned", "beta"], - "case-insensitive prefix" - ); - assert_eq!(app.group_sel, 1, "filtering preselects the first match"); - - app.on_key_pickgroup(key(KeyCode::Char('z'))); - assert_eq!(app.group_candidates.len(), 1, "\"Bz\" matches nothing"); - assert_eq!(app.group_sel, 0); - - app.on_key_pickgroup(key(KeyCode::Backspace)); - assert_eq!(app.group_candidates.len(), 2, "backspace re-widens"); - } - - /// Enter assigns the highlighted candidate to the target task. - #[test] - fn group_enter_on_a_candidate_assigns_it() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 - app.spawn_in("sleep 5", inv); // id 2, no group - app.pump(); - - app.selected_id = Some(2); - app.on_key_dashboard(key(KeyCode::Char('g'))); - app.on_key_pickgroup(key(KeyCode::Char('a'))); // highlights "alpha" - app.on_key_pickgroup(key(KeyCode::Enter)); - assert!(app.mode == Mode::Dashboard); - app.pump(); - let v = app.views.iter().find(|v| v.id == 2).unwrap(); - assert_eq!(v.group.as_deref(), Some("alpha")); - } - - /// Enter creates the typed group when no candidate matches. - #[test] - fn group_enter_on_novel_text_creates_the_group() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 - app.pump(); - app.resolve_selection(); - app.on_key_dashboard(key(KeyCode::Char('g'))); - for c in "gamma".chars() { - app.on_key_pickgroup(key(KeyCode::Char(c))); - } - assert_eq!(app.group_candidates.len(), 1, "nothing matches"); - app.on_key_pickgroup(key(KeyCode::Enter)); - app.pump(); - let v = app.views.iter().find(|v| v.id == 1).unwrap(); - assert_eq!(v.group.as_deref(), Some("gamma")); - } - - /// Empty input selects Unassigned, so Enter clears the target's group. - #[test] - fn group_enter_on_empty_input_clears_to_unassigned() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 - app.pump(); - app.resolve_selection(); - app.on_key_dashboard(key(KeyCode::Char('g'))); - assert_eq!(app.group_sel, 0, "empty input highlights the clear row"); - app.on_key_pickgroup(key(KeyCode::Enter)); - app.pump(); - let v = app.views.iter().find(|v| v.id == 1).unwrap(); - assert_eq!(v.group, None); - } - - /// Esc closes the picker without changing the target task. - #[test] - fn group_esc_cancels_without_sending() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 - app.pump(); - app.resolve_selection(); - app.on_key_dashboard(key(KeyCode::Char('g'))); - for c in "gamma".chars() { - app.on_key_pickgroup(key(KeyCode::Char(c))); - } - app.on_key_pickgroup(key(KeyCode::Esc)); - assert!(app.mode == Mode::Dashboard); - assert!(app.group_input.is_empty() && app.group_candidates.is_empty()); - assert_eq!(app.group_target, None); - app.pump(); - let v = app.views.iter().find(|v| v.id == 1).unwrap(); - assert_eq!(v.group.as_deref(), Some("alpha"), "Esc must send nothing"); - } - - // --- `R` rename prompt -------------------------------------------------- - - /// The rename prompt captures the selected task ID and current name. - #[test] - fn rename_prompt_opens_on_shift_r_only_with_a_selection() { - let mut app = App::new_local(30, 100); - app.on_key_dashboard(key(KeyCode::Char('R'))); - assert!(app.mode == Mode::Dashboard, "no selection: R must no-op"); - assert_eq!(app.rename_target, None); - - let inv = app.invocation_dir.clone(); - app.spawn_in("sleep 5", inv); - app.pump(); - app.resolve_selection(); - app.on_key_dashboard(key(KeyCode::Char('R'))); - assert!(app.mode == Mode::Rename); - assert_eq!(app.rename_target, Some(1)); - assert_eq!(app.input.as_str(), "", "an unnamed task prefills empty"); - - // A named task prefills its name. - app.on_key_rename(key(KeyCode::Esc)); - app.transport.send(Command::SetName { - id: 1, - name: Some("api".to_string()), - }); - app.pump(); - app.on_key_dashboard(key(KeyCode::Char('R'))); - assert_eq!(app.input.as_str(), "api"); - } - - /// Enter sends the trimmed name and returns to the dashboard. - #[test] - fn rename_enter_sends_the_typed_name() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_in("sleep 5", inv); - app.pump(); - app.resolve_selection(); - app.on_key_dashboard(key(KeyCode::Char('R'))); - for c in "api server".chars() { - app.on_key_rename(key(KeyCode::Char(c))); - } - app.on_key_rename(key(KeyCode::Enter)); - assert!(app.mode == Mode::Dashboard); - assert!(app.input.is_empty() && app.rename_target.is_none()); - app.pump(); - let v = app.views.iter().find(|v| v.id == 1).unwrap(); - assert_eq!(v.name.as_deref(), Some("api server")); - } - - /// Enter on an empty input clears the name. - #[test] - fn rename_enter_on_empty_input_clears_the_name() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_in("sleep 5", inv); - app.transport.send(Command::SetName { - id: 1, - name: Some("api".to_string()), - }); - app.pump(); - app.resolve_selection(); - app.on_key_dashboard(key(KeyCode::Char('R'))); - assert_eq!(app.input.as_str(), "api"); - for _ in 0.."api".len() { - app.on_key_rename(key(KeyCode::Backspace)); - } - app.on_key_rename(key(KeyCode::Enter)); - app.pump(); - let v = app.views.iter().find(|v| v.id == 1).unwrap(); - assert_eq!(v.name, None); - } - - /// Esc closes the prompt without changing the target task. - #[test] - fn rename_esc_cancels_without_sending() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_in("sleep 5", inv); - app.transport.send(Command::SetName { - id: 1, - name: Some("api".to_string()), - }); - app.pump(); - app.resolve_selection(); - app.on_key_dashboard(key(KeyCode::Char('R'))); - for c in "junk".chars() { - app.on_key_rename(key(KeyCode::Char(c))); - } - app.on_key_rename(key(KeyCode::Esc)); - assert!(app.mode == Mode::Dashboard); - assert!(app.input.is_empty()); - assert_eq!(app.rename_target, None); - app.pump(); - let v = app.views.iter().find(|v| v.id == 1).unwrap(); - assert_eq!(v.name.as_deref(), Some("api"), "Esc must send nothing"); - } - - // --- prompt caret editing ---------------------------------------------- - - /// Left/Right, Ctrl-A/Ctrl-E, and Home/End reposition the caret in the - /// rename prompt, and edits land at it. - #[test] - fn rename_caret_keys_edit_mid_name() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_in("sleep 5", inv); - app.transport.send(Command::SetName { - id: 1, - name: Some("api".to_string()), - }); - app.pump(); - app.resolve_selection(); - app.on_key_dashboard(key(KeyCode::Char('R'))); - assert_eq!(app.input.as_str(), "api"); - - // Move after "a" and insert within the name. - app.on_key_rename(key(KeyCode::Left)); - app.on_key_rename(key(KeyCode::Left)); - app.on_key_rename(key(KeyCode::Char('x'))); - assert_eq!(app.input.as_str(), "axpi"); - - // Ctrl-A jumps to the start; typing prepends. - app.on_key_rename(ctrl(KeyCode::Char('a'))); - app.on_key_rename(key(KeyCode::Char('z'))); - assert_eq!(app.input.as_str(), "zaxpi"); - - // Ctrl-E returns to the end; typing appends. - app.on_key_rename(ctrl(KeyCode::Char('e'))); - app.on_key_rename(key(KeyCode::Char('!'))); - assert_eq!(app.input.as_str(), "zaxpi!"); - - // Backspace removes only the char before the caret. - app.on_key_rename(key(KeyCode::Left)); - app.on_key_rename(key(KeyCode::Backspace)); - assert_eq!(app.input.as_str(), "zaxp!"); - - // Home/End alias the chords. - app.on_key_rename(key(KeyCode::Home)); - app.on_key_rename(key(KeyCode::Char('0'))); - app.on_key_rename(key(KeyCode::End)); - app.on_key_rename(key(KeyCode::Char('9'))); - assert_eq!(app.input.as_str(), "0zaxp!9"); - } - - /// Unbound Ctrl chords do not insert their literal letters. - #[test] - fn ctrl_chords_never_insert_their_letter() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 - app.pump(); - app.resolve_selection(); - - app.on_key_dashboard(key(KeyCode::Char('w'))); - app.on_key_savesession(ctrl(KeyCode::Char('k'))); - assert!(app.input.is_empty(), "Ctrl-K must not insert into a prompt"); - app.on_key_savesession(key(KeyCode::Esc)); - - app.on_key_dashboard(key(KeyCode::Char('@'))); - app.on_key_pickdir(ctrl(KeyCode::Char('k'))); - assert!(app.dir_input.is_empty(), "Ctrl-K must not filter the dirs"); - app.on_key_pickdir(key(KeyCode::Esc)); - - app.on_key_dashboard(key(KeyCode::Char('g'))); - let before = app.group_candidates.len(); - app.on_key_pickgroup(ctrl(KeyCode::Char('k'))); - assert!( - app.group_input.is_empty(), - "Ctrl-K must not filter the groups" - ); - assert_eq!(app.group_candidates.len(), before); - } - - /// In the `@` picker, Right descends only when the caret sits at the end - /// of the typed path; off the end it is caret motion. - #[test] - fn pickdir_right_descends_only_from_the_end() { - let dir = temp("caret_pickdir"); - std::fs::create_dir_all(dir.join("alpha")).unwrap(); - let mut app = App::new_local(30, 100); - app.invocation_dir = dir.clone(); - - app.on_key_dashboard(key(KeyCode::Char('@'))); - for c in "al".chars() { - app.on_key_pickdir(key(KeyCode::Char(c))); - } - assert_eq!(app.dir_sel, 1, "the fragment preselects alpha"); - - // Off the end (one left), Right moves the caret without descending. - app.on_key_pickdir(key(KeyCode::Left)); - app.on_key_pickdir(key(KeyCode::Right)); - assert_eq!( - app.dir_input.as_str(), - "al", - "Right off-end must not descend" - ); - - // That Right returned the caret to the end, so the next one descends. - app.on_key_pickdir(key(KeyCode::Right)); - assert!( - app.dir_input.ends_with("alpha/"), - "Right at end descends: {:?}", - app.dir_input.as_str() - ); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Typing after caret motion still refreshes the `@` candidates; the - /// motion itself does not. - #[test] - fn pickdir_refreshes_on_edits_not_caret_motion() { - let dir = temp("caret_pickdir_refresh"); - std::fs::create_dir_all(dir.join("alpha")).unwrap(); - let mut app = App::new_local(30, 100); - app.invocation_dir = dir.clone(); - - app.on_key_dashboard(key(KeyCode::Char('@'))); - app.on_key_pickdir(key(KeyCode::Char('l'))); - assert_eq!(app.dir_candidates.len(), 1, "\"l\" matches nothing"); - - app.on_key_pickdir(key(KeyCode::Home)); - assert_eq!(app.dir_candidates.len(), 1, "caret motion must not refresh"); - - // "a" typed at the start makes the buffer "al": a match again. - app.on_key_pickdir(key(KeyCode::Char('a'))); - assert_eq!(app.dir_input.as_str(), "al"); - assert!( - app.dir_candidates.iter().any(|c| c.label == "alpha"), - "an edit at the caret refreshes the candidates" - ); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Caret-positioned edits refresh the group filter like end-of-line ones. - #[test] - fn pickgroup_caret_edits_refresh_the_filter() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 - app.spawn_grouped("sleep 5", inv, "beta"); // id 2 - app.pump(); - app.resolve_selection(); - app.on_key_dashboard(key(KeyCode::Char('g'))); - - app.on_key_pickgroup(key(KeyCode::Char('b'))); - assert_eq!(app.group_candidates.len(), 2, "\"b\" matches beta"); - - app.on_key_pickgroup(key(KeyCode::Left)); - assert_eq!( - app.group_candidates.len(), - 2, - "caret motion must not refresh" - ); - - // "a" before the 'b' makes the filter "ab": nothing matches now. - app.on_key_pickgroup(key(KeyCode::Char('a'))); - assert_eq!(app.group_input.as_str(), "ab"); - assert_eq!(app.group_candidates.len(), 1, "the caret edit re-filtered"); - } - - /// A paste inserts at the caret and leaves the caret after the pasted text. - #[test] - fn paste_lands_at_the_caret() { - let mut app = App::new_local(30, 100); - app.mode = Mode::Spawn; - for c in "cargo t".chars() { - app.on_key_spawn(key(KeyCode::Char(c))); - } - app.on_key_spawn(key(KeyCode::Left)); // caret before 't' - app.on_paste("x\ny"); // control chars still stripped - assert_eq!(app.input.as_str(), "cargo xyt"); - app.on_key_spawn(key(KeyCode::Char('z'))); - assert_eq!( - app.input.as_str(), - "cargo xyzt", - "caret sits after the paste" - ); - } - - /// Multibyte characters move, insert, and delete as whole units. - #[test] - fn multibyte_chars_edit_cleanly_in_a_prompt() { - let mut app = App::new_local(30, 100); - app.on_key_dashboard(key(KeyCode::Char('w'))); - app.on_key_savesession(key(KeyCode::Char('é'))); - app.on_key_savesession(key(KeyCode::Left)); - app.on_key_savesession(key(KeyCode::Char('日'))); - assert_eq!(app.input.as_str(), "日é"); - app.on_key_savesession(key(KeyCode::Backspace)); - assert_eq!(app.input.as_str(), "é"); - app.on_key_savesession(key(KeyCode::Right)); - app.on_key_savesession(key(KeyCode::Backspace)); - assert!(app.input.is_empty()); - } - - // --- spawn group inheritance ------------------------------------------- - - /// In Custom mode, `n` assigns the selected task's group to the spawn. - #[test] - fn custom_mode_spawn_inherits_the_selected_group() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 - app.pump(); - app.group_mode = GroupMode::Custom; - app.resolve_selection(); - - app.on_key_dashboard(key(KeyCode::Char('n'))); - assert!(app.mode == Mode::Spawn); - assert_eq!(app.spawn_group.as_deref(), Some("alpha")); - - for c in "sleep 5".chars() { - app.on_key_spawn(key(KeyCode::Char(c))); - } - app.on_key_spawn(key(KeyCode::Enter)); - app.pump(); - let v = app.views.iter().find(|v| v.id == 2).unwrap(); - assert_eq!( - v.group.as_deref(), - Some("alpha"), - "the spawn must carry the inherited group" - ); - } - - /// In Custom mode, the `@` flow snapshots the group after directory selection. - #[test] - fn dir_picker_handoff_inherits_the_selected_group_in_custom_mode() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 - app.pump(); - app.group_mode = GroupMode::Custom; - app.resolve_selection(); - - app.on_key_dashboard(key(KeyCode::Char('@'))); - assert!(app.mode == Mode::PickDir); - // Row 0 is the current dir (DirKind::Use): Enter hands off to Spawn. - app.on_key_pickdir(key(KeyCode::Enter)); - assert!(app.mode == Mode::Spawn); - assert_eq!(app.spawn_group.as_deref(), Some("alpha")); - } - - /// State and Dir mode spawns are unassigned. - #[test] - fn state_and_dir_mode_spawns_stay_unassigned() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 - app.pump(); - app.resolve_selection(); - - for mode in [GroupMode::State, GroupMode::Dir] { - app.group_mode = mode; - app.spawn_group = Some("stale".to_string()); - app.on_key_dashboard(key(KeyCode::Char('n'))); - assert_eq!(app.spawn_group, None, "{mode:?} must not inherit"); - app.on_key_spawn(key(KeyCode::Esc)); - } - - app.on_key_dashboard(key(KeyCode::Char('n'))); - for c in "sleep 5".chars() { - app.on_key_spawn(key(KeyCode::Char(c))); - } - app.on_key_spawn(key(KeyCode::Enter)); - app.pump(); - let v = app.views.iter().find(|v| v.id == 2).unwrap(); - assert_eq!(v.group, None); - } -} +#[path = "app_tests.rs"] +mod tests; diff --git a/src/app_tests.rs b/src/app_tests.rs new file mode 100644 index 0000000..577f3e9 --- /dev/null +++ b/src/app_tests.rs @@ -0,0 +1,1656 @@ +use super::*; +use crate::{ + supervisor::Supervisor, + testutil::{temp, wait_until}, + transport::LocalTransport, + ui::scroll_window, +}; + +impl App { + /// A synchronous App: the supervisor ticks inline on `poll`, so `send` + /// then `pump` is deterministic with no core-thread timing to race. + /// Uses this process's launch context. + fn new_local(rows: u16, cols: u16) -> App { + App::assemble(rows, cols, |pr, c, _wait_tx| { + let mut sup = Supervisor::new(pr, c, 2000); + sup.set_launch_context(crate::protocol::LaunchContext::here()); + Box::new(LocalTransport::new(sup)) + }) + } + + /// `new_local` with an explicit launch context, for tests that must pin + /// the core's session root instead of inheriting this process's env. + fn new_local_with_ctx(rows: u16, cols: u16, ctx: crate::protocol::LaunchContext) -> App { + App::assemble(rows, cols, move |pr, c, _wait_tx| { + let mut sup = Supervisor::new(pr, c, 2000); + sup.set_launch_context(ctx); + Box::new(LocalTransport::new(sup)) + }) + } + + /// Drive one core sync so `views` reflects the latest spawns and reaps: + /// the test-side equivalent of one run-loop tick. + fn pump(&mut self) { + self.sync(); + } + + fn spawn_in(&mut self, cmd: &str, cwd: PathBuf) { + self.spawn_checked(cmd, cwd, None); + } + + fn spawn_grouped(&mut self, cmd: &str, cwd: PathBuf, group: &str) { + self.spawn_checked(cmd, cwd, Some(group)); + } + + /// Send a spawn and retry transient failures. Failed spawns leave + /// `next_id` unchanged, so retries preserve task IDs. + fn spawn_checked(&mut self, cmd: &str, cwd: PathBuf, group: Option<&str>) { + self.pump(); + let want = self.views.len() + 1; + for attempt in 0u64..5 { + if attempt > 0 { + std::thread::sleep(std::time::Duration::from_millis(25 << attempt)); + } + self.transport.send(Command::Spawn { + command: cmd.to_string(), + cwd: cwd.clone(), + group: group.map(str::to_string), + }); + self.pump(); + if self.views.len() == want { + // Drop the refusal notice a failed attempt left behind so + // status assertions see only their own test's traffic. + if attempt > 0 { + self.status = None; + } + return; + } + } + panic!("spawn never landed: {:?}", self.status); + } + + /// Return section labels with task ids instead of `views` indices. + fn section_ids(&self) -> Vec<(String, Vec)> { + self.sections() + .into_iter() + .map(|(l, idxs)| (l, idxs.into_iter().map(|i| self.views[i].id).collect())) + .collect() + } +} + +/// Selection is bound to a task id, so a reorder (here: tagging a task into +/// the "In use" bucket) must not move the highlight to a different task. +#[test] +fn selection_follows_task_across_reorder() { + let mut app = App::new_local(30, 100); + let dir = app.invocation_dir.clone(); + app.spawn_in("sleep 5", dir.clone()); // id 1 + app.spawn_in("sleep 5", dir); // id 2 + app.pump(); + app.resolve_selection(); + assert_eq!(app.selected_id, Some(1)); + + // Tag id 2 -> it sorts into the "In use" bucket, ahead of id 1. + app.transport.send(Command::Tag { id: 2, on: true }); + app.pump(); + + let order = app.display_order(); + assert_eq!(app.views[order[0]].id, 2, "tagged task should sort first"); + + // Still on id 1, even though it is now the second row. + assert_eq!(app.selected_id, Some(1)); + assert_eq!(app.views[app.selected_task().unwrap()].id, 1); +} + +/// Dir mode makes one section per distinct cwd (invocation dir first); state +/// mode collapses them back into the state buckets. +#[test] +fn dir_mode_groups_by_cwd() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", inv); // id 1, invocation dir + app.spawn_in("sleep 5", PathBuf::from("/tmp")); // id 2, /tmp + app.pump(); + + app.group_mode = GroupMode::State; + let s = app.sections(); + assert_eq!(s.len(), 1); + assert_eq!(s[0].0, "Running"); + assert_eq!(s[0].1.len(), 2); + + app.group_mode = GroupMode::Dir; + let s = app.sections(); + assert_eq!(s.len(), 2, "one section per distinct cwd"); + assert_eq!(s[0].0, app.invocation_label, "invocation dir sorts first"); + assert_eq!(s[1].0, "/tmp"); +} + +/// `s` cycles through all grouping modes. +#[test] +fn group_mode_cycles_state_dir_custom() { + assert_eq!(GroupMode::State.next(), GroupMode::Dir); + assert_eq!(GroupMode::Dir.next(), GroupMode::Custom); + assert_eq!(GroupMode::Custom.next(), GroupMode::State); + + let mut app = App::new_local(30, 100); + assert_eq!(app.group_mode, GroupMode::State); + for expect in [GroupMode::Dir, GroupMode::Custom, GroupMode::State] { + app.on_key_dashboard(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); + assert_eq!(app.group_mode, expect); + } +} + +/// Custom mode sorts named sections alphabetically and Unassigned last. +#[test] +fn custom_mode_groups_by_name_with_unassigned_last() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_grouped("sleep 5", inv.clone(), "beta"); // id 1 + app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 2 + app.spawn_in("sleep 5", inv); // id 3, no group + app.pump(); + + app.group_mode = GroupMode::Custom; + assert_eq!( + app.section_ids(), + vec![ + ("alpha".to_string(), vec![2]), + ("beta".to_string(), vec![1]), + ("Unassigned".to_string(), vec![3]), + ] + ); +} + +/// Custom mode does not emit empty group sections. +#[test] +fn custom_mode_unassigned_tracks_membership() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 + app.pump(); + app.group_mode = GroupMode::Custom; + assert_eq!(app.section_ids(), vec![("alpha".to_string(), vec![1])]); + + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", inv); // id 1, no group + app.pump(); + app.group_mode = GroupMode::Custom; + assert_eq!(app.section_ids(), vec![("Unassigned".to_string(), vec![1])]); +} + +/// In Custom mode, tagged tasks sort first within their existing group. +#[test] +fn custom_mode_tag_floats_within_group() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 + app.spawn_grouped("sleep 5", inv, "alpha"); // id 2 + app.pump(); + app.group_mode = GroupMode::Custom; + assert_eq!(app.section_ids(), vec![("alpha".to_string(), vec![1, 2])]); + + app.transport.send(Command::Tag { id: 2, on: true }); + app.pump(); + assert_eq!( + app.section_ids(), + vec![("alpha".to_string(), vec![2, 1])], + "tag floats id 2 to the top of alpha, not into an In use section" + ); +} + +/// Group reassignment can reorder sections without changing the selected id. +#[test] +fn custom_mode_selection_survives_group_move() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 + app.spawn_grouped("sleep 5", inv, "beta"); // id 2 + app.pump(); + app.group_mode = GroupMode::Custom; + app.resolve_selection(); + assert_eq!(app.selected_id, Some(1)); + + // Move id 1 from the first section to the last. + app.transport.send(Command::SetGroup { + id: 1, + group: Some("zeta".to_string()), + }); + app.pump(); + assert_eq!( + app.section_ids(), + vec![("beta".to_string(), vec![2]), ("zeta".to_string(), vec![1]),] + ); + + // Selection remains on id 1 in its new section. + assert_eq!(app.selected_id, Some(1)); + assert_eq!(app.views[app.selected_task().unwrap()].id, 1); +} + +/// Within a group, tasks cluster by directory and then by spawn order. +#[test] +fn custom_mode_clusters_by_dir_within_group() { + let mut app = App::new_local(30, 100); + let base = temp("app_cg"); + let (dir_a, dir_b) = (base.join("a"), base.join("b")); + std::fs::create_dir_all(&dir_a).unwrap(); + std::fs::create_dir_all(&dir_b).unwrap(); + + app.spawn_grouped("sleep 5", dir_b.clone(), "alpha"); // id 1, dir b + app.spawn_grouped("sleep 5", dir_a.clone(), "alpha"); // id 2, dir a + app.spawn_grouped("sleep 5", dir_a, "alpha"); // id 3, dir a + app.pump(); + + app.group_mode = GroupMode::Custom; + assert_eq!( + app.section_ids(), + vec![("alpha".to_string(), vec![2, 3, 1])], + "dir a's tasks cluster (in id order) ahead of dir b's" + ); + let _ = std::fs::remove_dir_all(&base); +} + +/// A manual tag must pull a task out of Completed into In use, even after it +/// has exited. +#[test] +fn tagging_a_finished_task_moves_it_to_in_use() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("true", inv); // exits ~immediately + wait_until(Duration::from_secs(5), || { + app.pump(); + app.views + .first() + .map(|v| matches!(v.lifecycle, Lifecycle::Ok | Lifecycle::Failed)) + .unwrap_or(false) + }); + assert!(matches!( + app.views[0].lifecycle, + Lifecycle::Ok | Lifecycle::Failed + )); + assert_eq!(app.sections()[0].0, "Completed"); + + let id = app.views[0].id; + app.transport.send(Command::Tag { id, on: true }); + app.pump(); + assert_eq!(app.sections()[0].0, "In use"); +} + +/// A parked live task gets its own "Idle" section between "Running" and +/// "Completed". The test updates the local snapshot after the last pump so a +/// fresh core snapshot cannot overwrite it; this avoids waiting for the 10 s +/// quiet window. +#[test] +fn parked_task_lands_in_idle_between_running_and_completed() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", inv.clone()); // id 1: running + app.spawn_in("sleep 5", inv.clone()); // id 2: parked below + app.spawn_in("sleep 5", inv.clone()); // id 3: tagged below + app.spawn_in("true", inv); // id 4: exits ~immediately + wait_until(Duration::from_secs(5), || { + app.pump(); + app.views + .iter() + .any(|v| v.id == 4 && matches!(v.lifecycle, Lifecycle::Ok | Lifecycle::Failed)) + }); + app.transport.send(Command::Tag { id: 3, on: true }); + app.pump(); + + let i = app.views.iter().position(|v| v.id == 2).unwrap(); + app.views[i].parked = true; + + assert_eq!( + app.section_ids(), + vec![ + ("In use".to_string(), vec![3]), + ("Running".to_string(), vec![1]), + ("Idle".to_string(), vec![2]), + ("Completed".to_string(), vec![4]), + ] + ); +} + +/// Tagged beats parked: a tagged task stays in "In use" even while parked. +#[test] +fn tagged_parked_task_stays_in_use() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", inv.clone()); // id 1: tagged + parked + app.spawn_in("sleep 5", inv); // id 2: running + app.pump(); + app.transport.send(Command::Tag { id: 1, on: true }); + app.pump(); + + let i = app.views.iter().position(|v| v.id == 1).unwrap(); + app.views[i].parked = true; + + assert_eq!( + app.section_ids(), + vec![ + ("In use".to_string(), vec![1]), + ("Running".to_string(), vec![2]), + ] + ); +} + +/// One window drives both signals, so a live core ships `Lifecycle::Idle` +/// and `parked` together: an idle-glyph task lands in the "Idle" section +/// under state grouping. Glyph and placement agree. +#[test] +fn idle_glyph_task_lands_in_idle_section() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", inv); // id 1 + app.pump(); + + let i = app.views.iter().position(|v| v.id == 1).unwrap(); + app.views[i].lifecycle = Lifecycle::Idle; + app.views[i].parked = true; + + assert_eq!(app.section_ids(), vec![("Idle".to_string(), vec![1])]); +} + +/// Selection is bound to a task id, so a `parked` flip (re-bucketing the +/// row from "Running" into "Idle") must not move the highlight to a +/// different task. +#[test] +fn selection_follows_task_across_parked_rebucket() { + let mut app = App::new_local(30, 100); + let dir = app.invocation_dir.clone(); + app.spawn_in("sleep 5", dir.clone()); // id 1 + app.spawn_in("sleep 5", dir); // id 2 + app.pump(); + app.resolve_selection(); + assert_eq!(app.selected_id, Some(1)); + + // Park id 1 -> it sinks into "Idle", below id 2's "Running". + let i = app.views.iter().position(|v| v.id == 1).unwrap(); + app.views[i].parked = true; + + let order = app.display_order(); + assert_eq!(app.views[order[0]].id, 2, "running task should sort first"); + + // Still on id 1, even though it is now the second row. + assert_eq!(app.selected_id, Some(1)); + assert_eq!(app.views[app.selected_task().unwrap()].id, 1); +} + +/// `bucket` doubles as the within-group tiebreak, so a parked task sinks +/// below a running one inside a Custom group too, not only in State mode. +#[test] +fn custom_mode_parked_sinks_within_group() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 + app.spawn_grouped("sleep 5", inv, "alpha"); // id 2 + app.pump(); + app.group_mode = GroupMode::Custom; + assert_eq!(app.section_ids(), vec![("alpha".to_string(), vec![1, 2])]); + + let i = app.views.iter().position(|v| v.id == 1).unwrap(); + app.views[i].parked = true; + assert_eq!( + app.section_ids(), + vec![("alpha".to_string(), vec![2, 1])], + "parked id 1 sinks below running id 2 within alpha" + ); +} + +/// `r` sends `Restart` only for a finished selection. On a running task +/// the key is a client-side no-op: nothing crosses the transport, so no +/// supervisor complaint lands in the status line. On a finished one the +/// same row (same id) comes back to life and the command re-executes. +#[test] +fn rerun_key_is_gated_to_finished_tasks() { + let mut app = App::new_local(30, 100); + let dir = temp("app_rerun"); + let marker = dir.join("marker"); + app.spawn_in("sleep 30", dir.clone()); // id 1: stays running + app.spawn_in(&format!("echo run >> {}", marker.display()), dir.clone()); // id 2 + wait_until(Duration::from_secs(5), || { + app.pump(); + app.views + .iter() + .any(|v| v.id == 2 && matches!(v.lifecycle, Lifecycle::Ok)) + }); + + // Running selection: `r` must send nothing (and thus kill nothing). + app.selected_id = Some(1); + app.on_key_dashboard(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + app.pump(); + assert!(app.status.is_none(), "no Restart should have been sent"); + assert!( + app.views + .iter() + .any(|v| v.id == 1 && matches!(v.lifecycle, Lifecycle::Active | Lifecycle::Idle)), + "the running task must be untouched" + ); + + // Finished selection: `r` reruns it under the same id. + app.selected_id = Some(2); + app.on_key_dashboard(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + wait_until(Duration::from_secs(5), || { + app.pump(); + std::fs::read_to_string(&marker) + .map(|s| s.lines().count() == 2) + .unwrap_or(false) + }); + assert_eq!( + std::fs::read_to_string(&marker).unwrap().lines().count(), + 2, + "rerun must re-execute the command" + ); + assert!( + app.views.iter().any(|v| v.id == 2), + "rerun must keep the id" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Scratch config dir with the given pre-written (empty) session recipes. +fn session_scratch(tag: &str, names: &[&str]) -> PathBuf { + let dir = temp(&format!("app_{tag}")); + std::fs::create_dir_all(dir.join("sessions")).unwrap(); + for n in names { + std::fs::write(dir.join("sessions").join(format!("{n}.json")), "{}").unwrap(); + } + dir +} + +/// An App whose core's session root is pinned to `dir` via the launch +/// context, so these tests never read this process's real config dir. +fn app_with_config_dir(dir: &Path) -> App { + App::new_local_with_ctx( + 30, + 100, + crate::protocol::LaunchContext { + env: vec![( + "FLEETCOM_CONFIG_DIR".into(), + dir.to_path_buf().into_os_string(), + )], + cwd: dir.to_path_buf(), + }, + ) +} + +/// `o` never touches the local filesystem: it sends `ListSessions`, opens +/// the picker empty, and the core's `Sessions` reply fills it. +#[test] +fn o_key_round_trips_the_session_list_through_the_core() { + let dir = session_scratch("sess_list", &["b", "a"]); + let mut app = app_with_config_dir(&dir); + app.session_sel = 3; // stale from a previous picker visit + + app.on_key_dashboard(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + assert!(matches!(app.mode, Mode::LoadSession)); + assert!( + app.session_names.is_empty(), + "the picker opens empty until the reply lands" + ); + assert_eq!( + app.session_sel, 0, + "opening the picker resets the selection" + ); + + app.pump(); + assert_eq!( + app.session_names, + vec!["a".to_string(), "b".to_string()], + "the Sessions reply populates the picker, sorted" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A shorter list arriving while the picker is open clamps the selection so +/// Enter cannot index past the new end. +#[test] +fn session_selection_clamps_when_a_shorter_list_arrives() { + let dir = session_scratch("sess_clamp", &["a", "b", "c"]); + let mut app = app_with_config_dir(&dir); + app.on_key_dashboard(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + app.pump(); + assert_eq!(app.session_names.len(), 3); + app.session_sel = 2; + + // Two recipes vanish; a refresh lands while the picker is still open. + std::fs::remove_file(dir.join("sessions").join("b.json")).unwrap(); + std::fs::remove_file(dir.join("sessions").join("c.json")).unwrap(); + app.transport.send(Command::ListSessions); + app.pump(); + assert_eq!(app.session_names, vec!["a".to_string()]); + assert_eq!(app.session_sel, 0, "selection must clamp to the new length"); + + // The empty list parks the selection at 0 too. + std::fs::remove_file(dir.join("sessions").join("a.json")).unwrap(); + app.transport.send(Command::ListSessions); + app.pump(); + assert!(app.session_names.is_empty()); + assert_eq!(app.session_sel, 0); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The `@` recent list is the distinct task cwds, newest first. +#[test] +fn recent_dirs_are_distinct_and_newest_first() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", PathBuf::from("/tmp")); // id 1 /tmp + app.spawn_in("sleep 5", inv.clone()); // id 2 invocation + app.spawn_in("sleep 5", PathBuf::from("/tmp")); // id 3 /tmp (dup) + app.pump(); + + let dirs = app.in_use_dirs(); + assert_eq!(dirs.len(), 2, "duplicate dirs collapse"); + assert_eq!(dirs[0], PathBuf::from("/tmp"), "newest first"); + assert_eq!(dirs[1], inv); +} + +#[test] +fn picker_puts_current_dir_first_and_selected() { + let mut app = App::new_local(30, 100); + app.dir_input.clear(); + app.refresh_dir_candidates(); + assert_eq!(app.dir_sel, 0, "current dir selected by default"); + assert_eq!(app.dir_candidates[0].kind, DirKind::Use); + assert_eq!(app.dir_candidates[0].path, app.invocation_dir); +} + +/// Focus is by id, so it points at the same task even after the list shifts +/// (a lower-id task is removed) and reports gone once it's removed. +#[test] +fn focus_by_id_survives_index_shift() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", inv.clone()); // id 1 + app.spawn_in("sleep 5", inv); // id 2 + app.pump(); + app.focused_id = Some(2); + assert_eq!(app.views[app.focused_task().unwrap()].id, 2); + + app.transport.send(Command::Remove { id: 1 }); // id 2 slides to index 0 + app.pump(); + assert_eq!(app.views[app.focused_task().unwrap()].id, 2); + + app.transport.send(Command::Remove { id: 2 }); + app.pump(); + assert!(app.focused_task().is_none()); +} + +/// The flat row list interleaves each section header with its tasks, in +/// section order: what the dashboard's scroll window slides over. +#[test] +fn rows_interleave_headers_and_tasks() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("a", inv.clone()); // id 1, invocation dir + app.spawn_in("b", PathBuf::from("/tmp")); // id 2, /tmp + app.pump(); + + app.group_mode = GroupMode::Dir; + let rows = app.list_rows(); + assert_eq!(rows.len(), 4, "two sections, one task each"); + assert_eq!(rows[0], Row::Section(app.invocation_label.clone())); + assert!(matches!(rows[1], Row::Task(i) if app.views[i].id == 1)); + assert_eq!(rows[2], Row::Section("/tmp".to_string())); + assert!(matches!(rows[3], Row::Task(i) if app.views[i].id == 2)); +} + +/// A fleet taller than the list region must keep the selected row inside +/// the scroll window at every step: the dashboard equivalent of the picker +/// guarantee, over the composed header+task row list. +#[test] +fn dashboard_selection_stays_in_scroll_window() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + for _ in 0..8 { + app.spawn_in("sleep 5", inv.clone()); + } + app.pump(); + app.resolve_selection(); + + // A 4-row window over 9 rows (1 header + 8 tasks): walking the whole + // list down and back up must never let the selection leave the window. + let height = 4; + for step in 0..10 { + app.select_down(); + let rows = app.list_rows(); + let sel = app.selected_row(&rows).expect("selection always resolves"); + let (start, count) = scroll_window(sel, rows.len(), height); + assert!( + sel >= start && sel < start + count, + "step {step}: row {sel} outside window ({start}, {count})" + ); + } + for step in 0..10 { + app.select_up(); + let rows = app.list_rows(); + let sel = app.selected_row(&rows).expect("selection always resolves"); + let (start, count) = scroll_window(sel, rows.len(), height); + assert!( + sel >= start && sel < start + count, + "step {step}: row {sel} outside window ({start}, {count})" + ); + } +} + +/// Selection wraps at the list edges: up from the first task lands on the +/// last and down from the last lands on the first, across the section +/// boundary. `display_order` is flat, so headers never trap the cursor. +#[test] +fn selection_wraps_at_list_edges() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", inv.clone()); // id 1 + app.spawn_in("sleep 5", inv.clone()); // id 2 + app.spawn_in("sleep 5", inv); // id 3 + app.pump(); + + // Tag id 2 -> it sorts into a leading "In use" section, so the wrap + // below crosses a section boundary. + app.transport.send(Command::Tag { id: 2, on: true }); + app.pump(); + app.resolve_selection(); + assert_eq!(app.section_ids().len(), 2, "tag splits the list in two"); + + let order = app.display_order(); + let first = app.views[order[0]].id; + let last = app.views[*order.last().unwrap()].id; + assert_eq!(first, 2, "tagged task sorts first"); + assert_eq!(app.selected_id, Some(first)); + + app.select_up(); + assert_eq!( + app.selected_id, + Some(last), + "up from the first wraps to the last" + ); + app.select_down(); + assert_eq!( + app.selected_id, + Some(first), + "down from the last wraps to the first" + ); +} + +/// With a single task, wrap degrades to a no-op in both directions. +#[test] +fn selection_wrap_is_noop_with_one_task() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", inv); + app.pump(); + app.resolve_selection(); + assert_eq!(app.selected_id, Some(1)); + + app.select_up(); + assert_eq!(app.selected_id, Some(1)); + app.select_down(); + assert_eq!(app.selected_id, Some(1)); +} + +/// Build two two-task sections: In use [3, 4] and Running [1, 2]. +fn app_with_two_sections() -> App { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + for _ in 0..4 { + app.spawn_in("sleep 5", inv.clone()); + } + app.pump(); + app.transport.send(Command::Tag { id: 3, on: true }); + app.transport.send(Command::Tag { id: 4, on: true }); + app.pump(); + assert_eq!( + app.section_ids(), + vec![ + ("In use".to_string(), vec![3, 4]), + ("Running".to_string(), vec![1, 2]), + ], + "tags split the list into two two-task sections" + ); + app +} + +/// Next-section navigation selects its first task and wraps forward. +#[test] +fn tab_jumps_to_next_section_first_task() { + let mut app = app_with_two_sections(); + + // From In use, select Running's first task. + app.selected_id = Some(4); + app.select_next_section(); + assert_eq!( + app.selected_id, + Some(1), + "next section's first, not same offset" + ); + + // From the last section, wrap to the first task in In use. + app.selected_id = Some(2); + app.select_next_section(); + assert_eq!(app.selected_id, Some(3), "forward from last section wraps"); +} + +/// Previous-section navigation selects its first task and wraps backward. +#[test] +fn backtab_jumps_to_previous_section_first_task() { + let mut app = app_with_two_sections(); + + // From Running, select In use's first task. + app.selected_id = Some(2); + app.select_prev_section(); + assert_eq!( + app.selected_id, + Some(3), + "previous section's first, not current's" + ); + + // From the first section, wrap to Running's first task. + app.selected_id = Some(3); + app.select_prev_section(); + assert_eq!( + app.selected_id, + Some(1), + "backward from first section wraps" + ); +} + +/// Section navigation keeps a single task selected. +#[test] +fn section_nav_is_noop_with_one_task() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", inv); + app.pump(); + app.resolve_selection(); + assert_eq!(app.selected_id, Some(1)); + + app.select_next_section(); + assert_eq!(app.selected_id, Some(1)); + app.select_prev_section(); + assert_eq!(app.selected_id, Some(1)); +} + +/// Without a selection, navigation chooses the boundary section. +#[test] +fn section_nav_defaults_without_selection() { + let mut app = app_with_two_sections(); + + app.selected_id = None; + app.select_next_section(); + assert_eq!( + app.selected_id, + Some(3), + "no selection: first section's first" + ); + + app.selected_id = None; + app.select_prev_section(); + assert_eq!( + app.selected_id, + Some(1), + "no selection: last section's first" + ); + + let mut empty = App::new_local(30, 100); + empty.select_next_section(); + assert_eq!(empty.selected_id, None); + empty.select_prev_section(); + assert_eq!(empty.selected_id, None); +} + +/// Supported crossterm keys and modifiers map to their wire representation. +#[test] +fn key_event_maps_to_semantic_key() { + assert_eq!( + key_event_to_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)), + Some((Key::Char('a'), Mods::default())) + ); + assert_eq!( + key_event_to_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)), + Some((Key::F(5), Mods::default())) + ); + assert_eq!( + key_event_to_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SHIFT)), + Some(( + Key::Char('a'), + Mods { + shift: true, + ..Mods::default() + } + )) + ); + assert_eq!( + key_event_to_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)), + Some(( + Key::Char('a'), + Mods { + ctrl: true, + ..Mods::default() + } + )) + ); + assert_eq!( + key_event_to_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT)), + Some(( + Key::Char('a'), + Mods { + alt: true, + ..Mods::default() + } + )) + ); + assert_eq!( + key_event_to_key(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT)), + Some(( + Key::Left, + Mods { + alt: true, + ..Mods::default() + } + )) + ); +} + +/// Unsupported modifier bits are ignored; unsupported key codes are dropped. +#[test] +fn unencodable_modifiers_and_keys_are_dropped() { + assert_eq!( + key_event_to_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER)), + Some((Key::Char('a'), Mods::default())) + ); + assert_eq!( + key_event_to_key(KeyEvent::new(KeyCode::CapsLock, KeyModifiers::NONE)), + None + ); + assert_eq!( + key_event_to_key(KeyEvent::new(KeyCode::Null, KeyModifiers::NONE)), + None + ); +} + +/// An oversized attached paste is refused before it closes the connection. +#[test] +fn oversized_paste_is_refused_with_a_notice() { + let mut app = App::new_local(30, 100); + app.mode = Mode::Attached; + app.focused_id = Some(1); + app.on_paste(&"x".repeat(MAX_PASTE + 1)); + let status = app.status.clone().unwrap_or_default(); + assert!(status.contains("paste dropped"), "status was {status:?}"); + // The boundary value is accepted. + app.on_paste(&"x".repeat(MAX_PASTE)); + assert!(app.status.is_none(), "boundary paste must not be refused"); +} + +/// A paste into a text-entry mode lands as one string with control +/// characters stripped: a multi-line clipboard must not fake the Enter +/// press that would launch a half-pasted command. +#[test] +fn paste_into_text_entry_strips_controls() { + let mut app = App::new_local(30, 100); + app.mode = Mode::Spawn; + app.on_paste("cargo\ttest\r\n --all"); + assert_eq!(app.input.as_str(), "cargotest --all"); + assert!(app.mode == Mode::Spawn, "paste must not submit"); +} + +/// Select mouse capture by screen type. +#[test] +fn input_modes_match_screen_type() { + let screen = |wants_mouse, alt_screen, alt_scroll| ScreenView { + id: 1, + lines: Vec::new(), + formatted: Vec::new(), + cursor: (0, 0), + hide_cursor: false, + wants_mouse, + alt_screen, + alt_scroll, + scrollback: 0, + }; + // No attached screen: keep native selection available. + assert!(!desired_mouse_capture(None, false)); + // Mouse-aware child: capture. + assert!(desired_mouse_capture( + Some(&screen(true, true, true)), + false + )); + assert!(desired_mouse_capture( + Some(&screen(true, false, false)), + false + )); + // Full-screen child with alternate scroll enabled. + assert!(!desired_mouse_capture( + Some(&screen(false, true, true)), + false + )); + // Full-screen child with alternate scroll disabled. + assert!(desired_mouse_capture( + Some(&screen(false, true, false)), + false + )); + // Inline child: capture wheel-up to enter scrollback. + assert!(desired_mouse_capture( + Some(&screen(false, false, false)), + false + )); + // The scroll view overrides everything: the wheel must scroll it. + assert!(desired_mouse_capture( + Some(&screen(false, false, false)), + true + )); + assert!(desired_mouse_capture(None, true)); +} + +/// Wheel-up enters scrollback for inline children, but forwards for +/// mouse-aware children. +#[test] +fn wheel_up_enters_scroll_view_for_inline_children() { + let mut app = App::new_local(30, 100); + let dir = app.invocation_dir.clone(); + app.spawn_in("sleep 5", dir); + app.pump(); + app.resolve_selection(); + app.attach(); + let id = app.focused_id.expect("attached"); + let screen = |wants_mouse| ScreenView { + id, + lines: Vec::new(), + formatted: Vec::new(), + cursor: (0, 0), + hide_cursor: false, + wants_mouse, + alt_screen: false, + alt_scroll: false, + scrollback: 0, + }; + let wheel_up = MouseEvent { + kind: MouseEventKind::ScrollUp, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }; + + // Inline child: wheel-up enters scrollback. + app.focused_screen = Some(screen(false)); + app.on_mouse(wheel_up); + assert!(app.view_scroll, "wheel-up must open the scroll view"); + + // Mouse-aware child: wheel-up forwards instead. + app.view_scroll = false; + app.focused_screen = Some(screen(true)); + app.on_mouse(wheel_up); + assert!(!app.view_scroll, "mouse-aware children keep their wheel"); +} + +/// Attached wheel input follows the child's DECSET 1007 state. +#[test] +fn attached_wheel_honors_the_childs_1007_veto() { + let dir = temp("app_1007"); + + let wheel_up = MouseEvent { + kind: MouseEventKind::ScrollUp, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }; + // Send one wheel notch and return the first `take` bytes read by the child. + let run = |veto: bool, take: usize, out: PathBuf| -> Vec { + let mut app = App::new_local(30, 100); + let cwd = app.invocation_dir.clone(); + let modes = if veto { + "\\033[?1049h\\033[?1007l" + } else { + "\\033[?1049h" + }; + // Noncanonical input lets `head` read arrow sequences without a newline. + let cmd = format!( + "stty -icanon -echo min 1 time 0; printf '{modes}'; head -c {take} > {}", + out.display() + ); + app.spawn_in(&cmd, cwd); + app.pump(); + app.resolve_selection(); + app.attach(); + let id = app.focused_id.expect("attached"); + app.set_watch(Some(id)); + // Wait for the child's terminal modes to reach the client. + assert!( + wait_until(Duration::from_secs(5), || { + app.pump(); + matches!( + app.screen_for(id), + Some(s) if s.alt_screen && s.alt_scroll != veto + ) + }), + "gate state (veto: {veto}) never reached the client" + ); + // Disabled alternate scroll captures the wheel; enabled does not. + assert_eq!(desired_mouse_capture(app.screen_for(id), false), veto); + app.on_mouse(wheel_up); + if veto { + // The sentinel follows the wheel on the writer queue. + app.transport.send(Command::Input { + id, + bytes: b"zzz".to_vec(), + }); + } + let mut got = Vec::new(); + wait_until(Duration::from_secs(5), || { + got = std::fs::read(&out).unwrap_or_default(); + got.len() >= take + }); + got + }; + + // Disabled alternate scroll suppresses the wheel bytes. + assert_eq!(run(true, 3, dir.join("veto")), b"zzz".to_vec()); + // Enabled alternate scroll emits three arrows per notch. + assert_eq!( + run(false, 9, dir.join("dflt")), + b"\x1b[A\x1b[A\x1b[A".to_vec() + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Scrollback opens with modified PageUp and closes on Esc or typing. +#[test] +fn scroll_view_entry_and_exit() { + let mut app = App::new_local(30, 100); + let dir = app.invocation_dir.clone(); + app.spawn_in("sleep 5", dir); + app.pump(); + app.resolve_selection(); + app.attach(); + assert!(app.mode == Mode::Attached); + let mut out = io::stdout(); + + let key = |code| KeyEvent::new(code, KeyModifiers::NONE); + app.on_key_attached( + &mut out, + KeyEvent::new(KeyCode::PageUp, KeyModifiers::SHIFT), + ) + .unwrap(); + assert!(app.view_scroll, "Shift+PageUp must enter the scroll view"); + app.on_key_attached(&mut out, key(KeyCode::Esc)).unwrap(); + assert!(!app.view_scroll, "Esc must return to live"); + + app.on_key_attached( + &mut out, + KeyEvent::new(KeyCode::PageUp, KeyModifiers::CONTROL), + ) + .unwrap(); + assert!(app.view_scroll, "Ctrl+PageUp is an entry fallback"); + app.on_key_attached(&mut out, key(KeyCode::Char('x'))) + .unwrap(); + assert!(!app.view_scroll, "typing must snap back to live"); + + // Plain PageUp is forwarded to the child. + app.on_key_attached(&mut out, key(KeyCode::PageUp)).unwrap(); + assert!(!app.view_scroll); +} + +/// A wheel notch on the dashboard moves the selection like an arrow key. +#[test] +fn wheel_moves_dashboard_selection() { + let mut app = App::new_local(30, 100); + let dir = app.invocation_dir.clone(); + app.spawn_in("sleep 5", dir.clone()); // id 1 + app.spawn_in("sleep 5", dir); // id 2 + app.pump(); + app.resolve_selection(); + assert_eq!(app.selected_id, Some(1)); + + let wheel = |kind| MouseEvent { + kind, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }; + app.on_mouse(wheel(MouseEventKind::ScrollDown)); + assert_eq!(app.selected_id, Some(2)); + app.on_mouse(wheel(MouseEventKind::ScrollUp)); + assert_eq!(app.selected_id, Some(1)); +} + +// --- `g` group picker ------------------------------------------------- + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +fn ctrl(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::CONTROL) +} + +/// `g` opens the picker only when a task is selected, pinning the target +/// to that task's id. +#[test] +fn group_picker_opens_on_g_only_with_a_selection() { + let mut app = App::new_local(30, 100); + app.on_key_dashboard(key(KeyCode::Char('g'))); + assert!(app.mode == Mode::Dashboard, "no selection: g must no-op"); + + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", inv); + app.pump(); + app.resolve_selection(); + app.on_key_dashboard(key(KeyCode::Char('g'))); + assert!(app.mode == Mode::PickGroup); + assert_eq!(app.group_target, Some(1)); +} + +/// Picker candidates are distinct byte-sorted groups after Unassigned, with +/// the target's assignment marked "(current)". +#[test] +fn group_candidates_are_distinct_sorted_and_marked() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_grouped("sleep 5", inv.clone(), "beta"); // id 1 + app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 2 + app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 3, dup group + app.spawn_in("sleep 5", inv); // id 4, no group + app.pump(); + + app.selected_id = Some(1); // group "beta" + app.on_key_dashboard(key(KeyCode::Char('g'))); + let labels: Vec<&str> = app + .group_candidates + .iter() + .map(|c| c.label.as_str()) + .collect(); + assert_eq!(labels, vec!["Unassigned", "alpha", "beta (current)"]); + let groups: Vec> = app + .group_candidates + .iter() + .map(|c| c.group.as_deref()) + .collect(); + assert_eq!(groups, vec![None, Some("alpha"), Some("beta")]); + assert_eq!(app.group_sel, 0, "nothing typed keeps the clear row"); + + // An ungrouped target marks the Unassigned row instead. + app.on_key_pickgroup(key(KeyCode::Esc)); + app.selected_id = Some(4); + app.on_key_dashboard(key(KeyCode::Char('g'))); + assert_eq!(app.group_candidates[0].label, "Unassigned (current)"); +} + +/// Typing applies a case-insensitive prefix filter and selects the first +/// match; Backspace expands the candidate set again. +#[test] +fn group_filter_narrows_and_preselects_the_first_match() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 + app.spawn_grouped("sleep 5", inv, "beta"); // id 2 + app.pump(); + app.resolve_selection(); + app.on_key_dashboard(key(KeyCode::Char('g'))); + assert_eq!(app.group_candidates.len(), 3); + + app.on_key_pickgroup(key(KeyCode::Char('B'))); + let labels: Vec<&str> = app + .group_candidates + .iter() + .map(|c| c.label.as_str()) + .collect(); + assert_eq!( + labels, + vec!["Unassigned", "beta"], + "case-insensitive prefix" + ); + assert_eq!(app.group_sel, 1, "filtering preselects the first match"); + + app.on_key_pickgroup(key(KeyCode::Char('z'))); + assert_eq!(app.group_candidates.len(), 1, "\"Bz\" matches nothing"); + assert_eq!(app.group_sel, 0); + + app.on_key_pickgroup(key(KeyCode::Backspace)); + assert_eq!(app.group_candidates.len(), 2, "backspace re-widens"); +} + +/// Enter assigns the highlighted candidate to the target task. +#[test] +fn group_enter_on_a_candidate_assigns_it() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 + app.spawn_in("sleep 5", inv); // id 2, no group + app.pump(); + + app.selected_id = Some(2); + app.on_key_dashboard(key(KeyCode::Char('g'))); + app.on_key_pickgroup(key(KeyCode::Char('a'))); // highlights "alpha" + app.on_key_pickgroup(key(KeyCode::Enter)); + assert!(app.mode == Mode::Dashboard); + app.pump(); + let v = app.views.iter().find(|v| v.id == 2).unwrap(); + assert_eq!(v.group.as_deref(), Some("alpha")); +} + +/// Enter creates the typed group when no candidate matches. +#[test] +fn group_enter_on_novel_text_creates_the_group() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 + app.pump(); + app.resolve_selection(); + app.on_key_dashboard(key(KeyCode::Char('g'))); + for c in "gamma".chars() { + app.on_key_pickgroup(key(KeyCode::Char(c))); + } + assert_eq!(app.group_candidates.len(), 1, "nothing matches"); + app.on_key_pickgroup(key(KeyCode::Enter)); + app.pump(); + let v = app.views.iter().find(|v| v.id == 1).unwrap(); + assert_eq!(v.group.as_deref(), Some("gamma")); +} + +/// Empty input selects Unassigned, so Enter clears the target's group. +#[test] +fn group_enter_on_empty_input_clears_to_unassigned() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 + app.pump(); + app.resolve_selection(); + app.on_key_dashboard(key(KeyCode::Char('g'))); + assert_eq!(app.group_sel, 0, "empty input highlights the clear row"); + app.on_key_pickgroup(key(KeyCode::Enter)); + app.pump(); + let v = app.views.iter().find(|v| v.id == 1).unwrap(); + assert_eq!(v.group, None); +} + +/// Esc closes the picker without changing the target task. +#[test] +fn group_esc_cancels_without_sending() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 + app.pump(); + app.resolve_selection(); + app.on_key_dashboard(key(KeyCode::Char('g'))); + for c in "gamma".chars() { + app.on_key_pickgroup(key(KeyCode::Char(c))); + } + app.on_key_pickgroup(key(KeyCode::Esc)); + assert!(app.mode == Mode::Dashboard); + assert!(app.group_input.is_empty() && app.group_candidates.is_empty()); + assert_eq!(app.group_target, None); + app.pump(); + let v = app.views.iter().find(|v| v.id == 1).unwrap(); + assert_eq!(v.group.as_deref(), Some("alpha"), "Esc must send nothing"); +} + +// --- `R` rename prompt -------------------------------------------------- + +/// The rename prompt captures the selected task ID and current name. +#[test] +fn rename_prompt_opens_on_shift_r_only_with_a_selection() { + let mut app = App::new_local(30, 100); + app.on_key_dashboard(key(KeyCode::Char('R'))); + assert!(app.mode == Mode::Dashboard, "no selection: R must no-op"); + assert_eq!(app.rename_target, None); + + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", inv); + app.pump(); + app.resolve_selection(); + app.on_key_dashboard(key(KeyCode::Char('R'))); + assert!(app.mode == Mode::Rename); + assert_eq!(app.rename_target, Some(1)); + assert_eq!(app.input.as_str(), "", "an unnamed task prefills empty"); + + // A named task prefills its name. + app.on_key_rename(key(KeyCode::Esc)); + app.transport.send(Command::SetName { + id: 1, + name: Some("api".to_string()), + }); + app.pump(); + app.on_key_dashboard(key(KeyCode::Char('R'))); + assert_eq!(app.input.as_str(), "api"); +} + +/// Enter sends the trimmed name and returns to the dashboard. +#[test] +fn rename_enter_sends_the_typed_name() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", inv); + app.pump(); + app.resolve_selection(); + app.on_key_dashboard(key(KeyCode::Char('R'))); + for c in "api server".chars() { + app.on_key_rename(key(KeyCode::Char(c))); + } + app.on_key_rename(key(KeyCode::Enter)); + assert!(app.mode == Mode::Dashboard); + assert!(app.input.is_empty() && app.rename_target.is_none()); + app.pump(); + let v = app.views.iter().find(|v| v.id == 1).unwrap(); + assert_eq!(v.name.as_deref(), Some("api server")); +} + +/// Enter on an empty input clears the name. +#[test] +fn rename_enter_on_empty_input_clears_the_name() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", inv); + app.transport.send(Command::SetName { + id: 1, + name: Some("api".to_string()), + }); + app.pump(); + app.resolve_selection(); + app.on_key_dashboard(key(KeyCode::Char('R'))); + assert_eq!(app.input.as_str(), "api"); + for _ in 0.."api".len() { + app.on_key_rename(key(KeyCode::Backspace)); + } + app.on_key_rename(key(KeyCode::Enter)); + app.pump(); + let v = app.views.iter().find(|v| v.id == 1).unwrap(); + assert_eq!(v.name, None); +} + +/// Esc closes the prompt without changing the target task. +#[test] +fn rename_esc_cancels_without_sending() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", inv); + app.transport.send(Command::SetName { + id: 1, + name: Some("api".to_string()), + }); + app.pump(); + app.resolve_selection(); + app.on_key_dashboard(key(KeyCode::Char('R'))); + for c in "junk".chars() { + app.on_key_rename(key(KeyCode::Char(c))); + } + app.on_key_rename(key(KeyCode::Esc)); + assert!(app.mode == Mode::Dashboard); + assert!(app.input.is_empty()); + assert_eq!(app.rename_target, None); + app.pump(); + let v = app.views.iter().find(|v| v.id == 1).unwrap(); + assert_eq!(v.name.as_deref(), Some("api"), "Esc must send nothing"); +} + +// --- prompt caret editing ---------------------------------------------- + +/// Left/Right, Ctrl-A/Ctrl-E, and Home/End reposition the caret in the +/// rename prompt, and edits land at it. +#[test] +fn rename_caret_keys_edit_mid_name() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", inv); + app.transport.send(Command::SetName { + id: 1, + name: Some("api".to_string()), + }); + app.pump(); + app.resolve_selection(); + app.on_key_dashboard(key(KeyCode::Char('R'))); + assert_eq!(app.input.as_str(), "api"); + + // Move after "a" and insert within the name. + app.on_key_rename(key(KeyCode::Left)); + app.on_key_rename(key(KeyCode::Left)); + app.on_key_rename(key(KeyCode::Char('x'))); + assert_eq!(app.input.as_str(), "axpi"); + + // Ctrl-A jumps to the start; typing prepends. + app.on_key_rename(ctrl(KeyCode::Char('a'))); + app.on_key_rename(key(KeyCode::Char('z'))); + assert_eq!(app.input.as_str(), "zaxpi"); + + // Ctrl-E returns to the end; typing appends. + app.on_key_rename(ctrl(KeyCode::Char('e'))); + app.on_key_rename(key(KeyCode::Char('!'))); + assert_eq!(app.input.as_str(), "zaxpi!"); + + // Backspace removes only the char before the caret. + app.on_key_rename(key(KeyCode::Left)); + app.on_key_rename(key(KeyCode::Backspace)); + assert_eq!(app.input.as_str(), "zaxp!"); + + // Home/End alias the chords. + app.on_key_rename(key(KeyCode::Home)); + app.on_key_rename(key(KeyCode::Char('0'))); + app.on_key_rename(key(KeyCode::End)); + app.on_key_rename(key(KeyCode::Char('9'))); + assert_eq!(app.input.as_str(), "0zaxp!9"); +} + +/// Unbound Ctrl chords do not insert their literal letters. +#[test] +fn ctrl_chords_never_insert_their_letter() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 + app.pump(); + app.resolve_selection(); + + app.on_key_dashboard(key(KeyCode::Char('w'))); + app.on_key_savesession(ctrl(KeyCode::Char('k'))); + assert!(app.input.is_empty(), "Ctrl-K must not insert into a prompt"); + app.on_key_savesession(key(KeyCode::Esc)); + + app.on_key_dashboard(key(KeyCode::Char('@'))); + app.on_key_pickdir(ctrl(KeyCode::Char('k'))); + assert!(app.dir_input.is_empty(), "Ctrl-K must not filter the dirs"); + app.on_key_pickdir(key(KeyCode::Esc)); + + app.on_key_dashboard(key(KeyCode::Char('g'))); + let before = app.group_candidates.len(); + app.on_key_pickgroup(ctrl(KeyCode::Char('k'))); + assert!( + app.group_input.is_empty(), + "Ctrl-K must not filter the groups" + ); + assert_eq!(app.group_candidates.len(), before); +} + +/// In the `@` picker, Right descends only when the caret sits at the end +/// of the typed path; off the end it is caret motion. +#[test] +fn pickdir_right_descends_only_from_the_end() { + let dir = temp("caret_pickdir"); + std::fs::create_dir_all(dir.join("alpha")).unwrap(); + let mut app = App::new_local(30, 100); + app.invocation_dir = dir.clone(); + + app.on_key_dashboard(key(KeyCode::Char('@'))); + for c in "al".chars() { + app.on_key_pickdir(key(KeyCode::Char(c))); + } + assert_eq!(app.dir_sel, 1, "the fragment preselects alpha"); + + // Off the end (one left), Right moves the caret without descending. + app.on_key_pickdir(key(KeyCode::Left)); + app.on_key_pickdir(key(KeyCode::Right)); + assert_eq!( + app.dir_input.as_str(), + "al", + "Right off-end must not descend" + ); + + // That Right returned the caret to the end, so the next one descends. + app.on_key_pickdir(key(KeyCode::Right)); + assert!( + app.dir_input.ends_with("alpha/"), + "Right at end descends: {:?}", + app.dir_input.as_str() + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Typing after caret motion still refreshes the `@` candidates; the +/// motion itself does not. +#[test] +fn pickdir_refreshes_on_edits_not_caret_motion() { + let dir = temp("caret_pickdir_refresh"); + std::fs::create_dir_all(dir.join("alpha")).unwrap(); + let mut app = App::new_local(30, 100); + app.invocation_dir = dir.clone(); + + app.on_key_dashboard(key(KeyCode::Char('@'))); + app.on_key_pickdir(key(KeyCode::Char('l'))); + assert_eq!(app.dir_candidates.len(), 1, "\"l\" matches nothing"); + + app.on_key_pickdir(key(KeyCode::Home)); + assert_eq!(app.dir_candidates.len(), 1, "caret motion must not refresh"); + + // "a" typed at the start makes the buffer "al": a match again. + app.on_key_pickdir(key(KeyCode::Char('a'))); + assert_eq!(app.dir_input.as_str(), "al"); + assert!( + app.dir_candidates.iter().any(|c| c.label == "alpha"), + "an edit at the caret refreshes the candidates" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Caret-positioned edits refresh the group filter like end-of-line ones. +#[test] +fn pickgroup_caret_edits_refresh_the_filter() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 + app.spawn_grouped("sleep 5", inv, "beta"); // id 2 + app.pump(); + app.resolve_selection(); + app.on_key_dashboard(key(KeyCode::Char('g'))); + + app.on_key_pickgroup(key(KeyCode::Char('b'))); + assert_eq!(app.group_candidates.len(), 2, "\"b\" matches beta"); + + app.on_key_pickgroup(key(KeyCode::Left)); + assert_eq!( + app.group_candidates.len(), + 2, + "caret motion must not refresh" + ); + + // "a" before the 'b' makes the filter "ab": nothing matches now. + app.on_key_pickgroup(key(KeyCode::Char('a'))); + assert_eq!(app.group_input.as_str(), "ab"); + assert_eq!(app.group_candidates.len(), 1, "the caret edit re-filtered"); +} + +/// A paste inserts at the caret and leaves the caret after the pasted text. +#[test] +fn paste_lands_at_the_caret() { + let mut app = App::new_local(30, 100); + app.mode = Mode::Spawn; + for c in "cargo t".chars() { + app.on_key_spawn(key(KeyCode::Char(c))); + } + app.on_key_spawn(key(KeyCode::Left)); // caret before 't' + app.on_paste("x\ny"); // control chars still stripped + assert_eq!(app.input.as_str(), "cargo xyt"); + app.on_key_spawn(key(KeyCode::Char('z'))); + assert_eq!( + app.input.as_str(), + "cargo xyzt", + "caret sits after the paste" + ); +} + +/// Multibyte characters move, insert, and delete as whole units. +#[test] +fn multibyte_chars_edit_cleanly_in_a_prompt() { + let mut app = App::new_local(30, 100); + app.on_key_dashboard(key(KeyCode::Char('w'))); + app.on_key_savesession(key(KeyCode::Char('é'))); + app.on_key_savesession(key(KeyCode::Left)); + app.on_key_savesession(key(KeyCode::Char('日'))); + assert_eq!(app.input.as_str(), "日é"); + app.on_key_savesession(key(KeyCode::Backspace)); + assert_eq!(app.input.as_str(), "é"); + app.on_key_savesession(key(KeyCode::Right)); + app.on_key_savesession(key(KeyCode::Backspace)); + assert!(app.input.is_empty()); +} + +// --- spawn group inheritance ------------------------------------------- + +/// In Custom mode, `n` assigns the selected task's group to the spawn. +#[test] +fn custom_mode_spawn_inherits_the_selected_group() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 + app.pump(); + app.group_mode = GroupMode::Custom; + app.resolve_selection(); + + app.on_key_dashboard(key(KeyCode::Char('n'))); + assert!(app.mode == Mode::Spawn); + assert_eq!(app.spawn_group.as_deref(), Some("alpha")); + + for c in "sleep 5".chars() { + app.on_key_spawn(key(KeyCode::Char(c))); + } + app.on_key_spawn(key(KeyCode::Enter)); + app.pump(); + let v = app.views.iter().find(|v| v.id == 2).unwrap(); + assert_eq!( + v.group.as_deref(), + Some("alpha"), + "the spawn must carry the inherited group" + ); +} + +/// In Custom mode, the `@` flow snapshots the group after directory selection. +#[test] +fn dir_picker_handoff_inherits_the_selected_group_in_custom_mode() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 + app.pump(); + app.group_mode = GroupMode::Custom; + app.resolve_selection(); + + app.on_key_dashboard(key(KeyCode::Char('@'))); + assert!(app.mode == Mode::PickDir); + // Row 0 is the current dir (DirKind::Use): Enter hands off to Spawn. + app.on_key_pickdir(key(KeyCode::Enter)); + assert!(app.mode == Mode::Spawn); + assert_eq!(app.spawn_group.as_deref(), Some("alpha")); +} + +/// State and Dir mode spawns are unassigned. +#[test] +fn state_and_dir_mode_spawns_stay_unassigned() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 + app.pump(); + app.resolve_selection(); + + for mode in [GroupMode::State, GroupMode::Dir] { + app.group_mode = mode; + app.spawn_group = Some("stale".to_string()); + app.on_key_dashboard(key(KeyCode::Char('n'))); + assert_eq!(app.spawn_group, None, "{mode:?} must not inherit"); + app.on_key_spawn(key(KeyCode::Esc)); + } + + app.on_key_dashboard(key(KeyCode::Char('n'))); + for c in "sleep 5".chars() { + app.on_key_spawn(key(KeyCode::Char(c))); + } + app.on_key_spawn(key(KeyCode::Enter)); + app.pump(); + let v = app.views.iter().find(|v| v.id == 2).unwrap(); + assert_eq!(v.group, None); +} diff --git a/src/core.rs b/src/core.rs index da8b865..8a11db6 100644 --- a/src/core.rs +++ b/src/core.rs @@ -41,7 +41,7 @@ pub enum Wake { /// has already fed the parser), so the loop should tick to ship it. Output, /// The command source ended: the client's socket hit EOF. Distinct from a - /// `Shutdown` command: the jobs keep running, only this connection is done. + /// `Shutdown` command: the tasks keep running, only this connection is done. Hangup, } @@ -52,9 +52,9 @@ pub type Waker = Arc>>>; /// Why the loop returned. pub enum LoopExit { - /// A `Shutdown` command: jobs killed, the caller stops the core. + /// A `Shutdown` command: tasks killed, the caller stops the core. Shutdown, - /// The client is gone (socket EOF, or a write failed). Keep the jobs; the + /// The client is gone (socket EOF, or a write failed). Keep the tasks; the /// daemon loops back to accept the next client. ClientGone, } @@ -91,7 +91,7 @@ fn ready_to_tick(dirty: bool, since_last_tick: Duration) -> bool { /// `stop` is an external stop request (the daemon's signal flag): checked once /// per wake/timeout, so a raised flag ends the loop within one `FALLBACK` even /// when nothing else is happening. It shuts down exactly like a `Shutdown` -/// command: jobs killed, `LoopExit::Shutdown` returned. +/// command: tasks killed, `LoopExit::Shutdown` returned. pub fn run_loop( sup: &mut Supervisor, wake_rx: &Receiver, @@ -259,7 +259,7 @@ mod tests { core.join().unwrap(); } - /// A raised stop flag ends the loop as `Shutdown` (killing the jobs) without + /// A raised stop flag ends the loop as `Shutdown` (killing the tasks) without /// any command arriving: the path a signalled daemon takes. #[test] fn stop_flag_ends_loop_with_shutdown() { diff --git a/src/daemon.rs b/src/daemon.rs index a49dcc1..77d438f 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -2,7 +2,7 @@ //! per-user Unix socket, and serves a client at a time: a hello handshake //! (protocol version + the client's launch context), then framed `Command`s in, //! framed `Event`s back. It runs the shared event-driven `core::run_loop`. The -//! supervisor **outlives each client connection**: `q` disconnects, the jobs +//! supervisor **outlives each client connection**: `q` disconnects, the tasks //! keep running, and the next `fleetcom` reattaches. //! //! It also autostarts a detached daemon when no socket is available. @@ -10,10 +10,10 @@ //! The fleet's lifetime is bounded by the daemon's. The daemon holds every //! task's PTY master, so daemon death of any kind closes them, and the kernel //! hangs up each task's controlling terminal: SIGHUP to its foreground process -//! group, which (job control being off under `$SHELL -c`) is the whole job. -//! A normal shutdown sends SIGTERM to each job group, then SIGKILL after a +//! group, which (job control being off under `$SHELL -c`) is the whole task. +//! A normal shutdown sends SIGTERM to each task group, then SIGKILL after a //! grace period, and removes the socket and lock. A crash or SIGKILL only -//! closes the PTYs; HUP-immune jobs can survive without a supervisor. +//! closes the PTYs; HUP-immune tasks can survive without a supervisor. use std::{ fs, @@ -330,7 +330,7 @@ fn spawn_daemon() -> io::Result<()> { Ok(()) } -/// `fleetcom --kill`: stop the daemon and every job it owns. Signal path, not +/// `fleetcom --kill`: stop the daemon and every task it owns. Signal path, not /// socket: the daemon serves one client at a time, so a `Shutdown` *frame* /// would sit in the accept backlog until an attached client detached. /// `--kill` must work while someone else is attached. The pid comes from the @@ -375,9 +375,9 @@ pub fn run_kill() -> io::Result<()> { Err(e) => return Err(io::Error::other(e)), } - // The daemon notices the flag within ~200 ms, then tears down its jobs. + // The daemon notices the flag within ~200 ms, then tears down its tasks. // Its exit releases the flock, so acquiring it is the completion signal: - // jobs dead, socket removed. 10 s covers the teardown with slack. + // tasks dead, socket removed. 10 s covers the teardown with slack. for _ in 0..200 { match Flock::lock(file, FlockArg::LockExclusiveNonblock) { Ok(_held) => return Ok(()), @@ -392,7 +392,7 @@ pub fn run_kill() -> io::Result<()> { } /// Send `Shutdown` when the lock file has no usable pid. Complete the handshake -/// first, then wait for the daemon to close the socket after stopping its jobs. +/// first, then wait for the daemon to close the socket after stopping its tasks. fn kill_via_socket() -> io::Result<()> { let path = socket_path(); match UnixStream::connect(&path) { @@ -416,7 +416,7 @@ fn kill_via_socket() -> io::Result<()> { /// The daemon entry point (`fleetcom --daemon`). Binds the socket and serves clients /// until an explicit shutdown. The supervisor is created once and persists across -/// reconnects: jobs outlive any single client. +/// reconnects: tasks outlive any single client. pub fn run_daemon() -> io::Result<()> { let dir = runtime_dir(); ensure_runtime_dir(&dir)?; // private 0700 directory @@ -455,11 +455,11 @@ pub fn run_daemon() -> io::Result<()> { // Use one scrollback depth for every task owned by this daemon. let mut sup = Supervisor::new(24, 80, supervisor::resolve_scrollback()); - // A signalled daemon shuts down *cleanly*: TERM each job's group with a + // A signalled daemon shuts down *cleanly*: TERM each task's group with a // KILL after the grace, remove the socket. Dying without that cleanup // would still kill the fleet (closing the PTY masters hangs up every - // job's terminal; see the module docs), but rudely: no TERM, no grace, - // and HUP-immune jobs would leak unowned. The flag is checked in the idle + // task's terminal; see the module docs), but rudely: no TERM, no grace, + // and HUP-immune tasks would leak unowned. The flag is checked in the idle // branch below and inside `run_loop` while a client is being served; both // observe it within ~200 ms. let term = Arc::new(AtomicBool::new(false)); @@ -468,15 +468,15 @@ pub fn run_daemon() -> io::Result<()> { // as shutdown like the rest. crate::install_signal_handlers(Arc::clone(&term))?; - // Non-blocking accept lets the daemon reap exited jobs while idle: + // Non-blocking accept lets the daemon reap exited tasks while idle: // between clients it would otherwise block in accept() and never call - // poll_exit, so a job that finished after `q` would linger as a zombie until + // poll_exit, so a task that finished after `q` would linger as a zombie until // a reconnect. listener.set_nonblocking(true)?; const IDLE_REAP: Duration = Duration::from_millis(100); loop { if term.load(Ordering::Relaxed) { - // Kill the jobs now, not via drop at the end of `main`: explicit at + // Kill the tasks now, not via drop at the end of `main`: explicit at // the one place the loop decides to stop. sup.apply(Command::Shutdown); break; @@ -532,7 +532,7 @@ fn transient_accept_error(e: &io::Error) -> bool { #[derive(PartialEq)] enum ServeOutcome { - /// Client left; daemon keeps running and the jobs survive. + /// Client left; daemon keeps running and the tasks survive. Disconnected, /// Client asked to kill everything and stop the daemon. Shutdown, diff --git a/src/harness/assets.rs b/src/harness/assets.rs index 35e756b..c482e4e 100644 --- a/src/harness/assets.rs +++ b/src/harness/assets.rs @@ -159,7 +159,7 @@ mod tests { use super::super::testutil::ID; use super::super::{CAPTURE_ENV, NOTIFY_CHAIN_ENV}; use super::*; - use crate::testutil::temp; + use crate::testutil::{install_fake_notifier, temp, write_executable}; fn mode(p: &Path) -> u32 { fs::metadata(p).unwrap().permissions().mode() & 0o777 @@ -365,21 +365,6 @@ mod tests { let _ = fs::remove_dir_all(&root); } - /// Install a fake notifier at `path` that records its argv, one token - /// per line, into `record`. - fn install_fake_notifier(path: &Path, record: &Path) { - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write( - path, - format!( - "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\n", - record.display() - ), - ) - .unwrap(); - fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); - } - /// A configured chain runs after capture and receives its original argv /// followed by the payload. Spaces within an argument remain intact. #[test] @@ -419,8 +404,7 @@ mod tests { let assets = CaptureAssets::install(&root, std::process::id()).unwrap(); let cap = assets.paths_for(4, 0).capture_file; let notifier = root.join("failing"); - fs::write(¬ifier, "#!/bin/sh\nexit 1\n").unwrap(); - fs::set_permissions(¬ifier, fs::Permissions::from_mode(0o700)).unwrap(); + write_executable(¬ifier, "exit 1"); let payload = r#"{"type":"agent-turn-complete","turn-id":"t4"}"#; let out = Command::new(&assets.codex_notify) diff --git a/src/harness/claude.rs b/src/harness/claude.rs index 6aec8f1..b650fb9 100644 --- a/src/harness/claude.rs +++ b/src/harness/claude.rs @@ -88,9 +88,9 @@ fn slug(cwd: &Path) -> Option { mod tests { use std::{fs, path::PathBuf}; - use super::super::testutil::{ID, OTHER, paths}; + use super::super::testutil::{ID, OTHER, assert_all_opaque, assert_corpus_scrape, paths}; use super::*; - use crate::testutil::{corpus_emulator, temp}; + use crate::testutil::temp; /// Claude-specific opaque shapes: flags, `--continue`/`-c`, subcommands, /// the short/`=` resume spellings, and `--session-id`. The syntax shared @@ -112,14 +112,7 @@ mod tests { format!("claude --session-id {ID}"), ]) .collect(); - for cmd in opaque { - assert_eq!(Claude.detect(&cmd), None, "{cmd:?} must be opaque"); - assert_eq!( - Claude.resume_command(&cmd, ID), - cmd, - "an opaque command must never be rewritten" - ); - } + assert_all_opaque(&Claude, ID, &opaque); } #[test] @@ -232,11 +225,10 @@ mod tests { /// The scraper recovers the exit-hint ID from the corpus terminal bytes. #[test] fn corpus_scrape_recovers_the_exit_hint_id() { - let mut emu = corpus_emulator(); - emu.process(include_bytes!("../../tests/corpus/claude_resume.bin")); - assert_eq!( - Claude.scrape_exit(&emu.text_with_history()).as_deref(), - Some("c8c4a5cc-0b32-4ba0-a6b4-6ed08c218e0d") + assert_corpus_scrape( + &Claude, + include_bytes!("../../tests/corpus/claude_resume.bin"), + "c8c4a5cc-0b32-4ba0-a6b4-6ed08c218e0d", ); } } diff --git a/src/harness/codex.rs b/src/harness/codex.rs index b4085ef..219153c 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -368,9 +368,9 @@ pub(crate) fn civil_from_days(days: i64) -> (i64, u32, u32) { mod tests { use std::path::PathBuf; - use super::super::testutil::{OTHER, paths}; + use super::super::testutil::{OTHER, assert_all_opaque, assert_corpus_scrape, paths}; use super::*; - use crate::testutil::{corpus_emulator, temp, v7_at, write_rollout}; + use crate::testutil::{temp, v7_at, write_rollout}; /// Codex's own launch and resume commands carry v7 IDs; the shared v4 /// fixture stays valid for detection, which is version-agnostic. @@ -404,14 +404,7 @@ mod tests { format!("codex --resume {ID}"), ]) .collect(); - for cmd in opaque { - assert_eq!(Codex.detect(&cmd), None, "{cmd:?} must be opaque"); - assert_eq!( - Codex.resume_command(&cmd, ID), - cmd, - "an opaque command must never be rewritten" - ); - } + assert_all_opaque(&Codex, ID, &opaque); } /// Scratch home without a `config.toml`. @@ -808,11 +801,10 @@ mod tests { /// terminal emulation removes the styling. #[test] fn corpus_scrape_recovers_the_exit_hint_id() { - let mut emu = corpus_emulator(); - emu.process(include_bytes!("../../tests/corpus/codex_resume.bin")); - assert_eq!( - Codex.scrape_exit(&emu.text_with_history()).as_deref(), - Some("019f5453-de22-7240-b2e5-0d32692aa6d9") + assert_corpus_scrape( + &Codex, + include_bytes!("../../tests/corpus/codex_resume.bin"), + "019f5453-de22-7240-b2e5-0d32692aa6d9", ); } } diff --git a/src/harness/grok.rs b/src/harness/grok.rs index ee5ad08..9b382b6 100644 --- a/src/harness/grok.rs +++ b/src/harness/grok.rs @@ -86,9 +86,9 @@ mod tests { use std::fs; use super::super::is_uuid; - use super::super::testutil::{ID, OTHER, paths}; + use super::super::testutil::{ID, OTHER, assert_all_opaque, assert_corpus_scrape, paths}; use super::*; - use crate::testutil::{corpus_emulator, temp}; + use crate::testutil::temp; /// Grok-specific opaque shapes: flags, the `-r`/`-s`/`=` spellings the /// tool prints but detection refuses, and subcommands. The syntax shared @@ -111,14 +111,7 @@ mod tests { format!("grok --resume {ID} --debug"), ]) .collect(); - for cmd in opaque { - assert_eq!(Grok.detect(&cmd), None, "{cmd:?} must be opaque"); - assert_eq!( - Grok.resume_command(&cmd, ID), - cmd, - "an opaque command must never be rewritten" - ); - } + assert_all_opaque(&Grok, ID, &opaque); } /// A bare launch gains only the pinned ID because Grok exposes no live @@ -226,11 +219,10 @@ mod tests { /// The scraper recovers the exit-hint ID from the corpus terminal bytes. #[test] fn corpus_scrape_recovers_the_exit_hint_id() { - let mut emu = corpus_emulator(); - emu.process(include_bytes!("../../tests/corpus/grok_resume.bin")); - assert_eq!( - Grok.scrape_exit(&emu.text_with_history()).as_deref(), - Some("17ac97af-8cfc-46a7-9599-8cea45a687a6") + assert_corpus_scrape( + &Grok, + include_bytes!("../../tests/corpus/grok_resume.bin"), + "17ac97af-8cfc-46a7-9599-8cea45a687a6", ); } } diff --git a/src/harness/mod.rs b/src/harness/mod.rs index dc841f6..54b3a3e 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -162,7 +162,7 @@ const PROGRAM_WORD_REFUSALS: &[char] = &[ /// program matches by basename, and the strict UUID may be bare or wrapped in /// the single quote pair emitted by `resume_command`. Extra arguments, prompts, /// alternate selectors, and shell syntax do not match. -pub(crate) fn detect_shape(cmd: &str, program: &str, selector: &str) -> Option { +fn detect_shape(cmd: &str, program: &str, selector: &str) -> Option { let mut words = cmd.split([' ', '\t']).filter(|w| !w.is_empty()); let first = words.next()?; if first.contains(PROGRAM_WORD_REFUSALS) || Path::new(first).file_name()?.to_str()? != program { @@ -187,7 +187,7 @@ fn unquote(token: &str) -> &str { /// Rewrite an accepted command as /// ` ''`. Invalid IDs and unsupported /// command shapes pass through unchanged. -pub(crate) fn resume_shape(cmd: &str, program: &str, selector: &str, id: &str) -> String { +fn resume_shape(cmd: &str, program: &str, selector: &str, id: &str) -> String { if !is_uuid(id) || detect_shape(cmd, program, selector).is_none() { return cmd.to_string(); } @@ -210,7 +210,7 @@ pub fn is_uuid(s: &str) -> bool { /// Return the strict UUID at the start of `s`. The next byte must end the token; /// an alphanumeric character, `-`, or `_` extends the token and rejects it. -pub(crate) fn leading_uuid(s: &str) -> Option<&str> { +fn leading_uuid(s: &str) -> Option<&str> { let head = s.get(..36).filter(|h| is_uuid(h))?; match s.as_bytes().get(36) { Some(&c) if c.is_ascii_alphanumeric() || c == b'-' || c == b'_' => None, @@ -221,7 +221,7 @@ pub(crate) fn leading_uuid(s: &str) -> Option<&str> { /// Extract the ID after the last valid resume hint in `text`. Every /// occurrence of every `hints` prefix competes when a strict UUID follows it, /// and the largest byte offset wins across prefixes. -pub(crate) fn last_hint(text: &str, hints: &[&str]) -> Option { +fn last_hint(text: &str, hints: &[&str]) -> Option { let mut last: Option<(usize, String)> = None; for hint in hints { for (i, _) in text.match_indices(hint) { @@ -237,7 +237,7 @@ pub(crate) fn last_hint(text: &str, hints: &[&str]) -> Option { /// Generate a v4 UUID from `/dev/urandom`. A read failure returns `None`, which /// lets the caller launch without pinning an ID. -pub(crate) fn uuid_v4() -> Option { +fn uuid_v4() -> Option { use std::fmt::Write; let mut bytes = [0u8; 16]; File::open("/dev/urandom") @@ -259,7 +259,7 @@ pub(crate) fn uuid_v4() -> Option { /// Spawn plan for the launch-time ID pin: a bare launch pins a fresh v4 UUID /// through `--session-id`, the resume form already targets its conversation, /// and a `uuid_v4` failure launches without pinning. -pub(crate) fn pin_plan(inv: &Invocation) -> SpawnPlan { +fn pin_plan(inv: &Invocation) -> SpawnPlan { let mut plan = SpawnPlan::default(); if *inv == Invocation::Bare && let Some(id) = uuid_v4() @@ -271,7 +271,7 @@ pub(crate) fn pin_plan(inv: &Invocation) -> SpawnPlan { } /// Whether `a` and `b` differ by at most [`CORRELATE_WINDOW`]. -pub(crate) fn within_window(a: SystemTime, b: SystemTime) -> bool { +fn within_window(a: SystemTime, b: SystemTime) -> bool { match a.duration_since(b) { Ok(d) => d <= CORRELATE_WINDOW, Err(e) => e.duration() <= CORRELATE_WINDOW, @@ -279,7 +279,7 @@ pub(crate) fn within_window(a: SystemTime, b: SystemTime) -> bool { } /// Millisecond form of [`within_window`] for UUID-embedded timestamps. -pub(crate) fn within_window_ms(a: u128, b: u128) -> bool { +fn within_window_ms(a: u128, b: u128) -> bool { a.abs_diff(b) <= CORRELATE_WINDOW.as_millis() } @@ -288,7 +288,7 @@ pub(crate) fn within_window_ms(a: u128, b: u128) -> bool { /// creation times cannot be correlated by window and are skipped too. Several /// in-window candidates cannot be told apart, and a stray non-uuid candidate /// still counts against uniqueness: both return `None`. -pub(crate) fn unique_in_window( +fn unique_in_window( dir: PathBuf, spawned: SystemTime, candidate: impl Fn(&fs::DirEntry) -> Option, @@ -313,17 +313,17 @@ pub(crate) fn unique_in_window( } /// Single-quote `s` for `$SHELL -c`, encoding embedded `'` as `'\''`. -pub(crate) fn shell_quote(s: &str) -> String { +fn shell_quote(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } -/// Fixtures shared by the per-harness test modules and the supervisor's -/// capture suite. +/// Fixtures and assertions for harness detection and exit scraping. #[cfg(test)] pub(crate) mod testutil { use std::path::PathBuf; - use super::CapturePaths; + use super::{CapturePaths, Harness}; + use crate::testutil::corpus_emulator; /// Strict v4 UUID used wherever a valid session ID is needed. pub(crate) const ID: &str = "c8c4a5cc-0b32-4ba0-a6b4-6ed08c218e0d"; @@ -332,13 +332,31 @@ pub(crate) mod testutil { /// Capture-path fixture. The spaced `claude_settings` and `codex_notify` /// paths keep the shell- and TOML-quoting assertions honest. - pub(crate) fn paths() -> CapturePaths { + pub(super) fn paths() -> CapturePaths { CapturePaths { capture_file: PathBuf::from("/tmp/cap/session.json"), claude_settings: PathBuf::from("/tmp/Application Support/fleetcom.json"), codex_notify: PathBuf::from("/tmp/Application Support/notify.sh"), } } + + /// Assert that every command is opaque to `h`: detection fails and resume + /// leaves the command unchanged. + pub(super) fn assert_all_opaque(h: &dyn Harness, id: &str, cmds: &[String]) { + for cmd in cmds { + assert_eq!(h.detect(cmd), None, "{cmd:?} must be opaque"); + let resumed = h.resume_command(cmd, id); + assert_eq!(resumed, *cmd, "an opaque command must never be rewritten"); + } + } + + /// Replay `bytes` at corpus geometry and assert the scraped exit ID. + pub(super) fn assert_corpus_scrape(h: &dyn Harness, bytes: &[u8], expected: &str) { + let mut emu = corpus_emulator(); + emu.process(bytes); + let text = emu.text_with_history(); + assert_eq!(h.scrape_exit(&text).as_deref(), Some(expected)); + } } #[cfg(test)] diff --git a/src/harness/summary.rs b/src/harness/summary.rs index 8222cfe..48e793a 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -27,30 +27,7 @@ use std::path::Path; -use crate::preview::ScreenFacts; - -/// Display-only status and model-label extraction for one agent CLI. -pub trait SummaryAdapter: Sync { - /// Return normalized live status and its matcher ID when the expected - /// chrome structure is present. - fn live_preview(&self, screen: &dyn ScreenFacts) -> Option<(String, &'static str)>; - - /// Return a model label from stable CLI chrome. The preview cascade - /// prepends it to live status as `{label} · `. - fn model_label(&self, screen: &dyn ScreenFacts) -> Option; - - /// Return an optional final summary derived from retained terminal text. - /// The default implementation produces no summary. - fn exit_preview(&self, _retained_text: &str) -> Option { - None - } - - /// Optionally normalize a captured title for display. Emulator title - /// capture remains program-agnostic; `None` renders the title verbatim. - fn normalize_title(&self, _title: &str) -> Option { - None - } -} +use crate::preview::{ScreenFacts, SummaryAdapter}; /// Select an adapter by the basename of the command's first /// whitespace-separated word. Arguments are accepted; environment prefixes @@ -80,6 +57,38 @@ fn is_rule_row(row: &str) -> bool { n >= 40 } +/// The status phrase of a spinner row: a frame char accepted by `is_frame`, +/// a space, then text through the first `…` inclusive. The phrase must open +/// alphanumeric; past that it is task-derived and unconstrained. Trailing +/// text is left for the caller to interpret. +fn spinner_text(row: &str, is_frame: impl Fn(char) -> bool) -> Option { + let mut chars = row.chars(); + if !is_frame(chars.next()?) || chars.next()? != ' ' { + return None; + } + let rest = chars.as_str(); + let text = &rest[..rest.find('…')? + '…'.len_utf8()]; + text.chars() + .next()? + .is_alphanumeric() + .then(|| text.to_string()) +} + +/// Keep nonempty ` · `-separated segments not matched by `drop`, preserving +/// their order and separator prefixes. +fn slow_segments(tail: &str, drop: impl Fn(&str) -> bool) -> String { + let mut out = String::new(); + for seg in tail.split(" · ") { + let seg = seg.trim(); + if seg.is_empty() || drop(seg) { + continue; + } + out.push_str(" · "); + out.push_str(seg); + } + out +} + // ---------------------------------------------------------------- claude -- /// Accepted claude spinner frames. A frame matches only when followed by a @@ -155,7 +164,7 @@ fn claude_spinner_status(rows: &[String], top: usize) -> Option<(String, &'stati if row.starts_with(' ') { continue; } - if let Some(verb) = claude_spinner_text(row) { + if let Some(verb) = spinner_text(row, |c| CLAUDE_SPINNER.contains(&c)) { // The spinner row's parenthetical contributes its slow // semantic tail to whichever text wins the head. let tail = claude_semantic_tail(row); @@ -197,24 +206,6 @@ fn claude_waiting_text(row: &str) -> Option { .then(|| text.to_string()) } -/// Extract the text through the first `…` after a claude spinner frame. -/// Task-derived phrases may contain spaces, parentheses, and digits. The -/// parenthetical after the ellipsis is not discarded wholesale: its -/// recognized tickers drop and its slow segments survive through -/// [`claude_semantic_tail`]. -fn claude_spinner_text(row: &str) -> Option { - let mut chars = row.chars(); - if !CLAUDE_SPINNER.contains(&chars.next()?) || chars.next()? != ' ' { - return None; - } - let rest = chars.as_str(); - let text = &rest[..rest.find('…')? + '…'.len_utf8()]; - text.chars() - .next()? - .is_alphanumeric() - .then(|| text.to_string()) -} - /// The spinner parenthetical's slow semantic tail: /// `(1m 8s · ↓ 2.1k tokens · thinking with high effort)` keeps /// ` · thinking with high effort`. Recognized ticker segments drop; @@ -226,16 +217,7 @@ fn claude_semantic_tail(row: &str) -> String { }; let inner = &row[open + "… (".len()..]; let inner = inner.strip_suffix(')').unwrap_or(inner); - let mut out = String::new(); - for seg in inner.split(" · ") { - let seg = seg.trim(); - if seg.is_empty() || claude_ticker_segment(seg) { - continue; - } - out.push_str(" · "); - out.push_str(seg); - } - out + slow_segments(inner, claude_ticker_segment) } /// Whether one parenthetical segment is recognized ticker churn: elapsed @@ -434,17 +416,8 @@ fn codex_status(rows: &[String], composer: usize) -> Option<(String, &'static st /// everything else is slow-moving state and is kept, with its own ellipsis /// when the CLI truncated it. fn codex_working(after_paren: &str) -> String { - let mut out = String::from("Working"); let tail = after_paren.find(')').map_or("", |i| &after_paren[i + 1..]); - for seg in tail.split(" · ") { - let seg = seg.trim(); - if seg.is_empty() || seg.starts_with('/') { - continue; - } - out.push_str(" · "); - out.push_str(seg); - } - out + format!("Working{}", slow_segments(tail, |seg| seg.starts_with('/'))) } // ------------------------------------------------------------------ grok -- @@ -463,7 +436,9 @@ impl SummaryAdapter for GrokSummary { // states and match neither shape. let probe = rows[..top].iter().rev().find(|r| !r.is_empty())?; let t = probe.trim_start(); - if let Some(text) = grok_spinner_text(t) { + // Keep the label through its first ellipsis. Wrapped tail rows have no + // spinner prefix, so they fail the frame check and fall through. + if let Some(text) = spinner_text(t, |c| ('\u{2800}'..='\u{28FF}').contains(&c)) { return Some((text, "grok:spinner")); } grok_worked(t).then(|| (t.to_string(), "grok:worked")) @@ -494,24 +469,6 @@ fn grok_input_box(rows: &[String]) -> Option<(usize, usize)> { .then_some((top, bottom)) } -/// `⠼ Sleep 5 seconds then echo ok… 1.5s 2.8s ⇣14.2k [↓][stop]` → the label -/// through its `…`: a braille spinner frame, a space, text cut at the first -/// `…`. Everything after it is elapsed/throughput ticker. A wrapped status -/// row leaves its `…` tail on the probe row with no spinner head, which -/// fails here and falls through. -fn grok_spinner_text(t: &str) -> Option { - let mut chars = t.chars(); - if !('\u{2800}'..='\u{28FF}').contains(&chars.next()?) || chars.next()? != ' ' { - return None; - } - let rest = chars.as_str(); - let text = &rest[..rest.find('…')? + '…'.len_utf8()]; - text.chars() - .next()? - .is_alphanumeric() - .then(|| text.to_string()) -} - /// The completion row grok leaves above its box, kept verbatim: `Worked for /// 8.7s`, with digits, `.`, and the `m`/`h`/space of longer durations /// tolerated after a leading digit. @@ -538,1073 +495,5 @@ fn grok_border_label(row: &str) -> Option { } #[cfg(test)] -mod tests { - use std::time::Instant; - - use super::*; - use crate::{ - emulator::Emulator, - preview::{MARKER, Preview, PreviewSource, PreviewState}, - }; - - /// Synthetic screen: adapters read only `live_rows`, so the other facts - /// are inert defaults. - struct RowsScreen { - rows: Vec, - } - - fn rs(rows: &[&str]) -> RowsScreen { - RowsScreen { - rows: rows.iter().map(|s| s.to_string()).collect(), - } - } - - impl ScreenFacts for RowsScreen { - fn revision(&self) -> u64 { - 1 - } - - fn alt_epoch(&self) -> u64 { - 0 - } - - fn alternate_screen(&self) -> bool { - false - } - - fn title(&self) -> Option<&str> { - None - } - - fn live_floor(&self) -> String { - self.rows - .iter() - .rev() - .find(|r| !r.is_empty()) - .cloned() - .unwrap_or_default() - } - - fn live_rows(&self) -> Vec { - self.rows.clone() - } - - fn alt_leave_floor(&self) -> Option<&str> { - None - } - } - - /// Replay a corpus fixture and resolve one preview against its final - /// screen with `adapter` installed. - fn resolve_corpus(bytes: &[u8], adapter: &dyn SummaryAdapter, rows: u16, cols: u16) -> Preview { - let mut emu = Emulator::new(rows, cols, 2000); - emu.process(bytes); - let mut st = PreviewState::new(); - st.resolve(Instant::now(), false, &emu, Some(adapter)) - .clone() - } - - fn anchor(text: &str, rule: &'static str) -> (String, PreviewSource, Option<&'static str>) { - (text.to_string(), PreviewSource::Anchor, Some(rule)) - } - - fn parts(p: &Preview) -> (String, PreviewSource, Option<&'static str>) { - (p.text.clone(), p.source, p.rule) - } - - /// Selection is a basename match on the first word only: wider than - /// harness detection (arguments are tolerated), but env prefixes and - /// shell syntax glued to the word select nothing. - #[test] - fn select_matches_first_word_basenames_only() { - for cmd in [ - "claude", - "claude --model opus", - "/usr/local/bin/claude --resume abc", - "codex resume 'not-checked-here'", - "grok", - ] { - assert!(select(cmd).is_some(), "{cmd:?} must select an adapter"); - } - for cmd in ["vim", "FOO=bar claude", "claude|tee log", "codex; ls", ""] { - assert!(select(cmd).is_none(), "{cmd:?} must select nothing"); - } - } - - /// Each program word routes to its own CLI's matchers: the selected - /// adapter fires that CLI's rule on that CLI's screen shape. - #[test] - fn select_routes_to_the_matching_adapter() { - let sep = "─".repeat(80); - let claude = rs(&["✻ Hashing… (6s · ↓ 87 tokens)", &sep, "❯", &sep]); - assert_eq!( - select("claude").unwrap().live_preview(&claude).unwrap().1, - "claude:spinner" - ); - let codex = rs(&[ - "• Working (2s • esc to interrupt)", - "", - "› Write tests", - "", - " gpt-5.6-sol high · 0 in · 0 out", - ]); - assert_eq!( - select("codex").unwrap().live_preview(&codex).unwrap().1, - "codex:working" - ); - let grok = rs(&[ - " ⠼ Sleep 5 seconds then echo ok… 1.5s 2.8s ⇣14.2k [↓][stop]", - "", - " ╭──────────────────────╮", - " │ ❯ │", - " ╰── Grok 4.5 (xhigh) · always-approve ─╯", - ]); - assert_eq!( - select("grok").unwrap().live_preview(&grok).unwrap().1, - "grok:spinner" - ); - } - - /// The spinner phrase survives, the elapsed/token parenthetical drops, - /// and the concrete-action row wins over the spinner when present. - #[test] - fn claude_spinner_and_action_row() { - let sep = "─".repeat(120); - let spin = rs(&["✻ Hashing… (6s · ↓ 87 tokens)", &sep, "❯", &sep, " status"]); - assert_eq!( - ClaudeSummary.live_preview(&spin), - Some(("Hashing…".to_string(), "claude:spinner")) - ); - - let action = rs(&[ - "⏺ Running 1 shell command…", - "", - "· Hashing… (3s · ↓ 52 tokens)", - &sep, - "❯", - &sep, - ]); - assert_eq!( - ClaudeSummary.live_preview(&action), - Some(("Running 1 shell command…".to_string(), "claude:action-row")) - ); - - // An indented attachment above the spinner is not the action row. - let attach = rs(&[ - " Running 1 shell command…", - " ⎿ $ sleep 5 && echo ok", - "✻ Hashing… (6s)", - &sep, - "❯", - &sep, - ]); - assert_eq!( - ClaudeSummary.live_preview(&attach), - Some(("Hashing…".to_string(), "claude:spinner")) - ); - - // A `⏺` reply row without a trailing ellipsis is not the action row. - let reply = rs(&["⏺ ok", "", "✻ Hashing… (2s)", &sep, "❯", &sep]); - assert_eq!( - ClaudeSummary.live_preview(&reply), - Some(("Hashing…".to_string(), "claude:spinner")) - ); - } - - /// Task-derived spinner phrases may contain spaces, parentheses, and - /// digits; extraction keeps everything through the first ellipsis. - #[test] - fn claude_spinner_extracts_task_derived_phrases() { - let sep = "─".repeat(120); - let s = rs(&[ - "✳ Overseeing phase 4 (adapters)… (54s · almost done thinking with high effort)", - &sep, - "❯", - &sep, - ]); - assert_eq!( - ClaudeSummary.live_preview(&s), - Some(( - "Overseeing phase 4 (adapters)… · almost done thinking with high effort" - .to_string(), - "claude:spinner" - )) - ); - } - - /// Parenthetical segments: recognized ticker shapes are dropped and - /// unknown segments are preserved. A bare row is unchanged. - #[test] - fn claude_parenthetical_keeps_slow_segments_and_drops_tickers() { - let sep = "─".repeat(120); - let spin = |row: &str| { - let rows = [row, &sep, "❯", &sep]; - ClaudeSummary.live_preview(&rs(&rows)) - }; - assert_eq!( - spin("✻ Envisioning… (1m 8s · ↓ 2.1k tokens · thinking with high effort)"), - Some(( - "Envisioning… · thinking with high effort".to_string(), - "claude:spinner" - )) - ); - for ticker in [ - "6s", - "1m 8s", - "2h 3m", - "8.7s", - "↓ 87 tokens", - "↑ 1.2k tokens", - "↓ 2.1k", - "2.1k tokens", - "esc to interrupt", - ] { - assert_eq!( - spin(&format!("✻ Hashing… ({ticker})")), - Some(("Hashing…".to_string(), "claude:spinner")), - "{ticker:?} must drop" - ); - assert_eq!( - spin(&format!("✻ Hashing… ({ticker} · thinking)")), - Some(("Hashing… · thinking".to_string(), "claude:spinner")), - "{ticker:?} must drop beside a kept segment" - ); - } - assert_eq!( - spin("✽ Concocting…"), - Some(("Concocting…".to_string(), "claude:spinner")), - "a row with no parenthetical is unchanged" - ); - } - - /// The action row wins the head while the spinner row's parenthetical - /// still contributes the semantic tail. - #[test] - fn claude_action_row_carries_the_spinner_rows_semantic_tail() { - let sep = "─".repeat(120); - let rows = [ - "⏺ Running 1 shell command…", - "", - "✻ Envisioning… (1m 8s · ↓ 2.1k tokens · thinking with high effort)", - &sep, - "❯", - &sep, - ]; - assert_eq!( - ClaudeSummary.live_preview(&rs(&rows)), - Some(( - "Running 1 shell command… · thinking with high effort".to_string(), - "claude:action-row" - )) - ); - } - - /// Waiting rows return verbatim; malformed skeletons fail and do not - /// trigger an action-row lookup. - #[test] - fn claude_waiting_family_matches_the_skeleton_and_never_probes() { - let sep = "─".repeat(120); - let spin = |row: &str| { - let rows = [row, &sep, "❯", &sep]; - ClaudeSummary.live_preview(&rs(&rows)) - }; - for row in [ - "✻ Waiting for 1 background agent to finish", - "· Waiting for 1 background agent to finish", - "✻ Waiting for 3 background agents to finish", - "✻ Waiting for 1 dynamic workflow to finish", - "✽ Waiting for 3 dynamic workflows to finish", - "✻ Waiting for 2 tasks to finish", - ] { - let want = row.chars().skip(2).collect::(); - assert_eq!(spin(row), Some((want, "claude:waiting")), "{row:?}"); - } - - // Reject missing digits, more than three subject words, a foreign - // suffix, or a missing subject. - for row in [ - "✻ Waiting patiently", - "· Waiting for review comments to land", - "✻ Waiting for some agents to finish", - "✻ Waiting for 2 very long noun phrases here to finish", - "✻ Waiting for 2 agents to start", - "✻ Waiting for 3 to finish", - ] { - assert_eq!(spin(row), None, "{row:?}"); - } - - // Waiting rows return without probing the action row above them. - let rows = [ - "⏺ Running 1 shell command…", - "", - "✻ Waiting for 1 background agent to finish", - &sep, - "❯", - &sep, - ]; - assert_eq!( - ClaudeSummary.live_preview(&rs(&rows)), - Some(( - "Waiting for 1 background agent to finish".to_string(), - "claude:waiting" - )) - ); - } - - /// Every spinner frame canonicalizes to `✻`; non-frame titles pass through. - #[test] - fn claude_title_frames_canonicalize_to_constant_text() { - for frame in CLAUDE_SPINNER { - assert_eq!( - ClaudeSummary.normalize_title(&format!("{frame} Claude Code")), - Some("✻ Claude Code".to_string()), - "{frame:?}" - ); - } - let a = ClaudeSummary.normalize_title("✢ Claude Code"); - let b = ClaudeSummary.normalize_title("✽ Claude Code"); - assert_eq!(a, b, "two frames must normalize identically"); - - // A braille frame plus the session summary. - assert_eq!( - ClaudeSummary.normalize_title("⠐ Review fleetcom preview design document"), - Some("✻ Review fleetcom preview design document".to_string()) - ); - assert_eq!( - ClaudeSummary.normalize_title("⠴ Review fleetcom preview design document"), - Some("✻ Review fleetcom preview design document".to_string()), - "mid-block braille frame" - ); - - assert_eq!(ClaudeSummary.normalize_title("zellij: main"), None); - assert_eq!(ClaudeSummary.normalize_title("✻"), None, "frame alone"); - } - - /// Cascade-level: with the claude adapter installed and no anchor on - /// the screen, a frame-led title renders canonicalized under the Title - /// tier; without an adapter it renders verbatim. - #[test] - fn title_tier_renders_the_normalized_title() { - let mut emu = Emulator::new(24, 80, 100); - emu.process(b"\x1b[?1049h\x1b]0;\xe2\x9c\xa2 Claude Code\x07conversation body"); - let mut st = PreviewState::new(); - let p = st - .resolve(Instant::now(), false, &emu, Some(&ClaudeSummary)) - .clone(); - assert_eq!( - (p.text.as_str(), p.source, p.rule), - ("✻ Claude Code", PreviewSource::Title, None) - ); - - let mut st = PreviewState::new(); - let p = st.resolve(Instant::now(), false, &emu, None).clone(); - assert_eq!( - (p.text.as_str(), p.source), - ("✢ Claude Code", PreviewSource::Title), - "no adapter: verbatim" - ); - } - - /// A column-0 row in the chrome window that is not spinner-shaped aborts: - /// a wrapped status tail and body text touching the chrome both refuse. - #[test] - fn claude_aborts_on_foreign_column_zero_rows() { - let sep = "─".repeat(120); - let wrapped = rs(&["✻ Hashing… (6s · ↓ 87 to", "kens)", &sep, "❯", &sep]); - assert_eq!(ClaudeSummary.live_preview(&wrapped), None); - - // A menu quoted in the body, reaching the window with the input box - // intact, refuses rather than synthesizing approval. - let menu = rs(&["❯ 1. Yes", " 2. No", &sep, "❯", &sep]); - assert_eq!(ClaudeSummary.live_preview(&menu), None); - } - - /// The status scan crosses bounded indented gaps but stops at body prose. - #[test] - fn claude_scan_crosses_task_list_gaps_within_the_window() { - let sep = "─".repeat(120); - let behind_gap = |status: &str, gap: usize| { - let mut rows = vec![status.to_string()]; - rows.push(" ⎿ ✔ Phase 0: verify facts".to_string()); - rows.extend((1..gap).map(|i| format!(" ◼ Phase {i}: generic step"))); - rows.extend([sep.clone(), "❯".to_string(), sep.clone()]); - let refs: Vec<&str> = rows.iter().map(String::as_str).collect(); - ClaudeSummary.live_preview(&rs(&refs)) - }; - for gap in [4, 15] { - assert_eq!( - behind_gap( - "✢ Running phase 1 (dashboard UI)… (4m 20s · ↓ 17.1k tokens)", - gap - ), - Some(( - "Running phase 1 (dashboard UI)…".to_string(), - "claude:spinner" - )), - "gap of {gap} indented rows" - ); - } - for gap in [16, 17, 24] { - assert_eq!( - behind_gap( - "✢ Running phase 1 (dashboard UI)… (4m 20s · ↓ 17.1k tokens)", - gap - ), - None, - "gap of {gap} indented rows must exhaust the window" - ); - } - - // Waiting rows use the same bounded scan. - assert_eq!( - behind_gap("✻ Waiting for 2 background agents to finish", 5), - Some(( - "Waiting for 2 background agents to finish".to_string(), - "claude:waiting" - )) - ); - - // Column-0 body prose invalidates the status structure. - let prose = rs(&[ - "✢ Running phase 1 (dashboard UI)… (4m 20s · ↓ 17.1k tokens)", - "⏺ The phase list below is queued, not running.", - " ⎿ ✔ Phase 0: verify facts", - " ◼ Phase 1: dashboard polish", - &sep, - "❯", - &sep, - ]); - assert_eq!(ClaudeSummary.live_preview(&prose), None); - } - - /// Blank rows do not consume the nonblank-row window. - #[test] - fn claude_blank_rows_do_not_consume_the_window() { - let sep = "─".repeat(120); - let resolve = |rows: Vec| { - let refs: Vec<&str> = rows.iter().map(String::as_str).collect(); - ClaudeSummary.live_preview(&rs(&refs)) - }; - let boxed = |sep: &str| [sep.to_string(), "❯ /workflows".to_string(), sep.to_string()]; - - // Nineteen blank rows separate the waiting row from the input box. - let mut rows = vec!["✻ Waiting for 1 dynamic workflow to finish".to_string()]; - rows.extend(std::iter::repeat_n(String::new(), 19)); - rows.extend(boxed(&sep)); - assert_eq!( - resolve(rows), - Some(( - "Waiting for 1 dynamic workflow to finish".to_string(), - "claude:waiting" - )) - ); - - // Fifteen indented rows plus the spinner fill the 16-row window; - // interleaved blank rows do not affect the count. - let mut rows = vec!["✢ Running phase 1 (dashboard UI)… (4m 20s)".to_string()]; - for i in 0..15 { - rows.push(String::new()); - rows.push(format!(" ◼ Phase {i}: generic step")); - } - rows.extend(boxed(&sep)); - assert_eq!( - resolve(rows), - Some(( - "Running phase 1 (dashboard UI)…".to_string(), - "claude:spinner" - )) - ); - - // Sixteen indented rows plus the spinner exceed the window. - let mut rows = vec!["✢ Running phase 1 (dashboard UI)… (4m 20s)".to_string()]; - for i in 0..16 { - rows.push(String::new()); - rows.push(format!(" ◼ Phase {i}: generic step")); - } - rows.extend(boxed(&sep)); - assert_eq!(resolve(rows), None); - - // An intervening column-0 prose row still aborts the scan. - let prose = rs(&[ - "✻ Hashing… (6s · ↓ 87 tokens)", - "", - "", - "⏺ The workflow report lands below.", - "", - "", - &sep, - "❯ /workflows", - &sep, - ]); - assert_eq!(ClaudeSummary.live_preview(&prose), None); - } - - /// The approval menu synthesizes its label only with the input box gone, - /// and requires the `2.` sibling below the selector. - #[test] - fn claude_approval_requires_the_dialog_shape() { - let dialog = rs(&[ - " Do you want to create word.txt?", - " ❯ 1. Yes", - " 2. Yes, allow all edits during this session (shift+tab)", - " 3. No", - "", - " Esc to cancel · Tab to amend", - ]); - assert_eq!( - ClaudeSummary.live_preview(&dialog), - Some(("awaiting approval".to_string(), "claude:approval-menu")) - ); - - let lone = rs(&[" ❯ 1. Yes", "", " Esc to cancel"]); - assert_eq!(ClaudeSummary.live_preview(&lone), None); - } - - /// The model label comes from the welcome box and reads as - /// `{model} ({effort})`; no box, no label. - #[test] - fn claude_label_reads_the_welcome_box() { - let boxed = rs(&[ - "╭─── Claude Code v2.1.215 ────────────╮", - "│ Fable 5 with high effort · Claude Max · │ notes │", - "╰──────────────────────────────────────╯", - ]); - assert_eq!( - ClaudeSummary.model_label(&boxed), - Some("Fable 5 (high)".to_string()) - ); - assert_eq!(ClaudeSummary.model_label(&rs(&["no box here"])), None); - } - - /// Working-row normalization: parenthetical dropped (unclosed included), - /// `/`-hint suffixes dropped, slow suffixes kept, with the CLI's own - /// ellipsis when truncated. - #[test] - fn codex_working_normalization() { - let tail = [ - "", - "› Write tests for @filename", - "", - " gpt-5.6-sol high · 0 in · 0 out", - ]; - let full = "• Working (7s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close"; - for (row, want) in [ - (full, "Working · 1 background terminal running"), - ("• Working (2s • esc to interrupt)", "Working"), - ("• Working (7s • esc to…", "Working"), - ( - "• Working (7s • esc to interrupt) · 1 background termi…", - "Working · 1 background termi…", - ), - ] { - let mut rows = vec![row]; - rows.extend(tail); - assert_eq!( - CodexSummary.live_preview(&rs(&rows)), - Some((want.to_string(), "codex:working")), - "{row:?}" - ); - } - assert_eq!( - CodexSummary.model_label(&rs(&tail[1..])), - Some("gpt-5.6-sol high".to_string()) - ); - } - - /// `• Ran` extracts through its indented attachment, but never through a - /// foreign column-0 row: scrollback `• Ran` rows from prior turns sit - /// behind reply bullets and separators, and skipping those would - /// resurface stale work. - #[test] - fn codex_ran_stops_at_foreign_rows() { - let transient = rs(&[ - "• Ran sleep 5 && echo ok", - " └ ok", - "", - "› ", - "", - " gpt-5.6-sol high · 1 in · 2 out", - ]); - assert_eq!( - CodexSummary.live_preview(&transient), - Some(("Ran sleep 5 && echo ok".to_string(), "codex:ran")) - ); - - let sep = "─".repeat(120); - let behind_reply = rs(&[ - "• Ran sleep 5 && echo ok", - " └ ok", - "", - &sep, - "", - "• ok", - "", - "› ", - "", - " gpt-5.6-sol high · 1 in · 2 out", - ]); - assert_eq!(CodexSummary.live_preview(&behind_reply), None); - } - - /// A hint row may follow the composer without a token bar. The anchor - /// still fires, without a model prefix. - #[test] - fn codex_hint_row_layout_anchors_without_a_token_bar() { - let hinted = rs(&[ - "• Running cargo test --test daemon_env", - "", - "", - "• Working (10m 26s • esc to interrupt)", - "", - "›", - "", - " tab to queue message", - ]); - assert_eq!( - CodexSummary.live_preview(&hinted), - Some(("Working".to_string(), "codex:working")) - ); - assert_eq!(CodexSummary.model_label(&hinted), None); - } - - /// The approval modal replaces composer and token bar with a numbered - /// menu; the selector row plus a numbered sibling synthesizes the - /// label, wherever the selection sits. - #[test] - fn codex_approval_modal_synthesizes_on_any_selection() { - let on_first = rs(&[ - " Would you like to run the following command?", - "", - " $ cargo test --test daemon_env", - "", - "› 1. Yes, proceed (y)", - " 2. Yes, and don't ask again for commands that start with `cargo test` (p)", - " 3. No, and tell Codex what to do differently (esc)", - "", - " Press enter to confirm or esc to cancel", - ]); - assert_eq!( - CodexSummary.live_preview(&on_first), - Some(("awaiting approval".to_string(), "codex:approval-menu")) - ); - - let on_second = rs(&[ - " 1. Yes, proceed (y)", - "› 2. Yes, and don't ask again (p)", - " 3. No (esc)", - "", - " Press enter to confirm or esc to cancel", - ]); - assert_eq!( - CodexSummary.live_preview(&on_second), - Some(("awaiting approval".to_string(), "codex:approval-menu")) - ); - } - - /// A menu quoted in the conversation always has the live composer - /// somewhere below it; the composer's presence suppresses the modal - /// match, and the quote is a foreign row to the status scan: no - /// anchor, floor tier. - #[test] - fn codex_quoted_menu_with_a_live_composer_is_not_a_modal() { - let quoted = rs(&[ - "• I found these options in the doc:", - "", - "› 1. Yes, proceed (y)", - " 2. No, cancel (esc)", - "", - "›", - "", - " gpt-5.6-sol high · 0 in · 0 out", - ]); - assert_eq!(CodexSummary.live_preview("ed), None); - } - - /// Without any composer row (codex exited; its resume hint owns the - /// floor) the whole pin fails. - #[test] - fn codex_requires_the_composer_pin() { - let exited = rs(&[ - "• Ran sleep 5 && echo ok", - "", - "Token usage: total=14,353 input=14,063", - "To continue this session, run codex resume 0199-fake", - ]); - assert_eq!(CodexSummary.live_preview(&exited), None); - assert_eq!(CodexSummary.model_label(&exited), None); - } - - /// Spinner label cut at its `…`; the completion row kept verbatim, - /// longer durations included; free text above the box refuses. - #[test] - fn grok_status_shapes() { - let boxed = [ - " ╭──────────────────────╮", - " │ ❯ │", - " ╰── Grok 4.5 (xhigh) · always-approve ─╯", - ]; - let probe = |status: &str| { - let mut rows = vec![status, ""]; - rows.extend(boxed); - GrokSummary.live_preview(&rs(&rows)) - }; - assert_eq!( - probe(" ⠼ Sleep 5 seconds then echo ok… 1.5s 2.8s ⇣14.2k [↓][stop]"), - Some(("Sleep 5 seconds then echo ok…".to_string(), "grok:spinner")) - ); - assert_eq!( - probe(" ⠋ Thinking… 0.2s"), - Some(("Thinking…".to_string(), "grok:spinner")) - ); - assert_eq!( - probe(" Worked for 8.7s"), - Some(("Worked for 8.7s".to_string(), "grok:worked")) - ); - assert_eq!( - probe(" Worked for 1m 24s"), - Some(("Worked for 1m 24s".to_string(), "grok:worked")) - ); - assert_eq!(probe(" Worked for a while"), None); - assert_eq!( - probe(" Coming from Codex? Resume your session from 7m ago using ctrl+u"), - None - ); - - let mut rows = vec![" ⠋ Thinking… 0.2s", ""]; - rows.extend(boxed); - assert_eq!( - GrokSummary.model_label(&rs(&rows)), - Some("Grok 4.5 (xhigh)".to_string()) - ); - // A plain border carries no label. - let plain = rs(&[" ⠋ Thinking… 0.2s", "", "╭────╮", "│ ❯ │", "╰────╯"]); - assert_eq!(GrokSummary.model_label(&plain), None); - } - - // ------------------------------------------------------- corpus replay -- - - /// Positive per-state fixtures at capture geometry (40×120): exact - /// normalized text, Anchor provenance, and the matcher id. - #[test] - fn corpus_positive_states_anchor_exactly() { - struct Case( - &'static str, - &'static [u8], - &'static dyn SummaryAdapter, - &'static str, - &'static str, - ); - let cases = [ - Case( - "preview_claude_working", - include_bytes!("../../tests/corpus/preview_claude_working.bin"), - &ClaudeSummary, - "Fable 5 (high) · Concocting…", - "claude:spinner", - ), - Case( - "preview_claude_working_tool", - include_bytes!("../../tests/corpus/preview_claude_working_tool.bin"), - &ClaudeSummary, - "Fable 5 (high) · Hashing…", - "claude:spinner", - ), - Case( - "preview_claude_action", - include_bytes!("../../tests/corpus/preview_claude_action.bin"), - &ClaudeSummary, - "Fable 5 (high) · Running 1 shell command…", - "claude:action-row", - ), - Case( - "preview_claude_approval", - include_bytes!("../../tests/corpus/preview_claude_approval.bin"), - &ClaudeSummary, - "Fable 5 (high) · awaiting approval", - "claude:approval-menu", - ), - Case( - "preview_claude_tasklist", - include_bytes!("../../tests/corpus/preview_claude_tasklist.bin"), - &ClaudeSummary, - // Without the welcome box, the preview has no model prefix. - "Running phase 1 (dashboard UI)…", - "claude:spinner", - ), - Case( - "preview_codex_working", - include_bytes!("../../tests/corpus/preview_codex_working.bin"), - &CodexSummary, - "gpt-5.6-sol high · Working · 1 background terminal running", - "codex:working", - ), - Case( - "preview_codex_ran", - include_bytes!("../../tests/corpus/preview_codex_ran.bin"), - &CodexSummary, - "gpt-5.6-sol high · Ran sleep 5 && echo ok", - "codex:ran", - ), - Case( - "preview_claude_waiting", - include_bytes!("../../tests/corpus/preview_claude_waiting.bin"), - &ClaudeSummary, - // The welcome box is absent, so there is no model prefix. - "Waiting for 1 background agent to finish", - "claude:waiting", - ), - Case( - "preview_claude_workflow_wait", - include_bytes!("../../tests/corpus/preview_claude_workflow_wait.bin"), - &ClaudeSummary, - // The roster below the input box is excluded from the status. - "Waiting for 1 dynamic workflow to finish", - "claude:waiting", - ), - Case( - "preview_codex_hint_row", - include_bytes!("../../tests/corpus/preview_codex_hint_row.bin"), - &CodexSummary, - // No token bar in this layout: no model prefix, correctly. - "Working", - "codex:working", - ), - Case( - "preview_codex_approval", - include_bytes!("../../tests/corpus/preview_codex_approval.bin"), - &CodexSummary, - "awaiting approval", - "codex:approval-menu", - ), - Case( - "preview_grok_working", - include_bytes!("../../tests/corpus/preview_grok_working.bin"), - &GrokSummary, - "Grok 4.5 (xhigh) · Sleep 5 seconds then echo ok…", - "grok:spinner", - ), - Case( - "preview_grok_worked", - include_bytes!("../../tests/corpus/preview_grok_worked.bin"), - &GrokSummary, - "Grok 4.5 (xhigh) · Worked for 8.7s", - "grok:worked", - ), - ]; - for Case(name, bytes, adapter, text, rule) in cases { - let p = resolve_corpus(bytes, adapter, 40, 120); - assert_eq!(parts(&p), anchor(text, rule), "{name}"); - } - } - - /// Idle and post-turn screens have no anchor and resolve to the - /// alternate-screen marker. - #[test] - fn corpus_idle_states_fall_through() { - let cases: [(&str, &[u8], &dyn SummaryAdapter); 4] = [ - ( - "preview_claude_idle", - include_bytes!("../../tests/corpus/preview_claude_idle.bin"), - &ClaudeSummary, - ), - ( - "preview_claude_done", - include_bytes!("../../tests/corpus/preview_claude_done.bin"), - &ClaudeSummary, - ), - ( - "preview_grok_idle", - include_bytes!("../../tests/corpus/preview_grok_idle.bin"), - &GrokSummary, - ), - ( - "preview_grok_splash", - include_bytes!("../../tests/corpus/preview_grok_splash.bin"), - &GrokSummary, - ), - ]; - for (name, bytes, adapter) in cases { - let p = resolve_corpus(bytes, adapter, 40, 120); - assert_eq!( - parts(&p), - (MARKER.to_string(), PreviewSource::Marker, None), - "{name}" - ); - } - } - - /// Status-shaped conversation text does not extract. - /// The claude fixtures quote an approval menu in the conversation; the - /// codex fixtures hold `• Ran` in scrollback behind a finished turn. - #[test] - fn corpus_body_shaped_text_never_extracts() { - // Menu in the body, spinner live: the pinned spinner wins. - let p = resolve_corpus( - include_bytes!("../../tests/corpus/preview_claude_body_menu.bin"), - &ClaudeSummary, - 40, - 120, - ); - assert_eq!( - parts(&p), - anchor("Fable 5 (high) · Hashing…", "claude:spinner") - ); - - // Menu touching the chrome window on an idle screen: abort, marker. - let p = resolve_corpus( - include_bytes!("../../tests/corpus/preview_claude_body_menu_idle.bin"), - &ClaudeSummary, - 40, - 120, - ); - assert_eq!(parts(&p), (MARKER.to_string(), PreviewSource::Marker, None)); - - // Body prose between spinner-shaped text and the task list yields the marker. - let p = resolve_corpus( - include_bytes!("../../tests/corpus/preview_claude_body_above_tasklist.bin"), - &ClaudeSummary, - 40, - 120, - ); - assert_eq!(parts(&p), (MARKER.to_string(), PreviewSource::Marker, None)); - - // Prior-turn `• Ran` in scrollback with the turn finished: the scan - // stops at the reply bullet and the floor tier reports the screen. - let p = resolve_corpus( - include_bytes!("../../tests/corpus/preview_codex_scrollback.bin"), - &CodexSummary, - 40, - 120, - ); - assert_eq!( - parts(&p), - ( - // Floor previews omit the status bar's indentation. - "gpt-5.6-sol high · 5.26K used · 28.2K in · 78 out".to_string(), - PreviewSource::Floor, - None - ) - ); - - // A modal-shaped menu quoted in the body with the live composer - // below it: the composer suppresses the approval match, the quote - // is foreign to the status scan, and the floor tier reports. - let p = resolve_corpus( - include_bytes!("../../tests/corpus/preview_codex_body_menu.bin"), - &CodexSummary, - 40, - 120, - ); - assert_eq!( - parts(&p), - ( - "gpt-5.6-sol high · 0 in · 0 out".to_string(), - PreviewSource::Floor, - None - ) - ); - - // `• Ran` visible mid-turn with `• Working` at the pin: live wins. - let p = resolve_corpus( - include_bytes!("../../tests/corpus/preview_codex_working_over_ran.bin"), - &CodexSummary, - 40, - 120, - ); - assert_eq!( - parts(&p), - anchor("gpt-5.6-sol high · Working", "codex:working") - ); - } - - /// 80-column truncation: the CLIs cut their status rows at a word - /// boundary with their own ellipsis; head matching still extracts and - /// the kept suffix keeps that ellipsis verbatim. - #[test] - fn corpus_truncated_rows_still_anchor() { - let p = resolve_corpus( - include_bytes!("../../tests/corpus/preview_trunc_claude.bin"), - &ClaudeSummary, - 40, - 80, - ); - // No welcome box on the narrow screen: the label drops with it. - assert_eq!(parts(&p), anchor("Hashing…", "claude:spinner")); - - let p = resolve_corpus( - include_bytes!("../../tests/corpus/preview_trunc_codex.bin"), - &CodexSummary, - 40, - 80, - ); - assert_eq!( - parts(&p), - anchor( - "gpt-5.6-sol high · Working · 1 background terminal running", - "codex:working" - ) - ); - - let p = resolve_corpus( - include_bytes!("../../tests/corpus/preview_trunc_grok.bin"), - &GrokSummary, - 40, - 80, - ); - assert_eq!( - parts(&p), - anchor( - "Grok 4.5 (xhigh) · Sleep 5 seconds then echo…", - "grok:spinner" - ) - ); - } - - /// At 30 columns, a wrapped status ellipsis fails the structure check and - /// resolves to the alternate-screen marker. - #[test] - fn corpus_wrapped_ellipsis_falls_through() { - let p = resolve_corpus( - include_bytes!("../../tests/corpus/preview_wrap_grok.bin"), - &GrokSummary, - 40, - 30, - ); - assert_eq!(parts(&p), (MARKER.to_string(), PreviewSource::Marker, None)); - } - - /// Non-agent TUIs on the alternate screen resolve through the - /// title/marker tiers with an adapter installed exactly as without one: - /// the anchor tier never fires on foreign screens. - #[test] - fn corpus_non_agent_tuis_keep_their_tiers() { - for (name, bytes) in [ - ( - "vim_session", - &include_bytes!("../../tests/corpus/vim_session.bin")[..], - ), - ( - "less_altscreen", - &include_bytes!("../../tests/corpus/less_altscreen.bin")[..], - ), - ] { - // Cut before the final alt-screen exit so the TUI still owns the - // screen, as it does for the task's whole interactive life. - let cut = bytes - .windows(8) - .rposition(|w| w == b"\x1b[?1049l") - .expect("fixture exits the alt screen"); - let mut emu = Emulator::new(40, 120, 2000); - emu.process(&bytes[..cut]); - assert!(emu.alternate_screen(), "{name}: alt screen active at cut"); - let mut st = PreviewState::new(); - let with = st - .resolve(Instant::now(), false, &emu, Some(&ClaudeSummary)) - .clone(); - let mut st = PreviewState::new(); - let without = st.resolve(Instant::now(), false, &emu, None).clone(); - assert_eq!(with, without, "{name}: the adapter must change nothing"); - assert_eq!(with.source, PreviewSource::Marker, "{name}"); - } - } -} +#[path = "summary_tests.rs"] +mod tests; diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs new file mode 100644 index 0000000..61ebda9 --- /dev/null +++ b/src/harness/summary_tests.rs @@ -0,0 +1,1067 @@ +use std::time::Instant; + +use super::*; +use crate::{ + emulator::Emulator, + preview::{MARKER, PreviewState, SummaryAdapter}, + protocol::{Preview, PreviewSource}, +}; + +/// Synthetic screen: adapters read only `live_rows`, so the other facts +/// are inert defaults. +struct RowsScreen { + rows: Vec, +} + +fn rs(rows: &[&str]) -> RowsScreen { + RowsScreen { + rows: rows.iter().map(|s| s.to_string()).collect(), + } +} + +impl ScreenFacts for RowsScreen { + fn revision(&self) -> u64 { + 1 + } + + fn alt_epoch(&self) -> u64 { + 0 + } + + fn alternate_screen(&self) -> bool { + false + } + + fn title(&self) -> Option<&str> { + None + } + + fn live_floor(&self) -> String { + self.rows + .iter() + .rev() + .find(|r| !r.is_empty()) + .cloned() + .unwrap_or_default() + } + + fn live_rows(&self) -> Vec { + self.rows.clone() + } + + fn alt_leave_floor(&self) -> Option<&str> { + None + } +} + +/// Replay a corpus fixture and resolve one preview against its final +/// screen with `adapter` installed. +fn resolve_corpus(bytes: &[u8], adapter: &dyn SummaryAdapter, rows: u16, cols: u16) -> Preview { + let mut emu = Emulator::new(rows, cols, 2000); + emu.process(bytes); + let mut st = PreviewState::new(); + st.resolve(Instant::now(), &emu, Some(adapter)).clone() +} + +fn anchor(text: &str, rule: &'static str) -> (String, PreviewSource, Option<&'static str>) { + (text.to_string(), PreviewSource::Anchor, Some(rule)) +} + +fn parts(p: &Preview) -> (String, PreviewSource, Option<&'static str>) { + (p.text.clone(), p.source, p.rule) +} + +/// Selection is a basename match on the first word only: wider than +/// harness detection (arguments are tolerated), but env prefixes and +/// shell syntax glued to the word select nothing. +#[test] +fn select_matches_first_word_basenames_only() { + for cmd in [ + "claude", + "claude --model opus", + "/usr/local/bin/claude --resume abc", + "codex resume 'not-checked-here'", + "grok", + ] { + assert!(select(cmd).is_some(), "{cmd:?} must select an adapter"); + } + for cmd in ["vim", "FOO=bar claude", "claude|tee log", "codex; ls", ""] { + assert!(select(cmd).is_none(), "{cmd:?} must select nothing"); + } +} + +/// Each program word routes to its own CLI's matchers: the selected +/// adapter fires that CLI's rule on that CLI's screen shape. +#[test] +fn select_routes_to_the_matching_adapter() { + let sep = "─".repeat(80); + let claude = rs(&["✻ Hashing… (6s · ↓ 87 tokens)", &sep, "❯", &sep]); + assert_eq!( + select("claude").unwrap().live_preview(&claude).unwrap().1, + "claude:spinner" + ); + let codex = rs(&[ + "• Working (2s • esc to interrupt)", + "", + "› Write tests", + "", + " gpt-5.6-sol high · 0 in · 0 out", + ]); + assert_eq!( + select("codex").unwrap().live_preview(&codex).unwrap().1, + "codex:working" + ); + let grok = rs(&[ + " ⠼ Sleep 5 seconds then echo ok… 1.5s 2.8s ⇣14.2k [↓][stop]", + "", + " ╭──────────────────────╮", + " │ ❯ │", + " ╰── Grok 4.5 (xhigh) · always-approve ─╯", + ]); + assert_eq!( + select("grok").unwrap().live_preview(&grok).unwrap().1, + "grok:spinner" + ); +} + +/// The spinner phrase survives, the elapsed/token parenthetical drops, +/// and the concrete-action row wins over the spinner when present. +#[test] +fn claude_spinner_and_action_row() { + let sep = "─".repeat(120); + let spin = rs(&["✻ Hashing… (6s · ↓ 87 tokens)", &sep, "❯", &sep, " status"]); + assert_eq!( + ClaudeSummary.live_preview(&spin), + Some(("Hashing…".to_string(), "claude:spinner")) + ); + + let action = rs(&[ + "⏺ Running 1 shell command…", + "", + "· Hashing… (3s · ↓ 52 tokens)", + &sep, + "❯", + &sep, + ]); + assert_eq!( + ClaudeSummary.live_preview(&action), + Some(("Running 1 shell command…".to_string(), "claude:action-row")) + ); + + // An indented attachment above the spinner is not the action row. + let attach = rs(&[ + " Running 1 shell command…", + " ⎿ $ sleep 5 && echo ok", + "✻ Hashing… (6s)", + &sep, + "❯", + &sep, + ]); + assert_eq!( + ClaudeSummary.live_preview(&attach), + Some(("Hashing…".to_string(), "claude:spinner")) + ); + + // A `⏺` reply row without a trailing ellipsis is not the action row. + let reply = rs(&["⏺ ok", "", "✻ Hashing… (2s)", &sep, "❯", &sep]); + assert_eq!( + ClaudeSummary.live_preview(&reply), + Some(("Hashing…".to_string(), "claude:spinner")) + ); +} + +/// Task-derived spinner phrases may contain spaces, parentheses, and +/// digits; extraction keeps everything through the first ellipsis. +#[test] +fn claude_spinner_extracts_task_derived_phrases() { + let sep = "─".repeat(120); + let s = rs(&[ + "✳ Overseeing phase 4 (adapters)… (54s · almost done thinking with high effort)", + &sep, + "❯", + &sep, + ]); + assert_eq!( + ClaudeSummary.live_preview(&s), + Some(( + "Overseeing phase 4 (adapters)… · almost done thinking with high effort".to_string(), + "claude:spinner" + )) + ); +} + +/// Parenthetical segments: recognized ticker shapes are dropped and +/// unknown segments are preserved. A bare row is unchanged. +#[test] +fn claude_parenthetical_keeps_slow_segments_and_drops_tickers() { + let sep = "─".repeat(120); + let spin = |row: &str| { + let rows = [row, &sep, "❯", &sep]; + ClaudeSummary.live_preview(&rs(&rows)) + }; + assert_eq!( + spin("✻ Envisioning… (1m 8s · ↓ 2.1k tokens · thinking with high effort)"), + Some(( + "Envisioning… · thinking with high effort".to_string(), + "claude:spinner" + )) + ); + for ticker in [ + "6s", + "1m 8s", + "2h 3m", + "8.7s", + "↓ 87 tokens", + "↑ 1.2k tokens", + "↓ 2.1k", + "2.1k tokens", + "esc to interrupt", + ] { + assert_eq!( + spin(&format!("✻ Hashing… ({ticker})")), + Some(("Hashing…".to_string(), "claude:spinner")), + "{ticker:?} must drop" + ); + assert_eq!( + spin(&format!("✻ Hashing… ({ticker} · thinking)")), + Some(("Hashing… · thinking".to_string(), "claude:spinner")), + "{ticker:?} must drop beside a kept segment" + ); + } + assert_eq!( + spin("✽ Concocting…"), + Some(("Concocting…".to_string(), "claude:spinner")), + "a row with no parenthetical is unchanged" + ); +} + +/// The action row wins the head while the spinner row's parenthetical +/// still contributes the semantic tail. +#[test] +fn claude_action_row_carries_the_spinner_rows_semantic_tail() { + let sep = "─".repeat(120); + let rows = [ + "⏺ Running 1 shell command…", + "", + "✻ Envisioning… (1m 8s · ↓ 2.1k tokens · thinking with high effort)", + &sep, + "❯", + &sep, + ]; + assert_eq!( + ClaudeSummary.live_preview(&rs(&rows)), + Some(( + "Running 1 shell command… · thinking with high effort".to_string(), + "claude:action-row" + )) + ); +} + +/// Waiting rows return verbatim; malformed skeletons fail and do not +/// trigger an action-row lookup. +#[test] +fn claude_waiting_family_matches_the_skeleton_and_never_probes() { + let sep = "─".repeat(120); + let spin = |row: &str| { + let rows = [row, &sep, "❯", &sep]; + ClaudeSummary.live_preview(&rs(&rows)) + }; + for row in [ + "✻ Waiting for 1 background agent to finish", + "· Waiting for 1 background agent to finish", + "✻ Waiting for 3 background agents to finish", + "✻ Waiting for 1 dynamic workflow to finish", + "✽ Waiting for 3 dynamic workflows to finish", + "✻ Waiting for 2 tasks to finish", + ] { + let want = row.chars().skip(2).collect::(); + assert_eq!(spin(row), Some((want, "claude:waiting")), "{row:?}"); + } + + // Reject missing digits, more than three subject words, a foreign + // suffix, or a missing subject. + for row in [ + "✻ Waiting patiently", + "· Waiting for review comments to land", + "✻ Waiting for some agents to finish", + "✻ Waiting for 2 very long noun phrases here to finish", + "✻ Waiting for 2 agents to start", + "✻ Waiting for 3 to finish", + ] { + assert_eq!(spin(row), None, "{row:?}"); + } + + // Waiting rows return without probing the action row above them. + let rows = [ + "⏺ Running 1 shell command…", + "", + "✻ Waiting for 1 background agent to finish", + &sep, + "❯", + &sep, + ]; + assert_eq!( + ClaudeSummary.live_preview(&rs(&rows)), + Some(( + "Waiting for 1 background agent to finish".to_string(), + "claude:waiting" + )) + ); +} + +/// Every spinner frame canonicalizes to `✻`; non-frame titles pass through. +#[test] +fn claude_title_frames_canonicalize_to_constant_text() { + for frame in CLAUDE_SPINNER { + assert_eq!( + ClaudeSummary.normalize_title(&format!("{frame} Claude Code")), + Some("✻ Claude Code".to_string()), + "{frame:?}" + ); + } + let a = ClaudeSummary.normalize_title("✢ Claude Code"); + let b = ClaudeSummary.normalize_title("✽ Claude Code"); + assert_eq!(a, b, "two frames must normalize identically"); + + // A braille frame plus the session summary. + assert_eq!( + ClaudeSummary.normalize_title("⠐ Review fleetcom preview design document"), + Some("✻ Review fleetcom preview design document".to_string()) + ); + assert_eq!( + ClaudeSummary.normalize_title("⠴ Review fleetcom preview design document"), + Some("✻ Review fleetcom preview design document".to_string()), + "mid-block braille frame" + ); + + assert_eq!(ClaudeSummary.normalize_title("zellij: main"), None); + assert_eq!(ClaudeSummary.normalize_title("✻"), None, "frame alone"); +} + +/// Cascade-level: with the claude adapter installed and no anchor on +/// the screen, a frame-led title renders canonicalized under the Title +/// tier; without an adapter it renders verbatim. +#[test] +fn title_tier_renders_the_normalized_title() { + let mut emu = Emulator::new(24, 80, 100); + emu.process(b"\x1b[?1049h\x1b]0;\xe2\x9c\xa2 Claude Code\x07conversation body"); + let mut st = PreviewState::new(); + let p = st + .resolve(Instant::now(), &emu, Some(&ClaudeSummary)) + .clone(); + assert_eq!( + (p.text.as_str(), p.source, p.rule), + ("✻ Claude Code", PreviewSource::Title, None) + ); + + let mut st = PreviewState::new(); + let p = st.resolve(Instant::now(), &emu, None).clone(); + assert_eq!( + (p.text.as_str(), p.source), + ("✢ Claude Code", PreviewSource::Title), + "no adapter: verbatim" + ); +} + +/// A column-0 row in the chrome window that is not spinner-shaped aborts: +/// a wrapped status tail and body text touching the chrome both refuse. +#[test] +fn claude_aborts_on_foreign_column_zero_rows() { + let sep = "─".repeat(120); + let wrapped = rs(&["✻ Hashing… (6s · ↓ 87 to", "kens)", &sep, "❯", &sep]); + assert_eq!(ClaudeSummary.live_preview(&wrapped), None); + + // A menu quoted in the body, reaching the window with the input box + // intact, refuses rather than synthesizing approval. + let menu = rs(&["❯ 1. Yes", " 2. No", &sep, "❯", &sep]); + assert_eq!(ClaudeSummary.live_preview(&menu), None); +} + +/// The status scan crosses bounded indented gaps but stops at body prose. +#[test] +fn claude_scan_crosses_task_list_gaps_within_the_window() { + let sep = "─".repeat(120); + let behind_gap = |status: &str, gap: usize| { + let mut rows = vec![status.to_string()]; + rows.push(" ⎿ ✔ Phase 0: verify facts".to_string()); + rows.extend((1..gap).map(|i| format!(" ◼ Phase {i}: generic step"))); + rows.extend([sep.clone(), "❯".to_string(), sep.clone()]); + let refs: Vec<&str> = rows.iter().map(String::as_str).collect(); + ClaudeSummary.live_preview(&rs(&refs)) + }; + for gap in [4, 15] { + assert_eq!( + behind_gap( + "✢ Running phase 1 (dashboard UI)… (4m 20s · ↓ 17.1k tokens)", + gap + ), + Some(( + "Running phase 1 (dashboard UI)…".to_string(), + "claude:spinner" + )), + "gap of {gap} indented rows" + ); + } + for gap in [16, 17, 24] { + assert_eq!( + behind_gap( + "✢ Running phase 1 (dashboard UI)… (4m 20s · ↓ 17.1k tokens)", + gap + ), + None, + "gap of {gap} indented rows must exhaust the window" + ); + } + + // Waiting rows use the same bounded scan. + assert_eq!( + behind_gap("✻ Waiting for 2 background agents to finish", 5), + Some(( + "Waiting for 2 background agents to finish".to_string(), + "claude:waiting" + )) + ); + + // Column-0 body prose invalidates the status structure. + let prose = rs(&[ + "✢ Running phase 1 (dashboard UI)… (4m 20s · ↓ 17.1k tokens)", + "⏺ The phase list below is queued, not running.", + " ⎿ ✔ Phase 0: verify facts", + " ◼ Phase 1: dashboard polish", + &sep, + "❯", + &sep, + ]); + assert_eq!(ClaudeSummary.live_preview(&prose), None); +} + +/// Blank rows do not consume the nonblank-row window. +#[test] +fn claude_blank_rows_do_not_consume_the_window() { + let sep = "─".repeat(120); + let resolve = |rows: Vec| { + let refs: Vec<&str> = rows.iter().map(String::as_str).collect(); + ClaudeSummary.live_preview(&rs(&refs)) + }; + let boxed = |sep: &str| [sep.to_string(), "❯ /workflows".to_string(), sep.to_string()]; + + // Nineteen blank rows separate the waiting row from the input box. + let mut rows = vec!["✻ Waiting for 1 dynamic workflow to finish".to_string()]; + rows.extend(std::iter::repeat_n(String::new(), 19)); + rows.extend(boxed(&sep)); + assert_eq!( + resolve(rows), + Some(( + "Waiting for 1 dynamic workflow to finish".to_string(), + "claude:waiting" + )) + ); + + // Fifteen indented rows plus the spinner fill the 16-row window; + // interleaved blank rows do not affect the count. + let mut rows = vec!["✢ Running phase 1 (dashboard UI)… (4m 20s)".to_string()]; + for i in 0..15 { + rows.push(String::new()); + rows.push(format!(" ◼ Phase {i}: generic step")); + } + rows.extend(boxed(&sep)); + assert_eq!( + resolve(rows), + Some(( + "Running phase 1 (dashboard UI)…".to_string(), + "claude:spinner" + )) + ); + + // Sixteen indented rows plus the spinner exceed the window. + let mut rows = vec!["✢ Running phase 1 (dashboard UI)… (4m 20s)".to_string()]; + for i in 0..16 { + rows.push(String::new()); + rows.push(format!(" ◼ Phase {i}: generic step")); + } + rows.extend(boxed(&sep)); + assert_eq!(resolve(rows), None); + + // An intervening column-0 prose row still aborts the scan. + let prose = rs(&[ + "✻ Hashing… (6s · ↓ 87 tokens)", + "", + "", + "⏺ The workflow report lands below.", + "", + "", + &sep, + "❯ /workflows", + &sep, + ]); + assert_eq!(ClaudeSummary.live_preview(&prose), None); +} + +/// The approval menu synthesizes its label only with the input box gone, +/// and requires the `2.` sibling below the selector. +#[test] +fn claude_approval_requires_the_dialog_shape() { + let dialog = rs(&[ + " Do you want to create word.txt?", + " ❯ 1. Yes", + " 2. Yes, allow all edits during this session (shift+tab)", + " 3. No", + "", + " Esc to cancel · Tab to amend", + ]); + assert_eq!( + ClaudeSummary.live_preview(&dialog), + Some(("awaiting approval".to_string(), "claude:approval-menu")) + ); + + let lone = rs(&[" ❯ 1. Yes", "", " Esc to cancel"]); + assert_eq!(ClaudeSummary.live_preview(&lone), None); +} + +/// The model label comes from the welcome box and reads as +/// `{model} ({effort})`; no box, no label. +#[test] +fn claude_label_reads_the_welcome_box() { + let boxed = rs(&[ + "╭─── Claude Code v2.1.215 ────────────╮", + "│ Fable 5 with high effort · Claude Max · │ notes │", + "╰──────────────────────────────────────╯", + ]); + assert_eq!( + ClaudeSummary.model_label(&boxed), + Some("Fable 5 (high)".to_string()) + ); + assert_eq!(ClaudeSummary.model_label(&rs(&["no box here"])), None); +} + +/// Working-row normalization: parenthetical dropped (unclosed included), +/// `/`-hint suffixes dropped, slow suffixes kept, with the CLI's own +/// ellipsis when truncated. +#[test] +fn codex_working_normalization() { + let tail = [ + "", + "› Write tests for @filename", + "", + " gpt-5.6-sol high · 0 in · 0 out", + ]; + let full = "• Working (7s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close"; + for (row, want) in [ + (full, "Working · 1 background terminal running"), + ("• Working (2s • esc to interrupt)", "Working"), + ("• Working (7s • esc to…", "Working"), + ( + "• Working (7s • esc to interrupt) · 1 background termi…", + "Working · 1 background termi…", + ), + ] { + let mut rows = vec![row]; + rows.extend(tail); + assert_eq!( + CodexSummary.live_preview(&rs(&rows)), + Some((want.to_string(), "codex:working")), + "{row:?}" + ); + } + assert_eq!( + CodexSummary.model_label(&rs(&tail[1..])), + Some("gpt-5.6-sol high".to_string()) + ); +} + +/// `• Ran` extracts through its indented attachment, but never through a +/// foreign column-0 row: scrollback `• Ran` rows from prior turns sit +/// behind reply bullets and separators, and skipping those would +/// resurface stale work. +#[test] +fn codex_ran_stops_at_foreign_rows() { + let transient = rs(&[ + "• Ran sleep 5 && echo ok", + " └ ok", + "", + "› ", + "", + " gpt-5.6-sol high · 1 in · 2 out", + ]); + assert_eq!( + CodexSummary.live_preview(&transient), + Some(("Ran sleep 5 && echo ok".to_string(), "codex:ran")) + ); + + let sep = "─".repeat(120); + let behind_reply = rs(&[ + "• Ran sleep 5 && echo ok", + " └ ok", + "", + &sep, + "", + "• ok", + "", + "› ", + "", + " gpt-5.6-sol high · 1 in · 2 out", + ]); + assert_eq!(CodexSummary.live_preview(&behind_reply), None); +} + +/// A hint row may follow the composer without a token bar. The anchor +/// still fires, without a model prefix. +#[test] +fn codex_hint_row_layout_anchors_without_a_token_bar() { + let hinted = rs(&[ + "• Running cargo test --test daemon_env", + "", + "", + "• Working (10m 26s • esc to interrupt)", + "", + "›", + "", + " tab to queue message", + ]); + assert_eq!( + CodexSummary.live_preview(&hinted), + Some(("Working".to_string(), "codex:working")) + ); + assert_eq!(CodexSummary.model_label(&hinted), None); +} + +/// The approval modal replaces composer and token bar with a numbered +/// menu; the selector row plus a numbered sibling synthesizes the +/// label, wherever the selection sits. +#[test] +fn codex_approval_modal_synthesizes_on_any_selection() { + let on_first = rs(&[ + " Would you like to run the following command?", + "", + " $ cargo test --test daemon_env", + "", + "› 1. Yes, proceed (y)", + " 2. Yes, and don't ask again for commands that start with `cargo test` (p)", + " 3. No, and tell Codex what to do differently (esc)", + "", + " Press enter to confirm or esc to cancel", + ]); + assert_eq!( + CodexSummary.live_preview(&on_first), + Some(("awaiting approval".to_string(), "codex:approval-menu")) + ); + + let on_second = rs(&[ + " 1. Yes, proceed (y)", + "› 2. Yes, and don't ask again (p)", + " 3. No (esc)", + "", + " Press enter to confirm or esc to cancel", + ]); + assert_eq!( + CodexSummary.live_preview(&on_second), + Some(("awaiting approval".to_string(), "codex:approval-menu")) + ); +} + +/// A menu quoted in the conversation always has the live composer +/// somewhere below it; the composer's presence suppresses the modal +/// match, and the quote is a foreign row to the status scan: no +/// anchor, floor tier. +#[test] +fn codex_quoted_menu_with_a_live_composer_is_not_a_modal() { + let quoted = rs(&[ + "• I found these options in the doc:", + "", + "› 1. Yes, proceed (y)", + " 2. No, cancel (esc)", + "", + "›", + "", + " gpt-5.6-sol high · 0 in · 0 out", + ]); + assert_eq!(CodexSummary.live_preview("ed), None); +} + +/// Without any composer row (codex exited; its resume hint owns the +/// floor) the whole pin fails. +#[test] +fn codex_requires_the_composer_pin() { + let exited = rs(&[ + "• Ran sleep 5 && echo ok", + "", + "Token usage: total=14,353 input=14,063", + "To continue this session, run codex resume 0199-fake", + ]); + assert_eq!(CodexSummary.live_preview(&exited), None); + assert_eq!(CodexSummary.model_label(&exited), None); +} + +/// Spinner label cut at its `…`; the completion row kept verbatim, +/// longer durations included; free text above the box refuses. +#[test] +fn grok_status_shapes() { + let boxed = [ + " ╭──────────────────────╮", + " │ ❯ │", + " ╰── Grok 4.5 (xhigh) · always-approve ─╯", + ]; + let probe = |status: &str| { + let mut rows = vec![status, ""]; + rows.extend(boxed); + GrokSummary.live_preview(&rs(&rows)) + }; + assert_eq!( + probe(" ⠼ Sleep 5 seconds then echo ok… 1.5s 2.8s ⇣14.2k [↓][stop]"), + Some(("Sleep 5 seconds then echo ok…".to_string(), "grok:spinner")) + ); + assert_eq!( + probe(" ⠋ Thinking… 0.2s"), + Some(("Thinking…".to_string(), "grok:spinner")) + ); + assert_eq!( + probe(" Worked for 8.7s"), + Some(("Worked for 8.7s".to_string(), "grok:worked")) + ); + assert_eq!( + probe(" Worked for 1m 24s"), + Some(("Worked for 1m 24s".to_string(), "grok:worked")) + ); + assert_eq!(probe(" Worked for a while"), None); + assert_eq!( + probe(" Coming from Codex? Resume your session from 7m ago using ctrl+u"), + None + ); + + let mut rows = vec![" ⠋ Thinking… 0.2s", ""]; + rows.extend(boxed); + assert_eq!( + GrokSummary.model_label(&rs(&rows)), + Some("Grok 4.5 (xhigh)".to_string()) + ); + // A plain border carries no label. + let plain = rs(&[" ⠋ Thinking… 0.2s", "", "╭────╮", "│ ❯ │", "╰────╯"]); + assert_eq!(GrokSummary.model_label(&plain), None); +} + +// ------------------------------------------------------- corpus replay -- + +/// Positive per-state fixtures at capture geometry (40×120): exact +/// normalized text, Anchor provenance, and the matcher id. +#[test] +fn corpus_positive_states_anchor_exactly() { + struct Case( + &'static str, + &'static [u8], + &'static dyn SummaryAdapter, + &'static str, + &'static str, + ); + let cases = [ + Case( + "preview_claude_working", + include_bytes!("../../tests/corpus/preview_claude_working.bin"), + &ClaudeSummary, + "Fable 5 (high) · Concocting…", + "claude:spinner", + ), + Case( + "preview_claude_working_tool", + include_bytes!("../../tests/corpus/preview_claude_working_tool.bin"), + &ClaudeSummary, + "Fable 5 (high) · Hashing…", + "claude:spinner", + ), + Case( + "preview_claude_action", + include_bytes!("../../tests/corpus/preview_claude_action.bin"), + &ClaudeSummary, + "Fable 5 (high) · Running 1 shell command…", + "claude:action-row", + ), + Case( + "preview_claude_approval", + include_bytes!("../../tests/corpus/preview_claude_approval.bin"), + &ClaudeSummary, + "Fable 5 (high) · awaiting approval", + "claude:approval-menu", + ), + Case( + "preview_claude_tasklist", + include_bytes!("../../tests/corpus/preview_claude_tasklist.bin"), + &ClaudeSummary, + // Without the welcome box, the preview has no model prefix. + "Running phase 1 (dashboard UI)…", + "claude:spinner", + ), + Case( + "preview_codex_working", + include_bytes!("../../tests/corpus/preview_codex_working.bin"), + &CodexSummary, + "gpt-5.6-sol high · Working · 1 background terminal running", + "codex:working", + ), + Case( + "preview_codex_ran", + include_bytes!("../../tests/corpus/preview_codex_ran.bin"), + &CodexSummary, + "gpt-5.6-sol high · Ran sleep 5 && echo ok", + "codex:ran", + ), + Case( + "preview_claude_waiting", + include_bytes!("../../tests/corpus/preview_claude_waiting.bin"), + &ClaudeSummary, + // The welcome box is absent, so there is no model prefix. + "Waiting for 1 background agent to finish", + "claude:waiting", + ), + Case( + "preview_claude_workflow_wait", + include_bytes!("../../tests/corpus/preview_claude_workflow_wait.bin"), + &ClaudeSummary, + // The roster below the input box is excluded from the status. + "Waiting for 1 dynamic workflow to finish", + "claude:waiting", + ), + Case( + "preview_codex_hint_row", + include_bytes!("../../tests/corpus/preview_codex_hint_row.bin"), + &CodexSummary, + // No token bar in this layout: no model prefix, correctly. + "Working", + "codex:working", + ), + Case( + "preview_codex_approval", + include_bytes!("../../tests/corpus/preview_codex_approval.bin"), + &CodexSummary, + "awaiting approval", + "codex:approval-menu", + ), + Case( + "preview_grok_working", + include_bytes!("../../tests/corpus/preview_grok_working.bin"), + &GrokSummary, + "Grok 4.5 (xhigh) · Sleep 5 seconds then echo ok…", + "grok:spinner", + ), + Case( + "preview_grok_worked", + include_bytes!("../../tests/corpus/preview_grok_worked.bin"), + &GrokSummary, + "Grok 4.5 (xhigh) · Worked for 8.7s", + "grok:worked", + ), + ]; + for Case(name, bytes, adapter, text, rule) in cases { + let p = resolve_corpus(bytes, adapter, 40, 120); + assert_eq!(parts(&p), anchor(text, rule), "{name}"); + } +} + +/// Idle and post-turn screens have no anchor and resolve to the +/// alternate-screen marker. +#[test] +fn corpus_idle_states_fall_through() { + let cases: [(&str, &[u8], &dyn SummaryAdapter); 4] = [ + ( + "preview_claude_idle", + include_bytes!("../../tests/corpus/preview_claude_idle.bin"), + &ClaudeSummary, + ), + ( + "preview_claude_done", + include_bytes!("../../tests/corpus/preview_claude_done.bin"), + &ClaudeSummary, + ), + ( + "preview_grok_idle", + include_bytes!("../../tests/corpus/preview_grok_idle.bin"), + &GrokSummary, + ), + ( + "preview_grok_splash", + include_bytes!("../../tests/corpus/preview_grok_splash.bin"), + &GrokSummary, + ), + ]; + for (name, bytes, adapter) in cases { + let p = resolve_corpus(bytes, adapter, 40, 120); + assert_eq!( + parts(&p), + (MARKER.to_string(), PreviewSource::Marker, None), + "{name}" + ); + } +} + +/// Status-shaped conversation text does not extract. +/// The claude fixtures quote an approval menu in the conversation; the +/// codex fixtures hold `• Ran` in scrollback behind a finished turn. +#[test] +fn corpus_body_shaped_text_never_extracts() { + // Menu in the body, spinner live: the pinned spinner wins. + let p = resolve_corpus( + include_bytes!("../../tests/corpus/preview_claude_body_menu.bin"), + &ClaudeSummary, + 40, + 120, + ); + assert_eq!( + parts(&p), + anchor("Fable 5 (high) · Hashing…", "claude:spinner") + ); + + // Menu touching the chrome window on an idle screen: abort, marker. + let p = resolve_corpus( + include_bytes!("../../tests/corpus/preview_claude_body_menu_idle.bin"), + &ClaudeSummary, + 40, + 120, + ); + assert_eq!(parts(&p), (MARKER.to_string(), PreviewSource::Marker, None)); + + // Body prose between spinner-shaped text and the task list yields the marker. + let p = resolve_corpus( + include_bytes!("../../tests/corpus/preview_claude_body_above_tasklist.bin"), + &ClaudeSummary, + 40, + 120, + ); + assert_eq!(parts(&p), (MARKER.to_string(), PreviewSource::Marker, None)); + + // Prior-turn `• Ran` in scrollback with the turn finished: the scan + // stops at the reply bullet and the floor tier reports the screen. + let p = resolve_corpus( + include_bytes!("../../tests/corpus/preview_codex_scrollback.bin"), + &CodexSummary, + 40, + 120, + ); + assert_eq!( + parts(&p), + ( + // Floor previews omit the status bar's indentation. + "gpt-5.6-sol high · 5.26K used · 28.2K in · 78 out".to_string(), + PreviewSource::Floor, + None + ) + ); + + // A modal-shaped menu quoted in the body with the live composer + // below it: the composer suppresses the approval match, the quote + // is foreign to the status scan, and the floor tier reports. + let p = resolve_corpus( + include_bytes!("../../tests/corpus/preview_codex_body_menu.bin"), + &CodexSummary, + 40, + 120, + ); + assert_eq!( + parts(&p), + ( + "gpt-5.6-sol high · 0 in · 0 out".to_string(), + PreviewSource::Floor, + None + ) + ); + + // `• Ran` visible mid-turn with `• Working` at the pin: live wins. + let p = resolve_corpus( + include_bytes!("../../tests/corpus/preview_codex_working_over_ran.bin"), + &CodexSummary, + 40, + 120, + ); + assert_eq!( + parts(&p), + anchor("gpt-5.6-sol high · Working", "codex:working") + ); +} + +/// 80-column truncation: the CLIs cut their status rows at a word +/// boundary with their own ellipsis; head matching still extracts and +/// the kept suffix keeps that ellipsis verbatim. +#[test] +fn corpus_truncated_rows_still_anchor() { + let p = resolve_corpus( + include_bytes!("../../tests/corpus/preview_trunc_claude.bin"), + &ClaudeSummary, + 40, + 80, + ); + // No welcome box on the narrow screen: the label drops with it. + assert_eq!(parts(&p), anchor("Hashing…", "claude:spinner")); + + let p = resolve_corpus( + include_bytes!("../../tests/corpus/preview_trunc_codex.bin"), + &CodexSummary, + 40, + 80, + ); + assert_eq!( + parts(&p), + anchor( + "gpt-5.6-sol high · Working · 1 background terminal running", + "codex:working" + ) + ); + + let p = resolve_corpus( + include_bytes!("../../tests/corpus/preview_trunc_grok.bin"), + &GrokSummary, + 40, + 80, + ); + assert_eq!( + parts(&p), + anchor( + "Grok 4.5 (xhigh) · Sleep 5 seconds then echo…", + "grok:spinner" + ) + ); +} + +/// At 30 columns, a wrapped status ellipsis fails the structure check and +/// resolves to the alternate-screen marker. +#[test] +fn corpus_wrapped_ellipsis_falls_through() { + let p = resolve_corpus( + include_bytes!("../../tests/corpus/preview_wrap_grok.bin"), + &GrokSummary, + 40, + 30, + ); + assert_eq!(parts(&p), (MARKER.to_string(), PreviewSource::Marker, None)); +} + +/// Non-agent TUIs on the alternate screen resolve through the +/// title/marker tiers with an adapter installed exactly as without one: +/// the anchor tier never fires on foreign screens. +#[test] +fn corpus_non_agent_tuis_keep_their_tiers() { + for (name, bytes) in [ + ( + "vim_session", + &include_bytes!("../../tests/corpus/vim_session.bin")[..], + ), + ( + "less_altscreen", + &include_bytes!("../../tests/corpus/less_altscreen.bin")[..], + ), + ] { + // Cut before the final alt-screen exit so the TUI still owns the + // screen, as it does for the task's whole interactive life. + let cut = bytes + .windows(8) + .rposition(|w| w == b"\x1b[?1049l") + .expect("fixture exits the alt screen"); + let mut emu = Emulator::new(40, 120, 2000); + emu.process(&bytes[..cut]); + assert!(emu.alternate_screen(), "{name}: alt screen active at cut"); + let mut st = PreviewState::new(); + let with = st + .resolve(Instant::now(), &emu, Some(&ClaudeSummary)) + .clone(); + let mut st = PreviewState::new(); + let without = st.resolve(Instant::now(), &emu, None).clone(); + assert_eq!(with, without, "{name}: the adapter must change nothing"); + assert_eq!(with.source, PreviewSource::Marker, "{name}"); + } +} diff --git a/src/main.rs b/src/main.rs index f73f7b4..055fd5b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,31 +8,32 @@ #[cfg(not(unix))] compile_error!("fleetcom supports Unix platforms only."); +// Terminal-attached client. mod app; -mod core; -mod daemon; -// Caret-addressed single-line buffer backing the text prompts. mod editbuf; -// Differential emulator tests over recorded PTY output. -#[cfg(test)] -mod golden; -// Agent-CLI session capture: the supervisor instruments spawns through it. -mod harness; -mod path; -// Dashboard-preview resolution: the provenance cascade over emulator facts. +mod ui; + +// Task lifecycle and dashboard-preview core. +mod core; mod preview; -mod protocol; -mod session; mod supervisor; mod task; + +// Daemon transport, sessions, and wire protocol. +mod daemon; +mod protocol; +mod session; +mod transport; + +mod harness; +mod path; mod terminal; -// Shared test scaffolds: scratch dirs, deadline polling, corpus fixtures. + +// Shared test scaffolding. #[cfg(test)] mod testutil; -mod transport; -mod ui; -pub(crate) use terminal::{ansi, emulator, format, frame}; +pub(crate) use terminal::{ansi, emulator, format, frame, input}; use std::{ io, @@ -292,7 +293,7 @@ fn emit_restore_sequences(out: &mut impl io::Write, kitty_pushed: bool) -> io::R /// Route external termination signals (SIGTERM/SIGHUP/SIGINT) into a quit /// flag so the observing loop runs its normal teardown: the client restores -/// the terminal instead of dying in raw mode, and the daemon kills its jobs +/// the terminal instead of dying in raw mode, and the daemon kills its tasks /// cleanly. `flag::register` only stores into an atomic, so it stays within /// `#![forbid(unsafe_code)]`. pub(crate) fn install_signal_handlers(flag: Arc) -> io::Result<()> { diff --git a/src/preview.rs b/src/preview.rs index f4b2d3c..e81ee20 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -4,7 +4,10 @@ use std::time::{Duration, Instant}; -use crate::{emulator::Emulator, harness::summary::SummaryAdapter}; +use crate::{ + emulator::Emulator, + protocol::{Preview, PreviewSource}, +}; // Holds use elapsed time because tick intervals range from the 8 ms frame // minimum to the 200 ms idle backstop. @@ -19,58 +22,7 @@ pub const DEMOTION_HOLD: Duration = Duration::from_millis(600); /// Preview text for an alternate-screen child with no usable title. pub const MARKER: &str = "full-screen"; -/// Where a preview's text came from. Declared in ascending authority so the -/// derived `Ord` ranks provenance directly: `Anchor > Title > Marker > -/// Floor`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum PreviewSource { - /// The last non-blank row of the live screen: unshadowable for - /// primary-screen programs, so a title never replaces live stream output. - Floor, - /// The alternate screen is active with no usable title. - Marker, - /// The child's window title, honored only on the alternate screen. - Title, - /// Normalized adapter output: the cascade's top tier. - Anchor, -} - -impl PreviewSource { - /// Lowercase label shared by the wire encoding and the peek footer. - pub fn label(self) -> &'static str { - match self { - PreviewSource::Floor => "floor", - PreviewSource::Marker => "marker", - PreviewSource::Title => "title", - PreviewSource::Anchor => "anchor", - } - } -} - -/// One resolved preview. `frozen` marks an immutable final value. `rule` is -/// the summary-adapter matcher ID for an Anchor preview and is `None` for -/// other sources. -#[derive(Debug, Clone, PartialEq)] -pub struct Preview { - pub text: String, - pub source: PreviewSource, - pub rule: Option<&'static str>, - pub frozen: bool, -} - -impl Preview { - fn floor(text: String) -> Preview { - Preview { - text, - source: PreviewSource::Floor, - rule: None, - frozen: false, - } - } -} - -/// The emulator facts one resolution step reads. A trait so unit tests -/// resolve against synthetic screens without a PTY. +/// Read-only emulator state consumed by one preview-resolution step. pub trait ScreenFacts { fn revision(&self) -> u64; fn alt_epoch(&self) -> u64; @@ -115,6 +67,23 @@ impl ScreenFacts for Emulator { } } +/// Display-only status and model-label extraction for one agent CLI. +pub trait SummaryAdapter: Sync { + /// Return normalized live status and its matcher ID when the expected + /// chrome structure is present. + fn live_preview(&self, screen: &dyn ScreenFacts) -> Option<(String, &'static str)>; + + /// Return a model label from stable CLI chrome. The preview cascade + /// prepends it to live status as `{label} · `. + fn model_label(&self, screen: &dyn ScreenFacts) -> Option; + + /// Optionally normalize a captured title for display. Emulator title + /// capture remains program-agnostic; `None` renders the title verbatim. + fn normalize_title(&self, _title: &str) -> Option { + None + } +} + /// Resolve the instantaneous candidate in descending priority: /// 1. summary adapter: the normalized live status when the CLI's working /// structure is present, `{model label} · `-prefixed when the adapter @@ -174,7 +143,7 @@ fn cascade(screen: &impl ScreenFacts, adapter: Option<&dyn SummaryAdapter>) -> P } /// State that invalidates the cached preview candidate. -type ResolveKey = (u64, u64, bool, Option, bool); +type ResolveKey = (u64, u64, bool, Option); /// Per-task preview resolution state. A rerun replaces the `Task` and resets /// this state. @@ -236,7 +205,6 @@ impl PreviewState { pub fn resolve( &mut self, now: Instant, - finished: bool, screen: &impl ScreenFacts, adapter: Option<&dyn SummaryAdapter>, ) -> &Preview { @@ -248,7 +216,6 @@ impl PreviewState { screen.alt_epoch(), screen.alternate_screen(), screen.title().map(str::to_owned), - finished, ); if self.last_key.as_ref() != Some(&key) { self.candidate = cascade(screen, adapter); @@ -328,32 +295,18 @@ impl PreviewState { self.downgrade_pending_since = None; } - /// Freeze the preview once output is complete. An `exit_line` takes - /// precedence; otherwise the final screen is resolved without hold timers. + /// Freeze the preview once output is complete: the final screen is + /// resolved without hold timers. /// If an alternate-screen render is followed only by restoration of the /// snapshotted primary floor, the rendered preview is retained. A /// different final floor is resolved normally. /// Repeated calls are no-ops. - pub fn finalize( - &mut self, - screen: &impl ScreenFacts, - adapter: Option<&dyn SummaryAdapter>, - exit_line: Option, - ) { + pub fn finalize(&mut self, screen: &impl ScreenFacts, adapter: Option<&dyn SummaryAdapter>) { if self.finalized { return; } self.finalized = true; self.cancel_demotion(); - if let Some(text) = exit_line { - self.rendered = Preview { - text, - source: PreviewSource::Anchor, - rule: None, - frozen: true, - }; - return; - } let alt_torn_down_at_exit = self.rendered_under_alt && !screen.alternate_screen() && screen @@ -498,7 +451,7 @@ mod tests { live: Some(("Working", "stub:working")), label: Some("model-x"), }; - let p = st.resolve(now, false, &s, Some(&adapter)).clone(); + let p = st.resolve(now, &s, Some(&adapter)).clone(); assert_eq!( (p.text.as_str(), p.source, p.rule), ( @@ -514,7 +467,7 @@ mod tests { label: None, }; let mut st = PreviewState::new(); - let p = st.resolve(now, false, &s, Some(&bare)).clone(); + let p = st.resolve(now, &s, Some(&bare)).clone(); assert_eq!(p.text, "Working"); } @@ -531,7 +484,7 @@ mod tests { live: Some(("Working", "stub:working")), label: None, }; - st.resolve(t0, false, &s, Some(&working)); + st.resolve(t0, &s, Some(&working)); let idle = StubAdapter { live: None, @@ -539,13 +492,11 @@ mod tests { }; s.advance(); assert_eq!( - st.resolve(t0, false, &s, Some(&idle)).source, + st.resolve(t0, &s, Some(&idle)).source, PreviewSource::Anchor, "a lost anchor must not demote instantly" ); - let p = st - .resolve(t0 + DEMOTION_HOLD, false, &s, Some(&idle)) - .clone(); + let p = st.resolve(t0 + DEMOTION_HOLD, &s, Some(&idle)).clone(); assert_eq!((p.text.as_str(), p.source), ("app", PreviewSource::Title)); } @@ -560,32 +511,17 @@ mod tests { live: Some(("Ran echo ok", "stub:ran")), label: None, }; - st.resolve(t0, false, &s, None); + st.resolve(t0, &s, None); s.advance(); - st.finalize(&s, Some(&adapter), None); - let p = st.resolve(t0, true, &s, Some(&adapter)).clone(); + st.finalize(&s, Some(&adapter)); + let p = st.resolve(t0, &s, Some(&adapter)).clone(); assert_eq!( (p.text.as_str(), p.source, p.rule, p.frozen), ("Ran echo ok", PreviewSource::Anchor, Some("stub:ran"), true) ); } - /// An adapter exit line outranks the final screen and freezes verbatim. - #[test] - fn finalize_prefers_an_adapter_exit_line() { - let t0 = Instant::now(); - let mut st = PreviewState::new(); - let s = FakeScreen::primary("junk floor"); - st.resolve(t0, false, &s, None); - st.finalize(&s, None, Some("session done".to_string())); - let p = st.resolve(t0, true, &s, None).clone(); - assert_eq!( - (p.text.as_str(), p.source, p.frozen), - ("session done", PreviewSource::Anchor, true) - ); - } - /// Each cascade tier maps one emulator fact to one source. #[test] fn cascade_maps_screen_facts_to_sources() { @@ -593,7 +529,7 @@ mod tests { let mut st = PreviewState::new(); let s = FakeScreen::primary("last row"); - let p = st.resolve(now, false, &s, None).clone(); + let p = st.resolve(now, &s, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("last row", PreviewSource::Floor, false) @@ -603,18 +539,18 @@ mod tests { let mut s = FakeScreen::primary("shell"); s.enter_alt(); s.set_title("app"); - let p = st.resolve(now, false, &s, None).clone(); + let p = st.resolve(now, &s, None).clone(); assert_eq!((p.text.as_str(), p.source), ("app", PreviewSource::Title)); let mut st = PreviewState::new(); let mut s = FakeScreen::primary("shell"); s.enter_alt(); - let p = st.resolve(now, false, &s, None).clone(); + let p = st.resolve(now, &s, None).clone(); assert_eq!((p.text.as_str(), p.source), (MARKER, PreviewSource::Marker)); let mut st = PreviewState::new(); let s = FakeScreen::primary(""); - let p = st.resolve(now, false, &s, None).clone(); + let p = st.resolve(now, &s, None).clone(); assert_eq!((p.text.as_str(), p.source), ("", PreviewSource::Floor)); } @@ -624,17 +560,14 @@ mod tests { let now = Instant::now(); let mut st = PreviewState::new(); let mut s = FakeScreen::primary("building"); - assert_eq!( - st.resolve(now, false, &s, None).source, - PreviewSource::Floor - ); + assert_eq!(st.resolve(now, &s, None).source, PreviewSource::Floor); s.enter_alt(); - let p = st.resolve(now, false, &s, None).clone(); + let p = st.resolve(now, &s, None).clone(); assert_eq!((p.text.as_str(), p.source), (MARKER, PreviewSource::Marker)); s.set_title("app"); - let p = st.resolve(now, false, &s, None).clone(); + let p = st.resolve(now, &s, None).clone(); assert_eq!((p.text.as_str(), p.source), ("app", PreviewSource::Title)); } @@ -647,20 +580,17 @@ mod tests { let mut s = FakeScreen::primary("shell"); s.enter_alt(); s.set_title("app"); - st.resolve(t0, false, &s, None); + st.resolve(t0, &s, None); s.clear_title(); assert_eq!( - st.resolve(t0, false, &s, None).source, + st.resolve(t0, &s, None).source, PreviewSource::Title, "a demotion must not render instantly" ); let inside = t0 + DEMOTION_HOLD - Duration::from_millis(1); - assert_eq!( - st.resolve(inside, false, &s, None).source, - PreviewSource::Title - ); - let p = st.resolve(t0 + DEMOTION_HOLD, false, &s, None).clone(); + assert_eq!(st.resolve(inside, &s, None).source, PreviewSource::Title); + let p = st.resolve(t0 + DEMOTION_HOLD, &s, None).clone(); assert_eq!((p.text.as_str(), p.source), (MARKER, PreviewSource::Marker)); } @@ -674,25 +604,25 @@ mod tests { let mut s = FakeScreen::primary("shell"); s.enter_alt(); s.set_title("app"); - st.resolve(t0, false, &s, None); + st.resolve(t0, &s, None); s.clear_title(); - st.resolve(t0, false, &s, None); + st.resolve(t0, &s, None); // The title returns inside the hold: cancel, no visible change. s.set_title("app"); - let p = st.resolve(t0 + ms(300), false, &s, None).clone(); + let p = st.resolve(t0 + ms(300), &s, None).clone(); assert_eq!((p.text.as_str(), p.source), ("app", PreviewSource::Title)); // The next demotion starts a new hold interval. s.clear_title(); - st.resolve(t0 + ms(400), false, &s, None); + st.resolve(t0 + ms(400), &s, None); assert_eq!( - st.resolve(t0 + ms(900), false, &s, None).source, + st.resolve(t0 + ms(900), &s, None).source, PreviewSource::Title, "the canceled hold must not shorten the fresh one" ); assert_eq!( - st.resolve(t0 + ms(1_000), false, &s, None).source, + st.resolve(t0 + ms(1_000), &s, None).source, PreviewSource::Marker ); } @@ -706,20 +636,19 @@ mod tests { let mut s = FakeScreen::primary("shell"); s.enter_alt(); s.set_title("app"); - st.resolve(t0, false, &s, None); + st.resolve(t0, &s, None); // First demoted candidate: the marker. s.clear_title(); - st.resolve(t0, false, &s, None); + st.resolve(t0, &s, None); // The pending candidate flaps to a floor; the timer keeps t0. s.leave_alt(); s.set_floor("done 3 tests"); assert_eq!( - st.resolve(t0 + Duration::from_millis(300), false, &s, None) - .source, + st.resolve(t0 + Duration::from_millis(300), &s, None).source, PreviewSource::Title ); - let p = st.resolve(t0 + DEMOTION_HOLD, false, &s, None).clone(); + let p = st.resolve(t0 + DEMOTION_HOLD, &s, None).clone(); assert_eq!( (p.text.as_str(), p.source), ("done 3 tests", PreviewSource::Floor), @@ -737,14 +666,14 @@ mod tests { let mut s = FakeScreen::primary("shell"); s.enter_alt(); s.set_title("one"); - assert_eq!(st.resolve(t0, false, &s, None).text, "one"); + assert_eq!(st.resolve(t0, &s, None).text, "one"); s.set_title("two"); - assert_eq!(st.resolve(t0 + ms(200), false, &s, None).text, "one"); + assert_eq!(st.resolve(t0 + ms(200), &s, None).text, "one"); s.set_title("three"); - assert_eq!(st.resolve(t0 + ms(300), false, &s, None).text, "one"); + assert_eq!(st.resolve(t0 + ms(300), &s, None).text, "one"); assert_eq!( - st.resolve(t0 + TITLE_MIN_HOLD, false, &s, None).text, + st.resolve(t0 + TITLE_MIN_HOLD, &s, None).text, "three", "the newest candidate wins at the deadline" ); @@ -756,9 +685,9 @@ mod tests { let t0 = Instant::now(); let mut st = PreviewState::new(); let mut s = FakeScreen::primary("compiling foo"); - assert_eq!(st.resolve(t0, false, &s, None).text, "compiling foo"); + assert_eq!(st.resolve(t0, &s, None).text, "compiling foo"); s.set_floor("compiling bar"); - assert_eq!(st.resolve(t0, false, &s, None).text, "compiling bar"); + assert_eq!(st.resolve(t0, &s, None).text, "compiling bar"); } /// An unchanged resolution key carries the candidate without re-reading @@ -769,14 +698,14 @@ mod tests { let ms = Duration::from_millis; let mut st = PreviewState::new(); let mut s = FakeScreen::primary("steady"); - st.resolve(t0, false, &s, None); + st.resolve(t0, &s, None); assert_eq!(s.floor_calls.get(), 1); - st.resolve(t0 + ms(200), false, &s, None); + st.resolve(t0 + ms(200), &s, None); assert_eq!(s.floor_calls.get(), 1, "unchanged key must not re-read"); s.advance(); - st.resolve(t0 + ms(400), false, &s, None); + st.resolve(t0 + ms(400), &s, None); assert_eq!(s.floor_calls.get(), 2, "a revision bump must recompute"); } @@ -787,11 +716,11 @@ mod tests { let t0 = Instant::now(); let mut st = PreviewState::new(); let mut s = FakeScreen::primary("a long row that fit"); - assert_eq!(st.resolve(t0, false, &s, None).text, "a long row that fit"); + assert_eq!(st.resolve(t0, &s, None).text, "a long row that fit"); s.set_floor("a long row"); assert_eq!( - st.resolve(t0, false, &s, None).text, + st.resolve(t0, &s, None).text, "a long row", "the reflowed floor must render, not the carried candidate" ); @@ -804,11 +733,11 @@ mod tests { let t0 = Instant::now(); let mut st = PreviewState::new(); let mut s = FakeScreen::primary("running"); - st.resolve(t0, false, &s, None); + st.resolve(t0, &s, None); s.set_floor("test result: ok"); - st.finalize(&s, None, None); - let p = st.resolve(t0, true, &s, None).clone(); + st.finalize(&s, None); + let p = st.resolve(t0, &s, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("test result: ok", PreviewSource::Floor, true) @@ -824,12 +753,12 @@ mod tests { let mut s = FakeScreen::primary("prelaunch junk"); s.enter_alt(); s.set_title("agent: working"); - st.resolve(t0, false, &s, None); + st.resolve(t0, &s, None); // The exit's 1049l lands with no live resolution in between. s.leave_alt(); - st.finalize(&s, None, None); - let p = st.resolve(t0, true, &s, None).clone(); + st.finalize(&s, None); + let p = st.resolve(t0, &s, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("agent: working", PreviewSource::Title, true) @@ -837,7 +766,7 @@ mod tests { s.set_floor("stray"); assert_eq!( - st.resolve(t0 + Duration::from_secs(5), true, &s, None).text, + st.resolve(t0 + Duration::from_secs(5), &s, None).text, "agent: working", "resolution must short-circuit to the frozen value" ); @@ -852,19 +781,19 @@ mod tests { let mut s = FakeScreen::primary("prelaunch junk"); s.enter_alt(); s.set_title("agent: working"); - st.resolve(t0, false, &s, None); + st.resolve(t0, &s, None); // Teardown lands and a tick resolves before output completes. s.leave_alt(); - let p = st.resolve(t0, false, &s, None).clone(); + let p = st.resolve(t0, &s, None).clone(); assert_eq!( p.source, PreviewSource::Title, "premise: the demotion hold keeps the title rendered" ); - st.finalize(&s, None, None); - let p = st.resolve(t0, true, &s, None).clone(); + st.finalize(&s, None); + let p = st.resolve(t0, &s, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("agent: working", PreviewSource::Title, true) @@ -880,14 +809,14 @@ mod tests { let mut s = FakeScreen::primary("prelaunch junk"); s.enter_alt(); s.set_title("agent: working"); - st.resolve(t0, false, &s, None); + st.resolve(t0, &s, None); // The child returns to the primary screen and keeps printing; the // hold expires and commits the floor, stamped primary. s.leave_alt(); s.set_floor("wrote 12 files"); - st.resolve(t0, false, &s, None); - let p = st.resolve(t0 + DEMOTION_HOLD, false, &s, None).clone(); + st.resolve(t0, &s, None); + let p = st.resolve(t0 + DEMOTION_HOLD, &s, None).clone(); assert_eq!( (p.text.as_str(), p.source), ("wrote 12 files", PreviewSource::Floor), @@ -895,8 +824,8 @@ mod tests { ); s.set_floor("exit summary"); - st.finalize(&s, None, None); - let p = st.resolve(t0 + DEMOTION_HOLD, true, &s, None).clone(); + st.finalize(&s, None); + let p = st.resolve(t0 + DEMOTION_HOLD, &s, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("exit summary", PreviewSource::Floor, true), @@ -913,16 +842,16 @@ mod tests { let mut s = FakeScreen::primary("prelaunch junk"); s.enter_alt(); s.set_title("agent: working"); - st.resolve(t0, false, &s, None); + st.resolve(t0, &s, None); // Teardown observed (snapshot: "prelaunch junk"), title still held. s.leave_alt(); - assert_eq!(st.resolve(t0, false, &s, None).source, PreviewSource::Title); + assert_eq!(st.resolve(t0, &s, None).source, PreviewSource::Title); // A real final line lands before exit, inside the hold. s.set_floor("done"); - st.finalize(&s, None, None); - let p = st.resolve(t0, true, &s, None).clone(); + st.finalize(&s, None); + let p = st.resolve(t0, &s, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("done", PreviewSource::Floor, true), @@ -940,18 +869,18 @@ mod tests { let mut s = FakeScreen::primary("prelaunch junk"); s.enter_alt(); s.set_title("agent: working"); - st.resolve(t0, false, &s, None); + st.resolve(t0, &s, None); s.leave_alt(); - assert_eq!(st.resolve(t0, false, &s, None).source, PreviewSource::Title); + assert_eq!(st.resolve(t0, &s, None).source, PreviewSource::Title); // A later advance changes the revision (and drops the title) but // leaves the floor untouched: nothing visible moved. s.clear_title(); - assert_eq!(st.resolve(t0, false, &s, None).source, PreviewSource::Title); + assert_eq!(st.resolve(t0, &s, None).source, PreviewSource::Title); - st.finalize(&s, None, None); - let p = st.resolve(t0, true, &s, None).clone(); + st.finalize(&s, None); + let p = st.resolve(t0, &s, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("agent: working", PreviewSource::Title, true), @@ -969,7 +898,7 @@ mod tests { emu.process(b"prelaunch junk\r\n"); emu.process(b"\x1b[?1049h\x1b]0;working\x07app body"); assert_eq!( - st.resolve(t0, false, &emu, None).source, + st.resolve(t0, &emu, None).source, PreviewSource::Title, "premise: the title rendered under the alt screen" ); @@ -981,8 +910,8 @@ mod tests { Some("prelaunch junk"), "premise: the snapshot is the restore, not the successor line" ); - st.finalize(&emu, None, None); - let p = st.resolve(t0, true, &emu, None).clone(); + st.finalize(&emu, None); + let p = st.resolve(t0, &emu, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("done", PreviewSource::Floor, true) @@ -998,10 +927,7 @@ mod tests { let mut emu = Emulator::new(24, 40, 100); emu.process(b"prelaunch junk that will wrap\r\n"); emu.process(b"\x1b[?1049h\x1b]0;working\x07app body"); - assert_eq!( - st.resolve(t0, false, &emu, None).source, - PreviewSource::Title - ); + assert_eq!(st.resolve(t0, &emu, None).source, PreviewSource::Title); emu.process(b"\x1b[?1049l"); let before = emu.live_floor(); @@ -1011,8 +937,8 @@ mod tests { before, "premise: the reflow moved the floor" ); - st.finalize(&emu, None, None); - let p = st.resolve(t0, true, &emu, None).clone(); + st.finalize(&emu, None); + let p = st.resolve(t0, &emu, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("working", PreviewSource::Title, true), @@ -1029,16 +955,13 @@ mod tests { let mut emu = Emulator::new(24, 40, 100); emu.process(b"prelaunch junk that will wrap\r\n"); emu.process(b"\x1b[?1049h\x1b]0;working\x07app body"); - assert_eq!( - st.resolve(t0, false, &emu, None).source, - PreviewSource::Title - ); + assert_eq!(st.resolve(t0, &emu, None).source, PreviewSource::Title); emu.process(b"\x1b[?1049l"); emu.process(b"done\r\n"); emu.resize(24, 20); - st.finalize(&emu, None, None); - let p = st.resolve(t0, true, &emu, None).clone(); + st.finalize(&emu, None); + let p = st.resolve(t0, &emu, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("done", PreviewSource::Floor, true), @@ -1055,11 +978,11 @@ mod tests { let mut s = FakeScreen::primary("shell"); s.enter_alt(); s.set_title("step 1"); - st.resolve(t0, false, &s, None); + st.resolve(t0, &s, None); s.set_title("step 2: done"); - st.finalize(&s, None, None); - let p = st.resolve(t0, true, &s, None).clone(); + st.finalize(&s, None); + let p = st.resolve(t0, &s, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("step 2: done", PreviewSource::Title, true) @@ -1072,7 +995,7 @@ mod tests { let mut st = PreviewState::new(); let t0 = Instant::now(); let s = FakeScreen::primary(" gpt-5.6-sol high · fleetcom · 89.9K used"); - let p = st.resolve(t0, false, &s, None).clone(); + let p = st.resolve(t0, &s, None).clone(); assert_eq!( (p.text.as_str(), p.source), ( diff --git a/src/protocol.rs b/src/protocol.rs index 22c1533..ccf8e60 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -9,10 +9,7 @@ use std::{ use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; -use crate::{ - frame::{KIND_CONTROL, KIND_HELLO, KIND_SCREEN}, - preview::PreviewSource, -}; +use crate::frame::{KIND_CONTROL, KIND_HELLO, KIND_SCREEN}; /// Wire-protocol version; the handshake rejects mismatched peers. pub const PROTOCOL_VERSION: u32 = 8; @@ -204,6 +201,58 @@ pub enum Lifecycle { Failed, } +/// Where a preview's text came from. Declared in ascending authority so the +/// derived `Ord` ranks provenance directly: `Anchor > Title > Marker > +/// Floor`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum PreviewSource { + /// The last non-blank row of the live screen: unshadowable for + /// primary-screen programs, so a title never replaces live stream output. + Floor, + /// The alternate screen is active with no usable title. + Marker, + /// The child's window title, honored only on the alternate screen. + Title, + /// Normalized adapter output: the cascade's top tier. + Anchor, +} + +impl PreviewSource { + /// Lowercase provenance identifier. + pub fn label(self) -> &'static str { + match self { + PreviewSource::Floor => "floor", + PreviewSource::Marker => "marker", + PreviewSource::Title => "title", + PreviewSource::Anchor => "anchor", + } + } +} + +/// A resolved dashboard preview sent as part of [`TaskView`]. +#[derive(Debug, Clone, PartialEq)] +pub struct Preview { + pub text: String, + pub source: PreviewSource, + /// Summary-adapter matcher ID for an Anchor preview; `None` for other + /// sources. Never encoded, so a wire-decoded view always carries `None`. + pub rule: Option<&'static str>, + /// Whether the preview froze at output-complete and can no longer change. + pub frozen: bool, +} + +impl Preview { + /// An unfrozen `Floor` preview of `text`. + pub(crate) fn floor(text: String) -> Preview { + Preview { + text, + source: PreviewSource::Floor, + rule: None, + frozen: false, + } + } +} + /// A read-only snapshot of one task: everything a dashboard row needs, with no /// handle into the live process. Time is pre-reduced to the `*_ago` durations /// and `lifecycle`/`parked` are pre-computed by the core (it owns the clock @@ -223,14 +272,8 @@ pub struct TaskView { /// Quiet past the placement window, a much longer edge than `lifecycle`'s /// idle threshold; `false` once finished. pub parked: bool, - pub preview: String, - /// Provenance of `preview`. - pub source: PreviewSource, - /// Whether the preview froze at output-complete and can no longer change. - pub frozen: bool, - /// Matcher ID for an adapter-produced preview. It is available only - /// in-process and is not encoded on the wire. - pub rule: Option<&'static str>, + /// The dashboard preview, resolved by the core at snapshot time. + pub preview: Preview, pub started_ago: Duration, /// Time since the last PTY output; `Some` only while the task is live. pub quiet_ago: Option, @@ -366,7 +409,6 @@ fn lifecycle_from(s: &str) -> Option { } } -// Encoding uses `PreviewSource::label`; only the decode direction lives here. fn source_from(s: &str) -> Option { match s { "floor" => Some(PreviewSource::Floor), @@ -751,10 +793,10 @@ pub fn encode_event(ev: &Event) -> (u8, Vec) { insert_opt_str(&mut o, "group", &tv.group); insert_opt_str(&mut o, "name", &tv.name); let _ = o.insert("life", lifecycle_str(tv.lifecycle)); - let _ = o.insert("preview", tv.preview.as_str()); + let _ = o.insert("preview", tv.preview.text.as_str()); // Matcher rules are process-local and omitted from the wire. - let _ = o.insert("src", tv.source.label()); - let _ = o.insert("frozen", tv.frozen); + let _ = o.insert("src", tv.preview.source.label()); + let _ = o.insert("frozen", tv.preview.frozen); let _ = o.insert("started_ms", tv.started_ago.as_millis() as u64); let _ = o.insert("parked", tv.parked); // Each age exists in exactly one phase: `quiet_ms` while @@ -839,11 +881,6 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { } else { source_from(tv["src"].as_str()?)? }; - let frozen = if tv["frozen"].is_null() { - false - } else { - tv["frozen"].as_bool()? - }; views.push(TaskView { id: tv["id"].as_u64()?, command: tv["command"].as_str()?.to_string(), @@ -854,10 +891,12 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { name: opt_str(&tv["name"])?, lifecycle, parked, - preview: tv["preview"].as_str()?.to_string(), - source, - frozen, - rule: None, + preview: Preview { + text: tv["preview"].as_str()?.to_string(), + source, + rule: None, + frozen: bool_flag(&tv["frozen"])?, + }, started_ago: Duration::from_millis(tv["started_ms"].as_u64()?), // Absent from pre-`parked` daemons: unknown, not zero. quiet_ago: opt_ms(&tv["quiet_ms"])?, @@ -1150,6 +1189,25 @@ mod tests { p } + /// The neutral task view the exact-wire-string assertions pin: every + /// optional key absent, every flag false, an empty unfrozen floor preview. + fn tv(id: u64) -> TaskView { + TaskView { + id, + command: "x".into(), + cwd: PathBuf::from("/"), + tagged: false, + group: None, + name: None, + lifecycle: Lifecycle::Ok, + parked: false, + preview: Preview::floor(String::new()), + started_ago: Duration::from_millis(0), + quiet_ago: None, + finished_ago: None, + } + } + /// A mistyped member in `lines`, `tasks`, or `names` rejects the whole /// event, keeping decoded rows aligned with their encoded positions. #[test] @@ -1206,31 +1264,23 @@ mod tests { name: Some("editor".into()), lifecycle: Lifecycle::Idle, parked: false, - preview: "~ line".into(), - source: PreviewSource::Title, - frozen: false, - rule: None, + preview: Preview { + text: "~ line".into(), + source: PreviewSource::Title, + rule: None, + frozen: false, + }, started_ago: Duration::from_millis(4200), quiet_ago: Some(Duration::from_millis(700)), finished_ago: None, }, TaskView { - id: 2, command: "make".into(), // Exercise byte-preserving task-path serialization. cwd: PathBuf::from(OsString::from_vec(b"/srv/\xff\xfe".to_vec())), - tagged: false, - group: None, - name: None, lifecycle: Lifecycle::Active, - parked: false, - preview: String::new(), - source: PreviewSource::Floor, - frozen: false, - rule: None, started_ago: Duration::from_millis(10), - quiet_ago: None, - finished_ago: None, + ..tv(2) }, ]); let (k, p) = encode_event(&tasks); @@ -1307,23 +1357,7 @@ mod tests { other => panic!("expected tasks event, got {other:?}"), } // Encoding an unassigned task omits the group key. - let (_, p) = encode_event(&Event::Tasks(vec![TaskView { - id: 1, - command: "x".into(), - cwd: PathBuf::from("/"), - tagged: false, - group: None, - name: None, - lifecycle: Lifecycle::Ok, - parked: false, - preview: String::new(), - source: PreviewSource::Floor, - frozen: false, - rule: None, - started_ago: Duration::from_millis(0), - quiet_ago: None, - finished_ago: None, - }])); + let (_, p) = encode_event(&Event::Tasks(vec![tv(1)])); assert_eq!(std::str::from_utf8(&p).unwrap(), ungrouped); let grouped = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"group":"infra","life":"ok","preview":"","started_ms":0}]}"#; @@ -1344,23 +1378,7 @@ mod tests { other => panic!("expected tasks event, got {other:?}"), } // Encoding an unnamed task omits the name key. - let (_, p) = encode_event(&Event::Tasks(vec![TaskView { - id: 1, - command: "x".into(), - cwd: PathBuf::from("/"), - tagged: false, - group: None, - name: None, - lifecycle: Lifecycle::Ok, - parked: false, - preview: String::new(), - source: PreviewSource::Floor, - frozen: false, - rule: None, - started_ago: Duration::from_millis(0), - quiet_ago: None, - finished_ago: None, - }])); + let (_, p) = encode_event(&Event::Tasks(vec![tv(1)])); assert_eq!(std::str::from_utf8(&p).unwrap(), unnamed); let named = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"name":"build","life":"ok","preview":"","started_ms":0}]}"#; @@ -1377,38 +1395,18 @@ mod tests { fn parked_and_age_fields_round_trip() { let tasks = Event::Tasks(vec![ TaskView { - id: 1, command: "top".into(), - cwd: PathBuf::from("/"), - tagged: false, - group: None, - name: None, lifecycle: Lifecycle::Idle, parked: true, - preview: String::new(), - source: PreviewSource::Floor, - frozen: false, - rule: None, started_ago: Duration::from_millis(60_000), quiet_ago: Some(Duration::from_millis(12_000)), - finished_ago: None, + ..tv(1) }, TaskView { - id: 2, command: "make".into(), - cwd: PathBuf::from("/"), - tagged: false, - group: None, - name: None, - lifecycle: Lifecycle::Ok, - parked: false, - preview: String::new(), - source: PreviewSource::Floor, - frozen: false, - rule: None, started_ago: Duration::from_millis(60_000), - quiet_ago: None, finished_ago: Some(Duration::from_millis(3_000)), + ..tv(2) }, ]); let (k, p) = encode_event(&tasks); @@ -1441,38 +1439,36 @@ mod tests { #[test] fn preview_source_and_frozen_round_trip() { let base = TaskView { - id: 1, - command: "x".into(), - cwd: PathBuf::from("/"), - tagged: false, - group: None, - name: None, lifecycle: Lifecycle::Active, - parked: false, - preview: "p".into(), - source: PreviewSource::Floor, - frozen: false, - rule: None, - started_ago: Duration::from_millis(0), + preview: Preview::floor("p".into()), quiet_ago: Some(Duration::from_millis(1)), - finished_ago: None, + ..tv(1) }; let tasks = Event::Tasks(vec![ base.clone(), TaskView { id: 2, - source: PreviewSource::Marker, + preview: Preview { + source: PreviewSource::Marker, + ..base.preview.clone() + }, ..base.clone() }, TaskView { id: 3, - source: PreviewSource::Title, - frozen: true, + preview: Preview { + source: PreviewSource::Title, + frozen: true, + ..base.preview.clone() + }, ..base.clone() }, TaskView { id: 4, - source: PreviewSource::Anchor, + preview: Preview { + source: PreviewSource::Anchor, + ..base.preview.clone() + }, ..base.clone() }, ]); @@ -1481,8 +1477,11 @@ mod tests { // Encoding omits the process-local matcher rule. let ruled = Event::Tasks(vec![TaskView { - source: PreviewSource::Anchor, - rule: Some("claude-status"), + preview: Preview { + source: PreviewSource::Anchor, + rule: Some("claude-status"), + ..base.preview.clone() + }, ..base.clone() }]); let (k, p) = encode_event(&ruled); @@ -1493,8 +1492,8 @@ mod tests { ); match decode_event(k, &p) { Some(Event::Tasks(v)) => { - assert_eq!(v[0].source, PreviewSource::Anchor); - assert_eq!(v[0].rule, None, "rule must stay daemon-side"); + assert_eq!(v[0].preview.source, PreviewSource::Anchor); + assert_eq!(v[0].preview.rule, None, "rule must stay daemon-side"); } other => panic!("expected tasks event, got {other:?}"), } @@ -1506,9 +1505,9 @@ mod tests { let old = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"active","preview":"p","started_ms":0,"parked":false}]}"#; match decode_event(KIND_CONTROL, old.as_bytes()) { Some(Event::Tasks(v)) => { - assert_eq!(v[0].source, PreviewSource::Floor); - assert!(!v[0].frozen); - assert_eq!(v[0].rule, None); + assert_eq!(v[0].preview.source, PreviewSource::Floor); + assert!(!v[0].preview.frozen); + assert_eq!(v[0].preview.rule, None); } other => panic!("expected tasks event, got {other:?}"), } diff --git a/src/supervisor.rs b/src/supervisor.rs index bcf72fc..77083d7 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -26,9 +26,6 @@ use crate::{ /// use this same threshold. const IDLE_AFTER: Duration = Duration::from_secs(10); -/// Send-on-change fingerprint for the watched screen and scrollback offset. -type LastScreen = (u64, Vec, (u16, u16), bool, (bool, bool, bool), usize); - /// Per-dimension PTY size limit. Resizes are clamped to `[1, MAX_DIM]` to keep /// grid dimensions valid and memory bounded. const MAX_DIM: u16 = 1000; @@ -88,10 +85,8 @@ fn effective_scrollback(flag: Option, env: Option<&str>) -> usize { .map_or(DEFAULT_SCROLLBACK, |lines| lines.min(MAX_SCROLLBACK)) } -/// How long a SIGTERMed job gets to exit before SIGKILL. TERM-respecting -/// processes exit in milliseconds, so this is the *ceiling* on quit latency, -/// not the norm; 2 s is enough for any real flush handler while keeping a -/// wedged job from making `Q` feel broken. +/// Grace period between SIGTERM and SIGKILL, bounding shutdown delay for tasks +/// that do not exit after SIGTERM. const KILL_GRACE: Duration = Duration::from_secs(2); /// Maximum stored label length in Unicode scalar values after normalization, @@ -182,11 +177,10 @@ pub struct Supervisor { scrollback: usize, /// The task whose screen the client is watching (attach/peek), or `None`. watched: Option, - /// The last `Screen` we emitted (`(id, formatted, cursor, hide)`), so an - /// unchanged screen isn't re-serialized and re-sent every tick. Reset to - /// `None` whenever `watched` changes, so re-attaching always gets a fresh - /// full screen (the client cleared its copy on detach). - last_screen: Option, + /// The last emitted screen fingerprint. `lines` stays empty because only + /// emitted copies carry them. Cleared when `watched` changes to force a + /// fresh screen after attachment. + last_screen: Option, /// The current client's launch context, used for spawns and session paths. /// Spawning is refused until one is installed. launch: Option, @@ -457,7 +451,6 @@ impl Supervisor { .iter_mut() .map(|t| { t.flush_expired_sync(); - let preview = t.resolve_preview(now); TaskView { id: t.id, command: t.command.clone(), @@ -467,10 +460,7 @@ impl Supervisor { name: t.name.clone(), lifecycle: t.lifecycle(now, IDLE_AFTER), parked: t.parked(now, IDLE_AFTER), - preview: preview.text, - source: preview.source, - frozen: preview.frozen, - rule: preview.rule, + preview: t.resolve_preview(now), started_ago: now.duration_since(t.started), quiet_ago: t.finished.is_none().then(|| t.quiet_for(now)), finished_ago: t.finished.map(|f| now.duration_since(f)), @@ -482,30 +472,29 @@ impl Supervisor { if let Some(id) = self.watched && let Some(t) = self.tasks.iter().find(|t| t.id == id) { - let (formatted, cursor, hide_cursor) = t.formatted(); - let hints = t.input_hints(); + let (formatted, cursor, hide) = t.formatted(); + let (wants_mouse, alt_screen, alt_scroll) = t.input_hints(); let sb = t.scroll_offset(); + let mut view = ScreenView { + id, + // `lines` stays empty on both the stored and candidate copies + // so it never affects equality; it is filled only on the + // emitted copy. + lines: Vec::new(), + formatted, + cursor, + // Hide the live cursor while displaying scrollback. + hide_cursor: hide || sb > 0, + wants_mouse, + alt_screen, + alt_scroll, + scrollback: sb, + }; // Send only when rendering or input-policy state changes. - let unchanged = matches!( - &self.last_screen, - Some((lid, lf, lc, lh, lhints, lsb)) - if *lid == id && *lf == formatted && *lc == cursor - && *lh == hide_cursor && *lhints == hints && *lsb == sb - ); - if !unchanged { - self.last_screen = Some((id, formatted.clone(), cursor, hide_cursor, hints, sb)); - self.events.push(Event::Screen(ScreenView { - id, - lines: t.screen_lines(), - formatted, - cursor, - // Hide the live cursor while displaying scrollback. - hide_cursor: hide_cursor || sb > 0, - wants_mouse: hints.0, - alt_screen: hints.1, - alt_scroll: hints.2, - scrollback: sb, - })); + if self.last_screen.as_ref() != Some(&view) { + self.last_screen = Some(view.clone()); + view.lines = t.screen_lines(); + self.events.push(Event::Screen(view)); } } } @@ -705,7 +694,7 @@ impl Supervisor { fresh.tagged = self.tasks[i].tagged; fresh.group = self.tasks[i].group.clone(); fresh.name = self.tasks[i].name.clone(); - // The displaced job exits like a Remove: TERM now, the + // The displaced task exits like a Remove: TERM now, the // graveyard's grace-then-KILL behind it. Dropping it here // would straight-SIGKILL stragglers of the old run. let mut old = std::mem::replace(&mut self.tasks[i], fresh); @@ -867,7 +856,6 @@ impl Supervisor { } } -// Tests live in supervisor_tests.rs: at ≈2,800 lines they dwarf the module itself. #[cfg(test)] #[path = "supervisor_tests.rs"] mod tests; diff --git a/src/supervisor_capture_tests.rs b/src/supervisor_capture_tests.rs new file mode 100644 index 0000000..5dad1d7 --- /dev/null +++ b/src/supervisor_capture_tests.rs @@ -0,0 +1,1105 @@ +use super::*; +use crate::harness::testutil::{ID as CAP_ID, OTHER as CAP_OTHER}; + +// --- session-capture wiring ------------------------------------------- + +/// Install an executable stub that records `FLEETCOM_CAPTURE_FILE` and +/// its argv, one token per line, then exits. +fn install_stub(bin: &Path, name: &str, out: &Path) { + install_script( + bin, + name, + &format!( + "printf '%s' \"$FLEETCOM_CAPTURE_FILE\" > '{out}/capenv'\n\ + printf '%s\\n' \"$@\" > '{out}/argv'", + out = out.display() + ), + ); +} + +/// Launch context containing only the stub path, shell, and capture root. +fn agent_ctx(bin: &Path, runtime: &Path, cwd: PathBuf) -> LaunchContext { + LaunchContext { + env: vec![ + ( + "PATH".into(), + format!("{}:/usr/bin:/bin", bin.display()).into(), + ), + ("SHELL".into(), "/bin/sh".into()), + ( + "FLEETCOM_RUNTIME_DIR".into(), + runtime.as_os_str().to_os_string(), + ), + ], + cwd, + } +} + +/// Poll until the stub's argv record contains data, then return its lines. +fn wait_argv(s: &mut Supervisor, path: &Path) -> Vec { + assert!( + reap_until(s, Duration::from_secs(5), |_| std::fs::read_to_string(path) + .is_ok_and(|c| !c.is_empty())), + "the stub never recorded its argv" + ); + std::fs::read_to_string(path) + .unwrap() + .lines() + .map(str::to_string) + .collect() +} + +/// `agent_ctx` plus explicit environment pairs for recipe and harness +/// storage tests. +fn agent_ctx_plus( + bin: &Path, + runtime: &Path, + cwd: PathBuf, + extra: &[(&str, &Path)], +) -> LaunchContext { + let mut ctx = agent_ctx(bin, runtime, cwd); + for (k, v) in extra { + ctx.env.push(((*k).into(), v.as_os_str().to_os_string())); + } + ctx +} + +/// Install an executable stub with caller-supplied shell behavior. +fn install_script(bin: &Path, name: &str, body: &str) { + std::fs::create_dir_all(bin).unwrap(); + write_executable(&bin.join(name), body); +} + +/// Save a recipe and return its persisted JSON. +fn save_and_read(s: &mut Supervisor, config: &Path, name: &str) -> String { + s.apply(Command::SaveSession { name: name.into() }); + let _ = s.drain(); + std::fs::read_to_string(config.join("sessions").join(format!("{name}.json"))).unwrap() +} + +/// The FNV-1a discriminator is stable and separates distinct config roots. +#[test] +fn fnv_discriminator_is_stable_and_distinguishes_roots() { + // FNV-1a 64-bit vectors for the empty input and "a". + assert_eq!(fnv1a_hex(b""), "cbf29ce484222325"); + assert_eq!(fnv1a_hex(b"a"), "af63dc4c8601ec8c"); + assert_eq!(fnv1a_hex(b"/cfg/one"), fnv1a_hex(b"/cfg/one")); + assert_ne!(fnv1a_hex(b"/cfg/one"), fnv1a_hex(b"/cfg/two")); +} + +/// A `claude` spawn receives a pinned ID, settings overlay, and capture +/// environment without changing the stored command. +#[test] +fn spawn_claude_pins_an_id_and_layers_settings() { + let dir = scratch("cap_claude"); + let (bin, runtime) = (dir.join("bin"), dir.join("run")); + install_stub(&bin, "claude", &dir); + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); + spawn(&mut s, "claude", dir.clone()); + + let argv = wait_argv(&mut s, &dir.join("argv")); + let si = argv + .iter() + .position(|a| a == "--session-id") + .expect("the stub must receive --session-id"); + let id = argv[si + 1].clone(); + assert!( + crate::harness::is_uuid(&id), + "the pinned id must be a strict uuid: {id:?}" + ); + let fi = argv + .iter() + .position(|a| a == "--settings") + .expect("the stub must receive --settings"); + let settings = PathBuf::from(&argv[fi + 1]); + assert!(settings.is_file(), "the settings overlay must exist"); + let parsed = jzon::parse(&std::fs::read_to_string(&settings).unwrap()) + .expect("the settings overlay must be valid JSON"); + let hook = parsed["hooks"]["SessionStart"][0]["hooks"][0]["command"] + .as_str() + .expect("the overlay must carry the hook command"); + assert!( + hook.contains("FLEETCOM_CAPTURE_FILE"), + "the hook must write to the capture env: {hook:?}" + ); + + let t = &s.tasks[0]; + let cap = t.capture_file.clone().expect("capture file set"); + // An explicit runtime directory is used without a discriminator; + // capture files sit in this incarnation's `-` namespace + // directly under it. + let ns = cap.parent().expect("capture file must sit in a namespace"); + assert_eq!(ns.parent(), Some(runtime.as_path())); + assert!( + ns.file_name() + .unwrap() + .to_str() + .unwrap() + .starts_with(&format!("{}-", std::process::id())), + "the namespace must carry this process's pid prefix: {ns:?}" + ); + assert_eq!( + cap.file_name().unwrap().to_str().unwrap(), + format!("task-{}-0.json", t.id), + "the capture file must be keyed by task and run" + ); + assert_eq!( + settings.parent(), + Some(ns), + "assets and captures must share the namespace" + ); + assert_eq!( + std::fs::read_to_string(dir.join("capenv")).unwrap(), + cap.display().to_string(), + "the capture env must name task--.json under the incarnation namespace" + ); + assert_eq!( + t.command, "claude", + "instrumentation must never leak into the stored command" + ); + assert_eq!(t.resume_id.as_deref(), Some(id.as_str())); + assert!(t.harness.is_some()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// An unrecognized command spawns without capture state or assets. +#[test] +fn spawn_non_agent_command_is_not_instrumented() { + let dir = scratch("cap_plain"); + let runtime = dir.join("run"); + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(agent_ctx(&dir.join("bin"), &runtime, dir.clone())); + spawn(&mut s, "printf ok", dir.clone()); + let t = &s.tasks[0]; + assert!(t.harness.is_none()); + assert!(t.capture_file.is_none()); + assert!(t.resume_id.is_none()); + assert!( + s.capture.is_empty(), + "a non-agent spawn must not install capture assets" + ); + assert!(!runtime.exists()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A resuming `claude` launch retains its target ID and adds only the +/// capture overlay. +#[test] +fn spawn_resuming_claude_injects_only_the_capture_channel() { + let dir = scratch("cap_resume"); + let (bin, runtime) = (dir.join("bin"), dir.join("run")); + install_stub(&bin, "claude", &dir); + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); + spawn(&mut s, format!("claude --resume {CAP_ID}"), dir.clone()); + + let argv = wait_argv(&mut s, &dir.join("argv")); + assert!( + !argv.iter().any(|a| a == "--session-id"), + "a resuming launch must never pin a second id; argv: {argv:?}" + ); + assert!( + argv.iter().any(|a| a == "--settings"), + "the settings overlay must still ride along; argv: {argv:?}" + ); + let t = &s.tasks[0]; + assert_eq!(t.command, format!("claude --resume {CAP_ID}")); + assert_eq!(t.resume_id.as_deref(), Some(CAP_ID)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Rerun prefers the capture-file ID, stores the resulting resume command, +/// and deletes the displaced run's capture after deriving the resume command. +#[test] +fn rerun_resumes_the_captured_conversation() { + use crate::protocol::Lifecycle; + let dir = scratch("cap_rerun"); + let (bin, runtime) = (dir.join("bin"), dir.join("run")); + install_stub(&bin, "claude", &dir); + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); + spawn(&mut s, "claude", dir.clone()); + let _ = wait_argv(&mut s, &dir.join("argv")); + let id = s.tasks[0].id; + wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); + + // The capture payload reports a different ID from the pinned one. + let cap = s.tasks[0].capture_file.clone().expect("capture file set"); + std::fs::write( + &cap, + format!( + r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"clear"}}"# + ), + ) + .unwrap(); + std::fs::remove_file(dir.join("argv")).unwrap(); + + s.apply(Command::Restart { id }); + let argv = wait_argv(&mut s, &dir.join("argv")); + assert_eq!( + s.tasks[0].command, + format!("claude --resume '{CAP_OTHER}'"), + "the stored command must become the resuming one" + ); + let ri = argv + .iter() + .position(|a| a == "--resume") + .expect("the respawn must resume"); + assert_eq!(argv[ri + 1], CAP_OTHER); + assert!( + !argv.iter().any(|a| a == "--session-id"), + "re-detection classifies the respawn as resuming: no second id" + ); + assert!( + reap_until(&mut s, Duration::from_secs(5), |s| s.graveyard.is_empty()), + "the displaced run was never collected" + ); + // The resume command retains the ID after the source capture is deleted. + assert!( + !cap.exists(), + "rerun must delete the displaced run's capture file" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A rerun uses a new capture path, so the displaced run's payload and +/// later writes cannot affect the replacement. +#[test] +fn rerun_cannot_read_the_old_runs_stale_capture() { + let dir = scratch("cap_stale_run"); + let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); + // The session drifts to CAP_ID mid-run and the exit hint reports it. + install_script( + &bin, + "claude", + &format!("printf 'Resume this session with:\\nclaude --resume {CAP_ID}\\n'"), + ); + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(agent_ctx_plus( + &bin, + &runtime, + dir.clone(), + &[("FLEETCOM_CONFIG_DIR", &config)], + )); + spawn(&mut s, "claude", dir.clone()); + let id = s.tasks[0].id; + // The capture file still holds the pre-drift session. + let stale = format!( + r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"startup"}}"# + ); + let old_cap = s.tasks[0].capture_file.clone().expect("capture file set"); + std::fs::write(&old_cap, &stale).unwrap(); + assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] + .scraped_id + .is_some())); + assert_eq!(s.tasks[0].scraped_id.as_deref(), Some(CAP_ID)); + + s.apply(Command::Restart { id }); + let new_cap = s.tasks[0].capture_file.clone().expect("capture file set"); + assert_ne!(new_cap, old_cap, "the fresh run needs its own capture file"); + assert_eq!(s.tasks[0].command, format!("claude --resume '{CAP_ID}'")); + assert!( + !old_cap.exists(), + "rerun must delete the displaced run's capture file" + ); + + // A late hook write can recreate the old path, but the new run cannot read it. + std::fs::write(&old_cap, &stale).unwrap(); + let text = save_and_read(&mut s, &config, "stalecap"); + assert!( + text.contains(&format!("claude --resume '{CAP_ID}'")), + "the fresh run must save the drifted session; got {text}" + ); + assert!( + !text.contains(CAP_OTHER), + "the old run's stale capture must be unreachable; got {text}" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Removing a task also removes its capture file. +#[test] +fn remove_deletes_the_capture_file() { + use crate::protocol::Lifecycle; + let dir = scratch("cap_remove"); + let (bin, runtime) = (dir.join("bin"), dir.join("run")); + install_stub(&bin, "claude", &dir); + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); + spawn(&mut s, "claude", dir.clone()); + let _ = wait_argv(&mut s, &dir.join("argv")); + let id = s.tasks[0].id; + wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); + let cap = s.tasks[0].capture_file.clone().unwrap(); + std::fs::write(&cap, "{}").unwrap(); + + s.apply(Command::Remove { id }); + assert!(!cap.exists(), "Remove must delete the task's capture file"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Reconnecting with the active root reuses installed assets and preserves +/// live capture files. +#[test] +fn reconnect_with_unchanged_root_preserves_capture_files() { + let dir = scratch("cap_reconnect"); + let (bin, runtime) = (dir.join("bin"), dir.join("run")); + install_stub(&bin, "claude", &dir); + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); + spawn(&mut s, "claude", dir.clone()); + let cap = s.tasks[0].capture_file.clone().expect("capture file set"); + std::fs::write(&cap, "{}").unwrap(); + + // The client reconnects with an identical env and spawns again. + s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); + spawn(&mut s, "claude", dir.clone()); + assert_eq!(s.tasks.len(), 2); + assert!( + cap.exists(), + "an unchanged root must not disturb live capture files" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Returning to an installed root preserves its live capture files. +#[test] +fn returning_to_a_prior_root_preserves_its_live_captures() { + let dir = scratch("cap_aba"); + let (bin, root_a, root_b) = (dir.join("bin"), dir.join("run-a"), dir.join("run-b")); + install_stub(&bin, "claude", &dir); + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(agent_ctx(&bin, &root_a, dir.clone())); + spawn(&mut s, "claude", dir.clone()); + let cap_a = s.tasks[0].capture_file.clone().expect("capture file set"); + std::fs::write(&cap_a, "{}").unwrap(); + + // The client reconnects under root B, spawns, then returns to A and + // spawns again. + s.set_launch_context(agent_ctx(&bin, &root_b, dir.clone())); + spawn(&mut s, "claude", dir.clone()); + s.set_launch_context(agent_ctx(&bin, &root_a, dir.clone())); + spawn(&mut s, "claude", dir.clone()); + + assert_eq!(s.tasks.len(), 3); + assert!( + cap_a.exists(), + "returning to a known root must not disturb its live captures" + ); + let ns_b = s.tasks[1] + .capture_file + .as_deref() + .and_then(|c| c.parent()) + .expect("the root-B spawn must have a namespaced capture file"); + assert!(ns_b.starts_with(&root_b), "root B owns its namespace"); + assert!( + ns_b.join("claude-settings.json").is_file(), + "the interleaved root must keep its own namespaced assets" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Remove deletes the capture file under the root the task spawned in, +/// not under whichever root the current client presents. +#[test] +fn remove_deletes_the_capture_file_under_the_spawn_root() { + use crate::protocol::Lifecycle; + let dir = scratch("cap_remove_cross"); + let (bin, root_a, root_b) = (dir.join("bin"), dir.join("run-a"), dir.join("run-b")); + install_stub(&bin, "claude", &dir); + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(agent_ctx(&bin, &root_a, dir.clone())); + spawn(&mut s, "claude", dir.clone()); + let _ = wait_argv(&mut s, &dir.join("argv")); + let id = s.tasks[0].id; + wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); + let cap = s.tasks[0].capture_file.clone().unwrap(); + std::fs::write(&cap, "{}").unwrap(); + + // Root B is installed by a newer spawn; a same-id file under it must + // survive the A task's removal. + s.set_launch_context(agent_ctx(&bin, &root_b, dir.clone())); + spawn(&mut s, "claude", dir.clone()); + let decoy = s.tasks[1] + .capture_file + .as_deref() + .and_then(|c| c.parent()) + .expect("the root-B spawn must have a namespaced capture file") + .join(format!("task-{id}-0.json")); + std::fs::write(&decoy, "{}").unwrap(); + + s.apply(Command::Remove { id }); + assert!( + !cap.exists(), + "Remove must delete the task's own capture file" + ); + assert!( + decoy.exists(), + "Remove must not touch the same id under another root" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A `codex` spawn receives a `notify=[...]` override naming an executable +/// capture script. +#[test] +fn spawn_codex_installs_the_notify_override() { + use std::os::unix::fs::PermissionsExt; + let dir = scratch("cap_codex"); + let (bin, runtime) = (dir.join("bin"), dir.join("run")); + install_stub(&bin, "codex", &dir); + let mut s = Supervisor::new(24, 80, 2000); + // Keep config lookup within this test's scratch directory. + s.set_launch_context(agent_ctx_plus( + &bin, + &runtime, + dir.clone(), + &[("CODEX_HOME", &dir.join("codex_home"))], + )); + spawn(&mut s, "codex", dir.clone()); + + let argv = wait_argv(&mut s, &dir.join("argv")); + let ci = argv + .iter() + .position(|a| a == "-c") + .expect("the stub must receive -c"); + let script = argv[ci + 1] + .strip_prefix("notify=[\"") + .and_then(|t| t.strip_suffix("\"]")) + .unwrap_or_else(|| panic!("malformed notify override: {:?}", argv[ci + 1])); + let meta = std::fs::metadata(script).expect("the notify program must exist"); + assert!( + meta.permissions().mode() & 0o111 != 0, + "codex execs the notify program directly; it must be executable" + ); + let t = &s.tasks[0]; + assert_eq!(t.command, "codex"); + assert!(t.resume_id.is_none(), "codex cannot pin an id at launch"); + assert!(t.capture_file.is_some()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A `grok` spawn receives exactly the pinned ID: no settings overlay, +/// no config override, and no capture environment (grok has no +/// injectable live channel). The saved recipe resumes the pinned ID. +#[test] +fn spawn_grok_pins_an_id_and_injects_nothing_else() { + let dir = scratch("cap_grok"); + let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); + install_stub(&bin, "grok", &dir); + let mut s = Supervisor::new(24, 80, 2000); + // Keep save-time correlation inside the scratch tree. + s.set_launch_context(agent_ctx_plus( + &bin, + &runtime, + dir.clone(), + &[ + ("FLEETCOM_CONFIG_DIR", &config), + ("GROK_HOME", &dir.join("grok_home")), + ], + )); + spawn(&mut s, "grok", dir.clone()); + + let argv = wait_argv(&mut s, &dir.join("argv")); + assert_eq!( + argv.len(), + 2, + "only the pinned id may be injected: {argv:?}" + ); + assert_eq!(argv[0], "--session-id"); + let id = argv[1].clone(); + assert!( + crate::harness::is_uuid(&id), + "the pinned id must be a strict uuid: {id:?}" + ); + // The stub recorded an empty FLEETCOM_CAPTURE_FILE: no capture env. + assert_eq!( + std::fs::read_to_string(dir.join("capenv")).unwrap(), + "", + "grok has no capture channel, so the env must not name one" + ); + let t = &s.tasks[0]; + assert_eq!( + t.command, "grok", + "instrumentation must never leak into the stored command" + ); + assert_eq!(t.resume_id.as_deref(), Some(id.as_str())); + + let text = save_and_read(&mut s, &config, "grokpin"); + assert!( + text.contains(&format!("grok --resume '{id}'")), + "the recipe must resume the pinned session; got {text}" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A `claude` exit hint becomes the session ID used by the saved recipe. +#[test] +fn exit_hint_is_scraped_and_saved_as_a_resume() { + let dir = scratch("scrape_exit"); + let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); + install_script( + &bin, + "claude", + &format!("printf 'Resume this session with:\\nclaude --resume {CAP_ID}\\n'"), + ); + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(agent_ctx_plus( + &bin, + &runtime, + dir.clone(), + &[("FLEETCOM_CONFIG_DIR", &config)], + )); + spawn(&mut s, "claude", dir.clone()); + // No pre-exit synchronization: the scrape's reader-EOF gate means + // reap can run against the exiting stub at any point and the hint + // still lands. + assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] + .scraped_id + .is_some())); + assert_eq!(s.tasks[0].scraped_id.as_deref(), Some(CAP_ID)); + + let text = save_and_read(&mut s, &config, "hint"); + assert!( + text.contains(&format!("claude --resume '{CAP_ID}'")), + "the recipe must resume the scraped session; got {text}" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Saving between process exit and the next reap tick still captures the +/// exit hint because `save_session` performs its own ready scrape. +#[test] +fn save_scrapes_a_finished_task_without_reap() { + let dir = scratch("save_sync_scrape"); + let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); + install_script( + &bin, + "claude", + &format!("printf 'Resume this session with:\\nclaude --resume {CAP_ID}\\n'"), + ); + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(agent_ctx_plus( + &bin, + &runtime, + dir.clone(), + &[("FLEETCOM_CONFIG_DIR", &config)], + )); + spawn(&mut s, "claude", dir.clone()); + + // Wait out only the residual reader-drain race: after EOF the sole + // remaining gate is the exit latch, which save's own pass must flip. + assert!( + wait_until(Duration::from_secs(5), || s.tasks[0].reader_done()), + "the stub never reached EOF" + ); + assert!(s.tasks[0].finished.is_none(), "no reap may have run yet"); + + let text = save_and_read(&mut s, &config, "syncsave"); + assert!( + text.contains(&format!("claude --resume '{CAP_ID}'")), + "save must scrape the finished task itself; got {text}" + ); + assert_eq!(s.tasks[0].scraped_id.as_deref(), Some(CAP_ID)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Rerunning between process exit and the next reap tick latches the exit, +/// scrapes the hint, and resumes that session. +#[test] +fn rerun_scrapes_a_finished_task_without_reap() { + let dir = scratch("rerun_sync_scrape"); + let (bin, runtime) = (dir.join("bin"), dir.join("run")); + install_script( + &bin, + "claude", + &format!("printf 'Resume this session with:\\nclaude --resume {CAP_ID}\\n'"), + ); + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); + spawn(&mut s, "claude", dir.clone()); + let id = s.tasks[0].id; + + assert!( + wait_until(Duration::from_secs(5), || s.tasks[0].reader_done()), + "the stub never reached EOF" + ); + assert!(s.tasks[0].finished.is_none(), "no reap may have run yet"); + + s.apply(Command::Restart { id }); + assert_eq!( + s.tasks[0].command, + format!("claude --resume '{CAP_ID}'"), + "rerun must compute its resume command from the exit scrape" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Session-ID precedence is exit scrape, capture file, then spawn-time ID. +#[test] +fn resume_id_precedence_scrape_over_capture_over_spawn() { + let dir = scratch("precedence"); + let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); + let (hinted, done) = (dir.join("hinted"), dir.join("done")); + install_script( + &bin, + "claude", + &format!( + "until [ -e '{h}' ]; do sleep 0.05; done\n\ + printf 'Resume this session with:\\nclaude --resume {CAP_ID}\\n'\n\ + until [ -e '{d}' ]; do sleep 0.05; done", + h = hinted.display(), + d = done.display() + ), + ); + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(agent_ctx_plus( + &bin, + &runtime, + dir.clone(), + &[("FLEETCOM_CONFIG_DIR", &config)], + )); + spawn(&mut s, "claude", dir.clone()); + let injected = s.tasks[0] + .resume_id + .clone() + .expect("a fresh claude launch pins an id"); + assert_ne!(injected.as_str(), CAP_OTHER); + + // The hook moved the session mid-run: pre-exit, the capture file + // must beat the injected id. + let cap = s.tasks[0].capture_file.clone().expect("capture file set"); + std::fs::write( + &cap, + format!( + r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"clear"}}"# + ), + ) + .unwrap(); + let text = save_and_read(&mut s, &config, "mid"); + assert!( + text.contains(&format!("claude --resume '{CAP_OTHER}'")), + "pre-exit the capture file must beat the injected id; got {text}" + ); + + // Print the hint and let the task exit: post-exit, the scrape must + // beat the capture file. + std::fs::write(&hinted, b"").unwrap(); + std::fs::write(&done, b"").unwrap(); + assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] + .scraped_id + .is_some())); + assert_eq!(s.tasks[0].scraped_id.as_deref(), Some(CAP_ID)); + let text = save_and_read(&mut s, &config, "post"); + assert!( + text.contains(&format!("claude --resume '{CAP_ID}'")), + "post-exit the scraped hint must beat the capture file; got {text}" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A silent Codex task falls back to one matching rollout under +/// `CODEX_HOME` when live channels produce no ID. +#[test] +fn save_falls_back_to_fs_correlation_for_a_silent_codex() { + let dir = scratch("correlate_save"); + let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); + let codex_home = dir.join("codex_home"); + install_stub(&bin, "codex", &dir); + // Create a rollout with a current v7 instant and the task's cwd. + let now_ms = now_ms(); + let id = write_rollout(&codex_home, now_ms, 1, &dir); + + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(agent_ctx_plus( + &bin, + &runtime, + dir.clone(), + &[ + ("FLEETCOM_CONFIG_DIR", &config), + ("CODEX_HOME", &codex_home), + ], + )); + spawn(&mut s, "codex", dir.clone()); + assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] + .finished + .is_some())); + assert!(s.tasks[0].scraped_id.is_none(), "a silent exit has no hint"); + assert!( + current_resume_id(&s.tasks[0]).is_none(), + "no capture channel fired" + ); + + let text = save_and_read(&mut s, &config, "corr"); + assert!( + text.contains(&format!("codex resume '{id}'")), + "save must fall back to filesystem correlation; got {text}" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Save-time correlation uses the task's spawn-time `CODEX_HOME`, even +/// after a reconnect supplies another home containing a matching rollout. +#[test] +fn save_correlates_against_the_spawn_time_home() { + let dir = scratch("correlate_home"); + let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); + let (home_a, home_b) = (dir.join("codex_a"), dir.join("codex_b")); + install_stub(&bin, "codex", &dir); + let now_ms = now_ms(); + // One unique in-window rollout per store, both naming the task cwd. + let id_a = write_rollout(&home_a, now_ms, 1, &dir); + let id_b = write_rollout(&home_b, now_ms, 2, &dir); + + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(agent_ctx_plus( + &bin, + &runtime, + dir.clone(), + &[("FLEETCOM_CONFIG_DIR", &config), ("CODEX_HOME", &home_a)], + )); + spawn(&mut s, "codex", dir.clone()); + assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] + .finished + .is_some())); + + // Reconnect under home B, then save. + s.set_launch_context(agent_ctx_plus( + &bin, + &runtime, + dir.clone(), + &[("FLEETCOM_CONFIG_DIR", &config), ("CODEX_HOME", &home_b)], + )); + let text = save_and_read(&mut s, &config, "homepin"); + assert!( + text.contains(&format!("codex resume '{id_a}'")), + "the recipe must resolve from the launch-time store; got {text}" + ); + assert!( + !text.contains(&id_b), + "the reconnect store's decoy must not correlate; got {text}" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Home resolution order: the tool's own var, then the launch env's HOME +/// joined with the tool's dot directory, then nothing. +#[test] +fn harness_home_prefers_the_tool_var_then_home() { + use crate::harness::{Claude, Codex, Grok}; + let env: Vec<(OsString, OsString)> = vec![ + ("HOME".into(), "/h".into()), + ("CODEX_HOME".into(), "/x".into()), + ]; + assert_eq!(harness_home(&env, &Codex).as_deref(), Some(Path::new("/x"))); + let env: Vec<(OsString, OsString)> = vec![("HOME".into(), "/h".into())]; + assert_eq!( + harness_home(&env, &Codex).as_deref(), + Some(Path::new("/h/.codex")) + ); + assert_eq!( + harness_home(&env, &Claude).as_deref(), + Some(Path::new("/h/.claude")) + ); + assert_eq!( + harness_home(&env, &Grok).as_deref(), + Some(Path::new("/h/.grok")) + ); + assert_eq!(harness_home(&[], &Codex), None); +} + +/// With only `HOME` in the launch environment, both notify routing and +/// save-time correlation resolve through `/.codex`. +#[test] +fn home_only_launch_env_targets_the_clients_dot_codex() { + let dir = scratch("home_resolve"); + let (bin, runtime, config, home) = ( + dir.join("bin"), + dir.join("run"), + dir.join("config"), + dir.join("home"), + ); + let codex_home = home.join(".codex"); + std::fs::create_dir_all(&codex_home).unwrap(); + // A multi-line notify is Opaque: injection is suppressed only when + // the guard reads the client's config.toml through HOME. + std::fs::write( + codex_home.join("config.toml"), + "notify = [\n \"/my/thing\",\n]\n", + ) + .unwrap(); + install_stub(&bin, "codex", &dir); + // A unique in-window rollout in the same tree for save-time + // correlation: with injection suppressed, no capture channel fires. + let now_ms = now_ms(); + let id = write_rollout(&codex_home, now_ms, 1, &dir); + + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(agent_ctx_plus( + &bin, + &runtime, + dir.clone(), + &[("FLEETCOM_CONFIG_DIR", &config), ("HOME", &home)], + )); + spawn(&mut s, "codex", dir.clone()); + assert_eq!( + s.tasks[0].harness_home.as_deref(), + Some(codex_home.as_path()), + "HOME alone must resolve the harness home" + ); + let argv = wait_argv(&mut s, &dir.join("argv")); + assert!( + !argv.iter().any(|a| a.contains("notify=")), + "the guard must read /.codex/config.toml; argv: {argv:?}" + ); + assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] + .finished + .is_some())); + + let text = save_and_read(&mut s, &config, "homeonly"); + assert!( + text.contains(&format!("codex resume '{id}'")), + "correlation must read /.codex; got {text}" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// With no configured notifier, instrumentation clears an inherited +/// `FLEETCOM_NOTIFY_CHAIN` so the capture script cannot execute it. +#[test] +fn stale_inherited_notify_chain_is_never_executed() { + let dir = scratch("stale_chain"); + let (bin, runtime) = (dir.join("bin"), dir.join("run")); + // No config.toml exists: nothing routed, so nothing may be chained. + let codex_home = dir.join("codex_home"); + let stale = dir.join("stale"); + let record = dir.join("stale-record"); + write_executable(&stale, &format!("touch '{}'", record.display())); + + // The stub invokes the injected notify script the way codex would. + let payload = format!(r#"{{"type":"agent-turn-complete","thread-id":"{CAP_ID}"}}"#); + // The notify script sits beside the capture file, in a namespace + // whose nonce is unknowable before spawn: derive it from the env. + install_script( + &bin, + "codex", + &format!("\"${{FLEETCOM_CAPTURE_FILE%/*}}/codex-notify.sh\" '{payload}'"), + ); + let mut s = Supervisor::new(24, 80, 2000); + let mut ctx = agent_ctx_plus(&bin, &runtime, dir.clone(), &[("CODEX_HOME", &codex_home)]); + ctx.env.push(( + crate::harness::NOTIFY_CHAIN_ENV.into(), + stale.as_os_str().to_os_string(), + )); + s.set_launch_context(ctx); + spawn(&mut s, "codex", dir.clone()); + assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] + .finished + .is_some())); + let cap = s.tasks[0].capture_file.clone().expect("capture file set"); + assert_eq!( + std::fs::read_to_string(&cap).unwrap(), + payload, + "the capture write must land before the script exits" + ); + assert!( + !record.exists(), + "the stale inherited chain must not execute" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Without a live or filesystem ID, an agent recipe retains the original +/// command. +#[test] +fn agent_save_without_any_id_keeps_the_plain_command() { + let dir = scratch("no_id"); + let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); + // CODEX_HOME names a store that never exists: correlation has + // nothing to find, and the notify routing nothing to read. + let codex_home = dir.join("codex_home"); + install_stub(&bin, "codex", &dir); + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(agent_ctx_plus( + &bin, + &runtime, + dir.clone(), + &[ + ("FLEETCOM_CONFIG_DIR", &config), + ("CODEX_HOME", &codex_home), + ], + )); + spawn(&mut s, "codex", dir.clone()); + assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] + .finished + .is_some())); + + let text = save_and_read(&mut s, &config, "plainagent"); + assert!( + text.contains("\"codex\""), + "the plain command must survive; got {text}" + ); + assert!( + !text.contains("resume"), + "no id exists, so nothing may be rewritten; got {text}" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A representable `notify` assignment runs through the injected notifier +/// after the capture write. +#[test] +fn config_toml_notify_chains_through_the_injected_script() { + use crate::harness::NOTIFY_CHAIN_ENV; + let dir = scratch("cfg_chain"); + let (bin, runtime) = (dir.join("bin"), dir.join("run")); + let codex_home = dir.join("codex_home"); + std::fs::create_dir_all(&codex_home).unwrap(); + // The notifier path contains spaces and carries a fixed argument. + let notifier = dir.join("Fake App.app").join("Sky Client"); + let record = dir.join("notifier-record"); + install_fake_notifier(¬ifier, &record); + std::fs::write( + codex_home.join("config.toml"), + format!("notify = [\"{}\", \"turn-ended\"]\n", notifier.display()), + ) + .unwrap(); + + // The stub records argv and the chain env, then invokes the notify + // script with notification JSON as the final argument. + let payload = format!(r#"{{"type":"agent-turn-complete","thread-id":"{CAP_ID}"}}"#); + install_script( + &bin, + "codex", + &format!( + "printf '%s\\n' \"$@\" > '{out}/argv'\n\ + printf '%s' \"${chain}\" > '{out}/chainenv'\n\ + \"${{FLEETCOM_CAPTURE_FILE%/*}}/codex-notify.sh\" '{payload}'", + out = dir.display(), + chain = NOTIFY_CHAIN_ENV, + ), + ); + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(agent_ctx_plus( + &bin, + &runtime, + dir.clone(), + &[("CODEX_HOME", &codex_home)], + )); + spawn(&mut s, "codex", dir.clone()); + let argv = wait_argv(&mut s, &dir.join("argv")); + assert!( + argv.iter().any(|a| a.starts_with("notify=[")), + "a chained spawn must still inject the override; argv: {argv:?}" + ); + // Redirection creates the record before `printf` writes, so wait for the + // complete expected contents. + let expected = format!("turn-ended\n{payload}\n"); + assert!( + reap_until(&mut s, Duration::from_secs(5), |_| { + std::fs::read_to_string(&record).is_ok_and(|r| r == expected) + }), + "the chained notifier never wrote its complete record" + ); + assert_eq!( + std::fs::read_to_string(dir.join("chainenv")).unwrap(), + format!("{}\nturn-ended", notifier.display()), + "the child env must carry the displaced argv, newline-joined" + ); + let cap = s.tasks[0].capture_file.clone().unwrap(); + assert_eq!( + std::fs::read_to_string(&cap).unwrap(), + payload, + "the capture write must precede the chain handoff" + ); + assert_eq!( + std::fs::read_to_string(&record).unwrap(), + format!("turn-ended\n{payload}\n"), + "the notifier must receive its original args plus the payload" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// An unrepresentable `notify` value disables injection, while a commented +/// assignment defines no route and leaves injection enabled. +#[test] +fn unrepresentable_config_notify_suppresses_injection() { + let dir = scratch("cfg_guard"); + let (bin, runtime) = (dir.join("bin"), dir.join("run")); + let codex_home = dir.join("codex_home"); + std::fs::create_dir_all(&codex_home).unwrap(); + // A multi-line array is out of the line-based parser's reach. + std::fs::write( + codex_home.join("config.toml"), + "notify = [\n \"/my/thing\",\n]\n", + ) + .unwrap(); + install_stub(&bin, "codex", &dir); + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(agent_ctx_plus( + &bin, + &runtime, + dir.clone(), + &[("CODEX_HOME", &codex_home)], + )); + spawn(&mut s, "codex", dir.clone()); + let argv = wait_argv(&mut s, &dir.join("argv")); + assert!( + !argv.iter().any(|a| a.contains("notify=")), + "fleetcom must not guess at an unparseable notify; argv: {argv:?}" + ); + + // The same route commented out is inert: the injection returns. + std::fs::write( + codex_home.join("config.toml"), + "# notify = [\"/my/thing\"]\n", + ) + .unwrap(); + std::fs::remove_file(dir.join("argv")).unwrap(); + spawn(&mut s, "codex", dir.clone()); + let argv = wait_argv(&mut s, &dir.join("argv")); + assert!( + argv.iter().any(|a| a.starts_with("notify=[")), + "a commented notify must not suppress the injection; argv: {argv:?}" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Non-agent commands remain plain string entries in persisted JSON. +#[test] +fn non_agent_entries_survive_save_as_plain_strings() { + let dir = scratch("plain_save"); + let config = dir.join("config"); + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(LaunchContext { + env: vec![ + ("SHELL".into(), "/bin/sh".into()), + ( + "FLEETCOM_CONFIG_DIR".into(), + config.clone().into_os_string(), + ), + ], + cwd: dir.clone(), + }); + spawn(&mut s, "sleep 30", dir.clone()); + let text = save_and_read(&mut s, &config, "plain"); + assert!( + text.contains("\"sleep 30\""), + "string-form member expected; got {text}" + ); + assert!( + !text.contains("\"cmd\""), + "no object form for an unadorned entry; got {text}" + ); + let cfg = session::load_in(&config.join("sessions"), "plain").unwrap(); + assert_eq!( + cfg[&path::abbreviate(&dir)], + vec![SessionEntry { + cmd: "sleep 30".into(), + group: None, + name: None, + }] + ); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/src/supervisor_tests.rs b/src/supervisor_tests.rs index 5b911e1..0326d1f 100644 --- a/src/supervisor_tests.rs +++ b/src/supervisor_tests.rs @@ -1,13 +1,11 @@ use std::path::Path; use super::*; -use crate::harness::testutil::{ID as CAP_ID, OTHER as CAP_OTHER}; use crate::protocol::{Key, Mods}; -use crate::testutil::{now_ms, read_pid, wait_until, write_rollout}; - -fn here() -> PathBuf { - std::env::current_dir().unwrap() -} +use crate::testutil::{ + here, install_fake_notifier, now_ms, read_pid, sh_env, wait_until, write_executable, + write_rollout, +}; /// Build a supervisor with this process's launch context. fn sup(rows: u16, cols: u16) -> Supervisor { @@ -16,6 +14,24 @@ fn sup(rows: u16, cols: u16) -> Supervisor { s } +/// Apply an ungrouped `Command::Spawn` of `cmd` in `cwd`. +fn spawn(s: &mut Supervisor, cmd: impl Into, cwd: PathBuf) { + s.apply(Command::Spawn { + command: cmd.into(), + cwd, + group: None, + }); +} + +/// Apply a `Command::Spawn` of `cmd` in `cwd` under `group`. +fn spawn_grouped(s: &mut Supervisor, cmd: impl Into, cwd: PathBuf, group: &str) { + s.apply(Command::Spawn { + command: cmd.into(), + cwd, + group: Some(group.into()), + }); +} + /// Scrollback resolution applies precedence, clamping, and environment fallback. #[test] fn scrollback_resolution_precedence_clamp_and_fallback() { @@ -35,21 +51,9 @@ fn scrollback_resolution_precedence_clamp_and_fallback() { #[test] fn session_config_groups_by_dir_in_spawn_order() { let mut s = sup(24, 80); - s.apply(Command::Spawn { - command: "a".into(), - cwd: here(), - group: None, - }); - s.apply(Command::Spawn { - command: "b".into(), - cwd: PathBuf::from("/tmp"), - group: None, - }); - s.apply(Command::Spawn { - command: "c".into(), - cwd: here(), - group: None, - }); + spawn(&mut s, "a", here()); + spawn(&mut s, "b", PathBuf::from("/tmp")); + spawn(&mut s, "c", here()); let cfg = s.session_config(); assert_eq!( @@ -83,11 +87,7 @@ fn session_config_groups_by_dir_in_spawn_order() { #[test] fn tick_emits_snapshot_and_watched_screen() { let mut s = sup(24, 80); - s.apply(Command::Spawn { - command: "sleep 30".into(), - cwd: here(), - group: None, - }); + spawn(&mut s, "sleep 30", here()); s.tick(); let evs = s.drain(); @@ -116,11 +116,7 @@ fn tick_emits_snapshot_and_watched_screen() { #[test] fn watched_screen_not_resent_when_unchanged() { let mut s = sup(24, 80); - s.apply(Command::Spawn { - command: "sleep 30".into(), - cwd: here(), - group: None, - }); + spawn(&mut s, "sleep 30", here()); // Settle: let the silent shell finish any startup writes so the screen // stabilizes before we assert nothing changes. let mut id = 0; @@ -194,11 +190,11 @@ fn decset_1007_flip_resends_watched_screen() { #[test] fn tick_flushes_a_stalled_sync_update() { let mut s = sup(24, 80); - s.apply(Command::Spawn { - command: "printf 'begin\\033[?2026hstalled'; sleep 30".into(), - cwd: here(), - group: None, - }); + spawn( + &mut s, + "printf 'begin\\033[?2026hstalled'; sleep 30", + here(), + ); let mut preview = String::new(); wait_until(Duration::from_secs(5), || { s.tick(); @@ -206,7 +202,7 @@ fn tick_flushes_a_stalled_sync_update() { if let Event::Tasks(v) = e && let Some(t) = v.first() { - preview = t.preview.clone(); + preview = t.preview.text.clone(); } } preview.contains("stalled") @@ -226,11 +222,7 @@ fn scratch(tag: &str) -> PathBuf { /// keeps kill-path tests deterministic (no signalling a shell that hasn't /// installed its trap yet). fn spawn_ready(s: &mut Supervisor, command: String, cwd: PathBuf, ready: &Path) -> u64 { - s.apply(Command::Spawn { - command, - cwd, - group: None, - }); + spawn(s, command, cwd); assert!( wait_until(Duration::from_secs(5), || ready.exists()), "task never signalled ready" @@ -288,7 +280,7 @@ fn kill_delivers_term_before_kill() { let _ = std::fs::remove_dir_all(&dir); } -/// A job that ignores SIGTERM is SIGKILLed once the grace elapses, via the +/// A task that ignores SIGTERM is SIGKILLed once the grace elapses, via the /// reap-driven escalation. `Kill` must never leave an immortal task. #[test] fn term_ignoring_task_escalates_to_kill() { @@ -311,21 +303,17 @@ fn term_ignoring_task_escalates_to_kill() { let _ = std::fs::remove_dir_all(&dir); } -/// `Shutdown` exits as soon as TERM-respecting jobs die: well inside the +/// `Shutdown` exits as soon as TERM-respecting tasks die: well inside the /// grace, not after it. #[test] -fn shutdown_returns_early_when_jobs_respect_term() { +fn shutdown_returns_early_when_tasks_respect_term() { let mut s = sup(24, 80); - s.apply(Command::Spawn { - command: "sleep 300".into(), - cwd: here(), - group: None, - }); + spawn(&mut s, "sleep 300", here()); let t0 = Instant::now(); s.apply(Command::Shutdown); assert!( t0.elapsed() < Duration::from_secs(1), - "shutdown waited the full grace for a TERM-respecting job" + "shutdown waited the full grace for a TERM-respecting task" ); s.tick(); assert!( @@ -341,11 +329,7 @@ fn shutdown_returns_early_when_jobs_respect_term() { fn shutdown_survives_a_child_that_never_reads_stdin() { let mut s = sup(24, 80); s.set_kill_grace(Duration::from_millis(200)); - s.apply(Command::Spawn { - command: "sleep 300".into(), - cwd: here(), - group: None, - }); + spawn(&mut s, "sleep 300", here()); let id = first_id(&mut s); // Newline-terminated input fills the canonical-mode PTY queue and // blocks the writer worker while the child is not reading. @@ -373,11 +357,7 @@ fn shutdown_survives_a_child_that_never_reads_stdin() { fn overfull_writer_queue_refuses_message_with_notice() { let mut s = sup(24, 80); s.set_kill_grace(Duration::from_millis(200)); - s.apply(Command::Spawn { - command: "sleep 300".into(), - cwd: here(), - group: None, - }); + spawn(&mut s, "sleep 300", here()); let id = first_id(&mut s); // Newline-terminated input keeps the worker blocked and its admitted // byte count pending while the child does not read. @@ -406,7 +386,7 @@ fn overfull_writer_queue_refuses_message_with_notice() { ); } -/// `Shutdown` with a TERM-ignoring job is bounded by the grace, then +/// `Shutdown` with a TERM-ignoring task is bounded by the grace, then /// SIGKILLs it: quit can be slowed, never wedged. #[test] fn shutdown_is_bounded_by_grace() { @@ -445,11 +425,7 @@ fn shutdown_is_bounded_by_grace() { #[test] fn clear_watch_stops_screen_stream_and_resets_dedup() { let mut s = sup(24, 80); - s.apply(Command::Spawn { - command: "sleep 30".into(), - cwd: here(), - group: None, - }); + spawn(&mut s, "sleep 30", here()); let id = first_id(&mut s); s.apply(Command::Watch { id: Some(id) }); @@ -482,11 +458,7 @@ fn clear_watch_stops_screen_stream_and_resets_dedup() { fn clear_watch_snaps_the_watched_task_live() { // Use a short grid to build scrollback quickly. let mut s = sup(6, 80); - s.apply(Command::Spawn { - command: "seq 1 200; sleep 30".into(), - cwd: here(), - group: None, - }); + spawn(&mut s, "seq 1 200; sleep 30", here()); let id = first_id(&mut s); s.apply(Command::Watch { id: Some(id) }); @@ -519,11 +491,11 @@ fn rerun_replaces_finished_task_in_place() { let dir = scratch("rerun"); let marker = dir.join("marker"); let mut s = sup(24, 80); - s.apply(Command::Spawn { - command: format!("echo run >> {}", marker.display()), - cwd: dir.clone(), - group: None, - }); + spawn( + &mut s, + format!("echo run >> {}", marker.display()), + dir.clone(), + ); let id = first_id(&mut s); s.apply(Command::Tag { id, on: true }); wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); @@ -592,11 +564,7 @@ fn display_names_normalize_at_the_boundary() { #[test] fn set_group_round_trips_and_clears() { let mut s = sup(24, 80); - s.apply(Command::Spawn { - command: "sleep 30".into(), - cwd: here(), - group: None, - }); + spawn(&mut s, "sleep 30", here()); let id = first_id(&mut s); let group_of = |s: &mut Supervisor| -> Option { s.tick(); @@ -633,11 +601,7 @@ fn set_group_round_trips_and_clears() { #[test] fn set_name_round_trips_and_clears() { let mut s = sup(24, 80); - s.apply(Command::Spawn { - command: "sleep 30".into(), - cwd: here(), - group: None, - }); + spawn(&mut s, "sleep 30", here()); let id = first_id(&mut s); let name_of = |s: &mut Supervisor| -> Option { s.tick(); @@ -680,11 +644,7 @@ fn set_name_round_trips_and_clears() { #[test] fn spawn_carries_a_normalized_group_from_birth() { let mut s = sup(24, 80); - s.apply(Command::Spawn { - command: "sleep 30".into(), - cwd: here(), - group: Some(" ui\x1b[2J ".into()), - }); + spawn_grouped(&mut s, "sleep 30", here(), " ui\x1b[2J "); s.tick(); match s.drain().first() { Some(Event::Tasks(v)) => assert_eq!(v[0].group.as_deref(), Some("ui[2J")), @@ -697,11 +657,7 @@ fn spawn_carries_a_normalized_group_from_birth() { fn rerun_carries_the_group_over() { use crate::protocol::Lifecycle; let mut s = sup(24, 80); - s.apply(Command::Spawn { - command: "true".into(), - cwd: here(), - group: Some("infra".into()), - }); + spawn_grouped(&mut s, "true", here(), "infra"); let id = first_id(&mut s); wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); @@ -720,11 +676,7 @@ fn rerun_carries_the_group_over() { fn rerun_carries_the_name_over() { use crate::protocol::Lifecycle; let mut s = sup(24, 80); - s.apply(Command::Spawn { - command: "true".into(), - cwd: here(), - group: None, - }); + spawn(&mut s, "true", here()); let id = first_id(&mut s); s.apply(Command::SetName { id, @@ -748,11 +700,7 @@ fn rerun_carries_the_name_over() { fn rerun_refuses_running_task_and_unknown_id() { use crate::protocol::Lifecycle; let mut s = sup(24, 80); - s.apply(Command::Spawn { - command: "sleep 30".into(), - cwd: here(), - group: None, - }); + spawn(&mut s, "sleep 30", here()); let id = first_id(&mut s); s.apply(Command::Restart { id }); @@ -786,11 +734,7 @@ fn rerun_refuses_running_task_and_unknown_id() { fn rerun_watched_task_resends_screen() { use crate::protocol::Lifecycle; let mut s = sup(24, 80); - s.apply(Command::Spawn { - command: "true".into(), - cwd: here(), - group: None, - }); + spawn(&mut s, "true", here()); let id = first_id(&mut s); wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); @@ -813,11 +757,7 @@ fn rerun_watched_task_resends_screen() { #[test] fn resize_clamps_hostile_dimensions() { let mut s = sup(24, 80); - s.apply(Command::Spawn { - command: "sleep 30".into(), - cwd: here(), - group: None, - }); + spawn(&mut s, "sleep 30", here()); s.apply(Command::Resize { rows: 0, cols: 0 }); s.tick(); // exercises the resized grid (snapshot + screen): no panic let _ = s.drain(); @@ -962,10 +902,7 @@ fn reap_until( /// Use `/bin/sh` so background-process tests have consistent semantics. fn hello_with_sh(s: &mut Supervisor, cwd: PathBuf) { - let mut env: Vec<(std::ffi::OsString, std::ffi::OsString)> = std::env::vars_os().collect(); - env.retain(|(k, _)| k != "SHELL"); - env.push(("SHELL".into(), "/bin/sh".into())); - s.set_launch_context(LaunchContext { env, cwd }); + s.set_launch_context(LaunchContext { env: sh_env(), cwd }); } /// `Remove` must sweep group members the exited leader left behind (a @@ -1009,7 +946,7 @@ fn remove_sweeps_stragglers_of_an_exited_leader() { let _ = std::fs::remove_dir_all(&dir); } -/// Rerun must give the displaced job the same graceful exit as Remove: +/// Rerun must give the displaced task the same graceful exit as Remove: /// TERM through the graveyard, not the straight SIGKILL a `Drop` delivers. /// The old run's HUP-immune straggler dies of the TERM while the fresh run /// (same id) is already up. @@ -1191,11 +1128,7 @@ fn shutdown_holds_the_grace_for_members_of_an_exited_leader() { fn shutdown_is_prompt_when_every_group_is_already_empty() { let mut s = sup(24, 80); for _ in 0..2 { - s.apply(Command::Spawn { - command: "true".into(), - cwd: here(), - group: None, - }); + spawn(&mut s, "true", here()); } assert!(reap_until(&mut s, Duration::from_secs(5), |s| { s.tasks.len() == 2 && s.tasks.iter().all(|t| t.finished.is_some()) @@ -1269,16 +1202,8 @@ fn load_session_restores_saved_groups() { }; let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(ctx.clone()); - s.apply(Command::Spawn { - command: "sleep 30".into(), - cwd: dir.clone(), - group: Some("api".into()), - }); - s.apply(Command::Spawn { - command: "sleep 31".into(), - cwd: dir.clone(), - group: None, - }); + spawn_grouped(&mut s, "sleep 30", dir.clone(), "api"); + spawn(&mut s, "sleep 31", dir.clone()); s.apply(Command::SaveSession { name: "fleet".into(), }); @@ -1330,16 +1255,8 @@ fn load_session_restores_saved_names() { }; let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(ctx.clone()); - s.apply(Command::Spawn { - command: "sleep 30".into(), - cwd: dir.clone(), - group: None, - }); - s.apply(Command::Spawn { - command: "sleep 31".into(), - cwd: dir.clone(), - group: None, - }); + spawn(&mut s, "sleep 30", dir.clone()); + spawn(&mut s, "sleep 31", dir.clone()); s.tick(); let id = match s.drain().first() { Some(Event::Tasks(v)) => v.iter().find(|t| t.command == "sleep 30").unwrap().id, @@ -1576,18 +1493,18 @@ fn spawn_uses_the_launch_context_env_not_the_process_env() { env: vec![("FLEETCOM_MARKER".into(), "xyzzy".into())], cwd: dir.clone(), }); - s.apply(Command::Spawn { - command: format!( + spawn( + &mut s, + format!( "printf '%s:%s' \"$FLEETCOM_MARKER\" \"${{USER:-unset}}\" > {}", out.display() ), - cwd: dir.clone(), - group: None, - }); + dir.clone(), + ); let ok = reap_until(&mut s, Duration::from_secs(5), |_| { std::fs::read_to_string(&out).is_ok_and(|c| !c.is_empty()) }); - assert!(ok, "the marker job never wrote its output"); + assert!(ok, "the marker task never wrote its output"); assert_eq!(std::fs::read_to_string(&out).unwrap(), "xyzzy:unset"); let _ = std::fs::remove_dir_all(&dir); } @@ -1597,11 +1514,7 @@ fn spawn_uses_the_launch_context_env_not_the_process_env() { #[test] fn launch_without_context_is_refused() { let mut s = Supervisor::new(24, 80, 2000); - s.apply(Command::Spawn { - command: "true".into(), - cwd: here(), - group: None, - }); + spawn(&mut s, "true", here()); assert!( s.drain() .iter() @@ -1625,1302 +1538,75 @@ fn launch_without_context_is_refused() { ); } -// --- session-capture wiring ------------------------------------------- - -/// Install an executable stub that records `FLEETCOM_CAPTURE_FILE` and -/// its argv, one token per line, then exits. -fn install_stub(bin: &Path, name: &str, out: &Path) { - install_script( - bin, - name, - &format!( - "printf '%s' \"$FLEETCOM_CAPTURE_FILE\" > '{out}/capenv'\n\ - printf '%s\\n' \"$@\" > '{out}/argv'", - out = out.display() - ), - ); -} - -/// Launch context containing only the stub path, shell, and capture root. -fn agent_ctx(bin: &Path, runtime: &Path, cwd: PathBuf) -> LaunchContext { - LaunchContext { - env: vec![ - ( - "PATH".into(), - format!("{}:/usr/bin:/bin", bin.display()).into(), - ), - ("SHELL".into(), "/bin/sh".into()), - ( - "FLEETCOM_RUNTIME_DIR".into(), - runtime.as_os_str().to_os_string(), - ), - ], - cwd, - } -} - -/// Poll until the stub's argv record contains data, then return its lines. -fn wait_argv(s: &mut Supervisor, path: &Path) -> Vec { - assert!( - reap_until(s, Duration::from_secs(5), |_| std::fs::read_to_string(path) - .is_ok_and(|c| !c.is_empty())), - "the stub never recorded its argv" - ); - std::fs::read_to_string(path) - .unwrap() - .lines() - .map(str::to_string) - .collect() -} - -/// `agent_ctx` plus explicit environment pairs for recipe and harness -/// storage tests. -fn agent_ctx_plus( - bin: &Path, - runtime: &Path, - cwd: PathBuf, - extra: &[(&str, &Path)], -) -> LaunchContext { - let mut ctx = agent_ctx(bin, runtime, cwd); - for (k, v) in extra { - ctx.env.push(((*k).into(), v.as_os_str().to_os_string())); - } - ctx -} - -/// Install an executable stub with caller-supplied shell behavior. -fn install_script(bin: &Path, name: &str, body: &str) { - use std::os::unix::fs::PermissionsExt; - std::fs::create_dir_all(bin).unwrap(); - let path = bin.join(name); - std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); -} - -/// Save a recipe and return its persisted JSON. -fn save_and_read(s: &mut Supervisor, config: &Path, name: &str) -> String { - s.apply(Command::SaveSession { name: name.into() }); - let _ = s.drain(); - std::fs::read_to_string(config.join("sessions").join(format!("{name}.json"))).unwrap() -} - -/// The FNV-1a discriminator is stable and separates distinct config roots. -#[test] -fn fnv_discriminator_is_stable_and_distinguishes_roots() { - // FNV-1a 64-bit vectors for the empty input and "a". - assert_eq!(fnv1a_hex(b""), "cbf29ce484222325"); - assert_eq!(fnv1a_hex(b"a"), "af63dc4c8601ec8c"); - assert_eq!(fnv1a_hex(b"/cfg/one"), fnv1a_hex(b"/cfg/one")); - assert_ne!(fnv1a_hex(b"/cfg/one"), fnv1a_hex(b"/cfg/two")); -} - -/// A `claude` spawn receives a pinned ID, settings overlay, and capture -/// environment without changing the stored command. +/// A `Command::Key` is encoded against the child's live cursor-key mode. #[test] -fn spawn_claude_pins_an_id_and_layers_settings() { - let dir = scratch("cap_claude"); - let (bin, runtime) = (dir.join("bin"), dir.join("run")); - install_stub(&bin, "claude", &dir); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); - s.apply(Command::Spawn { - command: "claude".into(), - cwd: dir.clone(), - group: None, - }); +fn key_command_encodes_against_live_cursor_mode() { + let dir = scratch("key_live_mode"); + let (ready, out) = (dir.join("ready"), dir.join("out")); + let mut s = sup(24, 80); + hello_with_sh(&mut s, dir.clone()); - let argv = wait_argv(&mut s, &dir.join("argv")); - let si = argv - .iter() - .position(|a| a == "--session-id") - .expect("the stub must receive --session-id"); - let id = argv[si + 1].clone(); - assert!( - crate::harness::is_uuid(&id), - "the pinned id must be a strict uuid: {id:?}" - ); - let fi = argv - .iter() - .position(|a| a == "--settings") - .expect("the stub must receive --settings"); - let settings = PathBuf::from(&argv[fi + 1]); - assert!(settings.is_file(), "the settings overlay must exist"); - let parsed = jzon::parse(&std::fs::read_to_string(&settings).unwrap()) - .expect("the settings overlay must be valid JSON"); - let hook = parsed["hooks"]["SessionStart"][0]["hooks"][0]["command"] - .as_str() - .expect("the overlay must carry the hook command"); - assert!( - hook.contains("FLEETCOM_CAPTURE_FILE"), - "the hook must write to the capture env: {hook:?}" + // Raw mode lets `cat` receive ESC-prefixed keys without a newline. The + // child enables DECCKM before alternate-screen mode, so observing the + // alternate screen also confirms that DECCKM has been processed. + let id = spawn_ready( + &mut s, + format!( + "stty raw 2>/dev/null; printf '\\033[?1h\\033[?1049h'; echo r > {r}; cat > {o}", + r = ready.display(), + o = out.display() + ), + dir.clone(), + &ready, ); - let t = &s.tasks[0]; - let cap = t.capture_file.clone().expect("capture file set"); - // An explicit runtime directory is used without a discriminator; - // capture files sit in this incarnation's `-` namespace - // directly under it. - let ns = cap.parent().expect("capture file must sit in a namespace"); - assert_eq!(ns.parent(), Some(runtime.as_path())); + let app_cursor_on = |s: &Supervisor| { + s.tasks + .iter() + .find(|t| t.id == id) + .is_some_and(|t| t.input_hints().1) + }; assert!( - ns.file_name() - .unwrap() - .to_str() - .unwrap() - .starts_with(&format!("{}-", std::process::id())), - "the namespace must carry this process's pid prefix: {ns:?}" - ); - assert_eq!( - cap.file_name().unwrap().to_str().unwrap(), - format!("task-{}-0.json", t.id), - "the capture file must be keyed by task and run" - ); - assert_eq!( - settings.parent(), - Some(ns), - "assets and captures must share the namespace" - ); - assert_eq!( - std::fs::read_to_string(dir.join("capenv")).unwrap(), - cap.display().to_string(), - "the capture env must name task--.json under the incarnation namespace" - ); - assert_eq!( - t.command, "claude", - "instrumentation must never leak into the stored command" + wait_until(Duration::from_secs(5), || app_cursor_on(&s)), + "child never entered application-cursor mode" ); - assert_eq!(t.resume_id.as_deref(), Some(id.as_str())); - assert!(t.harness.is_some()); - let _ = std::fs::remove_dir_all(&dir); -} -/// An unrecognized command spawns without capture state or assets. -#[test] -fn spawn_non_agent_command_is_not_instrumented() { - let dir = scratch("cap_plain"); - let runtime = dir.join("run"); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx(&dir.join("bin"), &runtime, dir.clone())); - s.apply(Command::Spawn { - command: "printf ok".into(), - cwd: dir.clone(), - group: None, + // An unmodified Up uses SS3 while application-cursor mode is active. + s.apply(Command::Key { + id, + code: Key::Up, + mods: Mods::default(), }); - let t = &s.tasks[0]; - assert!(t.harness.is_none()); - assert!(t.capture_file.is_none()); - assert!(t.resume_id.is_none()); + let up: &[u8] = b"\x1bOA"; assert!( - s.capture.is_empty(), - "a non-agent spawn must not install capture assets" + wait_until(Duration::from_secs(5), || { + std::fs::read(&out).is_ok_and(|b| b == up) + }), + "Up under app-cursor must arrive as SS3 ESC O A; got {:?}", + std::fs::read(&out) ); - assert!(!runtime.exists()); - let _ = std::fs::remove_dir_all(&dir); -} -/// A resuming `claude` launch retains its target ID and adds only the -/// capture overlay. -#[test] -fn spawn_resuming_claude_injects_only_the_capture_channel() { - let dir = scratch("cap_resume"); - let (bin, runtime) = (dir.join("bin"), dir.join("run")); - install_stub(&bin, "claude", &dir); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); - s.apply(Command::Spawn { - command: format!("claude --resume {CAP_ID}"), - cwd: dir.clone(), - group: None, + // A modified cursor key uses CSI even in application-cursor mode. + s.apply(Command::Key { + id, + code: Key::Left, + mods: Mods { + alt: true, + ..Mods::default() + }, }); - - let argv = wait_argv(&mut s, &dir.join("argv")); - assert!( - !argv.iter().any(|a| a == "--session-id"), - "a resuming launch must never pin a second id; argv: {argv:?}" - ); + let total: &[u8] = b"\x1bOA\x1b[1;3D"; assert!( - argv.iter().any(|a| a == "--settings"), - "the settings overlay must still ride along; argv: {argv:?}" + wait_until(Duration::from_secs(5), || { + std::fs::read(&out).is_ok_and(|b| b == total) + }), + "Alt+Left must force CSI 1;3D under app-cursor; got {:?}", + std::fs::read(&out) ); - let t = &s.tasks[0]; - assert_eq!(t.command, format!("claude --resume {CAP_ID}")); - assert_eq!(t.resume_id.as_deref(), Some(CAP_ID)); + + s.apply(Command::Kill { id }); let _ = std::fs::remove_dir_all(&dir); } -/// Rerun prefers the capture-file ID, stores the resulting resume command, -/// and deletes the displaced run's capture after deriving the resume command. -#[test] -fn rerun_resumes_the_captured_conversation() { - use crate::protocol::Lifecycle; - let dir = scratch("cap_rerun"); - let (bin, runtime) = (dir.join("bin"), dir.join("run")); - install_stub(&bin, "claude", &dir); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); - s.apply(Command::Spawn { - command: "claude".into(), - cwd: dir.clone(), - group: None, - }); - let _ = wait_argv(&mut s, &dir.join("argv")); - let id = s.tasks[0].id; - wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); - - // The capture payload reports a different ID from the pinned one. - let cap = s.tasks[0].capture_file.clone().expect("capture file set"); - std::fs::write( - &cap, - format!( - r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"clear"}}"# - ), - ) - .unwrap(); - std::fs::remove_file(dir.join("argv")).unwrap(); - - s.apply(Command::Restart { id }); - let argv = wait_argv(&mut s, &dir.join("argv")); - assert_eq!( - s.tasks[0].command, - format!("claude --resume '{CAP_OTHER}'"), - "the stored command must become the resuming one" - ); - let ri = argv - .iter() - .position(|a| a == "--resume") - .expect("the respawn must resume"); - assert_eq!(argv[ri + 1], CAP_OTHER); - assert!( - !argv.iter().any(|a| a == "--session-id"), - "re-detection classifies the respawn as resuming: no second id" - ); - assert!( - reap_until(&mut s, Duration::from_secs(5), |s| s.graveyard.is_empty()), - "the displaced run was never collected" - ); - // The resume command retains the ID after the source capture is deleted. - assert!( - !cap.exists(), - "rerun must delete the displaced run's capture file" - ); - let _ = std::fs::remove_dir_all(&dir); -} - -/// A rerun uses a new capture path, so the displaced run's payload and -/// later writes cannot affect the replacement. -#[test] -fn rerun_cannot_read_the_old_runs_stale_capture() { - let dir = scratch("cap_stale_run"); - let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); - // The session drifts to CAP_ID mid-run and the exit hint reports it. - install_script( - &bin, - "claude", - &format!("printf 'Resume this session with:\\nclaude --resume {CAP_ID}\\n'"), - ); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( - &bin, - &runtime, - dir.clone(), - &[("FLEETCOM_CONFIG_DIR", &config)], - )); - s.apply(Command::Spawn { - command: "claude".into(), - cwd: dir.clone(), - group: None, - }); - let id = s.tasks[0].id; - // The capture file still holds the pre-drift session. - let stale = format!( - r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"startup"}}"# - ); - let old_cap = s.tasks[0].capture_file.clone().expect("capture file set"); - std::fs::write(&old_cap, &stale).unwrap(); - assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] - .scraped_id - .is_some())); - assert_eq!(s.tasks[0].scraped_id.as_deref(), Some(CAP_ID)); - - s.apply(Command::Restart { id }); - let new_cap = s.tasks[0].capture_file.clone().expect("capture file set"); - assert_ne!(new_cap, old_cap, "the fresh run needs its own capture file"); - assert_eq!(s.tasks[0].command, format!("claude --resume '{CAP_ID}'")); - assert!( - !old_cap.exists(), - "rerun must delete the displaced run's capture file" - ); - - // A late hook write can recreate the old path, but the new run cannot read it. - std::fs::write(&old_cap, &stale).unwrap(); - let text = save_and_read(&mut s, &config, "stalecap"); - assert!( - text.contains(&format!("claude --resume '{CAP_ID}'")), - "the fresh run must save the drifted session; got {text}" - ); - assert!( - !text.contains(CAP_OTHER), - "the old run's stale capture must be unreachable; got {text}" - ); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Removing a task also removes its capture file. -#[test] -fn remove_deletes_the_capture_file() { - use crate::protocol::Lifecycle; - let dir = scratch("cap_remove"); - let (bin, runtime) = (dir.join("bin"), dir.join("run")); - install_stub(&bin, "claude", &dir); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); - s.apply(Command::Spawn { - command: "claude".into(), - cwd: dir.clone(), - group: None, - }); - let _ = wait_argv(&mut s, &dir.join("argv")); - let id = s.tasks[0].id; - wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); - let cap = s.tasks[0].capture_file.clone().unwrap(); - std::fs::write(&cap, "{}").unwrap(); - - s.apply(Command::Remove { id }); - assert!(!cap.exists(), "Remove must delete the task's capture file"); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Reconnecting with the active root reuses installed assets and preserves -/// live capture files. -#[test] -fn reconnect_with_unchanged_root_preserves_capture_files() { - let dir = scratch("cap_reconnect"); - let (bin, runtime) = (dir.join("bin"), dir.join("run")); - install_stub(&bin, "claude", &dir); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); - s.apply(Command::Spawn { - command: "claude".into(), - cwd: dir.clone(), - group: None, - }); - let cap = s.tasks[0].capture_file.clone().expect("capture file set"); - std::fs::write(&cap, "{}").unwrap(); - - // The client reconnects with an identical env and spawns again. - s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); - s.apply(Command::Spawn { - command: "claude".into(), - cwd: dir.clone(), - group: None, - }); - assert_eq!(s.tasks.len(), 2); - assert!( - cap.exists(), - "an unchanged root must not disturb live capture files" - ); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Returning to an installed root preserves its live capture files. -#[test] -fn returning_to_a_prior_root_preserves_its_live_captures() { - let dir = scratch("cap_aba"); - let (bin, root_a, root_b) = (dir.join("bin"), dir.join("run-a"), dir.join("run-b")); - install_stub(&bin, "claude", &dir); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx(&bin, &root_a, dir.clone())); - s.apply(Command::Spawn { - command: "claude".into(), - cwd: dir.clone(), - group: None, - }); - let cap_a = s.tasks[0].capture_file.clone().expect("capture file set"); - std::fs::write(&cap_a, "{}").unwrap(); - - // The client reconnects under root B, spawns, then returns to A and - // spawns again. - s.set_launch_context(agent_ctx(&bin, &root_b, dir.clone())); - s.apply(Command::Spawn { - command: "claude".into(), - cwd: dir.clone(), - group: None, - }); - s.set_launch_context(agent_ctx(&bin, &root_a, dir.clone())); - s.apply(Command::Spawn { - command: "claude".into(), - cwd: dir.clone(), - group: None, - }); - - assert_eq!(s.tasks.len(), 3); - assert!( - cap_a.exists(), - "returning to a known root must not disturb its live captures" - ); - let ns_b = s.tasks[1] - .capture_file - .as_deref() - .and_then(|c| c.parent()) - .expect("the root-B spawn must have a namespaced capture file"); - assert!(ns_b.starts_with(&root_b), "root B owns its namespace"); - assert!( - ns_b.join("claude-settings.json").is_file(), - "the interleaved root must keep its own namespaced assets" - ); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Remove deletes the capture file under the root the task spawned in, -/// not under whichever root the current client presents. -#[test] -fn remove_deletes_the_capture_file_under_the_spawn_root() { - use crate::protocol::Lifecycle; - let dir = scratch("cap_remove_cross"); - let (bin, root_a, root_b) = (dir.join("bin"), dir.join("run-a"), dir.join("run-b")); - install_stub(&bin, "claude", &dir); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx(&bin, &root_a, dir.clone())); - s.apply(Command::Spawn { - command: "claude".into(), - cwd: dir.clone(), - group: None, - }); - let _ = wait_argv(&mut s, &dir.join("argv")); - let id = s.tasks[0].id; - wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); - let cap = s.tasks[0].capture_file.clone().unwrap(); - std::fs::write(&cap, "{}").unwrap(); - - // Root B is installed by a newer spawn; a same-id file under it must - // survive the A task's removal. - s.set_launch_context(agent_ctx(&bin, &root_b, dir.clone())); - s.apply(Command::Spawn { - command: "claude".into(), - cwd: dir.clone(), - group: None, - }); - let decoy = s.tasks[1] - .capture_file - .as_deref() - .and_then(|c| c.parent()) - .expect("the root-B spawn must have a namespaced capture file") - .join(format!("task-{id}-0.json")); - std::fs::write(&decoy, "{}").unwrap(); - - s.apply(Command::Remove { id }); - assert!( - !cap.exists(), - "Remove must delete the task's own capture file" - ); - assert!( - decoy.exists(), - "Remove must not touch the same id under another root" - ); - let _ = std::fs::remove_dir_all(&dir); -} - -/// A `codex` spawn receives a `notify=[...]` override naming an executable -/// capture script. -#[test] -fn spawn_codex_installs_the_notify_override() { - use std::os::unix::fs::PermissionsExt; - let dir = scratch("cap_codex"); - let (bin, runtime) = (dir.join("bin"), dir.join("run")); - install_stub(&bin, "codex", &dir); - let mut s = Supervisor::new(24, 80, 2000); - // Keep config lookup within this test's scratch directory. - s.set_launch_context(agent_ctx_plus( - &bin, - &runtime, - dir.clone(), - &[("CODEX_HOME", &dir.join("codex_home"))], - )); - s.apply(Command::Spawn { - command: "codex".into(), - cwd: dir.clone(), - group: None, - }); - - let argv = wait_argv(&mut s, &dir.join("argv")); - let ci = argv - .iter() - .position(|a| a == "-c") - .expect("the stub must receive -c"); - let script = argv[ci + 1] - .strip_prefix("notify=[\"") - .and_then(|t| t.strip_suffix("\"]")) - .unwrap_or_else(|| panic!("malformed notify override: {:?}", argv[ci + 1])); - let meta = std::fs::metadata(script).expect("the notify program must exist"); - assert!( - meta.permissions().mode() & 0o111 != 0, - "codex execs the notify program directly; it must be executable" - ); - let t = &s.tasks[0]; - assert_eq!(t.command, "codex"); - assert!(t.resume_id.is_none(), "codex cannot pin an id at launch"); - assert!(t.capture_file.is_some()); - let _ = std::fs::remove_dir_all(&dir); -} - -/// A `grok` spawn receives exactly the pinned ID: no settings overlay, -/// no config override, and no capture environment (grok has no -/// injectable live channel). The saved recipe resumes the pinned ID. -#[test] -fn spawn_grok_pins_an_id_and_injects_nothing_else() { - let dir = scratch("cap_grok"); - let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); - install_stub(&bin, "grok", &dir); - let mut s = Supervisor::new(24, 80, 2000); - // Keep save-time correlation inside the scratch tree. - s.set_launch_context(agent_ctx_plus( - &bin, - &runtime, - dir.clone(), - &[ - ("FLEETCOM_CONFIG_DIR", &config), - ("GROK_HOME", &dir.join("grok_home")), - ], - )); - s.apply(Command::Spawn { - command: "grok".into(), - cwd: dir.clone(), - group: None, - }); - - let argv = wait_argv(&mut s, &dir.join("argv")); - assert_eq!( - argv.len(), - 2, - "only the pinned id may be injected: {argv:?}" - ); - assert_eq!(argv[0], "--session-id"); - let id = argv[1].clone(); - assert!( - crate::harness::is_uuid(&id), - "the pinned id must be a strict uuid: {id:?}" - ); - // The stub recorded an empty FLEETCOM_CAPTURE_FILE: no capture env. - assert_eq!( - std::fs::read_to_string(dir.join("capenv")).unwrap(), - "", - "grok has no capture channel, so the env must not name one" - ); - let t = &s.tasks[0]; - assert_eq!( - t.command, "grok", - "instrumentation must never leak into the stored command" - ); - assert_eq!(t.resume_id.as_deref(), Some(id.as_str())); - - let text = save_and_read(&mut s, &config, "grokpin"); - assert!( - text.contains(&format!("grok --resume '{id}'")), - "the recipe must resume the pinned session; got {text}" - ); - let _ = std::fs::remove_dir_all(&dir); -} - -/// A `claude` exit hint becomes the session ID used by the saved recipe. -#[test] -fn exit_hint_is_scraped_and_saved_as_a_resume() { - let dir = scratch("scrape_exit"); - let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); - install_script( - &bin, - "claude", - &format!("printf 'Resume this session with:\\nclaude --resume {CAP_ID}\\n'"), - ); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( - &bin, - &runtime, - dir.clone(), - &[("FLEETCOM_CONFIG_DIR", &config)], - )); - s.apply(Command::Spawn { - command: "claude".into(), - cwd: dir.clone(), - group: None, - }); - // No pre-exit synchronization: the scrape's reader-EOF gate means - // reap can run against the exiting stub at any point and the hint - // still lands. - assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] - .scraped_id - .is_some())); - assert_eq!(s.tasks[0].scraped_id.as_deref(), Some(CAP_ID)); - - let text = save_and_read(&mut s, &config, "hint"); - assert!( - text.contains(&format!("claude --resume '{CAP_ID}'")), - "the recipe must resume the scraped session; got {text}" - ); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Saving between process exit and the next reap tick still captures the -/// exit hint because `save_session` performs its own ready scrape. -#[test] -fn save_scrapes_a_finished_task_without_reap() { - let dir = scratch("save_sync_scrape"); - let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); - install_script( - &bin, - "claude", - &format!("printf 'Resume this session with:\\nclaude --resume {CAP_ID}\\n'"), - ); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( - &bin, - &runtime, - dir.clone(), - &[("FLEETCOM_CONFIG_DIR", &config)], - )); - s.apply(Command::Spawn { - command: "claude".into(), - cwd: dir.clone(), - group: None, - }); - - // Wait out only the residual reader-drain race: after EOF the sole - // remaining gate is the exit latch, which save's own pass must flip. - assert!( - wait_until(Duration::from_secs(5), || s.tasks[0].reader_done()), - "the stub never reached EOF" - ); - assert!(s.tasks[0].finished.is_none(), "no reap may have run yet"); - - let text = save_and_read(&mut s, &config, "syncsave"); - assert!( - text.contains(&format!("claude --resume '{CAP_ID}'")), - "save must scrape the finished task itself; got {text}" - ); - assert_eq!(s.tasks[0].scraped_id.as_deref(), Some(CAP_ID)); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Rerunning between process exit and the next reap tick latches the exit, -/// scrapes the hint, and resumes that session. -#[test] -fn rerun_scrapes_a_finished_task_without_reap() { - let dir = scratch("rerun_sync_scrape"); - let (bin, runtime) = (dir.join("bin"), dir.join("run")); - install_script( - &bin, - "claude", - &format!("printf 'Resume this session with:\\nclaude --resume {CAP_ID}\\n'"), - ); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); - s.apply(Command::Spawn { - command: "claude".into(), - cwd: dir.clone(), - group: None, - }); - let id = s.tasks[0].id; - - assert!( - wait_until(Duration::from_secs(5), || s.tasks[0].reader_done()), - "the stub never reached EOF" - ); - assert!(s.tasks[0].finished.is_none(), "no reap may have run yet"); - - s.apply(Command::Restart { id }); - assert_eq!( - s.tasks[0].command, - format!("claude --resume '{CAP_ID}'"), - "rerun must compute its resume command from the exit scrape" - ); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Session-ID precedence is exit scrape, capture file, then spawn-time ID. -#[test] -fn resume_id_precedence_scrape_over_capture_over_spawn() { - let dir = scratch("precedence"); - let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); - let (hinted, done) = (dir.join("hinted"), dir.join("done")); - install_script( - &bin, - "claude", - &format!( - "until [ -e '{h}' ]; do sleep 0.05; done\n\ - printf 'Resume this session with:\\nclaude --resume {CAP_ID}\\n'\n\ - until [ -e '{d}' ]; do sleep 0.05; done", - h = hinted.display(), - d = done.display() - ), - ); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( - &bin, - &runtime, - dir.clone(), - &[("FLEETCOM_CONFIG_DIR", &config)], - )); - s.apply(Command::Spawn { - command: "claude".into(), - cwd: dir.clone(), - group: None, - }); - let injected = s.tasks[0] - .resume_id - .clone() - .expect("a fresh claude launch pins an id"); - assert_ne!(injected.as_str(), CAP_OTHER); - - // The hook moved the session mid-run: pre-exit, the capture file - // must beat the injected id. - let cap = s.tasks[0].capture_file.clone().expect("capture file set"); - std::fs::write( - &cap, - format!( - r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"clear"}}"# - ), - ) - .unwrap(); - let text = save_and_read(&mut s, &config, "mid"); - assert!( - text.contains(&format!("claude --resume '{CAP_OTHER}'")), - "pre-exit the capture file must beat the injected id; got {text}" - ); - - // Print the hint and let the task exit: post-exit, the scrape must - // beat the capture file. - std::fs::write(&hinted, b"").unwrap(); - std::fs::write(&done, b"").unwrap(); - assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] - .scraped_id - .is_some())); - assert_eq!(s.tasks[0].scraped_id.as_deref(), Some(CAP_ID)); - let text = save_and_read(&mut s, &config, "post"); - assert!( - text.contains(&format!("claude --resume '{CAP_ID}'")), - "post-exit the scraped hint must beat the capture file; got {text}" - ); - let _ = std::fs::remove_dir_all(&dir); -} - -/// A silent Codex task falls back to one matching rollout under -/// `CODEX_HOME` when live channels produce no ID. -#[test] -fn save_falls_back_to_fs_correlation_for_a_silent_codex() { - let dir = scratch("correlate_save"); - let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); - let codex_home = dir.join("codex_home"); - install_stub(&bin, "codex", &dir); - // Create a rollout with a current v7 instant and the task's cwd. - let now_ms = now_ms(); - let id = write_rollout(&codex_home, now_ms, 1, &dir); - - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( - &bin, - &runtime, - dir.clone(), - &[ - ("FLEETCOM_CONFIG_DIR", &config), - ("CODEX_HOME", &codex_home), - ], - )); - s.apply(Command::Spawn { - command: "codex".into(), - cwd: dir.clone(), - group: None, - }); - assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] - .finished - .is_some())); - assert!(s.tasks[0].scraped_id.is_none(), "a silent exit has no hint"); - assert!( - current_resume_id(&s.tasks[0]).is_none(), - "no capture channel fired" - ); - - let text = save_and_read(&mut s, &config, "corr"); - assert!( - text.contains(&format!("codex resume '{id}'")), - "save must fall back to filesystem correlation; got {text}" - ); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Save-time correlation uses the task's spawn-time `CODEX_HOME`, even -/// after a reconnect supplies another home containing a matching rollout. -#[test] -fn save_correlates_against_the_spawn_time_home() { - let dir = scratch("correlate_home"); - let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); - let (home_a, home_b) = (dir.join("codex_a"), dir.join("codex_b")); - install_stub(&bin, "codex", &dir); - let now_ms = now_ms(); - // One unique in-window rollout per store, both naming the task cwd. - let id_a = write_rollout(&home_a, now_ms, 1, &dir); - let id_b = write_rollout(&home_b, now_ms, 2, &dir); - - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( - &bin, - &runtime, - dir.clone(), - &[("FLEETCOM_CONFIG_DIR", &config), ("CODEX_HOME", &home_a)], - )); - s.apply(Command::Spawn { - command: "codex".into(), - cwd: dir.clone(), - group: None, - }); - assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] - .finished - .is_some())); - - // Reconnect under home B, then save. - s.set_launch_context(agent_ctx_plus( - &bin, - &runtime, - dir.clone(), - &[("FLEETCOM_CONFIG_DIR", &config), ("CODEX_HOME", &home_b)], - )); - let text = save_and_read(&mut s, &config, "homepin"); - assert!( - text.contains(&format!("codex resume '{id_a}'")), - "the recipe must resolve from the launch-time store; got {text}" - ); - assert!( - !text.contains(&id_b), - "the reconnect store's decoy must not correlate; got {text}" - ); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Home resolution order: the tool's own var, then the launch env's HOME -/// joined with the tool's dot directory, then nothing. -#[test] -fn harness_home_prefers_the_tool_var_then_home() { - use crate::harness::{Claude, Codex, Grok}; - let env: Vec<(OsString, OsString)> = vec![ - ("HOME".into(), "/h".into()), - ("CODEX_HOME".into(), "/x".into()), - ]; - assert_eq!(harness_home(&env, &Codex).as_deref(), Some(Path::new("/x"))); - let env: Vec<(OsString, OsString)> = vec![("HOME".into(), "/h".into())]; - assert_eq!( - harness_home(&env, &Codex).as_deref(), - Some(Path::new("/h/.codex")) - ); - assert_eq!( - harness_home(&env, &Claude).as_deref(), - Some(Path::new("/h/.claude")) - ); - assert_eq!( - harness_home(&env, &Grok).as_deref(), - Some(Path::new("/h/.grok")) - ); - assert_eq!(harness_home(&[], &Codex), None); -} - -/// With only `HOME` in the launch environment, both notify routing and -/// save-time correlation resolve through `/.codex`. -#[test] -fn home_only_launch_env_targets_the_clients_dot_codex() { - let dir = scratch("home_resolve"); - let (bin, runtime, config, home) = ( - dir.join("bin"), - dir.join("run"), - dir.join("config"), - dir.join("home"), - ); - let codex_home = home.join(".codex"); - std::fs::create_dir_all(&codex_home).unwrap(); - // A multi-line notify is Opaque: injection is suppressed only when - // the guard reads the client's config.toml through HOME. - std::fs::write( - codex_home.join("config.toml"), - "notify = [\n \"/my/thing\",\n]\n", - ) - .unwrap(); - install_stub(&bin, "codex", &dir); - // A unique in-window rollout in the same tree for save-time - // correlation: with injection suppressed, no capture channel fires. - let now_ms = now_ms(); - let id = write_rollout(&codex_home, now_ms, 1, &dir); - - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( - &bin, - &runtime, - dir.clone(), - &[("FLEETCOM_CONFIG_DIR", &config), ("HOME", &home)], - )); - s.apply(Command::Spawn { - command: "codex".into(), - cwd: dir.clone(), - group: None, - }); - assert_eq!( - s.tasks[0].harness_home.as_deref(), - Some(codex_home.as_path()), - "HOME alone must resolve the harness home" - ); - let argv = wait_argv(&mut s, &dir.join("argv")); - assert!( - !argv.iter().any(|a| a.contains("notify=")), - "the guard must read /.codex/config.toml; argv: {argv:?}" - ); - assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] - .finished - .is_some())); - - let text = save_and_read(&mut s, &config, "homeonly"); - assert!( - text.contains(&format!("codex resume '{id}'")), - "correlation must read /.codex; got {text}" - ); - let _ = std::fs::remove_dir_all(&dir); -} - -/// With no configured notifier, instrumentation clears an inherited -/// `FLEETCOM_NOTIFY_CHAIN` so the capture script cannot execute it. -#[test] -fn stale_inherited_notify_chain_is_never_executed() { - use std::os::unix::fs::PermissionsExt; - let dir = scratch("stale_chain"); - let (bin, runtime) = (dir.join("bin"), dir.join("run")); - // No config.toml exists: nothing routed, so nothing may be chained. - let codex_home = dir.join("codex_home"); - let stale = dir.join("stale"); - let record = dir.join("stale-record"); - std::fs::write(&stale, format!("#!/bin/sh\ntouch '{}'\n", record.display())).unwrap(); - std::fs::set_permissions(&stale, std::fs::Permissions::from_mode(0o700)).unwrap(); - - // The stub invokes the injected notify script the way codex would. - let payload = format!(r#"{{"type":"agent-turn-complete","thread-id":"{CAP_ID}"}}"#); - // The notify script sits beside the capture file, in a namespace - // whose nonce is unknowable before spawn: derive it from the env. - install_script( - &bin, - "codex", - &format!("\"${{FLEETCOM_CAPTURE_FILE%/*}}/codex-notify.sh\" '{payload}'"), - ); - let mut s = Supervisor::new(24, 80, 2000); - let mut ctx = agent_ctx_plus(&bin, &runtime, dir.clone(), &[("CODEX_HOME", &codex_home)]); - ctx.env.push(( - crate::harness::NOTIFY_CHAIN_ENV.into(), - stale.as_os_str().to_os_string(), - )); - s.set_launch_context(ctx); - s.apply(Command::Spawn { - command: "codex".into(), - cwd: dir.clone(), - group: None, - }); - assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] - .finished - .is_some())); - let cap = s.tasks[0].capture_file.clone().expect("capture file set"); - assert_eq!( - std::fs::read_to_string(&cap).unwrap(), - payload, - "the capture write must land before the script exits" - ); - assert!( - !record.exists(), - "the stale inherited chain must not execute" - ); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Without a live or filesystem ID, an agent recipe retains the original -/// command. -#[test] -fn agent_save_without_any_id_keeps_the_plain_command() { - let dir = scratch("no_id"); - let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); - // CODEX_HOME names a store that never exists: correlation has - // nothing to find, and the notify routing nothing to read. - let codex_home = dir.join("codex_home"); - install_stub(&bin, "codex", &dir); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( - &bin, - &runtime, - dir.clone(), - &[ - ("FLEETCOM_CONFIG_DIR", &config), - ("CODEX_HOME", &codex_home), - ], - )); - s.apply(Command::Spawn { - command: "codex".into(), - cwd: dir.clone(), - group: None, - }); - assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] - .finished - .is_some())); - - let text = save_and_read(&mut s, &config, "plainagent"); - assert!( - text.contains("\"codex\""), - "the plain command must survive; got {text}" - ); - assert!( - !text.contains("resume"), - "no id exists, so nothing may be rewritten; got {text}" - ); - let _ = std::fs::remove_dir_all(&dir); -} - -/// A representable `notify` assignment runs through the injected notifier -/// after the capture write. -#[test] -fn config_toml_notify_chains_through_the_injected_script() { - use crate::harness::NOTIFY_CHAIN_ENV; - use std::os::unix::fs::PermissionsExt; - let dir = scratch("cfg_chain"); - let (bin, runtime) = (dir.join("bin"), dir.join("run")); - let codex_home = dir.join("codex_home"); - std::fs::create_dir_all(&codex_home).unwrap(); - // The notifier path contains spaces and carries a fixed argument. - let notifier = dir.join("Fake App.app").join("Sky Client"); - let record = dir.join("notifier-record"); - std::fs::create_dir_all(notifier.parent().unwrap()).unwrap(); - std::fs::write( - ¬ifier, - format!( - "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\n", - record.display() - ), - ) - .unwrap(); - std::fs::set_permissions(¬ifier, std::fs::Permissions::from_mode(0o700)).unwrap(); - std::fs::write( - codex_home.join("config.toml"), - format!("notify = [\"{}\", \"turn-ended\"]\n", notifier.display()), - ) - .unwrap(); - - // The stub records argv and the chain env, then invokes the notify - // script with notification JSON as the final argument. - let payload = format!(r#"{{"type":"agent-turn-complete","thread-id":"{CAP_ID}"}}"#); - install_script( - &bin, - "codex", - &format!( - "printf '%s\\n' \"$@\" > '{out}/argv'\n\ - printf '%s' \"${chain}\" > '{out}/chainenv'\n\ - \"${{FLEETCOM_CAPTURE_FILE%/*}}/codex-notify.sh\" '{payload}'", - out = dir.display(), - chain = NOTIFY_CHAIN_ENV, - ), - ); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( - &bin, - &runtime, - dir.clone(), - &[("CODEX_HOME", &codex_home)], - )); - s.apply(Command::Spawn { - command: "codex".into(), - cwd: dir.clone(), - group: None, - }); - let argv = wait_argv(&mut s, &dir.join("argv")); - assert!( - argv.iter().any(|a| a.starts_with("notify=[")), - "a chained spawn must still inject the override; argv: {argv:?}" - ); - // Redirection creates the record before `printf` writes, so wait for the - // complete expected contents. - let expected = format!("turn-ended\n{payload}\n"); - assert!( - reap_until(&mut s, Duration::from_secs(5), |_| { - std::fs::read_to_string(&record).is_ok_and(|r| r == expected) - }), - "the chained notifier never wrote its complete record" - ); - assert_eq!( - std::fs::read_to_string(dir.join("chainenv")).unwrap(), - format!("{}\nturn-ended", notifier.display()), - "the child env must carry the displaced argv, newline-joined" - ); - let cap = s.tasks[0].capture_file.clone().unwrap(); - assert_eq!( - std::fs::read_to_string(&cap).unwrap(), - payload, - "the capture write must precede the chain handoff" - ); - assert_eq!( - std::fs::read_to_string(&record).unwrap(), - format!("turn-ended\n{payload}\n"), - "the notifier must receive its original args plus the payload" - ); - let _ = std::fs::remove_dir_all(&dir); -} - -/// An unrepresentable `notify` value disables injection, while a commented -/// assignment defines no route and leaves injection enabled. -#[test] -fn unrepresentable_config_notify_suppresses_injection() { - let dir = scratch("cfg_guard"); - let (bin, runtime) = (dir.join("bin"), dir.join("run")); - let codex_home = dir.join("codex_home"); - std::fs::create_dir_all(&codex_home).unwrap(); - // A multi-line array is out of the line-based parser's reach. - std::fs::write( - codex_home.join("config.toml"), - "notify = [\n \"/my/thing\",\n]\n", - ) - .unwrap(); - install_stub(&bin, "codex", &dir); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( - &bin, - &runtime, - dir.clone(), - &[("CODEX_HOME", &codex_home)], - )); - s.apply(Command::Spawn { - command: "codex".into(), - cwd: dir.clone(), - group: None, - }); - let argv = wait_argv(&mut s, &dir.join("argv")); - assert!( - !argv.iter().any(|a| a.contains("notify=")), - "fleetcom must not guess at an unparseable notify; argv: {argv:?}" - ); - - // The same route commented out is inert: the injection returns. - std::fs::write( - codex_home.join("config.toml"), - "# notify = [\"/my/thing\"]\n", - ) - .unwrap(); - std::fs::remove_file(dir.join("argv")).unwrap(); - s.apply(Command::Spawn { - command: "codex".into(), - cwd: dir.clone(), - group: None, - }); - let argv = wait_argv(&mut s, &dir.join("argv")); - assert!( - argv.iter().any(|a| a.starts_with("notify=[")), - "a commented notify must not suppress the injection; argv: {argv:?}" - ); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Non-agent commands remain plain string entries in persisted JSON. -#[test] -fn non_agent_entries_survive_save_as_plain_strings() { - let dir = scratch("plain_save"); - let config = dir.join("config"); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(LaunchContext { - env: vec![ - ("SHELL".into(), "/bin/sh".into()), - ( - "FLEETCOM_CONFIG_DIR".into(), - config.clone().into_os_string(), - ), - ], - cwd: dir.clone(), - }); - s.apply(Command::Spawn { - command: "sleep 30".into(), - cwd: dir.clone(), - group: None, - }); - let text = save_and_read(&mut s, &config, "plain"); - assert!( - text.contains("\"sleep 30\""), - "string-form member expected; got {text}" - ); - assert!( - !text.contains("\"cmd\""), - "no object form for an unadorned entry; got {text}" - ); - let cfg = session::load_in(&config.join("sessions"), "plain").unwrap(); - assert_eq!( - cfg[&path::abbreviate(&dir)], - vec![SessionEntry { - cmd: "sleep 30".into(), - group: None, - name: None, - }] - ); - let _ = std::fs::remove_dir_all(&dir); -} - -/// A `Command::Key` is encoded against the child's live cursor-key mode. -#[test] -fn key_command_encodes_against_live_cursor_mode() { - let dir = scratch("key_live_mode"); - let (ready, out) = (dir.join("ready"), dir.join("out")); - let mut s = sup(24, 80); - hello_with_sh(&mut s, dir.clone()); - - // Raw mode lets `cat` receive ESC-prefixed keys without a newline. The - // child enables DECCKM before alternate-screen mode, so observing the - // alternate screen also confirms that DECCKM has been processed. - let id = spawn_ready( - &mut s, - format!( - "stty raw 2>/dev/null; printf '\\033[?1h\\033[?1049h'; echo r > {r}; cat > {o}", - r = ready.display(), - o = out.display() - ), - dir.clone(), - &ready, - ); - - let app_cursor_on = |s: &Supervisor| { - s.tasks - .iter() - .find(|t| t.id == id) - .is_some_and(|t| t.input_hints().1) - }; - assert!( - wait_until(Duration::from_secs(5), || app_cursor_on(&s)), - "child never entered application-cursor mode" - ); - - // An unmodified Up uses SS3 while application-cursor mode is active. - s.apply(Command::Key { - id, - code: Key::Up, - mods: Mods::default(), - }); - let up: &[u8] = b"\x1bOA"; - assert!( - wait_until(Duration::from_secs(5), || { - std::fs::read(&out).is_ok_and(|b| b == up) - }), - "Up under app-cursor must arrive as SS3 ESC O A; got {:?}", - std::fs::read(&out) - ); - - // A modified cursor key uses CSI even in application-cursor mode. - s.apply(Command::Key { - id, - code: Key::Left, - mods: Mods { - alt: true, - ..Mods::default() - }, - }); - let total: &[u8] = b"\x1bOA\x1b[1;3D"; - assert!( - wait_until(Duration::from_secs(5), || { - std::fs::read(&out).is_ok_and(|b| b == total) - }), - "Alt+Left must force CSI 1;3D under app-cursor; got {:?}", - std::fs::read(&out) - ); - - s.apply(Command::Kill { id }); - let _ = std::fs::remove_dir_all(&dir); -} +#[path = "supervisor_capture_tests.rs"] +mod capture; diff --git a/src/task.rs b/src/task.rs index a7d5509..677f4df 100644 --- a/src/task.rs +++ b/src/task.rs @@ -24,8 +24,9 @@ use rustix::process::{WaitId, WaitIdOptions, waitid}; use crate::{ core::{Wake, Waker}, emulator::Emulator, - preview::{Preview, PreviewState}, - protocol::{Key, Lifecycle, Mods, MouseKind, ScrollAction, env_get}, + input, + preview::PreviewState, + protocol::{Key, Lifecycle, Mods, MouseKind, Preview, ScrollAction, env_get}, }; /// Maximum bytes admitted to one task's writer queue but not yet written to the @@ -46,261 +47,6 @@ fn io_err(e: impl std::fmt::Display) -> io::Error { io::Error::other(e.to_string()) } -/// The bracketed-paste terminator. Stripped from paste *content* before -/// wrapping: a clipboard that contains this sequence would otherwise end the -/// paste early and smuggle the remainder in as live keystrokes. -const PASTE_END: &[u8] = b"\x1b[201~"; - -/// Encode a clipboard paste for a child whose DECSET 2004 state is -/// `bracketed`. Opted in: wrap in `200~`/`201~` markers with embedded -/// terminators stripped, content otherwise verbatim. Legacy: no markers, and -/// line endings (`\r\n` and bare `\n`) become `\r` (the byte Enter sends), -/// because a legacy line editor reads `\n` as ^J, not as end-of-line. -fn paste_bytes(bracketed: bool, content: &[u8]) -> Vec { - if bracketed { - let mut out = Vec::with_capacity(content.len() + 2 * PASTE_END.len() + 6); - out.extend_from_slice(b"\x1b[200~"); - let mut rest = content; - while let Some(pos) = rest.windows(PASTE_END.len()).position(|w| w == PASTE_END) { - out.extend_from_slice(&rest[..pos]); - rest = &rest[pos + PASTE_END.len()..]; - } - out.extend_from_slice(rest); - out.extend_from_slice(PASTE_END); - out - } else { - let mut out = Vec::with_capacity(content.len()); - let mut i = 0; - while i < content.len() { - if content[i] == b'\r' && content.get(i + 1) == Some(&b'\n') { - out.push(b'\r'); - i += 2; - } else if content[i] == b'\n' { - out.push(b'\r'); - i += 1; - } else { - out.push(content[i]); - i += 1; - } - } - out - } -} - -/// Encode a mouse action under the child's current terminal mode. The selected -/// protocol determines which actions are valid and how they are encoded. With -/// no mouse protocol, wheel actions become alternate-scroll arrows when the -/// alternate screen and DECSET 1007 are both active. DECSET 1007 defaults on; -/// see [`Emulator::alternate_scroll`]. Unsupported actions return `None`. -fn mouse_bytes(emu: &Emulator, kind: MouseKind, col: u16, row: u16) -> Option> { - use crate::emulator::{MouseProtocolEncoding, MouseProtocolMode}; - let mode = emu.mouse_protocol_mode(); - if mode != MouseProtocolMode::None { - // Every supported mode reports presses, releases, and wheel events; - // only motion modes 1002 and 1003 report drags. - if matches!(kind, MouseKind::Drag(_)) - && !matches!( - mode, - MouseProtocolMode::ButtonMotion | MouseProtocolMode::AnyMotion - ) - { - return None; - } - // xterm button codes: wheel 64/65; drag adds 32. - let code: u16 = match kind { - MouseKind::WheelUp => 64, - MouseKind::WheelDown => 65, - MouseKind::Press(b) | MouseKind::Release(b) => b as u16, - MouseKind::Drag(b) => 32 + b as u16, - }; - let release = matches!(kind, MouseKind::Release(_)); - return Some(match emu.mouse_protocol_encoding() { - // SGR releases use the `m` suffix. - MouseProtocolEncoding::Sgr => { - let suffix = if release { 'm' } else { 'M' }; - format!("\x1b[<{};{};{}{}", code, col + 1, row + 1, suffix).into_bytes() - } - // UTF-8 fields encode `32 + value` up to 2047; releases use code 3. - MouseProtocolEncoding::Utf8 => { - let code = if release { 3 } else { code }; - let mut out = b"\x1b[M".to_vec(); - for v in [32 + code, 33 + col.min(2014), 33 + row.min(2014)] { - let mut buf = [0u8; 4]; - // Values are bounded to valid UTF-8 scalar values. - let c = char::from_u32(u32::from(v)).unwrap_or(' '); - out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); - } - out - } - // Default fields are single bytes capped at 255; releases use code 3. - MouseProtocolEncoding::Default => { - let code = if release { 3 } else { code }; - vec![ - 0x1b, - b'[', - b'M', - 32 + code as u8, - (33 + col.min(222)) as u8, - (33 + row.min(222)) as u8, - ] - } - }); - } - if emu.alternate_scroll() { - let up = match kind { - MouseKind::WheelUp => true, - MouseKind::WheelDown => false, - // Only wheel actions map to alternate-scroll arrows. - _ => return None, - }; - let arrow: &[u8] = match (emu.application_cursor(), up) { - (true, true) => b"\x1bOA", - (true, false) => b"\x1bOB", - (false, true) => b"\x1b[A", - (false, false) => b"\x1b[B", - }; - return Some(arrow.repeat(3)); - } - None -} - -/// Return the control byte for a supported `Ctrl`+key combination. ASCII -/// letters and the standard symbol/digit aliases map to C0 control bytes. -fn ctrl_byte(c: char) -> Option { - if c.is_ascii_alphabetic() { - return Some((c.to_ascii_uppercase() as u8) & 0x1f); - } - Some(match c { - ' ' | '@' | '2' => 0x00, - '[' | '3' => 0x1b, - '\\' | '4' => 0x1c, - ']' | '5' => 0x1d, - '^' | '6' => 0x1e, - '_' | '7' | '/' => 0x1f, - '?' | '8' => 0x7f, - _ => return None, - }) -} - -/// Encode a printable key. Shift is already folded into `c` by the client, so -/// it is ignored here; only `ctrl` (control byte) and `alt` (ESC prefix, the -/// meta convention) change the bytes. -fn char_bytes(c: char, mods: Mods) -> Option> { - let mut out = if mods.ctrl { - vec![ctrl_byte(c)?] - } else { - let mut buf = [0u8; 4]; - c.encode_utf8(&mut buf).as_bytes().to_vec() - }; - if mods.alt { - out.insert(0, 0x1b); - } - Some(out) -} - -/// Encode F1–F4 as SS3 when unmodified and CSI when modified. F5–F12 use their -/// CSI numeric forms. Numbers outside `1..=12` encode to nothing. -fn f_bytes(n: u8, m: Option) -> Option> { - if let Some(letter) = match n { - 1 => Some('P'), - 2 => Some('Q'), - 3 => Some('R'), - 4 => Some('S'), - _ => None, - } { - return Some(match m { - None => format!("\x1bO{letter}").into_bytes(), - Some(m) => format!("\x1b[1;{m}{letter}").into_bytes(), - }); - } - let code = match n { - 5 => 15, - 6 => 17, - 7 => 18, - 8 => 19, - 9 => 20, - 10 => 21, - 11 => 23, - 12 => 24, - _ => return None, - }; - Some(match m { - None => format!("\x1b[{code}~").into_bytes(), - Some(m) => format!("\x1b[{code};{m}~").into_bytes(), - }) -} - -/// Encode a key for the child. Application-cursor mode selects SS3 for -/// unmodified cursor and Home/End keys; their modified forms use CSI. -/// Unsupported key combinations return `None`. -fn key_bytes(app_cursor: bool, code: Key, mods: Mods) -> Option> { - let m = mods.param(); - match code { - Key::Char(c) => char_bytes(c, mods), - Key::F(n) => f_bytes(n, m), - Key::Up | Key::Down | Key::Left | Key::Right | Key::Home | Key::End => { - let letter = match code { - Key::Up => 'A', - Key::Down => 'B', - Key::Right => 'C', - Key::Left => 'D', - Key::Home => 'H', - Key::End => 'F', - _ => unreachable!(), - }; - Some(match m { - None if app_cursor => format!("\x1bO{letter}").into_bytes(), - None => format!("\x1b[{letter}").into_bytes(), - Some(m) => format!("\x1b[1;{m}{letter}").into_bytes(), - }) - } - Key::Insert | Key::Delete | Key::PageUp | Key::PageDown => { - // Navigation-cluster keys always use CSI `~`. - let n = match code { - Key::Insert => 2, - Key::Delete => 3, - Key::PageUp => 5, - Key::PageDown => 6, - _ => unreachable!(), - }; - Some(match m { - None => format!("\x1b[{n}~").into_bytes(), - Some(m) => format!("\x1b[{n};{m}~").into_bytes(), - }) - } - // Enter uses ESC CR for Shift or Alt; Control does not change plain CR. - Key::Enter => Some(if mods.shift || mods.alt { - vec![0x1b, 0x0d] - } else { - vec![0x0d] - }), - // Alt prefixes Tab with ESC; Control and Shift do not change HT. - Key::Tab => Some(if mods.alt { - vec![0x1b, 0x09] - } else { - vec![0x09] - }), - // Alt prefixes BackTab's CSI Z sequence; Control and Shift are ignored. - Key::BackTab => Some(if mods.alt { - b"\x1b\x1b[Z".to_vec() - } else { - b"\x1b[Z".to_vec() - }), - // Backspace is DEL; Alt prefixes ESC, and Control/Shift leave it unchanged. - Key::Backspace => Some(if mods.alt { - vec![0x1b, 0x7f] - } else { - vec![0x7f] - }), - // Alt+Esc is the ESC-ESC meta form; Ctrl/Shift fold into a plain ESC. - Key::Esc => Some(if mods.alt { - vec![0x1b, 0x1b] - } else { - vec![0x1b] - }), - } -} - pub struct Task { pub id: u64, pub command: String, @@ -338,7 +84,7 @@ pub struct Task { pub harness_home: Option, /// Display-only summary adapter selected from the requested command, /// independently of session-capture instrumentation. - pub summary_adapter: Option<&'static dyn crate::harness::summary::SummaryAdapter>, + pub summary_adapter: Option<&'static dyn crate::preview::SummaryAdapter>, /// Run number used to give each rerun a distinct capture path. pub run: u32, /// Session ID injected or recognized at spawn. Later capture data or an @@ -486,7 +232,7 @@ impl Task { // shell functions are not loaded. cmd.arg("-c"); cmd.arg(exec_command); - // The job runs under the *client's* environment, verbatim: clear the + // The task runs under the *client's* environment, verbatim: clear the // builder's captured base (the daemon's own env, whatever the client // that first autostarted it happened to have) so nothing leaks through // where the client's env lacks a key. @@ -624,13 +370,13 @@ impl Task { /// Whether the child exited and the PTY reader stopped, so no more bytes /// can reach the grid. A missing reader handle counts as complete; the /// reader treats EOF and read errors identically. - pub(crate) fn output_complete(&self) -> bool { + fn output_complete(&self) -> bool { self.finished.is_some() && self.handle.as_ref().is_none_or(JoinHandle::is_finished) } /// Scrape at most one exit hint after the process exits and the PTY reader /// reaches EOF (see [`Task::output_complete`]). - pub(crate) fn scrape_exit_hint(&mut self) { + pub fn scrape_exit_hint(&mut self) { let Some(h) = self.harness else { return }; if self.scraped || !self.output_complete() { return; @@ -743,27 +489,21 @@ impl Task { /// the grid lock (see [`crate::preview`]). `now` is the caller's tick /// instant so every task in one snapshot resolves against the same clock. pub fn resolve_preview(&mut self, now: Instant) -> Preview { - let finished = self.finished.is_some(); let emu = grid(&self.parser); self.preview - .resolve(now, finished, &*emu, self.summary_adapter) + .resolve(now, &*emu, self.summary_adapter) .clone() } /// Freeze the preview once output is complete. Any open `?2026` frame is - /// landed first, and retained text is read only when an adapter is - /// selected. - pub(crate) fn finalize_preview(&mut self) { + /// landed first. + pub fn finalize_preview(&mut self) { if self.preview.finalized() || !self.output_complete() { return; } let mut emu = grid(&self.parser); let _ = emu.finish_output(); - let exit_line = self - .summary_adapter - .and_then(|a| a.exit_preview(&emu.text_with_history())); - self.preview - .finalize(&*emu, self.summary_adapter, exit_line); + self.preview.finalize(&*emu, self.summary_adapter); } /// Full screen as ANSI bytes for attached mode, plus cursor state so we can @@ -836,23 +576,21 @@ impl Task { grid(&self.parser).scrollback() } - /// Forward a clipboard paste in whichever shape the child negotiated; see - /// [`paste_bytes`]. Encoded under the grid lock (the DECSET 2004 read stays - /// on this thread), then queued whole: the PTY write itself happens on the - /// writer worker. + /// Encode a paste using the child's bracketed-paste mode, read under the + /// grid lock, then queue it as one PTY write. pub fn send_paste(&mut self, content: &[u8]) -> Result<(), WriteRefused> { let bracketed = grid(&self.parser).bracketed_paste(); - let msg = paste_bytes(bracketed, content); + let msg = input::paste_bytes(bracketed, content); self.snap_live(); self.queue_write(msg) } - /// Forward one mouse action, routed by the child's own screen state; see - /// [`mouse_bytes`]. A child that gets `None` receives nothing at all. + /// Encode and queue one mouse action using the child's screen modes. + /// Unsupported actions send nothing. pub fn send_mouse(&mut self, kind: MouseKind, col: u16, row: u16) -> Result<(), WriteRefused> { let bytes = { let p = grid(&self.parser); - mouse_bytes(&p, kind, col, row) + input::mouse_bytes(&p, kind, col, row) }; match bytes { Some(b) => self.send_input(&b), @@ -860,14 +598,12 @@ impl Task { } } - /// Forward one key press, encoded under the child's cursor-key mode; see - /// [`key_bytes`]. The DECCKM read stays on this thread under the grid lock, - /// like [`Task::send_paste`]/[`Task::send_mouse`]. A key that encodes to - /// nothing sends nothing. + /// Encode and queue one key using the child's cursor-key mode, read under + /// the grid lock. Unsupported combinations send nothing. pub fn send_key(&mut self, code: Key, mods: Mods) -> Result<(), WriteRefused> { let bytes = { let p = grid(&self.parser); - key_bytes(p.application_cursor(), code, mods) + input::key_bytes(p.application_cursor(), code, mods) }; match bytes { Some(b) => self.send_input(&b), @@ -886,10 +622,10 @@ impl Task { ) } - /// Ask the whole job to exit: SIGTERM to the process *group*, not just the + /// Ask the whole task to exit: SIGTERM to the process *group*, not just the /// direct child, so every group member gets it, including background /// children a `cmd &` left behind (a non-interactive shell's `&` creates no - /// new group, so they never leave this one). TERM, not KILL: the job gets a + /// new group, so they never leave this one). TERM, not KILL: the task gets a /// chance to flush and clean up. The supervisor owns the escalation: /// `overdue` turns true once the grace elapses, and `force_kill` finishes it. /// @@ -953,7 +689,7 @@ impl Task { /// the price of observing emptiness at all; the graveyard declines to /// pay it and keeps its zombies until `kill_sent` (see /// `Supervisor::reap`). - pub(crate) fn group_gone(&mut self) -> bool { + pub fn group_gone(&mut self) -> bool { let Some(pid) = self.pid else { // No pid was ever known: nothing waitable or signalable exists. return true; @@ -982,7 +718,7 @@ impl Task { impl Drop for Task { fn drop(&mut self) { // The last-resort backstop, not the policy point: guarantees no - // orphaned job tree regardless of how a Task leaves scope. Graceful + // orphaned task tree regardless of how a Task leaves scope. Graceful // TERM-first teardown happens above this, in the supervisor. The // collect is best-effort: an already-exited leader reaps instantly; one // still dying from the KILL reparents to init, which collects it. @@ -992,1154 +728,5 @@ impl Drop for Task { } #[cfg(test)] -mod tests { - use super::*; - use crate::protocol::MouseBtn; - use crate::testutil::{read_pid, temp, wait_until}; - - fn here() -> PathBuf { - std::env::current_dir().unwrap() - } - - /// Tests launch under this process's own env, the same fallback the - /// supervisor uses when no client context has arrived. - fn env_here() -> Vec<(OsString, OsString)> { - std::env::vars_os().collect() - } - - /// `env_here` with `SHELL` pinned to `/bin/sh` for portable background-job - /// behavior in process-group tests. - fn sh_env() -> Vec<(OsString, OsString)> { - let mut env = env_here(); - env.retain(|(k, _)| k != "SHELL"); - env.push(("SHELL".into(), "/bin/sh".into())); - env - } - - /// Tests drive the reader directly, so there is no core loop to wake. - fn no_waker() -> Waker { - Arc::new(Mutex::new(None)) - } - - fn spawn(id: u64, command: &str) -> Task { - Task::spawn( - id, - command, - command, - &here(), - 24, - 80, - 2000, - &env_here(), - no_waker(), - ) - .unwrap() - } - - fn wait_finished(t: &mut Task) { - assert!( - wait_until(Duration::from_secs(5), || { - t.poll_exit().unwrap(); - t.finished.is_some() - }), - "task never finished" - ); - } - - /// End-to-end plumbing: spawn under a PTY, the reader thread feeds the - /// emulator, the screen reflects the output, and the exit code is latched. - #[test] - fn spawn_reads_output_and_exits_zero() { - let mut t = spawn(1, "printf 'alpha\\nomega\\n'"); - let mut preview = String::new(); - wait_until(Duration::from_secs(5), || { - t.poll_exit().unwrap(); - preview = t.resolve_preview(Instant::now()).text; - t.finished.is_some() && preview.contains("omega") - }); - assert_eq!(t.exit_code, Some(0)); - assert!(preview.contains("omega"), "preview was {preview:?}"); - t.terminate(); - } - - #[test] - fn nonzero_exit_is_recorded() { - let mut t = spawn(2, "exit 3"); - wait_finished(&mut t); - assert_eq!(t.exit_code, Some(3)); - assert_eq!( - t.lifecycle(Instant::now(), Duration::from_secs(10)), - Lifecycle::Failed - ); - t.terminate(); - } - - /// Lifecycle and placement cross the shared quiet threshold together. - #[test] - fn lifecycle_and_parked_agree_across_the_window_edge() { - let mut t = spawn(5, "sleep 5"); - // `sleep` writes nothing, so `last_activity` keeps its spawn value - // and the injected `now`s measure against a fixed instant. - let quiet_since = *t.last_activity.lock().unwrap(); - let window = Duration::from_secs(10); - - let inside = quiet_since + Duration::from_secs(9); - assert_eq!(t.lifecycle(inside, window), Lifecycle::Active); - assert!(!t.parked(inside, window)); - - let past = quiet_since + Duration::from_secs(11); - assert_eq!(t.lifecycle(past, window), Lifecycle::Idle); - assert!(t.parked(past, window)); - t.terminate(); - } - - /// Repeated output before the quiet threshold keeps a task active. - #[test] - fn sub_window_quiet_gaps_never_read_as_idle() { - let mut t = spawn(7, "sleep 5"); - let window = Duration::from_secs(10); - let start = *t.last_activity.lock().unwrap(); - for gaps in 1..=4u32 { - let probe = start + Duration::from_secs(9) * gaps; - assert_eq!(t.lifecycle(probe, window), Lifecycle::Active); - assert!(!t.parked(probe, window)); - // Simulate output at the end of each quiet gap. - *t.last_activity.lock().unwrap() = probe; - } - t.terminate(); - } - - /// A finished task is never parked, no matter how long it has been quiet. - #[test] - fn finished_tasks_are_never_parked() { - let mut t = spawn(6, "exit 0"); - wait_finished(&mut t); - let now = *t.last_activity.lock().unwrap() + Duration::from_secs(11); - assert!(!t.parked(now, Duration::from_secs(10))); - t.terminate(); - } - - #[test] - fn resize_is_reflected_in_the_grid() { - let mut t = Task::spawn( - 3, - "sleep 5", - "sleep 5", - &here(), - 24, - 80, - 2000, - &env_here(), - no_waker(), - ) - .unwrap(); - t.resize(30, 100).unwrap(); - assert_eq!(t.parser.lock().size(), (30, 100)); - t.terminate(); - } - - /// The exit latch must not reap: after `finished` latches, the leader is - /// still a zombie (pid reserved, so the pgid stays valid for group - /// signals); `Drop` collects it and only then does the pid free up. - #[test] - fn exited_leader_stays_a_zombie_until_drop() { - use nix::sys::signal::kill; - let mut t = spawn(4, "exit 7"); - wait_finished(&mut t); - assert_eq!(t.exit_code, Some(7)); - let pid = Pid::from_raw(t.pid.expect("spawn always yields a pid") as i32); - // Signal 0 = existence check; a zombie still exists. - assert!( - kill(pid, None).is_ok(), - "leader was reaped by the latch; the pgid reservation is gone" - ); - drop(t); - // The zombie was already collectible, so Drop's collect is synchronous - // here: the pid is free immediately (barring an improbable instant - // recycle, which would fail this assertion spuriously, not silently). - assert!(kill(pid, None).is_err(), "Drop did not collect the zombie"); - } - - /// `terminate` reaches live group members after the leader exits. - #[test] - fn terminate_reaches_stragglers_after_leader_exit() { - use nix::sys::signal::kill; - let dir = temp("task_straggler"); - let spid = dir.join("spid"); - // `trap '' HUP` first: the ignore is inherited by the `&` child, which - // must survive its session leader's exit (leader death HUPs the - // foreground group) to *be* a straggler. - let cmd = format!("trap '' HUP; sleep 300 & echo $! > {}", spid.display()); - let mut t = - Task::spawn(5, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); - wait_finished(&mut t); // leader exits as soon as the background job is up - let straggler = read_pid(&spid); - assert!(kill(straggler, None).is_ok(), "straggler should be alive"); - - t.terminate(); // leader already finished: the group signal must still fire - assert!( - wait_until(Duration::from_secs(5), || kill(straggler, None).is_err()), - "TERM after leader exit never reached the straggler" - ); - let _ = std::fs::remove_dir_all(&dir); - } - - /// `collect` reports SIGKILL as shell exit code 137. - #[test] - fn killed_leader_latches_137_via_collect() { - let mut t = spawn(8, "sleep 300"); - t.force_kill(); // sets kill_sent, so try_collect may reap - assert!( - wait_until(Duration::from_secs(5), || t.try_collect()), - "KILLed leader was never collected" - ); - assert_eq!(t.exit_code, Some(137)); - } - - /// The shutdown probe reaps the exited leader, then probes the group in - /// the same pass: a zombie-only group turns gone in that one call. The - /// pre-reap assertions pin why the reap must come first: the zombie - /// alone keeps the group id resolvable for kill-style probes. - #[test] - fn group_gone_reaps_then_probes_past_the_zombie() { - use nix::errno::Errno; - let mut t = spawn(30, "exit 0"); - wait_finished(&mut t); - let pgid = Pid::from_raw(t.pid.expect("spawn always yields a pid") as i32); - // Zombie in place: the probe answer is Ok on Linux, EPERM on macOS, - // never ESRCH, so emptiness is invisible before the reap. - assert_ne!( - killpg(pgid, None::), - Err(Errno::ESRCH), - "an unreaped zombie must keep the group id resolvable" - ); - assert!( - t.group_gone(), - "a zombie-only group must probe gone in one reap+probe pass" - ); - // The probe spent the zombie: the group id no longer resolves. - assert_eq!(killpg(pgid, None::), Err(Errno::ESRCH)); - } - - /// A member that survives the leader holds the probe after the reap, - /// and the probe turns gone once that member dies. - #[test] - fn group_gone_holds_while_a_member_survives() { - use nix::sys::signal::kill; - let dir = temp("task_gone"); - let spid = dir.join("spid"); - // `trap '' HUP` first: the `&` child must survive its session - // leader's exit to be a straggler (see the terminate test above). - let cmd = format!("trap '' HUP; sleep 300 & echo $! > {}", spid.display()); - let mut t = - Task::spawn(31, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); - wait_finished(&mut t); - let straggler = read_pid(&spid); - - assert!(!t.group_gone(), "a surviving member must hold the probe"); - assert!(t.reaped, "the probe reaps the exited leader to see past it"); - - let _ = kill(straggler, Signal::SIGKILL); - assert!( - wait_until(Duration::from_secs(5), || t.group_gone()), - "the group must probe gone once its last member dies" - ); - let _ = std::fs::remove_dir_all(&dir); - } - - /// `finished` gates the zombie-spending reap: a leader that has not - /// exited is never reaped (or waited on) by the probe. - #[test] - fn group_gone_never_reaps_a_live_leader() { - let mut t = spawn(32, "sleep 300"); - assert!(!t.group_gone(), "a live leader is a live group"); - assert!(!t.reaped, "the probe must not reap a running leader"); - t.terminate(); - } - - /// Paste encoding follows the child's DECSET 2004 opt-in: markers only - /// when asked for, newline→CR conversion only when not. - #[test] - fn paste_wraps_only_when_child_opted_in() { - assert_eq!( - paste_bytes(true, b"hello"), - b"\x1b[200~hello\x1b[201~".to_vec() - ); - // Inside brackets the content rides verbatim: the child's own paste - // handling decides what a newline means. - assert_eq!( - paste_bytes(true, b"a\nb"), - b"\x1b[200~a\nb\x1b[201~".to_vec() - ); - assert_eq!(paste_bytes(false, b"hello"), b"hello".to_vec()); - } - - /// A clipboard containing the end marker must not terminate the paste - /// early: the remainder would arrive as live keystrokes. - #[test] - fn paste_strips_embedded_terminator() { - assert_eq!( - paste_bytes(true, b"safe\x1b[201~rm -rf /\n"), - b"\x1b[200~saferm -rf /\n\x1b[201~".to_vec() - ); - // Multiple embedded markers all go. - assert_eq!( - paste_bytes(true, b"\x1b[201~a\x1b[201~b\x1b[201~"), - b"\x1b[200~ab\x1b[201~".to_vec() - ); - } - - /// Legacy paste converts both `\r\n` and bare `\n` to the `\r` Enter sends, - /// without doubling a CRLF into two returns. - #[test] - fn legacy_paste_converts_line_endings() { - assert_eq!(paste_bytes(false, b"a\r\nb\nc\r"), b"a\rb\rc\r".to_vec()); - } - - /// Wheel routing follows the child's own escape sequences: nothing for an - /// inline child, alternate-scroll arrows for a full-screen one, real mouse - /// events once a protocol is requested, in the negotiated encoding. - #[test] - fn wheel_routes_by_child_state() { - let up = MouseKind::WheelUp; - let down = MouseKind::WheelDown; - let mut p = Emulator::new(24, 80, 0); - // Inline child, no mouse: dropped, not translated into arrow spam. - assert_eq!(mouse_bytes(&p, up, 0, 0), None); - // Full-screen child: three arrows per notch, normal cursor keys. - p.process(b"\x1b[?1049h"); - assert_eq!( - mouse_bytes(&p, up, 0, 0), - Some(b"\x1b[A\x1b[A\x1b[A".to_vec()) - ); - // Clicks mean nothing to a full-screen child without a mouse mode. - assert_eq!( - mouse_bytes(&p, MouseKind::Press(MouseBtn::Left), 0, 0), - None - ); - // Application cursor keys switch the arrows to SS3 form. - p.process(b"\x1b[?1h"); - assert_eq!( - mouse_bytes(&p, down, 0, 0), - Some(b"\x1bOB\x1bOB\x1bOB".to_vec()) - ); - // SGR mouse protocol: a real wheel event, 1-based coordinates. - p.process(b"\x1b[?1000h\x1b[?1006h"); - assert_eq!(mouse_bytes(&p, up, 4, 2), Some(b"\x1b[<64;5;3M".to_vec())); - // Default encoding: single-byte cells, clamped to fit. - p.process(b"\x1b[?1006l"); - assert_eq!( - mouse_bytes(&p, down, 0, 0), - Some(vec![0x1b, b'[', b'M', 32 + 65, 33, 33]) - ); - assert_eq!( - mouse_bytes(&p, down, 500, 500), - Some(vec![0x1b, b'[', b'M', 32 + 65, 255, 255]) - ); - // UTF-8 mouse coordinates can use multiple bytes. - p.process(b"\x1b[?1005h"); - assert_eq!( - mouse_bytes(&p, up, 200, 2), - Some(vec![0x1b, b'[', b'M', 32 + 64, 0xc3, 0xa9, 33 + 2]) - ); - // UTF-8 mouse coordinates cap at the protocol limit. - assert_eq!( - mouse_bytes(&p, up, 5000, 5000), - Some(vec![0x1b, b'[', b'M', 32 + 64, 0xdf, 0xbf, 0xdf, 0xbf]) - ); - } - - /// A full-screen child receives wheel arrows only while DECSET 1007 is - /// enabled; the mode defaults on. - #[test] - fn wheel_arrows_honor_decset_1007() { - let up = MouseKind::WheelUp; - let mut p = Emulator::new(24, 80, 0); - p.process(b"\x1b[?1049h\x1b[?1007l"); - assert_eq!(mouse_bytes(&p, up, 0, 0), None, "1007 off: no arrows"); - p.process(b"\x1b[?1007h"); - assert_eq!( - mouse_bytes(&p, up, 0, 0), - Some(b"\x1b[A\x1b[A\x1b[A".to_vec()), - "1007 back on: arrows resume" - ); - // A mouse protocol still outranks the gate: real wheel events. - p.process(b"\x1b[?1000h\x1b[?1006h"); - assert_eq!(mouse_bytes(&p, up, 0, 0), Some(b"\x1b[<64;1;1M".to_vec())); - } - - /// DECSET 1000/1002/1003 all report presses, releases, and wheel events; - /// only motion modes 1002 and 1003 report drags. SGR marks releases with - /// the `m` suffix and preserves the button code; the default and UTF-8 - /// encodings use code 3 for every release. - #[test] - fn buttons_respect_mode_granularity_and_encoding() { - let press = MouseKind::Press(MouseBtn::Left); - let drag = MouseKind::Drag(MouseBtn::Left); - let release = MouseKind::Release(MouseBtn::Left); - let wheel = MouseKind::WheelUp; - - for (mode, drags) in [(1000, false), (1002, true), (1003, true)] { - let mut p = Emulator::new(24, 80, 0); - p.process(format!("\x1b[?{mode}h").as_bytes()); - - // Default encoding: single-byte fields. - assert_eq!( - mouse_bytes(&p, press, 4, 2), - Some(vec![0x1b, b'[', b'M', 32, 33 + 4, 33 + 2]), - "mode {mode}: default press" - ); - assert_eq!( - mouse_bytes(&p, release, 4, 2), - Some(vec![0x1b, b'[', b'M', 32 + 3, 33 + 4, 33 + 2]), - "mode {mode}: default release" - ); - assert_eq!( - mouse_bytes(&p, wheel, 4, 2), - Some(vec![0x1b, b'[', b'M', 32 + 64, 33 + 4, 33 + 2]), - "mode {mode}: default wheel" - ); - assert_eq!( - mouse_bytes(&p, drag, 4, 2), - drags.then(|| vec![0x1b, b'[', b'M', 32 + 32, 33 + 4, 33 + 2]), - "mode {mode}: default drag" - ); - - // UTF-8 encoding: same codes, multi-byte coordinates. - p.process(b"\x1b[?1005h"); - assert_eq!( - mouse_bytes(&p, press, 200, 2), - Some(vec![0x1b, b'[', b'M', 32, 0xc3, 0xa9, 33 + 2]), - "mode {mode}: utf8 press" - ); - assert_eq!( - mouse_bytes(&p, release, 200, 2), - Some(vec![0x1b, b'[', b'M', 32 + 3, 0xc3, 0xa9, 33 + 2]), - "mode {mode}: utf8 release" - ); - assert_eq!( - mouse_bytes(&p, wheel, 200, 2), - Some(vec![0x1b, b'[', b'M', 32 + 64, 0xc3, 0xa9, 33 + 2]), - "mode {mode}: utf8 wheel" - ); - assert_eq!( - mouse_bytes(&p, drag, 200, 2), - drags.then(|| vec![0x1b, b'[', b'M', 32 + 32, 0xc3, 0xa9, 33 + 2]), - "mode {mode}: utf8 drag" - ); - - // SGR encoding: parameterized fields, release keeps its code. - p.process(b"\x1b[?1006h"); - assert_eq!( - mouse_bytes(&p, press, 4, 2), - Some(b"\x1b[<0;5;3M".to_vec()), - "mode {mode}: sgr press" - ); - assert_eq!( - mouse_bytes(&p, release, 4, 2), - Some(b"\x1b[<0;5;3m".to_vec()), - "mode {mode}: sgr release" - ); - assert_eq!( - mouse_bytes(&p, wheel, 4, 2), - Some(b"\x1b[<64;5;3M".to_vec()), - "mode {mode}: sgr wheel" - ); - assert_eq!( - mouse_bytes(&p, drag, 4, 2), - drags.then(|| b"\x1b[<32;5;3M".to_vec()), - "mode {mode}: sgr drag" - ); - } - } - - fn mods(shift: bool, alt: bool, ctrl: bool) -> Mods { - Mods { shift, alt, ctrl } - } - - /// Cursor and Home/End keys: application-cursor mode picks SS3 vs CSI for - /// the unmodified sequence, and any modifier forces the CSI `1;m` form even - /// in application mode. - #[test] - fn cursor_keys_encode_by_mode_and_modifier() { - let none = Mods::default(); - for (code, l) in [ - (Key::Up, 'A'), - (Key::Down, 'B'), - (Key::Right, 'C'), - (Key::Left, 'D'), - (Key::Home, 'H'), - (Key::End, 'F'), - ] { - assert_eq!( - key_bytes(false, code, none), - Some(format!("\x1b[{l}").into_bytes()), - "{code:?} normal", - ); - assert_eq!( - key_bytes(true, code, none), - Some(format!("\x1bO{l}").into_bytes()), - "{code:?} app-cursor", - ); - assert_eq!( - key_bytes(true, code, mods(false, true, false)), - Some(format!("\x1b[1;3{l}").into_bytes()), - "{code:?} alt forces CSI even in app mode", - ); - } - } - - /// The modifier parameter is `1 + shift + 2·alt + 4·ctrl`: shift=2, alt=3, - /// ctrl=5, ctrl+alt=7, all-three=8. - #[test] - fn modifier_param_formula() { - for (m, digit) in [ - (mods(true, false, false), '2'), - (mods(false, true, false), '3'), - (mods(false, false, true), '5'), - (mods(false, true, true), '7'), - (mods(true, true, true), '8'), - ] { - assert_eq!( - key_bytes(false, Key::Up, m), - Some(format!("\x1b[1;{digit}A").into_bytes()), - "param for {m:?}", - ); - } - } - - /// Application-cursor mode uses SS3 only for unmodified cursor keys. - #[test] - fn app_cursor_drives_unmodified_only() { - assert_eq!( - key_bytes(true, Key::Left, mods(false, true, false)), - Some(b"\x1b[1;3D".to_vec()), - ); - assert_eq!( - key_bytes(true, Key::Up, Mods::default()), - Some(b"\x1bOA".to_vec()), - ); - } - - /// The full F1–F12 table, including the terminfo gaps (no 16 between - /// F5=15 and F6=17; no 22 before F11=23) and the modified forms. - #[test] - fn function_keys_cover_the_terminfo_gaps() { - let none = Mods::default(); - for (n, seq) in [ - (1u8, b"\x1bOP".to_vec()), - (2, b"\x1bOQ".to_vec()), - (3, b"\x1bOR".to_vec()), - (4, b"\x1bOS".to_vec()), - (5, b"\x1b[15~".to_vec()), - (6, b"\x1b[17~".to_vec()), - (7, b"\x1b[18~".to_vec()), - (8, b"\x1b[19~".to_vec()), - (9, b"\x1b[20~".to_vec()), - (10, b"\x1b[21~".to_vec()), - (11, b"\x1b[23~".to_vec()), - (12, b"\x1b[24~".to_vec()), - ] { - assert_eq!(key_bytes(false, Key::F(n), none), Some(seq), "F{n}"); - } - // F1–F4 collapse to CSI `1;m`; F5–F12 splice m before the tilde. - assert_eq!( - key_bytes(false, Key::F(1), mods(true, false, false)), - Some(b"\x1b[1;2P".to_vec()), - ); - assert_eq!( - key_bytes(false, Key::F(5), mods(false, false, true)), - Some(b"\x1b[15;5~".to_vec()), - ); - assert_eq!( - key_bytes(false, Key::F(12), mods(false, true, false)), - Some(b"\x1b[24;3~".to_vec()), - ); - assert_eq!(key_bytes(false, Key::F(0), none), None); - assert_eq!(key_bytes(false, Key::F(13), none), None); - } - - /// The Insert/Delete/PageUp/PageDown cluster is CSI `~` regardless of - /// application-cursor mode. - #[test] - fn nav_cluster_is_mode_independent() { - for (code, n) in [ - (Key::Insert, 2), - (Key::Delete, 3), - (Key::PageUp, 5), - (Key::PageDown, 6), - ] { - assert_eq!( - key_bytes(false, code, Mods::default()), - Some(format!("\x1b[{n}~").into_bytes()), - "{code:?} normal", - ); - assert_eq!( - key_bytes(true, code, Mods::default()), - Some(format!("\x1b[{n}~").into_bytes()), - "{code:?} app-cursor unchanged", - ); - assert_eq!( - key_bytes(false, code, mods(false, false, true)), - Some(format!("\x1b[{n};5~").into_bytes()), - "{code:?} modified", - ); - } - } - - /// Supported Ctrl symbol/digit aliases produce their C0 control bytes. - #[test] - fn ctrl_symbol_and_digit_table() { - let ctrl = mods(false, false, true); - for (c, byte) in [ - (' ', 0x00), - ('@', 0x00), - ('2', 0x00), - ('[', 0x1b), - ('3', 0x1b), - ('\\', 0x1c), - ('4', 0x1c), - (']', 0x1d), - ('5', 0x1d), - ('^', 0x1e), - ('6', 0x1e), - ('_', 0x1f), - ('7', 0x1f), - ('/', 0x1f), - ('?', 0x7f), - ('8', 0x7f), - ] { - assert_eq!( - key_bytes(false, Key::Char(c), ctrl), - Some(vec![byte]), - "Ctrl+{c:?}", - ); - } - assert_eq!(key_bytes(false, Key::Char('1'), ctrl), None); - assert_eq!(key_bytes(false, Key::Char('9'), ctrl), None); - } - - /// Char encodings: plain UTF-8 (multibyte preserved), shift folded into the - /// char, Alt as an ESC prefix, and Ctrl+letter folding to its C0 control. - #[test] - fn char_alt_and_ctrl_letters() { - let none = Mods::default(); - assert_eq!(key_bytes(false, Key::Char('a'), none), Some(b"a".to_vec())); - assert_eq!( - key_bytes(false, Key::Char('é'), none), - Some("é".as_bytes().to_vec()), - ); - // Shift is already in the char; on its own it changes nothing. - assert_eq!( - key_bytes(false, Key::Char('A'), mods(true, false, false)), - Some(b"A".to_vec()), - ); - assert_eq!( - key_bytes(false, Key::Char('x'), mods(false, true, false)), - Some(b"\x1bx".to_vec()), - ); - assert_eq!( - key_bytes(false, Key::Char('a'), mods(false, false, true)), - Some(vec![0x01]), - ); - assert_eq!( - key_bytes(false, Key::Char('C'), mods(false, false, true)), - Some(vec![0x03]), - ); - assert_eq!( - key_bytes(false, Key::Char('z'), mods(false, false, true)), - Some(vec![0x1a]), - ); - assert_eq!( - key_bytes(false, Key::Char('c'), mods(false, true, true)), - Some(vec![0x1b, 0x03]), - ); - } - - /// Named keys and their modifier forms: keys with no distinct modified - /// encoding ignore an unsupported Ctrl/Shift (base sequence, never dropped) - /// and take the ESC-prefix meta form under Alt. - #[test] - fn named_keys_and_meta_prefixes() { - let none = Mods::default(); - assert_eq!(key_bytes(false, Key::Enter, none), Some(vec![0x0d])); - assert_eq!(key_bytes(false, Key::Tab, none), Some(vec![0x09])); - assert_eq!( - key_bytes(false, Key::BackTab, none), - Some(b"\x1b[Z".to_vec()) - ); - assert_eq!(key_bytes(false, Key::Backspace, none), Some(vec![0x7f])); - assert_eq!(key_bytes(false, Key::Esc, none), Some(vec![0x1b])); - // Shift or Alt Enter -> ESC CR; Alt+Backspace -> ESC DEL. - assert_eq!( - key_bytes(false, Key::Enter, mods(true, false, false)), - Some(b"\x1b\r".to_vec()), - ); - assert_eq!( - key_bytes(false, Key::Enter, mods(false, true, false)), - Some(b"\x1b\r".to_vec()), - ); - assert_eq!( - key_bytes(false, Key::Backspace, mods(false, true, false)), - Some(b"\x1b\x7f".to_vec()), - ); - // BackTab already is Shift+Tab: its inherent Shift is ignored; Alt - // meta-prefixes the CSI Z sequence. - assert_eq!( - key_bytes(false, Key::BackTab, mods(true, false, false)), - Some(b"\x1b[Z".to_vec()), - ); - assert_eq!( - key_bytes(false, Key::BackTab, mods(false, true, false)), - Some(b"\x1b\x1b[Z".to_vec()), - ); - // These keys have no distinct modified form: an unsupported Ctrl/Shift - // is ignored (base sequence, never dropped), and Alt is the ESC-prefix - // meta form. - assert_eq!( - key_bytes(false, Key::Enter, mods(false, false, true)), - Some(vec![0x0d]), - "Ctrl+Enter folds to CR", - ); - assert_eq!( - key_bytes(false, Key::Backspace, mods(false, false, true)), - Some(vec![0x7f]), - "Ctrl+Backspace folds to DEL", - ); - assert_eq!( - key_bytes(false, Key::Tab, mods(false, false, true)), - Some(vec![0x09]), - "Ctrl+Tab folds to HT", - ); - assert_eq!( - key_bytes(false, Key::Tab, mods(false, true, false)), - Some(vec![0x1b, 0x09]), - "Alt+Tab is ESC TAB", - ); - assert_eq!( - key_bytes(false, Key::Esc, mods(false, true, false)), - Some(vec![0x1b, 0x1b]), - "Alt+Esc is ESC ESC", - ); - assert_eq!( - key_bytes(false, Key::Esc, mods(false, false, true)), - Some(vec![0x1b]), - "Ctrl+Esc folds to ESC", - ); - } - - /// Scrollback clamps at both ends and input returns to live output. - #[test] - fn viewport_scrolls_and_snaps_live_on_input() { - let mut t = spawn(9, "cat"); - // Feed enough rows to create scrollback. - for i in 0..50 { - grid(&t.parser).process(format!("line{i}\r\n").as_bytes()); - } - assert_eq!(t.scroll_offset(), 0); - t.scroll_view(ScrollAction::Up(10)); - assert_eq!(t.scroll_offset(), 10); - t.scroll_view(ScrollAction::Down(4)); - assert_eq!(t.scroll_offset(), 6); - t.scroll_view(ScrollAction::Top); - let top = t.scroll_offset(); - assert!(top > 0); - assert!( - t.screen_lines()[0].starts_with("line0"), - "Top must show the oldest stored row, got {:?}", - t.screen_lines()[0] - ); - // Large upward movement clamps at the oldest row. - t.scroll_view(ScrollAction::Live); - t.scroll_view(ScrollAction::Up(10_000)); - assert_eq!(t.scroll_offset(), top); - // Input returns the viewport to live output. - t.send_input(b"x").unwrap(); - assert_eq!(t.scroll_offset(), 0); - t.terminate(); - } - - /// The per-task writer worker delivers queued messages in FIFO order. - #[test] - fn queued_writes_reach_the_child_in_order() { - let mut t = spawn(10, "cat"); - t.send_input(b"zqfirstqz\n").unwrap(); - t.send_input(b"zqsecondqz\n").unwrap(); - let mut contents = String::new(); - wait_until(Duration::from_secs(5), || { - contents = grid(&t.parser).contents(); - contents.contains("zqsecondqz") - }); - let first = contents - .find("zqfirstqz") - .expect("first message never echoed"); - let second = contents - .find("zqsecondqz") - .expect("second message never echoed"); - assert!(first < second, "queued writes reordered: {contents:?}"); - t.terminate(); - } - - /// Test writer that accepts writes within `limit` bytes, then fails. - struct FailingWriter { - limit: usize, - written: usize, - } - - impl Write for FailingWriter { - fn write(&mut self, buf: &[u8]) -> io::Result { - if self.written + buf.len() > self.limit { - return Err(io::Error::other("slave side closed")); - } - self.written += buf.len(); - Ok(buf.len()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } - - /// A write error stops delivery, not accounting: `pending` returns to - /// zero once the channel closes, including messages queued behind the - /// failure that never touch the writer. - #[test] - fn write_error_keeps_draining_the_pending_counter() { - let (tx, rx) = channel::>(); - let pending = AtomicUsize::new(0); - // Queue one successful write, one failure, and one discarded message. - let msgs: [&[u8]; 3] = [b"fits", b"fails", b"queued-behind"]; - for msg in msgs { - admit_write(&tx, &pending, msg.to_vec()).unwrap(); - } - let total: usize = msgs.iter().map(|m| m.len()).sum(); - assert_eq!(pending.load(Ordering::Acquire), total); - // Closing the channel lets the worker finish draining. - drop(tx); - let mut w = FailingWriter { - limit: msgs[0].len(), - written: 0, - }; - drain_writes(rx, &mut w, &pending); - assert_eq!( - pending.load(Ordering::Acquire), - 0, - "accounting must survive a dead writer" - ); - assert_eq!( - w.written, - msgs[0].len(), - "post-error messages must be discarded, not written" - ); - } - - /// Input hints track mouse, alternate-screen, and DECSET 1007 modes. - #[test] - fn input_hints_track_child_modes() { - let mut t = spawn(8, "sleep 5"); - assert_eq!(t.input_hints(), (false, false, false)); - grid(&t.parser).process(b"\x1b[?1000h"); - assert_eq!(t.input_hints(), (true, false, false)); - grid(&t.parser).process(b"\x1b[?1000l\x1b[?1049h"); - assert_eq!(t.input_hints(), (false, true, true)); - grid(&t.parser).process(b"\x1b[?1007l"); - assert_eq!(t.input_hints(), (false, true, false)); - t.terminate(); - } - - /// Holding the grid lock after process exit blocks reader EOF, which must - /// also block exit-hint scraping. - #[test] - fn scrape_exit_hint_waits_for_reader_eof() { - const ID: &str = "c8c4a5cc-0b32-4ba0-a6b4-6ed08c218e0d"; - let dir = temp("task_scrape"); - let flag = dir.join("flag"); - let cmd = format!( - "until [ -e '{f}' ]; do sleep 0.05; done; \ - printf 'Resume this session with:\\nclaude --resume {ID}\\n'", - f = flag.display() - ); - let mut t = - Task::spawn(20, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); - t.harness = Some(&crate::harness::Claude); - - // Hold the grid before output so the reader cannot process bytes or - // observe EOF. - let parser = Arc::clone(&t.parser); - let guard = parser.lock(); - std::fs::write(&flag, b"").unwrap(); - // The process can exit while its hint remains blocked in the reader. - // The long deadline bounds failure without constraining loaded CI. - assert!( - wait_until(Duration::from_secs(60), || { - t.poll_exit().unwrap(); - t.finished.is_some() - }), - "child never exited" - ); - t.scrape_exit_hint(); - assert_eq!(t.scraped_id, None, "the scrape must wait for reader EOF"); - - // Release the reader so it can parse the hint and reach EOF. - drop(guard); - wait_until(Duration::from_secs(60), || { - t.scrape_exit_hint(); - t.scraped_id.is_some() - }); - assert_eq!(t.scraped_id.as_deref(), Some(ID)); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A child that dies with a `?2026` frame still open leaves its hint - /// buffered in the parser, and no ESU can ever arrive to release it: the - /// scrape must land the frame instead of reading pre-frame text. - #[test] - fn scrape_exit_hint_lands_an_open_sync_frame() { - const ID: &str = "7f3b9c1e-5a2d-4e8f-9b6a-0c4d2e8f1a3b"; - let cmd = - format!("printf '\\033[?2026hResume this session with:\\nclaude --resume {ID}\\n'"); - let mut t = - Task::spawn(21, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); - t.harness = Some(&crate::harness::Claude); - assert!( - wait_until(Duration::from_secs(60), || { - t.poll_exit().unwrap(); - t.finished.is_some() && t.reader_done() - }), - "child never exited" - ); - assert!( - !grid(&t.parser).text_with_history().contains(ID), - "premise: the unclosed frame still buffers the hint at scrape time" - ); - t.scrape_exit_hint(); - assert_eq!(t.scraped_id.as_deref(), Some(ID)); - } - - /// Primary-screen finalization re-resolves: a final line that lands - /// after the last resolution tick (here: after the only pre-exit - /// resolve) still reaches the frozen floor. - #[test] - fn finalize_preview_freezes_the_final_primary_line() { - use crate::preview::PreviewSource; - let dir = temp("task_final_primary"); - let flag = dir.join("flag"); - let cmd = format!( - "until [ -e '{}' ]; do sleep 0.05; done; printf 'test result: ok\\n'", - flag.display() - ); - let mut t = - Task::spawn(40, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); - // The last live resolution predates every byte of output. - let early = t.resolve_preview(Instant::now()); - assert!(!early.frozen); - std::fs::write(&flag, b"").unwrap(); - assert!( - wait_until(Duration::from_secs(60), || { - t.poll_exit().unwrap(); - t.output_complete() - }), - "child never completed" - ); - t.finalize_preview(); - let p = t.resolve_preview(Instant::now()); - assert_eq!( - (p.text.as_str(), p.source, p.frozen), - ("test result: ok", PreviewSource::Floor, true) - ); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A resolution after 1049l but before reader EOF retains and freezes the - /// alternate-screen title when the restored primary floor is unchanged. - #[test] - fn finalize_preview_keeps_the_last_render_across_alt_teardown() { - use crate::preview::PreviewSource; - let dir = temp("task_final_alt"); - let teardown = dir.join("teardown"); - let exit = dir.join("exit"); - let cmd = format!( - "printf 'prelaunch junk\\n'; \ - printf '\\033[?1049h\\033]0;working\\007app body'; \ - until [ -e '{td}' ]; do sleep 0.05; done; printf '\\033[?1049l'; \ - until [ -e '{ex}' ]; do sleep 0.05; done", - td = teardown.display(), - ex = exit.display() - ); - let mut t = - Task::spawn(41, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); - assert!( - wait_until(Duration::from_secs(5), || { - t.resolve_preview(Instant::now()).source == PreviewSource::Title - }), - "title never rendered" - ); - std::fs::write(&teardown, b"").unwrap(); - assert!( - wait_until(Duration::from_secs(60), || { - !grid(&t.parser).alternate_screen() - }), - "teardown never reached the grid" - ); - // Resolve against the restored primary screen before reader EOF. The - // demotion hold retains the alternate-screen title and mode stamp. - assert_eq!( - t.resolve_preview(Instant::now()).source, - PreviewSource::Title, - "premise: the demotion hold keeps the title rendered" - ); - std::fs::write(&exit, b"").unwrap(); - assert!( - wait_until(Duration::from_secs(60), || { - t.poll_exit().unwrap(); - t.output_complete() - }), - "child never completed" - ); - t.finalize_preview(); - assert_eq!( - grid(&t.parser).live_floor(), - "prelaunch junk", - "premise: 1049l restored the pre-launch primary screen" - ); - let p = t.resolve_preview(Instant::now()); - assert_eq!( - (p.text.as_str(), p.source, p.frozen), - ("working", PreviewSource::Title, true) - ); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Alternate-screen teardown followed by primary output freezes the - /// primary line even when both are written together inside the demotion - /// hold. - #[test] - fn finalize_preview_freezes_primary_output_after_alt_teardown() { - use crate::preview::PreviewSource; - let dir = temp("task_final_alt_output"); - let flag = dir.join("flag"); - let cmd = format!( - "printf 'prelaunch junk\\n'; \ - printf '\\033[?1049h\\033]0;working\\007app body'; \ - until [ -e '{}' ]; do sleep 0.05; done; \ - printf '\\033[?1049ldone\\n'", - flag.display() - ); - let mut t = - Task::spawn(43, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); - assert!( - wait_until(Duration::from_secs(5), || { - t.resolve_preview(Instant::now()).source == PreviewSource::Title - }), - "title never rendered" - ); - std::fs::write(&flag, b"").unwrap(); - assert!( - wait_until(Duration::from_secs(60), || { - t.poll_exit().unwrap(); - t.output_complete() - }), - "child never completed" - ); - t.finalize_preview(); - let p = t.resolve_preview(Instant::now()); - assert_eq!( - (p.text.as_str(), p.source, p.frozen), - ("done", PreviewSource::Floor, true), - "the post-teardown line must win over the stale title" - ); - let _ = std::fs::remove_dir_all(&dir); - } - - /// End-to-end adapter path: a PTY screen resolves as a Codex anchor while - /// live and after exit. The test installs the adapter directly because the - /// child command is `printf`. - #[test] - fn summary_adapter_anchors_live_and_freezes_completion_at_exit() { - use crate::preview::PreviewSource; - let dir = temp("task_anchor_e2e"); - let flag = dir.join("flag"); - let cmd = format!( - "printf '• Working (3s • esc to interrupt)\\n\\n› \\n synth-model high · 1 in · 2 out'; \ - until [ -e '{f}' ]; do sleep 0.05; done; \ - printf '\\033[H\\033[2J• Ran echo ok\\n\\n› \\n synth-model high · 2 in · 3 out'", - f = flag.display() - ); - let mut t = - Task::spawn(42, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); - assert!(t.summary_adapter.is_none(), "printf selects nothing"); - t.summary_adapter = crate::harness::summary::select("codex"); - assert!(t.summary_adapter.is_some()); - - let mut live = t.resolve_preview(Instant::now()); - assert!( - wait_until(Duration::from_secs(5), || { - live = t.resolve_preview(Instant::now()); - live.source == PreviewSource::Anchor - }), - "anchor never resolved, last preview {live:?}" - ); - assert_eq!( - (live.text.as_str(), live.rule, live.frozen), - ("synth-model high · Working", Some("codex:working"), false) - ); - - std::fs::write(&flag, b"").unwrap(); - assert!( - wait_until(Duration::from_secs(60), || { - t.poll_exit().unwrap(); - t.output_complete() - }), - "child never completed" - ); - t.finalize_preview(); - let p = t.resolve_preview(Instant::now()); - assert_eq!( - (p.text.as_str(), p.source, p.rule, p.frozen), - ( - "synth-model high · Ran echo ok", - PreviewSource::Anchor, - Some("codex:ran"), - true - ) - ); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A child's cursor-position probe is answered on the wire: the reply - /// crosses the reader thread → allowlist → writer worker → PTY, and only - /// the advertised shape arrives. The child first sends secondary DA (a - /// denied probe), then primary DA and DSR 6; it reads 11 bytes: exactly - /// primary DA (5) plus CPR (6). If the secondary-DA reply leaked, those - /// bytes would arrive first and the assertion would see `ESC[>...`. - #[test] - fn probe_replies_reach_the_child_through_the_allowlist() { - let dir = temp("task_probe"); - let out = dir.join("out"); - // Raw-ish input: the CPR reply has no newline, so canonical mode - // would never hand it to the child. - let cmd = format!( - "stty -icanon -echo min 1 time 0; printf '\\033[>c\\033[c\\033[6n'; \ - head -c 11 > {}", - out.display() - ); - let mut t = - Task::spawn(11, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); - let mut got = Vec::new(); - wait_until(Duration::from_secs(5), || { - got = std::fs::read(&out).unwrap_or_default(); - got.len() >= 11 - }); - assert!( - got.starts_with(b"\x1b[?6c\x1b["), - "child must read the primary DA reply first (no secondary-DA \ - leak); got {got:?}" - ); - assert!( - got.ends_with(b"R"), - "CPR reply must follow the DA reply; got {got:?}" - ); - t.terminate(); - let _ = std::fs::remove_dir_all(&dir); - } -} +#[path = "task_tests.rs"] +mod tests; diff --git a/src/task_tests.rs b/src/task_tests.rs new file mode 100644 index 0000000..2fd7a87 --- /dev/null +++ b/src/task_tests.rs @@ -0,0 +1,648 @@ +use super::*; +use crate::testutil::{env_here, here, read_pid, sh_env, temp, wait_until}; + +/// Tests drive the reader directly, so there is no core loop to wake. +fn no_waker() -> Waker { + Arc::new(Mutex::new(None)) +} + +fn spawn(id: u64, command: &str) -> Task { + Task::spawn( + id, + command, + command, + &here(), + 24, + 80, + 2000, + &env_here(), + no_waker(), + ) + .unwrap() +} + +fn wait_finished(t: &mut Task) { + assert!( + wait_until(Duration::from_secs(5), || { + t.poll_exit().unwrap(); + t.finished.is_some() + }), + "task never finished" + ); +} + +/// End-to-end plumbing: spawn under a PTY, the reader thread feeds the +/// emulator, the screen reflects the output, and the exit code is latched. +#[test] +fn spawn_reads_output_and_exits_zero() { + let mut t = spawn(1, "printf 'alpha\\nomega\\n'"); + let mut preview = String::new(); + wait_until(Duration::from_secs(5), || { + t.poll_exit().unwrap(); + preview = t.resolve_preview(Instant::now()).text; + t.finished.is_some() && preview.contains("omega") + }); + assert_eq!(t.exit_code, Some(0)); + assert!(preview.contains("omega"), "preview was {preview:?}"); + t.terminate(); +} + +#[test] +fn nonzero_exit_is_recorded() { + let mut t = spawn(2, "exit 3"); + wait_finished(&mut t); + assert_eq!(t.exit_code, Some(3)); + assert_eq!( + t.lifecycle(Instant::now(), Duration::from_secs(10)), + Lifecycle::Failed + ); + t.terminate(); +} + +/// Lifecycle and placement cross the shared quiet threshold together. +#[test] +fn lifecycle_and_parked_agree_across_the_window_edge() { + let mut t = spawn(5, "sleep 5"); + // `sleep` writes nothing, so `last_activity` keeps its spawn value + // and the injected `now`s measure against a fixed instant. + let quiet_since = *t.last_activity.lock().unwrap(); + let window = Duration::from_secs(10); + + let inside = quiet_since + Duration::from_secs(9); + assert_eq!(t.lifecycle(inside, window), Lifecycle::Active); + assert!(!t.parked(inside, window)); + + let past = quiet_since + Duration::from_secs(11); + assert_eq!(t.lifecycle(past, window), Lifecycle::Idle); + assert!(t.parked(past, window)); + t.terminate(); +} + +/// Repeated output before the quiet threshold keeps a task active. +#[test] +fn sub_window_quiet_gaps_never_read_as_idle() { + let mut t = spawn(7, "sleep 5"); + let window = Duration::from_secs(10); + let start = *t.last_activity.lock().unwrap(); + for gaps in 1..=4u32 { + let probe = start + Duration::from_secs(9) * gaps; + assert_eq!(t.lifecycle(probe, window), Lifecycle::Active); + assert!(!t.parked(probe, window)); + // Simulate output at the end of each quiet gap. + *t.last_activity.lock().unwrap() = probe; + } + t.terminate(); +} + +/// A finished task is never parked, no matter how long it has been quiet. +#[test] +fn finished_tasks_are_never_parked() { + let mut t = spawn(6, "exit 0"); + wait_finished(&mut t); + let now = *t.last_activity.lock().unwrap() + Duration::from_secs(11); + assert!(!t.parked(now, Duration::from_secs(10))); + t.terminate(); +} + +#[test] +fn resize_is_reflected_in_the_grid() { + let mut t = Task::spawn( + 3, + "sleep 5", + "sleep 5", + &here(), + 24, + 80, + 2000, + &env_here(), + no_waker(), + ) + .unwrap(); + t.resize(30, 100).unwrap(); + assert_eq!(t.parser.lock().size(), (30, 100)); + t.terminate(); +} + +/// The exit latch must not reap: after `finished` latches, the leader is +/// still a zombie (pid reserved, so the pgid stays valid for group +/// signals); `Drop` collects it and only then does the pid free up. +#[test] +fn exited_leader_stays_a_zombie_until_drop() { + use nix::sys::signal::kill; + let mut t = spawn(4, "exit 7"); + wait_finished(&mut t); + assert_eq!(t.exit_code, Some(7)); + let pid = Pid::from_raw(t.pid.expect("spawn always yields a pid") as i32); + // Signal 0 = existence check; a zombie still exists. + assert!( + kill(pid, None).is_ok(), + "leader was reaped by the latch; the pgid reservation is gone" + ); + drop(t); + // The zombie was already collectible, so Drop's collect is synchronous + // here: the pid is free immediately (barring an improbable instant + // recycle, which would fail this assertion spuriously, not silently). + assert!(kill(pid, None).is_err(), "Drop did not collect the zombie"); +} + +/// `terminate` reaches live group members after the leader exits. +#[test] +fn terminate_reaches_stragglers_after_leader_exit() { + use nix::sys::signal::kill; + let dir = temp("task_straggler"); + let spid = dir.join("spid"); + // `trap '' HUP` first: the ignore is inherited by the `&` child, which + // must survive its session leader's exit (leader death HUPs the + // foreground group) to *be* a straggler. + let cmd = format!("trap '' HUP; sleep 300 & echo $! > {}", spid.display()); + let mut t = Task::spawn(5, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); + wait_finished(&mut t); // leader exits as soon as the background job is up + let straggler = read_pid(&spid); + assert!(kill(straggler, None).is_ok(), "straggler should be alive"); + + t.terminate(); // leader already finished: the group signal must still fire + assert!( + wait_until(Duration::from_secs(5), || kill(straggler, None).is_err()), + "TERM after leader exit never reached the straggler" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// `collect` reports SIGKILL as shell exit code 137. +#[test] +fn killed_leader_latches_137_via_collect() { + let mut t = spawn(8, "sleep 300"); + t.force_kill(); // sets kill_sent, so try_collect may reap + assert!( + wait_until(Duration::from_secs(5), || t.try_collect()), + "KILLed leader was never collected" + ); + assert_eq!(t.exit_code, Some(137)); +} + +/// The shutdown probe reaps the exited leader, then probes the group in +/// the same pass: a zombie-only group turns gone in that one call. The +/// pre-reap assertions pin why the reap must come first: the zombie +/// alone keeps the group id resolvable for kill-style probes. +#[test] +fn group_gone_reaps_then_probes_past_the_zombie() { + use nix::errno::Errno; + let mut t = spawn(30, "exit 0"); + wait_finished(&mut t); + let pgid = Pid::from_raw(t.pid.expect("spawn always yields a pid") as i32); + // Zombie in place: the probe answer is Ok on Linux, EPERM on macOS, + // never ESRCH, so emptiness is invisible before the reap. + assert_ne!( + killpg(pgid, None::), + Err(Errno::ESRCH), + "an unreaped zombie must keep the group id resolvable" + ); + assert!( + t.group_gone(), + "a zombie-only group must probe gone in one reap+probe pass" + ); + // The probe spent the zombie: the group id no longer resolves. + assert_eq!(killpg(pgid, None::), Err(Errno::ESRCH)); +} + +/// A member that survives the leader holds the probe after the reap, +/// and the probe turns gone once that member dies. +#[test] +fn group_gone_holds_while_a_member_survives() { + use nix::sys::signal::kill; + let dir = temp("task_gone"); + let spid = dir.join("spid"); + // `trap '' HUP` first so the background child survives its session + // leader's exit and remains available for the group probe. + let cmd = format!("trap '' HUP; sleep 300 & echo $! > {}", spid.display()); + let mut t = Task::spawn(31, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); + wait_finished(&mut t); + let straggler = read_pid(&spid); + + assert!(!t.group_gone(), "a surviving member must hold the probe"); + assert!(t.reaped, "the probe reaps the exited leader to see past it"); + + let _ = kill(straggler, Signal::SIGKILL); + assert!( + wait_until(Duration::from_secs(5), || t.group_gone()), + "the group must probe gone once its last member dies" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// `finished` gates the zombie-spending reap: a leader that has not +/// exited is never reaped (or waited on) by the probe. +#[test] +fn group_gone_never_reaps_a_live_leader() { + let mut t = spawn(32, "sleep 300"); + assert!(!t.group_gone(), "a live leader is a live group"); + assert!(!t.reaped, "the probe must not reap a running leader"); + t.terminate(); +} + +/// Scrollback clamps at both ends and input returns to live output. +#[test] +fn viewport_scrolls_and_snaps_live_on_input() { + let mut t = spawn(9, "cat"); + // Feed enough rows to create scrollback. + for i in 0..50 { + grid(&t.parser).process(format!("line{i}\r\n").as_bytes()); + } + assert_eq!(t.scroll_offset(), 0); + t.scroll_view(ScrollAction::Up(10)); + assert_eq!(t.scroll_offset(), 10); + t.scroll_view(ScrollAction::Down(4)); + assert_eq!(t.scroll_offset(), 6); + t.scroll_view(ScrollAction::Top); + let top = t.scroll_offset(); + assert!(top > 0); + assert!( + t.screen_lines()[0].starts_with("line0"), + "Top must show the oldest stored row, got {:?}", + t.screen_lines()[0] + ); + // Large upward movement clamps at the oldest row. + t.scroll_view(ScrollAction::Live); + t.scroll_view(ScrollAction::Up(10_000)); + assert_eq!(t.scroll_offset(), top); + // Input returns the viewport to live output. + t.send_input(b"x").unwrap(); + assert_eq!(t.scroll_offset(), 0); + t.terminate(); +} + +/// The per-task writer worker delivers queued messages in FIFO order. +#[test] +fn queued_writes_reach_the_child_in_order() { + let mut t = spawn(10, "cat"); + t.send_input(b"zqfirstqz\n").unwrap(); + t.send_input(b"zqsecondqz\n").unwrap(); + let mut contents = String::new(); + wait_until(Duration::from_secs(5), || { + contents = grid(&t.parser).contents(); + contents.contains("zqsecondqz") + }); + let first = contents + .find("zqfirstqz") + .expect("first message never echoed"); + let second = contents + .find("zqsecondqz") + .expect("second message never echoed"); + assert!(first < second, "queued writes reordered: {contents:?}"); + t.terminate(); +} + +/// Test writer that accepts writes within `limit` bytes, then fails. +struct FailingWriter { + limit: usize, + written: usize, +} + +impl Write for FailingWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + if self.written + buf.len() > self.limit { + return Err(io::Error::other("slave side closed")); + } + self.written += buf.len(); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +/// A write error stops delivery, not accounting: `pending` returns to +/// zero once the channel closes, including messages queued behind the +/// failure that never touch the writer. +#[test] +fn write_error_keeps_draining_the_pending_counter() { + let (tx, rx) = channel::>(); + let pending = AtomicUsize::new(0); + // Queue one successful write, one failure, and one discarded message. + let msgs: [&[u8]; 3] = [b"fits", b"fails", b"queued-behind"]; + for msg in msgs { + admit_write(&tx, &pending, msg.to_vec()).unwrap(); + } + let total: usize = msgs.iter().map(|m| m.len()).sum(); + assert_eq!(pending.load(Ordering::Acquire), total); + // Closing the channel lets the worker finish draining. + drop(tx); + let mut w = FailingWriter { + limit: msgs[0].len(), + written: 0, + }; + drain_writes(rx, &mut w, &pending); + assert_eq!( + pending.load(Ordering::Acquire), + 0, + "accounting must survive a dead writer" + ); + assert_eq!( + w.written, + msgs[0].len(), + "post-error messages must be discarded, not written" + ); +} + +/// Input hints track mouse, alternate-screen, and DECSET 1007 modes. +#[test] +fn input_hints_track_child_modes() { + let mut t = spawn(8, "sleep 5"); + assert_eq!(t.input_hints(), (false, false, false)); + grid(&t.parser).process(b"\x1b[?1000h"); + assert_eq!(t.input_hints(), (true, false, false)); + grid(&t.parser).process(b"\x1b[?1000l\x1b[?1049h"); + assert_eq!(t.input_hints(), (false, true, true)); + grid(&t.parser).process(b"\x1b[?1007l"); + assert_eq!(t.input_hints(), (false, true, false)); + t.terminate(); +} + +/// Holding the grid lock after process exit blocks reader EOF, which must +/// also block exit-hint scraping. +#[test] +fn scrape_exit_hint_waits_for_reader_eof() { + const ID: &str = "c8c4a5cc-0b32-4ba0-a6b4-6ed08c218e0d"; + let dir = temp("task_scrape"); + let flag = dir.join("flag"); + let cmd = format!( + "until [ -e '{f}' ]; do sleep 0.05; done; \ + printf 'Resume this session with:\\nclaude --resume {ID}\\n'", + f = flag.display() + ); + let mut t = Task::spawn(20, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); + t.harness = Some(&crate::harness::Claude); + + // Hold the grid before output so the reader cannot process bytes or + // observe EOF. + let parser = Arc::clone(&t.parser); + let guard = parser.lock(); + std::fs::write(&flag, b"").unwrap(); + // The process can exit while its hint remains blocked in the reader. + // The long deadline bounds failure without constraining loaded CI. + assert!( + wait_until(Duration::from_secs(60), || { + t.poll_exit().unwrap(); + t.finished.is_some() + }), + "child never exited" + ); + t.scrape_exit_hint(); + assert_eq!(t.scraped_id, None, "the scrape must wait for reader EOF"); + + // Release the reader so it can parse the hint and reach EOF. + drop(guard); + wait_until(Duration::from_secs(60), || { + t.scrape_exit_hint(); + t.scraped_id.is_some() + }); + assert_eq!(t.scraped_id.as_deref(), Some(ID)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A child that dies with a `?2026` frame still open leaves its hint +/// buffered in the parser, and no ESU can ever arrive to release it: the +/// scrape must land the frame instead of reading pre-frame text. +#[test] +fn scrape_exit_hint_lands_an_open_sync_frame() { + const ID: &str = "7f3b9c1e-5a2d-4e8f-9b6a-0c4d2e8f1a3b"; + let cmd = format!("printf '\\033[?2026hResume this session with:\\nclaude --resume {ID}\\n'"); + let mut t = Task::spawn(21, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); + t.harness = Some(&crate::harness::Claude); + assert!( + wait_until(Duration::from_secs(60), || { + t.poll_exit().unwrap(); + t.finished.is_some() && t.reader_done() + }), + "child never exited" + ); + assert!( + !grid(&t.parser).text_with_history().contains(ID), + "premise: the unclosed frame still buffers the hint at scrape time" + ); + t.scrape_exit_hint(); + assert_eq!(t.scraped_id.as_deref(), Some(ID)); +} + +/// Primary-screen finalization re-resolves: a final line that lands +/// after the last resolution tick (here: after the only pre-exit +/// resolve) still reaches the frozen floor. +#[test] +fn finalize_preview_freezes_the_final_primary_line() { + use crate::protocol::PreviewSource; + let dir = temp("task_final_primary"); + let flag = dir.join("flag"); + let cmd = format!( + "until [ -e '{}' ]; do sleep 0.05; done; printf 'test result: ok\\n'", + flag.display() + ); + let mut t = Task::spawn(40, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); + // The last live resolution predates every byte of output. + let early = t.resolve_preview(Instant::now()); + assert!(!early.frozen); + std::fs::write(&flag, b"").unwrap(); + assert!( + wait_until(Duration::from_secs(60), || { + t.poll_exit().unwrap(); + t.output_complete() + }), + "child never completed" + ); + t.finalize_preview(); + let p = t.resolve_preview(Instant::now()); + assert_eq!( + (p.text.as_str(), p.source, p.frozen), + ("test result: ok", PreviewSource::Floor, true) + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A resolution after 1049l but before reader EOF retains and freezes the +/// alternate-screen title when the restored primary floor is unchanged. +#[test] +fn finalize_preview_keeps_the_last_render_across_alt_teardown() { + use crate::protocol::PreviewSource; + let dir = temp("task_final_alt"); + let teardown = dir.join("teardown"); + let exit = dir.join("exit"); + let cmd = format!( + "printf 'prelaunch junk\\n'; \ + printf '\\033[?1049h\\033]0;working\\007app body'; \ + until [ -e '{td}' ]; do sleep 0.05; done; printf '\\033[?1049l'; \ + until [ -e '{ex}' ]; do sleep 0.05; done", + td = teardown.display(), + ex = exit.display() + ); + let mut t = Task::spawn(41, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); + assert!( + wait_until(Duration::from_secs(5), || { + t.resolve_preview(Instant::now()).source == PreviewSource::Title + }), + "title never rendered" + ); + std::fs::write(&teardown, b"").unwrap(); + assert!( + wait_until(Duration::from_secs(60), || { + !grid(&t.parser).alternate_screen() + }), + "teardown never reached the grid" + ); + // Resolve against the restored primary screen before reader EOF. The + // demotion hold retains the alternate-screen title and mode stamp. + assert_eq!( + t.resolve_preview(Instant::now()).source, + PreviewSource::Title, + "premise: the demotion hold keeps the title rendered" + ); + std::fs::write(&exit, b"").unwrap(); + assert!( + wait_until(Duration::from_secs(60), || { + t.poll_exit().unwrap(); + t.output_complete() + }), + "child never completed" + ); + t.finalize_preview(); + assert_eq!( + grid(&t.parser).live_floor(), + "prelaunch junk", + "premise: 1049l restored the pre-launch primary screen" + ); + let p = t.resolve_preview(Instant::now()); + assert_eq!( + (p.text.as_str(), p.source, p.frozen), + ("working", PreviewSource::Title, true) + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Alternate-screen teardown followed by primary output freezes the +/// primary line even when both are written together inside the demotion +/// hold. +#[test] +fn finalize_preview_freezes_primary_output_after_alt_teardown() { + use crate::protocol::PreviewSource; + let dir = temp("task_final_alt_output"); + let flag = dir.join("flag"); + let cmd = format!( + "printf 'prelaunch junk\\n'; \ + printf '\\033[?1049h\\033]0;working\\007app body'; \ + until [ -e '{}' ]; do sleep 0.05; done; \ + printf '\\033[?1049ldone\\n'", + flag.display() + ); + let mut t = Task::spawn(43, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); + assert!( + wait_until(Duration::from_secs(5), || { + t.resolve_preview(Instant::now()).source == PreviewSource::Title + }), + "title never rendered" + ); + std::fs::write(&flag, b"").unwrap(); + assert!( + wait_until(Duration::from_secs(60), || { + t.poll_exit().unwrap(); + t.output_complete() + }), + "child never completed" + ); + t.finalize_preview(); + let p = t.resolve_preview(Instant::now()); + assert_eq!( + (p.text.as_str(), p.source, p.frozen), + ("done", PreviewSource::Floor, true), + "the post-teardown line must win over the stale title" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// End-to-end adapter path: a PTY screen resolves as a Codex anchor while +/// live and after exit. The test installs the adapter directly because the +/// child command is `printf`. +#[test] +fn summary_adapter_anchors_live_and_freezes_completion_at_exit() { + use crate::protocol::PreviewSource; + let dir = temp("task_anchor_e2e"); + let flag = dir.join("flag"); + let cmd = format!( + "printf '• Working (3s • esc to interrupt)\\n\\n› \\n synth-model high · 1 in · 2 out'; \ + until [ -e '{f}' ]; do sleep 0.05; done; \ + printf '\\033[H\\033[2J• Ran echo ok\\n\\n› \\n synth-model high · 2 in · 3 out'", + f = flag.display() + ); + let mut t = Task::spawn(42, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); + assert!(t.summary_adapter.is_none(), "printf selects nothing"); + t.summary_adapter = crate::harness::summary::select("codex"); + assert!(t.summary_adapter.is_some()); + + let mut live = t.resolve_preview(Instant::now()); + assert!( + wait_until(Duration::from_secs(5), || { + live = t.resolve_preview(Instant::now()); + live.source == PreviewSource::Anchor + }), + "anchor never resolved, last preview {live:?}" + ); + assert_eq!( + (live.text.as_str(), live.rule, live.frozen), + ("synth-model high · Working", Some("codex:working"), false) + ); + + std::fs::write(&flag, b"").unwrap(); + assert!( + wait_until(Duration::from_secs(60), || { + t.poll_exit().unwrap(); + t.output_complete() + }), + "child never completed" + ); + t.finalize_preview(); + let p = t.resolve_preview(Instant::now()); + assert_eq!( + (p.text.as_str(), p.source, p.rule, p.frozen), + ( + "synth-model high · Ran echo ok", + PreviewSource::Anchor, + Some("codex:ran"), + true + ) + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A child's cursor-position probe is answered on the wire: the reply +/// crosses the reader thread → allowlist → writer worker → PTY, and only +/// the advertised shape arrives. The child first sends secondary DA (a +/// denied probe), then primary DA and DSR 6; it reads 11 bytes: exactly +/// primary DA (5) plus CPR (6). If the secondary-DA reply leaked, those +/// bytes would arrive first and the assertion would see `ESC[>...`. +#[test] +fn probe_replies_reach_the_child_through_the_allowlist() { + let dir = temp("task_probe"); + let out = dir.join("out"); + // Raw-ish input: the CPR reply has no newline, so canonical mode + // would never hand it to the child. + let cmd = format!( + "stty -icanon -echo min 1 time 0; printf '\\033[>c\\033[c\\033[6n'; \ + head -c 11 > {}", + out.display() + ); + let mut t = Task::spawn(11, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); + let mut got = Vec::new(); + wait_until(Duration::from_secs(5), || { + got = std::fs::read(&out).unwrap_or_default(); + got.len() >= 11 + }); + assert!( + got.starts_with(b"\x1b[?6c\x1b["), + "child must read the primary DA reply first (no secondary-DA \ + leak); got {got:?}" + ); + assert!( + got.ends_with(b"R"), + "CPR reply must follow the DA reply; got {got:?}" + ); + t.terminate(); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/src/golden.rs b/src/terminal/golden.rs similarity index 92% rename from src/golden.rs rename to src/terminal/golden.rs index 7ad79ca..e1a2a30 100644 --- a/src/golden.rs +++ b/src/terminal/golden.rs @@ -102,7 +102,7 @@ fn assert_grid_unstyled(fixture: &str, al: &Term) { /// the following row. The first cell pins the message's default styling. #[test] fn compat_tmux_split() { - let al = alacritty(include_bytes!("../tests/corpus/tmux_split.bin")); + let al = alacritty(include_bytes!("../../tests/corpus/tmux_split.bin")); assert_screen( "tmux_split.bin", &al, @@ -124,7 +124,7 @@ fn compat_tmux_split() { /// grid with default colors and flags and the cursor at the origin. #[test] fn compat_vim_session() { - let al = alacritty(include_bytes!("../tests/corpus/vim_session.bin")); + let al = alacritty(include_bytes!("../../tests/corpus/vim_session.bin")); assert_screen("vim_session.bin", &al, &[], (0, 0)); assert_grid_unstyled("vim_session.bin", &al); } @@ -133,7 +133,7 @@ fn compat_vim_session() { /// with default colors and flags and the cursor at the origin. #[test] fn compat_less_altscreen() { - let al = alacritty(include_bytes!("../tests/corpus/less_altscreen.bin")); + let al = alacritty(include_bytes!("../../tests/corpus/less_altscreen.bin")); assert_screen("less_altscreen.bin", &al, &[], (0, 0)); assert_grid_unstyled("less_altscreen.bin", &al); } @@ -142,7 +142,7 @@ fn compat_less_altscreen() { /// grid with default colors and flags. #[test] fn compat_top_live() { - let al = alacritty(include_bytes!("../tests/corpus/top_live.bin")); + let al = alacritty(include_bytes!("../../tests/corpus/top_live.bin")); assert_screen("top_live.bin", &al, &[], (0, 0)); assert_grid_unstyled("top_live.bin", &al); } @@ -153,7 +153,7 @@ fn compat_top_live() { #[test] fn compat_shell_colors() { const F: &str = "shell_colors.bin"; - let al = alacritty(include_bytes!("../tests/corpus/shell_colors.bin")); + let al = alacritty(include_bytes!("../../tests/corpus/shell_colors.bin")); assert_screen( F, &al, @@ -387,7 +387,7 @@ fn compat_shell_colors() { #[test] fn compat_build_log() { const F: &str = "build_log.bin"; - let al = alacritty(include_bytes!("../tests/corpus/build_log.bin")); + let al = alacritty(include_bytes!("../../tests/corpus/build_log.bin")); assert_screen( F, &al, @@ -476,7 +476,7 @@ fn compat_build_log() { /// dim and no intervening SGR 22: both intensity flags stack on the cells. #[test] fn semantic_codex_resume_scrollback_retention() { - let al = alacritty(include_bytes!("../tests/corpus/codex_resume.bin")); + let al = alacritty(include_bytes!("../../tests/corpus/codex_resume.bin")); assert_eq!(al.grid().history_size(), 85, "codex chat history retention"); let marker = &al.grid()[Line(7)][Column(0)]; @@ -497,7 +497,7 @@ fn semantic_codex_resume_scrollback_retention() { /// scrolls plus the initial row that `ESC[2J` moves into history. #[test] fn semantic_topregion_scroll_retention() { - let al = alacritty(include_bytes!("../tests/corpus/topregion_scroll.bin")); + let al = alacritty(include_bytes!("../../tests/corpus/topregion_scroll.bin")); assert_eq!( al.grid().history_size(), 35, @@ -509,7 +509,7 @@ fn semantic_topregion_scroll_retention() { /// VS16 remains a zero-width attachment; default-emoji codepoints remain wide. #[test] fn semantic_wide_emoji_vs16_width() { - let al = alacritty(include_bytes!("../tests/corpus/wide_emoji.bin")); + let al = alacritty(include_bytes!("../../tests/corpus/wide_emoji.bin")); let grid = al.grid(); // U+2705 has emoji presentation by default: two cells. @@ -540,7 +540,7 @@ fn semantic_wide_emoji_vs16_width() { /// is a full-screen `\r\n` after the region resets, retained as one row. #[test] fn semantic_dec_scrollregion_charset_translation() { - let al = alacritty(include_bytes!("../tests/corpus/dec_scrollregion.bin")); + let al = alacritty(include_bytes!("../../tests/corpus/dec_scrollregion.bin")); assert_eq!( al.grid().history_size(), @@ -584,45 +584,51 @@ fn emulator_wrapper_matches_the_raw_backend_on_every_fixture() { let fixtures: [(&str, &[u8]); 12] = [ ( "tmux_split", - include_bytes!("../tests/corpus/tmux_split.bin"), + include_bytes!("../../tests/corpus/tmux_split.bin"), ), ( "vim_session", - include_bytes!("../tests/corpus/vim_session.bin"), + include_bytes!("../../tests/corpus/vim_session.bin"), ), ( "less_altscreen", - include_bytes!("../tests/corpus/less_altscreen.bin"), + include_bytes!("../../tests/corpus/less_altscreen.bin"), + ), + ( + "top_live", + include_bytes!("../../tests/corpus/top_live.bin"), ), - ("top_live", include_bytes!("../tests/corpus/top_live.bin")), ( "shell_colors", - include_bytes!("../tests/corpus/shell_colors.bin"), + include_bytes!("../../tests/corpus/shell_colors.bin"), + ), + ( + "build_log", + include_bytes!("../../tests/corpus/build_log.bin"), ), - ("build_log", include_bytes!("../tests/corpus/build_log.bin")), ( "claude_resume", - include_bytes!("../tests/corpus/claude_resume.bin"), + include_bytes!("../../tests/corpus/claude_resume.bin"), ), ( "codex_resume", - include_bytes!("../tests/corpus/codex_resume.bin"), + include_bytes!("../../tests/corpus/codex_resume.bin"), ), ( "grok_resume", - include_bytes!("../tests/corpus/grok_resume.bin"), + include_bytes!("../../tests/corpus/grok_resume.bin"), ), ( "wide_emoji", - include_bytes!("../tests/corpus/wide_emoji.bin"), + include_bytes!("../../tests/corpus/wide_emoji.bin"), ), ( "dec_scrollregion", - include_bytes!("../tests/corpus/dec_scrollregion.bin"), + include_bytes!("../../tests/corpus/dec_scrollregion.bin"), ), ( "topregion_scroll", - include_bytes!("../tests/corpus/topregion_scroll.bin"), + include_bytes!("../../tests/corpus/topregion_scroll.bin"), ), ]; for (name, bytes) in fixtures { diff --git a/src/terminal/input.rs b/src/terminal/input.rs new file mode 100644 index 0000000..2b674b3 --- /dev/null +++ b/src/terminal/input.rs @@ -0,0 +1,266 @@ +//! The input-direction mirror of `ansi`: protocol events in, VT byte +//! sequences out. Encoding only: no PTY writes and no `Task` state; +//! callers read the child's negotiated modes under their own locks and +//! pass them in. + +use crate::{ + emulator::Emulator, + protocol::{Key, Mods, MouseKind}, +}; + +/// The bracketed-paste terminator. Stripped from paste *content* before +/// wrapping: a clipboard that contains this sequence would otherwise end the +/// paste early and smuggle the remainder in as live keystrokes. +const PASTE_END: &[u8] = b"\x1b[201~"; + +/// Encode a clipboard paste using the child's DECSET 2004 state. Bracketed +/// mode wraps content and strips embedded terminators; unbracketed mode omits +/// the markers and converts CRLF and LF line endings to CR. +pub fn paste_bytes(bracketed: bool, content: &[u8]) -> Vec { + if bracketed { + let mut out = Vec::with_capacity(content.len() + 2 * PASTE_END.len() + 6); + out.extend_from_slice(b"\x1b[200~"); + let mut rest = content; + while let Some(pos) = rest.windows(PASTE_END.len()).position(|w| w == PASTE_END) { + out.extend_from_slice(&rest[..pos]); + rest = &rest[pos + PASTE_END.len()..]; + } + out.extend_from_slice(rest); + out.extend_from_slice(PASTE_END); + out + } else { + let mut out = Vec::with_capacity(content.len()); + let mut i = 0; + while i < content.len() { + if content[i] == b'\r' && content.get(i + 1) == Some(&b'\n') { + out.push(b'\r'); + i += 2; + } else if content[i] == b'\n' { + out.push(b'\r'); + i += 1; + } else { + out.push(content[i]); + i += 1; + } + } + out + } +} + +/// Encode a mouse action under the child's current terminal mode. The selected +/// protocol determines which actions are valid and how they are encoded. With +/// no mouse protocol, wheel actions become alternate-scroll arrows when the +/// alternate screen and DECSET 1007 are both active. DECSET 1007 defaults on; +/// see [`Emulator::alternate_scroll`]. Unsupported actions return `None`. +pub fn mouse_bytes(emu: &Emulator, kind: MouseKind, col: u16, row: u16) -> Option> { + use crate::emulator::{MouseProtocolEncoding, MouseProtocolMode}; + let mode = emu.mouse_protocol_mode(); + if mode != MouseProtocolMode::None { + // Every supported mode reports presses, releases, and wheel events; + // only motion modes 1002 and 1003 report drags. + if matches!(kind, MouseKind::Drag(_)) + && !matches!( + mode, + MouseProtocolMode::ButtonMotion | MouseProtocolMode::AnyMotion + ) + { + return None; + } + // xterm button codes: wheel 64/65; drag adds 32. + let code: u16 = match kind { + MouseKind::WheelUp => 64, + MouseKind::WheelDown => 65, + MouseKind::Press(b) | MouseKind::Release(b) => b as u16, + MouseKind::Drag(b) => 32 + b as u16, + }; + let release = matches!(kind, MouseKind::Release(_)); + return Some(match emu.mouse_protocol_encoding() { + // SGR releases use the `m` suffix. + MouseProtocolEncoding::Sgr => { + let suffix = if release { 'm' } else { 'M' }; + format!("\x1b[<{};{};{}{}", code, col + 1, row + 1, suffix).into_bytes() + } + // UTF-8 fields encode `32 + value` up to 2047; releases use code 3. + MouseProtocolEncoding::Utf8 => { + let code = if release { 3 } else { code }; + let mut out = b"\x1b[M".to_vec(); + for v in [32 + code, 33 + col.min(2014), 33 + row.min(2014)] { + let mut buf = [0u8; 4]; + // Values are bounded to valid UTF-8 scalar values. + let c = char::from_u32(u32::from(v)).unwrap_or(' '); + out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + } + out + } + // Default fields are single bytes capped at 255; releases use code 3. + MouseProtocolEncoding::Default => { + let code = if release { 3 } else { code }; + vec![ + 0x1b, + b'[', + b'M', + 32 + code as u8, + (33 + col.min(222)) as u8, + (33 + row.min(222)) as u8, + ] + } + }); + } + if emu.alternate_scroll() { + let up = match kind { + MouseKind::WheelUp => true, + MouseKind::WheelDown => false, + // Only wheel actions map to alternate-scroll arrows. + _ => return None, + }; + let arrow: &[u8] = match (emu.application_cursor(), up) { + (true, true) => b"\x1bOA", + (true, false) => b"\x1bOB", + (false, true) => b"\x1b[A", + (false, false) => b"\x1b[B", + }; + return Some(arrow.repeat(3)); + } + None +} + +/// Return the control byte for a supported `Ctrl`+key combination. ASCII +/// letters and the standard symbol/digit aliases map to C0 control bytes. +fn ctrl_byte(c: char) -> Option { + if c.is_ascii_alphabetic() { + return Some((c.to_ascii_uppercase() as u8) & 0x1f); + } + Some(match c { + ' ' | '@' | '2' => 0x00, + '[' | '3' => 0x1b, + '\\' | '4' => 0x1c, + ']' | '5' => 0x1d, + '^' | '6' => 0x1e, + '_' | '7' | '/' => 0x1f, + '?' | '8' => 0x7f, + _ => return None, + }) +} + +/// Encode a printable key. Shift is already folded into `c` by the client, so +/// it is ignored here; only `ctrl` (control byte) and `alt` (ESC prefix, the +/// meta convention) change the bytes. +fn char_bytes(c: char, mods: Mods) -> Option> { + let mut out = if mods.ctrl { + vec![ctrl_byte(c)?] + } else { + let mut buf = [0u8; 4]; + c.encode_utf8(&mut buf).as_bytes().to_vec() + }; + if mods.alt { + out.insert(0, 0x1b); + } + Some(out) +} + +/// Encode F1–F4 as SS3 when unmodified and CSI when modified. F5–F12 use their +/// CSI numeric forms. Numbers outside `1..=12` encode to nothing. +fn f_bytes(n: u8, m: Option) -> Option> { + if let Some(letter) = match n { + 1 => Some('P'), + 2 => Some('Q'), + 3 => Some('R'), + 4 => Some('S'), + _ => None, + } { + return Some(match m { + None => format!("\x1bO{letter}").into_bytes(), + Some(m) => format!("\x1b[1;{m}{letter}").into_bytes(), + }); + } + let code = match n { + 5 => 15, + 6 => 17, + 7 => 18, + 8 => 19, + 9 => 20, + 10 => 21, + 11 => 23, + 12 => 24, + _ => return None, + }; + Some(match m { + None => format!("\x1b[{code}~").into_bytes(), + Some(m) => format!("\x1b[{code};{m}~").into_bytes(), + }) +} + +/// Encode a key for the child. Application-cursor mode selects SS3 for +/// unmodified cursor and Home/End keys; their modified forms use CSI. +/// Unsupported key combinations return `None`. +pub fn key_bytes(app_cursor: bool, code: Key, mods: Mods) -> Option> { + let m = mods.param(); + match code { + Key::Char(c) => char_bytes(c, mods), + Key::F(n) => f_bytes(n, m), + Key::Up | Key::Down | Key::Left | Key::Right | Key::Home | Key::End => { + let letter = match code { + Key::Up => 'A', + Key::Down => 'B', + Key::Right => 'C', + Key::Left => 'D', + Key::Home => 'H', + Key::End => 'F', + _ => unreachable!(), + }; + Some(match m { + None if app_cursor => format!("\x1bO{letter}").into_bytes(), + None => format!("\x1b[{letter}").into_bytes(), + Some(m) => format!("\x1b[1;{m}{letter}").into_bytes(), + }) + } + Key::Insert | Key::Delete | Key::PageUp | Key::PageDown => { + // Navigation-cluster keys always use CSI `~`. + let n = match code { + Key::Insert => 2, + Key::Delete => 3, + Key::PageUp => 5, + Key::PageDown => 6, + _ => unreachable!(), + }; + Some(match m { + None => format!("\x1b[{n}~").into_bytes(), + Some(m) => format!("\x1b[{n};{m}~").into_bytes(), + }) + } + // Enter uses ESC CR for Shift or Alt; Control does not change plain CR. + Key::Enter => Some(if mods.shift || mods.alt { + vec![0x1b, 0x0d] + } else { + vec![0x0d] + }), + // Alt prefixes Tab with ESC; Control and Shift do not change HT. + Key::Tab => Some(if mods.alt { + vec![0x1b, 0x09] + } else { + vec![0x09] + }), + // Alt prefixes BackTab's CSI Z sequence; Control and Shift are ignored. + Key::BackTab => Some(if mods.alt { + b"\x1b\x1b[Z".to_vec() + } else { + b"\x1b[Z".to_vec() + }), + // Backspace is DEL; Alt prefixes ESC, and Control/Shift leave it unchanged. + Key::Backspace => Some(if mods.alt { + vec![0x1b, 0x7f] + } else { + vec![0x7f] + }), + // Alt+Esc is the ESC-ESC meta form; Ctrl/Shift fold into a plain ESC. + Key::Esc => Some(if mods.alt { + vec![0x1b, 0x1b] + } else { + vec![0x1b] + }), + } +} + +#[cfg(test)] +#[path = "input_tests.rs"] +mod tests; diff --git a/src/terminal/input_tests.rs b/src/terminal/input_tests.rs new file mode 100644 index 0000000..dcad11f --- /dev/null +++ b/src/terminal/input_tests.rs @@ -0,0 +1,473 @@ +use super::*; +use crate::protocol::MouseBtn; + +/// Paste encoding follows the child's DECSET 2004 opt-in: markers only +/// when asked for, newline→CR conversion only when not. +#[test] +fn paste_wraps_only_when_child_opted_in() { + assert_eq!( + paste_bytes(true, b"hello"), + b"\x1b[200~hello\x1b[201~".to_vec() + ); + // Inside brackets the content rides verbatim: the child's own paste + // handling decides what a newline means. + assert_eq!( + paste_bytes(true, b"a\nb"), + b"\x1b[200~a\nb\x1b[201~".to_vec() + ); + assert_eq!(paste_bytes(false, b"hello"), b"hello".to_vec()); +} + +/// A clipboard containing the end marker must not terminate the paste +/// early: the remainder would arrive as live keystrokes. +#[test] +fn paste_strips_embedded_terminator() { + assert_eq!( + paste_bytes(true, b"safe\x1b[201~rm -rf /\n"), + b"\x1b[200~saferm -rf /\n\x1b[201~".to_vec() + ); + // Multiple embedded markers all go. + assert_eq!( + paste_bytes(true, b"\x1b[201~a\x1b[201~b\x1b[201~"), + b"\x1b[200~ab\x1b[201~".to_vec() + ); +} + +/// Unbracketed paste converts both `\r\n` and bare `\n` to the `\r` Enter +/// sends, without doubling a CRLF into two returns. +#[test] +fn legacy_paste_converts_line_endings() { + assert_eq!(paste_bytes(false, b"a\r\nb\nc\r"), b"a\rb\rc\r".to_vec()); +} + +/// Wheel routing follows the child's own escape sequences: nothing for an +/// inline child, alternate-scroll arrows for a full-screen one, real mouse +/// events once a protocol is requested, in the negotiated encoding. +#[test] +fn wheel_routes_by_child_state() { + let up = MouseKind::WheelUp; + let down = MouseKind::WheelDown; + let mut p = Emulator::new(24, 80, 0); + // Inline child, no mouse: dropped, not translated into arrow spam. + assert_eq!(mouse_bytes(&p, up, 0, 0), None); + // Full-screen child: three arrows per notch, normal cursor keys. + p.process(b"\x1b[?1049h"); + assert_eq!( + mouse_bytes(&p, up, 0, 0), + Some(b"\x1b[A\x1b[A\x1b[A".to_vec()) + ); + // Clicks mean nothing to a full-screen child without a mouse mode. + assert_eq!( + mouse_bytes(&p, MouseKind::Press(MouseBtn::Left), 0, 0), + None + ); + // Application cursor keys switch the arrows to SS3 form. + p.process(b"\x1b[?1h"); + assert_eq!( + mouse_bytes(&p, down, 0, 0), + Some(b"\x1bOB\x1bOB\x1bOB".to_vec()) + ); + // SGR mouse protocol: a real wheel event, 1-based coordinates. + p.process(b"\x1b[?1000h\x1b[?1006h"); + assert_eq!(mouse_bytes(&p, up, 4, 2), Some(b"\x1b[<64;5;3M".to_vec())); + // Default encoding: single-byte cells, clamped to fit. + p.process(b"\x1b[?1006l"); + assert_eq!( + mouse_bytes(&p, down, 0, 0), + Some(vec![0x1b, b'[', b'M', 32 + 65, 33, 33]) + ); + assert_eq!( + mouse_bytes(&p, down, 500, 500), + Some(vec![0x1b, b'[', b'M', 32 + 65, 255, 255]) + ); + // UTF-8 mouse coordinates can use multiple bytes. + p.process(b"\x1b[?1005h"); + assert_eq!( + mouse_bytes(&p, up, 200, 2), + Some(vec![0x1b, b'[', b'M', 32 + 64, 0xc3, 0xa9, 33 + 2]) + ); + // UTF-8 mouse coordinates cap at the protocol limit. + assert_eq!( + mouse_bytes(&p, up, 5000, 5000), + Some(vec![0x1b, b'[', b'M', 32 + 64, 0xdf, 0xbf, 0xdf, 0xbf]) + ); +} + +/// A full-screen child receives wheel arrows only while DECSET 1007 is +/// enabled; the mode defaults on. +#[test] +fn wheel_arrows_honor_decset_1007() { + let up = MouseKind::WheelUp; + let mut p = Emulator::new(24, 80, 0); + p.process(b"\x1b[?1049h\x1b[?1007l"); + assert_eq!(mouse_bytes(&p, up, 0, 0), None, "1007 off: no arrows"); + p.process(b"\x1b[?1007h"); + assert_eq!( + mouse_bytes(&p, up, 0, 0), + Some(b"\x1b[A\x1b[A\x1b[A".to_vec()), + "1007 back on: arrows resume" + ); + // A mouse protocol still outranks the gate: real wheel events. + p.process(b"\x1b[?1000h\x1b[?1006h"); + assert_eq!(mouse_bytes(&p, up, 0, 0), Some(b"\x1b[<64;1;1M".to_vec())); +} + +/// DECSET 1000/1002/1003 all report presses, releases, and wheel events; +/// only motion modes 1002 and 1003 report drags. SGR marks releases with +/// the `m` suffix and preserves the button code; the default and UTF-8 +/// encodings use code 3 for every release. +#[test] +fn buttons_respect_mode_granularity_and_encoding() { + let press = MouseKind::Press(MouseBtn::Left); + let drag = MouseKind::Drag(MouseBtn::Left); + let release = MouseKind::Release(MouseBtn::Left); + let wheel = MouseKind::WheelUp; + + for (mode, drags) in [(1000, false), (1002, true), (1003, true)] { + let mut p = Emulator::new(24, 80, 0); + p.process(format!("\x1b[?{mode}h").as_bytes()); + + // Default encoding: single-byte fields. + assert_eq!( + mouse_bytes(&p, press, 4, 2), + Some(vec![0x1b, b'[', b'M', 32, 33 + 4, 33 + 2]), + "mode {mode}: default press" + ); + assert_eq!( + mouse_bytes(&p, release, 4, 2), + Some(vec![0x1b, b'[', b'M', 32 + 3, 33 + 4, 33 + 2]), + "mode {mode}: default release" + ); + assert_eq!( + mouse_bytes(&p, wheel, 4, 2), + Some(vec![0x1b, b'[', b'M', 32 + 64, 33 + 4, 33 + 2]), + "mode {mode}: default wheel" + ); + assert_eq!( + mouse_bytes(&p, drag, 4, 2), + drags.then(|| vec![0x1b, b'[', b'M', 32 + 32, 33 + 4, 33 + 2]), + "mode {mode}: default drag" + ); + + // UTF-8 encoding: same codes, multi-byte coordinates. + p.process(b"\x1b[?1005h"); + assert_eq!( + mouse_bytes(&p, press, 200, 2), + Some(vec![0x1b, b'[', b'M', 32, 0xc3, 0xa9, 33 + 2]), + "mode {mode}: utf8 press" + ); + assert_eq!( + mouse_bytes(&p, release, 200, 2), + Some(vec![0x1b, b'[', b'M', 32 + 3, 0xc3, 0xa9, 33 + 2]), + "mode {mode}: utf8 release" + ); + assert_eq!( + mouse_bytes(&p, wheel, 200, 2), + Some(vec![0x1b, b'[', b'M', 32 + 64, 0xc3, 0xa9, 33 + 2]), + "mode {mode}: utf8 wheel" + ); + assert_eq!( + mouse_bytes(&p, drag, 200, 2), + drags.then(|| vec![0x1b, b'[', b'M', 32 + 32, 0xc3, 0xa9, 33 + 2]), + "mode {mode}: utf8 drag" + ); + + // SGR encoding: parameterized fields, release keeps its code. + p.process(b"\x1b[?1006h"); + assert_eq!( + mouse_bytes(&p, press, 4, 2), + Some(b"\x1b[<0;5;3M".to_vec()), + "mode {mode}: sgr press" + ); + assert_eq!( + mouse_bytes(&p, release, 4, 2), + Some(b"\x1b[<0;5;3m".to_vec()), + "mode {mode}: sgr release" + ); + assert_eq!( + mouse_bytes(&p, wheel, 4, 2), + Some(b"\x1b[<64;5;3M".to_vec()), + "mode {mode}: sgr wheel" + ); + assert_eq!( + mouse_bytes(&p, drag, 4, 2), + drags.then(|| b"\x1b[<32;5;3M".to_vec()), + "mode {mode}: sgr drag" + ); + } +} + +fn mods(shift: bool, alt: bool, ctrl: bool) -> Mods { + Mods { shift, alt, ctrl } +} + +/// Cursor and Home/End keys: application-cursor mode picks SS3 vs CSI for +/// the unmodified sequence, and any modifier forces the CSI `1;m` form even +/// in application mode. +#[test] +fn cursor_keys_encode_by_mode_and_modifier() { + let none = Mods::default(); + for (code, l) in [ + (Key::Up, 'A'), + (Key::Down, 'B'), + (Key::Right, 'C'), + (Key::Left, 'D'), + (Key::Home, 'H'), + (Key::End, 'F'), + ] { + assert_eq!( + key_bytes(false, code, none), + Some(format!("\x1b[{l}").into_bytes()), + "{code:?} normal", + ); + assert_eq!( + key_bytes(true, code, none), + Some(format!("\x1bO{l}").into_bytes()), + "{code:?} app-cursor", + ); + assert_eq!( + key_bytes(true, code, mods(false, true, false)), + Some(format!("\x1b[1;3{l}").into_bytes()), + "{code:?} alt forces CSI even in app mode", + ); + } +} + +/// The modifier parameter is `1 + shift + 2·alt + 4·ctrl`: shift=2, alt=3, +/// ctrl=5, ctrl+alt=7, all-three=8. +#[test] +fn modifier_param_formula() { + for (m, digit) in [ + (mods(true, false, false), '2'), + (mods(false, true, false), '3'), + (mods(false, false, true), '5'), + (mods(false, true, true), '7'), + (mods(true, true, true), '8'), + ] { + assert_eq!( + key_bytes(false, Key::Up, m), + Some(format!("\x1b[1;{digit}A").into_bytes()), + "param for {m:?}", + ); + } +} + +/// Application-cursor mode uses SS3 only for unmodified cursor keys. +#[test] +fn app_cursor_drives_unmodified_only() { + assert_eq!( + key_bytes(true, Key::Left, mods(false, true, false)), + Some(b"\x1b[1;3D".to_vec()), + ); + assert_eq!( + key_bytes(true, Key::Up, Mods::default()), + Some(b"\x1bOA".to_vec()), + ); +} + +/// The full F1–F12 table, including the terminfo gaps (no 16 between +/// F5=15 and F6=17; no 22 before F11=23) and the modified forms. +#[test] +fn function_keys_cover_the_terminfo_gaps() { + let none = Mods::default(); + for (n, seq) in [ + (1u8, b"\x1bOP".to_vec()), + (2, b"\x1bOQ".to_vec()), + (3, b"\x1bOR".to_vec()), + (4, b"\x1bOS".to_vec()), + (5, b"\x1b[15~".to_vec()), + (6, b"\x1b[17~".to_vec()), + (7, b"\x1b[18~".to_vec()), + (8, b"\x1b[19~".to_vec()), + (9, b"\x1b[20~".to_vec()), + (10, b"\x1b[21~".to_vec()), + (11, b"\x1b[23~".to_vec()), + (12, b"\x1b[24~".to_vec()), + ] { + assert_eq!(key_bytes(false, Key::F(n), none), Some(seq), "F{n}"); + } + // F1–F4 collapse to CSI `1;m`; F5–F12 splice m before the tilde. + assert_eq!( + key_bytes(false, Key::F(1), mods(true, false, false)), + Some(b"\x1b[1;2P".to_vec()), + ); + assert_eq!( + key_bytes(false, Key::F(5), mods(false, false, true)), + Some(b"\x1b[15;5~".to_vec()), + ); + assert_eq!( + key_bytes(false, Key::F(12), mods(false, true, false)), + Some(b"\x1b[24;3~".to_vec()), + ); + assert_eq!(key_bytes(false, Key::F(0), none), None); + assert_eq!(key_bytes(false, Key::F(13), none), None); +} + +/// The Insert/Delete/PageUp/PageDown cluster is CSI `~` regardless of +/// application-cursor mode. +#[test] +fn nav_cluster_is_mode_independent() { + for (code, n) in [ + (Key::Insert, 2), + (Key::Delete, 3), + (Key::PageUp, 5), + (Key::PageDown, 6), + ] { + assert_eq!( + key_bytes(false, code, Mods::default()), + Some(format!("\x1b[{n}~").into_bytes()), + "{code:?} normal", + ); + assert_eq!( + key_bytes(true, code, Mods::default()), + Some(format!("\x1b[{n}~").into_bytes()), + "{code:?} app-cursor unchanged", + ); + assert_eq!( + key_bytes(false, code, mods(false, false, true)), + Some(format!("\x1b[{n};5~").into_bytes()), + "{code:?} modified", + ); + } +} + +/// Supported Ctrl symbol/digit aliases produce their C0 control bytes. +#[test] +fn ctrl_symbol_and_digit_table() { + let ctrl = mods(false, false, true); + for (c, byte) in [ + (' ', 0x00), + ('@', 0x00), + ('2', 0x00), + ('[', 0x1b), + ('3', 0x1b), + ('\\', 0x1c), + ('4', 0x1c), + (']', 0x1d), + ('5', 0x1d), + ('^', 0x1e), + ('6', 0x1e), + ('_', 0x1f), + ('7', 0x1f), + ('/', 0x1f), + ('?', 0x7f), + ('8', 0x7f), + ] { + assert_eq!( + key_bytes(false, Key::Char(c), ctrl), + Some(vec![byte]), + "Ctrl+{c:?}", + ); + } + assert_eq!(key_bytes(false, Key::Char('1'), ctrl), None); + assert_eq!(key_bytes(false, Key::Char('9'), ctrl), None); +} + +/// Char encodings: plain UTF-8 (multibyte preserved), shift folded into the +/// char, Alt as an ESC prefix, and Ctrl+letter folding to its C0 control. +#[test] +fn char_alt_and_ctrl_letters() { + let none = Mods::default(); + assert_eq!(key_bytes(false, Key::Char('a'), none), Some(b"a".to_vec())); + assert_eq!( + key_bytes(false, Key::Char('é'), none), + Some("é".as_bytes().to_vec()), + ); + // Shift is already in the char; on its own it changes nothing. + assert_eq!( + key_bytes(false, Key::Char('A'), mods(true, false, false)), + Some(b"A".to_vec()), + ); + assert_eq!( + key_bytes(false, Key::Char('x'), mods(false, true, false)), + Some(b"\x1bx".to_vec()), + ); + assert_eq!( + key_bytes(false, Key::Char('a'), mods(false, false, true)), + Some(vec![0x01]), + ); + assert_eq!( + key_bytes(false, Key::Char('C'), mods(false, false, true)), + Some(vec![0x03]), + ); + assert_eq!( + key_bytes(false, Key::Char('z'), mods(false, false, true)), + Some(vec![0x1a]), + ); + assert_eq!( + key_bytes(false, Key::Char('c'), mods(false, true, true)), + Some(vec![0x1b, 0x03]), + ); +} + +/// Named keys and their modifier forms: keys with no distinct modified +/// encoding ignore an unsupported Ctrl/Shift (base sequence, never dropped) +/// and take the ESC-prefix meta form under Alt. +#[test] +fn named_keys_and_meta_prefixes() { + let none = Mods::default(); + assert_eq!(key_bytes(false, Key::Enter, none), Some(vec![0x0d])); + assert_eq!(key_bytes(false, Key::Tab, none), Some(vec![0x09])); + assert_eq!( + key_bytes(false, Key::BackTab, none), + Some(b"\x1b[Z".to_vec()) + ); + assert_eq!(key_bytes(false, Key::Backspace, none), Some(vec![0x7f])); + assert_eq!(key_bytes(false, Key::Esc, none), Some(vec![0x1b])); + // Shift or Alt Enter -> ESC CR; Alt+Backspace -> ESC DEL. + assert_eq!( + key_bytes(false, Key::Enter, mods(true, false, false)), + Some(b"\x1b\r".to_vec()), + ); + assert_eq!( + key_bytes(false, Key::Enter, mods(false, true, false)), + Some(b"\x1b\r".to_vec()), + ); + assert_eq!( + key_bytes(false, Key::Backspace, mods(false, true, false)), + Some(b"\x1b\x7f".to_vec()), + ); + // BackTab already is Shift+Tab: its inherent Shift is ignored; Alt + // meta-prefixes the CSI Z sequence. + assert_eq!( + key_bytes(false, Key::BackTab, mods(true, false, false)), + Some(b"\x1b[Z".to_vec()), + ); + assert_eq!( + key_bytes(false, Key::BackTab, mods(false, true, false)), + Some(b"\x1b\x1b[Z".to_vec()), + ); + // These keys have no distinct modified form: an unsupported Ctrl/Shift + // is ignored (base sequence, never dropped), and Alt is the ESC-prefix + // meta form. + assert_eq!( + key_bytes(false, Key::Enter, mods(false, false, true)), + Some(vec![0x0d]), + "Ctrl+Enter folds to CR", + ); + assert_eq!( + key_bytes(false, Key::Backspace, mods(false, false, true)), + Some(vec![0x7f]), + "Ctrl+Backspace folds to DEL", + ); + assert_eq!( + key_bytes(false, Key::Tab, mods(false, false, true)), + Some(vec![0x09]), + "Ctrl+Tab folds to HT", + ); + assert_eq!( + key_bytes(false, Key::Tab, mods(false, true, false)), + Some(vec![0x1b, 0x09]), + "Alt+Tab is ESC TAB", + ); + assert_eq!( + key_bytes(false, Key::Esc, mods(false, true, false)), + Some(vec![0x1b, 0x1b]), + "Alt+Esc is ESC ESC", + ); + assert_eq!( + key_bytes(false, Key::Esc, mods(false, false, true)), + Some(vec![0x1b]), + "Ctrl+Esc folds to ESC", + ); +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 6a22f0a..71328e7 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -1,7 +1,12 @@ //! Terminal reconstruction: each task's screen is rebuilt from raw PTY bytes, -//! serialized back to ANSI, framed over the wire, and formatted for display. +//! serialized back to ANSI, framed over the wire, and formatted for display; +//! `input` runs the reverse direction, encoding client events into PTY bytes. pub(crate) mod ansi; pub(crate) mod emulator; pub(crate) mod format; pub(crate) mod frame; +// Differential emulator tests over recorded PTY output. +#[cfg(test)] +mod golden; +pub(crate) mod input; diff --git a/src/testutil.rs b/src/testutil.rs index cfca9c3..bfcce3e 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -3,7 +3,9 @@ //! at the declaration in `main.rs`), so nothing here ships. use std::{ + ffi::OsString, fs, + os::unix::fs::PermissionsExt, path::{Path, PathBuf}, time::{Duration, Instant, SystemTime}, }; @@ -42,7 +44,7 @@ pub(crate) fn now_ms() -> u64 { .as_millis() as u64 } -/// Read a pid a test job wrote, waiting for the write to land. +/// Read a pid a test task wrote, waiting for the write to land. pub(crate) fn read_pid(path: &Path) -> nix::unistd::Pid { let mut pid = None; wait_until(Duration::from_secs(5), || { @@ -54,6 +56,41 @@ pub(crate) fn read_pid(path: &Path) -> nix::unistd::Pid { nix::unistd::Pid::from_raw(pid.expect("pid file never appeared")) } +/// Return this process's working directory. +pub(crate) fn here() -> PathBuf { + std::env::current_dir().unwrap() +} + +/// Snapshot this process's environment for a launch context. +pub(crate) fn env_here() -> Vec<(OsString, OsString)> { + std::env::vars_os().collect() +} + +/// `env_here` with `SHELL` pinned to `/bin/sh` for portable background-job +/// behavior in process-group tests. +pub(crate) fn sh_env() -> Vec<(OsString, OsString)> { + let mut env = env_here(); + env.retain(|(k, _)| k != "SHELL"); + env.push(("SHELL".into(), "/bin/sh".into())); + env +} + +/// Write an executable `#!/bin/sh` script at `path`. The parent must exist. +pub(crate) fn write_executable(path: &Path, body: &str) { + fs::write(path, format!("#!/bin/sh\n{body}\n")).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); +} + +/// Install a fake notifier at `path` that records its argv, one token +/// per line, into `record`. +pub(crate) fn install_fake_notifier(path: &Path, record: &Path) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + write_executable( + path, + &format!("printf '%s\\n' \"$@\" > '{}'", record.display()), + ); +} + /// Corpus geometry: the fixture recordings in `tests/corpus/` were captured /// under a 40-row, 120-column PTY (tests/corpus/README.md). pub(crate) const CORPUS_LINES: usize = 40; diff --git a/src/transport.rs b/src/transport.rs index a6005cb..7e1c058 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -14,7 +14,7 @@ use crate::{ core::{Wake, run_loop}, frame::{SEND_TIMEOUT, read_frame, write_frame}, protocol::{Command, Event, decode_event, encode_command}, - supervisor::Supervisor, + supervisor::{Supervisor, resolve_scrollback}, }; /// How the client is leaving, chosen by the exit key/signal. Only @@ -22,9 +22,9 @@ use crate::{ /// leave running, so both intents kill everything there. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum ExitIntent { - /// Detach this client; the daemon and its jobs keep running. + /// Detach this client; the daemon and its tasks keep running. Disconnect, - /// Kill every job and stop the daemon. + /// Kill every task and stop the daemon. Quit, } @@ -40,7 +40,7 @@ pub trait Transport { /// client surfaces instead of freezing on a stale mirror. fn connected(&self) -> bool; /// Tear down per `intent`, blocking until it's done, so the client restores - /// the terminal only after the core has acted (jobs killed on `Quit`, the + /// the terminal only after the core has acted (tasks killed on `Quit`, the /// connection closed on `Disconnect`). fn shutdown(&mut self, intent: ExitIntent); } @@ -75,9 +75,11 @@ pub struct ThreadTransport { } impl ThreadTransport { - /// Run `sup` on its own thread. `wait_tx` wakes the *client's* run loop when - /// an event is produced, so the loop reacts without polling. - pub fn spawn(mut sup: Supervisor, wait_tx: Sender<()>) -> ThreadTransport { + /// Build the in-process core at `rows`×`cols` and run it on its own + /// thread. `wait_tx` wakes the *client's* run loop when an event is + /// produced, so the loop reacts without polling. + pub fn foreground(rows: u16, cols: u16, wait_tx: Sender<()>) -> ThreadTransport { + let mut sup = Supervisor::new(rows, cols, resolve_scrollback()); let (wake_tx, wake_rx) = channel::(); let (evt_tx, evt_rx) = channel::(); // The in-process core uses this process's launch context. @@ -98,7 +100,7 @@ impl ThreadTransport { true }); // Loop returned (Shutdown or client gone): `sup` drops here, and with - // it every Task (Task::drop → killpg), so no job outlives the core. + // it every Task (Task::drop → killpg), so no task outlives the core. }); ThreadTransport { wake_tx, @@ -109,7 +111,7 @@ impl ThreadTransport { } fn stop(&mut self) { - // Tell the core to kill jobs and exit, then wait for it. The join is what + // Tell the core to kill tasks and exit, then wait for it. The join is what // guarantees the SIGKILLs have been sent before we return. The core // clears its tasks (Task::drop → killpg) as `run_loop` returns. let _ = self.wake_tx.send(Wake::Cmd(Command::Shutdown)); @@ -225,17 +227,17 @@ impl Transport for SocketTransport { fn shutdown(&mut self, intent: ExitIntent) { match intent { - // Group-kill every job and stop the daemon; the socket then closes - // (daemon gone = jobs killed). + // Group-kill every task and stop the daemon; the socket then closes + // (daemon gone = tasks killed). ExitIntent::Quit => self.send(Command::Shutdown), // Close the connection without a Shutdown: the daemon sees EOF and - // keeps the jobs running for the next client to reattach. + // keeps the tasks running for the next client to reattach. ExitIntent::Disconnect => { let _ = self.write.shutdown(Shutdown::Both); } } // Either way, wait for our reader to see the socket close before the - // client restores the terminal. On Quit that means the jobs are dead. + // client restores the terminal. On Quit that means the tasks are dead. if let Some(h) = self.reader.take() { let _ = h.join(); } diff --git a/src/ui.rs b/src/ui.rs index 86d2b6d..72c15b5 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -16,8 +16,7 @@ use crate::{ app::{App, DirKind, GroupMode, Mode, Row}, editbuf::EditBuffer, format::{pad, rel_time, truncate}, - preview::PreviewSource, - protocol::{Lifecycle, TaskView}, + protocol::{Lifecycle, Preview, PreviewSource, TaskView}, }; pub fn render(out: &mut Stdout, app: &mut App) -> io::Result<()> { @@ -166,7 +165,7 @@ fn render_dashboard(out: &mut impl Write, app: &App) -> io::Result<()> { let v = &app.views[*ti]; if app.selected_id == Some(v.id) { rev(out, y, &task_row(v, cols), cols)?; - } else if v.source == PreviewSource::Marker { + } else if v.preview.source == PreviewSource::Marker { // The marker is a placeholder, not output: dim the // preview cell so it reads as metadata. dim_preview_row(out, y, v, cols)?; @@ -201,7 +200,7 @@ fn render_dashboard(out: &mut impl Write, app: &App) -> io::Result<()> { // Footer hints. In foreground there is no daemon to detach from: both // intents stop the in-process core (`ThreadTransport::shutdown` ignores // the intent), so advertising `q detach` there would promise survival the - // jobs don't have. + // tasks don't have. let exit_hint = if app.daemon_backed { "q detach · Q quit" } else { @@ -341,7 +340,7 @@ fn task_row_parts(v: &TaskView, cols: usize) -> (String, String, String) { // prefix(2) glyph+sp(2) tag+sp(2) title(title_w) sp(1) preview(prev_w) sp(1) time let used = 2 + 2 + 2 + title_w + 1 + 1 + time.chars().count(); let prev_w = cols.saturating_sub(used); - let preview = truncate(&v.preview, prev_w); + let preview = truncate(&v.preview.text, prev_w); ( format!(" {glyph} {tag}{title: io::Result<()> { // The peek footer identifies the preview source and in-process matcher. let footer = format!( " space/esc close · enter attach · preview: {} ", - preview_provenance(v) + preview_provenance(&v.preview) ); queue!( out, @@ -442,13 +441,13 @@ fn render_peek(out: &mut impl Write, app: &App) -> io::Result<()> { /// The peek footer's provenance label: source, then the matcher rule when /// one produced it, then the frozen flag. Examples: `title`, `floor (frozen)`. -fn preview_provenance(v: &TaskView) -> String { - let mut s = v.source.label().to_string(); - if let Some(rule) = v.rule { +fn preview_provenance(p: &Preview) -> String { + let mut s = p.source.label().to_string(); + if let Some(rule) = p.rule { s.push('/'); s.push_str(rule); } - if v.frozen { + if p.frozen { s.push_str(" (frozen)"); } s @@ -738,10 +737,7 @@ mod tests { name: name.map(str::to_string), lifecycle: Lifecycle::Active, parked: false, - preview: String::new(), - source: PreviewSource::Floor, - frozen: false, - rule: None, + preview: Preview::floor(String::new()), started_ago: std::time::Duration::from_secs(5), quiet_ago: None, finished_ago: None, @@ -805,15 +801,15 @@ mod tests { /// The peek footer's provenance label composes source, rule, and frozen. #[test] fn preview_provenance_label_shapes() { - let mut v = view(None); - assert_eq!(preview_provenance(&v), "floor"); - v.source = PreviewSource::Title; - v.frozen = true; - assert_eq!(preview_provenance(&v), "title (frozen)"); - v.source = PreviewSource::Anchor; - v.rule = Some("claude-status"); - v.frozen = false; - assert_eq!(preview_provenance(&v), "anchor/claude-status"); + let mut p = Preview::floor(String::new()); + assert_eq!(preview_provenance(&p), "floor"); + p.source = PreviewSource::Title; + p.frozen = true; + assert_eq!(preview_provenance(&p), "title (frozen)"); + p.source = PreviewSource::Anchor; + p.rule = Some("claude-status"); + p.frozen = false; + assert_eq!(preview_provenance(&p), "anchor/claude-status"); } /// The attached bar shows both the name and the command for a named task. diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 678c33a..f4524d2 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -133,8 +133,8 @@ pub fn spawn_frame(command: &str, cwd: &Path) -> Vec { } /// Spawn `command` (which must write its own `$$` to `pidfile`) and return the -/// job's leader pid (== pgid: portable-pty `setsid`s it). -pub fn spawn_job( +/// task's leader pid (== pgid: portable-pty `setsid`s it). +pub fn spawn_task( stream: &mut UnixStream, cwd: &Path, pidfile: &Path, @@ -147,7 +147,7 @@ pub fn spawn_job( .map(|s| !s.trim().is_empty()) .unwrap_or(false) }), - "the job never wrote its pid" + "the task never wrote its pid" ); nix::unistd::Pid::from_raw( std::fs::read_to_string(pidfile) @@ -172,7 +172,7 @@ pub fn stop_daemon(daemon: &mut KillOnDrop) { } /// Kill the daemon if the test fails before its clean shutdown, so an -/// assertion failure never leaks a daemon (and its jobs) onto the host. +/// assertion failure never leaks a daemon (and its tasks) onto the host. pub struct KillOnDrop(pub Child); impl Drop for KillOnDrop { fn drop(&mut self) { diff --git a/tests/daemon_env.rs b/tests/daemon_env.rs index fd35d5d..5639109 100644 --- a/tests/daemon_env.rs +++ b/tests/daemon_env.rs @@ -6,20 +6,17 @@ mod common; use std::{io::Write, time::Duration}; -use common::{ - PROTOCOL_VERSION, hello_frame, read_frame, spawn_frame, start_daemon_raw, stop_daemon, - wait_until, -}; +use common::{shake_hands_env, spawn_frame, start_daemon_raw, stop_daemon, wait_until}; #[test] fn spawn_runs_under_the_hello_env() { - // The daemon gets a var of its own; it must NOT reach the job. + // The daemon gets a var of its own; it must NOT reach the task. let (dir, mut daemon, mut stream) = start_daemon_raw("cliexenv", |cmd| { cmd.env("FLEETCOM_DAEMON_ONLY", "leaked"); }); let cwd = dir.display().to_string(); - // Hand-rolled hello: PATH + /bin/sh (so the job runs), a marker, and a + // Hand-rolled hello: PATH + /bin/sh (so the task runs), a marker, and a // non-UTF-8 var (0xFF/0xFE are invalid anywhere in a UTF-8 sequence) that // must not break the spawn. let path = std::env::var("PATH").unwrap_or_default(); @@ -29,15 +26,7 @@ fn spawn_runs_under_the_hello_env() { (b"FLEETCOM_MARKER".as_slice(), b"from-client".as_slice()), (b"FLEETCOM_BAD".as_slice(), b"ok\xff\xfe".as_slice()), ]; - stream - .write_all(&hello_frame(PROTOCOL_VERSION, &env, &cwd)) - .unwrap(); - let (_, payload) = read_frame(&mut stream).expect("no reply to hello"); - assert!( - String::from_utf8_lossy(&payload).contains("hello_ok"), - "hello was refused: {}", - String::from_utf8_lossy(&payload) - ); + shake_hands_env(&mut stream, &cwd, &env); let out = dir.join("out"); let command = format!( @@ -51,14 +40,14 @@ fn spawn_runs_under_the_hello_env() { let wrote = wait_until(Duration::from_secs(5), || { std::fs::read_to_string(&out).is_ok_and(|c| !c.is_empty()) }); - assert!(wrote, "the spawned job never wrote its output"); + assert!(wrote, "the spawned task never wrote its output"); assert_eq!( std::fs::read_to_string(&out).unwrap(), "from-client:absent", - "job must see the client's env and not the daemon's" + "task must see the client's env and not the daemon's" ); - // Clean shutdown; the job already exited on its own. + // Clean shutdown; the task already exited on its own. stop_daemon(&mut daemon); let _ = std::fs::remove_dir_all(&dir); } diff --git a/tests/daemon_kill.rs b/tests/daemon_kill.rs index f407053..9bc39e5 100644 --- a/tests/daemon_kill.rs +++ b/tests/daemon_kill.rs @@ -1,4 +1,4 @@ -//! `fleetcom --kill` must stop the daemon and its jobs while another client is +//! `fleetcom --kill` must stop the daemon and its tasks while another client is //! attached. mod common; @@ -10,16 +10,16 @@ use std::{ use nix::sys::signal::kill; -use common::{spawn_job, start_daemon, wait_until}; +use common::{spawn_task, start_daemon, wait_until}; #[test] fn kill_works_while_a_client_is_attached() { let (dir, mut daemon, mut stream) = start_daemon("kill", |_| {}); let sock = dir.join("default.sock"); - // A long-lived job that records its pid (== its pgid, via setsid). - let pidfile = dir.join("job.pid"); - let job = spawn_job( + // A long-lived task that records its pid (== its pgid, via setsid). + let pidfile = dir.join("task.pid"); + let task = spawn_task( &mut stream, &dir, &pidfile, @@ -47,7 +47,7 @@ fn kill_works_while_a_client_is_attached() { "--kill exited with an error" ); - // --kill returning means the teardown is complete: daemon exited, job + // --kill returning means the teardown is complete: daemon exited, task // group-killed, socket removed. assert!( wait_until(Duration::from_secs(5), || { @@ -56,8 +56,8 @@ fn kill_works_while_a_client_is_attached() { "daemon still running after --kill returned" ); assert!( - wait_until(Duration::from_secs(5), || kill(job, None).is_err()), - "job survived --kill" + wait_until(Duration::from_secs(5), || kill(task, None).is_err()), + "task survived --kill" ); assert!(!sock.exists(), "socket file left behind"); diff --git a/tests/daemon_lifetime.rs b/tests/daemon_lifetime.rs index 164d5fc..ccf3234 100644 --- a/tests/daemon_lifetime.rs +++ b/tests/daemon_lifetime.rs @@ -1,6 +1,6 @@ //! The fleet's lifetime is bounded by the daemon's: SIGKILLing the daemon -//! closes every PTY master, and the resulting hangup SIGHUPs each job's -//! foreground group. Ordinary jobs die; only HUP-immune jobs survive, unowned. +//! closes every PTY master, and the resulting hangup SIGHUPs each task's +//! foreground group. Ordinary tasks die; only HUP-immune tasks survive, unowned. //! These tests pin both halves so the docs stay honest. mod common; @@ -12,43 +12,43 @@ use nix::{ unistd::Pid, }; -use common::{spawn_job, start_daemon, wait_until}; +use common::{spawn_task, start_daemon, wait_until}; #[test] -fn ordinary_jobs_die_with_a_sigkilled_daemon() { +fn ordinary_tasks_die_with_a_sigkilled_daemon() { let (dir, mut daemon, mut stream) = start_daemon("hup_dies", |_| {}); - let pidfile = dir.join("job.pid"); - let job = spawn_job( + let pidfile = dir.join("task.pid"); + let task = spawn_task( &mut stream, &dir, &pidfile, &format!("echo $$ > {}; exec sleep 300", pidfile.display()), ); - assert!(kill(job, None).is_ok(), "job should be alive"); + assert!(kill(task, None).is_ok(), "task should be alive"); // SIGKILL: no shutdown path runs; only the fd-close/HUP mechanism remains. kill(Pid::from_raw(daemon.0.id() as i32), Signal::SIGKILL).unwrap(); let _ = daemon.0.wait(); assert!( - wait_until(Duration::from_secs(5), || kill(job, None).is_err()), - "an ordinary job must die with the daemon (PTY hangup)" + wait_until(Duration::from_secs(5), || kill(task, None).is_err()), + "an ordinary task must die with the daemon (PTY hangup)" ); let _ = std::fs::remove_dir_all(&dir); } #[test] -fn hup_immune_jobs_survive_a_sigkilled_daemon_unowned() { +fn hup_immune_tasks_survive_a_sigkilled_daemon_unowned() { let (dir, mut daemon, mut stream) = start_daemon("hup_immune", |_| {}); - let pidfile = dir.join("job.pid"); + let pidfile = dir.join("task.pid"); // The trap precedes the long sleep, and the fg child inherits the ignore. - let job = spawn_job( + let task = spawn_task( &mut stream, &dir, &pidfile, &format!("trap '' HUP; echo $$ > {}; sleep 300", pidfile.display()), ); - assert!(kill(job, None).is_ok(), "job should be alive"); + assert!(kill(task, None).is_ok(), "task should be alive"); kill(Pid::from_raw(daemon.0.id() as i32), Signal::SIGKILL).unwrap(); let _ = daemon.0.wait(); @@ -59,12 +59,12 @@ fn hup_immune_jobs_survive_a_sigkilled_daemon_unowned() { while Instant::now() < deadline { std::thread::sleep(Duration::from_millis(100)); } - let survived = kill(job, None).is_ok(); + let survived = kill(task, None).is_ok(); // Clean up the survivor either way before asserting. - let _ = killpg(job, Signal::SIGKILL); + let _ = killpg(task, Signal::SIGKILL); assert!( survived, - "a HUP-immune job should have outlived the daemon (unowned)" + "a HUP-immune task should have outlived the daemon (unowned)" ); let _ = std::fs::remove_dir_all(&dir); } diff --git a/tests/daemon_resume.rs b/tests/daemon_resume.rs index 120bec5..2af4027 100644 --- a/tests/daemon_resume.rs +++ b/tests/daemon_resume.rs @@ -203,6 +203,23 @@ fn value_after<'a>(argv: &'a [String], flag: &str) -> &'a str { .unwrap_or_else(|| panic!("{flag} carries no value: {argv:?}")) } +/// Assert that `asset` exists in a namespace prefixed by the daemon's PID. +fn assert_daemon_namespaced(asset: &Path, daemon_pid: u32, what: &str, argv: &[String]) { + let ns = asset + .parent() + .unwrap_or_else(|| panic!("the {what} must sit in a namespace")); + assert!( + ns.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with(&format!("{daemon_pid}-"))), + "the namespace must carry the daemon's pid prefix: {argv:?}" + ); + assert!( + asset.is_file(), + "the daemon's namespace must hold the installed {what}" + ); +} + /// Save once and return the persisted recipe. Each caller first waits for its /// ID channel, and the daemon scrapes finished tasks before reading IDs. As a /// result, one save must already contain the resume form. @@ -261,22 +278,7 @@ fn claude_spawn_save_load_resumes_the_conversation() { let id = value_after(&argv, "--session-id").to_string(); assert_eq!(id.len(), 36, "pinned id must be uuid-shaped: {argv:?}"); let settings = PathBuf::from(value_after(&argv, "--settings")); - // The namespace layout itself is pinned by the supervisor capture unit - // tests; the integration-only facts are the DAEMON's pid in the - // namespace name (a cross-process fact) and the asset existing on disk. - let ns = settings - .parent() - .expect("the overlay must sit in a namespace"); - assert!( - ns.file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.starts_with(&format!("{}-", daemon.0.id()))), - "the namespace must carry the daemon's pid prefix: {argv:?}" - ); - assert!( - settings.is_file(), - "the daemon's namespace must hold the installed settings overlay" - ); + assert_daemon_namespaced(&settings, daemon.0.id(), "settings overlay", &argv); // The pinned id rides `resume_id` from spawn: one save suffices. let recipe = save_once(&mut stream, &s.recipe("story"), "story"); @@ -330,20 +332,7 @@ fn codex_capture_file_drives_save_and_load_resumes() { .and_then(|v| v.strip_suffix(r#""]"#)) .map(PathBuf::from) .unwrap_or_else(|| panic!("spawn must route notify at one script: {argv:?}")); - // The namespace layout itself is pinned by the supervisor capture unit - // tests; the integration-only facts are the DAEMON's pid in the - // namespace name (a cross-process fact) and the asset existing on disk. - let ns = script.parent().expect("the script must sit in a namespace"); - assert!( - ns.file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.starts_with(&format!("{}-", daemon.0.id()))), - "the namespace must carry the daemon's pid prefix: {argv:?}" - ); - assert!( - script.is_file(), - "the daemon's namespace must hold the installed notify script" - ); + assert_daemon_namespaced(&script, daemon.0.id(), "notify script", &argv); // The stub exits silently, so its capture write is the only id channel; // wait for the file, then a single save must persist the resuming form. diff --git a/tests/daemon_session.rs b/tests/daemon_session.rs index 2bb133c..4fba788 100644 --- a/tests/daemon_session.rs +++ b/tests/daemon_session.rs @@ -10,8 +10,8 @@ use std::{ }; use common::{ - PROTOCOL_VERSION, control_frame, hello_frame, read_frame, start_daemon, start_daemon_raw, - stop_daemon, wait_until, + control_frame, read_frame, shake_hands_env, start_daemon, start_daemon_raw, stop_daemon, + wait_until, }; #[test] @@ -70,15 +70,7 @@ fn session_commands_follow_the_hello_config_dir() { let client_cfg_str = client_cfg.display().to_string(); let env: Vec<(&[u8], &[u8])> = vec![(b"FLEETCOM_CONFIG_DIR".as_slice(), client_cfg_str.as_bytes())]; - stream - .write_all(&hello_frame(PROTOCOL_VERSION, &env, &cwd)) - .unwrap(); - let (_, payload) = read_frame(&mut stream).expect("no reply to hello"); - assert!( - String::from_utf8_lossy(&payload).contains("hello_ok"), - "hello was refused: {}", - String::from_utf8_lossy(&payload) - ); + shake_hands_env(&mut stream, &cwd, &env); stream .write_all(&control_frame(r#"{"t":"save","name":"where"}"#)) diff --git a/tests/daemon_signal.rs b/tests/daemon_signal.rs index 6aed263..b57e9f1 100644 --- a/tests/daemon_signal.rs +++ b/tests/daemon_signal.rs @@ -1,5 +1,5 @@ //! End-to-end daemon signal handling: SIGTERM to a serving daemon must -//! group-kill its jobs, remove its socket, and exit. The jobs live in their own +//! group-kill its tasks, remove its socket, and exit. The tasks live in their own //! process groups and must be terminated explicitly during daemon shutdown. mod common; @@ -8,17 +8,17 @@ use std::time::Duration; use nix::sys::signal::kill; -use common::{spawn_job, start_daemon, stop_daemon, wait_until}; +use common::{spawn_task, start_daemon, stop_daemon, wait_until}; #[test] -fn sigterm_kills_daemon_and_its_jobs() { +fn sigterm_kills_daemon_and_its_tasks() { let (dir, mut daemon, mut stream) = start_daemon("sigterm", |_| {}); let sock = dir.join("default.sock"); - // Spawn a job that records its own pid ($$ is the setsid'd shell, so pid == + // Spawn a task that records its own pid ($$ is the setsid'd shell, so pid == // pgid) and then outlives the test unless killed. - let pidfile = dir.join("job.pid"); - let job = spawn_job( + let pidfile = dir.join("task.pid"); + let task = spawn_task( &mut stream, &dir, &pidfile, @@ -26,18 +26,18 @@ fn sigterm_kills_daemon_and_its_jobs() { ); // Signal 0: existence check only. assert!( - kill(job, None).is_ok(), - "job should be alive before SIGTERM" + kill(task, None).is_ok(), + "task should be alive before SIGTERM" ); // SIGTERM the daemon *while our client is attached*: the flag must // interrupt `run_loop` mid-serve, not just the idle accept loop. stop_daemon(&mut daemon); - // The job was group-killed on the way out (grace: init still has to reap + // The task was group-killed on the way out (grace: init still has to reap // the reparented child before ESRCH). - let job_dead = wait_until(Duration::from_secs(5), || kill(job, None).is_err()); - assert!(job_dead, "job survived the daemon's SIGTERM shutdown"); + let task_dead = wait_until(Duration::from_secs(5), || kill(task, None).is_err()); + assert!(task_dead, "task survived the daemon's SIGTERM shutdown"); // Clean shutdown removes the socket. assert!(!sock.exists(), "socket file left behind");