From 73976cbe5187a0f41c48fb2f0e4992ddc82d8605 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 10 Feb 2026 10:44:47 +0000 Subject: [PATCH] feat(test): add e2e browser test helpers BrowserSession wraps headless Chrome via thirtyfour. Auth helper injects session cookies to bypass WebAuthn. Wait helpers handle Datastar's async DOM updates (visibility, text, URL). Form helpers find visible elements to avoid hidden duplicates on tabbed pages. Chromedriver is auto-spawned on first use via a dedicated parked thread with PR_SET_PDEATHSIG so the kernel kills it when the test binary exits. --- tests/e2e/helpers/auth.rs | 21 ++++++ tests/e2e/helpers/browser.rs | 42 +++++++++++ tests/e2e/helpers/chromedriver.rs | 112 ++++++++++++++++++++++++++++++ tests/e2e/helpers/forms.rs | 95 +++++++++++++++++++++++++ tests/e2e/helpers/mod.rs | 9 +++ tests/e2e/helpers/wait.rs | 89 ++++++++++++++++++++++++ tests/e2e/main.rs | 9 +++ 7 files changed, 377 insertions(+) create mode 100644 tests/e2e/helpers/auth.rs create mode 100644 tests/e2e/helpers/browser.rs create mode 100644 tests/e2e/helpers/chromedriver.rs create mode 100644 tests/e2e/helpers/forms.rs create mode 100644 tests/e2e/helpers/mod.rs create mode 100644 tests/e2e/helpers/wait.rs create mode 100644 tests/e2e/main.rs diff --git a/tests/e2e/helpers/auth.rs b/tests/e2e/helpers/auth.rs new file mode 100644 index 0000000..969f20c --- /dev/null +++ b/tests/e2e/helpers/auth.rs @@ -0,0 +1,21 @@ +use thirtyfour::prelude::*; + +use super::browser::BrowserSession; +use super::server_helpers::{TestApp, create_session}; + +/// Authenticate the browser session by injecting a session cookie. +/// +/// WebAuthn login can't be automated in headless Chrome, so we create a valid +/// session directly in the database and set the cookie in the browser. +pub async fn authenticate_browser(session: &BrowserSession, app: &TestApp) -> WebDriverResult<()> { + // Must visit the domain first before setting cookies + session.goto("/login").await?; + + let session_token = create_session(app).await; + + let mut cookie = Cookie::new("brewlog_session", &session_token); + cookie.set_path("/"); + session.driver.add_cookie(cookie).await?; + + Ok(()) +} diff --git a/tests/e2e/helpers/browser.rs b/tests/e2e/helpers/browser.rs new file mode 100644 index 0000000..e16a2fc --- /dev/null +++ b/tests/e2e/helpers/browser.rs @@ -0,0 +1,42 @@ +use std::time::Duration; + +use thirtyfour::prelude::*; + +pub struct BrowserSession { + pub driver: WebDriver, + pub base_url: String, +} + +impl BrowserSession { + pub async fn new(base_url: &str) -> WebDriverResult { + let port = super::chromedriver::ensure_chromedriver(); + let chromedriver_url = format!("http://localhost:{port}"); + + let mut caps = DesiredCapabilities::chrome(); + caps.set_headless()?; + caps.add_arg("--no-sandbox")?; + caps.add_arg("--disable-gpu")?; + caps.add_arg("--disable-dev-shm-usage")?; + caps.add_arg("--window-size=1280,1024")?; + + let driver = WebDriver::new(&chromedriver_url, caps).await?; + driver + .set_implicit_wait_timeout(Duration::from_secs(2)) + .await?; + + Ok(Self { + driver, + base_url: base_url.to_string(), + }) + } + + pub async fn goto(&self, path: &str) -> WebDriverResult<()> { + self.driver + .goto(&format!("{}{}", self.base_url, path)) + .await + } + + pub async fn quit(self) { + let _ = self.driver.quit().await; + } +} diff --git a/tests/e2e/helpers/chromedriver.rs b/tests/e2e/helpers/chromedriver.rs new file mode 100644 index 0000000..9b4a333 --- /dev/null +++ b/tests/e2e/helpers/chromedriver.rs @@ -0,0 +1,112 @@ +use std::os::unix::process::CommandExt; +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; +use std::time::Duration; + +use once_cell::sync::Lazy; + +unsafe extern "C" { + fn prctl(option: i32, arg2: u64, arg3: u64, arg4: u64, arg5: u64) -> i32; +} +const PR_SET_PDEATHSIG: i32 = 1; +const SIGTERM: u64 = 15; + +struct ChromedriverProcess { + process: Child, + port: u16, +} + +impl Drop for ChromedriverProcess { + fn drop(&mut self) { + let _ = self.process.kill(); + let _ = self.process.wait(); + } +} + +/// Single shared chromedriver process for all e2e tests. +/// Spawned on first use, killed when the test binary exits via `PR_SET_PDEATHSIG`. +static CHROMEDRIVER: Lazy>> = Lazy::new(|| Mutex::new(None)); + +/// Spawn chromedriver on a dedicated thread that stays alive for the lifetime of the process. +/// +/// `PR_SET_PDEATHSIG` fires when the *thread* that called `fork()` exits, not when the +/// process exits. Since `ensure_chromedriver()` may be called from a short-lived tokio +/// worker thread, we spawn chromedriver from a dedicated parked thread to prevent +/// premature cleanup. +fn spawn_chromedriver(port: u16) -> Child { + let (tx, rx) = std::sync::mpsc::sync_channel(1); + + std::thread::spawn(move || { + let mut cmd = Command::new("chromedriver"); + cmd.arg(format!("--port={port}")) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + // SAFETY: prctl(PR_SET_PDEATHSIG, SIGTERM) is async-signal-safe. + // It asks the kernel to send SIGTERM to the child when the parent exits, + // preventing orphaned chromedriver processes. + unsafe { + cmd.pre_exec(|| { + if prctl(PR_SET_PDEATHSIG, SIGTERM, 0, 0, 0) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + + let process = cmd + .spawn() + .expect("Failed to start chromedriver — is it installed?"); + + let _ = tx.send(process); + + // Keep this thread alive so PR_SET_PDEATHSIG (bound to it) doesn't fire + // until the test binary exits and all threads are torn down. + loop { + std::thread::park(); + } + }); + + rx.recv().expect("Chromedriver spawner thread panicked") +} + +/// Ensure chromedriver is running and return its port. +/// +/// If `CHROMEDRIVER_URL` is set (e.g. `http://localhost:4444`), assumes an external +/// chromedriver is already running and parses the port from the URL. +/// Otherwise, spawns chromedriver on a random port and waits until it accepts connections. +#[allow(clippy::expect_used, clippy::unwrap_used)] +pub fn ensure_chromedriver() -> u16 { + if let Ok(url) = std::env::var("CHROMEDRIVER_URL") { + return url + .rsplit(':') + .next() + .and_then(|p| p.parse().ok()) + .unwrap_or(9515); + } + + let mut guard = match CHROMEDRIVER.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + + if guard.is_none() { + let port = portpicker::pick_unused_port().expect("no free port"); + eprintln!("Starting chromedriver on port {port}..."); + + let process = spawn_chromedriver(port); + + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while std::time::Instant::now() < deadline { + if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + + eprintln!("Chromedriver ready on port {port}"); + *guard = Some(ChromedriverProcess { process, port }); + } + + guard.as_ref().unwrap().port +} diff --git a/tests/e2e/helpers/forms.rs b/tests/e2e/helpers/forms.rs new file mode 100644 index 0000000..2008f5c --- /dev/null +++ b/tests/e2e/helpers/forms.rs @@ -0,0 +1,95 @@ +use std::time::Duration; + +use thirtyfour::error::no_such_element; +use thirtyfour::prelude::*; + +/// Find the first visible element matching a CSS selector. +/// The `/add` page has duplicate `name` fields across tabbed forms (roaster, roast, etc.) +/// hidden via Datastar `data-show`. This ensures we interact with the active form's fields. +async fn find_visible(driver: &WebDriver, css: &str) -> WebDriverResult { + let elements = driver.find_all(By::Css(css)).await?; + for el in elements { + if el.is_displayed().await.unwrap_or(false) { + return Ok(el); + } + } + Err(no_such_element(format!( + "No visible element found for selector: {css}" + ))) +} + +/// Fill a text input identified by its `name` attribute. +/// Finds the first *visible* match to avoid hidden duplicate fields on tabbed pages. +pub async fn fill_input(driver: &WebDriver, name: &str, value: &str) -> WebDriverResult<()> { + let input = find_visible(driver, &format!("input[name='{name}']")).await?; + input.clear().await?; + input.send_keys(value).await?; + Ok(()) +} + +/// Fill a textarea identified by its `name` attribute. +/// Finds the first *visible* match to avoid hidden duplicate fields on tabbed pages. +pub async fn fill_textarea(driver: &WebDriver, name: &str, value: &str) -> WebDriverResult<()> { + let textarea = find_visible(driver, &format!("textarea[name='{name}']")).await?; + textarea.clear().await?; + textarea.send_keys(value).await?; + Ok(()) +} + +/// Select an option from a native `