There were three answers to "how do I test against a daemon", and the CLI
— the thinnest client — had none, so its formatting and error handling
were exercised by compiling and forking the real binary plus tmux.
Adds crates/maestro-testing with a MockDaemon that serves the real /api/*
surface from maestro-protocol types, backed by a Vec<Session>. It replaces
two ad-hoc stubs: maestro-web's, which returned [{"id":"session-1"}] —
not a valid Session — and maestro-tui's scripted socket with a hardcoded
timeline.
maestro-cli gets its first tests against a daemon: six cases covering the
catalog, that DaemonInfo carries the version and presets its old private
copy dropped, that a structured failure surfaces the daemon's own message,
and that connection-refused stays distinguishable from an HTTP rejection.
maestro-web's proxy suite gains an end-to-end check that an upstream
rejection reaches the browser as a 4502 close carrying the daemon's
message — upstream_close_reason had only unit tests.
e2e keeps the tmux-shaped tests. cli_list_reads_from_daemon still runs the
shipped binary as a subprocess but now against MockDaemon, and
tui_integration.rs is deleted: the real daemon's /api/events contract is
covered by maestro-daemon/tests/local_api.rs and the client reducer by
maestro-tui/tests/catalog_ws.rs. The e2e suite drops from ~32s to ~20s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
139 lines
4.9 KiB
Rust
139 lines
4.9 KiB
Rust
//! Integration tests for the maestro-web reverse-proxy.
|
|
//!
|
|
//! Points maestro-web at a `MockDaemon` and exercises both the HTTP and WS
|
|
//! proxy paths end-to-end.
|
|
|
|
use maestro_testing::MockDaemon;
|
|
use std::net::{Ipv4Addr, SocketAddr};
|
|
use std::time::Duration;
|
|
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use maestro_web::config::WebConfig;
|
|
use tokio::net::TcpListener;
|
|
use tokio::sync::oneshot;
|
|
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
|
|
|
async fn spawn_web(daemon_url: String) -> (SocketAddr, oneshot::Sender<()>) {
|
|
let cfg = WebConfig {
|
|
bind_addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
|
|
daemon_url,
|
|
};
|
|
let router = maestro_web::router(&cfg).expect("router");
|
|
let listener = TcpListener::bind(cfg.bind_addr).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;
|
|
});
|
|
(addr, tx)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn http_get_api_path_forwards_to_daemon() {
|
|
let daemon = MockDaemon::start().await;
|
|
let daemon_addr = daemon.addr();
|
|
let (web_addr, _w) = spawn_web(format!("http://{daemon_addr}")).await;
|
|
|
|
let body: serde_json::Value = reqwest::get(format!("http://{web_addr}/api/sessions"))
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert!(body.as_array().expect("array").is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn http_get_unrelated_path_falls_back_to_spa_index() {
|
|
let daemon = MockDaemon::start().await;
|
|
let daemon_addr = daemon.addr();
|
|
let (web_addr, _w) = spawn_web(format!("http://{daemon_addr}")).await;
|
|
|
|
// The bundle is empty in test builds — the placeholder index.html serves.
|
|
let resp = reqwest::get(format!("http://{web_addr}/some/route"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), reqwest::StatusCode::OK);
|
|
let ct = resp
|
|
.headers()
|
|
.get(reqwest::header::CONTENT_TYPE)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
assert!(ct.starts_with("text/html"), "ct={ct}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ws_api_attach_forwards_to_daemon_and_echoes() {
|
|
let daemon = MockDaemon::start().await;
|
|
let id = uuid::Uuid::from_u128(7);
|
|
daemon.add_session(maestro_testing::session(id, "/home/mock", "bash"));
|
|
let daemon_addr = daemon.addr();
|
|
let (web_addr, _w) = spawn_web(format!("http://{daemon_addr}")).await;
|
|
|
|
let url = format!("ws://{web_addr}/api/attach/{id}");
|
|
let (mut ws, _) = tokio_tungstenite::connect_async(url).await.unwrap();
|
|
ws.send(WsMessage::Binary(b"hello".to_vec())).await.unwrap();
|
|
|
|
let echoed = tokio::time::timeout(Duration::from_secs(1), ws.next())
|
|
.await
|
|
.unwrap();
|
|
match echoed {
|
|
Some(Ok(WsMessage::Binary(b))) => assert_eq!(b, b"hello"),
|
|
other => panic!("unexpected frame: {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// The client is upgraded before the upstream connection is attempted, so an
|
|
/// upstream rejection has to come back as a close code. This is the end-to-end
|
|
/// counterpart to `upstream_close_reason`, which had only unit tests: the
|
|
/// daemon's own message must reach the browser rather than a generic failure.
|
|
#[tokio::test]
|
|
async fn ws_attach_to_an_unknown_session_closes_with_the_daemon_reason() {
|
|
let daemon = MockDaemon::start().await;
|
|
let (web_addr, _w) = spawn_web(format!("http://{}", daemon.addr())).await;
|
|
|
|
let url = format!("ws://{web_addr}/api/attach/{}", uuid::Uuid::new_v4());
|
|
let (mut ws, _) = tokio_tungstenite::connect_async(url)
|
|
.await
|
|
.expect("upgrade");
|
|
|
|
let frame = tokio::time::timeout(Duration::from_secs(2), ws.next())
|
|
.await
|
|
.expect("close frame");
|
|
match frame {
|
|
Some(Ok(WsMessage::Close(Some(cf)))) => {
|
|
assert_eq!(u16::from(cf.code), 4502);
|
|
assert!(
|
|
cf.reason.contains("no such session"),
|
|
"expected the daemon's message, got {:?}",
|
|
cf.reason
|
|
);
|
|
}
|
|
other => panic!("expected a Close frame with a reason, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proxy_returns_502_when_daemon_unreachable() {
|
|
// Bind a port, immediately drop the listener, hand the address to the
|
|
// proxy. Connections to it will be refused.
|
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
|
.await
|
|
.unwrap();
|
|
let dead_addr = listener.local_addr().unwrap();
|
|
drop(listener);
|
|
|
|
let (web_addr, _w) = spawn_web(format!("http://{dead_addr}")).await;
|
|
|
|
let resp = reqwest::get(format!("http://{web_addr}/api/sessions"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), reqwest::StatusCode::BAD_GATEWAY);
|
|
let body: serde_json::Value = resp.json().await.unwrap();
|
|
assert_eq!(body["error"]["code"], "proxy_failed");
|
|
}
|