From 6f2e7885a47c3aa5d63422057348f16d6baa3f46 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 22:20:26 +0700 Subject: [PATCH] test(cli): wire-level integration tests via SPACESH_SOCK mock daemon Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spacesh-cli/tests/integration.rs | 76 +++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 crates/spacesh-cli/tests/integration.rs diff --git a/crates/spacesh-cli/tests/integration.rs b/crates/spacesh-cli/tests/integration.rs new file mode 100644 index 0000000..1a46a2a --- /dev/null +++ b/crates/spacesh-cli/tests/integration.rs @@ -0,0 +1,76 @@ +use std::path::PathBuf; +use spacesh_proto::codec::{read_frame, write_frame}; +use spacesh_proto::{Cmd, Envelope, SurfaceId}; +use spacesh_proto::status::SurfaceState; +use spacesh_cli::client; +use tokio::net::UnixListener; + +// These tests mutate the process-global SPACESH_SOCK env var, so they must not +// run concurrently. Serialize them on a process-wide lock (poison-tolerant). +static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); +fn serial() -> std::sync::MutexGuard<'static, ()> { + SERIAL.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn tmp_sock(name: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + p.push(format!("spacesh-cli-{name}-{n}.sock")); + p +} + +/// One-shot mock daemon: accept one connection, read one request, send `reply`. +fn mock_daemon(sock: PathBuf, reply: Envelope) { + let listener = UnixListener::bind(&sock).unwrap(); + tokio::spawn(async move { + if let Ok((mut stream, _)) = listener.accept().await { + if let Ok(Some(_req)) = read_frame(&mut stream).await { + let _ = write_frame(&mut stream, &reply).await; + } + } + }); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn request_returns_data_from_daemon() { + let _g = serial(); + let sock = tmp_sock("req"); + std::env::set_var("SPACESH_SOCK", &sock); + mock_daemon(sock.clone(), Envelope::Res { + id: 1, ok: true, data: serde_json::json!({ "workspace_id": "w_1" }), error: None, + }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; // let the listener bind + + let data = client::request(Cmd::Open { path: "/tmp".into() }).await.unwrap(); + std::env::remove_var("SPACESH_SOCK"); + let _ = std::fs::remove_file(&sock); + assert_eq!(data["workspace_id"], "w_1"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn request_surfaces_daemon_error() { + let _g = serial(); + let sock = tmp_sock("err"); + std::env::set_var("SPACESH_SOCK", &sock); + mock_daemon(sock.clone(), Envelope::Res { + id: 1, ok: false, data: serde_json::Value::Null, + error: Some(spacesh_proto::ErrorBody { code: "NOT_FOUND".into(), msg: "surface".into() }), + }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let res = client::request(Cmd::Close { surface_id: SurfaceId("s_x".into()) }).await; + std::env::remove_var("SPACESH_SOCK"); + let _ = std::fs::remove_file(&sock); + assert!(res.is_err()); + assert!(res.unwrap_err().to_string().contains("NOT_FOUND")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn notify_with_no_daemon_is_silent_success() { + let _g = serial(); + let sock = tmp_sock("nodaemon"); // never bound + std::env::set_var("SPACESH_SOCK", &sock); + let r = client::notify(Cmd::SetState { surface_id: SurfaceId("s_1".into()), state: SurfaceState::Done }).await; + std::env::remove_var("SPACESH_SOCK"); + assert!(r.is_ok(), "notify must be a silent success when no daemon is listening"); +}