Selenium RS #
Browser automation in Rust uses the thirtyfour crate — an async, ergonomic implementation of the WebDriver protocol. The name “Selenium RS” on this site refers to the Rust browser automation ecosystem in general, with thirtyfour as the main choice thanks to its clean API and native async support. Browser automation serves two main purposes: end-to-end testing (verifying web applications from a real user’s perspective) and web scraping (extracting data from pages that need JavaScript to render content). This article covers setup, all important operations, idiomatic testing patterns, and headless mode considerations for CI/CD.
WebDriver Architecture #
sequenceDiagram
participant R as Rust Program\n(thirtyfour)
participant D as WebDriver\n(ChromeDriver/GeckoDriver)
participant B as Browser\n(Chrome/Firefox)
R->>D: HTTP: POST /session (capabilities)
D->>B: Launch browser
B->>D: Browser ready
D->>R: session_id
R->>D: HTTP: POST /session/{id}/url
D->>B: Navigate to URL
B->>D: Page loaded
R->>D: HTTP: POST /session/{id}/element
D->>B: Find element
B->>D: element_id
R->>D: HTTP: POST /element/{id}/click
D->>B: Click element
B->>D: DoneWebDriver is a W3C standard — you write code once and run it on Chrome, Firefox, Edge, or Safari by just switching the driver.
Prerequisites and Installation #
Before running the code, make sure a WebDriver is installed:
# Chrome — download ChromeDriver matching your Chrome version
# https://chromedriver.chromium.org/downloads
# or use chromedriver-autoinstall
# Firefox — GeckoDriver
# https://github.com/mozilla/geckodriver/releases
# Run the WebDriver in a separate terminal
chromedriver --port=4444
# or
geckodriver --port=4444
[dependencies]
thirtyfour = "0.32"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Connections and Basic Navigation #
use thirtyfour::prelude::*;
#[tokio::main]
async fn main() -> WebDriverResult<()> {
// Setup capabilities
let mut caps = DesiredCapabilities::chrome();
// Headless mode — no browser window (for servers/CI)
caps.set_headless()?;
caps.add_chrome_arg("--no-sandbox")?;
caps.add_chrome_arg("--disable-dev-shm-usage")?;
caps.add_chrome_arg("--window-size=1920,1080")?;
// Connect to a running ChromeDriver
let driver = WebDriver::new("http://localhost:4444", caps).await?;
// Navigate to a URL
driver.goto("https://www.contoh.com").await?;
println!("URL: {}", driver.current_url().await?);
println!("Title: {}", driver.title().await?);
// Navigate back/forward
driver.back().await?;
driver.forward().await?;
// Refresh the page
driver.refresh().await?;
// Get the full page HTML
let source = driver.source().await?;
println!("HTML length: {} characters", source.len());
// Close the browser — IMPORTANT: always close at the end
driver.quit().await?;
Ok(())
}
Finding Elements #
use thirtyfour::prelude::*;
async fn contoh_find(driver: &WebDriver) -> WebDriverResult<()> {
driver.goto("https://www.contoh.com").await?;
// By ID
let elemen = driver.find(By::Id("tombol-utama")).await?;
// By CSS selector — most flexible
let judul = driver.find(By::Css("h1.judul-utama")).await?;
let tombol = driver.find(By::Css("button[type='submit']")).await?;
// By XPath — powerful but verbose
let link = driver.find(By::XPath("//a[@href='/tentang']")).await?;
// By tag name
let semua_h2: Vec<WebElement> = driver.find_all(By::Tag("h2")).await?;
println!("Number of H2s: {}", semua_h2.len());
// By link text
let link_kontak = driver.find(By::LinkText("Hubungi Kami")).await?;
// By partial link text
let link_partial = driver.find(By::PartialLinkText("Tentang")).await?;
// Elements within elements (relative search)
let form = driver.find(By::Id("form-login")).await?;
let input_email = form.find(By::Name("email")).await?;
// Check element existence without an error
let mungkin_ada = driver.find(By::Id("mungkin-tidak-ada")).await;
match mungkin_ada {
Ok(el) => println!("Element exists: {}", el.id().await?.unwrap_or_default()),
Err(_) => println!("Element not found"),
}
Ok(())
}
Interacting with Elements #
use thirtyfour::prelude::*;
async fn contoh_interaksi(driver: &WebDriver) -> WebDriverResult<()> {
driver.goto("https://contoh.com/login").await?;
// Click an element
let tombol = driver.find(By::Css("button.buka-menu")).await?;
tombol.click().await?;
// Input text
let email_input = driver.find(By::Id("email")).await?;
email_input.clear().await?; // clear it first
email_input.send_keys("[email protected]").await?;
let password_input = driver.find(By::Id("password")).await?;
password_input.send_keys("rahasia123").await?;
// Submit the form with Enter
password_input.send_keys(Key::Return).await?;
// or click the submit button
driver.find(By::Css("button[type='submit']")).await?.click().await?;
// Get text from an element
let pesan = driver.find(By::Css(".pesan-sukses")).await?;
println!("Message: {}", pesan.text().await?);
// Get an attribute
let link = driver.find(By::Css("a.link-utama")).await?;
let href = link.attr("href").await?.unwrap_or_default();
println!("Href: {}", href);
// Get an input value
let nilai_input: String = driver
.find(By::Id("nama-depan"))
.await?
.attr("value")
.await?
.unwrap_or_default();
// Check element status
let tombol_hapus = driver.find(By::Id("tombol-hapus")).await?;
println!("Enabled: {}", tombol_hapus.is_enabled().await?);
println!("Displayed: {}", tombol_hapus.is_displayed().await?);
println!("Selected: {}", tombol_hapus.is_selected().await?);
Ok(())
}
Dropdowns, Checkboxes, and Selects #
use thirtyfour::prelude::*;
async fn contoh_form_lanjutan(driver: &WebDriver) -> WebDriverResult<()> {
driver.goto("https://contoh.com/form").await?;
// Checkbox — check the status and toggle
let checkbox = driver.find(By::Id("setuju-syarat")).await?;
if !checkbox.is_selected().await? {
checkbox.click().await?; // check it
}
println!("Checkbox checked: {}", checkbox.is_selected().await?);
// Radio button
let radio_ya = driver.find(By::Css("input[type='radio'][value='ya']")).await?;
radio_ya.click().await?;
// <select> dropdown
let select_el = driver.find(By::Id("pilih-kota")).await?;
// Select by visible text
let select = thirtyfour::extensions::form::SelectElement::new(&select_el).await?;
select.select_by_visible_text("Jakarta").await?;
// Select by value attribute
select.select_by_value("surabaya").await?;
// Select by index
select.select_by_index(2).await?;
// Get the selected option
let opsi_terpilih = select.first_selected_option().await?;
println!("Selected: {}", opsi_terpilih.text().await?);
// File upload
let file_input = driver.find(By::Id("upload-foto")).await?;
file_input.send_keys("/path/to/foto.jpg").await?;
Ok(())
}
Wait Strategies — Waiting for Elements #
This is the most important aspect of stable browser automation. Never use a fixed sleep — use explicit waits:
use thirtyfour::prelude::*;
use std::time::Duration;
async fn contoh_wait(driver: &WebDriver) -> WebDriverResult<()> {
driver.goto("https://contoh.com/async-load").await?;
// ANTI-PATTERN: fixed sleep — fragile and slow
// tokio::time::sleep(Duration::from_secs(3)).await;
// let el = driver.find(By::Id("konten")).await?;
// CORRECT: wait until — wait for a condition to be met
// 10 second timeout, polling every 500ms
let el = driver
.query(By::Id("konten-dimuat"))
.wait(Duration::from_secs(10), Duration::from_millis(500))
.first()
.await?;
// Wait until an element is visible
let loading = driver.find(By::Css(".loading-spinner")).await.ok();
if let Some(spinner) = loading {
// Wait until the spinner disappears
spinner
.wait_until()
.displayed(false)
.await?;
}
// Wait until text changes
let status = driver.find(By::Id("status-proses")).await?;
status
.wait_until()
.condition(Box::new(|el: &WebElement| {
Box::pin(async move {
let teks = el.text().await?;
Ok(teks == "Selesai" || teks == "Error")
})
}))
.await?;
println!("Final status: {}", status.text().await?);
Ok(())
}
Frames, Windows, and Tabs #
use thirtyfour::prelude::*;
async fn contoh_frame_window(driver: &WebDriver) -> WebDriverResult<()> {
driver.goto("https://contoh.com").await?;
// Working with iframes
let iframe = driver.find(By::Id("embedded-content")).await?;
driver.enter_frame(Some(&iframe)).await?;
// Now you can access content inside the iframe
let konten = driver.find(By::Css(".konten-iframe")).await?;
println!("Iframe content: {}", konten.text().await?);
// Return to the main frame
driver.enter_parent_frame().await?;
// or
driver.enter_default_frame().await?;
// Open a new tab
driver.execute("window.open('https://contoh2.com', '_blank');", vec![]).await?;
// List all windows/tabs
let windows = driver.windows().await?;
println!("Number of tabs: {}", windows.len());
// Switch to the new tab (usually the last one)
driver.switch_to_window(windows.last().unwrap().clone()).await?;
println!("New tab URL: {}", driver.current_url().await?);
// Back to the first tab
driver.switch_to_window(windows.first().unwrap().clone()).await?;
// Close the current tab
driver.close_window().await?;
Ok(())
}
Screenshots and JavaScript #
use thirtyfour::prelude::*;
async fn contoh_screenshot_js(driver: &WebDriver) -> WebDriverResult<()> {
driver.goto("https://contoh.com").await?;
// Full-page screenshot
let screenshot = driver.screenshot_as_png().await?;
std::fs::write("screenshot.png", &screenshot)?;
println!("Screenshot saved: {} bytes", screenshot.len());
// Screenshot of a single element
let elemen = driver.find(By::Id("konten-utama")).await?;
let el_screenshot = elemen.screenshot_as_png().await?;
std::fs::write("elemen.png", &el_screenshot)?;
// Execute JavaScript
let title: serde_json::Value = driver
.execute("return document.title;", vec![])
.await?
.json()?;
println!("Title via JS: {}", title);
// Scroll to an element
let footer = driver.find(By::Tag("footer")).await?;
driver
.execute("arguments[0].scrollIntoView(true);", vec![footer.to_json()?])
.await?;
// Set an input value via JavaScript (for fields that are hard to interact with)
let input = driver.find(By::Id("input-readonly")).await?;
driver
.execute(
"arguments[0].value = arguments[1];",
vec![input.to_json()?, serde_json::Value::String("nilai baru".to_string())],
)
.await?;
// Click via JavaScript (useful if the element is covered by another)
driver
.execute("arguments[0].click();", vec![input.to_json()?])
.await?;
// Get localStorage
let token: serde_json::Value = driver
.execute("return localStorage.getItem('auth_token');", vec![])
.await?
.json()?;
println!("Token: {}", token);
Ok(())
}
End-to-End Testing #
use thirtyfour::prelude::*;
use std::time::Duration;
#[tokio::test]
async fn test_alur_login() -> WebDriverResult<()> {
let mut caps = DesiredCapabilities::chrome();
caps.set_headless()?;
caps.add_chrome_arg("--no-sandbox")?;
caps.add_chrome_arg("--disable-dev-shm-usage")?;
let driver = WebDriver::new("http://localhost:4444", caps).await?;
// Make sure the browser is closed even if the test fails
let result = test_login_internal(&driver).await;
driver.quit().await?;
result
}
async fn test_login_internal(driver: &WebDriver) -> WebDriverResult<()> {
driver.goto("http://localhost:3000/login").await?;
// Fill in the login form
driver.find(By::Id("email")).await?
.send_keys("[email protected]").await?;
driver.find(By::Id("password")).await?
.send_keys("password123").await?;
driver.find(By::Css("button[type='submit']")).await?
.click().await?;
// Wait for the redirect to the dashboard
driver
.query(By::Css(".dashboard-container"))
.wait(Duration::from_secs(5), Duration::from_millis(200))
.first()
.await?;
// Verify the redirect succeeded
let url = driver.current_url().await?;
assert!(url.contains("/dashboard"), "Should be on the dashboard, but: {}", url);
// Verify elements exist on the page
let pesan_sambutan = driver.find(By::Css(".sambutan-pengguna")).await?;
assert!(pesan_sambutan.text().await?.contains("admin"),
"The welcome message does not contain the username");
Ok(())
}
// Page test
#[tokio::test]
async fn test_halaman_tidak_ditemukan() -> WebDriverResult<()> {
let mut caps = DesiredCapabilities::chrome();
caps.set_headless()?;
let driver = WebDriver::new("http://localhost:4444", caps).await?;
driver.goto("http://localhost:3000/halaman-tidak-ada").await?;
let judul = driver.title().await?;
assert_eq!(judul, "404 - Not Found");
driver.quit().await?;
Ok(())
}
Web Scraping #
use thirtyfour::prelude::*;
#[derive(Debug)]
struct HasilScraping {
judul: String,
harga: String,
rating: Option<String>,
}
async fn scrape_produk(driver: &WebDriver, url: &str) -> WebDriverResult<Vec<HasilScraping>> {
driver.goto(url).await?;
// Scroll to the bottom to load lazy content
for _ in 0..5 {
driver.execute("window.scrollBy(0, 800);", vec![]).await?;
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
// Get all products
let produk_elements = driver.find_all(By::Css(".produk-card")).await?;
let mut hasil = Vec::new();
for el in &produk_elements {
let judul = el.find(By::Css(".produk-judul")).await?
.text().await?;
let harga = el.find(By::Css(".produk-harga")).await?
.text().await?;
// The rating may not always exist
let rating = el.find(By::Css(".produk-rating")).await
.ok()
.map(|r| async move { r.text().await })
.map(|f| tokio::runtime::Handle::current().block_on(f))
.and_then(|r| r.ok());
hasil.push(HasilScraping { judul, harga, rating });
}
println!("Found {} products", hasil.len());
Ok(hasil)
}
CI/CD Configuration #
# Docker Compose for CI/CD with Selenium Grid
# docker-compose.yml for testing
services:
chrome:
image: selenium/standalone-chrome:latest
ports:
- "4444:4444"
shm_size: "2gb"
environment:
- SE_NODE_MAX_SESSIONS=5
- SE_NODE_OVERRIDE_MAX_SESSIONS=true
test:
build: .
depends_on:
- chrome
environment:
- WEBDRIVER_URL=http://chrome:4444
command: cargo test --test e2e_tests
// Get the WebDriver URL from an environment variable for flexibility
async fn buat_driver() -> WebDriverResult<WebDriver> {
let url = std::env::var("WEBDRIVER_URL")
.unwrap_or_else(|_| "http://localhost:4444".to_string());
let mut caps = DesiredCapabilities::chrome();
caps.set_headless()?;
caps.add_chrome_arg("--no-sandbox")?;
caps.add_chrome_arg("--disable-dev-shm-usage")?;
caps.add_chrome_arg("--window-size=1920,1080")?;
WebDriver::new(&url, caps).await
}
Summary #
- Always
driver.quit()at the end — close the WebDriver session even if an error occurs. Use the patternlet result = operasi().await; driver.quit().await?; resultto guarantee cleanup always happens.- Use
query(...).wait(timeout, interval)instead ofsleep— explicit waits are far more reliable than fixed sleeps. Wait for specific conditions, not guessed times.- Headless mode for servers and CI —
caps.set_headless()?+--no-sandbox+--disable-dev-shm-usageis the standard combination for display-less environments.- CSS selectors are more stable than XPath — use classes, IDs, and attributes with semantic meaning. Avoid fragile order-based XPath.
- Take screenshots when tests fail — capture a screenshot when an assertion fails to make debugging easier. Save with a name containing a timestamp or the test name.
By::Cssis the most flexible — supports all CSS selectors including:nth-child,[attribute], class combinations, and pseudo-classes.- Scraping JavaScript-heavy pages — scroll to trigger lazy loading, wait for network idle, and use
driver.execute()to interact with state that isn’t accessible via the regular DOM.- Use Selenium Grid via Docker — for parallel testing in CI, use the
selenium/standalone-chromeDocker image and configure the driver URL from an environment variable.thirtyfourvsfantoccini— both are WebDriver clients for Rust;thirtyfouris more ergonomic and actively developed;fantocciniis more minimal. For most cases,thirtyfouris the better choice.