use std::path::{Path, PathBuf};
use ratatui::{Terminal, backend::TestBackend};
use trusty_mpm::client::ManagedSessionSummary;
use trusty_mpm::project::{Project, local_checkout_for};
use trusty_mpm::session_manager::rename::validate_session_name;
use super::layout::{self, Column};
use super::new_session::{
self, NewProject, NewSessionFlow, NewSessionRequest, ProjectIdentity, Step, Target,
};
use super::render;
use super::state::{Action, Input, Mode, Severity, TuiState, is_self_session};
use super::{delete_outcome, map_key};
use crate::commands::picker_delete::DeleteReport;
use crate::commands::tmux_attach::AttachOutcome;
fn session(name: &str, state: &str, slot: u32) -> ManagedSessionSummary {
ManagedSessionSummary {
id: format!("{name}-id"),
name: name.to_string(),
state: state.to_string(),
persisted_state: None,
workspace_path: None,
repo_url: None,
branch: None,
created_at: None,
last_activity_at: None,
pending_decision: None,
proposed_default: None,
source_id: None,
task: None,
cwd: None,
claude_session_id: None,
deliverable_id: None,
pane_id: None,
injection_status: None,
unresumable: false,
stale_assets: false,
stale_assets_unchecked: false,
attached: false,
slot,
deleted: false,
auto_resume_parked: None,
kind: None,
}
}
fn fleet() -> Vec<ManagedSessionSummary> {
let mut a = session("tm-alpha-01", "active", 1);
a.source_id = Some("bobmatnyc/trusty-tools".to_string());
a.pane_id = Some("%11".to_string());
let b = session("tm-bravo-02", "stopped", 2);
let mut c = session("tm-charlie-03", "active", 3);
c.attached = true;
vec![a, b, c]
}
fn browsing() -> TuiState {
let mut state = TuiState::new(None, None);
state.sync(&fleet());
state
}
#[test]
fn layout_columns_full_set_at_wide_width() {
assert_eq!(
layout::visible_columns(200),
vec![
Column::Name,
Column::Id,
Column::Project,
Column::State,
Column::Tmux
]
);
}
#[test]
fn layout_columns_drops_tmux_then_id_at_eighty() {
assert_eq!(
layout::visible_columns(80),
vec![Column::Name, Column::Project, Column::State]
);
assert_eq!(
layout::visible_columns(91),
vec![Column::Name, Column::Id, Column::Project, Column::State]
);
assert_eq!(
layout::visible_columns(92),
vec![
Column::Name,
Column::Id,
Column::Project,
Column::State,
Column::Tmux
],
"92 is the exact width the full set fits in"
);
}
#[test]
fn layout_columns_keeps_name_at_absurdly_narrow_width() {
for width in [0u16, 1, 5, 20] {
assert_eq!(
layout::visible_columns(width),
vec![Column::Name],
"width {width}"
);
}
}
#[test]
fn layout_cell_shortens_the_id_without_an_ellipsis() {
let s = session("tm-alpha-01", "active", 1);
let cell = layout::cell(Column::Id, &s);
assert_eq!(cell.chars().count(), layout::SHORT_ID_LEN);
assert!(
!cell.contains('…'),
"a short id must stay pasteable: {cell}"
);
assert!(s.id.starts_with(&cell));
}
#[test]
fn layout_cell_truncates_a_long_name_with_an_ellipsis() {
let s = session(&"x".repeat(80), "active", 1);
let cell = layout::cell(Column::Name, &s);
assert_eq!(cell.chars().count(), layout::width(Column::Name) as usize);
assert!(cell.ends_with('…'), "expected an ellipsis, got {cell}");
}
#[test]
fn layout_cell_placeholders_absent_project_and_pane() {
let s = session("tm-alpha-01", "active", 1);
assert_eq!(layout::cell(Column::Project, &s), "-");
assert_eq!(layout::cell(Column::Tmux, &s), "-");
}
#[test]
fn layout_cell_state_uses_the_shared_annotations() {
let mut s = session("tm-alpha-01", "stopped", 1);
s.unresumable = true;
assert!(layout::state_cell(&s).contains("[dead]"));
let mut attached = session("tm-charlie-03", "active", 3);
attached.attached = true;
assert_eq!(layout::state_cell(&attached), "attached");
}
#[test]
fn window_keeps_selection_visible_scrolling_down() {
let (start, len) = layout::visible_window(20, 12, 5, 0);
assert_eq!(len, 5);
assert!((start..start + len).contains(&12), "start {start}");
}
#[test]
fn window_holds_still_while_the_selection_stays_visible() {
assert_eq!(layout::visible_window(20, 6, 5, 5), (5, 5));
}
#[test]
fn window_clamps_to_the_end_of_the_list() {
let (start, len) = layout::visible_window(6, 5, 5, 99);
assert_eq!((start, len), (1, 5));
assert!(start + len <= 6);
}
#[test]
fn window_zero_height_is_empty() {
assert_eq!(layout::visible_window(20, 3, 0, 0), (0, 0));
assert_eq!(layout::visible_window(0, 0, 10, 0), (0, 0));
}
#[test]
fn state_navigation_moves_and_clamps_at_both_ends() {
let sessions = fleet();
let mut state = browsing();
assert_eq!(state.selected(), 0);
assert_eq!(state.apply(Input::Down, &sessions), Action::Redraw);
assert_eq!(state.selected(), 1);
assert_eq!(state.apply(Input::End, &sessions), Action::Redraw);
assert_eq!(state.selected(), 2);
assert_eq!(state.apply(Input::Down, &sessions), Action::Ignore);
assert_eq!(state.apply(Input::Home, &sessions), Action::Redraw);
assert_eq!(state.selected(), 0);
assert_eq!(state.apply(Input::Up, &sessions), Action::Ignore);
}
#[test]
fn state_vi_keys_mirror_the_arrows() {
let sessions = fleet();
let mut state = browsing();
state.apply(Input::Char('j'), &sessions);
assert_eq!(state.selected(), 1);
state.apply(Input::Char('k'), &sessions);
assert_eq!(state.selected(), 0);
state.apply(Input::Char('G'), &sessions);
assert_eq!(state.selected(), 2);
state.apply(Input::Char('g'), &sessions);
assert_eq!(state.selected(), 0);
}
#[test]
fn state_quit_keys_all_leave() {
let sessions = fleet();
for input in [Input::Char('q'), Input::Escape, Input::Cancel] {
let mut state = browsing();
assert_eq!(state.apply(input, &sessions), Action::Quit, "{input:?}");
}
}
#[test]
fn state_enter_opens_the_selected_row() {
let sessions = fleet();
let mut state = browsing();
state.apply(Input::Down, &sessions);
assert_eq!(state.apply(Input::Enter, &sessions), Action::Open(1));
}
#[test]
fn state_enter_on_an_empty_fleet_does_nothing() {
let mut state = TuiState::new(None, None);
state.sync(&[]);
assert_eq!(state.apply(Input::Enter, &[]), Action::Ignore);
}
#[test]
fn state_refresh_key_asks_for_a_refetch() {
let sessions = fleet();
let mut state = browsing();
assert_eq!(state.apply(Input::Char('R'), &sessions), Action::Refresh);
}
#[test]
fn state_help_opens_and_any_key_closes_it() {
let sessions = fleet();
let mut state = browsing();
state.apply(Input::Char('?'), &sessions);
assert_eq!(state.mode(), &Mode::Help);
state.apply(Input::Char('x'), &sessions);
assert_eq!(state.mode(), &Mode::Browse);
}
#[test]
fn state_sync_follows_the_pinned_session_across_a_reorder() {
let sessions = fleet();
let mut state = browsing();
state.apply(Input::Down, &sessions); let reordered = vec![
sessions[2].clone(),
sessions[1].clone(),
sessions[0].clone(),
];
state.sync(&reordered);
assert_eq!(state.selected(), 1);
assert_eq!(reordered[state.selected()].name, "tm-bravo-02");
}
#[test]
fn state_sync_clamps_when_the_pinned_session_is_gone() {
let sessions = fleet();
let mut state = browsing();
state.apply(Input::End, &sessions); let shrunk = vec![sessions[0].clone()];
state.sync(&shrunk);
assert_eq!(state.selected(), 0);
state.sync(&shrunk);
assert_eq!(state.selected(), 0);
}
#[test]
fn state_sync_drops_an_overlay_whose_row_vanished() {
let sessions = fleet();
let mut state = browsing();
state.apply(Input::End, &sessions);
state.apply(Input::Char('r'), &sessions);
assert!(matches!(state.mode(), Mode::Rename { .. }));
state.sync(&sessions[..1]);
assert_eq!(state.mode(), &Mode::Browse);
}
#[test]
fn state_delete_refuses_the_attached_self_session() {
let sessions = fleet();
let mut state = TuiState::new(Some("tm-alpha-01-id".to_string()), None);
state.sync(&sessions);
assert_eq!(state.apply(Input::Char('d'), &sessions), Action::Redraw);
assert_eq!(state.mode(), &Mode::Browse, "no confirm dialog may open");
let (text, severity) = state.message().expect("a visible refusal");
assert_eq!(severity, Severity::Error);
assert!(
text.contains("tm-alpha-01") && text.contains("attached"),
"refusal must name the session and why: {text}"
);
}
#[test]
fn state_delete_on_a_stopped_row_confirms_with_yes() {
let sessions = fleet();
let mut state = browsing();
state.apply(Input::Down, &sessions); assert_eq!(state.apply(Input::Char('d'), &sessions), Action::Redraw);
assert_eq!(
state.mode(),
&Mode::Confirm {
index: 1,
force: false,
stop_first: false,
typed: String::new()
}
);
state.apply(Input::Char('y'), &sessions);
assert_eq!(
state.apply(Input::Enter, &sessions),
Action::Delete {
index: 1,
force: false,
stop_first: false
}
);
}
#[test]
fn state_delete_on_an_errored_row_asks_to_stop_and_delete() {
let mut sessions = fleet();
sessions[1].state = "errored".to_string();
let mut state = browsing();
state.sync(&sessions);
state.apply(Input::Down, &sessions); assert_eq!(state.apply(Input::Char('d'), &sessions), Action::Redraw);
assert_eq!(
state.mode(),
&Mode::Confirm {
index: 1,
force: false,
stop_first: true,
typed: String::new()
},
"an errored row must open a stop-and-delete confirm, not a plain one"
);
state.apply(Input::Char('y'), &sessions);
assert_eq!(
state.apply(Input::Enter, &sessions),
Action::Delete {
index: 1,
force: false,
stop_first: true
}
);
}
#[test]
fn state_delete_sets_stop_first_only_for_errored() {
let sessions = fleet();
for (index, expect_stop) in [(0usize, false), (1, false)] {
let mut state = browsing();
state.apply(
if index == 0 { Input::Home } else { Input::Down },
&sessions,
);
state.apply(Input::Char('d'), &sessions);
match state.mode() {
Mode::Confirm { stop_first, .. } => {
assert_eq!(*stop_first, expect_stop, "row {index}");
}
other => panic!("row {index} must open a confirm, got {other:?}"),
}
}
}
#[test]
fn state_delete_on_a_running_row_requires_the_force_word() {
let sessions = fleet();
let mut state = browsing(); state.apply(Input::Char('d'), &sessions);
assert!(matches!(state.mode(), Mode::Confirm { force: true, .. }));
for c in "y".chars() {
state.apply(Input::Char(c), &sessions);
}
assert_eq!(state.apply(Input::Enter, &sessions), Action::Redraw);
assert_eq!(state.mode(), &Mode::Browse);
assert_eq!(
state.message().expect("cancel notice").0,
"delete cancelled"
);
let mut state = browsing();
state.apply(Input::Char('d'), &sessions);
for c in "force".chars() {
state.apply(Input::Char(c), &sessions);
}
assert_eq!(
state.apply(Input::Enter, &sessions),
Action::Delete {
index: 0,
force: true,
stop_first: false
}
);
}
#[test]
fn state_confirm_backspace_and_escape_both_step_back() {
let sessions = fleet();
let mut state = browsing();
state.apply(Input::Down, &sessions);
state.apply(Input::Char('d'), &sessions);
state.apply(Input::Char('y'), &sessions);
state.apply(Input::Backspace, &sessions);
assert_eq!(state.apply(Input::Enter, &sessions), Action::Redraw);
let mut state = browsing();
state.apply(Input::Down, &sessions);
state.apply(Input::Char('d'), &sessions);
assert_eq!(state.apply(Input::Escape, &sessions), Action::Redraw);
assert_eq!(state.mode(), &Mode::Browse);
}
#[test]
fn is_self_session_matches_by_env_id() {
let s = session("tm-alpha-01", "active", 1);
assert!(is_self_session(&s, Some("tm-alpha-01-id"), None));
assert!(!is_self_session(&s, Some("someone-else"), None));
}
#[test]
fn is_self_session_matches_by_tmux_name() {
let s = session("tm-alpha-01", "active", 1);
assert!(is_self_session(&s, None, Some("tm-alpha-01")));
assert!(!is_self_session(&s, None, Some("tm-other-09")));
}
#[test]
fn is_self_session_false_outside_a_managed_pane() {
let s = session("tm-alpha-01", "active", 1);
assert!(!is_self_session(&s, None, None));
}
#[test]
fn state_rename_seeds_the_overlay_with_the_current_name() {
let sessions = fleet();
let mut state = browsing();
assert_eq!(state.apply(Input::Char('r'), &sessions), Action::Redraw);
assert_eq!(
state.mode(),
&Mode::Rename {
index: 0,
typed: "tm-alpha-01".to_string()
}
);
}
#[test]
fn state_rename_rejects_an_invalid_name_with_the_shared_validator() {
let sessions = fleet();
let mut state = browsing();
state.apply(Input::Char('r'), &sessions);
for _ in 0.."tm-alpha-01".len() {
state.apply(Input::Backspace, &sessions);
}
for c in "bad name!".chars() {
state.apply(Input::Char(c), &sessions);
}
assert_eq!(state.apply(Input::Enter, &sessions), Action::Redraw);
assert!(matches!(state.mode(), Mode::Rename { .. }));
let (text, severity) = state.message().expect("a validation error");
assert_eq!(severity, Severity::Error);
assert_eq!(
text,
validate_session_name("bad name!").expect_err("fixture must be invalid")
);
}
#[test]
fn state_rename_sends_the_trimmed_validated_name() {
let sessions = fleet();
let mut state = browsing();
state.apply(Input::Char('r'), &sessions);
for _ in 0.."tm-alpha-01".len() {
state.apply(Input::Backspace, &sessions);
}
for c in "tm-renamed ".chars() {
state.apply(Input::Char(c), &sessions);
}
assert_eq!(
state.apply(Input::Enter, &sessions),
Action::Rename {
index: 0,
name: "tm-renamed".to_string()
}
);
assert_eq!(state.mode(), &Mode::Browse);
}
#[test]
fn state_rename_to_the_same_name_is_not_a_round_trip() {
let sessions = fleet();
let mut state = browsing();
state.apply(Input::Char('r'), &sessions);
assert_eq!(state.apply(Input::Enter, &sessions), Action::Redraw);
assert_eq!(state.message().expect("a notice").0, "name unchanged");
}
#[test]
fn state_rename_escape_cancels() {
let sessions = fleet();
let mut state = browsing();
state.apply(Input::Char('r'), &sessions);
assert_eq!(state.apply(Input::Escape, &sessions), Action::Redraw);
assert_eq!(state.mode(), &Mode::Browse);
assert_eq!(state.message().expect("a notice").0, "rename cancelled");
}
#[test]
fn state_rename_overlay_treats_action_keys_as_text() {
let sessions = fleet();
let mut state = browsing();
state.apply(Input::Char('r'), &sessions);
for _ in 0.."tm-alpha-01".len() {
state.apply(Input::Backspace, &sessions);
}
for c in "dqr".chars() {
state.apply(Input::Char(c), &sessions);
}
assert_eq!(
state.mode(),
&Mode::Rename {
index: 0,
typed: "dqr".to_string()
}
);
}
#[test]
fn delete_outcome_names_the_soft_delete() {
let (text, severity) = delete_outcome(
"tm-bravo-02",
Ok(DeleteReport::Deleted {
name: "tm-bravo-02".to_string(),
prior_state: "stopped".to_string(),
local: false,
note: None,
}),
);
assert_eq!(severity, Severity::Info);
assert!(
text.contains("--deleted--") && text.contains("still listed"),
"a managed delete is soft and must say so: {text}"
);
}
#[test]
fn delete_outcome_reports_the_local_store() {
let (text, _) = delete_outcome(
"local-01",
Ok(DeleteReport::Deleted {
name: "local-01".to_string(),
prior_state: "stopped".to_string(),
local: true,
note: None,
}),
);
assert!(text.contains("project store"), "{text}");
}
#[test]
fn delete_outcome_says_a_live_session_was_left_running() {
let note = "tmux session 'tm-x' is live but does not hold this record's pane %7; \
it was left running";
let (text, _) = delete_outcome(
"tm-x",
Ok(DeleteReport::Deleted {
name: "tm-x".to_string(),
prior_state: "stopped".to_string(),
local: false,
note: Some(note.to_string()),
}),
);
assert!(
text.contains("record only") && text.contains(note),
"{text}"
);
}
#[test]
fn delete_outcome_surfaces_a_refusal() {
let (text, severity) = delete_outcome(
"tm-alpha-01",
Ok(DeleteReport::Refused("session is running\n".to_string())),
);
assert_eq!(severity, Severity::Error);
assert_eq!(text, "delete refused: session is running");
}
#[test]
fn delete_outcome_reports_a_failed_stop_as_not_deleted() {
let (text, severity) = delete_outcome(
"tm-bravo-02",
Ok(DeleteReport::StopFailed(
"stop returned HTTP 500 Internal Server Error: tmux driver unavailable".to_string(),
)),
);
assert_eq!(severity, Severity::Error);
assert!(text.contains("NOT deleted"), "{text}");
assert!(text.contains("tmux driver unavailable"), "{text}");
assert!(text.contains("500"), "{text}");
}
#[test]
fn delete_outcome_reports_not_found() {
let (text, severity) = delete_outcome("gone-01", Ok(DeleteReport::NotFound));
assert_eq!(severity, Severity::Info);
assert!(text.contains("already gone"), "{text}");
}
#[test]
fn delete_outcome_reports_a_transport_error() {
let (text, severity) =
delete_outcome("tm-alpha-01", Err(anyhow::anyhow!("connection refused")));
assert_eq!(severity, Severity::Error);
assert!(text.contains("connection refused"), "{text}");
}
#[test]
fn input_maps_ctrl_c_to_cancel() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
for c in ['c', 'd'] {
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL);
assert_eq!(map_key(key), Some(Input::Cancel), "ctrl-{c}");
}
}
#[test]
fn input_maps_ctrl_n_to_name_new() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let chords = [
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('N'), KeyModifiers::CONTROL),
KeyEvent::new(
KeyCode::Char('N'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
),
];
for key in chords {
assert_eq!(map_key(key), Some(Input::NameNew), "{key:?}");
}
let plain = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE);
assert_eq!(map_key(plain), Some(Input::Char('n')));
}
#[test]
fn input_ignores_key_release() {
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
let key = KeyEvent::new_with_kind_and_state(
KeyCode::Char('q'),
KeyModifiers::NONE,
KeyEventKind::Release,
KeyEventState::NONE,
);
assert_eq!(map_key(key), None);
}
#[test]
fn input_passes_characters_through_unresolved() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let key = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE);
assert_eq!(map_key(key), Some(Input::Char('d')));
let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(map_key(key), Some(Input::Escape));
}
fn draw(w: u16, h: u16, sessions: &[ManagedSessionSummary], state: &mut TuiState) -> Vec<String> {
let mut terminal = Terminal::new(TestBackend::new(w, h)).expect("test terminal");
terminal
.draw(|frame| render::render(frame, sessions, state))
.expect("render must not panic");
let buffer = terminal.backend().buffer().clone();
(0..h)
.map(|y| {
(0..w)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
}
#[test]
fn render_at_eighty_by_twentyfour_drops_the_widest_columns() {
let sessions = fleet();
let mut state = browsing();
let screen = draw(80, 24, &sessions, &mut state);
let joined = screen.join("\n");
assert!(joined.contains("NAME"), "{joined}");
assert!(joined.contains("PROJECT"), "{joined}");
assert!(joined.contains("STATE"), "{joined}");
assert!(!joined.contains("TMUX"), "{joined}");
assert!(joined.contains("tm-alpha-01"), "{joined}");
for line in &screen {
assert!(line.chars().count() <= 80, "overlong row: {line}");
}
assert!(joined.contains("Enter open"), "{joined}");
assert!(joined.contains("q quit"), "{joined}");
}
#[test]
fn render_at_two_hundred_by_fifty_shows_every_column() {
let sessions = fleet();
let mut state = browsing();
let joined = draw(200, 50, &sessions, &mut state).join("\n");
for header in ["NAME", "ID", "PROJECT", "STATE", "TMUX"] {
assert!(joined.contains(header), "missing {header} in\n{joined}");
}
for name in ["tm-alpha-01", "tm-bravo-02", "tm-charlie-03"] {
assert!(joined.contains(name), "missing {name} in\n{joined}");
}
assert!(joined.contains("bobmatnyc/trusty-tools"), "{joined}");
assert!(joined.contains("%11"), "{joined}");
}
#[test]
fn confirm_overlay_asks_the_shared_errored_question() {
let sessions = vec![session("tm-quiet-falcon", "errored", 4)];
let mut state = TuiState::new(None, None);
state.sync(&sessions);
state.apply(Input::Char('d'), &sessions);
let joined = draw(200, 50, &sessions, &mut state).join("\n");
let ask = crate::commands::picker_delete::errored_confirm_ask("tm-quiet-falcon");
assert!(joined.contains(&ask), "expected {ask:?} in\n{joined}");
}
#[test]
fn render_tiny_terminal_does_not_panic() {
let sessions = fleet();
for (w, h) in [(20u16, 5u16), (10, 3), (5, 2), (1, 1)] {
for opener in ['?', 'd', 'r'] {
let mut state = browsing();
state.apply(Input::Char(opener), &sessions);
let screen = draw(w, h, &sessions, &mut state);
assert_eq!(screen.len(), h as usize, "{w}x{h} mode {opener}");
}
}
}
#[test]
fn render_empty_fleet_says_so() {
let mut state = TuiState::new(None, None);
state.sync(&[]);
let joined = draw(80, 24, &[], &mut state).join("\n");
assert!(joined.contains("no sessions"), "{joined}");
}
#[test]
fn render_scrolls_to_keep_the_selection_visible() {
let sessions: Vec<_> = (1..=40)
.map(|i| session(&format!("tm-row-{i:02}"), "stopped", i))
.collect();
let mut state = TuiState::new(None, None);
state.sync(&sessions);
for _ in 0..30 {
state.apply(Input::Down, &sessions);
}
let joined = draw(120, 10, &sessions, &mut state).join("\n");
assert!(
joined.contains("tm-row-31"),
"selection off screen:\n{joined}"
);
}
#[test]
fn render_confirm_overlay_names_the_word_it_wants() {
let sessions = fleet();
let mut state = browsing();
state.apply(Input::Down, &sessions); state.apply(Input::Char('d'), &sessions);
let joined = draw(100, 24, &sessions, &mut state).join("\n");
assert!(joined.contains("tm-bravo-02"), "{joined}");
assert!(joined.contains("Type y"), "{joined}");
let mut state = browsing(); state.apply(Input::Char('d'), &sessions);
let joined = draw(100, 24, &sessions, &mut state).join("\n");
assert!(joined.contains("force"), "{joined}");
}
#[test]
fn render_help_overlay_lists_every_action_key() {
let sessions = fleet();
let mut state = browsing();
state.apply(Input::Char('?'), &sessions);
let joined = draw(100, 30, &sessions, &mut state).join("\n");
for token in ["rename", "delete", "refresh", "quit"] {
assert!(joined.contains(token), "help omits {token}:\n{joined}");
}
}
#[test]
fn render_rows_pair_each_name_with_its_state() {
let sessions = fleet();
let mut state = browsing();
let lines = draw(140, 24, &sessions, &mut state);
for (name, expected_state) in [
("tm-alpha-01", "active"),
("tm-bravo-02", "stopped"),
("tm-charlie-03", "attached"),
] {
let row = lines
.iter()
.find(|line| line.contains(name))
.unwrap_or_else(|| panic!("no row for {name} in\n{}", lines.join("\n")));
assert!(
row.contains(expected_state),
"row for {name} omits state {expected_state}: {row:?}"
);
}
}
#[test]
fn render_shows_a_rename_after_the_refetch() {
let mut sessions = fleet();
let mut state = browsing();
state.apply(Input::Down, &sessions); state.apply(Input::Char('r'), &sessions);
for _ in 0..11 {
state.apply(Input::Backspace, &sessions); }
for c in "tm-renamed-02".chars() {
state.apply(Input::Char(c), &sessions);
}
let action = state.apply(Input::Enter, &sessions);
let Action::Rename { index, name } = action else {
panic!("expected a rename action, got {action:?}");
};
assert_eq!(name, "tm-renamed-02");
sessions[index].name = name.clone();
let refetched: Vec<_> = sessions.iter().rev().cloned().collect(); state.sync(&refetched);
let joined = draw(140, 24, &refetched, &mut state).join("\n");
assert!(
joined.contains("tm-renamed-02"),
"rename not shown:\n{joined}"
);
assert!(
!joined.contains("tm-bravo-02"),
"old name still shown:\n{joined}"
);
assert_eq!(
refetched[state.selected()].name,
"tm-renamed-02",
"selection must follow the renamed session across the re-fetch"
);
}
const GUARD_REFUSAL: &str = "delete refused: session 'tm-quiet-falcon' is errored — stop it first \
with `tm session stop` (or `tm session decommission`), or pass --force to delete the record \
anyway. Session id:\n8fc5db8f-b0a5-5c47-9a5b-59cd2a1e4f77";
const GUARD_UUID: &str = "8fc5db8f-b0a5-5c47-9a5b-59cd2a1e4f77";
#[test]
fn wrap_message_keeps_a_uuid_whole_at_eighty_columns() {
let lines = layout::wrap_message(GUARD_REFUSAL, 80, layout::STATUS_MAX_LINES);
for line in &lines {
assert!(
line.chars().count() <= 80,
"wrapped line exceeds the width: {line}"
);
}
assert!(
lines.iter().any(|l| l.contains(GUARD_UUID)),
"the full session id must survive the wrap, got:\n{lines:#?}"
);
}
#[test]
fn wrap_message_honours_explicit_newlines() {
let lines = layout::wrap_message("first line\nsecond line", 80, 4);
assert_eq!(lines, vec!["first line", "second line"]);
}
#[test]
fn wrap_message_elides_on_a_token_boundary() {
let text = "aaaaa bbbbb ccccc ddddd eeeee fffff ggggg hhhhh iiiii jjjjj";
let lines = layout::wrap_message(text, 12, 2);
assert_eq!(lines.len(), 2);
assert!(lines[1].ends_with('…'), "{lines:#?}");
for line in &lines {
assert!(line.chars().count() <= 12, "{line}");
for token in line.trim_end_matches('…').split_whitespace() {
assert_eq!(token.chars().count(), 5, "token cut mid-word: {token}");
}
}
}
#[test]
fn wrap_message_keeps_the_session_id_at_narrow_widths() {
for width in [40usize, 60, 80] {
let lines = layout::wrap_message(GUARD_REFUSAL, width, layout::STATUS_MAX_LINES);
assert!(
lines.len() <= layout::STATUS_MAX_LINES,
"width {width} overflowed the status region:\n{lines:#?}"
);
for line in &lines {
assert!(
line.chars().count() <= width,
"width {width}: line exceeds it: {line}"
);
}
assert!(
lines.iter().any(|l| l.contains(GUARD_UUID)),
"width {width}: the full session id must survive, got:\n{lines:#?}"
);
}
}
#[test]
fn wrap_message_keeps_an_overlong_token_whole() {
let token = "z".repeat(200);
let lines = layout::wrap_message(&token, 40, layout::STATUS_MAX_LINES);
assert_eq!(lines, vec![token]);
}
#[test]
fn wrap_message_zero_width_is_empty() {
assert!(layout::wrap_message("anything", 0, 4).is_empty());
assert!(layout::wrap_message("anything", 80, 0).is_empty());
}
#[test]
fn render_wraps_a_long_refusal_without_cutting_the_uuid() {
let sessions = fleet();
let mut state = browsing();
state.set_message(GUARD_REFUSAL, Severity::Error);
let screen = draw(80, 24, &sessions, &mut state);
let joined = screen.join("\n");
assert!(
joined.contains(GUARD_UUID),
"the session id was cut by the status line:\n{joined}"
);
for line in &screen {
assert!(line.chars().count() <= 80, "overlong row: {line}");
}
assert!(joined.contains("NAME"), "{joined}");
assert!(joined.contains("q quit"), "{joined}");
}
#[test]
fn render_status_uses_one_row_when_there_is_no_message() {
let sessions = fleet();
let mut state = browsing();
let screen = draw(80, 24, &sessions, &mut state);
assert_eq!(screen.len(), 24);
assert!(screen.last().is_some_and(|l| l.contains("q quit")));
}
fn project(name: &str, repo_url: &str) -> Project {
serde_json::from_value(serde_json::json!({
"name": name,
"repo_url": repo_url,
"default_branch": "main",
}))
.expect("project fixture")
}
fn targets() -> Vec<Target> {
new_session::targets_from_with(
&[
project("trusty-tools", "https://github.com/bobmatnyc/trusty-tools"),
project("apex", "https://github.com/duetto/apex"),
],
&[],
stub_checkout,
)
}
fn fixture_checkout(name: &str) -> PathBuf {
let here = Path::new(env!("CARGO_MANIFEST_DIR"));
match name {
"apex" => here.join("src").join("daemon"),
"zeta" => here.join("src").join("client"),
_ => here.join("src"),
}
}
fn stub_checkout(project: &Project) -> Option<PathBuf> {
Some(fixture_checkout(&project.name))
}
fn missing_checkout(project: &Project) -> Option<PathBuf> {
Some(PathBuf::from("/nonexistent/7887").join(&project.name))
}
fn stub_identity(typed: &str) -> Option<ProjectIdentity> {
match typed {
"/w/widgets" => Some(ProjectIdentity {
name: "widgets".to_string(),
repo_url: "https://github.com/acme/widgets".to_string(),
root: "/w/widgets".to_string(),
}),
"/w/trusty-tools" => Some(ProjectIdentity {
name: "trusty-tools".to_string(),
repo_url: "https://github.com/bobmatnyc/trusty-tools".to_string(),
root: "/w/trusty-tools".to_string(),
}),
_ => None,
}
}
fn creating() -> TuiState {
let mut state = browsing();
state.open_new_session(NewSessionFlow::with_resolver(targets(), stub_identity));
state
}
#[test]
fn new_session_targets_from_puts_the_path_escape_last() {
let targets = targets();
assert_eq!(
targets.first(),
Some(&Target::Registered {
name: "trusty-tools".to_string(),
repo: "https://github.com/bobmatnyc/trusty-tools".to_string(),
checkout: Some(fixture_checkout("trusty-tools")),
})
);
assert_eq!(targets.last(), Some(&Target::Other));
assert_eq!(new_session::targets_from(&[], &[]), vec![Target::Other]);
}
#[test]
fn new_session_key_opens_the_flow() {
let sessions = fleet();
let mut state = browsing();
assert_eq!(state.apply(Input::Char('n'), &sessions), Action::NewSession);
assert_eq!(state.mode(), &Mode::Browse, "the flow opens on the answer");
state.open_new_session(NewSessionFlow::with_resolver(targets(), stub_identity));
assert!(matches!(state.mode(), Mode::New(_)));
}
#[tokio::test]
async fn new_session_confirming_a_registered_project_creates_without_registering() {
let sessions = fleet();
let mut state = creating();
assert_eq!(state.apply(Input::Down, &sessions), Action::Redraw);
let action = state.apply(Input::Enter, &sessions);
let Action::Create(request) = action else {
panic!("expected a create action, got {action:?}");
};
let apex = fixture_checkout("apex").display().to_string();
assert_eq!(
request,
NewSessionRequest {
register: None,
repo: apex.clone(),
label: "apex".to_string(),
name_hint: None,
}
);
assert_eq!(state.mode(), &Mode::Browse, "the overlay closes on confirm");
let calls = std::cell::RefCell::new(Vec::new());
let outcome = new_session::perform_with(
request,
|p: NewProject| {
calls.borrow_mut().push(format!("register:{}", p.name));
async { anyhow::Ok(()) }
},
|repo: String, _: Option<String>| {
calls.borrow_mut().push(format!("create:{repo}"));
async { anyhow::Ok(AttachOutcome::Attached) }
},
)
.await
.expect("the create leg must succeed");
assert_eq!(outcome, AttachOutcome::Attached);
assert_eq!(
calls.into_inner(),
vec![format!("create:{apex}")],
"a registered project must not be registered again"
);
}
#[test]
fn new_session_registered_target_carries_its_local_checkout() {
let rows =
new_session::targets_from(&[project("cto", "https://github.com/bob-duetto/cto")], &[]);
let Some(Target::Registered { repo, checkout, .. }) = rows.first() else {
panic!("the registry row is not a target, got {rows:?}");
};
assert_eq!(
repo, "https://github.com/bob-duetto/cto",
"the row keeps its identity"
);
let checkout = checkout.as_ref().expect("a GitHub row names a directory");
assert!(checkout.is_absolute(), "{checkout:?}");
assert!(
checkout.ends_with("bob-duetto/cto"),
"not the managed clone path: {checkout:?}"
);
}
#[test]
fn new_session_confirming_a_registered_project_sends_its_local_checkout() {
let mut flow = NewSessionFlow::with_resolver(
new_session::targets_from_with(
&[project("cto", "https://github.com/bob-duetto/cto")],
&[],
stub_checkout,
),
stub_identity,
);
match flow.apply(Input::Enter) {
Step::Create(request) => {
assert_eq!(request.register, None, "the project is already registered");
assert_eq!(request.repo, fixture_checkout("cto").display().to_string());
assert!(Path::new(&request.repo).is_dir(), "{request:?}");
}
other => panic!("the registered row was not confirmed, got {other:?}"),
}
}
#[test]
fn new_session_registered_project_without_a_checkout_names_the_clone_step() {
let mut flow = NewSessionFlow::with_resolver(
new_session::targets_from_with(
&[project("cto", "https://github.com/bob-duetto/cto")],
&[],
missing_checkout,
),
stub_identity,
);
match flow.apply(Input::Enter) {
Step::Reject(message) => {
assert!(message.contains("/nonexistent/7887/cto"), "{message}");
assert!(
message
.contains("git clone https://github.com/bob-duetto/cto /nonexistent/7887/cto"),
"{message}"
);
}
other => panic!("a missing checkout was not refused, got {other:?}"),
}
}
#[tokio::test]
async fn new_session_unregistered_path_registers_before_creating() {
let sessions = fleet();
let mut state = creating();
state.apply(Input::Down, &sessions);
state.apply(Input::Down, &sessions);
assert_eq!(
state.apply(Input::Enter, &sessions),
Action::Redraw,
"the path entry opens rather than creating"
);
for c in "/w/widgets".chars() {
state.apply(Input::Char(c), &sessions);
}
let action = state.apply(Input::Enter, &sessions);
let Action::Create(request) = action else {
panic!("expected a create action, got {action:?}");
};
assert_eq!(
request,
NewSessionRequest {
register: Some(NewProject {
name: "widgets".to_string(),
repo_url: "https://github.com/acme/widgets".to_string(),
}),
repo: "/w/widgets".to_string(),
label: "widgets".to_string(),
name_hint: None,
}
);
let calls = std::cell::RefCell::new(Vec::new());
new_session::perform_with(
request,
|p: NewProject| {
calls.borrow_mut().push(format!("register:{}", p.repo_url));
async { anyhow::Ok(()) }
},
|repo: String, _: Option<String>| {
calls.borrow_mut().push(format!("create:{repo}"));
async { anyhow::Ok(AttachOutcome::Attached) }
},
)
.await
.expect("both legs must succeed");
assert_eq!(
calls.into_inner(),
vec![
"register:https://github.com/acme/widgets".to_string(),
"create:/w/widgets".to_string(),
],
"registration must land before the session is created"
);
}
#[tokio::test]
async fn new_session_a_failed_registration_never_creates() {
let request = NewSessionRequest {
register: Some(NewProject {
name: "widgets".to_string(),
repo_url: "https://github.com/acme/widgets".to_string(),
}),
repo: "/w/widgets".to_string(),
label: "widgets".to_string(),
name_hint: None,
};
let created = std::cell::Cell::new(false);
let err = new_session::perform_with(
request,
|_: NewProject| async { Err(anyhow::anyhow!("registry refused it")) },
|_: String, _: Option<String>| {
created.set(true);
async { anyhow::Ok(AttachOutcome::Attached) }
},
)
.await
.expect_err("a failed registration must abort the flow");
assert!(
!created.get(),
"nothing may be created after a failed register"
);
assert!(
format!("{err:#}").contains("registry refused it"),
"the daemon's own reason must survive: {err:#}"
);
}
#[test]
fn new_session_known_path_skips_registration() {
let request = new_session::request_for_path("/w/trusty-tools", &targets(), stub_identity)
.expect("a known checkout is usable");
assert_eq!(request.register, None);
assert_eq!(request.repo, "/w/trusty-tools");
}
#[test]
fn new_session_rejects_a_path_that_is_not_a_checkout() {
let sessions = fleet();
let mut state = creating();
state.apply(Input::Down, &sessions);
state.apply(Input::Down, &sessions);
state.apply(Input::Enter, &sessions);
for c in "/tmp/nope".chars() {
state.apply(Input::Char(c), &sessions);
}
assert_eq!(state.apply(Input::Enter, &sessions), Action::Redraw);
let (text, severity) = state.message().expect("a refusal must be shown");
assert!(text.contains("/tmp/nope"), "{text}");
assert_eq!(severity, Severity::Error);
match state.mode() {
Mode::New(flow) => assert_eq!(flow.typed(), Some("/tmp/nope")),
other => panic!("the flow must stay open, got {other:?}"),
}
}
#[test]
fn new_session_rejects_an_empty_path() {
let err = new_session::request_for_path(" ", &targets(), stub_identity)
.expect_err("an empty path is not a project");
assert!(err.contains("path"), "{err}");
}
#[test]
fn new_session_escape_cancels_from_both_steps() {
let sessions = fleet();
let mut state = creating();
assert_eq!(state.apply(Input::Escape, &sessions), Action::Redraw);
assert_eq!(state.mode(), &Mode::Browse);
let mut state = creating();
state.apply(Input::Down, &sessions);
state.apply(Input::Down, &sessions);
state.apply(Input::Enter, &sessions);
state.apply(Input::Char('/'), &sessions);
assert_eq!(state.apply(Input::Escape, &sessions), Action::Redraw);
assert_eq!(state.mode(), &Mode::Browse);
}
#[test]
fn new_session_escape_leaves_the_state_identical() {
let sessions = fleet();
let mut state = browsing();
state.apply(Input::Down, &sessions);
let before = (
state.mode().clone(),
state.selected(),
state.scroll(),
state.message().map(|(t, s)| (t.to_string(), s)),
);
assert_eq!(state.apply(Input::Char('n'), &sessions), Action::NewSession);
state.open_new_session(NewSessionFlow::with_resolver(targets(), stub_identity));
state.apply(Input::Down, &sessions);
state.apply(Input::Down, &sessions);
state.apply(Input::Enter, &sessions);
state.apply(Input::Char('/'), &sessions);
state.apply(Input::Char('w'), &sessions);
state.apply(Input::Escape, &sessions);
let after = (
state.mode().clone(),
state.selected(),
state.scroll(),
state.message().map(|(t, s)| (t.to_string(), s)),
);
assert_eq!(after, before);
}
#[test]
fn new_session_rows_window_keeps_the_selection_visible() {
let many: Vec<Project> = (0..12)
.map(|i| {
project(
&format!("p{i:02}"),
&format!("https://example.test/p{i:02}"),
)
})
.collect();
let mut flow =
NewSessionFlow::with_resolver(new_session::targets_from(&many, &[]), stub_identity);
assert_eq!(flow.rows().len(), 8);
assert!(flow.rows()[0].starts_with('▸'));
for _ in 0..12 {
flow.apply(Input::Down);
}
let rows = flow.rows();
assert_eq!(rows.len(), 8);
assert!(
rows.last()
.is_some_and(|r| r.starts_with('▸') && r.contains("other")),
"the selection scrolled off the window: {rows:?}"
);
assert!(
rows[0].contains("p05"),
"the window did not follow the selection down: {rows:?}"
);
}
#[test]
fn render_new_session_overlay_lists_the_registered_projects() {
let sessions = fleet();
let mut state = creating();
let joined = draw(110, 30, &sessions, &mut state).join("\n");
assert!(joined.contains("Start a new session in:"), "{joined}");
assert!(joined.contains("duetto/apex"), "{joined}");
assert!(!joined.contains("https://"), "{joined}");
assert!(joined.contains("other — type a project path"), "{joined}");
assert!(joined.contains("Esc cancels"), "{joined}");
}
#[test]
fn render_new_session_overlay_shows_the_typed_path() {
let sessions = fleet();
let mut state = creating();
state.apply(Input::Down, &sessions);
state.apply(Input::Down, &sessions);
state.apply(Input::Enter, &sessions);
for c in "/w/widgets".chars() {
state.apply(Input::Char(c), &sessions);
}
let joined = draw(110, 30, &sessions, &mut state).join("\n");
assert!(joined.contains("/w/widgets"), "{joined}");
assert!(joined.contains("registers it"), "{joined}");
}
#[test]
fn render_footer_offers_the_new_session_key() {
let sessions = fleet();
let mut state = browsing();
let screen = draw(80, 24, &sessions, &mut state);
let footer = screen.last().expect("a footer row");
assert!(footer.contains("n new"), "{footer}");
assert!(
footer.contains("q quit"),
"the legend was clipped: {footer}"
);
state.apply(Input::Char('?'), &sessions);
let joined = draw(110, 30, &sessions, &mut state).join("\n");
assert!(joined.contains("new session"), "{joined}");
}
fn overlay_rows(screen: &[String]) -> Vec<String> {
screen
.iter()
.filter_map(|line| {
let mut parts = line.split('│');
parts.next()?;
Some(parts.next()?.trim_end().to_string())
})
.collect()
}
fn target(name: &str, repo: &str) -> Target {
Target::Registered {
name: name.to_string(),
repo: repo.to_string(),
checkout: local_checkout_for(&project(name, repo)),
}
}
fn stub_target(name: &str, repo: &str) -> Target {
Target::Registered {
name: name.to_string(),
repo: repo.to_string(),
checkout: stub_checkout(&project(name, repo)),
}
}
fn three_projects() -> Vec<Target> {
new_session::targets_from(
&[
project("trusty-tools", "https://github.com/bobmatnyc/trusty-tools"),
project("apex", "https://github.com/duetto/apex"),
project("widgets", "https://github.com/acme/widgets"),
],
&[],
)
}
#[test]
fn new_session_preselects_the_cursor_sessions_project() {
let targets = three_projects();
let mut cursor = session("a1", "Active", 1);
cursor.repo_url = Some("git@github.com:duetto/apex.git".to_string());
assert_eq!(new_session::preselect_index(&targets, Some(&cursor)), 2);
let flow = NewSessionFlow::with_resolver(targets.clone(), stub_identity)
.preselected_for(Some(&cursor));
let rows = flow.rows();
assert_eq!(rows[2], "▸ duetto/apex", "{rows:?}");
assert!(!rows[0].starts_with('▸'), "{rows:?}");
let mut by_source = session("t1", "Active", 2);
by_source.source_id = Some("bobmatnyc/trusty-tools".to_string());
assert_eq!(new_session::preselect_index(&targets, Some(&by_source)), 1);
}
#[test]
fn new_session_preselect_falls_back_to_the_first_row() {
let targets = three_projects();
assert_eq!(new_session::preselect_index(&targets, None), 0);
let mut stranger = session("s1", "Active", 1);
stranger.repo_url = Some("https://github.com/other/thing".to_string());
assert_eq!(new_session::preselect_index(&targets, Some(&stranger)), 0);
let bare = session("b1", "Active", 1);
assert_eq!(new_session::preselect_index(&targets, Some(&bare)), 0);
}
#[test]
fn new_session_rows_show_owner_repo_only() {
let flow = NewSessionFlow::with_resolver(
vec![
target(
"trusty-tools",
"https://github.com/bobmatnyc/trusty-tools.git",
),
target("widgets", "/Users/me/code/widgets"),
target("nameless", ""),
Target::Other,
],
stub_identity,
);
assert_eq!(
flow.rows(),
vec![
"▸ bobmatnyc/trusty-tools".to_string(),
" widgets (local)".to_string(),
" nameless (local)".to_string(),
" other — type a project path…".to_string(),
]
);
}
#[test]
fn new_session_labels_disambiguate_by_host() {
let labels = new_session::row_labels(&[
target("apex", "https://github.com/duetto/apex"),
target("apex-gl", "https://gitlab.com/duetto/apex"),
target("trusty-tools", "https://github.com/bobmatnyc/trusty-tools"),
]);
assert_eq!(
labels,
vec![
"github.com/duetto/apex".to_string(),
"gitlab.com/duetto/apex".to_string(),
"bobmatnyc/trusty-tools".to_string(),
]
);
}
#[test]
fn new_session_targets_from_drops_a_scratchpad_registration() {
let targets = new_session::targets_from_with(
&[
project(
"mcp-probe-scratch-4181",
"/private/tmp/claude-502/abc/scratchpad/mcp-probe-scratch-4181",
),
project("gone", "/nonexistent/7406/no-such-checkout"),
project("trusty-tools", "https://github.com/bobmatnyc/trusty-tools"),
project("apex", "https://github.com/duetto/apex"),
],
&[],
stub_checkout,
);
assert_eq!(
targets,
vec![
stub_target("trusty-tools", "https://github.com/bobmatnyc/trusty-tools"),
stub_target("apex", "https://github.com/duetto/apex"),
Target::Other,
]
);
}
#[test]
fn render_new_session_rows_at_eighty_columns() {
let sessions = fleet();
let mut state = browsing();
state.open_new_session(NewSessionFlow::with_resolver(
vec![
target("trusty-tools", "https://github.com/bobmatnyc/trusty-tools"),
target("widgets", "/Users/me/code/widgets"),
Target::Other,
],
stub_identity,
));
let screen = draw(80, 24, &sessions, &mut state);
let rows = overlay_rows(&screen);
for row in &rows {
assert!(
row.chars().count() <= 62,
"row over the pane width: {row:?}"
);
}
assert!(
rows.contains(&"▸ bobmatnyc/trusty-tools".to_string()),
"{rows:?}"
);
assert!(rows.contains(&" widgets (local)".to_string()), "{rows:?}");
let joined = screen.join("\n");
assert!(
!joined.contains("https://"),
"a full URL reached a row: {joined}"
);
assert!(
!joined.contains("/Users/me/code"),
"an absolute path reached a row: {joined}"
);
}
fn six_projects() -> Vec<Project> {
vec![
project("apex", "https://github.com/duetto/apex"),
project("widgets", "https://github.com/acme/widgets"),
project("trusty-tools", "https://github.com/bobmatnyc/trusty-tools"),
project("zeta", "https://github.com/zed/zeta"),
project("mid", "https://github.com/mid/mid"),
project("early", "https://github.com/early/early"),
]
}
#[test]
fn new_session_order_is_independent_of_registry_iteration_order() {
let forward = new_session::targets_from_with(&six_projects(), &[], stub_checkout);
let mut shuffled = six_projects();
shuffled.reverse();
shuffled.swap(0, 3);
let reversed = new_session::targets_from_with(&shuffled, &[], stub_checkout);
assert_eq!(forward, reversed, "the row order followed the input order");
assert_eq!(
new_session::row_labels(&forward),
vec![
"acme/widgets".to_string(),
"bobmatnyc/trusty-tools".to_string(),
"duetto/apex".to_string(),
"early/early".to_string(),
"mid/mid".to_string(),
"zed/zeta".to_string(),
"other — type a project path…".to_string(),
]
);
}
#[test]
fn new_session_order_puts_a_live_session_project_first() {
let mut live = session("z1", "active", 1);
live.repo_url = Some("git@github.com:zed/zeta.git".to_string());
live.last_activity_at = Some("2026-09-11T10:00:00Z".to_string());
let mut older = session("w1", "stopped", 2);
older.source_id = Some("acme/widgets".to_string());
older.last_activity_at = Some("2026-09-01T10:00:00Z".to_string());
let rows = new_session::targets_from(&six_projects(), &[live, older]);
let labels = new_session::row_labels(&rows);
assert_eq!(
labels.first().map(String::as_str),
Some("zed/zeta"),
"the live-session project did not sort first: {labels:?}"
);
assert_eq!(
labels.get(1).map(String::as_str),
Some("acme/widgets"),
"the stopped-session project did not outrank the session-less ones: {labels:?}"
);
assert_eq!(
labels.get(2).map(String::as_str),
Some("bobmatnyc/trusty-tools"),
"the session-less rows lost their alphabetical order: {labels:?}"
);
}
#[test]
fn new_session_typing_filters_the_rows() {
let mut flow = NewSessionFlow::with_resolver(
new_session::targets_from(&six_projects(), &[]),
stub_identity,
);
assert_eq!(flow.rows().len(), 7);
assert_eq!(flow.apply(Input::Char('z')), Step::Redraw);
assert_eq!(flow.filter(), "z");
assert_eq!(
flow.rows(),
vec![
"▸ zed/zeta".to_string(),
" other — type a project path…".to_string(),
],
"the filter did not narrow the rows"
);
assert_eq!(flow.apply(Input::Backspace), Step::Redraw);
assert_eq!(flow.apply(Input::Char('A')), Step::Redraw);
assert_eq!(flow.apply(Input::Char('C')), Step::Redraw);
assert_eq!(flow.rows().len(), 2, "{:?}", flow.rows());
assert!(flow.rows()[0].contains("acme/widgets"), "{:?}", flow.rows());
assert_eq!(flow.apply(Input::Escape), Step::Redraw);
assert_eq!(flow.filter(), "");
assert_eq!(flow.rows().len(), 7);
assert_eq!(flow.apply(Input::Escape), Step::Cancel);
}
#[test]
fn new_session_filter_keeps_the_selection_valid() {
let mut flow = NewSessionFlow::with_resolver(
new_session::targets_from_with(&six_projects(), &[], stub_checkout),
stub_identity,
);
assert_eq!(flow.apply(Input::Down), Step::Redraw);
assert_eq!(flow.apply(Input::Char('z')), Step::Redraw);
let rows = flow.rows();
assert!(
rows[0].starts_with("▸ zed/zeta"),
"the highlight stayed on a hidden row: {rows:?}"
);
assert_eq!(
flow.apply(Input::Enter),
Step::Create(NewSessionRequest {
register: None,
repo: fixture_checkout("zeta").display().to_string(),
label: "zeta".to_string(),
name_hint: None,
})
);
}
#[test]
fn new_session_position_indicator_reports_the_window() {
let many: Vec<Project> = (0..26)
.map(|i| {
project(
&format!("p{i:02}"),
&format!("https://ex.test/o{i:02}/p{i:02}"),
)
})
.collect();
let mut flow =
NewSessionFlow::with_resolver(new_session::targets_from(&many, &[]), stub_identity);
assert_eq!(flow.position(), Some("1–8 of 27".to_string()));
for _ in 0..9 {
flow.apply(Input::Down);
}
assert_eq!(flow.position(), Some("3–10 of 27".to_string()));
let short = NewSessionFlow::with_resolver(new_session::targets_from(&[], &[]), stub_identity);
assert_eq!(short.position(), None);
}
#[test]
fn render_new_session_overlay_shows_the_position_and_filter() {
let sessions = fleet();
let many: Vec<Project> = (0..26)
.map(|i| {
project(
&format!("p{i:02}"),
&format!("https://ex.test/o{i:02}/p{i:02}"),
)
})
.collect();
let mut state = browsing();
state.open_new_session(NewSessionFlow::with_resolver(
new_session::targets_from(&many, &[]),
stub_identity,
));
let joined = draw(110, 30, &sessions, &mut state).join("\n");
assert!(joined.contains("1–8 of 27"), "{joined}");
assert!(joined.contains("Type to filter"), "{joined}");
state.apply(Input::Char('o'), &sessions);
state.apply(Input::Char('1'), &sessions);
let joined = draw(110, 30, &sessions, &mut state).join("\n");
assert!(joined.contains("filter: o1"), "{joined}");
}
#[test]
fn render_new_session_overlay_truncates_a_long_row() {
let sessions = fleet();
let mut state = browsing();
let long = format!("https://github.com/{}/{}", "o".repeat(40), "r".repeat(40));
state.open_new_session(NewSessionFlow::with_resolver(
vec![target("long", &long), Target::Other],
stub_identity,
));
let screen = draw(80, 24, &sessions, &mut state);
let joined = screen.join("\n");
assert!(joined.contains('…'), "the long row was not cut: {joined}");
assert!(
!joined.contains(&"r".repeat(40)),
"the long row wrapped instead of being cut: {joined}"
);
}
fn grouped_projects() -> Vec<Project> {
vec![
project("api", "https://github.com/acme/api"),
project("charts", "https://gitlab.com/zeta/charts"),
project("infra", "https://alpha.example.com/zed/infra"),
project("notes", ""),
project("tools", "git@gitlab.com:zeta/tools.git"),
project("trusty-tools", "https://github.com/bobmatnyc/trusty-tools"),
project("widgets", "https://github.com/acme/widgets"),
]
}
#[test]
fn new_session_order_groups_by_owner_then_domain() {
let forward = new_session::targets_from_with(&grouped_projects(), &[], stub_checkout);
assert_eq!(
new_session::row_labels(&forward),
vec![
"acme/api".to_string(),
"acme/widgets".to_string(),
"zed/infra".to_string(),
"bobmatnyc/trusty-tools".to_string(),
"zeta/charts".to_string(),
"zeta/tools".to_string(),
"notes (local)".to_string(),
"other — type a project path…".to_string(),
]
);
let mut shuffled = grouped_projects();
shuffled.reverse();
shuffled.swap(0, 4);
shuffled.swap(1, 5);
assert_eq!(
new_session::targets_from_with(&shuffled, &[], stub_checkout),
forward,
"the row order followed the input order"
);
let mut other = grouped_projects();
other.rotate_left(3);
other.swap(2, 6);
assert_eq!(
new_session::targets_from_with(&other, &[], stub_checkout),
forward,
"a second shuffle produced a third order"
);
}
#[test]
fn new_session_order_lifts_the_whole_group_of_a_live_session() {
let mut live = session("z1", "active", 1);
live.source_id = Some("zeta/tools".to_string());
live.last_activity_at = Some("2026-09-11T10:00:00Z".to_string());
let rows = new_session::targets_from(&grouped_projects(), &[live]);
assert_eq!(
new_session::row_labels(&rows),
vec![
"zeta/tools".to_string(),
"zeta/charts".to_string(),
"acme/api".to_string(),
"acme/widgets".to_string(),
"zed/infra".to_string(),
"bobmatnyc/trusty-tools".to_string(),
"notes (local)".to_string(),
"other — type a project path…".to_string(),
]
);
}
#[test]
fn new_session_order_group_of_reads_owner_then_domain() {
use super::new_session_order::group_of;
assert_eq!(
group_of("https://github.com/Acme/api"),
(0, "acme".to_string())
);
assert_eq!(
group_of("git@gitlab.com:zeta/tools.git"),
(0, "zeta".to_string())
);
assert_eq!(
group_of("https://alpha.example.com/zed/infra"),
(0, "alpha.example.com".to_string())
);
assert_eq!(
group_of("/Users/me/code/widgets"),
(1, "(local)".to_string())
);
assert_eq!(group_of(""), (1, "(local)".to_string()));
}
#[test]
fn new_session_entry_parses_the_three_shapes() {
use super::new_session_entry::{ProjectEntry, parse_project_entry};
let expect = |text: &str, name: &str, repo_url: &str| {
assert_eq!(
parse_project_entry(text),
Some(ProjectEntry {
name: name.to_string(),
repo_url: repo_url.to_string(),
}),
"{text}"
);
};
expect(
"https://github.com/acme/widgets",
"widgets",
"https://github.com/acme/widgets",
);
expect(
"https://github.com/acme/widgets.git",
"widgets",
"https://github.com/acme/widgets.git",
);
expect(
"git@github.com:acme/widgets.git",
"widgets",
"git@github.com:acme/widgets.git",
);
expect(
"ssh://git@git.example.com/ops/infra",
"infra",
"ssh://git@git.example.com/ops/infra",
);
expect("acme/widgets", "widgets", "https://github.com/acme/widgets");
expect(
"acme/widgets.git",
"widgets",
"https://github.com/acme/widgets",
);
expect(
"git.example.com/ops/infra",
"infra",
"https://git.example.com/ops/infra",
);
expect(
"localhost/ops/infra",
"infra",
"https://localhost/ops/infra",
);
}
#[test]
fn new_session_entry_rejects_malformed_text() {
use super::new_session_entry::parse_project_entry;
for bad in [
"",
" ",
"widgets",
"acme/",
"/acme/widgets",
"acme widgets",
"acme/widgets/extra/deep",
"/Users/me/code/widgets",
"~/code/widgets",
"./widgets",
"example.com/widgets",
"https://github.com/acme",
] {
assert_eq!(parse_project_entry(bad), None, "{bad:?} was accepted");
}
}
fn stub_checkout_for_url(repo_url: &str) -> Option<PathBuf> {
if repo_url == "https://github.com/acme/widgets" {
return Some(fixture_checkout("widgets"));
}
Some(PathBuf::from("/nonexistent/acme/missing"))
}
#[test]
fn new_session_entry_builds_a_clone_and_register_request() {
let request = super::new_session_entry::request_for_entry_with(
"acme/widgets",
&targets(),
stub_checkout_for_url,
)
.expect("owner/repo is a project");
assert_eq!(
request,
NewSessionRequest {
register: Some(NewProject {
name: "widgets".to_string(),
repo_url: "https://github.com/acme/widgets".to_string(),
}),
repo: fixture_checkout("widgets").display().to_string(),
label: "widgets".to_string(),
name_hint: None,
}
);
assert_ne!(request.repo, "https://github.com/acme/widgets");
}
#[test]
fn new_session_entry_without_a_checkout_names_the_clone_step() {
let err = super::new_session_entry::request_for_entry_with(
"acme/missing",
&targets(),
stub_checkout_for_url,
)
.expect_err("a project with no checkout cannot be started");
assert!(
err.contains("git clone https://github.com/acme/missing /nonexistent/acme/missing"),
"{err}"
);
}
#[test]
fn new_session_registered_row_without_a_directory_masks_the_stored_password_9285() {
let err = super::new_session::request_for_registered(
"cto",
"https://u:pa'ss9285@github.com/acme/cto",
None,
)
.expect_err("a row with no project directory cannot be started");
assert!(
!err.contains("ss9285"),
"a password fragment survived: {err}"
);
assert!(
!err.contains("u:pa"),
"the user:password pair survived: {err}"
);
assert!(
err.contains("cto is registered as https://***@github.com/acme/cto,"),
"{err}"
);
}
#[test]
fn new_session_registered_row_without_a_checkout_masks_the_stored_password_9285() {
let err = super::new_session::request_for_registered(
"cto",
"https://u:pa'ss9285@github.com/acme/cto",
Some(Path::new("/nonexistent/9285/cto")),
)
.expect_err("a row with no checkout cannot be started");
assert!(
!err.contains("ss9285"),
"a password fragment survived: {err}"
);
assert!(
!err.contains("u:pa"),
"the user:password pair survived: {err}"
);
assert!(!err.contains("git clone"), "{err}");
assert!(
err.contains(
"clone it first from https://***@github.com/acme/cto into /nonexistent/9285/cto"
),
"{err}"
);
}
#[test]
fn new_session_entry_with_no_checkout_masks_the_typed_password_9285() {
fn no_checkout(_: &str) -> Option<PathBuf> {
None
}
let err = super::new_session_entry::request_for_entry_with(
"https://u:pa'ss9285@github.com/acme/gadget",
&targets(),
no_checkout,
)
.expect_err("a URL with no checkout directory cannot be started");
assert!(
!err.contains("ss9285"),
"a password fragment survived: {err}"
);
assert!(
!err.contains("u:pa"),
"the user:password pair survived: {err}"
);
assert!(
err.starts_with("https://***@github.com/acme/gadget names no checkout"),
"{err}"
);
}
#[test]
fn new_session_entry_reuses_a_registered_project() {
let request = super::new_session_entry::request_for_entry("bobmatnyc/trusty-tools", &targets())
.expect("a registered project is still a project");
assert_eq!(request.register, None);
assert_eq!(
request.repo,
fixture_checkout("trusty-tools").display().to_string()
);
assert_ne!(request.repo, "https://github.com/bobmatnyc/trusty-tools");
assert_eq!(request.label, "trusty-tools");
}
#[test]
fn new_session_entry_from_the_filter_creates_a_clone_request() {
let mut flow = NewSessionFlow::with_resolver(targets(), stub_identity);
for c in "acme/widgets".chars() {
assert_eq!(flow.apply(Input::Char(c)), Step::Redraw);
}
match flow.apply(Input::Enter) {
Step::Reject(msg) => assert!(
msg.contains("git clone https://github.com/acme/widgets"),
"{msg}"
),
other => panic!("an uncloned project must be refused, got {other:?}"),
}
}
#[test]
fn new_session_entry_does_not_hijack_a_matching_filter() {
let mut flow = NewSessionFlow::with_resolver(targets(), stub_identity);
for c in "duetto".chars() {
flow.apply(Input::Char(c));
}
assert_eq!(
flow.apply(Input::Enter),
Step::Create(NewSessionRequest {
register: None,
repo: fixture_checkout("apex").display().to_string(),
label: "apex".to_string(),
name_hint: None,
})
);
}
#[test]
fn new_session_entry_from_the_filter_rejects_malformed_text() {
let sessions = fleet();
let mut state = creating();
for c in "nope!!".chars() {
state.apply(Input::Char(c), &sessions);
}
assert_eq!(
state.apply(Input::Enter, &sessions),
Action::Redraw,
"malformed text created a session"
);
let (text, severity) = state.message().expect("a refusal must be shown");
assert!(text.contains("nope!!"), "{text}");
assert_eq!(severity, Severity::Error);
match state.mode() {
Mode::New(flow) => assert_eq!(flow.filter(), "nope!!"),
other => panic!("the flow must stay open, got {other:?}"),
}
}
#[test]
fn new_session_entry_path_step_accepts_a_clone_url() {
let err =
new_session::request_for_path("https://github.com/acme/widgets", &targets(), stub_identity)
.expect_err("the project is not cloned on this host");
assert!(
err.contains("git clone https://github.com/acme/widgets"),
"{err}"
);
assert!(!err.contains("is not a git checkout"), "{err}");
}
fn state_color_fleet() -> Vec<ManagedSessionSummary> {
let selected = session("tm-selected-00", "active", 1);
let active = session("tm-active-01", "active", 2);
let stopped = session("tm-stopped-02", "stopped", 3);
let errored = session("tm-errored-03", "errored", 4);
let mut dead = session("tm-dead-04", "stopped", 5);
dead.unresumable = true;
let mut attached = session("tm-attached-05", "active", 6);
attached.attached = true;
let provisioning = session("tm-provisioning-06", "provisioning", 7);
let decommissioned = session("tm-decommissioned-07", "decommissioned", 8);
vec![
selected,
active,
stopped,
errored,
dead,
attached,
provisioning,
decommissioned,
]
}
fn row_foregrounds(
sessions: &[ManagedSessionSummary],
state: &mut TuiState,
) -> Vec<(String, ratatui::style::Color, ratatui::style::Modifier)> {
let (w, h) = (200, 30);
let mut terminal = Terminal::new(TestBackend::new(w, h)).expect("test terminal");
terminal
.draw(|frame| render::render(frame, sessions, state))
.expect("render must not panic");
let buffer = terminal.backend().buffer().clone();
sessions
.iter()
.map(|s| {
let (x, y) = (0..h)
.find_map(|y| {
let line: String = (0..w).map(|x| buffer[(x, y)].symbol()).collect();
line.find(&s.name)
.map(|byte| (line[..byte].chars().count() as u16, y))
})
.unwrap_or_else(|| panic!("{} not drawn", s.name));
let cell = &buffer[(x, y)];
(s.name.clone(), cell.fg, cell.modifier)
})
.collect()
}
#[test]
fn render_paints_each_row_in_its_state_color() {
use ratatui::style::{Color, Modifier};
let sessions = state_color_fleet();
let mut state = TuiState::new(None, None);
state.sync(&sessions);
let got = row_foregrounds(&sessions, &mut state);
let want = [
("tm-active-01", Color::Green, Modifier::empty()),
("tm-stopped-02", Color::Yellow, Modifier::empty()),
("tm-errored-03", Color::Red, Modifier::empty()),
("tm-dead-04", Color::Red, Modifier::empty()),
("tm-attached-05", Color::Cyan, Modifier::BOLD),
("tm-provisioning-06", Color::Blue, Modifier::empty()),
("tm-decommissioned-07", Color::Reset, Modifier::DIM),
];
for (name, color, modifier) in want {
let cell = got
.iter()
.find(|(n, _, _)| n == name)
.map(|(_, fg, m)| (*fg, *m));
assert_eq!(cell, Some((color, modifier)), "row {name}");
}
}
#[test]
fn render_draws_no_state_color_when_color_is_off() {
use ratatui::style::{Color, Modifier};
let sessions = state_color_fleet();
let mut state = TuiState::new(None, None).with_color(false);
state.sync(&sessions);
for (name, fg, modifier) in row_foregrounds(&sessions, &mut state).into_iter().skip(1) {
assert_eq!(fg, Color::Reset, "row {name} must be uncolored");
assert_eq!(modifier, Modifier::empty(), "row {name} must be unstyled");
}
}
fn open_flow(state: &TuiState) -> &NewSessionFlow {
match state.mode() {
Mode::New(flow) => flow,
other => panic!("the new-session flow is not open: {other:?}"),
}
}
fn naming_apex(name: &str) -> TuiState {
let sessions = fleet();
let mut state = creating();
state.apply(Input::Down, &sessions);
assert_eq!(state.apply(Input::NameNew, &sessions), Action::Redraw);
for c in name.chars() {
state.apply(Input::Char(c), &sessions);
}
state
}
fn apex_request(name_hint: Option<&str>) -> NewSessionRequest {
NewSessionRequest {
register: None,
repo: fixture_checkout("apex").display().to_string(),
label: "apex".to_string(),
name_hint: name_hint.map(str::to_string),
}
}
#[test]
fn new_session_name_ctrl_n_opens_the_name_step() {
let sessions = fleet();
let mut state = creating();
assert!(open_flow(&state).naming().is_none());
assert_eq!(state.apply(Input::NameNew, &sessions), Action::Redraw);
let naming = open_flow(&state)
.naming()
.expect("Ctrl-N on a registered row opens the name step");
assert_eq!(naming.label(), "trusty-tools");
assert_eq!(naming.typed(), "");
let mut browse = browsing();
assert_eq!(browse.apply(Input::NameNew, &sessions), Action::Ignore);
assert_eq!(browse.mode(), &Mode::Browse);
}
#[test]
fn new_session_name_ctrl_n_rejects_the_path_row() {
let sessions = fleet();
let mut state = creating();
state.apply(Input::Down, &sessions);
state.apply(Input::Down, &sessions);
assert_eq!(state.apply(Input::NameNew, &sessions), Action::Redraw);
assert_eq!(
state.message(),
Some((super::new_session_name::NOT_A_PROJECT, Severity::Error))
);
assert!(open_flow(&state).naming().is_none(), "no name step opened");
}
#[test]
fn new_session_name_ctrl_n_rejects_a_row_without_a_checkout() {
let flow = || {
NewSessionFlow::with_resolver(
new_session::targets_from_with(
&[project("cto", "https://github.com/bob-duetto/cto")],
&[],
missing_checkout,
),
stub_identity,
)
};
let Step::Reject(on_enter) = flow().apply(Input::Enter) else {
panic!("Enter must refuse a row with no checkout");
};
let mut named = flow();
assert_eq!(named.apply(Input::NameNew), Step::Reject(on_enter));
assert!(named.naming().is_none(), "no name step opened");
}
#[test]
fn new_session_name_enter_creates_with_the_slug() {
let sessions = fleet();
let mut state = naming_apex("Auth Refactor");
assert_eq!(
state.apply(Input::Enter, &sessions),
Action::Create(apex_request(Some("auth-refactor")))
);
assert_eq!(state.mode(), &Mode::Browse, "the overlay closes on create");
}
#[test]
fn new_session_name_rejects_an_empty_slug() {
let sessions = fleet();
for typed in ["", "!!!"] {
let mut state = naming_apex(typed);
assert_eq!(state.apply(Input::Enter, &sessions), Action::Redraw);
let (message, severity) = state.message().expect("the refusal is shown");
assert_eq!(severity, Severity::Error);
assert!(message.contains("no letters or digits"), "{message}");
let naming = open_flow(&state)
.naming()
.expect("the name step stays open after a refusal");
assert_eq!(naming.typed(), typed);
}
}
#[test]
fn new_session_name_escape_returns_to_the_list_intact() {
let sessions = fleet();
let mut state = creating();
for c in "ap".chars() {
state.apply(Input::Char(c), &sessions);
}
let before = open_flow(&state).clone();
assert!(
before
.rows()
.iter()
.any(|r| r.starts_with('▸') && r.contains("duetto/apex")),
"{:?}",
before.rows()
);
state.apply(Input::NameNew, &sessions);
state.apply(Input::Char('x'), &sessions);
assert_eq!(state.apply(Input::Escape, &sessions), Action::Redraw);
assert_eq!(open_flow(&state), &before, "filter and selection intact");
assert_eq!(open_flow(&state).filter(), "ap");
}
#[test]
fn new_session_name_preview_shows_the_slug() {
let named = naming_apex("Auth Refactor");
let naming = open_flow(&named).naming().expect("name step open");
assert_eq!(naming.preview().as_deref(), Some("tm-auth-refactor-NN"));
let symbols = naming_apex("!!!");
let naming = open_flow(&symbols).naming().expect("name step open");
assert_eq!(naming.preview(), None);
}
#[test]
fn new_session_name_overlay_shows_the_text_and_preview() {
let sessions = fleet();
let mut picker = creating();
let joined = draw(110, 30, &sessions, &mut picker).join("\n");
assert!(
joined.contains("Ctrl-N"),
"the list must offer Ctrl-N:\n{joined}"
);
let mut state = naming_apex("Auth Refactor");
let joined = draw(110, 30, &sessions, &mut state).join("\n");
assert!(joined.contains("in apex"), "{joined}");
assert!(joined.contains("> Auth Refactor"), "{joined}");
assert!(joined.contains("tm-auth-refactor-NN"), "{joined}");
assert!(joined.contains("Esc goes back"), "{joined}");
}
#[test]
fn render_help_overlay_lists_ctrl_n() {
let sessions = fleet();
let mut state = browsing();
state.apply(Input::Char('?'), &sessions);
let joined = draw(100, 30, &sessions, &mut state).join("\n");
assert!(joined.contains("Ctrl-N"), "help omits Ctrl-N:\n{joined}");
}
#[test]
fn new_session_name_status_line_names_the_session() {
use super::new_session_name::created_message;
assert_eq!(created_message(&apex_request(None)), "new session in apex");
assert_eq!(
created_message(&apex_request(Some("auth-refactor"))),
"new session named auth-refactor in apex"
);
}
#[test]
fn new_session_name_backspace_removes_a_whole_character() {
let sessions = fleet();
let mut state = naming_apex("café");
assert_eq!(state.apply(Input::Backspace, &sessions), Action::Redraw);
let naming = open_flow(&state).naming().expect("name step open");
assert_eq!(naming.typed(), "caf");
}
#[test]
fn new_session_name_long_name_preview_matches_the_capped_slug() {
let name = "authentication refactor phase two";
let slug = trusty_common::session_naming::leaf_slug_from_hint(name);
assert!(!slug.ends_with('-'), "{slug}");
assert!(slug.len() < name.len(), "the cap must have cut: {slug}");
let state = naming_apex(name);
let naming = open_flow(&state).naming().expect("name step open");
let preview = naming.preview().expect("a long name still slugs");
assert_eq!(preview, format!("tm-{slug}-NN"));
assert!(!preview.trim_end_matches("-NN").ends_with('-'), "{preview}");
}
#[test]
fn new_session_name_arrows_are_ignored_while_naming() {
let sessions = fleet();
let mut state = naming_apex("auth");
let before = open_flow(&state).clone();
assert_eq!(state.apply(Input::Down, &sessions), Action::Ignore);
assert_eq!(state.apply(Input::Up, &sessions), Action::Ignore);
assert_eq!(open_flow(&state), &before, "selection unchanged");
}
#[tokio::test]
async fn new_session_name_reaches_the_create_leg() {
let sessions = fleet();
let mut state = naming_apex("Auth Refactor");
let Action::Create(request) = state.apply(Input::Enter, &sessions) else {
panic!("Enter in the name step must create");
};
let received = std::cell::RefCell::new(None);
new_session::perform_with(
request,
|_: NewProject| async { anyhow::Ok(()) },
|repo: String, name_hint: Option<String>| {
*received.borrow_mut() = Some((repo, name_hint));
async { anyhow::Ok(AttachOutcome::Attached) }
},
)
.await
.expect("the create leg must succeed");
assert_eq!(
received.into_inner(),
Some((
fixture_checkout("apex").display().to_string(),
Some("auth-refactor".to_string())
))
);
}
#[tokio::test]
async fn new_session_name_reaches_the_daemon_post() {
use axum::{Json, Router, routing::post};
type Captured = std::sync::Arc<std::sync::Mutex<Option<serde_json::Value>>>;
let captured: Captured = std::sync::Arc::default();
let sink = std::sync::Arc::clone(&captured);
let handler = move |Json(body): Json<serde_json::Value>| {
let sink = std::sync::Arc::clone(&sink);
async move {
*sink.lock().expect("capture mutex") = Some(body);
axum::http::StatusCode::INTERNAL_SERVER_ERROR
}
};
let router = Router::new().route("/api/v1/sessions/managed", post(handler));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral port");
let url = format!("http://{}", listener.local_addr().expect("local_addr"));
tokio::spawn(async move { axum::serve(listener, router).await.ok() });
let result = new_session::perform(
&reqwest::Client::new(),
&url,
apex_request(Some("auth-refactor")),
)
.await;
assert!(result.is_err(), "the server answers 500: {result:?}");
let body = captured
.lock()
.expect("capture mutex")
.clone()
.expect("perform must POST the create request");
assert_eq!(body["name_hint"], "auth-refactor", "{body}");
}