use std::io::{BufRead, BufReader, Read, 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 MAX_REQUEST_LINE: usize = 64 * 1024;
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 connect(socket: &Path) -> (UnixStream, BufReader<UnixStream>) {
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"),
);
(stream, reader)
}
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 (mut stream, mut reader) = connect(socket);
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")
}
fn band_count(socket: &Path) -> usize {
let line = send_raw(socket, r#"{"cmd":"GetStatus"}"#);
let v: Value = serde_json::from_str(&line).expect("status response should be valid JSON");
v["status"]["bands"].as_array().map_or(0, Vec::len)
}
#[test]
fn oversized_line_is_rejected_and_connection_closed() {
let Some(d) = spawn_daemon() else { return };
let (mut stream, mut reader) = connect(&d.socket);
let huge = format!("{} \n", " ".repeat(MAX_REQUEST_LINE * 2));
stream
.write_all(huge.as_bytes())
.expect("writing oversized line should succeed");
stream
.flush()
.expect("flushing oversized line should succeed");
let deadline = Instant::now() + TIMEOUT;
let error_line = loop {
assert!(
Instant::now() < deadline,
"timed out waiting for the rejection"
);
let mut line = String::new();
let n = reader
.read_line(&mut line)
.expect("reading rejection response should succeed");
assert_ne!(n, 0, "daemon closed before sending the rejection");
if is_push_event(&line) {
continue;
}
break line;
};
assert!(!ok_of(&error_line), "oversized request must be rejected");
assert!(
error_line.contains("request line exceeds"),
"error should name the limit, got: {error_line}"
);
let mut rest = String::new();
let n = reader.read_to_string(&mut rest);
match n {
Ok(0) => {} Err(e) => assert_eq!(
e.kind(),
std::io::ErrorKind::ConnectionReset,
"daemon must close the connection after an oversized line, got: {e}"
),
Ok(n) => panic!("expected no data after the rejection, got {n} bytes: {rest}"),
}
assert!(ok_of(&send_raw(&d.socket, r#"{"cmd":"GetStatus"}"#)));
}
#[test]
fn non_finite_preamp_is_rejected() {
let Some(d) = spawn_daemon() else { return };
let line = send_raw(&d.socket, r#"{"cmd":"SetPreamp","gain":1e999}"#);
assert!(
!ok_of(&line),
"non-finite preamp must be rejected, got: {line}"
);
assert!(
line.contains("out of range"),
"error should name the range, got: {line}"
);
}
#[test]
fn too_many_bands_is_rejected_and_state_unchanged() {
let Some(d) = spawn_daemon() else { return };
assert_eq!(band_count(&d.socket), 0);
let mut body = String::from(r#"{"cmd":"SetBands","bands":["#);
for i in 0..32 {
if i > 0 {
body.push(',');
}
body.push_str(r#"{"frequency":1000.0,"gain":0.0,"q":1.0,"filter_type":"Peak"}"#);
}
body.push(']');
body.push('}');
let line = send_raw(&d.socket, &body);
assert!(!ok_of(&line), "32 bands must be rejected, got: {line}");
assert!(
line.contains("too many bands"),
"error should name the band limit, got: {line}"
);
assert_eq!(
band_count(&d.socket),
0,
"rejected SetBands must not mutate state"
);
}
#[test]
fn valid_request_still_roundtrips() {
let Some(d) = spawn_daemon() else { return };
let line = send_raw(
&d.socket,
r#"{"cmd":"SetBands","bands":[{"frequency":1000.0,"gain":3.0,"q":1.0,"filter_type":"Peak"}]}"#,
);
assert!(ok_of(&line), "valid SetBands must be accepted, got: {line}");
assert_eq!(band_count(&d.socket), 1);
let line = send_raw(&d.socket, r#"{"cmd":"SetPreamp","gain":-6.0}"#);
assert!(
ok_of(&line),
"valid SetPreamp must be accepted, got: {line}"
);
}