Merge pull request #90 from michel/tui/rerun-search

feat(tui): run the highlighted search again with S
This commit is contained in:
Michel de Graaf
2026-09-08 13:33:52 +02:00
committed by GitHub
13 changed files with 571 additions and 227 deletions
+7
View File
@@ -110,6 +110,13 @@ jobs:
SOULSEEK_E2E_REQUIRED: "1" SOULSEEK_E2E_REQUIRED: "1"
run: cargo test -p soulseek-rs --test cli_e2e -- --nocapture run: cargo test -p soulseek-rs --test cli_e2e -- --nocapture
# The window over a real session: keys in, a screen out, a peer on the
# other end. Nothing else opens the TUI against a server.
- name: Run TUI e2e tests (required against soulfind)
env:
SOULSEEK_E2E_REQUIRED: "1"
run: cargo test -p soulseek-rs --test tui_e2e -- --nocapture
# The control protocol as a third party meets it: the TCP transport, the # The control protocol as a third party meets it: the TCP transport, the
# token, and the gate. None of it is reachable through the command # token, and the gate. None of it is reachable through the command
# surface, so the CLI suite cannot cover it in either mode. # surface, so the CLI suite cannot cover it in either mode.
+16 -14
View File
@@ -107,7 +107,8 @@ zooms the focused pane to the whole window, `w` hides it so the others widen
takes `↑`/`↓`, `Home`/`End`, `PgUp`/`PgDn` and the vim keys, and `h`/`l` or takes `↑`/`↓`, `Home`/`End`, `PgUp`/`PgDn` and the vim keys, and `h`/`l` or
`←`/`→` scroll a long file name, folder or query sideways, `0` and `$` to `←`/`→` scroll a long file name, folder or query sideways, `0` and `$` to
either end. `PgUp`/`PgDn` scroll a chat log back through its history, and `?` either end. `PgUp`/`PgDn` scroll a chat log back through its history, and `?`
shows every key for where you are. shows every key for where you are. `s` starts a search, and `S` runs the
highlighted one again.
### Daemon mode ### Daemon mode
@@ -620,18 +621,19 @@ cargo fmt
### End-to-end tests ### End-to-end tests
Two suites run against [soulfind](https://github.com/soulfind-dev/soulfind), a Four suites run against [soulfind](https://github.com/soulfind-dev/soulfind),
local Soulseek server: `soulseek-rs-lib/tests/e2e.rs` covers the protocol a local Soulseek server: `soulseek-rs-lib/tests/e2e.rs` covers the protocol
library, and `soulseek-rs/tests/cli_e2e.rs` drives the binary the way a script library, `soulseek-rs/tests/cli_e2e.rs` drives the binary the way a script
would. Both are **server-optional**, running when a server is available and would, `soulseek-rs/tests/daemon_protocol_e2e.rs` meets the daemon's control
skipping otherwise, so `cargo test` stays green everywhere. protocol as a third party would, and `soulseek-rs/tests/tui_e2e.rs` drives the
window with key presses and reads the screen back. All are
**server-optional**, running when a server is available and skipping
otherwise, so `cargo test` stays green everywhere.
They locate a server in this order: Every suite spawns soulfind from `SOULFIND_BIN=/path/to/soulfind` (or a
`soulfind/bin/soulfind` checkout in a parent directory) on an ephemeral port
1. `SOULSEEK_TEST_SERVER=host:port`: connect to an already-running server, or with a throwaway database. The library and CLI suites take
2. `SOULFIND_BIN=/path/to/soulfind` (or a `soulfind/bin/soulfind` checkout in a `SOULSEEK_TEST_SERVER=host:port` first, to run against a server already up.
parent directory), which spawns soulfind on an ephemeral port with a
throwaway database.
```bash ```bash
# Build soulfind once (see its BUILDING.md), then: # Build soulfind once (see its BUILDING.md), then:
@@ -657,8 +659,8 @@ soulfind instead of skipping.
`cargo clippy --workspace --all-targets -- -D warnings`. `cargo clippy --workspace --all-targets -- -D warnings`.
- **Test**: `cargo test --verbose` on Linux, macOS, and Windows. - **Test**: `cargo test --verbose` on Linux, macOS, and Windows.
- **End-to-end**: builds soulfind from source (LDC + `dub build :server`), - **End-to-end**: builds soulfind from source (LDC + `dub build :server`),
points `SOULFIND_BIN` at it, and runs both the library and CLI e2e suites points `SOULFIND_BIN` at it, and runs all four e2e suites with
with `SOULSEEK_E2E_REQUIRED=1`, so a missing server fails instead of skipping. `SOULSEEK_E2E_REQUIRED=1`, so a missing server fails instead of skipping.
## Contributing ## Contributing
+6
View File
@@ -14,3 +14,9 @@ pub(crate) mod port_mapping;
pub mod remote; pub mod remote;
pub mod run; pub mod run;
pub(crate) mod ui; pub(crate) mod ui;
/// The interactive window, reachable so an integration test can drive it
/// with key events, advance it a frame at a time and read the screen back.
/// Hidden from the documented API: it is a harness door, not a contract.
#[doc(hidden)]
pub use ui::MainTui;
+11 -7
View File
@@ -14,7 +14,8 @@ use ratatui::widgets::TableState;
const NAME_SCROLL_STEP: isize = 8; const NAME_SCROLL_STEP: isize = 8;
impl MainTui { impl MainTui {
pub(super) fn handle_key_event(&mut self, key: KeyEvent) { /// One key press, routed to whatever owns the keyboard right now.
pub fn handle_key_event(&mut self, key: KeyEvent) {
// Command bar takes priority // Command bar takes priority
if self.state.command_bar_active { if self.state.command_bar_active {
return self.handle_command_bar_input(key); return self.handle_command_bar_input(key);
@@ -400,11 +401,10 @@ impl MainTui {
if self.navigate_focused_list(key) || self.scroll_focused_name(key) { if self.navigate_focused_list(key) || self.scroll_focused_name(key) {
return; return;
} }
let selected = self.state.searches_table_state.selected();
match key.code { match key.code {
KeyCode::Enter => { KeyCode::Enter => {
if let Some(selected) = if let Some(selected) = selected {
self.state.searches_table_state.selected()
{
self.state.selected_search_index = Some(selected); self.state.selected_search_index = Some(selected);
if let Some(search) = self.state.searches.get(selected) { if let Some(search) = self.state.searches.get(selected) {
self.state.results_items = search.results.clone(); self.state.results_items = search.results.clone();
@@ -420,12 +420,16 @@ impl MainTui {
} }
} }
KeyCode::Char('d') => { KeyCode::Char('d') => {
if let Some(selected) = if let Some(selected) = selected {
self.state.searches_table_state.selected()
{
self.remove_search_at_index(selected); self.remove_search_at_index(selected);
} }
} }
// `s` asks for a query; `S` reuses the highlighted one.
KeyCode::Char('S') => {
if let Some(selected) = selected {
self.rerun_search_at_index(selected);
}
}
KeyCode::Char('C') => { KeyCode::Char('C') => {
self.clear_all_searches(); self.clear_all_searches();
} }
+143 -23
View File
@@ -196,31 +196,30 @@ impl MainTui {
result result
} }
/// What the window holds, for a test to read back after driving it.
#[must_use]
pub const fn state(&self) -> &AppState {
&self.state
}
/// One frame's worth of catching up with the session: everything the
/// event loop does between drawing and reading the keyboard.
pub fn poll_session(&mut self) {
self.update_search_results();
self.update_downloads();
self.poll_private_messages();
self.poll_browse_result();
self.poll_room_events();
self.state.uploads = self.client.uploads();
self.spinner_state = (self.spinner_state + 1) % 10;
self.save_persisted_state();
}
fn run_event_loop(&mut self, terminal: &mut DefaultTerminal) -> Result<()> { fn run_event_loop(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
while !self.state.should_exit { while !self.state.should_exit {
terminal.draw(|frame| self.render(frame))?; terminal.draw(|frame| self.render(frame))?;
// Poll for search results updates self.poll_session();
self.update_search_results();
// Poll for download updates
self.update_downloads();
// Poll for incoming private messages
self.poll_private_messages();
// Poll for a browse (shared-file listing) response
self.poll_browse_result();
// Poll for chat-room events
self.poll_room_events();
// Refresh the uploads we are serving to peers
self.state.uploads = self.client.uploads();
self.spinner_state = (self.spinner_state + 1) % 10;
self.save_persisted_state();
// Drain every queued input event before the next draw: key // Drain every queued input event before the next draw: key
// autorepeat outpaces the frame time, and handling one event per // autorepeat outpaces the frame time, and handling one event per
@@ -279,6 +278,8 @@ mod tests {
searches: std::sync::Mutex<Vec<crate::api::SessionSearch>>, searches: std::sync::Mutex<Vec<crate::api::SessionSearch>>,
/// What `try_get_search_results` hands back to the window. /// What `try_get_search_results` hands back to the window.
results: std::sync::Mutex<Vec<soulseek_rs::SearchResult>>, results: std::sync::Mutex<Vec<soulseek_rs::SearchResult>>,
/// Every query a window put on the wire, in order.
searched: std::sync::Mutex<Vec<String>>,
/// How many times a window asked for the search list. /// How many times a window asked for the search list.
all_searches_calls: std::sync::atomic::AtomicUsize, all_searches_calls: std::sync::atomic::AtomicUsize,
/// How many times a window pulled a full result set across. Against a /// How many times a window pulled a full result set across. Against a
@@ -347,10 +348,14 @@ mod tests {
} }
fn search_with_cancel( fn search_with_cancel(
&self, &self,
_query: &str, query: &str,
_timeout: Duration, _timeout: Duration,
_cancel: Option<Arc<std::sync::atomic::AtomicBool>>, _cancel: Option<Arc<std::sync::atomic::AtomicBool>>,
) -> soulseek_rs::Result<Vec<soulseek_rs::SearchResult>> { ) -> soulseek_rs::Result<Vec<soulseek_rs::SearchResult>> {
self.searched
.lock()
.expect("not poisoned")
.push(query.to_string());
Ok(Vec::new()) Ok(Vec::new())
} }
fn get_search_results( fn get_search_results(
@@ -1954,11 +1959,15 @@ mod tests {
} }
#[test] #[test]
fn the_searches_pane_lists_its_clear_all_key() { fn the_searches_pane_lists_its_clear_all_and_rerun_keys() {
let mut tui = furnished_tui(); let mut tui = furnished_tui();
tui.state.focused_pane = FocusedPane::Searches; tui.state.focused_pane = FocusedPane::Searches;
let screen = screen_sized(&mut tui, 300, 40); let screen = screen_sized(&mut tui, 300, 40);
assert!(screen.contains("[C → clear all]"), "{screen}"); assert!(screen.contains("[C → clear all]"), "{screen}");
assert!(screen.contains("[S → search again]"), "{screen}");
press(&mut tui, KeyCode::Char('?'));
let screen = screen_of(&mut tui);
assert!(screen.contains("run the search again"), "{screen}");
} }
#[test] #[test]
@@ -2051,4 +2060,115 @@ mod tests {
let screen = screen_of(&mut tui); let screen = screen_of(&mut tui);
assert!(!screen.contains("@@x/Music/Album"), "{screen}"); assert!(!screen.contains("@@x/Music/Album"), "{screen}");
} }
/// The queries the window has sent so far, once at least `count` of them
/// are out: a search goes to the session from a worker thread, so a test
/// has to give it a moment.
fn searched(session: &TalkativeSession, count: usize) -> Vec<String> {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let seen = session.searched.lock().expect("not poisoned").clone();
if seen.len() >= count || std::time::Instant::now() > deadline {
return seen;
}
std::thread::sleep(Duration::from_millis(10));
}
}
/// A window over last session's search list: the queries came back from
/// disk, with nothing under them.
fn restored(session: Arc<TalkativeSession>, queries: &[&str]) -> MainTui {
let mut tui = attach(session);
let queries: Vec<String> =
queries.iter().map(ToString::to_string).collect();
restore_searches(&mut tui.state, &queries);
tui.state.focused_pane = FocusedPane::Searches;
tui
}
#[test]
fn shift_s_runs_the_highlighted_search_again_in_its_place() {
// The list survives a restart, but a query on it was only ever a
// reminder: seeing what the network has now meant typing it again.
let session = Arc::new(TalkativeSession::default());
let mut tui =
restored(session.clone(), &["boards of canada", "aphex twin"]);
tui.state.searches_table_state.select(Some(1));
press(&mut tui, KeyCode::Char('S'));
assert_eq!(
searched(&session, 1),
vec!["aphex twin".to_string()],
"the highlighted query went out again, untyped"
);
let queries: Vec<&str> = tui
.state
.searches
.iter()
.map(|s| s.query.as_str())
.collect();
assert_eq!(
queries,
["boards of canada", "aphex twin"],
"it keeps its row: no duplicate, nothing moved"
);
assert_eq!(tui.state.searches[1].status, SearchStatus::Active);
assert_eq!(tui.state.selected_search_index, Some(1));
assert_eq!(
tui.state.focused_pane,
FocusedPane::Results,
"and the window turns to where the results will land"
);
assert!(tui.state.results_items.is_empty());
}
#[test]
fn shift_s_stops_a_run_still_collecting_before_starting_over() {
let mut tui = furnished_tui();
tui.state.searches[0].status = SearchStatus::Active;
tui.state.searches[0].start_time = std::time::Instant::now()
.checked_sub(Duration::from_secs(2))
.expect("two seconds ago");
let previous = tui.state.searches[0].cancel_flag.clone();
tui.state.focused_pane = FocusedPane::Searches;
tui.state.searches_table_state.select(Some(0));
press(&mut tui, KeyCode::Char('S'));
assert!(
previous.load(std::sync::atomic::Ordering::Relaxed),
"the worker that was collecting is told to stop"
);
let fresh = &tui.state.searches[0];
assert!(!fresh.cancel_flag.load(std::sync::atomic::Ordering::Relaxed));
assert!(fresh.results.is_empty(), "stale rows do not linger");
assert_eq!(tui.state.searches.len(), 1);
}
#[test]
fn shift_s_is_only_a_key_over_the_searches_list() {
let session = Arc::new(TalkativeSession::default());
let mut tui = restored(session.clone(), &["aphex twin"]);
// Over Results the same key means nothing, so a stray shift does not
// throw a finished search away.
tui.state.focused_pane = FocusedPane::Results;
press(&mut tui, KeyCode::Char('S'));
assert_eq!(tui.state.searches[0].status, SearchStatus::Completed);
std::thread::sleep(Duration::from_millis(50));
assert!(searched(&session, 0).is_empty(), "nothing went out");
}
#[test]
fn a_held_shift_s_is_one_search_not_one_per_repeat() {
let session = Arc::new(TalkativeSession::default());
let mut tui = restored(session.clone(), &["aphex twin"]);
press(&mut tui, KeyCode::Char('S'));
press(&mut tui, KeyCode::Char('1'));
press(&mut tui, KeyCode::Char('S'));
assert_eq!(searched(&session, 1).len(), 1);
std::thread::sleep(Duration::from_millis(50));
assert_eq!(searched(&session, 1).len(), 1, "the repeat is ignored");
}
} }
+4 -1
View File
@@ -63,6 +63,7 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
"Searches", "Searches",
&[ &[
("Enter", "show its results"), ("Enter", "show its results"),
("S", "run the search again"),
("d", "remove the search"), ("d", "remove the search"),
("C", "clear every search"), ("C", "clear every search"),
("c", "chat rooms"), ("c", "chat rooms"),
@@ -126,7 +127,8 @@ fn filter_keys(
} }
impl MainTui { impl MainTui {
pub(super) fn render(&mut self, frame: &mut Frame) { /// Draw the whole window.
pub fn render(&mut self, frame: &mut Frame) {
let shortcuts = self.shortcut_rows(frame.area().width); let shortcuts = self.shortcut_rows(frame.area().width);
let mut constraints = vec![ let mut constraints = vec![
Constraint::Length(3), // Status bar Constraint::Length(3), // Status bar
@@ -631,6 +633,7 @@ impl MainTui {
let mut keys = match self.state.focused_pane { let mut keys = match self.state.focused_pane {
FocusedPane::Searches => vec![ FocusedPane::Searches => vec![
("s", "search"), ("s", "search"),
("S", "search again"),
("Enter", "results"), ("Enter", "results"),
("d", "remove"), ("d", "remove"),
("C", "clear all"), ("C", "clear all"),
+54 -35
View File
@@ -5,11 +5,18 @@ use crate::models::{
}; };
use chrono::Local; use chrono::Local;
use std::{ use std::{
sync::{Arc, atomic::AtomicBool}, sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread, thread,
time::Instant, time::Instant,
}; };
/// How long after a search went out a repeat of `S` counts as the same
/// press: key autorepeat is faster than anyone means to search.
const RERUN_HOLD: std::time::Duration = std::time::Duration::from_secs(1);
impl MainTui { impl MainTui {
pub(super) fn remove_search_at_index(&mut self, index: usize) { pub(super) fn remove_search_at_index(&mut self, index: usize) {
if index >= self.state.searches.len() { if index >= self.state.searches.len() {
@@ -18,9 +25,7 @@ impl MainTui {
// Cancel the search if it's active // Cancel the search if it's active
if let Some(search) = self.state.searches.get(index) { if let Some(search) = self.state.searches.get(index) {
search search.cancel_flag.store(true, Ordering::Relaxed);
.cancel_flag
.store(true, std::sync::atomic::Ordering::Relaxed);
} }
// Check if we're removing the currently active search // Check if we're removing the currently active search
@@ -66,9 +71,7 @@ impl MainTui {
pub(super) fn clear_all_searches(&mut self) { pub(super) fn clear_all_searches(&mut self) {
// Cancel all active searches // Cancel all active searches
for search in &self.state.searches { for search in &self.state.searches {
search search.cancel_flag.store(true, Ordering::Relaxed);
.cancel_flag
.store(true, std::sync::atomic::Ordering::Relaxed);
} }
// And drop them from the session, or the next sync brings every one // And drop them from the session, or the next sync brings every one
@@ -205,6 +208,34 @@ impl MainTui {
} }
pub(super) fn start_search(&mut self, query: String) { pub(super) fn start_search(&mut self, query: String) {
let entry = self.launch_search(query);
self.state.searches.push(entry);
self.show_search(self.state.searches.len() - 1);
}
/// Run the search at `index` again, in its row: a restored query comes
/// back with nothing under it, and this saves typing it out. A run still
/// collecting is stopped first, or two threads would fill one set.
pub(super) fn rerun_search_at_index(&mut self, index: usize) {
let Some(previous) = self.state.searches.get(index) else {
return;
};
// A held key repeats, and every repeat would put another search on
// the wire: a run that only just went out is left to run.
if previous.status == SearchStatus::Active
&& previous.start_time.elapsed() < RERUN_HOLD
{
return;
}
previous.cancel_flag.store(true, Ordering::Relaxed);
self.state.searches[index] = self.launch_search(previous.query.clone());
self.show_search(index);
}
/// Put `query` on the wire and hand back the entry that tracks it. The
/// session keys results by query, so a repeat starts its set afresh, and
/// the entry's empty results match that.
fn launch_search(&self, query: String) -> SearchEntry {
let cancel_flag = Arc::new(AtomicBool::new(false)); let cancel_flag = Arc::new(AtomicBool::new(false));
let search_entry = SearchEntry { let search_entry = SearchEntry {
query: query.clone(), query: query.clone(),
@@ -216,41 +247,29 @@ impl MainTui {
cancel_flag: cancel_flag.clone(), cancel_flag: cancel_flag.clone(),
}; };
self.state.searches.push(search_entry);
let search_index = self.state.searches.len() - 1;
self.state.searches_table_state.select(Some(search_index));
// Make this search the active one
self.state.selected_search_index = Some(search_index);
// Initialize results display (empty at first)
self.state.results_items.clear();
self.state.results_filtered_items.clear();
self.state.results_filtered_indices.clear();
self.state.results_selected_indices.clear();
self.state.results_table_state.select(Some(0));
self.state.results_name_offset = 0;
// Switch focus to Results pane, bringing it back if it was hidden
self.state.focus_pane(FocusedPane::Results);
let client = self.client.clone(); let client = self.client.clone();
let timeout = self.search_timeout; let timeout = self.search_timeout;
// Results are polled in update_search_results, not returned here.
thread::spawn(move || { thread::spawn(move || {
match client.search_with_cancel( if let Err(e) =
&query, client.search_with_cancel(&query, timeout, Some(cancel_flag))
timeout, {
Some(cancel_flag.clone()),
) {
Ok(_results) => {
// Results will be polled in update_search_results
}
Err(e) => {
soulseek_rs::warn!("Search failed: {e}"); soulseek_rs::warn!("Search failed: {e}");
} }
}
}); });
search_entry
}
/// Make the search at `index` the one the Results pane shows, starting
/// from an empty display, filter and all, for what is about to arrive.
fn show_search(&mut self, index: usize) {
self.clear_results_pane();
self.state.searches_table_state.select(Some(index));
self.state.selected_search_index = Some(index);
self.state.results_table_state.select(Some(0));
self.state.focus_pane(FocusedPane::Results);
} }
/// Show the searches the session knows about, not just this window's. /// Show the searches the session knows about, not just this window's.
+1 -1
View File
@@ -7,7 +7,7 @@ mod styles;
mod utils; mod utils;
pub use downloads::render_download_stats; pub use downloads::render_download_stats;
pub use main_tui::launch_main_tui; pub use main_tui::{MainTui, launch_main_tui};
pub use paging::*; pub use paging::*;
pub use styles::*; pub use styles::*;
pub use utils::*; pub use utils::*;
+11 -73
View File
@@ -11,8 +11,8 @@
mod common; mod common;
use common::{CLI_ENV_VARS, free_port, soulfind_binary}; use common::{CLI_ENV_VARS, Soulfind, free_port, login, settle};
use soulseek_rs::{Client, ClientSettings, PeerAddress}; use soulseek_rs::Client;
use std::io::Write; use std::io::Write;
use std::net::{TcpStream, ToSocketAddrs}; use std::net::{TcpStream, ToSocketAddrs};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -508,8 +508,9 @@ static SERVER_GATE: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct TestServer { struct TestServer {
host: String, host: String,
port: u16, port: u16,
child: Option<Child>, /// The soulfind spawned for this run; `None` against a server named by
db: Option<PathBuf>, /// `SOULSEEK_TEST_SERVER`.
_server: Option<Soulfind>,
_gate: std::sync::MutexGuard<'static, ()>, _gate: std::sync::MutexGuard<'static, ()>,
/// Daemons started for this server, one per (user, session flags). Dropped /// Daemons started for this server, one per (user, session flags). Dropped
/// with the server, which kills them. /// with the server, which kills them.
@@ -640,41 +641,17 @@ impl TestServer {
return Some(Self { return Some(Self {
host: host.to_string(), host: host.to_string(),
port, port,
child: None, _server: None,
db: None,
_gate: gate, _gate: gate,
daemons: std::sync::Mutex::new(std::collections::HashMap::new()), daemons: std::sync::Mutex::new(std::collections::HashMap::new()),
}); });
} }
let bin = soulfind_binary()?; let server = Soulfind::start()?;
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 { Some(Self {
host: "127.0.0.1".to_string(), host: "127.0.0.1".to_string(),
port, port: server.port(),
child: Some(child), _server: Some(server),
db: Some(db),
_gate: gate, _gate: gate,
daemons: std::sync::Mutex::new(std::collections::HashMap::new()), daemons: std::sync::Mutex::new(std::collections::HashMap::new()),
}) })
@@ -777,31 +754,7 @@ impl TestServer {
/// An in-process client, used to stand up the other end of a test. /// An in-process client, used to stand up the other end of a test.
fn client(&self, user: &str, shares: Vec<String>) -> Client { fn client(&self, user: &str, shares: Vec<String>) -> Client {
let port = free_port().expect("peer port"); login(&self.host, self.port, user, shares)
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);
}
} }
} }
@@ -829,17 +782,7 @@ 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") common::no_server("cli e2e");
.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; return;
} }
} }
@@ -936,11 +879,6 @@ fn split_session_flags(args: &[&str]) -> (Vec<String>, Vec<String>) {
(session, command) (session, command)
} }
/// Wait out the SetWaitPort registrations so peer lookups resolve.
fn settle() {
std::thread::sleep(Duration::from_secs(1));
}
/// Like [`cli`], but keeping a config file so a command that reads or writes /// Like [`cli`], but keeping a config file so a command that reads or writes
/// one (the wishlist) sees it. /// one (the wishlist) sees it.
/// ///
+117
View File
@@ -3,7 +3,11 @@
#![allow(dead_code)] #![allow(dead_code)]
use soulseek_rs::{Client, ClientSettings, PeerAddress};
use std::net::TcpStream;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
/// Every environment variable the binary reads, cleared for each run so a /// Every environment variable the binary reads, cleared for each run so a
/// developer's shell (or a stray `.env`) cannot change what a test observes — /// developer's shell (or a stray `.env`) cannot change what a test observes —
@@ -44,3 +48,116 @@ pub fn free_port() -> Option<u16> {
.ok() .ok()
.map(|addr| addr.port()) .map(|addr| addr.port())
} }
/// A soulfind spawned for one test, on a port nobody else holds, with a
/// throwaway database that goes with it.
pub struct Soulfind {
child: Child,
db: PathBuf,
port: u16,
}
impl Soulfind {
pub fn start() -> Option<Self> {
let port = free_port()?;
let db = std::env::temp_dir().join(format!("soulfind-{port}.db"));
let _ = std::fs::remove_file(&db);
let child = Self::spawn(port, &db)?;
Some(Self { child, db, port })
}
fn spawn(port: u16, db: &Path) -> Option<Child> {
let mut child = Command::new(soulfind_binary()?)
.args(["-d", db.to_str()?, "-p", &port.to_string()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()?;
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
// Whoever answers on the port has to be this child: one that
// lost the port and died would otherwise pass as up.
if child.try_wait().ok().flatten().is_some() {
return None;
}
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
return Some(child);
}
std::thread::sleep(Duration::from_millis(100));
}
let _ = child.kill();
let _ = child.wait();
None
}
pub const fn port(&self) -> u16 {
self.port
}
pub fn address(&self) -> String {
format!("127.0.0.1:{}", self.port)
}
pub fn stop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
/// Bring it back on the same port and database.
pub fn restart(&mut self) -> Option<()> {
self.stop();
self.child = Self::spawn(self.port, &self.db)?;
Some(())
}
/// A client logged in to this server as `user`, sharing `shares`.
pub fn client(&self, user: &str, shares: Vec<String>) -> Client {
login("127.0.0.1", self.port, user, shares)
}
}
impl Drop for Soulfind {
fn drop(&mut self) {
self.stop();
let _ = std::fs::remove_file(&self.db);
}
}
/// An in-process client logged in to the server at `host:port` as `user`,
/// sharing `shares`: the other end of a test.
///
/// The listener stays on: a peer delivers search results and transfers over
/// a connection it opens back to us, so a client without one hears nothing.
pub fn login(host: &str, port: u16, user: &str, shares: Vec<String>) -> Client {
let mut client = Client::with_settings(ClientSettings {
username: user.to_string(),
password: "pw".to_string(),
server_address: PeerAddress::new(host.to_string(), port),
enable_listen: true,
listen_port: free_port().expect("peer 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
}
/// Wait out the SetWaitPort registrations so peer lookups resolve.
pub fn settle() {
std::thread::sleep(Duration::from_secs(1));
}
/// What a suite does without a server: fail when the run said one is
/// required, otherwise say it skipped. The caller returns afterwards.
pub fn no_server(suite: &str) {
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 for the {suite} suite (set SOULFIND_BIN)"
);
println!("{suite} skipped: no soulfind server (set SOULFIND_BIN to run)");
}
+2 -72
View File
@@ -13,7 +13,7 @@
mod common; mod common;
use common::{CLI_ENV_VARS, free_port, soulfind_binary}; use common::{CLI_ENV_VARS, Soulfind, free_port};
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::net::TcpStream; use std::net::TcpStream;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -215,17 +215,7 @@ macro_rules! daemon_or_skip {
match Daemon::start() { match Daemon::start() {
Some(daemon) => daemon, Some(daemon) => daemon,
None => { None => {
let required = std::env::var("SOULSEEK_E2E_REQUIRED") common::no_server("daemon protocol e2e");
.is_ok_and(|v| v != "0" && !v.is_empty());
assert!(
!required,
"SOULSEEK_E2E_REQUIRED is set but no daemon could be \
started (set SOULFIND_BIN)"
);
println!(
"daemon protocol e2e skipped: no soulfind server (set \
SOULFIND_BIN to run)"
);
return; return;
} }
} }
@@ -431,66 +421,6 @@ fn discover_serves_a_contract_whose_parameters_match_what_is_accepted() {
// Fixtures // Fixtures
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
struct Soulfind {
child: Child,
db: PathBuf,
port: u16,
}
impl Soulfind {
fn start() -> Option<Self> {
let port = free_port()?;
let db = std::env::temp_dir().join(format!("soulfind-proto-{port}.db"));
let _ = std::fs::remove_file(&db);
let child = Self::spawn(port, &db)?;
Some(Self { child, db, port })
}
fn spawn(port: u16, db: &Path) -> Option<Child> {
let bin = soulfind_binary()?;
let mut child = Command::new(bin)
.args(["-d", db.to_str()?, "-p", &port.to_string()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()?;
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
return Some(child);
}
std::thread::sleep(Duration::from_millis(100));
}
let _ = child.kill();
let _ = child.wait();
None
}
fn address(&self) -> String {
format!("127.0.0.1:{}", self.port)
}
fn stop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
fn restart(&mut self) -> Option<()> {
self.stop();
self.child = Self::spawn(self.port, &self.db)?;
Some(())
}
}
impl Drop for Soulfind {
fn drop(&mut self) {
self.stop();
let _ = std::fs::remove_file(&self.db);
}
}
fn wait_until(timeout: Duration, mut f: impl FnMut() -> bool) -> bool { fn wait_until(timeout: Duration, mut f: impl FnMut() -> bool) -> bool {
let deadline = Instant::now() + timeout; let deadline = Instant::now() + timeout;
while Instant::now() < deadline { while Instant::now() < deadline {
+197
View File
@@ -0,0 +1,197 @@
//! End-to-end tests of the window itself against a real Soulseek server:
//! key presses in, a screen out, with soulfind and a real sharing peer on the
//! other end of the wire.
//!
//! The CLI suite proves the command surface; the unit tests under `ui` prove
//! the window's keys against a stand-in session. What neither covers is the
//! two together — a key that is supposed to put a search on the network and
//! bring its answers back onto the screen. The window is driven in-process
//! through the same handlers the terminal feeds, rendered onto a test
//! backend, and the session is a real client logged in to a real server.
//!
//! Like the rest of the e2e suite this needs a soulfind server and skips
//! without one; `SOULSEEK_E2E_REQUIRED=1` turns that skip into a failure.
mod common;
use common::{Soulfind, settle};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use soulseek_rs::Client;
use soulseek_rs_tui::MainTui;
use soulseek_rs_tui::models::SearchStatus;
use soulseek_rs_tui::persist::state::StateStore;
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
/// How long a search collects for. Short, because every run here has to wait
/// it out; long enough for a peer on loopback to answer many times over.
const SEARCH_WINDOW: Duration = Duration::from_secs(3);
macro_rules! server_or_skip {
() => {
match Soulfind::start() {
Some(server) => server,
None => {
common::no_server("tui e2e");
return;
}
}
};
}
/// The window under test: the real `MainTui` over a real session, drawn onto
/// a buffer instead of a terminal.
struct Window {
tui: MainTui,
terminal: Terminal<TestBackend>,
}
impl Window {
/// Open the window as the terminal would on start-up, over `state` — the
/// directory a previous run left its search list in.
fn open(client: Arc<Client>, state: &Path, downloads: &Path) -> Self {
let tui = MainTui::new(
client,
downloads.display().to_string(),
SEARCH_WINDOW,
Some(StateStore::new(state.to_path_buf())),
None,
);
let terminal = Terminal::new(TestBackend::new(160, 40))
.expect("a test backend always opens");
Self { tui, terminal }
}
fn press(&mut self, code: KeyCode) {
self.tui
.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE));
}
/// One turn of the event loop: catch up with the session, then draw.
/// Returns the screen as the user would see it.
fn frame(&mut self) -> String {
self.tui.poll_session();
self.terminal
.draw(|frame| self.tui.render(frame))
.expect("draw");
self.terminal.backend().to_string()
}
/// Keep the window running until `done` says so, and hand back the
/// screen it stopped on. Past `timeout` the test fails showing the last
/// screen, what the user would have been looking at, under `what`.
fn run_until(
&mut self,
timeout: Duration,
what: &str,
mut done: impl FnMut(&MainTui, &str) -> bool,
) -> String {
let deadline = Instant::now() + timeout;
loop {
let screen = self.frame();
if done(&self.tui, &screen) {
return screen;
}
assert!(Instant::now() <= deadline, "{what}:\n{screen}");
std::thread::sleep(Duration::from_millis(100));
}
}
/// File names in the Results pane right now.
fn result_names(&self) -> Vec<String> {
self.tui
.state()
.results_items
.iter()
.map(|file| file.filename.clone())
.collect()
}
}
/// Whether the file the sharer offers has reached the Results pane, on
/// screen as well as in the state behind it.
fn shows_the_shared_file(tui: &MainTui, screen: &str) -> bool {
tui.state()
.results_items
.iter()
.any(|file| file.filename.contains("tui_probe_rerun"))
&& screen.contains("tui_probe_rerun")
}
/// Whether the one search has run its window out.
fn finished(tui: &MainTui, _: &str) -> bool {
tui.state().searches[0].status == SearchStatus::Completed
}
#[test]
fn shift_s_runs_a_restored_query_again_and_the_answers_land_on_screen() {
let server = server_or_skip!();
// The other end of the wire: a peer sharing one file with a name only
// this test searches for.
let share = tempfile::tempdir().expect("share dir");
std::fs::write(share.path().join("tui_probe_rerun_vexq.bin"), [7u8; 64])
.expect("share file");
let _sharer = server
.client("tui_e2e_sharer", vec![share.path().display().to_string()]);
settle();
// Last time this user ran the window they searched for "vexq" and quit.
// The list came back with them; the results did not.
let state = tempfile::tempdir().expect("state dir");
let downloads = tempfile::tempdir().expect("download dir");
StateStore::new(state.path().to_path_buf())
.save_search_queries(&["vexq".to_string()])
.expect("a previous run's search list");
let seeker = Arc::new(server.client("tui_e2e_seeker", Vec::new()));
let mut window = Window::open(seeker, state.path(), downloads.path());
let screen = window.frame();
assert!(
screen.contains("vexq"),
"the old query is listed:\n{screen}"
);
// One key, no typing: the window opens on the list with the query
// highlighted.
window.press(KeyCode::Char('S'));
window.run_until(
Duration::from_secs(15),
"the sharer's file should reach the screen",
shows_the_shared_file,
);
// The search runs its window out and settles, keeping what it found.
let screen = window.run_until(
SEARCH_WINDOW + Duration::from_secs(5),
"the search should finish",
finished,
);
assert!(shows_the_shared_file(&window.tui, &screen));
let first_run = window.result_names();
// Again, now that the query has been on the wire once in this session:
// the same peer has to answer the same searcher a second time, and the
// window has to start the display over rather than stack the answers.
window.press(KeyCode::Char('1'));
window.press(KeyCode::Char('S'));
window.run_until(
Duration::from_secs(15),
"the second run should be answered too",
shows_the_shared_file,
);
window.run_until(
SEARCH_WINDOW + Duration::from_secs(5),
"the second run should finish",
finished,
);
assert_eq!(
window.result_names(),
first_run,
"the same answer, once, not the old set with the new one on top"
);
assert_eq!(window.tui.state().searches.len(), 1, "still one row");
}
+1
View File
@@ -17,6 +17,7 @@ import { LINKS } from '@/lib/links'
const GLOBAL_KEYS: readonly (readonly [string, string])[] = [ const GLOBAL_KEYS: readonly (readonly [string, string])[] = [
['s', 'search the network'], ['s', 'search the network'],
['S', 'run the highlighted search again'],
['Space', 'select a result'], ['Space', 'select a result'],
['Enter', 'download / send'], ['Enter', 'download / send'],
['b', "browse the owner's files"], ['b', "browse the owner's files"],