From a28a2a1f8c9a4257bfceed95093ebf57d00b0b39 Mon Sep 17 00:00:00 2001 From: Adrian Rivera Date: Fri, 26 Jun 2026 12:14:45 -0700 Subject: [PATCH] feat: add interactive binding cards to serve landing page Render each binding as an interactive card on the `serve` landing page. A card's title links through the server's `/?cmd=` redirect, and a JS-free GET form lets users type arguments: the command travels in a hidden `cmd` field and the visible field carries the user's `args`, which the server combines into `cmd args` (e.g. `threads` + `zuck` -> `threads zuck`). Add a helper in `server/mod.rs` to combine the `cmd` and `args` query parameters, and pass the resolved `display_url` into `BindingCard` so links and form actions use the configured public URL. Add integration tests covering the serve route, the cards feature, and the end-to-end arguments-form redirect. --- src/server/mod.rs | 30 ++++- src/server/web.rs | 87 +++++++++++--- tests/cards_feature_test.rs | 133 ++++++++++++++++++++++ tests/serve_cards.rs | 205 +++++++++++++++++++++++++++++++++ tests/serve_route.rs | 218 ++++++++++++++++++++++++++++++++++++ 5 files changed, 654 insertions(+), 19 deletions(-) create mode 100644 tests/cards_feature_test.rs create mode 100644 tests/serve_cards.rs create mode 100644 tests/serve_route.rs diff --git a/src/server/mod.rs b/src/server/mod.rs index 4703821..ca03a1b 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -24,6 +24,23 @@ use rocket::response::Redirect; #[cfg(feature = "server")] use crate::{BunnylolCommandRegistry, BunnylolConfig, ConfigReloader, History, utils}; +/// Combine the `cmd` query parameter with an optional `args` parameter into the +/// full command string the registry should process. +/// +/// The landing-page cards send the command in a hidden `cmd` field and the +/// user's arguments in a separate `args` field; this stitches them back into +/// `"cmd args"`. Empty/whitespace-only `args` yields the bare command, so +/// existing single-parameter requests (e.g. `/?cmd=gh facebook/bunnylol.rs`, +/// `/?cmd=%s`) are unchanged. Returns `None` when there is no command. +#[cfg(feature = "server")] +fn combine_command_args(cmd: Option<&str>, args: Option<&str>) -> Option { + let cmd = cmd?; + match args { + Some(args) if !args.trim().is_empty() => Some(format!("{} {}", cmd, args.trim())), + _ => Some(cmd.to_string()), + } +} + #[cfg(feature = "server")] mod server_impl { use super::*; @@ -44,27 +61,28 @@ mod server_impl { } } - // http://localhost:8000/?cmd=gh - #[rocket::get("/?")] + // http://localhost:8000/?cmd=gh (optionally &args=... from the card forms) + #[rocket::get("/?&")] pub(super) fn search( cmd: Option<&str>, + args: Option<&str>, config: &State, client_ip: ClientIP, ) -> Result> { let config = config.current(); - match cmd { + match combine_command_args(cmd, args) { Some(cmd_str) => { println!("bunnylol command: {}", cmd_str); - let command = utils::get_command_from_query_string(cmd_str); - let redirect_url = BunnylolCommandRegistry::process_command(command, cmd_str); + let command = utils::get_command_from_query_string(&cmd_str); + let redirect_url = BunnylolCommandRegistry::process_command(command, &cmd_str); println!("redirecting to: {}", redirect_url); // Track command in history if enabled if config.history.enabled && let Some(history) = History::new(&config) - && let Err(e) = history.add(cmd_str, &client_ip.0) + && let Err(e) = history.add(&cmd_str, &client_ip.0) { eprintln!("Warning: Failed to save command to history: {}", e); } diff --git a/src/server/web.rs b/src/server/web.rs index b829f16..ad21862 100644 --- a/src/server/web.rs +++ b/src/server/web.rs @@ -176,6 +176,13 @@ fn collect_user_bindings(config: &BunnylolConfig) -> Vec { #[component] fn BindingCard(binding: BindingData) -> impl IntoView { + // The card opens commands through the server's `/?cmd=` redirect. Links are + // relative to this server, so they work regardless of host/proxy. + let command_url = format!("/?cmd={}", binding.command); + // The argument form is a JS-free GET form. The command travels in a hidden + // `cmd` field (so it can't be edited away); the visible field carries only + // the user's `args`, which the server combines into `cmd args`. + let cmd_value = binding.command.clone(); view! {
impl IntoView { style:transition="transform 0.2s, box-shadow 0.2s" style:border="2px solid var(--border-light)" > -
- {binding.command} -
+ +
+ {binding.command} +
+
impl IntoView { {binding.example}
+ // Argument form: the command is fixed (hidden field); the user types + // only arguments and submits (button or Enter) to open a specific + // target. The `×` is a JS-free reset that clears the args field. +
+ +
+ + +
+ +
} } diff --git a/tests/cards_feature_test.rs b/tests/cards_feature_test.rs new file mode 100644 index 0000000..ae85c82 --- /dev/null +++ b/tests/cards_feature_test.rs @@ -0,0 +1,133 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +//! Integration test for the interactive `serve` cards feature. +//! +//! This is the test the task asks the solution to ship alongside the feature: +//! it exercises the end-to-end behavior of a card's arguments form — typing +//! arguments and submitting opens the combined `cmd args` target through the +//! server's redirect. It spawns the real `bunnylol serve` binary and drives it +//! over HTTP (no browser, no internal symbols). + +use std::fs; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +fn free_port() -> u16 { + TcpListener::bind(("127.0.0.1", 0)) + .expect("bind test port") + .local_addr() + .expect("read test port") + .port() +} + +fn write_config(xdg_dir: &Path, port: u16) { + fs::create_dir_all(xdg_dir.join("bunnylol")).expect("create config dir"); + fs::write( + xdg_dir.join("bunnylol/config.toml"), + format!( + "default_search = \"google\"\n\n[history]\nenabled = false\n\n[server]\nport = {port}\naddress = \"127.0.0.1\"\nlog_level = \"critical\"\n" + ), + ) + .expect("write config"); +} + +struct Server { + child: Child, +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn http_get(port: u16, path: &str) -> std::io::Result { + let mut stream = TcpStream::connect(("127.0.0.1", port))?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + stream.write_all( + format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n").as_bytes(), + )?; + let mut response = String::new(); + stream.read_to_string(&mut response)?; + Ok(response) +} + +fn location(response: &str) -> String { + response + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("location") + .then(|| value.trim().to_string()) + }) + .expect("redirect should include a Location header") +} + +fn start_server() -> (Server, u16, std::path::PathBuf) { + let mut dir = std::env::temp_dir(); + dir.push(format!( + "bunnylol-cards-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let port = free_port(); + write_config(&dir, port); + let child = Command::new(assert_cmd::cargo::cargo_bin!("bunnylol")) + .env("XDG_CONFIG_HOME", &dir) + .args([ + "serve", + "--port", + &port.to_string(), + "--address", + "127.0.0.1", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn bunnylol server"); + let mut server = Server { child }; + for _ in 0..50 { + if let Ok(r) = http_get(port, "/health") + && r.contains("ok") + { + return (server, port, dir); + } + if server.child.try_wait().expect("status").is_some() { + panic!("server exited early"); + } + std::thread::sleep(Duration::from_millis(100)); + } + panic!("server did not become ready"); +} + +#[test] +fn test_card_args_open_combined_target() { + let (server, port, dir) = start_server(); + // Typing "hello" into the `g` card and opening combines into `g hello`. + let resp = http_get(port, "/?cmd=g&args=hello").expect("request"); + assert_eq!(location(&resp), "https://google.com/search?q=hello"); + drop(server); + fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn test_card_open_bare_command() { + let (server, port, dir) = start_server(); + // Clicking the title (no args) opens the bare command. + let resp = http_get(port, "/?cmd=g").expect("request"); + assert_eq!(location(&resp), "https://google.com/search?q="); + drop(server); + fs::remove_dir_all(&dir).ok(); +} diff --git a/tests/serve_cards.rs b/tests/serve_cards.rs new file mode 100644 index 0000000..36746ea --- /dev/null +++ b/tests/serve_cards.rs @@ -0,0 +1,205 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +//! Black-box tests for the interactive `serve` landing-page cards. They render +//! the page to an HTML string via the public `render_landing_page_html` and +//! assert on observable markup — no browser, no running server. URLs are checked +//! by their `?cmd=` target, accepting either relative or absolute hrefs +//! (an implementation choice the feature does not constrain). + +#![cfg(feature = "server")] + +use bunnylol::config::UserBinding; +use bunnylol::server::web::render_landing_page_html; +use bunnylol::{BunnylolCommandRegistry, BunnylolConfig}; + +// --------------------------------------------------------------------------- +// FAIL_TO_PASS — new interactive behavior (must fail at base, pass after fix) +// --------------------------------------------------------------------------- + +/// Each command card's title must be a link that opens the bare command via the +/// server's `/?cmd=` redirect. +#[test] +fn test_builtin_card_links_to_command() { + let html = render_landing_page_html(&BunnylolConfig::default()); + assert!( + html.contains(r#"?cmd=threads""#), + "threads card title should link to ?cmd=threads" + ); +} + +/// Every command card must be interactive — not just one. There must be at least +/// as many `?cmd=` link targets as there are registered commands. +#[test] +fn test_all_builtin_commands_are_linked() { + let html = render_landing_page_html(&BunnylolConfig::default()); + let command_count = BunnylolCommandRegistry::get_all_commands().len(); + let link_count = html.matches("?cmd=").count(); + assert!( + link_count >= command_count, + "expected >= {command_count} command links, found {link_count}" + ); +} + +/// Each card carries a JS-free GET form: the command rides in a hidden `cmd` +/// field (not editable), the visible field is args-only with an +/// "arguments (optional)" placeholder, a submit button opens it (so Enter works +/// too), and a reset control clears the args. +#[test] +fn test_builtin_card_has_arg_form() { + let html = render_landing_page_html(&BunnylolConfig::default()); + // A real GET form (the command must travel to the server, not be combined in + // client-side JavaScript). A GET form may omit the default `method`, so we do + // not pin `method="get"` literally — only that it is not a POST form. + assert!(html.contains(""); + assert!( + !html.contains(r#"method="post""#), + "card form must submit via GET, not POST" + ); + // Command in a hidden field carrying its value. + assert!( + html.contains(r#"type="hidden""#), + "command should be carried in a hidden field" + ); + assert!( + html.contains(r#"name="cmd""#), + "hidden command field must be named cmd" + ); + assert!( + html.contains(r#"value="threads""#), + "hidden field must carry the command value (threads)" + ); + // Args-only visible field, identified by its mandated placeholder (its + // name/param is an implementation choice, so we don't pin it here — the + // route test discovers it from the form). + assert!( + html.contains(r#"placeholder="arguments (optional)""#), + "the args input must show an 'arguments (optional)' placeholder" + ); + // Submit (Enter opens) and reset (clear) controls. + assert!( + html.contains(r#"type="submit""#), + "an open/submit button must exist so Enter submits the form" + ); + assert!( + html.contains(r#"type="reset""#), + "a clear (reset) control must be present to empty the args field" + ); + // The command must NOT be pre-filled into an editable field. + assert!( + !html.contains(r#"value="threads ""#), + "command must not be seeded into an editable input" + ); +} + +/// The solution must ship its own test for the feature (a repo convention). We +/// look for an agent-authored test file under `tests/` — excluding the base +/// test files and these gold files — that contains a real `#[test]` with an +/// assertion and exercises the cards/args feature surface. This is a static +/// check: it never runs the agent's test. +#[test] +fn test_agent_added_feature_test() { + use std::fs; + // Test files that exist at the base commit or are installed by the gold + // test_patch — these do NOT count as the solution's own feature test. + let excluded = [ + "serve_cards.rs", + "serve_route.rs", + "server_e2e.rs", + "cli_integration.rs", + ]; + let mut found = false; + for entry in fs::read_dir("tests").expect("a tests/ directory") { + let path = entry.expect("dir entry").path(); + let name = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + if !name.ends_with(".rs") || excluded.contains(&name.as_str()) { + continue; + } + let src = fs::read_to_string(&path).unwrap_or_default(); + let has_test = src.contains("#[test]"); + let has_assert = src.contains("assert"); + let exercises_feature = src.contains("render_landing_page_html") + || src.contains("?cmd=") + || src.contains("/?cmd") + || src.contains("serve"); + if has_test && has_assert && exercises_feature { + found = true; + break; + } + } + assert!( + found, + "the solution must add an integration test (e.g. tests/cards_feature_test.rs) with a \ + #[test] that exercises the cards/args feature — none was found" + ); +} + +// --------------------------------------------------------------------------- +// PASS_TO_PASS — regressions that must hold before AND after the fix +// --------------------------------------------------------------------------- + +/// The page still renders for a default config and shows the commands section. +#[test] +fn test_landing_page_renders_with_default_config() { + let html = render_landing_page_html(&BunnylolConfig::default()); + assert!(html.contains("bunnylol"), "page title should be present"); + assert!( + html.contains("Available Commands"), + "commands section heading should be present" + ); +} + +/// The page still lists built-in command names. +#[test] +fn test_landing_page_lists_command_names() { + let html = render_landing_page_html(&BunnylolConfig::default()); + assert!(html.contains("threads"), "threads card should be listed"); + assert!(html.contains("Available Commands")); +} + +/// The existing "set as default search engine" example links are preserved. +#[test] +fn test_existing_example_links_present() { + let mut config = BunnylolConfig::default(); + config.server.server_display_url = Some("https://bunny.example.com".to_string()); + let html = render_landing_page_html(&config); + assert!( + html.contains("?cmd=%s"), + "the %s search-engine example URL should still be shown" + ); + assert!( + html.contains("cmd=gh facebook/bunnylol.rs"), + "the gh example URL should still be shown" + ); +} + +/// User-binding cards still render (guards the user_bindings render path). +#[test] +fn test_user_binding_card_still_renders() { + let mut config = BunnylolConfig::default(); + config.user_bindings.insert( + "myalias-p2p".to_string(), + UserBinding::Url { + url: "https://example.com/myalias-p2p".to_string(), + description: None, + override_builtin: false, + }, + ); + let html = render_landing_page_html(&config); + assert!( + html.contains("myalias-p2p"), + "user binding card should be rendered" + ); + assert!( + html.contains("User Bindings"), + "user bindings section heading" + ); +} diff --git a/tests/serve_route.rs b/tests/serve_route.rs new file mode 100644 index 0000000..18839ab --- /dev/null +++ b/tests/serve_route.rs @@ -0,0 +1,218 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +//! Black-box tests for the `serve` redirect route. They spawn the real +//! `bunnylol serve` binary and drive it over HTTP, asserting on the redirect +//! `Location`. This is the end-to-end contract of the card forms: the command +//! rides in `cmd`, the user's input rides in `args`, and the server combines +//! them into `"cmd args"` before resolving the target. No internal symbols are +//! referenced, so any valid implementation passes. + +use std::fs; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +fn unique_test_dir(test_name: &str) -> PathBuf { + let mut dir = std::env::temp_dir(); + dir.push(format!( + "bunnylol-route-{}-{}-{}", + test_name, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + dir +} + +fn write_config(xdg_dir: &Path, port: u16) { + fs::create_dir_all(xdg_dir.join("bunnylol")).expect("create config dir"); + fs::write( + xdg_dir.join("bunnylol/config.toml"), + format!( + r#"default_search = "google" + +[history] +enabled = false + +[server] +port = {port} +address = "127.0.0.1" +log_level = "critical" +"# + ), + ) + .expect("write config"); +} + +fn free_port() -> u16 { + TcpListener::bind(("127.0.0.1", 0)) + .expect("bind test port") + .local_addr() + .expect("read test port") + .port() +} + +struct ServerProcess { + child: Child, +} + +impl Drop for ServerProcess { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn spawn_server(xdg_dir: &Path, port: u16) -> ServerProcess { + let child = Command::new(assert_cmd::cargo::cargo_bin!("bunnylol")) + .env("XDG_CONFIG_HOME", xdg_dir) + .arg("serve") + .arg("--port") + .arg(port.to_string()) + .arg("--address") + .arg("127.0.0.1") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn bunnylol server"); + ServerProcess { child } +} + +fn http_get(port: u16, path: &str) -> std::io::Result { + let mut stream = TcpStream::connect(("127.0.0.1", port))?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + stream.write_all( + format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n").as_bytes(), + )?; + let mut response = String::new(); + stream.read_to_string(&mut response)?; + Ok(response) +} + +fn wait_for_server(server: &mut ServerProcess, port: u16) { + for _ in 0..50 { + if let Some(status) = server.child.try_wait().expect("check server status") { + panic!("server exited before becoming ready: {status}"); + } + if let Ok(response) = http_get(port, "/health") + && response.contains("ok") + { + return; + } + std::thread::sleep(Duration::from_millis(100)); + } + panic!("server did not become ready"); +} + +fn redirect_location(response: &str) -> String { + assert!( + response.starts_with("HTTP/1.1 303") || response.starts_with("HTTP/1.1 302"), + "expected redirect response, got:\n{response}" + ); + response + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("location") + .then(|| value.trim().to_string()) + }) + .expect("redirect response should include Location header") +} + +/// Spawn a server, run `f` with its port, and clean up. +fn with_server(test_name: &str, f: F) { + let xdg_dir = unique_test_dir(test_name); + let port = free_port(); + write_config(&xdg_dir, port); + let mut server = spawn_server(&xdg_dir, port); + wait_for_server(&mut server, port); + f(port); + drop(server); + fs::remove_dir_all(&xdg_dir).ok(); +} + +/// Discover the name of the card's arguments field from the rendered landing +/// page, so the test is agnostic to what the field/parameter is called. We find +/// the `` carrying the mandated "arguments (optional)" placeholder and +/// return its `name=""` value. Panics if no such field exists (e.g. at the base +/// commit, where the cards have no form) — which correctly fails these tests +/// until the feature is implemented. +fn discover_args_param(port: u16) -> String { + let body = http_get(port, "/").expect("landing page"); + for tag in body.split("