This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-cli/tests/against_mock_daemon.rs
T
Miguel PalhasandClaude Opus 4.6 88afd0df3f
ci / web (push) Failing after 6s
ci / rust (push) Failing after 51s
ci / flake (push) Successful in 2m41s
feat(maestro): auto-approve ACP permissions via preset config
Structured sessions ignored the --dangerously-skip-permissions flags in
preset commands because the daemon discards CLI flags when spawning the
ACP adapter binary. This adds an `auto_approve` bool to SpawnPreset and
CreateSessionRequest so the operator can declare it in config.toml:

  { id = "claude", command = "claude", auto_approve = true }

When set, the daemon's permission pump immediately responds AllowAlways
to every ACP permission request without surfacing it to clients.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:19:24 +01:00

222 lines
6.7 KiB
Rust

//! 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(),
auto_approve: false,
}],
..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 {
view: Default::default(),
cwd: "/home/mock".into(),
command: "bash".into(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
auto_approve: false,
})
.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());
}