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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user