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') }}
|
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
- name: Run clippy
|
- name: Run clippy
|
||||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
format:
|
format:
|
||||||
name: Format Check
|
name: Format Check
|
||||||
@@ -92,3 +92,44 @@ jobs:
|
|||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cargo test --verbose
|
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] Download files
|
||||||
- [x] Configure credentials
|
- [x] Configure credentials
|
||||||
- [x] TUI for searching and downloading files
|
- [x] TUI for searching and downloading files
|
||||||
- [ ] Configure download & upload directories
|
- [x] Configure download & upload directories
|
||||||
- [ ] Share files
|
- [ ] Share files
|
||||||
- [ ] Browse user(s) files
|
- [ ] Browse user(s) files
|
||||||
- [ ] Chat in chatrooms
|
- [ ] 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
|
> 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.
|
> 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
|
## License
|
||||||
|
|
||||||
This project is licensed under the MIT License — see the [LICENSE](./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.
|
/// 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 {
|
macro_rules! server_or_skip {
|
||||||
() => {
|
() => {
|
||||||
match TestServer::resolve() {
|
match TestServer::resolve() {
|
||||||
Some(server) => server,
|
Some(server) => server,
|
||||||
None => {
|
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!(
|
println!(
|
||||||
"e2e skipped: no soulfind server (set SOULFIND_BIN or \
|
"e2e skipped: no soulfind server (set SOULFIND_BIN or \
|
||||||
SOULSEEK_TEST_SERVER to run)"
|
SOULSEEK_TEST_SERVER to run)"
|
||||||
@@ -192,3 +202,46 @@ fn search_round_trips_without_error() {
|
|||||||
let _ = client.search(query, Duration::from_secs(2));
|
let _ = client.search(query, Duration::from_secs(2));
|
||||||
assert!(client.get_search_results(query).is_empty());
|
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,8 +56,9 @@ fn main() -> Result<()> {
|
|||||||
|
|
||||||
// Resolve the optional shared/upload directory up front; a misconfigured
|
// Resolve the optional shared/upload directory up front; a misconfigured
|
||||||
// one is a warning, not a fatal error (the client just shares nothing).
|
// one is a warning, not a fatal error (the client just shares nothing).
|
||||||
let shared_directory =
|
let shared_directory = match directories::resolve_shared_directory(
|
||||||
match directories::resolve_shared_directory(cli.shared_dir.as_deref()) {
|
cli.shared_dir.as_deref(),
|
||||||
|
) {
|
||||||
Ok(dir) => dir.map(|path| path.display().to_string()),
|
Ok(dir) => dir.map(|path| path.display().to_string()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("⚠️ Ignoring shared directory: {e}");
|
eprintln!("⚠️ Ignoring shared directory: {e}");
|
||||||
|
|||||||
Reference in New Issue
Block a user