use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use serde_json::Value;
const TIMEOUT: Duration = Duration::from_secs(5);
struct TestDaemon {
child: Child,
socket: PathBuf,
_dir: tempfile::TempDir,
}
impl Drop for TestDaemon {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn spawn_daemon() -> Option<TestDaemon> {
let dir = tempfile::tempdir().expect("tempdir creation should succeed");
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700))
.expect("setting tempdir mode should succeed");
let socket = dir.path().join("eqtui").join("eqtui.sock");
let mut command = Command::new(env!("CARGO_BIN_EXE_eqtui"));
command
.arg("daemon")
.env("XDG_RUNTIME_DIR", dir.path())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
if let Ok(ambient) = std::env::var("XDG_RUNTIME_DIR") {
command.env("PIPEWIRE_RUNTIME_DIR", ambient);
}
let mut child = match command.spawn() {
Ok(c) => c,
Err(e) => {
eprintln!("SKIP: could not spawn daemon binary: {e}");
return None;
}
};
let deadline = Instant::now() + TIMEOUT;
while Instant::now() < deadline {
if socket.exists() {
return Some(TestDaemon {
child,
socket,
_dir: dir,
});
}
if let Ok(Some(status)) = child.try_wait() {
eprintln!(
"SKIP: daemon exited during startup with {status} \
(no PipeWire session?) — skipping integration test"
);
return None;
}
std::thread::sleep(Duration::from_millis(100));
}
let _ = child.kill();
let _ = child.wait();
eprintln!("SKIP: daemon socket did not appear within 5s — skipping integration test");
None
}
fn is_push_event(line: &str) -> bool {
matches!(
serde_json::from_str::<Value>(line),
Ok(v) if v.get("event").is_some()
)
}
fn send_raw(socket: &Path, body: &str) -> String {
let stream = UnixStream::connect(socket).expect("daemon socket should accept connections");
stream
.set_read_timeout(Some(TIMEOUT))
.expect("setting read timeout should succeed");
stream
.set_write_timeout(Some(TIMEOUT))
.expect("setting write timeout should succeed");
let reader = BufReader::new(
stream
.try_clone()
.expect("cloning socket for reading should succeed"),
);
let (mut stream, mut reader) = (stream, reader);
stream
.write_all(body.as_bytes())
.expect("writing request should succeed");
stream
.write_all(b"\n")
.expect("writing newline should succeed");
stream.flush().expect("flushing request should succeed");
let deadline = Instant::now() + TIMEOUT;
loop {
assert!(
Instant::now() < deadline,
"timed out waiting for a response"
);
let mut line = String::new();
let n = reader
.read_line(&mut line)
.expect("reading response should succeed");
assert_ne!(n, 0, "daemon closed the connection before responding");
if !is_push_event(&line) {
return line;
}
}
}
fn ok_of(line: &str) -> bool {
let v: Value = serde_json::from_str(line).expect("response should be valid JSON");
v["ok"].as_bool().expect("response should have an ok field")
}
#[test]
fn clean_shutdown_exits_zero_and_removes_socket() {
let Some(mut d) = spawn_daemon() else { return };
let line = send_raw(&d.socket, r#"{"cmd":"Shutdown"}"#);
assert!(
ok_of(&line),
"Shutdown request must be accepted, got: {line}"
);
let deadline = Instant::now() + Duration::from_secs(2);
let status = loop {
if let Some(status) = d.child.try_wait().expect("try_wait should succeed") {
break status;
}
assert!(Instant::now() < deadline, "daemon did not exit within 2 s");
std::thread::sleep(Duration::from_millis(50));
};
assert!(status.success(), "daemon must exit 0, got: {status}");
assert!(
!d.socket.exists(),
"socket file must be removed on clean exit"
);
}