Files
soulseek-rs-modded/soulseek-rs/tests/cli_e2e.rs
T
Michel de Graaf af4434e764 test: prove the documented examples against the binary (#15)
* test: prove the documented examples against the binary

The README, the website and the shipped SKILL.md all promise specific
records, keys and exit codes, and nothing was checking that the binary
still agrees. Cover the claims that were unverified: the eleven
credential-free commands, the one-day bound on every wait, the config
directory relocation, `serve` refusing to run without a listener, the
four action commands that answer with an exit code alone, the search
filters, `--password-stdin`, `room listen`'s join records, and every
`--json` field the docs name for search, browse, whoami, user, shares,
room, download, serve and portmap.

The library README's examples now compile as doctests: lib.rs includes
it as the crate docs and the fences are `no_run`, so a signature change
cannot leave the advertised usage stale.

Two things were wrong. SKILL.md listed `queued` as a `serve` upload
status, which `upload_record` never emits — an agent branching on it
would wait for a state that never arrives. And the library README still
pointed at the 7.x lib.

* test: cover the completions commands the docs now advertise
2026-07-26 12:56:40 +02:00

2114 lines
68 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.
#![allow(clippy::doc_markdown)]
use soulseek_rs::{Client, ClientSettings, PeerAddress};
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;
/// Every environment variable the CLI reads, cleared for each run so a
/// developer's shell (or a stray `.env`) cannot change what a test observes.
const CLI_ENV_VARS: [&str; 13] = [
"SOULSEEK_CONFIG_DIR",
"SOULSEEK_STATE_DIR",
"SOULSEEK_USERNAME",
"SOULSEEK_PASSWORD",
"SOULSEEK_PASSWORD_CMD",
"SOULSEEK_SERVER",
"SOULSEEK_NO_LISTENER",
"SOULSEEK_LISTENER_PORT",
"SOULSEEK_DOWNLOAD_DIR",
"SOULSEEK_SHARED_DIR",
"SOULSEEK_MAX_CONCURRENT_DOWNLOADS",
"SOULSEEK_SEARCH_TIMEOUT",
"SOULSEEK_CONFIG",
];
/// Build a command for the binary, isolated from the developer's environment
/// and from the repository's own working directory.
fn command(args: &[&str]) -> Command {
let mut command = Command::new(BIN);
for name in CLI_ENV_VARS {
command.env_remove(name);
}
command.current_dir(std::env::temp_dir());
command.args(args);
command
}
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()
}
// ---------------------------------------------------------------------------
// 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",
] {
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"] {
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: serde_json::Value =
serde_json::from_str(records(&output).first().expect("one record"))
.expect("portmap should emit valid JSON");
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 {
let record: serde_json::Value =
serde_json::from_str(records(output).first().expect("one record"))
.expect("valid JSON");
record["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");
}
// ---------------------------------------------------------------------------
// 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,
child: Option<Child>,
db: Option<PathBuf>,
_gate: std::sync::MutexGuard<'static, ()>,
}
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,
child: None,
db: None,
_gate: gate,
});
}
let bin = soulfind_binary()?;
let port = free_port()?;
let db = std::env::temp_dir().join(format!("soulfind-cli-{port}.db"));
let _ = std::fs::remove_file(&db);
let mut child = Command::new(&bin)
.arg("-p")
.arg(port.to_string())
.arg("-d")
.arg(&db)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()?;
if wait_until_listening("127.0.0.1", port, Duration::from_secs(5))
.is_none()
{
let _ = child.kill();
let _ = child.wait();
return None;
}
Some(Self {
host: "127.0.0.1".to_string(),
port,
child: Some(child),
db: Some(db),
_gate: gate,
})
}
fn address(&self) -> String {
format!("{}:{}", self.host, self.port)
}
/// Arguments that point the binary at this server as `user`.
///
/// 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> {
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(),
free_port().expect("listener port").to_string(),
]
}
/// An in-process client, used to stand up the other end of a test.
fn client(&self, user: &str, shares: Vec<String>) -> Client {
let port = free_port().expect("peer port");
let mut client = Client::with_settings(ClientSettings {
username: user.to_string(),
password: "pw".to_string(),
server_address: PeerAddress::new(self.host.clone(), self.port),
enable_listen: true,
listen_port: port,
shared_directories: shares,
version: soulseek_rs::ClientVersion::default(),
});
client.connect().expect("peer connect");
assert!(client.login().expect("peer login"), "peer should log in");
client
}
}
impl Drop for TestServer {
fn drop(&mut self) {
if let Some(child) = self.child.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
if let Some(db) = self.db.as_ref() {
let _ = std::fs::remove_file(db);
}
}
}
fn soulfind_binary() -> Option<PathBuf> {
if let Ok(path) = std::env::var("SOULFIND_BIN") {
let path = PathBuf::from(path);
return path.exists().then_some(path);
}
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.map(|dir| dir.join("soulfind/bin/soulfind"))
.find(|candidate| candidate.exists())
}
fn free_port() -> Option<u16> {
std::net::TcpListener::bind("127.0.0.1:0")
.ok()?
.local_addr()
.ok()
.map(|addr| addr.port())
}
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 => {
let required = std::env::var("SOULSEEK_E2E_REQUIRED")
.is_ok_and(|v| v != "0" && !v.is_empty());
assert!(
!required,
"SOULSEEK_E2E_REQUIRED is set but no soulfind server could \
be started (set SOULFIND_BIN or SOULSEEK_TEST_SERVER)"
);
println!(
"cli e2e skipped: no soulfind server (set SOULFIND_BIN or \
SOULSEEK_TEST_SERVER to run)"
);
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`.
fn cli(server: &TestServer, user: &str, args: &[&str]) -> Output {
let mut all = server.args(user);
all.extend(args.iter().map(|a| (*a).to_string()));
let refs: Vec<&str> = all.iter().map(String::as_str).collect();
run(&refs)
}
/// Wait out the SetWaitPort registrations so peer lookups resolve.
fn settle() {
std::thread::sleep(Duration::from_secs(1));
}
#[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");
}
#[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 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: serde_json::Value =
serde_json::from_str(records(&output).first().expect("one record"))
.expect("valid JSON");
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_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: serde_json::Value =
serde_json::from_str(records(&output).first().expect("one record"))
.expect("valid JSON");
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.
let mut args = server.args("cli_e2e_server");
args.retain(|arg| arg != "--quiet");
args.extend(
[
"--shared-dir",
&share.display(),
"--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: serde_json::Value =
serde_json::from_str(records(&output).first().expect("one record"))
.expect("valid JSON");
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: serde_json::Value =
serde_json::from_str(records(&output).first().expect("one record"))
.expect("valid JSON");
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: serde_json::Value =
serde_json::from_str(records(&output).first().expect("one record"))
.expect("valid JSON");
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: serde_json::Value = serde_json::from_str(
records(output).first().expect("at least one record"),
)
.expect("valid JSON");
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],
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: serde_json::Value =
serde_json::from_str(records(&portmap).first().expect("one record"))
.expect("valid JSON");
for key in ["ok", "backend", "external", "port"] {
assert!(record.get(key).is_some(), "portmap should carry `{key}`");
}
}