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.
This commit is contained in:
michel
2026-07-19 13:19:46 +02:00
parent 1328736550
commit a127dd97bf
4 changed files with 119 additions and 10 deletions
+42 -1
View File
@@ -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
+15 -1
View File
@@ -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)
+53
View File
@@ -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"));
}
+9 -8
View File
@@ -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 {