Files
soulseek-rs-modded/soulseek-rs/tests/cli_e2e.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

3787 lines
123 KiB
Rust

//! End-to-end tests of the scriptable command surface, driving the real
//! binary the way a script would: arguments in, records on stdout, progress on
//! stderr, a verdict in the exit code.
//!
//! The tests split in two. The first half needs no server and always runs.
//! The second half talks to a real Soulseek server (soulfind) and follows the
//! same server-optional rule as the library suite: with `SOULFIND_BIN` (or a
//! sibling `soulfind/bin/soulfind` checkout) it spawns one, otherwise it skips
//! with a notice. `SOULSEEK_E2E_REQUIRED=1` turns a missing server into a
//! failure, which is how CI guarantees these actually run.
mod common;
use common::{CLI_ENV_VARS, Soulfind, free_port, login, settle};
use soulseek_rs::Client;
use std::io::Write;
use std::net::{TcpStream, ToSocketAddrs};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::time::{Duration, Instant};
const BIN: &str = env!("CARGO_BIN_EXE_soulseek-rs");
/// Exit codes the command surface promises. Kept as literals so a change to
/// the enum has to be a deliberate change here too.
const EXIT_OK: i32 = 0;
const EXIT_USAGE: i32 = 2;
const EXIT_CONNECTION: i32 = 3;
const EXIT_NO_RESULTS: i32 = 4;
const EXIT_TRANSFER: i32 = 6;
const EXIT_SESSION_LOST: i32 = 7;
/// Build a command for the binary, isolated from the developer's environment
/// and from the repository's own working directory.
///
/// The state directory is not merely cleared but pointed somewhere empty: a
/// client with no state directory of its own would look in the real one, find
/// whatever daemon the developer happens to be running, and route the whole
/// suite through their live Soulseek session.
fn command(args: &[&str]) -> Command {
let mut command = Command::new(BIN);
for name in CLI_ENV_VARS {
command.env_remove(name);
}
command.env("SOULSEEK_STATE_DIR", isolated_state_dir());
command.current_dir(std::env::temp_dir());
command.args(args);
command
}
/// An empty state directory shared by every run in this test process, so
/// auto-routing finds nothing.
fn isolated_state_dir() -> PathBuf {
static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
DIR.get_or_init(|| {
let path = std::env::temp_dir()
.join(format!("soulseek-cli-e2e-state-{}", std::process::id()));
std::fs::create_dir_all(&path).expect("state directory");
path
})
.clone()
}
/// Which kind of session the suite drives.
///
/// The point of the switch is that the *same* tests run both ways: identical
/// records, identical exit codes, whether the session is this process's own or
/// a daemon's. CI runs the suite twice, once per mode, and that equivalence is
/// the guarantee daemon mode rests on.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
enum Mode {
Local,
Daemon,
}
fn mode() -> Mode {
match std::env::var("SOULSEEK_E2E_MODE").as_deref() {
Ok("daemon") => Mode::Daemon,
_ => Mode::Local,
}
}
fn run(args: &[&str]) -> Output {
command(args).output().expect("the binary should run")
}
fn code(output: &Output) -> i32 {
output
.status
.code()
.expect("the process should not be signalled")
}
fn stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).to_string()
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).to_string()
}
/// Records are one per line; blank trailing lines are not records.
fn records(output: &Output) -> Vec<String> {
stdout(output)
.lines()
.filter(|line| !line.trim().is_empty())
.map(String::from)
.collect()
}
/// The first record of a `--json` run, parsed.
fn json_record(output: &Output) -> serde_json::Value {
serde_json::from_str(records(output).first().expect("one record"))
.expect("valid JSON")
}
// ---------------------------------------------------------------------------
// No server required
// ---------------------------------------------------------------------------
#[test]
fn help_lists_every_scriptable_command() {
let output = run(&["--help"]);
assert_eq!(code(&output), EXIT_OK);
let help = stdout(&output);
for command in [
"search",
"download",
"get",
"browse",
"room",
"message",
"portmap",
"skills",
"completions",
"wish",
] {
assert!(help.contains(command), "--help should mention {command}");
}
}
#[test]
fn help_documents_the_exit_codes_a_script_branches_on() {
let help = stdout(&run(&["--help"]));
assert!(help.contains("Exit codes"), "help should list exit codes");
for code in ["2", "3", "4", "5", "6", "7"] {
assert!(help.contains(code), "exit code {code} should be documented");
}
}
#[test]
fn an_unknown_flag_is_a_usage_error() {
let output = run(&["--definitely-not-a-flag"]);
assert_eq!(code(&output), EXIT_USAGE);
}
#[test]
fn missing_credentials_are_a_usage_error_with_a_pointer_to_the_fix() {
let output = run(&["--no-config", "search", "anything"]);
assert_eq!(code(&output), EXIT_USAGE);
assert!(stderr(&output).contains("SOULSEEK_USERNAME"));
assert!(stdout(&output).is_empty(), "stdout must stay data-only");
}
#[test]
fn a_falsey_boolean_environment_variable_is_accepted() {
// `DISABLE_LISTENER=1` used to abort every invocation with a clap parse
// error, which made the binary unusable from a container.
for value in ["1", "0", "true", "false", "yes", ""] {
let output = command(&["--no-config", "search", "anything"])
.env("SOULSEEK_NO_LISTENER", value)
.output()
.expect("the binary should run");
assert!(
!stderr(&output).contains("invalid value"),
"SOULSEEK_NO_LISTENER={value:?} should be accepted"
);
}
}
#[test]
fn global_flags_are_accepted_after_the_subcommand() {
let output = run(&["search", "anything", "--no-config", "--json"]);
assert_eq!(
code(&output),
EXIT_USAGE,
"should reach the credential check, not a parse error"
);
assert!(!stderr(&output).contains("unexpected argument"));
}
#[test]
fn the_password_environment_value_is_not_echoed_in_help() {
let output = command(&["--help"])
.env("SOULSEEK_PASSWORD", "hunter2")
.output()
.expect("the binary should run");
assert!(
!stdout(&output).contains("hunter2"),
"--help must not print the password it inherited"
);
}
#[test]
fn download_without_a_target_is_a_usage_error() {
assert_eq!(code(&run(&["download", "--no-config"])), EXIT_USAGE);
assert_eq!(code(&run(&["download", "--no-config", "bob"])), EXIT_USAGE);
}
#[test]
fn download_stdin_cannot_be_combined_with_positional_arguments() {
let output = run(&["download", "--stdin", "bob", "file.mp3"]);
assert_eq!(code(&output), EXIT_USAGE);
}
#[test]
fn a_malformed_server_address_is_a_usage_error() {
let output = run(&[
"--no-config",
"--username",
"u",
"--password",
"p",
"--server",
"no-port-here",
"room",
"list",
]);
assert_eq!(code(&output), EXIT_USAGE);
}
#[test]
fn an_unreachable_server_is_a_connection_error() {
// Port 1 is reserved and nothing listens on it, so the connect fails fast.
let output = run(&[
"--no-config",
"--username",
"u",
"--password",
"p",
"--server",
"127.0.0.1:1",
"--no-listener",
"room",
"list",
]);
assert_eq!(code(&output), EXIT_CONNECTION);
assert!(stdout(&output).is_empty(), "stdout must stay data-only");
}
#[test]
fn without_a_terminal_the_tui_reports_instead_of_panicking() {
// Captured stdout is not a terminal, which is exactly the cron/CI case.
let output = run(&["--no-config"]);
assert_eq!(code(&output), EXIT_USAGE);
assert!(
stderr(&output).contains("terminal"),
"should explain why, got: {}",
stderr(&output)
);
}
#[test]
fn portmap_emits_a_record_whose_verdict_matches_the_exit_code() {
let output = run(&["--no-config", "--quiet", "--json", "portmap"]);
let record = json_record(&output);
assert!(record["port"].is_number());
let ok = record["ok"].as_bool().expect("ok should be a boolean");
// Whether a router answers depends on the network, but the exit code and
// the record must always agree.
assert_eq!(
ok,
code(&output) == EXIT_OK,
"exit code and the ok field must agree"
);
if !ok {
assert_eq!(code(&output), EXIT_NO_RESULTS);
}
}
#[test]
fn quiet_keeps_progress_off_stderr() {
let loud = run(&["--no-config", "--json", "portmap"]);
let quiet = run(&["--no-config", "--quiet", "--json", "portmap"]);
assert!(
stderr(&quiet).lines().count() < stderr(&loud).lines().count(),
"--quiet should suppress the progress narration"
);
assert_eq!(
records(&loud).len(),
records(&quiet).len(),
"--quiet must not change the records"
);
}
/// What the first record says happened, parsed rather than matched as text.
fn action(output: &Output) -> String {
json_record(output)["action"]
.as_str()
.expect("a record carries an action")
.to_string()
}
#[test]
fn installing_the_skill_is_repeatable_and_reversible() {
let target = Scratch::new("skills");
let dir = target.display();
let installed = target.path().join("soulseek-rs").join("SKILL.md");
let first = run(&["--json", "skills", "install", "--dir", &dir]);
assert_eq!(code(&first), EXIT_OK, "stderr: {}", stderr(&first));
assert_eq!(action(&first), "installed");
let written = std::fs::read_to_string(&installed).expect("a skill file");
assert!(written.contains("name: soulseek-rs"));
assert!(written.contains("--json"));
let second = run(&["--json", "skills", "install", "--dir", &dir]);
assert_eq!(
action(&second),
"unchanged",
"reinstalling is how you update"
);
// A neighbour in the same folder proves the sweep removes our file rather
// than the directory it happened to be sitting in.
let neighbour = installed.with_file_name("notes.md");
std::fs::write(&neighbour, "mine").expect("a neighbouring file");
let removed = run(&["--json", "skills", "uninstall", "--dir", &dir]);
assert_eq!(action(&removed), "removed");
assert!(!installed.exists());
assert!(neighbour.exists(), "a file we did not write must survive");
let again = run(&["--json", "skills", "uninstall", "--dir", &dir]);
assert_eq!(code(&again), EXIT_OK, "removing nothing is not a failure");
assert_eq!(action(&again), "absent");
}
/// A mistyped `--dir` names somebody else's directory far more often than it
/// names one of ours, so uninstall has to leave it standing.
#[test]
fn uninstall_leaves_a_skill_it_did_not_write() {
let target = Scratch::new("skills-foreign");
let foreign = target.path().join("soulseek-rs").join("SKILL.md");
std::fs::create_dir_all(foreign.parent().expect("a parent"))
.expect("mkdir");
std::fs::write(&foreign, "---\nname: someone-else\n---\n").expect("write");
let output =
run(&["--json", "skills", "uninstall", "--dir", &target.display()]);
assert_eq!(code(&output), EXIT_OK);
assert_eq!(action(&output), "absent");
assert!(foreign.exists(), "a foreign skill must survive");
}
#[test]
fn listing_skills_reports_without_writing_anything() {
let target = Scratch::new("skills-list");
let output = run(&["--json", "skills", "list", "--dir", &target.display()]);
assert_eq!(code(&output), EXIT_OK);
assert_eq!(action(&output), "absent");
assert!(
!target.path().join("soulseek-rs").exists(),
"list must not create anything"
);
}
/// fish is the shell the whole round trip can be driven for on every platform:
/// its completions directory hangs off `XDG_CONFIG_HOME`, so nothing outside
/// the scratch directory is read or written. bash and zsh also append a line
/// to a real rc file under `$HOME`, which is not a thing to point a test at on
/// Windows — the unit tests cover that half.
#[test]
fn installing_completions_is_repeatable_and_reversible() {
let target = Scratch::new("completions");
let installed = target
.path()
.join("fish")
.join("completions")
.join("soulseek-rs.fish");
let completions = |verb: &str| {
command(&["--json", "completions", verb, "--shell", "fish"])
.env("XDG_CONFIG_HOME", target.path())
.output()
.expect("the binary should run")
};
let first = completions("install");
assert_eq!(code(&first), EXIT_OK, "stderr: {}", stderr(&first));
assert_eq!(action(&first), "installed");
let script = std::fs::read_to_string(&installed).expect("a fish script");
assert!(script.contains("complete -c soulseek-rs"));
assert!(
script.contains("-l download-dir"),
"flags are completed too"
);
assert_eq!(
action(&completions("install")),
"unchanged",
"reinstalling is how you update"
);
let removed = completions("uninstall");
assert_eq!(action(&removed), "removed");
assert!(!installed.exists());
let again = completions("uninstall");
assert_eq!(code(&again), EXIT_OK, "removing nothing is not a failure");
assert_eq!(action(&again), "absent");
}
// --- wishlist, the half that only touches the config file ------------------
#[test]
fn a_wish_is_added_listed_and_removed_through_the_config_file() {
let dir = Scratch::new("wish");
let config = dir.path().join("config.toml");
let added = with_config(&config, &["wish", "add", "aphex twin"]);
assert_eq!(code(&added), EXIT_OK, "stderr: {}", stderr(&added));
assert_eq!(records(&added), ["aphex twin"]);
let listed = with_config(&config, &["wish", "list"]);
assert_eq!(code(&listed), EXIT_OK);
assert_eq!(records(&listed), ["aphex twin"]);
let removed = with_config(&config, &["wish", "remove", "APHEX TWIN"]);
assert_eq!(code(&removed), EXIT_OK, "stderr: {}", stderr(&removed));
assert!(stdout(&removed).is_empty(), "remove prints nothing");
let empty = with_config(&config, &["wish", "list"]);
assert_eq!(code(&empty), EXIT_NO_RESULTS);
assert!(stdout(&empty).is_empty(), "stdout must stay data-only");
}
#[test]
fn adding_the_same_wish_twice_is_not_an_error_and_stores_it_once() {
let dir = Scratch::new("wish");
let config = dir.path().join("config.toml");
with_config(&config, &["wish", "add", "boards of canada"]);
let again = with_config(&config, &["wish", "add", "Boards Of Canada"]);
assert_eq!(code(&again), EXIT_OK, "a repeat is not a failure");
let listed = with_config(&config, &["wish", "list"]);
assert_eq!(records(&listed), ["boards of canada"]);
}
#[test]
fn a_wish_holding_a_comma_survives_the_config_file() {
let dir = Scratch::new("wish");
let config = dir.path().join("config.toml");
with_config(&config, &["wish", "add", "autechre, incunabula"]);
let listed = with_config(&config, &["--json", "wish", "list"]);
let stored: Vec<serde_json::Value> = records(&listed)
.iter()
.filter_map(|line| serde_json::from_str(line).ok())
.collect();
assert_eq!(stored.len(), 1, "one wish, not two split on the comma");
assert_eq!(stored[0]["query"], "autechre, incunabula");
}
#[test]
fn removing_a_wish_that_is_not_there_is_the_no_results_code() {
let dir = Scratch::new("wish");
let config = dir.path().join("config.toml");
let output = with_config(&config, &["wish", "remove", "never added"]);
assert_eq!(code(&output), EXIT_NO_RESULTS);
assert!(stdout(&output).is_empty());
}
#[test]
fn an_empty_wish_query_is_a_usage_error() {
let dir = Scratch::new("wish");
let config = dir.path().join("config.toml");
let output = with_config(&config, &["wish", "add", " "]);
assert_eq!(code(&output), EXIT_USAGE);
assert!(stdout(&output).is_empty());
}
#[test]
fn wish_add_without_a_config_file_to_write_to_is_a_usage_error() {
let output = run(&["--no-config", "wish", "add", "nowhere to keep this"]);
assert_eq!(code(&output), EXIT_USAGE);
assert!(stdout(&output).is_empty());
}
// ---------------------------------------------------------------------------
// Server required (soulfind)
// ---------------------------------------------------------------------------
/// Only one test at a time may drive a server.
///
/// Each of these tests runs a soulfind, one or more real clients, and the
/// binary itself. Letting the harness start a dozen of those at once starves
/// the servers, and a login that goes unanswered fails a test for a reason
/// that has nothing to do with the code under test. Serializing here keeps the
/// suite deterministic whatever `--test-threads` the runner picks.
static SERVER_GATE: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// A soulfind we spawned for one test, held for that test's lifetime.
struct TestServer {
host: String,
port: u16,
/// The soulfind spawned for this run; `None` against a server named by
/// `SOULSEEK_TEST_SERVER`.
_server: Option<Soulfind>,
_gate: std::sync::MutexGuard<'static, ()>,
/// Daemons started for this server, one per (user, session flags). Dropped
/// with the server, which kills them.
daemons: std::sync::Mutex<std::collections::HashMap<String, DaemonFixture>>,
}
/// A `soulseek-rs daemon` running against the test server, with a state
/// directory of its own so its socket and token cannot collide with another
/// fixture's — or with the developer's.
struct DaemonFixture {
child: Child,
socket: PathBuf,
state: PathBuf,
}
impl DaemonFixture {
fn start(
server: &TestServer,
user: &str,
session_flags: &[String],
) -> Self {
static COUNTER: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let state = std::env::temp_dir()
.join(format!("soulseek-e2e-daemon-{}-{n}", std::process::id()));
std::fs::create_dir_all(&state).expect("daemon state directory");
let mut args = vec![
"--no-config".to_string(),
"--server".to_string(),
server.address(),
"--username".to_string(),
user.to_string(),
"--password".to_string(),
"pw".to_string(),
];
// A listener of its own unless the test asked for something specific:
// transfers arrive on a connection the uploader opens back to us.
if !session_flags
.iter()
.any(|flag| flag == "--listener-port" || flag == "--no-listener")
{
args.push("--listener-port".to_string());
args.push(free_port().expect("listener port").to_string());
}
args.extend(session_flags.iter().cloned());
args.push("daemon".to_string());
let mut command = Command::new(BIN);
for name in CLI_ENV_VARS {
command.env_remove(name);
}
command.env("SOULSEEK_STATE_DIR", &state);
command.current_dir(std::env::temp_dir());
command.args(&args);
command.stdout(Stdio::null()).stderr(Stdio::null());
let child = command.spawn().expect("the daemon should start");
let socket = state.join("daemon.sock");
let fixture = Self {
child,
socket,
state,
};
fixture.wait_until_ready();
fixture
}
/// Wait until the socket actually accepts, not merely exists: the daemon
/// binds before it logs in, so a client that connects too early would be
/// answering questions about a session that is not there yet.
fn wait_until_ready(&self) {
let deadline = Instant::now() + Duration::from_secs(45);
while Instant::now() < deadline {
#[cfg(unix)]
if std::os::unix::net::UnixStream::connect(&self.socket).is_ok() {
// Binding happens first, so also wait for the login to land.
if self.answers() {
return;
}
}
std::thread::sleep(Duration::from_millis(100));
}
panic!("the daemon never became ready at {}", self.socket.display());
}
/// Whether the daemon answers `daemon status` with a session.
fn answers(&self) -> bool {
let mut command = Command::new(BIN);
for name in CLI_ENV_VARS {
command.env_remove(name);
}
command.env("SOULSEEK_STATE_DIR", &self.state);
command.current_dir(std::env::temp_dir());
command.args([
"--no-config",
"--json",
"--daemon",
&self.socket.display().to_string(),
"daemon",
"status",
]);
command.output().is_ok_and(|output| output.status.success())
}
}
impl Drop for DaemonFixture {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
let _ = std::fs::remove_dir_all(&self.state);
}
}
impl TestServer {
fn resolve() -> Option<Self> {
// A test that panicked while holding the gate poisoned it; the lock
// guards nothing but scheduling, so take it back and carry on.
let gate = SERVER_GATE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Ok(addr) = std::env::var("SOULSEEK_TEST_SERVER") {
let (host, port) = addr.rsplit_once(':')?;
let port = port.parse().ok()?;
wait_until_listening(host, port, Duration::from_secs(2))?;
return Some(Self {
host: host.to_string(),
port,
_server: None,
_gate: gate,
daemons: std::sync::Mutex::new(std::collections::HashMap::new()),
});
}
let server = Soulfind::start()?;
Some(Self {
host: "127.0.0.1".to_string(),
port: server.port(),
_server: Some(server),
_gate: gate,
daemons: std::sync::Mutex::new(std::collections::HashMap::new()),
})
}
fn address(&self) -> String {
format!("{}:{}", self.host, self.port)
}
/// Arguments that point the binary at this server as `user` — or, in
/// daemon mode, at a daemon logged in as `user`.
///
/// Every helper that builds a command goes through here, so routing at
/// this one point is what puts the streaming and wishlist tests through a
/// daemon too, rather than only the ones that happen to use [`cli`].
///
/// The listener stays on: a Soulseek transfer is delivered over a
/// connection the *uploader* opens back to us, so a downloader without a
/// listener never receives its file.
fn args(&self, user: &str) -> Vec<String> {
self.args_with(user, &[])
}
/// The same, with flags that configure the *session* rather than the
/// command — shares, the listener, where downloads land. Locally they go
/// to the run itself; in daemon mode they configure the daemon, because
/// that is whose session they now describe.
fn args_with(&self, user: &str, session_flags: &[String]) -> Vec<String> {
match mode() {
Mode::Local => {
let mut args =
self.args_on(user, free_port().expect("listener port"));
args.extend(session_flags.iter().cloned());
args
}
Mode::Daemon => self.daemon_args(user, session_flags),
}
}
/// The same arguments on a chosen listener port, for the tests about what
/// happens when two runs want one port.
fn args_on(&self, user: &str, port: u16) -> Vec<String> {
vec![
"--no-config".to_string(),
"--quiet".to_string(),
"--server".to_string(),
self.address(),
"--username".to_string(),
user.to_string(),
"--password".to_string(),
"pw".to_string(),
"--listener-port".to_string(),
port.to_string(),
]
}
/// Client arguments pointing at a daemon logged in as `user`, starting one
/// if this account has not been seen.
///
/// Strictly one daemon per account: the server allows a single session per
/// name, so a second daemon under the same name would displace the first
/// and every command already routed to it would start failing with
/// "session lost". The session flags of the first call configure it — a
/// later call's are ignored, which is exactly what happens in real daemon
/// mode, where shares and the listener belong to the daemon.
fn daemon_args(&self, user: &str, session_flags: &[String]) -> Vec<String> {
let socket = self.daemon_for(user, session_flags);
vec![
"--no-config".to_string(),
"--quiet".to_string(),
"--daemon".to_string(),
socket.display().to_string(),
]
}
/// The state directory of the daemon serving `user`, where its socket and
/// token live.
fn daemon_state(&self, user: &str) -> PathBuf {
self.daemon_for(user, &[]);
let key = format!("{}|{user}", self.address());
let daemons = self.daemons.lock().expect("daemon registry");
daemons
.get(&key)
.expect("the daemon was just started")
.state
.clone()
}
fn daemon_for(&self, user: &str, session_flags: &[String]) -> PathBuf {
let key = format!("{}|{user}", self.address());
let mut daemons = self.daemons.lock().expect("daemon registry");
if let Some(existing) = daemons.get(&key) {
return existing.socket.clone();
}
let handle = DaemonFixture::start(self, user, session_flags);
let socket = handle.socket.clone();
daemons.insert(key, handle);
socket
}
/// An in-process client, used to stand up the other end of a test.
fn client(&self, user: &str, shares: Vec<String>) -> Client {
login(&self.host, self.port, user, shares)
}
}
fn wait_until_listening(
host: &str,
port: u16,
timeout: Duration,
) -> Option<()> {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if let Ok(mut addrs) = format!("{host}:{port}").to_socket_addrs()
&& let Some(addr) = addrs.next()
&& TcpStream::connect_timeout(&addr, Duration::from_millis(200))
.is_ok()
{
return Some(());
}
std::thread::sleep(Duration::from_millis(100));
}
None
}
macro_rules! server_or_skip {
() => {
match TestServer::resolve() {
Some(server) => server,
None => {
common::no_server("cli e2e");
return;
}
}
};
}
/// A scratch directory that cleans itself up.
struct Scratch(PathBuf);
impl Scratch {
fn new(label: &str) -> Self {
static COUNTER: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"soulseek-cli-e2e-{label}-{}-{n}",
std::process::id()
));
std::fs::create_dir_all(&path).expect("scratch directory");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
fn display(&self) -> String {
self.0.display().to_string()
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Contents of the file every transfer test shares.
fn probe_bytes() -> Vec<u8> {
(0..4096u32).map(|i| (i % 251) as u8).collect()
}
/// Run the binary against `server` as `user`, appending `args`.
///
/// In daemon mode the same call is served by a daemon instead. The flags that
/// shape a *session* rather than a command — shares, the listener, where
/// downloads land — are handed to the daemon rather than the client, because
/// that is where they now belong. The test itself does not change, which is
/// the whole point: if the records and exit code come out the same either way,
/// daemon mode is equivalent.
fn cli(server: &TestServer, user: &str, args: &[&str]) -> Output {
let (session_flags, command_args) = match mode() {
// Locally a flag is a flag: the run configures its own session.
Mode::Local => {
(Vec::new(), args.iter().map(|a| (*a).to_string()).collect())
}
Mode::Daemon => split_session_flags(args),
};
let mut all = server.args_with(user, &session_flags);
all.extend(command_args);
let refs: Vec<&str> = all.iter().map(String::as_str).collect();
run(&refs)
}
/// Split arguments into the ones that configure a session and the ones that
/// are the command. Only meaningful in daemon mode, where the two halves go to
/// different processes.
fn split_session_flags(args: &[&str]) -> (Vec<String>, Vec<String>) {
/// Flags that take a value and describe the session, not the command.
const SESSION_PAIRS: [&str; 4] = [
"--shared-dir",
"--listener-port",
"--download-dir",
"--max-concurrent-downloads",
];
/// Session flags that stand alone.
const SESSION_SWITCHES: [&str; 2] = ["--no-listener", "--listener"];
let mut session = Vec::new();
let mut command = Vec::new();
let mut rest = args.iter();
while let Some(arg) = rest.next() {
if SESSION_PAIRS.contains(arg) {
session.push((*arg).to_string());
if let Some(value) = rest.next() {
session.push((*value).to_string());
}
} else if SESSION_SWITCHES.contains(arg) {
session.push((*arg).to_string());
} else {
command.push((*arg).to_string());
}
}
(session, command)
}
/// Like [`cli`], but keeping a config file so a command that reads or writes
/// one (the wishlist) sees it.
///
/// `--no-config` is dropped for the obvious reason, and `--quiet` with it:
/// these commands report the schedule the *server* chose on stderr, which is
/// the only place a test can see it.
fn cli_with_config(
server: &TestServer,
user: &str,
config: &Path,
args: &[&str],
) -> Output {
let mut all: Vec<String> = server
.args(user)
.into_iter()
.filter(|arg| arg != "--no-config" && arg != "--quiet")
.collect();
all.push("--config".to_string());
all.push(config.to_str().expect("utf-8 path").to_string());
all.extend(args.iter().map(|a| (*a).to_string()));
let refs: Vec<&str> = all.iter().map(String::as_str).collect();
run(&refs)
}
#[test]
fn a_wrong_password_is_a_connection_error() {
let server = server_or_skip!();
// Registers the name with password "pw".
let _owner = server.client("cli_e2e_owner", Vec::new());
let output = run(&[
"--no-config",
"--quiet",
"--server",
&server.address(),
"--username",
"cli_e2e_owner",
"--password",
"definitely-wrong",
"--no-listener",
"room",
"list",
]);
assert_eq!(code(&output), EXIT_CONNECTION);
assert!(stdout(&output).is_empty());
}
#[test]
fn search_prints_a_record_that_download_can_consume() {
let server = server_or_skip!();
let share = Scratch::new("share");
std::fs::write(share.path().join("cli_probe_qwix.bin"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_a", vec![share.display()]);
settle();
let output = cli(
&server,
"cli_e2e_seeker_a",
&["--search-timeout", "5", "search", "qwix"],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let record = records(&output)
.into_iter()
.find(|line| line.contains("cli_probe_qwix"))
.expect("the sharer's file should be listed");
// user TAB size TAB bitrate TAB path — the columns `download --stdin`
// reads back.
let fields: Vec<&str> = record.split('\t').collect();
assert_eq!(fields.len(), 4, "record was {record:?}");
assert_eq!(fields[0], "cli_e2e_sharer_a");
assert_eq!(fields[1], probe_bytes().len().to_string());
assert!(fields[3].contains("cli_probe_qwix.bin"));
}
#[test]
fn search_json_records_carry_the_fields_a_filter_needs() {
let server = server_or_skip!();
let share = Scratch::new("share");
std::fs::write(share.path().join("cli_probe_zorb.bin"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_b", vec![share.display()]);
settle();
let output = cli(
&server,
"cli_e2e_seeker_b",
&["--search-timeout", "5", "--json", "search", "zorb"],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let record: serde_json::Value = records(&output)
.iter()
.map(|line| {
serde_json::from_str::<serde_json::Value>(line)
.expect("each line should be valid JSON")
})
.find(|value| {
value["path"].as_str().is_some_and(|p| p.contains("zorb"))
})
.expect("the sharer's file should be listed");
assert_eq!(record["user"], "cli_e2e_sharer_b");
assert_eq!(record["size"], probe_bytes().len() as u64);
assert!(record["free_slot"].is_boolean());
assert!(record.get("speed").is_some());
}
#[test]
fn a_search_with_no_matches_exits_with_the_no_results_code() {
let server = server_or_skip!();
let _peer = server.client("cli_e2e_quiet_peer", Vec::new());
settle();
let output = cli(
&server,
"cli_e2e_seeker_c",
&["--search-timeout", "3", "search", "nothingmatchesthisqzx"],
);
assert_eq!(code(&output), EXIT_NO_RESULTS);
assert!(stdout(&output).is_empty(), "stdout must stay data-only");
}
/// One share holding three files that differ in name, extension and size, so
/// every result filter has something to keep and something to drop.
///
/// `flt_beta_skipme.flac` is deliberately the small one and
/// `flt_gamma_keepme.ogg` the large one, so a size bound and an extension
/// bound select different files and a combination of the two is a real test
/// rather than two spellings of the same assertion.
struct FilterFixture {
_share: Scratch,
}
impl FilterFixture {
const SMALL: usize = 128;
const MEDIUM: usize = 4096;
const LARGE: usize = 8192;
fn new(server: &TestServer, sharer: &str) -> (Self, Client) {
let share = Scratch::new("filter-share");
// The mp3 is a real 128 kbps stream so the bitrate filter has
// something to read; the others are padding of a known size.
std::fs::write(share.path().join("flt_alpha_keepme.mp3"), cbr_mp3(10))
.expect("share file");
for (name, size) in [
("flt_beta_skipme.flac", Self::SMALL),
("flt_gamma_keepme.ogg", Self::LARGE),
] {
std::fs::write(share.path().join(name), vec![7u8; size])
.expect("share file");
}
let client = server.client(sharer, vec![share.display()]);
settle();
(Self { _share: share }, client)
}
}
/// A constant-bitrate MP3: `frames` MPEG-1 Layer III frames at 128 kbps and
/// 44.1 kHz, 417 bytes each; ten of them land between the fixture's SMALL
/// and LARGE sizes.
fn cbr_mp3(frames: usize) -> Vec<u8> {
let mut out = Vec::new();
for _ in 0..frames {
out.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]);
out.resize(out.len() + 413, 0);
}
out
}
/// Search for the fixture as a fresh user and return the matching file names,
/// sorted so the assertion does not depend on result order.
///
/// A new username per call matters: soulfind answers a repeat of the same
/// search from the same user with nothing.
fn filtered_names(
server: &TestServer,
seeker: &str,
filter: &[&str],
) -> (i32, Vec<String>) {
let mut args = vec!["--search-timeout", "5", "search", "flt"];
args.extend_from_slice(filter);
let output = cli(server, seeker, &args);
let mut names: Vec<String> = records(&output)
.iter()
.filter_map(|line| line.split('\t').nth(3))
.filter_map(|path| path.rsplit(['/', '\\']).next())
.filter(|name| name.starts_with("flt_"))
.map(String::from)
.collect();
names.sort();
(code(&output), names)
}
#[test]
fn exclude_drops_the_results_whose_path_contains_the_term() {
let server = server_or_skip!();
let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_excl");
let (exit, names) =
filtered_names(&server, "cli_e2e_seek_excl", &["--exclude", "skipme"]);
assert_eq!(exit, EXIT_OK);
assert_eq!(names, ["flt_alpha_keepme.mp3", "flt_gamma_keepme.ogg"]);
}
#[test]
fn extension_keeps_one_type_and_repeats_into_a_union() {
let server = server_or_skip!();
let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_ext");
let (exit, one) = filtered_names(
&server,
"cli_e2e_seek_ext_one",
&["--extension", "mp3"],
);
assert_eq!(exit, EXIT_OK);
assert_eq!(one, ["flt_alpha_keepme.mp3"]);
let (exit, several) = filtered_names(
&server,
"cli_e2e_seek_ext_many",
&["--extension", ".flac", "--extension", "OGG"],
);
assert_eq!(exit, EXIT_OK);
assert_eq!(several, ["flt_beta_skipme.flac", "flt_gamma_keepme.ogg"]);
}
#[test]
fn a_bitrate_floor_keeps_only_files_that_advertise_at_least_that_much() {
let server = server_or_skip!();
let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_kbps");
// Only the mp3 carries a bitrate; the padding files have none and an
// unknown bitrate cannot satisfy a floor.
let (exit, enough) = filtered_names(
&server,
"cli_e2e_seek_kbps_128",
&["--min-bitrate", "128"],
);
assert_eq!(exit, EXIT_OK);
assert_eq!(enough, ["flt_alpha_keepme.mp3"]);
let (exit, none) = filtered_names(
&server,
"cli_e2e_seek_kbps_320",
&["--min-bitrate", "320"],
);
assert_eq!(exit, EXIT_NO_RESULTS);
assert!(none.is_empty());
}
#[test]
fn size_bounds_narrow_the_result_set_from_either_end() {
let server = server_or_skip!();
let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_size");
let (exit, big) = filtered_names(
&server,
"cli_e2e_seek_size_min",
&["--min-size", &FilterFixture::MEDIUM.to_string()],
);
assert_eq!(exit, EXIT_OK);
assert_eq!(big, ["flt_alpha_keepme.mp3", "flt_gamma_keepme.ogg"]);
let (exit, small) = filtered_names(
&server,
"cli_e2e_seek_size_max",
&["--max-size", &FilterFixture::SMALL.to_string()],
);
assert_eq!(exit, EXIT_OK);
assert_eq!(small, ["flt_beta_skipme.flac"]);
}
#[test]
fn filters_given_together_all_have_to_be_satisfied() {
let server = server_or_skip!();
let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_comb");
let (exit, names) = filtered_names(
&server,
"cli_e2e_seek_comb",
&[
"--exclude",
"skipme",
"--min-size",
&FilterFixture::LARGE.to_string(),
"--extension",
"ogg",
],
);
assert_eq!(exit, EXIT_OK);
assert_eq!(names, ["flt_gamma_keepme.ogg"]);
}
#[test]
fn a_filter_that_matches_nothing_is_the_no_results_code() {
let server = server_or_skip!();
let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_none");
let output = cli(
&server,
"cli_e2e_seek_none",
&[
"--search-timeout",
"5",
"search",
"flt",
"--extension",
"aiff",
],
);
assert_eq!(
code(&output),
EXIT_NO_RESULTS,
"stderr: {}",
stderr(&output)
);
assert!(stdout(&output).is_empty(), "stdout must stay data-only");
}
#[test]
fn get_honours_the_same_filters_search_does() {
let server = server_or_skip!();
let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_get");
let target = Scratch::new("filter-downloads");
let output = cli(
&server,
"cli_e2e_seek_get",
&[
"--search-timeout",
"5",
"--download-dir",
&target.display(),
"get",
"flt",
"--extension",
"ogg",
"--timeout",
"30",
],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
assert!(
target.path().join("flt_gamma_keepme.ogg").exists(),
"the only .ogg should be what got fetched; stdout: {}",
stdout(&output)
);
}
#[test]
fn wish_run_finds_a_shared_file_through_a_wishlist_search() {
let server = server_or_skip!();
let share = Scratch::new("wish-share");
std::fs::write(share.path().join("wisher_probe_krel.mp3"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_wish_sharer", vec![share.display()]);
let dir = Scratch::new("wish-config");
let config = dir.path().join("config.toml");
settle();
let added = with_config(&config, &["wish", "add", "krel"]);
assert_eq!(code(&added), EXIT_OK, "stderr: {}", stderr(&added));
let output = cli_with_config(
&server,
"cli_e2e_wish_seeker",
&config,
&["--search-timeout", "5", "wish", "run"],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let record = records(&output)
.into_iter()
.find(|line| line.contains("wisher_probe_krel"))
.expect("the wishlist search should find the sharer's file");
// The same four columns `search` prints, so this pipes into
// `download --stdin` with no reshaping.
let fields: Vec<&str> = record.split('\t').collect();
assert_eq!(fields.len(), 4, "record was {record:?}");
assert_eq!(fields[0], "cli_e2e_wish_sharer");
assert_eq!(fields[1], probe_bytes().len().to_string());
}
#[test]
fn wish_run_output_feeds_download_stdin() {
let server = server_or_skip!();
let share = Scratch::new("wish-share");
std::fs::write(share.path().join("wisher_probe_vosk.mp3"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_wish_sharer_b", vec![share.display()]);
let dir = Scratch::new("wish-config");
let config = dir.path().join("config.toml");
let target = Scratch::new("wish-downloads");
settle();
with_config(&config, &["wish", "add", "vosk"]);
let found = cli_with_config(
&server,
"cli_e2e_wish_seeker_b",
&config,
&["--search-timeout", "5", "--json", "wish", "run"],
);
assert_eq!(code(&found), EXIT_OK, "stderr: {}", stderr(&found));
let mut child = command(&[
"--no-config",
"--quiet",
"--server",
&server.address(),
"--username",
"cli_e2e_wish_puller",
"--password",
"pw",
"--listener-port",
&free_port().expect("listener port").to_string(),
"--download-dir",
&target.display(),
"download",
"--stdin",
"--timeout",
"30",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("download should start");
child
.stdin
.take()
.expect("stdin")
.write_all(stdout(&found).as_bytes())
.expect("feed the records back");
let pulled = child.wait_with_output().expect("download should finish");
assert_eq!(code(&pulled), EXIT_OK, "stderr: {}", stderr(&pulled));
assert!(
target.path().join("wisher_probe_vosk.mp3").exists(),
"the wished-for file should have landed; stdout: {}",
stdout(&pulled)
);
}
#[test]
fn wish_run_with_an_empty_wishlist_is_the_no_results_code() {
let server = server_or_skip!();
let _peer = server.client("cli_e2e_wish_quiet", Vec::new());
let dir = Scratch::new("wish-config");
let config = dir.path().join("config.toml");
let output = cli_with_config(
&server,
"cli_e2e_wish_none",
&config,
&["wish", "run"],
);
assert_eq!(code(&output), EXIT_NO_RESULTS);
assert!(stdout(&output).is_empty(), "stdout must stay data-only");
}
#[test]
fn wish_run_only_must_name_a_stored_wish() {
let server = server_or_skip!();
let _peer = server.client("cli_e2e_wish_quiet_b", Vec::new());
let dir = Scratch::new("wish-config");
let config = dir.path().join("config.toml");
with_config(&config, &["wish", "add", "stored one"]);
let unknown = cli_with_config(
&server,
"cli_e2e_wish_only_bad",
&config,
&["wish", "run", "--only", "not stored"],
);
assert_eq!(code(&unknown), EXIT_USAGE, "a typo is not an ad-hoc search");
assert!(stdout(&unknown).is_empty());
}
#[test]
fn wish_run_only_narrows_the_sweep_to_that_wish() {
let server = server_or_skip!();
let share = Scratch::new("wish-share");
for name in ["wisher_probe_zant.mp3", "wisher_probe_hurl.mp3"] {
std::fs::write(share.path().join(name), probe_bytes())
.expect("share file");
}
let _sharer = server.client("cli_e2e_wish_sharer_c", vec![share.display()]);
let dir = Scratch::new("wish-config");
let config = dir.path().join("config.toml");
settle();
with_config(&config, &["wish", "add", "zant"]);
with_config(&config, &["wish", "add", "hurl"]);
let output = cli_with_config(
&server,
"cli_e2e_wish_only_ok",
&config,
&["--search-timeout", "5", "wish", "run", "--only", "ZANT"],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let paths = stdout(&output);
assert!(paths.contains("wisher_probe_zant"), "stdout was {paths:?}");
assert!(
!paths.contains("wisher_probe_hurl"),
"--only must not run the other wish; stdout was {paths:?}"
);
}
#[test]
fn wish_run_filters_apply_to_a_wishlist_search_too() {
let server = server_or_skip!();
let share = Scratch::new("wish-share");
std::fs::write(share.path().join("wisher_probe_gleb.mp3"), probe_bytes())
.expect("share file");
std::fs::write(share.path().join("wisher_probe_gleb.flac"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_wish_sharer_d", vec![share.display()]);
let dir = Scratch::new("wish-config");
let config = dir.path().join("config.toml");
settle();
with_config(&config, &["wish", "add", "gleb"]);
let output = cli_with_config(
&server,
"cli_e2e_wish_filtered",
&config,
&[
"--search-timeout",
"5",
"wish",
"run",
"--extension",
"flac",
],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let paths = stdout(&output);
assert!(paths.contains("wisher_probe_gleb.flac"), "{paths:?}");
assert!(!paths.contains("wisher_probe_gleb.mp3"), "{paths:?}");
}
#[test]
fn serve_with_the_wishlist_sweeps_on_the_interval_the_server_announced() {
let server = server_or_skip!();
let their_share = Scratch::new("wish-their-share");
std::fs::write(
their_share.path().join("wisher_probe_yalt.mp3"),
probe_bytes(),
)
.expect("share file");
let _sharer =
server.client("cli_e2e_wish_sharer_e", vec![their_share.display()]);
// A serving client needs something of its own to share.
let our_share = Scratch::new("wish-our-share");
std::fs::write(our_share.path().join("ours.bin"), probe_bytes())
.expect("share file");
let dir = Scratch::new("wish-config");
let config = dir.path().join("config.toml");
settle();
with_config(&config, &["wish", "add", "yalt"]);
let output = cli_with_config(
&server,
"cli_e2e_wish_server",
&config,
&[
"--search-timeout",
"5",
"--shared-dir",
&our_share.display(),
"serve",
"--wishlist",
"--duration",
"12",
],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
assert!(
stdout(&output).contains("wisher_probe_yalt"),
"the first sweep happens at startup; stdout: {}",
stdout(&output)
);
// Soulfind announces 12 minutes for an unprivileged account, and the
// schedule is the server's to set — so that is what must be reported.
assert!(
stderr(&output).contains("next wishlist sweep in 720s"),
"the announced interval should drive the timer; stderr: {}",
stderr(&output)
);
}
#[test]
fn serve_wishlist_with_nothing_wished_for_still_serves() {
let server = server_or_skip!();
let our_share = Scratch::new("wish-our-share");
std::fs::write(our_share.path().join("ours.bin"), probe_bytes())
.expect("share file");
let dir = Scratch::new("wish-config");
let config = dir.path().join("config.toml");
let output = cli_with_config(
&server,
"cli_e2e_wish_server_b",
&config,
&[
"--shared-dir",
&our_share.display(),
"serve",
"--wishlist",
"--duration",
"2",
],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
assert!(
stderr(&output).contains("nothing to look for"),
"an empty wishlist should be said once, not serve-and-be-silent; \
stderr: {}",
stderr(&output)
);
}
#[test]
fn browse_lists_shares_as_paths_download_understands() {
let server = server_or_skip!();
let share = Scratch::new("share");
std::fs::write(share.path().join("browsable.bin"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_c", vec![share.display()]);
settle();
let output =
cli(&server, "cli_e2e_browser", &["browse", "cli_e2e_sharer_c"]);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let record = records(&output)
.into_iter()
.find(|line| line.contains("browsable.bin"))
.expect("the shared file should be listed");
// user TAB size TAB path
let fields: Vec<&str> = record.split('\t').collect();
assert_eq!(fields.len(), 3, "record was {record:?}");
assert_eq!(fields[0], "cli_e2e_sharer_c");
assert_eq!(fields[1], probe_bytes().len().to_string());
}
#[test]
fn transfer_cancel_without_a_matching_transfer_is_the_no_results_verdict() {
let server = server_or_skip!();
let output = cli(
&server,
"cli_e2e_canceller",
&["transfer", "cancel", "nobody", "nothing.mp3"],
);
assert_eq!(
code(&output),
EXIT_NO_RESULTS,
"stderr: {}",
stderr(&output)
);
assert!(
stderr(&output).contains("nothing.mp3"),
"the verdict names the transfer: {}",
stderr(&output)
);
}
#[test]
fn transfer_cancel_stops_a_download_another_client_is_waiting_on() {
if mode() != Mode::Daemon {
println!("skipped: a one-shot download owns its own session");
return;
}
let server = server_or_skip!();
let mut all = server.args("cli_e2e_cancel_waiter");
all.extend(
[
"download",
"e2e_ghost_peer",
"@@ghost\\never.mp3",
"--size",
"10",
"--timeout",
"60",
]
.iter()
.map(|a| (*a).to_string()),
);
let refs: Vec<&str> = all.iter().map(String::as_str).collect();
let waiting = command(&refs)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary should start");
let deadline = Instant::now() + Duration::from_secs(15);
let mut cancelled = None;
while Instant::now() < deadline {
let output = cli(
&server,
"cli_e2e_cancel_waiter",
&["transfer", "cancel", "e2e_ghost_peer", "@@ghost\\never.mp3"],
);
if code(&output) == EXIT_OK {
cancelled = Some(output);
break;
}
std::thread::sleep(Duration::from_millis(500));
}
assert!(
cancelled.is_some(),
"the queued transfer should be cancellable"
);
let waited = waiting.wait_with_output().expect("wait");
assert_eq!(code(&waited), EXIT_TRANSFER, "stderr: {}", stderr(&waited));
assert!(stderr(&waited).contains("cancelled"), "{}", stderr(&waited));
}
#[test]
fn download_fetches_a_file_and_prints_where_it_landed() {
let server = server_or_skip!();
let share = Scratch::new("share");
let target = Scratch::new("downloads");
std::fs::write(share.path().join("fetch_me.bin"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_d", vec![share.display()]);
settle();
// Learn the peer-side path the way a script would.
let listing =
cli(&server, "cli_e2e_fetcher", &["browse", "cli_e2e_sharer_d"]);
assert_eq!(code(&listing), EXIT_OK, "stderr: {}", stderr(&listing));
let row = records(&listing)
.into_iter()
.find(|line| line.contains("fetch_me.bin"))
.expect("the shared file should be listed");
let remote_path = row.rsplit('\t').next().expect("path column").to_string();
let output = cli(
&server,
"cli_e2e_fetcher",
&[
"--download-dir",
&target.display(),
"download",
"cli_e2e_sharer_d",
&remote_path,
"--size",
&probe_bytes().len().to_string(),
"--timeout",
"40",
],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let reported = records(&output);
assert_eq!(reported.len(), 1, "one file, one record");
let written = std::fs::read(reported[0].trim())
.expect("the reported path should be the file that was written");
assert_eq!(written, probe_bytes());
}
#[test]
fn download_without_a_size_looks_it_up_in_the_peers_shares() {
let server = server_or_skip!();
let share = Scratch::new("share");
let target = Scratch::new("downloads");
std::fs::write(share.path().join("sizeless.bin"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_g", vec![share.display()]);
settle();
let listing =
cli(&server, "cli_e2e_sizeless", &["browse", "cli_e2e_sharer_g"]);
let remote_path = records(&listing)
.into_iter()
.find(|line| line.contains("sizeless.bin"))
.and_then(|row| row.rsplit('\t').next().map(String::from))
.expect("the shared file should be listed");
// No --size: the size has to come from the peer's listing, and the remote
// path there is the full backslash path, not the bare file name.
let output = cli(
&server,
"cli_e2e_sizeless",
&[
"--download-dir",
&target.display(),
"download",
"cli_e2e_sharer_g",
&remote_path,
"--timeout",
"40",
],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let reported = records(&output);
assert_eq!(reported.len(), 1);
assert_eq!(
std::fs::read(reported[0].trim()).expect("downloaded file"),
probe_bytes()
);
}
#[test]
fn download_reads_the_records_search_emits_from_stdin() {
let server = server_or_skip!();
let share = Scratch::new("share");
let target = Scratch::new("downloads");
std::fs::write(share.path().join("piped.bin"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_e", vec![share.display()]);
settle();
let listing =
cli(&server, "cli_e2e_piper", &["browse", "cli_e2e_sharer_e"]);
let row = records(&listing)
.into_iter()
.find(|line| line.contains("piped.bin"))
.expect("the shared file should be listed");
let mut args = server.args("cli_e2e_piper");
args.extend(
[
"--download-dir",
&target.display(),
"download",
"--stdin",
"--timeout",
"40",
]
.iter()
.map(|a| (*a).to_string()),
);
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let mut child = command(&refs)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary should run");
child
.stdin
.as_mut()
.expect("stdin")
.write_all(format!("{row}\n").as_bytes())
.expect("write the record");
let output = child.wait_with_output().expect("the binary should finish");
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let reported = records(&output);
assert_eq!(reported.len(), 1);
assert_eq!(
std::fs::read(reported[0].trim()).expect("downloaded file"),
probe_bytes()
);
}
#[test]
fn get_searches_picks_and_downloads_in_one_command() {
let server = server_or_skip!();
let share = Scratch::new("share");
let target = Scratch::new("downloads");
std::fs::write(share.path().join("cli_probe_vroom.bin"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_f", vec![share.display()]);
settle();
let output = cli(
&server,
"cli_e2e_getter",
&[
"--search-timeout",
"5",
"--download-dir",
&target.display(),
"get",
"vroom",
"--timeout",
"40",
],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let reported = records(&output);
assert_eq!(reported.len(), 1, "--pick best downloads one file");
assert_eq!(
std::fs::read(reported[0].trim()).expect("downloaded file"),
probe_bytes()
);
}
#[test]
fn get_without_matches_exits_no_results_and_downloads_nothing() {
let server = server_or_skip!();
let target = Scratch::new("downloads");
let _peer = server.client("cli_e2e_silent_peer", Vec::new());
settle();
let output = cli(
&server,
"cli_e2e_getter_b",
&[
"--search-timeout",
"3",
"--download-dir",
&target.display(),
"get",
"nothingmatchesthisqzx",
],
);
assert_eq!(code(&output), EXIT_NO_RESULTS);
assert_eq!(
std::fs::read_dir(target.path())
.expect("download dir")
.count(),
0,
"nothing should have been written"
);
}
#[test]
fn room_say_reaches_a_listener_and_room_list_shows_the_room() {
let server = server_or_skip!();
let listener = server.client("cli_e2e_room_listener", Vec::new());
listener.join_room("cli_e2e_lobby").expect("join");
settle();
let said = cli(
&server,
"cli_e2e_speaker",
&["room", "say", "cli_e2e_lobby", "hello from a script"],
);
assert_eq!(code(&said), EXIT_OK, "stderr: {}", stderr(&said));
assert!(
stdout(&said).is_empty(),
"an action command emits no records"
);
let deadline = Instant::now() + Duration::from_secs(10);
let mut heard = false;
while Instant::now() < deadline && !heard {
heard = listener.take_room_events().iter().any(|event| {
matches!(
event,
soulseek_rs::types::RoomEvent::Message { message, .. }
if message == "hello from a script"
)
});
std::thread::sleep(Duration::from_millis(100));
}
assert!(heard, "the room should have received the message");
let listed = cli(&server, "cli_e2e_lister", &["room", "list"]);
assert_eq!(code(&listed), EXIT_OK, "stderr: {}", stderr(&listed));
let row = records(&listed)
.into_iter()
.find(|line| line.ends_with("cli_e2e_lobby"))
.expect("the joined room should be listed");
let (users, _) = row.split_once('\t').expect("users TAB room");
assert!(
users.parse::<u32>().is_ok(),
"the user count should be a bare number, got {users:?}"
);
}
#[test]
fn room_listen_streams_what_is_said_in_the_room() {
let server = server_or_skip!();
let speaker = server.client("cli_e2e_room_speaker", Vec::new());
speaker.join_room("cli_e2e_lobby_b").expect("join");
settle();
let mut args = server.args("cli_e2e_room_reader");
args.extend(
[
"room",
"listen",
"cli_e2e_lobby_b",
"--duration",
"8",
"--json",
]
.iter()
.map(|a| (*a).to_string()),
);
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let child = command(&refs)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary should run");
// Give the listener time to join before saying anything.
std::thread::sleep(Duration::from_secs(3));
speaker
.say_in_room("cli_e2e_lobby_b", "streamed line")
.expect("say");
// A second arrival: the README says a join is its own record type, with an
// empty message field.
let latecomer = server.client("cli_e2e_room_joiner", Vec::new());
latecomer.join_room("cli_e2e_lobby_b").expect("join");
let output = child.wait_with_output().expect("the binary should finish");
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let events: Vec<serde_json::Value> = records(&output)
.iter()
.filter_map(|line| serde_json::from_str(line).ok())
.collect();
let said = events
.iter()
.find(|event| event["message"] == "streamed line")
.unwrap_or_else(|| panic!("stdout was: {:?}", stdout(&output)));
assert_eq!(said["type"], "message");
assert_eq!(said["room"], "cli_e2e_lobby_b");
assert_eq!(said["user"], "cli_e2e_room_speaker");
// The listener's own arrival is a join too, so pick the one that matters.
let joined = events
.iter()
.find(|event| event["user"] == "cli_e2e_room_joiner")
.unwrap_or_else(|| panic!("no join record in {:?}", stdout(&output)));
assert_eq!(joined["type"], "join");
assert_eq!(joined["room"], "cli_e2e_lobby_b");
assert_eq!(joined["message"], "");
}
#[test]
fn a_private_message_reaches_its_recipient() {
let server = server_or_skip!();
let recipient = server.client("cli_e2e_recipient", Vec::new());
settle();
let output = cli(
&server,
"cli_e2e_sender",
&["message", "send", "cli_e2e_recipient", "ping from a script"],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
assert!(
stdout(&output).is_empty(),
"an action command emits no records"
);
let deadline = Instant::now() + Duration::from_secs(10);
let mut received = false;
while Instant::now() < deadline && !received {
received = recipient
.take_private_messages()
.iter()
.any(|message| message.message() == "ping from a script");
std::thread::sleep(Duration::from_millis(100));
}
assert!(received, "the recipient should have received the message");
}
#[test]
fn message_read_streams_incoming_private_messages() {
let server = server_or_skip!();
let sender = server.client("cli_e2e_pm_sender", Vec::new());
settle();
let mut args = server.args("cli_e2e_pm_reader");
args.extend(
["--json", "message", "read", "--duration", "8"]
.iter()
.map(|a| (*a).to_string()),
);
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let child = command(&refs)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary should run");
std::thread::sleep(Duration::from_secs(3));
sender
.send_private_message("cli_e2e_pm_reader", "inbox line")
.expect("send");
let output = child.wait_with_output().expect("the binary should finish");
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let read = records(&output).iter().any(|line| {
serde_json::from_str::<serde_json::Value>(line).is_ok_and(|value| {
value["message"] == "inbox line"
&& value["user"] == "cli_e2e_pm_sender"
})
});
assert!(read, "stdout was: {:?}", stdout(&output));
}
// ---------------------------------------------------------------------------
// Being a peer: whoami, user, serve, shares, room users
// ---------------------------------------------------------------------------
#[test]
fn whoami_confirms_the_login_and_reports_what_we_offer() {
let server = server_or_skip!();
let share = Scratch::new("share");
std::fs::write(share.path().join("offered.bin"), probe_bytes())
.expect("share file");
let output = cli(
&server,
"cli_e2e_whoami",
&["--shared-dir", &share.display(), "--json", "whoami"],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let record = json_record(&output);
assert_eq!(record["user"], "cli_e2e_whoami");
assert_eq!(record["shared_files"], 1, "the shared file is indexed");
assert!(record["server"].as_str().is_some_and(|s| s.contains(':')));
}
#[test]
fn whoami_reports_how_much_privilege_time_the_account_has() {
let server = server_or_skip!();
let _owner = server.client("cli_e2e_priv_owner", Vec::new());
let output = cli(&server, "cli_e2e_priv_owner", &["--json", "whoami"]);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let record = json_record(&output);
// Zero, not null: the server answered, and the answer is "no privileges".
// Null would mean it never said, which is a different fact.
assert_eq!(
record["privilege_seconds"], 0,
"a plain account should read as zero seconds, got {record}"
);
}
#[test]
fn serve_reports_a_queued_upload_once_the_slots_are_full() {
let server = server_or_skip!();
let share = Scratch::new("queue-share");
for name in ["q_one.bin", "q_two.bin"] {
std::fs::write(share.path().join(name), probe_bytes())
.expect("share file");
}
let target_a = Scratch::new("queue-dl-a");
let target_b = Scratch::new("queue-dl-b");
let serving = command(&[
"--no-config",
"--server",
&server.address(),
"--username",
"cli_e2e_queue_server",
"--password",
"pw",
"--listener-port",
&free_port().expect("listener port").to_string(),
"--shared-dir",
&share.display(),
"--json",
"serve",
"--upload-slots",
"1",
"--duration",
"18",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("serve should start");
settle();
settle();
// Two peers pull at once against a single slot, so one of them has to wait.
let mut pulls = Vec::new();
for (user, target) in [
("cli_e2e_queue_a", &target_a),
("cli_e2e_queue_b", &target_b),
] {
pulls.push(
command(&[
"--no-config",
"--quiet",
"--server",
&server.address(),
"--username",
user,
"--password",
"pw",
"--listener-port",
&free_port().expect("listener port").to_string(),
"--download-dir",
&target.display(),
"--search-timeout",
"5",
"get",
"q_",
"--pick",
"all",
"--timeout",
"30",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("puller should start"),
);
}
for mut pull in pulls {
let _ = pull.wait();
}
let uploaded = serving.wait_with_output().expect("serve should finish");
let statuses: Vec<String> = stdout(&uploaded)
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.filter_map(|value| {
value["status"]
.as_str()
.map(std::string::ToString::to_string)
})
.collect();
assert!(
statuses.iter().any(|status| status == "completed"),
"at least one upload should have gone through; stdout: {}",
stdout(&uploaded)
);
assert!(
statuses.iter().any(|status| status == "queued"),
"with one slot and two peers pulling, `serve` should report a queued \
upload; statuses were {statuses:?}"
);
}
#[test]
fn whoami_fails_with_the_connection_code_on_a_bad_password() {
let server = server_or_skip!();
let _owner = server.client("cli_e2e_whoami_owner", Vec::new());
let output = run(&[
"--no-config",
"--quiet",
"--server",
&server.address(),
"--username",
"cli_e2e_whoami_owner",
"--password",
"wrong",
"--no-listener",
"whoami",
]);
assert_eq!(code(&output), EXIT_CONNECTION);
}
#[test]
fn user_reports_a_peers_status_and_share_counts() {
let server = server_or_skip!();
let share = Scratch::new("share");
std::fs::create_dir_all(share.path().join("album")).expect("album");
for name in ["a.bin", "b.bin"] {
std::fs::write(share.path().join("album").join(name), probe_bytes())
.expect("share file");
}
let _subject = server.client("cli_e2e_subject", vec![share.display()]);
settle();
let output = cli(
&server,
"cli_e2e_asker",
&["--json", "user", "cli_e2e_subject"],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let record = json_record(&output);
assert_eq!(record["user"], "cli_e2e_subject");
assert_ne!(record["status"], "offline", "the subject is logged in");
assert_eq!(record["shared_files"], 2);
}
#[test]
fn serve_stays_online_and_reports_an_upload_a_peer_pulls() {
let server = server_or_skip!();
let share = Scratch::new("share");
let target = Scratch::new("downloads");
std::fs::write(share.path().join("served.bin"), probe_bytes())
.expect("share file");
// A serving process: online, sharing, streaming upload records. The share
// is a session flag: in daemon mode it must reach the daemon, whose
// session does the sharing — handed to the run itself it would be
// silently ignored and the daemon would serve its default folder.
let mut args = server.args_with(
"cli_e2e_server",
&["--shared-dir".to_string(), share.display()],
);
args.retain(|arg| arg != "--quiet");
args.extend(
["--json", "serve", "--duration", "25"]
.iter()
.map(|a| (*a).to_string()),
);
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let child = command(&refs)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary should run");
// Give it time to log in and register its listen port, then pull a file
// from it the way any peer would: browse to learn the path, then download.
std::thread::sleep(Duration::from_secs(4));
let listing = cli(&server, "cli_e2e_puller", &["browse", "cli_e2e_server"]);
let remote_path = records(&listing)
.into_iter()
.find(|line| line.contains("served.bin"))
.and_then(|row| row.rsplit('\t').next().map(String::from))
.unwrap_or_else(|| {
panic!(
"a serving process should answer a browse; exit={} stderr={}",
code(&listing),
stderr(&listing)
)
});
let puller = cli(
&server,
"cli_e2e_puller",
&[
"--download-dir",
&target.display(),
"download",
"cli_e2e_server",
&remote_path,
"--size",
&probe_bytes().len().to_string(),
"--timeout",
"20",
],
);
let output = child.wait_with_output().expect("serve should finish");
assert_eq!(code(&output), EXIT_OK, "serve stderr: {}", stderr(&output));
// The download may take the browse path for its remote name; what this
// test pins is that serve REPORTED the upload it served.
let uploads: Vec<serde_json::Value> = records(&output)
.iter()
.filter_map(|line| serde_json::from_str(line).ok())
.collect();
assert!(
!uploads.is_empty(),
"serve should report the upload. puller exit={} stderr={} serve stdout={:?}",
code(&puller),
stderr(&puller),
stdout(&output)
);
let last = &uploads[uploads.len() - 1];
assert_eq!(last["user"], "cli_e2e_puller");
assert!(
last["path"]
.as_str()
.is_some_and(|p| p.contains("served.bin"))
);
// The fields the README promises a JSON upload record carries.
for key in ["status", "user", "path", "bytes_sent", "size", "speed"] {
assert!(
last.get(key).is_some(),
"serve should carry `{key}`: {last}"
);
}
}
#[test]
fn serve_refuses_to_run_with_nothing_to_share() {
let server = server_or_skip!();
let output = cli(
&server,
"cli_e2e_empty_server",
&["--shared-dir", "", "serve", "--duration", "5"],
);
assert_eq!(code(&output), EXIT_USAGE);
assert!(stderr(&output).contains("share"), "got {}", stderr(&output));
}
#[test]
fn shares_status_reports_what_the_network_will_see() {
let server = server_or_skip!();
let share = Scratch::new("share");
for name in ["one.bin", "two.bin", "three.bin"] {
std::fs::write(share.path().join(name), probe_bytes())
.expect("share file");
}
let output = cli(
&server,
"cli_e2e_shares",
&[
"--shared-dir",
&share.display(),
"--json",
"shares",
"status",
],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let record = json_record(&output);
assert_eq!(record["files"], 3);
assert!(record["folders"].as_u64().is_some_and(|n| n >= 1));
}
#[test]
fn shares_reindex_picks_up_a_file_added_after_startup() {
let server = server_or_skip!();
let share = Scratch::new("share");
std::fs::write(share.path().join("first.bin"), probe_bytes())
.expect("share file");
std::fs::write(share.path().join("second.bin"), probe_bytes())
.expect("share file");
let output = cli(
&server,
"cli_e2e_reindex",
&[
"--shared-dir",
&share.display(),
"--json",
"shares",
"reindex",
],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let record = json_record(&output);
assert_eq!(record["files"], 2, "re-indexing should see both files");
}
#[test]
fn room_users_lists_who_is_in_the_room() {
let server = server_or_skip!();
let resident = server.client("cli_e2e_resident", Vec::new());
resident.join_room("cli_e2e_roster").expect("join");
settle();
let output = cli(
&server,
"cli_e2e_roster_asker",
&["room", "users", "cli_e2e_roster"],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
assert!(
records(&output)
.iter()
.any(|line| line == "cli_e2e_resident"),
"the resident should be listed, got {:?}",
records(&output)
);
}
// ---------------------------------------------------------------------------
// Configuration (no server needed)
// ---------------------------------------------------------------------------
/// Run the binary against a throwaway config file.
fn with_config(config: &Path, args: &[&str]) -> Output {
let mut all = vec!["--config", config.to_str().expect("utf-8 path")];
all.extend_from_slice(args);
run(&all)
}
#[test]
fn config_set_writes_a_value_that_get_reads_back() {
let dir = Scratch::new("config");
let config = dir.path().join("config.toml");
let set =
with_config(&config, &["config", "set", "download_dir", "/music"]);
assert_eq!(code(&set), EXIT_OK, "stderr: {}", stderr(&set));
let got = with_config(&config, &["config", "get", "download_dir"]);
assert_eq!(code(&got), EXIT_OK);
assert_eq!(records(&got), ["download_dir\t/music"]);
// And the next run actually starts from it.
let listed = with_config(&config, &["--json", "config", "list"]);
let values: Vec<serde_json::Value> = records(&listed)
.iter()
.filter_map(|line| serde_json::from_str(line).ok())
.collect();
assert!(
values
.iter()
.any(|v| v["key"] == "download_dir" && v["value"] == "/music"),
"config list should show the saved value"
);
}
#[test]
fn config_rejects_an_unknown_key_and_a_bad_value() {
let dir = Scratch::new("config");
let config = dir.path().join("config.toml");
let unknown = with_config(&config, &["config", "set", "colour", "blue"]);
assert_eq!(code(&unknown), EXIT_USAGE);
assert!(stderr(&unknown).contains("unknown setting"));
let bad =
with_config(&config, &["config", "set", "listener_port", "not-a-port"]);
assert_eq!(code(&bad), EXIT_USAGE);
}
#[test]
fn config_get_of_an_unset_key_is_the_no_results_code() {
let dir = Scratch::new("config");
let config = dir.path().join("config.toml");
let output = with_config(&config, &["config", "get", "username"]);
assert_eq!(code(&output), EXIT_NO_RESULTS);
}
#[test]
fn config_path_reports_the_file_in_use() {
let dir = Scratch::new("config");
let config = dir.path().join("config.toml");
let output = with_config(&config, &["config", "path"]);
assert_eq!(code(&output), EXIT_OK);
assert!(
records(&output)[0].contains("config.toml"),
"got {:?}",
records(&output)
);
}
#[test]
fn shares_add_and_remove_persist_to_the_config_file() {
let dir = Scratch::new("config");
let shared = Scratch::new("shared");
let config = dir.path().join("config.toml");
let added = with_config(&config, &["shares", "add", &shared.display()]);
assert_eq!(code(&added), EXIT_OK, "stderr: {}", stderr(&added));
let listed = with_config(&config, &["shares", "list"]);
assert_eq!(code(&listed), EXIT_OK);
assert!(
records(&listed)
.iter()
.any(|line| line.contains(&shared.display())),
"the added folder should be listed, got {:?}",
records(&listed)
);
let removed =
with_config(&config, &["shares", "remove", &shared.display()]);
assert_eq!(code(&removed), EXIT_OK, "stderr: {}", stderr(&removed));
let after = with_config(&config, &["shares", "list"]);
assert!(
!records(&after)
.iter()
.any(|line| line.contains(&shared.display())),
"the folder should be gone, got {:?}",
records(&after)
);
}
#[test]
fn shares_add_refuses_a_folder_that_is_not_there() {
let dir = Scratch::new("config");
let config = dir.path().join("config.toml");
let output =
with_config(&config, &["shares", "add", "/definitely/not/here"]);
assert_eq!(code(&output), EXIT_USAGE);
assert!(
stderr(&output).contains("does not exist"),
"got {}",
stderr(&output)
);
}
/// The contract every scripted consumer relies on: with `--json`, stdout
/// carries newline-delimited JSON and nothing else, while the progress
/// narration still goes to stderr.
#[test]
fn json_mode_keeps_stdout_pure_for_every_command() {
let server = server_or_skip!();
let share = Scratch::new("share");
std::fs::write(share.path().join("purity_probe.bin"), probe_bytes())
.expect("share file");
let peer = server.client("cli_e2e_purity_peer", vec![share.display()]);
peer.join_room("cli_e2e_purity_room").expect("join");
settle();
// Deliberately NOT --quiet: progress must be produced, and must not land
// on stdout. Commands are run for their output shape, not their verdict —
// several legitimately exit non-zero here.
let cases: Vec<Vec<&str>> = vec![
vec!["whoami"],
vec!["search", "purity_probe"],
vec!["browse", "cli_e2e_purity_peer"],
vec!["user", "cli_e2e_purity_peer"],
vec!["room", "list"],
vec!["room", "users", "cli_e2e_purity_room"],
vec!["room", "listen", "cli_e2e_purity_room", "--duration", "2"],
vec!["message", "read", "--duration", "2"],
vec!["shares", "status"],
vec!["shares", "list"],
vec!["config", "list"],
vec!["config", "path"],
vec!["portmap"],
vec!["search", "nothing_matches_this_qzx"],
vec!["browse", "no_such_user_qzx"],
];
let mut narrated = false;
for case in cases {
let mut args = server.args("cli_e2e_purity");
args.retain(|arg| arg != "--quiet");
args.push("--json".to_string());
args.push("--search-timeout".to_string());
args.push("3".to_string());
args.extend(case.iter().map(|a| (*a).to_string()));
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let output = run(&refs);
for line in stdout(&output).lines() {
assert!(
serde_json::from_str::<serde_json::Value>(line).is_ok(),
"`{}` put a non-JSON line on stdout: {line:?}",
case.join(" ")
);
}
narrated |= !stderr(&output).trim().is_empty();
}
assert!(
narrated,
"progress should still be reported — on stderr, where it belongs"
);
}
/// Failure must never leave half a record behind for a parser to trip on.
#[test]
fn a_failing_command_writes_nothing_at_all_to_stdout() {
let server = server_or_skip!();
for case in [
vec!["search", "nothing_matches_this_qzx"],
vec!["browse", "no_such_user_qzx"],
] {
let mut args = server.args("cli_e2e_empty_stdout");
args.push("--json".to_string());
args.push("--search-timeout".to_string());
args.push("3".to_string());
args.extend(case.iter().map(|a| (*a).to_string()));
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let output = run(&refs);
assert_ne!(code(&output), EXIT_OK, "`{}` should fail", case.join(" "));
assert!(
stdout(&output).trim().is_empty(),
"`{}` failed but still wrote to stdout: {:?}",
case.join(" "),
stdout(&output)
);
}
}
/// Asking about a user nobody has heard of is a question the server answers,
/// not an error: the answer is "offline", and a script branches on the record
/// rather than on a failure.
#[test]
fn asking_about_an_unknown_user_succeeds_with_an_offline_record() {
let server = server_or_skip!();
let output = cli(
&server,
"cli_e2e_unknown_asker",
&["--json", "user", "nobody_qzx"],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let record = json_record(&output);
assert_eq!(record["status"], "offline");
assert_eq!(record["shared_files"], 0);
}
/// Assert that `output` succeeded and that its first record carries every key
/// the documentation promises. `label` names the invocation in the failure.
fn assert_record_keys(output: &Output, label: &str, keys: &[&str]) {
assert_eq!(
code(output),
EXIT_OK,
"`{label}` should succeed, stderr: {}",
stderr(output)
);
let record = json_record(output);
for key in keys {
assert!(
record.get(*key).is_some(),
"`{label}` should carry `{key}`, got {record}"
);
}
}
/// "Thirteen of these need no credentials, because they never touch the
/// network." A script's first call is one of these, so an account must not be
/// a prerequisite for any of them.
#[test]
fn the_commands_advertised_as_credential_free_run_without_an_account() {
let dir = Scratch::new("nocred");
let config = dir.path().join("config.toml");
let target = Scratch::new("nocred-skills");
let skills = target.display();
let shared = Scratch::new("nocred-shared");
let shared_path = shared.display();
// `completions` writes where the shell looks, which is under the home
// directory: give it a throwaway one rather than the developer's.
let home = Scratch::new("nocred-home");
let cases: Vec<Vec<&str>> = vec![
vec!["config", "path"],
vec!["config", "list"],
vec!["config", "set", "search_timeout", "12"],
vec!["config", "get", "search_timeout"],
vec!["shares", "add", &shared_path],
vec!["shares", "list"],
vec!["shares", "remove", &shared_path],
// The wishlist is stored, not searched, until `wish run` asks the
// network — so managing it must not demand an account either.
vec!["wish", "add", "something rare"],
vec!["wish", "list"],
vec!["wish", "remove", "something rare"],
vec!["skills", "install", "--dir", &skills],
vec!["skills", "list", "--dir", &skills],
vec!["skills", "uninstall", "--dir", &skills],
vec!["completions", "install", "--shell", "fish"],
vec!["completions", "uninstall", "--shell", "fish"],
];
for case in cases {
let mut all = vec!["--config", config.to_str().expect("utf-8 path")];
all.extend_from_slice(&case);
let mut invocation = command(&all);
invocation.env("HOME", home.path());
invocation.env("XDG_CONFIG_HOME", home.path().join("config"));
let output = invocation.output().expect("the binary should run");
assert_eq!(
code(&output),
EXIT_OK,
"`{}` should not need credentials, stderr: {}",
case.join(" "),
stderr(&output)
);
}
// `portmap` is the thirteenth: it talks to the router, never to the
// server, so it answers with its own verdict rather than a credentials
// error.
let portmap = with_config(&config, &["portmap"]);
assert!(
matches!(code(&portmap), EXIT_OK | EXIT_NO_RESULTS),
"portmap should not need credentials, got {} / {}",
code(&portmap),
stderr(&portmap)
);
}
/// "Every wait expressed in seconds is bounded to one day, so a mistyped flag
/// is rejected rather than hanging until the heat death of the universe."
#[test]
fn a_wait_longer_than_a_day_is_rejected_rather_than_accepted() {
let day = 86_400;
let over = (day + 1).to_string();
let cases: Vec<Vec<&str>> = vec![
vec!["--no-config", "--search-timeout", &over, "search", "x"],
vec![
"--no-config",
"room",
"listen",
"lobby",
"--duration",
&over,
],
vec!["--no-config", "message", "read", "--duration", &over],
vec!["--no-config", "serve", "--duration", &over],
vec!["--no-config", "get", "x", "--timeout", &over],
];
// Missing credentials are a usage error too, so the exit code alone would
// not prove the bound rejected anything: the range has to be named.
for case in cases {
let output = run(&case);
assert_eq!(
code(&output),
EXIT_USAGE,
"`{}` should be a usage error, stderr: {}",
case.join(" "),
stderr(&output)
);
assert!(
stderr(&output).contains(&format!("1..={day}")),
"`{}` should be rejected by the one-day bound, stderr: {}",
case.join(" "),
stderr(&output)
);
}
}
/// "`SOULSEEK_CONFIG_DIR` and `SOULSEEK_STATE_DIR` relocate the config and
/// state directories wholesale" — what a containerised run relies on.
#[test]
fn the_config_directory_can_be_relocated_wholesale() {
let home = Scratch::new("relocated");
let config_dir = home.path().join("config");
let state_dir = home.path().join("state");
let mut set = command(&["config", "set", "search_timeout", "17"]);
set.env("SOULSEEK_CONFIG_DIR", &config_dir);
set.env("SOULSEEK_STATE_DIR", &state_dir);
let output = set.output().expect("the binary should run");
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let written = config_dir.join("config.toml");
assert!(
written.exists(),
"the setting should land under SOULSEEK_CONFIG_DIR, not the default"
);
let mut path = command(&["config", "path"]);
path.env("SOULSEEK_CONFIG_DIR", &config_dir);
let reported = path.output().expect("the binary should run");
assert!(
records(&reported)[0].ends_with(written.to_str().expect("utf-8 path")),
"got {:?}",
records(&reported)
);
}
/// The shipped SKILL.md tells an agent, key by key, what the offline commands
/// print. An agent has no way to notice a rename, so pin them here.
#[test]
fn the_offline_json_records_carry_the_keys_the_skill_file_names() {
let dir = Scratch::new("skill-keys");
let config = dir.path().join("config.toml");
let shared = Scratch::new("skill-keys-shared");
let shared_path = shared.display();
let skills = Scratch::new("skill-keys-agent");
let skills_path = skills.display();
with_config(&config, &["shares", "add", &shared_path]);
let cases: Vec<(Vec<&str>, Vec<&str>)> = vec![
(
vec!["--json", "shares", "list"],
vec!["directory", "usable"],
),
(
vec!["--json", "skills", "list", "--dir", &skills_path],
vec!["agent", "path", "action"],
),
(vec!["--json", "config", "list"], vec!["key", "value"]),
(vec!["--json", "config", "path"], vec!["key", "value"]),
];
for (case, keys) in cases {
let output = with_config(&config, &case);
assert_record_keys(&output, &case.join(" "), &keys);
}
// `completions` writes into the shell's own directory, so it only ever
// runs against a throwaway home.
let home = Scratch::new("skill-keys-home");
for verb in ["install", "uninstall"] {
let mut invocation = command(&[
"--no-config",
"--json",
"completions",
verb,
"--shell",
"fish",
]);
invocation.env("HOME", home.path());
invocation.env("XDG_CONFIG_HOME", home.path().join("config"));
let output = invocation.output().expect("the binary should run");
assert_record_keys(
&output,
&format!("completions {verb}"),
&["shell", "path", "action"],
);
}
}
/// The website's connectivity section: "`serve` refuses to start with the
/// listener off", because a source nobody can connect to serves nothing.
#[test]
fn serve_refuses_to_run_without_a_listener() {
let share = Scratch::new("serve-nolistener");
let output = run(&[
"--no-config",
"--username",
"someone",
"--password",
"pw",
"--no-listener",
"--shared-dir",
&share.display(),
"serve",
"--duration",
"5",
]);
assert_eq!(code(&output), EXIT_USAGE, "stdout: {}", stdout(&output));
assert!(
stderr(&output).contains("listener"),
"the error should name the listener, got {}",
stderr(&output)
);
}
/// The four commands the README calls out as acting rather than reporting:
/// "say nothing on stdout and answer with their exit code alone".
#[test]
fn the_action_commands_answer_with_their_exit_code_alone() {
let server = server_or_skip!();
let shared = Scratch::new("action-shared");
let shared_path = shared.display();
let dir = Scratch::new("action-config");
let config = dir.path().join("config.toml");
let listener = server.client("cli_e2e_action_peer", Vec::new());
listener.join_room("cli_e2e_action_room").expect("join");
settle();
for case in [
vec!["room", "say", "cli_e2e_action_room", "hello room"],
vec!["message", "send", "cli_e2e_action_peer", "hello there"],
] {
let output = cli(&server, "cli_e2e_action", &case);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
assert!(
stdout(&output).is_empty(),
"`{}` should print nothing on stdout, got {:?}",
case.join(" "),
stdout(&output)
);
}
for case in [
vec!["shares", "add", shared_path.as_str()],
vec!["shares", "remove", shared_path.as_str()],
] {
let output = with_config(&config, &case);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
assert!(
stdout(&output).is_empty(),
"`{}` should print nothing on stdout, got {:?}",
case.join(" "),
stdout(&output)
);
}
}
/// The recipe the README leads with: `--min-bitrate` and `--free-slots` keep
/// only what a script asked for, rather than filtering nothing at all.
#[test]
fn search_filters_drop_what_they_advertise_dropping() {
let server = server_or_skip!();
let share = Scratch::new("share");
std::fs::write(share.path().join("cli_probe_filt.bin"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_filt", vec![share.display()]);
settle();
let unfiltered = cli(
&server,
"cli_e2e_filterer",
&["--search-timeout", "5", "--json", "search", "filt"],
);
assert_eq!(
code(&unfiltered),
EXIT_OK,
"stderr: {}",
stderr(&unfiltered)
);
assert!(
records(&unfiltered)
.iter()
.any(|line| line.contains("cli_probe_filt")),
"the probe should be findable before filtering"
);
// A .bin carries no bitrate, so a bitrate floor must exclude it rather
// than let an unknown through.
let filtered = cli(
&server,
"cli_e2e_filterer",
&[
"--search-timeout",
"5",
"search",
"filt",
"--min-bitrate",
"320",
],
);
assert_eq!(
code(&filtered),
EXIT_NO_RESULTS,
"a bitrate floor should drop a result with no bitrate, got {:?}",
records(&filtered)
);
// `--free-slots` is a narrowing filter too: it may keep everything, but it
// must never invent results the unfiltered search did not have.
let free = cli(
&server,
"cli_e2e_filterer",
&["--search-timeout", "5", "search", "filt", "--free-slots"],
);
assert!(
matches!(code(&free), EXIT_OK | EXIT_NO_RESULTS),
"stderr: {}",
stderr(&free)
);
assert!(
records(&free).len() <= records(&unfiltered).len(),
"a filter must not add results"
);
}
/// "Nothing sensitive on the command line or in the environment":
/// `--password-stdin` reads the first line of stdin, `docker login` style.
#[test]
fn the_password_can_arrive_on_stdin_instead_of_in_argv() {
let server = server_or_skip!();
let _owner = server.client("cli_e2e_stdin_pw", Vec::new());
let args = [
"--no-config",
"--quiet",
"--server",
&server.address(),
"--username",
"cli_e2e_stdin_pw",
"--password-stdin",
"--no-listener",
"whoami",
];
let mut child = command(&args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary should run");
child
.stdin
.as_mut()
.expect("stdin")
.write_all(b"pw\n")
.expect("write the password");
let output = child.wait_with_output().expect("the binary should finish");
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
assert!(
records(&output)[0].starts_with("cli_e2e_stdin_pw\t"),
"got {:?}",
records(&output)
);
}
/// The `--json` paragraph names, field by field, what JSON carries that text
/// does not. A missing key silently breaks the `jq` filters the README sells.
#[test]
fn json_records_carry_every_extra_field_the_readme_names() {
let server = server_or_skip!();
let share = Scratch::new("share");
std::fs::write(share.path().join("cli_probe_keys.bin"), probe_bytes())
.expect("share file");
let target = Scratch::new("downloads");
let share_path = share.display();
let sharer = server.client("cli_e2e_sharer_keys", vec![share.display()]);
sharer.join_room("cli_e2e_keys_room").expect("join");
settle();
let mut expected: Vec<(Vec<&str>, Vec<&str>)> = vec![
(
vec!["search", "keys", "--search-timeout", "5"],
vec![
"user",
"size",
"bitrate",
"path",
"duration",
"slots",
"speed",
"free_slot",
],
),
(
vec!["browse", "cli_e2e_sharer_keys"],
vec!["user", "size", "path", "directory"],
),
(
vec!["whoami"],
vec![
"user",
"server",
"shared_folders",
"shared_files",
"listening",
"listen_port",
"download_dir",
],
),
(
vec!["user", "cli_e2e_sharer_keys"],
vec![
"user",
"status",
"average_speed",
"shared_files",
"privileged",
"shared_folders",
],
),
(
vec!["shares", "status", "--shared-dir", &share_path],
vec!["folders", "files", "directories"],
),
(
vec!["shares", "reindex", "--shared-dir", &share_path],
vec!["folders", "files", "directories"],
),
];
expected.push((vec!["room", "list"], vec!["users", "room"]));
expected.push((
vec!["room", "users", "cli_e2e_keys_room"],
vec!["room", "user"],
));
for (case, keys) in expected {
let mut args: Vec<&str> = vec!["--json"];
args.extend(case.iter().copied());
let output = cli(&server, "cli_e2e_keys", &args);
assert_record_keys(&output, &case.join(" "), &keys);
}
// `download` adds the user, remote path and size alongside the local file.
let listing =
cli(&server, "cli_e2e_keys", &["browse", "cli_e2e_sharer_keys"]);
let remote_path = records(&listing)
.into_iter()
.find(|line| line.contains("cli_probe_keys.bin"))
.and_then(|row| row.rsplit('\t').next().map(String::from))
.expect("the shared file should be listed");
// A fresh name: the peer connects back to the port this user announced at
// login, and the browse runs above left an older one registered.
let downloaded = cli(
&server,
"cli_e2e_keys_fetcher",
&[
"--json",
"--download-dir",
&target.display(),
"download",
"cli_e2e_sharer_keys",
&remote_path,
"--timeout",
"40",
],
);
assert_record_keys(
&downloaded,
"download",
&["file", "user", "path", "size"],
);
// `portmap` adds the port it probed; the verdict lives in the exit code,
// which is `EXIT_NO_RESULTS` on a network with no cooperating router.
let portmap = cli(&server, "cli_e2e_keys", &["--json", "portmap"]);
let record = json_record(&portmap);
for key in ["ok", "backend", "external", "port"] {
assert!(record.get(key).is_some(), "portmap should carry `{key}`");
}
}
// ---------------------------------------------------------------------------
// Several invocations at once: what an agent driving this CLI actually does.
// ---------------------------------------------------------------------------
/// Spawn the binary as `user` with an explicit listener port, without waiting.
fn spawn_cli(
server: &TestServer,
user: &str,
port: u16,
args: &[&str],
) -> std::process::Child {
let mut all = server.args_on(user, port);
all.extend(args.iter().map(|a| (*a).to_string()));
let refs: Vec<&str> = all.iter().map(String::as_str).collect();
command(&refs)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary should start")
}
#[test]
fn concurrent_searches_sharing_a_listener_port_all_find_the_file() {
let server = server_or_skip!();
let share = Scratch::new("share");
std::fs::write(share.path().join("cli_probe_multi.bin"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_multi", vec![share.display()]);
settle();
// One configured port between them, which is what a config file gives
// three agents started at the same moment. Losing the race for it used to
// cost that invocation its listener, and with it every search result.
let port = free_port().expect("shared listener port");
// Collected on purpose: all three have to be running before any is waited
// on, which is the whole point of the test.
#[allow(clippy::needless_collect)]
let running: Vec<_> = (0..3)
.map(|i| {
spawn_cli(
&server,
&format!("cli_e2e_par_{i}"),
port,
&["--search-timeout", "10", "search", "multi"],
)
})
.collect();
for (i, child) in running.into_iter().enumerate() {
let output = child.wait_with_output().expect("wait");
assert_eq!(
code(&output),
EXIT_OK,
"run {i} failed: {}",
stderr(&output)
);
assert!(
records(&output)
.iter()
.any(|line| line.contains("cli_probe_multi")),
"run {i} listed nothing"
);
}
}
#[test]
fn a_session_taken_over_mid_search_is_not_reported_as_no_results() {
let server = server_or_skip!();
let name = "cli_e2e_evicted";
// Run it loud, so its own progress line says when it is logged in: the
// eviction has to land after that, or this test would race the login it is
// trying to interrupt.
let mut args = server.args_on(name, free_port().expect("listener port"));
args.retain(|arg| arg != "--quiet");
args.extend(
["--search-timeout", "20", "search", "nothingmatchesthisqzx"]
.map(String::from),
);
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let mut searching = command(&refs)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary should start");
let (logged_in, wait_for_login) = std::sync::mpsc::channel();
let stderr = searching.stderr.take().expect("piped stderr");
let progress = std::thread::spawn(move || {
let mut seen = String::new();
for line in std::io::BufRead::lines(std::io::BufReader::new(stderr))
.map_while(Result::ok)
{
if line.contains("logged in as") {
let _ = logged_in.send(());
}
seen.push_str(&line);
seen.push('\n');
}
seen
});
wait_for_login
.recv_timeout(Duration::from_mins(1))
.expect("the search should log in");
// Now take the username away, the way a second invocation under one
// account does.
let _thief = server.client(name, Vec::new());
let status = searching.wait().expect("wait");
let seen = progress.join().expect("progress reader");
assert_eq!(
status.code(),
Some(EXIT_SESSION_LOST),
"a killed session must not look like an answered query: {seen}"
);
let mut written = String::new();
std::io::Read::read_to_string(
&mut searching.stdout.take().expect("piped stdout"),
&mut written,
)
.expect("read stdout");
assert!(written.trim().is_empty(), "stdout stays data-only");
}
#[test]
fn concurrent_downloads_sharing_a_listener_port_all_land() {
let server = server_or_skip!();
let share = Scratch::new("share");
std::fs::write(share.path().join("cli_probe_dual.bin"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_dual", vec![share.display()]);
settle();
// A transfer arrives over a connection the *uploader* opens back to us, so
// a run without a listener never receives its file however well its search
// went. Two `get` runs, one configured port between them.
let port = free_port().expect("shared listener port");
let targets: Vec<Scratch> =
(0..2).map(|i| Scratch::new(&format!("dl{i}"))).collect();
// Collected on purpose: both have to be running before either is waited
// on, which is the whole point of the test.
#[allow(clippy::needless_collect)]
let running: Vec<_> = targets
.iter()
.enumerate()
.map(|(i, target)| {
spawn_cli(
&server,
&format!("cli_e2e_dual_{i}"),
port,
&[
"--download-dir",
&target.display(),
"--search-timeout",
"10",
"get",
"dual",
"--timeout",
"40",
],
)
})
.collect();
for (i, (child, target)) in running.into_iter().zip(&targets).enumerate() {
let output = child.wait_with_output().expect("wait");
assert_eq!(
code(&output),
EXIT_OK,
"run {i} failed: {}",
stderr(&output)
);
let written = std::fs::read(target.path().join("cli_probe_dual.bin"))
.unwrap_or_else(|e| panic!("run {i} wrote no file: {e}"));
assert_eq!(written, probe_bytes(), "run {i} wrote the wrong bytes");
}
}
// ---------------------------------------------------------------------------
// Daemon mode
//
// Everything above proves a command behaves the same whichever kind of session
// serves it. These prove the things that only exist *because* there is a
// daemon: one session shared by several clients at once, the remote transport
// and its token, and the gate that decides who may talk to it at all.
// ---------------------------------------------------------------------------
/// Two clients, one session, at the same time.
///
/// This is the whole reason the daemon exists: the server allows one login per
/// account, so without it a second command would displace the first and both
/// would start reporting a lost session.
#[test]
fn two_clients_share_one_daemon_session_at_once() {
if mode() != Mode::Daemon {
println!("skipped: this is a daemon-mode property");
return;
}
let server = server_or_skip!();
let share = Scratch::new("share");
std::fs::write(share.path().join("cli_probe_shared.bin"), probe_bytes())
.expect("share file");
let _sharer =
server.client("cli_e2e_two_client_peer", vec![share.display()]);
settle();
// One daemon, because both runs use the same account.
let args = server.args("cli_e2e_two_clients");
let spawn = |extra: &[&str]| {
let mut all = args.clone();
all.extend(extra.iter().map(|a| (*a).to_string()));
let refs: Vec<&str> = all.iter().map(String::as_str).collect();
command(&refs)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary should start")
};
let first = spawn(&["--json", "--search-timeout", "8", "search", "shared"]);
let second = spawn(&["--json", "browse", "cli_e2e_two_client_peer"]);
let first = first.wait_with_output().expect("wait");
let second = second.wait_with_output().expect("wait");
assert_eq!(
code(&first),
EXIT_OK,
"the search failed while another client was attached: {}",
stderr(&first)
);
assert_eq!(
code(&second),
EXIT_OK,
"the browse failed while another client was attached: {}",
stderr(&second)
);
assert!(
!records(&first).is_empty() && !records(&second).is_empty(),
"both clients should have got answers"
);
// Neither may report the session being taken over: that is exactly what a
// second *login* would have caused, and what borrowing one avoids.
for output in [&first, &second] {
assert_ne!(code(output), EXIT_SESSION_LOST, "{}", stderr(output));
}
}
/// A daemon reports itself, and stops when asked.
#[test]
fn a_daemon_reports_its_session_and_stops_on_request() {
if mode() != Mode::Daemon {
println!("skipped: there is no daemon to ask in local mode");
return;
}
let server = server_or_skip!();
// A daemon of its own: this test stops it, and the shared fixtures are
// still serving the rest of the suite.
let user = "cli_e2e_daemon_status";
let state = server.daemon_state(user);
let socket = state.join("daemon.sock");
let at = socket.display().to_string();
let status =
run(&["--no-config", "--json", "--daemon", &at, "daemon", "status"]);
assert_eq!(code(&status), EXIT_OK, "stderr: {}", stderr(&status));
let record = json_record(&status);
assert_eq!(record["user"], user);
assert_eq!(record["server"], server.address());
assert!(
record["clients"].as_u64().is_some_and(|n| n >= 1),
"the asking client counts as attached: {record}"
);
let stopped = run(&["--no-config", "--daemon", &at, "daemon", "stop"]);
assert_eq!(code(&stopped), EXIT_OK, "stderr: {}", stderr(&stopped));
// The socket goes away, and a command that finds nothing there says so
// rather than hanging.
let deadline = Instant::now() + Duration::from_secs(20);
while socket.exists() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(200));
}
let after = run(&["--no-config", "--daemon", &at, "daemon", "status"]);
assert_eq!(
code(&after),
EXIT_CONNECTION,
"a stopped daemon is a connection error, not a hang: {}",
stderr(&after)
);
}
/// `daemon token` prints a secret, and the same one every time.
#[test]
fn the_daemon_token_is_stable_across_reads() {
let state = Scratch::new("token");
let read = || {
let mut command = Command::new(BIN);
for name in CLI_ENV_VARS {
command.env_remove(name);
}
command.env("SOULSEEK_STATE_DIR", state.path());
command.current_dir(std::env::temp_dir());
command.args(["--no-config", "daemon", "token"]);
command.output().expect("the binary should run")
};
let first = read();
assert_eq!(code(&first), EXIT_OK, "stderr: {}", stderr(&first));
let token = records(&first).first().expect("a token").clone();
assert_eq!(token.len(), 64, "256 bits, hex encoded");
assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(
records(&read()).first().expect("a token"),
&token,
"a restart must not lock existing clients out"
);
}
/// Adding a folder reaches a daemon that is already running.
///
/// Locally `shares add` means "shared from the next run on", because there is
/// no session yet. A daemon *is* that next run and it is already going, so
/// without pushing the change the command would look like it had worked while
/// the network still saw nothing.
#[test]
fn adding_a_share_takes_effect_on_a_running_daemon() {
if mode() != Mode::Daemon {
println!(
"skipped: there is no running session to update in local mode"
);
return;
}
let server = server_or_skip!();
let user = "cli_e2e_live_shares";
let socket = server.daemon_state(user).join("daemon.sock");
let at = socket.display().to_string();
let config = Scratch::new("live-shares-config");
let config_file = config.path().join("config.toml");
let share = Scratch::new("live-shares");
std::fs::write(share.path().join("cli_probe_live.bin"), probe_bytes())
.expect("share file");
let before =
run(&["--no-config", "--json", "--daemon", &at, "shares", "status"]);
assert_eq!(code(&before), EXIT_OK, "stderr: {}", stderr(&before));
let added = run(&[
"--config",
config_file.to_str().expect("utf-8 path"),
"--daemon",
&at,
"shares",
"add",
&share.display(),
]);
assert_eq!(code(&added), EXIT_OK, "stderr: {}", stderr(&added));
// The daemon re-indexes on the way through, so the folder is visible to
// the network now rather than after a restart.
let after = json_record(&run(&[
"--no-config",
"--json",
"--daemon",
&at,
"shares",
"status",
]));
let listed = after["directories"]
.as_array()
.expect("directories is an array");
assert!(
listed.iter().any(|d| d == &share.display()),
"the daemon should be sharing the folder that was just added: {after}"
);
assert!(
after["files"].as_u64().is_some_and(|files| files >= 1),
"and should have indexed what is in it: {after}"
);
}
/// Setting the download folder reaches a daemon that is already running.
///
/// Locally `config set download_dir` means "from the next run on". A daemon
/// *is* that next run and it is already going, so without pushing the change
/// every transfer would keep landing in the old folder while the config file
/// claims otherwise — the trap `shares add` already closes.
#[test]
fn setting_the_download_folder_takes_effect_on_a_running_daemon() {
if mode() != Mode::Daemon {
println!(
"skipped: there is no running session to update in local mode"
);
return;
}
let server = server_or_skip!();
let user = "cli_e2e_live_dl_dir";
let socket = server.daemon_state(user).join("daemon.sock");
let at = socket.display().to_string();
let config = Scratch::new("live-dl-config");
let config_file = config.path().join("config.toml");
let moved = Scratch::new("live-dl-target");
let set = run(&[
"--config",
config_file.to_str().expect("utf-8 path"),
"--daemon",
&at,
"config",
"set",
"download_dir",
&moved.display(),
]);
assert_eq!(code(&set), EXIT_OK, "stderr: {}", stderr(&set));
// The daemon reports the new folder as its own...
let status =
run(&["--no-config", "--json", "--daemon", &at, "daemon", "status"]);
assert_eq!(code(&status), EXIT_OK, "stderr: {}", stderr(&status));
assert_eq!(
json_record(&status)["download_dir"],
moved.display(),
"status must name the folder transfers will now land in"
);
// `config set shared_dirs` reaches the same running daemon: the folder
// is indexed now, not at the next restart.
let offered = Scratch::new("live-dl-offered");
std::fs::write(offered.path().join("cli_probe_offered.bin"), probe_bytes())
.expect("share file");
let set_shares = run(&[
"--config",
config_file.to_str().expect("utf-8 path"),
"--daemon",
&at,
"config",
"set",
"shared_dirs",
&offered.display(),
]);
assert_eq!(
code(&set_shares),
EXIT_OK,
"stderr: {}",
stderr(&set_shares)
);
let listing =
run(&["--no-config", "--json", "--daemon", &at, "shares", "status"]);
assert_eq!(code(&listing), EXIT_OK, "stderr: {}", stderr(&listing));
let indexed = json_record(&listing);
assert!(
indexed["directories"]
.as_array()
.expect("directories is an array")
.iter()
.any(|d| d == &offered.display()),
"the daemon should serve the folder the config now names: {indexed}"
);
// ...and the next transfer actually lands there.
let share = Scratch::new("live-dl-share");
std::fs::write(share.path().join("cli_probe_moved.bin"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_moved", vec![share.display()]);
settle();
let fetched = run(&[
"--no-config",
"--quiet",
"--daemon",
&at,
"get",
"cli_probe_moved",
"--timeout",
"40",
]);
assert_eq!(code(&fetched), EXIT_OK, "stderr: {}", stderr(&fetched));
let reported = records(&fetched);
assert_eq!(reported.len(), 1, "one file, one record");
assert!(
reported[0].trim().starts_with(&moved.display()),
"the bytes must land in the folder that was just set, got {}",
reported[0]
);
assert_eq!(
std::fs::read(reported[0].trim()).expect("downloaded file"),
probe_bytes()
);
}
/// The configured `download_dir` is where a download lands when no flag names
/// one — the local half of the promise the daemon test above makes.
#[test]
fn a_download_lands_in_the_folder_the_config_names() {
if mode() != Mode::Local {
println!("skipped: the daemon's folder wins in daemon mode");
return;
}
let server = server_or_skip!();
let share = Scratch::new("share");
let target = Scratch::new("configured-downloads");
std::fs::write(share.path().join("cli_probe_cfgdir.bin"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_cfg", vec![share.display()]);
settle();
let config = Scratch::new("dl-dir-config");
let config_file = config.path().join("config.toml");
let set = run(&[
"--config",
config_file.to_str().expect("utf-8 path"),
"config",
"set",
"download_dir",
&target.display(),
]);
assert_eq!(code(&set), EXIT_OK, "stderr: {}", stderr(&set));
let fetched = cli_with_config(
&server,
"cli_e2e_cfg_fetcher",
&config_file,
&["get", "cli_probe_cfgdir", "--timeout", "40"],
);
assert_eq!(code(&fetched), EXIT_OK, "stderr: {}", stderr(&fetched));
let reported = records(&fetched);
assert_eq!(reported.len(), 1, "one file, one record");
assert!(
reported[0].trim().starts_with(&target.display()),
"the config file names where downloads go, got {}",
reported[0]
);
}
/// With nothing running, the daemon control commands say so and exit with the
/// connection code — a script must be able to branch on "is it up?".
#[test]
fn daemon_status_and_stop_without_a_daemon_are_connection_errors() {
for command in ["status", "stop"] {
let output = run(&["--no-config", "daemon", command]);
assert_eq!(
code(&output),
EXIT_CONNECTION,
"daemon {command}: {}",
stderr(&output)
);
assert!(
stderr(&output).contains("no daemon is running"),
"daemon {command} should say what is wrong, got: {}",
stderr(&output)
);
assert!(stdout(&output).is_empty(), "stdout must stay data-only");
}
}
/// `--sort size` leads with the largest file, which is what a script that
/// wants "the full album rip, not the sample" branches on.
#[test]
fn search_sorted_by_size_leads_with_the_largest_file() {
let server = server_or_skip!();
let share = Scratch::new("share");
std::fs::write(share.path().join("cli_probe_sort_small.bin"), [7u8; 64])
.expect("share file");
std::fs::write(
share.path().join("cli_probe_sort_large.bin"),
vec![7u8; 4096],
)
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_sort", vec![share.display()]);
settle();
let output = cli(
&server,
"cli_e2e_sorter",
&[
"--search-timeout",
"5",
"search",
"cli_probe_sort",
"--sort",
"size",
],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let reported = records(&output);
assert!(reported.len() >= 2, "both files answer, got {reported:?}");
assert!(
reported[0].contains("cli_probe_sort_large"),
"largest first under --sort size, got {reported:?}"
);
// `--limit 1` keeps only the leader.
let capped = cli(
&server,
"cli_e2e_sorter",
&[
"--search-timeout",
"5",
"search",
"cli_probe_sort",
"--sort",
"size",
"--limit",
"1",
],
);
assert_eq!(code(&capped), EXIT_OK, "stderr: {}", stderr(&capped));
let capped = records(&capped);
assert_eq!(capped.len(), 1, "--limit 1 keeps one record");
assert!(
capped[0].contains("cli_probe_sort_large"),
"and it is the ranked leader, got {capped:?}"
);
}
/// `--pick first` takes the first arrival without waiting out the window.
#[test]
fn get_pick_first_downloads_the_first_arrival() {
let server = server_or_skip!();
let share = Scratch::new("share");
let target = Scratch::new("downloads");
std::fs::write(share.path().join("cli_probe_first.bin"), probe_bytes())
.expect("share file");
let _sharer = server.client("cli_e2e_sharer_first", vec![share.display()]);
settle();
let output = cli(
&server,
"cli_e2e_first_picker",
&[
"--download-dir",
&target.display(),
"get",
"cli_probe_first",
"--pick",
"first",
"--timeout",
"40",
],
);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let reported = records(&output);
assert_eq!(reported.len(), 1, "--pick first downloads one file");
assert_eq!(
std::fs::read(reported[0].trim()).expect("downloaded file"),
probe_bytes()
);
}
/// The password can come from a command, `pass`-manager style: nothing
/// sensitive in argv, nothing in the environment.
#[test]
fn the_password_can_come_from_a_command() {
let server = server_or_skip!();
let _owner = server.client("cli_e2e_cmd_pw", Vec::new());
let output = run(&[
"--no-config",
"--quiet",
"--server",
&server.address(),
"--username",
"cli_e2e_cmd_pw",
"--password-cmd",
"echo pw",
"--no-listener",
"whoami",
]);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
assert!(
records(&output)[0].starts_with("cli_e2e_cmd_pw\t"),
"got {:?}",
records(&output)
);
}
/// `--log-file` keeps diagnostics out of stderr and in a file a bug report
/// can attach.
#[test]
fn a_log_file_captures_what_verbose_would_print() {
let server = server_or_skip!();
let _owner = server.client("cli_e2e_logged", Vec::new());
let logs = Scratch::new("logs");
let log_file = logs.path().join("run.log");
let output = run(&[
"--no-config",
"--quiet",
"-vvv",
"--log-file",
log_file.to_str().expect("utf-8 path"),
"--username",
"cli_e2e_logged",
"--password",
"pw",
"--server",
&server.address(),
"--no-listener",
"whoami",
]);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
let written =
std::fs::read_to_string(&log_file).expect("the log file is created");
assert!(
!written.trim().is_empty(),
"-vvv must leave diagnostics in the file"
);
assert!(
!stderr(&output).contains("DEBUG"),
"with a log file, diagnostics stay out of stderr: {}",
stderr(&output)
);
}