Files
soulseek-rs-modded/soulseek-rs/tests/common/mod.rs
T
Michel de Graaf 3ef78c6de1 refactor(tests): share one soulfind fixture across the e2e suites
The TUI suite had grown its own copy of the soulfind spawner the protocol
suite already carried, and its own copy of the CLI suite's in-process
client and settle helpers. All four suites now spawn, log in, settle and
skip through `tests/common`: the CLI suite's own server goes too, and
the fixture checks its child is still alive before calling the port
answered, so a lost port cannot pass another server off as ours.
2026-09-08 13:12:12 +02:00

164 lines
5.1 KiB
Rust

//! Scaffolding both CLI end-to-end binaries need: the environment they scrub,
//! the soulfind they run against, and a port nobody else holds.
#![allow(dead_code)]
use soulseek_rs::{Client, ClientSettings, PeerAddress};
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
/// Every environment variable the binary reads, cleared for each run so a
/// developer's shell (or a stray `.env`) cannot change what a test observes —
/// and so no run finds a real daemon.
pub const CLI_ENV_VARS: [&str; 15] = [
"SOULSEEK_CONFIG_DIR",
"SOULSEEK_STATE_DIR",
"SOULSEEK_DAEMON",
"SOULSEEK_DAEMON_TOKEN",
"SOULSEEK_USERNAME",
"SOULSEEK_PASSWORD",
"SOULSEEK_PASSWORD_CMD",
"SOULSEEK_SERVER",
"SOULSEEK_NO_LISTENER",
"SOULSEEK_LISTENER_PORT",
"SOULSEEK_DOWNLOAD_DIR",
"SOULSEEK_SHARED_DIR",
"SOULSEEK_MAX_CONCURRENT_DOWNLOADS",
"SOULSEEK_SEARCH_TIMEOUT",
"SOULSEEK_CONFIG",
];
pub fn soulfind_binary() -> Option<PathBuf> {
if let Ok(path) = std::env::var("SOULFIND_BIN") {
let path = PathBuf::from(path);
return path.exists().then_some(path);
}
Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.map(|dir| dir.join("soulfind/bin/soulfind"))
.find(|candidate| candidate.exists())
}
pub fn free_port() -> Option<u16> {
std::net::TcpListener::bind("127.0.0.1:0")
.ok()?
.local_addr()
.ok()
.map(|addr| addr.port())
}
/// A soulfind spawned for one test, on a port nobody else holds, with a
/// throwaway database that goes with it.
pub struct Soulfind {
child: Child,
db: PathBuf,
port: u16,
}
impl Soulfind {
pub fn start() -> Option<Self> {
let port = free_port()?;
let db = std::env::temp_dir().join(format!("soulfind-{port}.db"));
let _ = std::fs::remove_file(&db);
let child = Self::spawn(port, &db)?;
Some(Self { child, db, port })
}
fn spawn(port: u16, db: &Path) -> Option<Child> {
let mut child = Command::new(soulfind_binary()?)
.args(["-d", db.to_str()?, "-p", &port.to_string()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()?;
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
// Whoever answers on the port has to be this child: one that
// lost the port and died would otherwise pass as up.
if child.try_wait().ok().flatten().is_some() {
return None;
}
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
return Some(child);
}
std::thread::sleep(Duration::from_millis(100));
}
let _ = child.kill();
let _ = child.wait();
None
}
pub const fn port(&self) -> u16 {
self.port
}
pub fn address(&self) -> String {
format!("127.0.0.1:{}", self.port)
}
pub fn stop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
/// Bring it back on the same port and database.
pub fn restart(&mut self) -> Option<()> {
self.stop();
self.child = Self::spawn(self.port, &self.db)?;
Some(())
}
/// A client logged in to this server as `user`, sharing `shares`.
pub fn client(&self, user: &str, shares: Vec<String>) -> Client {
login("127.0.0.1", self.port, user, shares)
}
}
impl Drop for Soulfind {
fn drop(&mut self) {
self.stop();
let _ = std::fs::remove_file(&self.db);
}
}
/// An in-process client logged in to the server at `host:port` as `user`,
/// sharing `shares`: the other end of a test.
///
/// The listener stays on: a peer delivers search results and transfers over
/// a connection it opens back to us, so a client without one hears nothing.
pub fn login(host: &str, port: u16, user: &str, shares: Vec<String>) -> Client {
let mut client = Client::with_settings(ClientSettings {
username: user.to_string(),
password: "pw".to_string(),
server_address: PeerAddress::new(host.to_string(), port),
enable_listen: true,
listen_port: free_port().expect("peer port"),
shared_directories: shares,
version: soulseek_rs::ClientVersion::default(),
});
client.connect().expect("peer connect");
assert!(client.login().expect("peer login"), "peer should log in");
client
}
/// Wait out the SetWaitPort registrations so peer lookups resolve.
pub fn settle() {
std::thread::sleep(Duration::from_secs(1));
}
/// What a suite does without a server: fail when the run said one is
/// required, otherwise say it skipped. The caller returns afterwards.
pub fn no_server(suite: &str) {
let required = std::env::var("SOULSEEK_E2E_REQUIRED")
.is_ok_and(|v| v != "0" && !v.is_empty());
assert!(
!required,
"SOULSEEK_E2E_REQUIRED is set but no soulfind server could be \
started for the {suite} suite (set SOULFIND_BIN)"
);
println!("{suite} skipped: no soulfind server (set SOULFIND_BIN to run)");
}