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 `