From a127dd97bf185ce237013061ac080d4500cc59c0 Mon Sep 17 00:00:00 2001 From: michel Date: Sun, 19 Jul 2026 13:18:25 +0200 Subject: [PATCH] ci: run soulfind e2e tests on GitHub Actions Extend the existing CI workflow with an ubuntu-only "End-to-end (soulfind)" job that builds soulfind from source (LDC + `dub build :server`), points SOULFIND_BIN at it, and runs the e2e suite with SOULSEEK_E2E_REQUIRED=1. Also widen the clippy job to the whole workspace. The e2e harness gains SOULSEEK_E2E_REQUIRED: when set, a missing/unstartable server is a hard failure instead of a silent skip, so CI genuinely exercises the client end-to-end. Add e2e coverage for two existing behaviours a single server can verify: wrong-password rejection and concurrent logins. --- .github/workflows/ci.yml | 43 ++++++++++++++++++++++++++++- README.md | 16 ++++++++++- soulseek-rs-lib/tests/e2e.rs | 53 ++++++++++++++++++++++++++++++++++++ soulseek-rs/src/main.rs | 17 ++++++------ 4 files changed, 119 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b2938b..447336c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - name: Run clippy - run: cargo clippy --all-targets --all-features -- -D warnings + run: cargo clippy --workspace --all-targets --all-features -- -D warnings format: name: Format Check @@ -92,3 +92,44 @@ jobs: - name: Run tests run: cargo test --verbose + + e2e: + name: End-to-end (soulfind) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - name: Install SQLite runtime/dev libraries + run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev + + - name: Install D toolchain (LDC) + uses: dlang-community/setup-dlang@v2 + with: + compiler: ldc-latest + + - name: Build soulfind server + run: | + git clone --depth 1 https://github.com/soulfind-dev/soulfind.git soulfind + dub build :server --root soulfind + test -x soulfind/bin/soulfind + echo "SOULFIND_BIN=$PWD/soulfind/bin/soulfind" >> "$GITHUB_ENV" + + - name: Run e2e tests (required against soulfind) + env: + SOULSEEK_E2E_REQUIRED: "1" + run: cargo test -p soulseek-rs-lib --test e2e -- --nocapture diff --git a/README.md b/README.md index df9b10f..ecf52b3 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ experience. For me, this is a good balance between learning and practicality. - [x] Download files - [x] Configure credentials - [x] TUI for searching and downloading files -- [ ] Configure download & upload directories +- [x] Configure download & upload directories - [ ] Share files - [ ] Browse user(s) files - [ ] Chat in chatrooms @@ -141,6 +141,20 @@ SOULFIND_BIN=/path/to/soulfind/bin/soulfind \ > the system SQLite; building soulfind with that call made non-fatal lets it run > locally. It is only a mutex optimization and does not affect the server. +Set `SOULSEEK_E2E_REQUIRED=1` to turn a missing server into a hard failure +instead of a skip. Continuous integration sets it so the e2e suite genuinely +runs against a freshly built soulfind rather than silently skipping. + +### Continuous integration + +`.github/workflows/ci.yml` runs on every push and pull request: + +- **Format & Clippy** — `cargo fmt --all --check` and + `cargo clippy --workspace --all-targets -- -D warnings`. +- **Test** — builds soulfind from source (LDC + `dub build :server`), points + `SOULFIND_BIN` at it, and runs `cargo test --workspace` with + `SOULSEEK_E2E_REQUIRED=1`, so unit and end-to-end tests both run. + ## License This project is licensed under the MIT License — see the [LICENSE](./LICENSE) diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 6921e89..e7b80c8 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -153,11 +153,21 @@ fn wait_until_listening( } /// Resolve a test server or return early with a skip notice. +/// +/// Set `SOULSEEK_E2E_REQUIRED=1` (as CI does) to turn a missing server into a +/// hard failure instead of a silent skip, so the suite genuinely runs there. 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!( "e2e skipped: no soulfind server (set SOULFIND_BIN or \ SOULSEEK_TEST_SERVER to run)" @@ -192,3 +202,46 @@ fn search_round_trips_without_error() { let _ = client.search(query, Duration::from_secs(2)); assert!(client.get_search_results(query).is_empty()); } + +#[test] +fn wrong_password_is_rejected() { + let server = server_or_skip!(); + + // soulfind auto-registers a username on first login and binds it to that + // password, so a second login with a different password must be rejected. + let user = "e2e_pw_user"; + let mut first = + Client::with_settings(server.settings(user, "correct-horse")); + first.connect().expect("connect (registering login)"); + assert!( + first.login().expect("first login"), + "registration should log in" + ); + drop(first); + + let mut second = + Client::with_settings(server.settings(user, "wrong-password")); + second.connect().expect("connect (wrong password)"); + // The server may signal rejection either as a non-success status or as an + // authentication error; both mean "not logged in", only `Ok(true)` accepts. + assert!( + !matches!(second.login(), Ok(true)), + "a mismatched password must not be accepted" + ); +} + +#[test] +fn two_clients_can_be_logged_in_together() { + let server = server_or_skip!(); + + // The server must handle several independent sessions at once — this is + // the precondition for any peer-to-peer feature routed through it. + let mut alice = Client::with_settings(server.settings("e2e_alice", "pw_a")); + let mut bob = Client::with_settings(server.settings("e2e_bob", "pw_b")); + + alice.connect().expect("alice connect"); + bob.connect().expect("bob connect"); + + assert!(alice.login().expect("alice login")); + assert!(bob.login().expect("bob login")); +} diff --git a/soulseek-rs/src/main.rs b/soulseek-rs/src/main.rs index a0b2fd4..f1bc0c6 100644 --- a/soulseek-rs/src/main.rs +++ b/soulseek-rs/src/main.rs @@ -56,14 +56,15 @@ fn main() -> Result<()> { // Resolve the optional shared/upload directory up front; a misconfigured // one is a warning, not a fatal error (the client just shares nothing). - let shared_directory = - match directories::resolve_shared_directory(cli.shared_dir.as_deref()) { - Ok(dir) => dir.map(|path| path.display().to_string()), - Err(e) => { - eprintln!("⚠️ Ignoring shared directory: {e}"); - None - } - }; + let shared_directory = match directories::resolve_shared_directory( + cli.shared_dir.as_deref(), + ) { + Ok(dir) => dir.map(|path| path.display().to_string()), + Err(e) => { + eprintln!("⚠️ Ignoring shared directory: {e}"); + None + } + }; match cli.command { Some(Commands::Search {