//! The CLI against a daemon. It had no such tests: `maestro list`'s formatting //! was unit-tested, but nothing exercised the client, the error decoding, or //! the unreachable-daemon path without compiling and forking a real daemon. use maestro_protocol::{DaemonClient, DaemonInfo}; use maestro_testing::{session, MockDaemon}; use uuid::Uuid; #[tokio::test] async fn list_reads_the_daemon_catalog() { let mock = MockDaemon::start().await; mock.add_session(session(Uuid::from_u128(1), "/home/mock/work", "claude")); let sessions = mock.client().list_sessions().await.expect("list"); assert_eq!(sessions.len(), 1); assert_eq!(sessions[0].command, "claude"); } #[tokio::test] async fn info_carries_version_and_presets() { let mock = MockDaemon::start().await; mock.set_info(DaemonInfo { hostname: "konishi".into(), version: "9.9.9".into(), home_dir: "/home/naps".into(), presets: vec![maestro_protocol::SpawnPreset { id: "claude".into(), label: "Claude".into(), command: "claude".into(), }], ..Default::default() }); let info = mock.client().daemon_info().await.expect("info"); // The CLI's own DaemonInfo used to drop both of these fields. assert_eq!(info.version, "9.9.9"); assert_eq!(info.presets.len(), 1); } #[tokio::test] async fn a_structured_failure_surfaces_the_daemon_message() { let mock = MockDaemon::start().await; mock.force_error(502, "tmux_failed", "no server running"); let err = mock .client() .list_sessions() .await .expect_err("should fail"); assert_eq!( err.daemon_message(), Some("no server running"), "the daemon's own message must survive: {err}" ); assert!(!err.is_unreachable()); } #[tokio::test] async fn an_unreachable_daemon_is_distinguishable_from_a_rejection() { // Port 1 has nothing listening on it. let client = DaemonClient::new("http://127.0.0.1:1"); let err = client.list_sessions().await.expect_err("should fail"); assert!( err.is_unreachable(), "connection refused must not read as an HTTP rejection: {err}" ); } #[tokio::test] async fn killing_an_unknown_session_reports_the_daemon_reason() { let mock = MockDaemon::start().await; let err = mock .client() .kill_session(Uuid::new_v4(), false) .await .expect_err("should fail"); assert_eq!(err.daemon_message(), Some("no such session on this daemon")); } #[tokio::test] async fn spawn_round_trips_through_the_daemon() { let mock = MockDaemon::start().await; let created = mock .client() .create_session(&maestro_protocol::CreateSessionRequest { cwd: "/home/mock".into(), command: "bash".into(), env: Default::default(), metadata: Default::default(), worktree: None, }) .await .expect("create"); assert_eq!(created.command, "bash"); assert_eq!(mock.session_count(), 1); mock.client() .kill_session(created.id, false) .await .expect("kill"); assert_eq!(mock.session_count(), 0); } #[tokio::test] async fn send_types_text_and_submits_by_default() { let mock = MockDaemon::start().await; let id = Uuid::from_u128(7); mock.add_session(session(id, "/home/mock/work", "claude")); let args = maestro_cli::cli::SendArgs { session: id.to_string(), text: vec!["run".into(), "the".into(), "tests".into()], no_enter: false, }; let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty()) .await .expect("send"); assert_eq!(sent, id); let inputs = mock.inputs(); assert_eq!(inputs.len(), 1); assert_eq!(inputs[0].0, id); assert_eq!(inputs[0].1.text, "run the tests"); assert!(inputs[0].1.submit, "Enter is the default"); } /// `--no-enter` leaves the text in the prompt; the flag has to survive the /// whole way to the daemon or the message is sent anyway. #[tokio::test] async fn send_without_enter_leaves_the_text_unsubmitted() { let mock = MockDaemon::start().await; let id = Uuid::from_u128(8); mock.add_session(session(id, "/tmp", "bash")); let args = maestro_cli::cli::SendArgs { session: id.to_string(), text: vec!["ls".into()], no_enter: true, }; maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty()) .await .expect("send"); assert!(!mock.inputs()[0].1.submit); } /// No text arguments → the message comes from stdin, minus the trailing /// newline a pipe or heredoc adds. #[tokio::test] async fn send_reads_stdin_and_drops_its_trailing_newline() { let mock = MockDaemon::start().await; let id = Uuid::from_u128(9); mock.add_session(session(id, "/tmp", "bash")); let args = maestro_cli::cli::SendArgs { session: id.to_string(), text: Vec::new(), no_enter: false, }; let mut stdin = std::io::Cursor::new(b"multi\nline\n".to_vec()); maestro_cli::sessions::run_send(&mock.client(), &args, &mut stdin) .await .expect("send"); assert_eq!(mock.inputs()[0].1.text, "multi\nline"); } /// A session named through `PATCH /api/sessions/:id` is addressable by that /// name, so scripts don't have to carry a UUID around. #[tokio::test] async fn send_resolves_a_session_by_name() { let mock = MockDaemon::start().await; let id = Uuid::from_u128(10); let mut named = session(id, "/tmp", "claude"); named .metadata .insert("name".into(), serde_json::Value::String("planner".into())); mock.add_session(named); mock.add_session(session(Uuid::from_u128(11), "/tmp", "bash")); let args = maestro_cli::cli::SendArgs { session: "Planner".into(), text: vec!["go".into()], no_enter: false, }; let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty()) .await .expect("send"); assert_eq!(sent, id); } #[tokio::test] async fn send_to_an_unknown_session_fails_without_typing_anything() { let mock = MockDaemon::start().await; mock.add_session(session(Uuid::from_u128(12), "/tmp", "bash")); let args = maestro_cli::cli::SendArgs { session: "nope".into(), text: vec!["hi".into()], no_enter: false, }; let err = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty()) .await .expect_err("should not resolve"); assert!(err.to_string().contains("no session matches"), "{err}"); assert!(mock.inputs().is_empty()); }