The guard moves off the worktree and onto the kill. DELETE /api/sessions/:id now runs the shared unsaved_work probe before tearing anything down and answers 409 kill_guarded with the breakdown on the wire, so a client can say "3 uncommitted files, 2 unpushed commits". The session keeps running and the tree is untouched. WorktreeManager::remove is unconditional now — git itself still refuses a dirty tree as a last line of defense.
542 lines
16 KiB
Rust
542 lines
16 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 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 splits into a known adjective and noun.
|
|
fn assert_valid_names(batch: &maestro_protocol::NameBatch) {
|
|
for name in &batch.names {
|
|
let (adj, noun) = name.split_once('-').unwrap_or_else(|| {
|
|
panic!("handle {name:?} is not adjective-noun");
|
|
});
|
|
assert!(
|
|
maestro_names::ADJECTIVES.contains(&adj),
|
|
"{adj} not in ADJECTIVES"
|
|
);
|
|
assert!(maestro_names::NOUNS.contains(&noun), "{noun} not in NOUNS");
|
|
}
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
/// 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(),
|
|
cleanup_on_kill: true,
|
|
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.dirty_tracked_files, 1);
|
|
|
|
assert!(h.manager.has(session.id).await, "the session keeps running");
|
|
}
|
|
|
|
#[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).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:?}"),
|
|
}
|
|
}
|
|
}
|