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.
This commit is contained in:
parent
c2b252824d
commit
73976cbe51
7 changed files with 377 additions and 0 deletions
21
tests/e2e/helpers/auth.rs
Normal file
21
tests/e2e/helpers/auth.rs
Normal file
|
|
@ -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(())
|
||||
}
|
||||
42
tests/e2e/helpers/browser.rs
Normal file
42
tests/e2e/helpers/browser.rs
Normal file
|
|
@ -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<Self> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
112
tests/e2e/helpers/chromedriver.rs
Normal file
112
tests/e2e/helpers/chromedriver.rs
Normal file
|
|
@ -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<Mutex<Option<ChromedriverProcess>>> = 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
|
||||
}
|
||||
95
tests/e2e/helpers/forms.rs
Normal file
95
tests/e2e/helpers/forms.rs
Normal file
|
|
@ -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<WebElement> {
|
||||
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 `<select>` by name and value.
|
||||
/// Finds the first *visible* select to avoid hidden duplicates on tabbed pages.
|
||||
pub async fn select_option(driver: &WebDriver, name: &str, value: &str) -> WebDriverResult<()> {
|
||||
let select = find_visible(driver, &format!("select[name='{name}']")).await?;
|
||||
let option = select
|
||||
.find(By::Css(&format!("option[value='{value}']")))
|
||||
.await?;
|
||||
option.click().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Interact with a `<searchable-select>` web component:
|
||||
/// 1. Find the search input (role=combobox) inside the component
|
||||
/// 2. Type to filter options
|
||||
/// 3. Click the first visible option button
|
||||
pub async fn select_searchable(
|
||||
driver: &WebDriver,
|
||||
name: &str,
|
||||
search_text: &str,
|
||||
) -> WebDriverResult<()> {
|
||||
let component = driver
|
||||
.find(By::Css(&format!("searchable-select[name='{name}']")))
|
||||
.await?;
|
||||
|
||||
let search_input = component.find(By::Css("input[role='combobox']")).await?;
|
||||
search_input.send_keys(search_text).await?;
|
||||
|
||||
// Wait for the dropdown to appear and options to filter
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
|
||||
// Click the first visible option
|
||||
let options = component.find_all(By::Css("button[role='option']")).await?;
|
||||
for option in options {
|
||||
if option.is_displayed().await.unwrap_or(false) {
|
||||
option.click().await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(no_such_element(format!(
|
||||
"No visible option found in searchable-select[name='{name}'] after typing '{search_text}'"
|
||||
)))
|
||||
}
|
||||
|
||||
/// Click the visible submit button on the page.
|
||||
pub async fn submit_visible_form(driver: &WebDriver) -> WebDriverResult<()> {
|
||||
let buttons = driver.find_all(By::Css("button[type='submit']")).await?;
|
||||
for button in buttons {
|
||||
if button.is_displayed().await.unwrap_or(false) {
|
||||
button.click().await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(no_such_element(
|
||||
"No visible submit button found".to_string(),
|
||||
))
|
||||
}
|
||||
9
tests/e2e/helpers/mod.rs
Normal file
9
tests/e2e/helpers/mod.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#[allow(dead_code)]
|
||||
#[path = "../../server/helpers.rs"]
|
||||
pub mod server_helpers;
|
||||
|
||||
pub mod auth;
|
||||
pub mod browser;
|
||||
pub mod chromedriver;
|
||||
pub mod forms;
|
||||
pub mod wait;
|
||||
89
tests/e2e/helpers/wait.rs
Normal file
89
tests/e2e/helpers/wait.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use thirtyfour::prelude::*;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Wait until an element matching the CSS selector is present in the DOM.
|
||||
pub async fn wait_for_element(driver: &WebDriver, selector: &str) -> WebDriverResult<WebElement> {
|
||||
driver
|
||||
.query(By::Css(selector))
|
||||
.wait(DEFAULT_TIMEOUT, POLL_INTERVAL)
|
||||
.first()
|
||||
.await
|
||||
}
|
||||
|
||||
/// Wait until an element is both present AND displayed.
|
||||
/// Needed because Datastar uses `style="display:none"` + `data-show`.
|
||||
pub async fn wait_for_visible(driver: &WebDriver, selector: &str) -> WebDriverResult<WebElement> {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if let Ok(el) = driver.find(By::Css(selector)).await {
|
||||
if el.is_displayed().await.unwrap_or(false) {
|
||||
return Ok(el);
|
||||
}
|
||||
}
|
||||
if start.elapsed() > DEFAULT_TIMEOUT {
|
||||
return Err(WebDriverError::Timeout(format!(
|
||||
"Element '{selector}' not visible within {DEFAULT_TIMEOUT:?}",
|
||||
)));
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait until an element with specific text content appears.
|
||||
pub async fn wait_for_text(
|
||||
driver: &WebDriver,
|
||||
selector: &str,
|
||||
expected_text: &str,
|
||||
) -> WebDriverResult<WebElement> {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if let Ok(el) = driver.find(By::Css(selector)).await {
|
||||
if let Ok(text) = el.text().await {
|
||||
if text.contains(expected_text) {
|
||||
return Ok(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
if start.elapsed() > DEFAULT_TIMEOUT {
|
||||
return Err(WebDriverError::Timeout(format!(
|
||||
"Element '{selector}' with text '{expected_text}' not found within {DEFAULT_TIMEOUT:?}",
|
||||
)));
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait until no element matches the selector.
|
||||
pub async fn wait_for_element_removed(driver: &WebDriver, selector: &str) -> WebDriverResult<()> {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if driver.find(By::Css(selector)).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
if start.elapsed() > DEFAULT_TIMEOUT {
|
||||
return Err(WebDriverError::Timeout(format!(
|
||||
"Element '{selector}' still present after {DEFAULT_TIMEOUT:?}",
|
||||
)));
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait until the current URL contains a specific substring.
|
||||
pub async fn wait_for_url_contains(driver: &WebDriver, substring: &str) -> WebDriverResult<()> {
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < DEFAULT_TIMEOUT {
|
||||
let url = driver.current_url().await?;
|
||||
if url.as_str().contains(substring) {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
Err(WebDriverError::Timeout(format!(
|
||||
"URL did not contain '{substring}' within {DEFAULT_TIMEOUT:?}",
|
||||
)))
|
||||
}
|
||||
9
tests/e2e/main.rs
Normal file
9
tests/e2e/main.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#![allow(dead_code)]
|
||||
|
||||
mod helpers;
|
||||
|
||||
mod brew_tests;
|
||||
mod checkin_tests;
|
||||
mod delete_tests;
|
||||
mod roaster_tests;
|
||||
mod scan_tests;
|
||||
Loading…
Reference in a new issue