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-daemon/tests/local_api.rs
T
Miguel PalhasandClaude Opus 5 eaf60d9dac feat(maestro): stamp the daemon with its build commit
`GET /api/info` now carries `build` (commit hash, commit date, dirty flag)
and `instance`, a fresh uuid per daemon process. Together they let a client
name the release it is talking to and, after losing its socket, tell an
upgrade from a plain restart from a network blip.

The stamp comes from `maestro-daemon/build.rs`, which reads the git checkout
it compiles in. The Nix build has no `.git` in the crane-filtered source, so
`flake.nix` passes the flake's own revision and last-modified date through
`MAESTRO_BUILD_*` instead. Neither available leaves the fields empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 15:40:41 +01:00

755 lines
23 KiB
Rust

//! Integration tests for the daemon's HTTP + WS API (NAP-243).
mod common;
use std::collections::HashMap;
use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use common::DaemonFixture;
use futures_util::{SinkExt, StreamExt};
use maestro_daemon::local_api;
use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
use uuid::Uuid;
type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
struct Harness {
addr: SocketAddr,
manager: maestro_daemon::SessionManager,
_shutdown: oneshot::Sender<()>,
_fx: DaemonFixture,
}
async fn spawn(hostname: &str) -> Harness {
spawn_with(
hostname,
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
)
.await
}
async fn spawn_with(
hostname: &str,
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
) -> Harness {
let fx = DaemonFixture::new();
let manager = fx.manager.clone();
// Built-in presets (claude/opencode/zsh + custom) so `/api/info` advertises
// a known set (#26).
let presets = maestro_config::SpawnSection::default().resolve().unwrap();
let router = local_api::router(local_api::state_for(
manager.clone(),
hostname,
"/home/test",
presets,
projects,
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, router)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
Harness {
addr,
manager,
_shutdown: tx,
_fx: fx,
}
}
fn url(addr: SocketAddr, path: &str) -> String {
format!("http://{addr}{path}")
}
fn ws_url(addr: SocketAddr, path: &str) -> String {
format!("ws://{addr}{path}")
}
#[tokio::test]
async fn info_returns_daemon_identity() {
let h = spawn("naps-laptop").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(resp["hostname"], "naps-laptop");
assert_eq!(resp["home_dir"], "/home/test");
assert!(resp["version"].is_string());
}
#[tokio::test]
async fn info_reports_build_and_instance() {
// The status indicator needs a release to name and a process to compare
// against after a reconnect. `commit`/`commit_date` are empty when the
// binary was built outside a checkout, so only their presence is asserted;
// `instance` is a fresh uuid per daemon process.
let h = spawn("naps-laptop").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
.await
.unwrap()
.json()
.await
.unwrap();
assert!(resp["build"]["commit"].is_string());
assert!(resp["build"]["commit_date"].is_string());
assert!(resp["build"]["dirty"].is_boolean());
let instance = resp["instance"].as_str().unwrap();
assert!(Uuid::parse_str(instance).is_ok(), "instance is a uuid");
// Same process, same id — a client only treats a *changed* id as a restart.
let again: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(again["instance"].as_str().unwrap(), instance);
}
#[tokio::test]
async fn list_sessions_starts_empty_and_reflects_creates() {
let h = spawn("naps-laptop").await;
let empty: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
.await
.unwrap()
.json()
.await
.unwrap();
assert!(empty.is_empty());
let req = CreateSessionRequest {
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
};
let created: Session = reqwest::Client::new()
.post(url(h.addr, "/api/sessions"))
.json(&req)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let after: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(after.len(), 1);
assert_eq!(after[0].id, created.id);
}
#[tokio::test]
async fn info_advertises_spawn_presets() {
// #26: the daemon's presets ride the info payload so the spawn form needs
// no extra round-trip. Built-ins + the implicit custom.
let h = spawn("naps-laptop").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
.await
.unwrap()
.json()
.await
.unwrap();
let ids: Vec<&str> = resp["presets"]
.as_array()
.unwrap()
.iter()
.map(|p| p["id"].as_str().unwrap())
.collect();
assert_eq!(ids, ["claude", "opencode", "zsh", "custom"]);
}
#[tokio::test]
async fn recent_dirs_returns_empty_without_history() {
// #26: reads this daemon's spawn history. The test fixture wires no
// history, so the list is empty but the endpoint is well-formed.
let h = spawn("naps-laptop").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/recent-dirs"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(resp["dirs"].as_array().unwrap().len(), 0);
assert_eq!(resp["truncated"], false);
}
/// #54: `GET /api/projects` lists every git repo under the scan roots, with
/// real git checkouts — not mocks.
#[tokio::test]
async fn projects_lists_repos_under_the_scan_roots() {
let tmp = tempfile::tempdir().unwrap();
for name in ["alpha", "beta"] {
let repo = tmp.path().join(name);
std::fs::create_dir_all(&repo).unwrap();
for args in [
vec!["init", "-q"],
vec![
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-qm",
"init",
"--allow-empty",
],
] {
let out = std::process::Command::new("git")
.arg("-C")
.arg(&repo)
.args(&args)
.output()
.unwrap();
assert!(out.status.success());
}
}
let scanner = std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::new(
vec![tmp.path().to_path_buf()],
3,
tmp.path().to_path_buf(),
));
let h = spawn_with("naps-laptop", scanner).await;
let projects: Vec<maestro_protocol::Project> = reqwest::get(url(h.addr, "/api/projects"))
.await
.unwrap()
.json()
.await
.unwrap();
let mut names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect();
names.sort();
assert_eq!(names, ["alpha", "beta"]);
for p in &projects {
assert!(!p.is_worktree);
assert_eq!(p.base_repo, None);
assert!(p.last_activity.is_some(), "fresh commit ⇒ HEAD mtime known");
}
}
#[tokio::test]
async fn projects_returns_empty_for_a_disabled_scanner() {
let h = spawn("naps-laptop").await;
let projects: Vec<maestro_protocol::Project> = reqwest::get(url(h.addr, "/api/projects"))
.await
.unwrap()
.json()
.await
.unwrap();
assert!(projects.is_empty());
}
/// #53: every returned handle is one word from the shared list.
fn assert_valid_names(batch: &maestro_protocol::NameBatch) {
for name in &batch.names {
assert!(!name.contains('-'), "handle {name:?} is not one word");
assert!(
maestro_names::WORDS.contains(&name.as_str()),
"{name} not in WORDS"
);
}
}
#[tokio::test]
async fn names_defaults_to_one_handle() {
let h = spawn("naps-laptop").await;
let batch: maestro_protocol::NameBatch = reqwest::get(url(h.addr, "/api/names"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(batch.names.len(), 1);
assert_valid_names(&batch);
}
#[tokio::test]
async fn names_honors_count_and_caps_it() {
use maestro_names::MAX_BATCH;
let h = spawn("naps-laptop").await;
let batch: maestro_protocol::NameBatch = reqwest::get(url(h.addr, "/api/names?count=5"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(batch.names.len(), 5);
assert_valid_names(&batch);
let batch: maestro_protocol::NameBatch =
reqwest::get(url(h.addr, &format!("/api/names?count={}", MAX_BATCH * 10)))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(batch.names.len(), MAX_BATCH);
}
#[tokio::test]
async fn gitroot_probes_path() {
// #26: resolves the query against the daemon home dir and walks up for a
// `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null.
let h = spawn("naps-laptop").await;
let tmp = tempfile::tempdir().unwrap();
let resp: serde_json::Value = reqwest::get(url(
h.addr,
&format!(
"/api/gitroot?path={}",
urlencoding::encode(&tmp.path().to_string_lossy())
),
))
.await
.unwrap()
.json()
.await
.unwrap();
assert!(resp.get("root").is_none() || resp["root"].is_null());
}
#[tokio::test]
async fn delete_unknown_session_returns_404() {
let h = spawn("naps-laptop").await;
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/sessions/{}", Uuid::new_v4())))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}
/// #125: the rename endpoint answers with the updated session, and the name
/// it stored is the trimmed one.
#[tokio::test]
async fn patch_session_renames_it() {
let h = spawn("naps-laptop").await;
let session = h
.manager
.create(CreateSessionRequest {
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap();
let resp = reqwest::Client::new()
.patch(url(h.addr, &format!("/api/sessions/{}", session.id)))
.json(&serde_json::json!({ "name": " crimson-otter " }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::OK);
let updated: Session = resp.json().await.unwrap();
assert_eq!(
updated.metadata.get("name").and_then(|v| v.as_str()),
Some("crimson-otter")
);
}
#[tokio::test]
async fn patch_unknown_session_returns_404() {
let h = spawn("naps-laptop").await;
let resp = reqwest::Client::new()
.patch(url(h.addr, &format!("/api/sessions/{}", Uuid::new_v4())))
.json(&serde_json::json!({ "name": "nope" }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn delete_owned_session_kills_it() {
let h = spawn("naps-laptop").await;
let session = h
.manager
.create(CreateSessionRequest {
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap();
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/sessions/{}", session.id)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
assert!(!h.manager.has(session.id).await);
}
/// Spawn a session through the manager and return its id.
async fn spawn_via_manager(h: &Harness) -> Uuid {
h.manager
.create(CreateSessionRequest {
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap()
.id
}
/// The agent activity endpoint on the wire (#105): a report lands in the
/// session snapshot with its detail.
#[tokio::test]
async fn post_activity_reports_error_with_detail() {
let h = spawn("naps-laptop").await;
let id = spawn_via_manager(&h).await;
let resp = reqwest::Client::new()
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
.json(&serde_json::json!({
"state": "Error",
"detail": "cargo build failed"
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
.await
.unwrap()
.json()
.await
.unwrap();
let s = sessions.iter().find(|s| s.id == id).unwrap();
assert_eq!(s.activity, maestro_protocol::ActivityState::Error);
assert_eq!(s.activity_detail.as_deref(), Some("cargo build failed"));
}
#[tokio::test]
async fn post_activity_to_unknown_session_returns_404() {
let h = spawn("naps-laptop").await;
let resp = reqwest::Client::new()
.post(url(
h.addr,
&format!("/api/sessions/{}/activity", Uuid::new_v4()),
))
.json(&serde_json::json!({ "state": "Idle" }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}
/// Unknown state values are rejected rather than coerced (#105).
#[tokio::test]
async fn post_activity_rejects_an_unknown_state_value() {
let h = spawn("naps-laptop").await;
let id = spawn_via_manager(&h).await;
let resp = reqwest::Client::new()
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
.json(&serde_json::json!({ "state": "Teleported" }))
.send()
.await
.unwrap();
assert!(resp.status().is_client_error(), "got {}", resp.status());
}
#[tokio::test]
async fn post_activity_caps_an_overlong_detail_server_side() {
let h = spawn("naps-laptop").await;
let id = spawn_via_manager(&h).await;
let resp = reqwest::Client::new()
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
.json(&serde_json::json!({
"state": "Error",
"detail": "x".repeat(10_000)
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
.await
.unwrap()
.json()
.await
.unwrap();
let s = sessions.iter().find(|s| s.id == id).unwrap();
assert_eq!(
s.activity_detail.as_ref().map(|d| d.chars().count()),
Some(maestro_daemon::MAX_REPORT_DETAIL_CHARS)
);
}
/// The kill guard on the wire (#79): `DELETE /api/sessions/:id` answers 409
/// with the structured breakdown, and the session keeps running.
#[tokio::test]
async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the_breakdown() {
let h = spawn("naps-laptop").await;
let repo = tempfile::TempDir::new().unwrap();
common::init_repo(repo.path());
let session = h
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/guarded".into(),
create_branch: true,
}),
})
.await
.unwrap();
std::fs::write(
repo.path()
.join("worktrees")
.join("feat")
.join("guarded")
.join("README"),
b"unsaved edits\n",
)
.unwrap();
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/sessions/{}", session.id)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::CONFLICT);
let body: ApiError = resp.json().await.unwrap();
assert_eq!(body.error.code, "kill_guarded");
let breakdown = body.error.unsaved_work.expect("breakdown on the wire");
assert_eq!(breakdown.uncommitted_files, 1);
assert!(h.manager.has(session.id).await, "the session keeps running");
}
/// `?force=true` on the wire (#81): the refusal is overridden and the session
/// ends, while the guarded worktree survives on disk.
#[tokio::test]
async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
let h = spawn("naps-laptop").await;
let repo = tempfile::TempDir::new().unwrap();
common::init_repo(repo.path());
let wt_path = repo
.path()
.join("worktrees")
.join("feat")
.join("guarded")
.to_path_buf();
let session = h
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/guarded".into(),
create_branch: true,
}),
})
.await
.unwrap();
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/sessions/{}", session.id)))
.query(&[("force", "true")])
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
// The tree would have blocked; force must not have removed it.
assert!(wt_path.exists(), "a forced kill never removes the worktree");
assert!(!h.manager.has(session.id).await, "the session is gone");
}
#[tokio::test]
async fn attach_ws_streams_pty_bytes() {
let h = spawn("naps-laptop").await;
let session = h
.manager
.create(CreateSessionRequest {
cwd: "/tmp".into(),
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap();
let ws_url = format!("ws://{}/api/attach/{}", h.addr, session.id);
let (mut ws, _): (WsClient, _) = connect_async(ws_url).await.unwrap();
// Provoke output. The bash process should echo this back via the PTY.
ws.send(WsMessage::Binary(b"echo hello-attach\n".to_vec()))
.await
.unwrap();
let saw_hello = tokio::time::timeout(Duration::from_secs(2), async {
loop {
match ws.next().await {
Some(Ok(WsMessage::Binary(bytes))) => {
if String::from_utf8_lossy(&bytes).contains("hello-attach") {
return true;
}
}
Some(Ok(_)) => continue,
Some(Err(_)) | None => return false,
}
}
})
.await
.unwrap_or(false);
assert!(saw_hello, "PTY did not echo through the attach WS");
}
#[tokio::test]
async fn attach_ws_for_unknown_session_returns_404() {
let h = spawn("naps-laptop").await;
let ws_url = format!("ws://{}/api/attach/{}", h.addr, Uuid::new_v4());
let err = connect_async(ws_url)
.await
.expect_err("expected handshake fail");
let msg = err.to_string();
assert!(
msg.contains("404") || msg.to_lowercase().contains("not found"),
"{msg}"
);
}
/// `WS /api/events` had no daemon-level test: the Snapshot-then-deltas contract
/// and the subscribe-before-snapshot ordering were only described in a comment.
#[tokio::test]
async fn events_ws_sends_a_snapshot_then_streams_deltas() {
let h = spawn("konishi").await;
let cwd = tempfile::TempDir::new().unwrap();
let (mut ws, _) = connect_async(ws_url(h.addr, "/api/events"))
.await
.expect("events ws");
match next_event(&mut ws).await {
maestro_protocol::CatalogEvent::Snapshot { sessions } => assert!(sessions.is_empty()),
other => panic!("first frame must be a Snapshot, got {other:?}"),
}
let created = h
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
match next_event(&mut ws).await {
maestro_protocol::CatalogEvent::SessionAdded { session } => {
assert_eq!(session.id, created.id);
}
other => panic!("expected SessionAdded, got {other:?}"),
}
h.manager.kill(created.id, false).await.expect("kill");
// Activity ticks may interleave; the removal is what we are waiting for.
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
assert!(
tokio::time::Instant::now() < deadline,
"timed out waiting for SessionRemoved"
);
if let maestro_protocol::CatalogEvent::SessionRemoved { id } = next_event(&mut ws).await {
assert_eq!(id, created.id);
break;
}
}
}
/// Each subscriber has its own lag domain now that the publisher task is gone:
/// two clients connected at different times both get their own Snapshot.
#[tokio::test]
async fn every_events_subscriber_gets_its_own_snapshot() {
let h = spawn("konishi").await;
let cwd = tempfile::TempDir::new().unwrap();
let (mut first, _) = connect_async(ws_url(h.addr, "/api/events")).await.unwrap();
let _ = next_event(&mut first).await;
h.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
let (mut second, _) = connect_async(ws_url(h.addr, "/api/events")).await.unwrap();
match next_event(&mut second).await {
maestro_protocol::CatalogEvent::Snapshot { sessions } => {
assert_eq!(
sessions.len(),
1,
"a late subscriber must see current state"
);
}
other => panic!("expected Snapshot, got {other:?}"),
}
}
async fn next_event(ws: &mut WsClient) -> maestro_protocol::CatalogEvent {
loop {
match tokio::time::timeout(Duration::from_secs(5), ws.next())
.await
.expect("events ws timed out")
{
Some(Ok(WsMessage::Text(t))) => {
return serde_json::from_str(&t).expect("decode catalog event")
}
Some(Ok(_)) => continue,
other => panic!("unexpected events frame: {other:?}"),
}
}
}