Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Welcome to The Book for thirtyfour.

thirtyfour is a crate for automating Web Browsers in Rust using the WebDriver / Selenium ecosystem. On top of the W3C WebDriver protocol it also provides typed support for two bidirectional control protocols:

  • The Chrome DevTools Protocol — the lower-level inspection and control API that Chromium-based browsers expose for debugging, profiling, and automation. Typed commands are on by default; optional WebSocket-based event subscription is gated behind the cdp-events feature.
  • WebDriver BiDi — the W3C-standard cross-browser bidirectional protocol, available behind the bidi feature. It works on both Chromium-based browsers and Firefox.

Why is it called “thirtyfour” ?

Thirty-four (34) is the atomic number for the Selenium chemical element (Se) ⚛️.

Features

  • All W3C WebDriver V1 and WebElement methods are supported
  • Create new browser session directly via WebDriver (e.g. chromedriver)
  • Create new browser session via Selenium Standalone or Grid
  • Find elements (via all common selectors e.g. Id, Class, CSS, Tag, XPath)
  • Send keys to elements, including key-combinations
  • Execute Javascript
  • Action Chains
  • Get and set cookies
  • Switch to frame/window/element/alert
  • Shadow DOM support
  • Alert support
  • Capture / Save screenshot of browser or individual element as PNG
  • Chrome DevTools Protocol (CDP) — typed commands plus optional WebSocket-based event subscription via the cdp-events feature
  • WebDriver BiDi — the W3C-standard cross-browser bidirectional protocol, behind the bidi feature
  • Powerful query interface (the recommended way to find elements) with explicit waits and various predicates
  • Component Wrappers (similar to Page Object Model)

Installation

Add thirtyfour as a dependency in your Cargo.toml:

[dependencies]
thirtyfour = "0.37.4"

You’ll also need the corresponding web browser (Chrome, Firefox, Edge, or Safari on macOS) installed in your operating system. thirtyfour handles the webdriver itself — it auto-downloads a compatible chromedriver / geckodriver / msedgedriver for your installed browser, runs it as a child process, and tears it down with your code. See WebDriver Manager for the details and configuration options.

Want to run the webdriver yourself instead — for example, to point thirtyfour at a remote Selenium grid? See Manual WebDriver Setup in the Appendix.

Your First Browser Automation Code

Writing Your First Browser Automation Code

Before we begin, you’ll need to install Rust. You can do so by using the rustup tool.

Let’s start a new project. Open your terminal application and navigate to the directory where you usually put your source code. Then run these commands:

cargo new --bin my-automation-project
cd my-automation-project

You will see a Cargo.toml file and a src/ directory there already.

First, let’s edit the Cargo.toml file in your editor (e.g. Visual Studio Code) and add some dependencies:

[dependencies]
thirtyfour = "0.37.4"
tokio = { version = "1", features = ["full"] }

Great! Now let’s open src/main.rs and add the following code.

NOTE: Make sure you remove any existing code from main.rs.

Don’t worry, we’ll go through what it does soon.

/src/main.rs

use std::error::Error;

use thirtyfour::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
    let driver = WebDriver::managed(DesiredCapabilities::chrome()).await?;

    // Navigate to https://wikipedia.org.
    driver.goto("https://wikipedia.org").await?;
    let elem_form = driver
        .query(By::Id("search-form"))
        .desc("Wikipedia search form")
        .single()
        .await?;

    // Querying from an element scopes the search to its subtree.
    let elem_text = elem_form
        .query(By::Id("searchInput"))
        .desc("Wikipedia search input")
        .single()
        .await?;

    // Type in the search terms.
    elem_text.send_keys("selenium").await?;

    // Click the search button.
    let elem_button = elem_form
        .query(By::Css("button[type='submit']"))
        .desc("Wikipedia search button")
        .single()
        .await?;
    elem_button.click().await?;

    // Wait for the unique article heading before checking the title.
    driver
        .query(By::ClassName("firstHeading"))
        .desc("Wikipedia article heading")
        .single()
        .await?;
    assert_eq!(driver.title().await?, "Selenium - Wikipedia");

    // Always explicitly close the browser.
    driver.quit().await?;

    Ok(())
}

Selector note: Wikipedia is a third-party site, so this example uses the stable IDs, element types, and classes that the real page exposes. In an app you control, add stable data-testid hooks to important controls and prefer By::Testid:

#![allow(unused)]
fn main() {
let save_button = driver
    .query(By::Testid("settings-save"))
    .desc("settings save button")
    .single()
    .await?;
}

See Element Queries for the selector priority and guidance on text matching and XPath.

Make sure Chrome is installed, then run:

cargo run

If everything worked correctly you should have seen a Chrome browser window open up, navigate to the “Selenium” article on Wikipedia, and then close again.

The first run will take a few seconds longer than subsequent runs — thirtyfour downloads a matching chromedriver into your system cache directory the first time, then reuses it on every later run. See WebDriver Manager for the version-pinning, offline-mode, and observability options that the manager provides.

Running on Firefox

To run the code using Firefox instead, change the capabilities in main:

#![allow(unused)]
fn main() {
    let driver = WebDriver::managed(DesiredCapabilities::firefox()).await?;
}

Make sure Firefox is installed, and re-run:

cargo run

If everything worked correctly, you should have seen the Wikipedia page open up on Firefox this time.

Congratulations! You successfully automated a web browser.

Before building a larger suite, copy the short Reliable AI-Generated Tests checklist into your coding-agent instructions or project guidance. It captures the reliability rules used by this example and links to the deeper documentation for each one.

AI/LLM Quickstart

Use this page as the compact source of truth when asking an AI coding tool to write thirtyfour automation. It keeps the reliable defaults in one place and links to the deeper documentation when a task needs more detail.

Minimal Setup

[dependencies]
thirtyfour = "0.37.4"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

The default thirtyfour features include the local WebDriver::managed setup used below. Use WebDriver::new or WebDriver::builder instead when a remote Selenium or Grid service owns the driver process. Install Chrome before running the test; the manager downloads a matching chromedriver automatically on first use.

Starter Test

This example is marked no_run because example.test represents your application and cannot be exercised as written.

#![allow(unused)]
fn main() {
use thirtyfour::{
    prelude::*,
    testing::{BrowserTestError, run_browser_test},
};

#[tokio::test]
async fn saves_profile_settings() -> Result<(), BrowserTestError> {
    let mut caps = DesiredCapabilities::chrome();
    caps.set_headless()?;
    run_browser_test(WebDriver::managed(caps), |driver| async move {
        driver.goto("https://example.test/settings").await?;

        let form = driver
            .query(By::Testid("profile-settings"))
            .desc("profile settings form")
            .single()
            .await?;

        form.query(By::Testid("display-name"))
            .desc("display name input")
            .single()
            .await?
            .send_keys("Ada")
            .await?;

        let save_button = form
            .query(By::Testid("save-profile"))
            .desc("save profile button")
            .single()
            .await?;
        save_button.click_when_ready().await?;

        // Assert the user-visible outcome. This query polls until the saved
        // state appears, so it replaces a brittle fixed sleep.
        driver
            .query(By::Testid("settings-status"))
            .with_text("Saved")
            .and_displayed()
            .desc("saved settings status")
            .single()
            .await?;

        Ok(())
    })
    .await
}
}

Rules For Generated Automation

  • Prefer app-owned By::Testid selectors, then stable semantic CSS. Match visible text when the copy is the behavior under test; use XPath only when CSS cannot reasonably express the target.
  • Use query() for normal lookup, .single() when uniqueness is a page contract, and .desc(...) on important user-facing elements.
  • Never use a fixed sleep for readiness. Poll for the required state with query() or use wait_until() on an element you already have.
  • Scope queries through a container or Component, and assert a user-visible outcome rather than only checking that a click returned successfully.
  • Prefer run_browser_test in tests so asynchronous cleanup still runs after early errors and panicking assertions. Otherwise explicitly quit every session. Do not run independent concurrent flows through clones of the same WebDriver.
  • On an error path, call FailureArtifactCollector::capture() before cleanup for bounded, best-effort URL, title, screenshot, source, and log context. See the diagnostics recipe for capability and feature limits.

The copyable Reliable AI-Generated Tests checklist is the review gate for generated code. Apply it before accepting a test.

Deeper Documentation

  • Task-Oriented Recipes — copyable starting points for common application flows, browser contexts, diagnostics, CDP, and BiDi.
  • Selenium And Playwright Translation Guide — map familiar APIs to queries, waits, Components, and managed sessions without inventing APIs from another ecosystem.
  • Element Queries — stable selectors, descriptions, filters, scoping, cardinality, and intentional one-shot find() calls.
  • Waiting For Element Changes — state waits for an element that has already been located.
  • Components — reusable UI areas with private selectors and resilient element resolution.
  • WebDriver Manager — local driver downloads, process lifetime, configuration, and separate sessions.
  • Failure Artifacts And Logs — bounded portable failure context suitable for CI and AI-assisted debugging.
  • Chrome DevTools Protocol — Chromium-only typed commands and optional event support.
  • WebDriver BiDi — cross-browser bidirectional commands and events, with feature and capability opt-in.

Understanding Web Browser Automation

So what did this code actually do?

NOTE: This section goes much deeper into how web browser automation works. Feel free to skip ahead and come back when you’re ready to dig deeper.

How Thirtyfour Works

Well, the first thing to know is that thirtyfour doesn’t talk directly to the Web Browser. It simply fires off commands to the webdriver server as HTTP Requests. The webdriver then talks to the Web Browser and tells it to execute each command, and then returns the response from the Web Browser to thirtyfour. As long as the webdriver is running, thirtyfour can do just about anything a human can do in a web browser.

Explaining The Code

So let’s go through the code and see what is going on.

#![allow(unused)]
fn main() {
    let driver = WebDriver::managed(DesiredCapabilities::chrome()).await?;
}

This single line does a lot of work for you:

  1. Picks a chromedriver version that matches your installed Chrome (downloading and caching the binary the first time, reusing it after).
  2. Spawns it as a child process on a free local port and waits for it to be ready.
  3. Connects to it and starts a new browser session.

The session opens the browser in a new profile (so it won’t add anything to your history etc.) and navigates to the default start page. When the last WebDriver handle drops, the browser closes and the driver subprocess is torn down with it.

See WebDriver Manager for everything else the manager can do — pinning a specific driver version, sharing one manager across many sessions, supplying a pre-installed driver binary, and observing what’s happening as the manager works.

If you’d rather run the webdriver yourself — for example to point thirtyfour at a remote Selenium grid — see Manual WebDriver Setup in the Appendix. The remaining sections work the same either way.

Capabilities

The way we tell it what browser we want is by using DesiredCapabilities. In this case, we construct a new ChromeCapabilities instance. Each *Capabilities struct will have additional helper methods for setting options like headless mode, proxy, and so on. See the documentation for more details.

Element Queries

Next, we tell the browser to navigate to Wikipedia:

#![allow(unused)]
fn main() {
    driver.goto("https://wikipedia.org").await?;
}

And then we look for an element on the page:

#![allow(unused)]
fn main() {
    let elem_form = driver
        .query(By::Id("search-form"))
        .desc("Wikipedia search form")
        .single()
        .await?;
}

We search for elements using what we call a selector or locator. In this case we are looking for an element with the id of search-form. The query() method polls until the element appears, and desc() gives the query a readable name if it times out. Calling single() also checks our page contract: exactly one search form should match this selector.

Choosing A Stable Selector

When you control the application under test, add stable data-testid hooks to important controls and prefer By::Testid:

#![allow(unused)]
fn main() {
let save_button = driver
    .query(By::Testid("settings-save"))
    .desc("settings save button")
    .single()
    .await?;
}

If there is no app-owned test ID, use a stable semantic CSS selector next. Match visible text when the wording itself is part of the behavior you need to verify, not as the default identity of a control: copy changes, localization, and duplicate labels can otherwise break the test. Use XPath only when CSS cannot reasonably express the target.

Wikipedia does not provide test IDs for this flow, so the walkthrough uses the stable selectors that the real third-party page exposes.

If you actually navigate to https://wikipedia.org and open your browser’s devtools (F12 in most browsers), then go to the Inspector tab, you will see the raw HTML for the page. In the search box at the top of the inspector, if you type #search-form (the # is how we specify an id) you will see that it highlights the search form element.

This is the container element that contains both the input field and the button itself.

But we want to type into the field, so we need to do another query:

#![allow(unused)]
fn main() {
    let elem_text = elem_form
        .query(By::Id("searchInput"))
        .desc("Wikipedia search input")
        .single()
        .await?;
}

This query starts from elem_form, so it only searches inside the form’s subtree. If you search in the inspector for #searchInput you get an <input /> element, which is the one we want to type into.

Typing Text Into An Element

To type into a field, we just tell thirtyfour to send the keys we want to type:

#![allow(unused)]
fn main() {
    elem_text.send_keys("selenium").await?;
}

This will literally type the text into the input field.

Now we need to find the search button and click it. Finding the element means doing another query:

#![allow(unused)]
fn main() {
    let elem_button = elem_form
        .query(By::Css("button[type='submit']"))
        .desc("Wikipedia search button")
        .single()
        .await?;
}

This time we use a CSS selector to locate a <button> element with an attribute type that has the value submit. To learn more about selectors, click here.

Clicking The Button

Next, the call to click() tells thirtyfour to simulate a click on that element:

#![allow(unused)]
fn main() {
    elem_button.click().await?;
}

Dealing With Page Loading Times

The page now starts loading the result of our search on Wikipedia. This brings us to our first issue when automating a web browser. How does our code know when the page has finished loading? If we had a slow internet connection, we might try to find an element on the page only for it to fail because that element hasn’t loaded yet.

How do we solve this?

Well, one option is to simply tell our code to wait for a few seconds and then try to find the element we are looking for. But this is brittle and likely to fail. Don’t do this. There are cases where you are forced to use this approach, but it should be a last resort. Incidentally, from a website testing perspective, it probably also means a human is going to be unsure of when the page has actually finished loading, and this is an indicator of a poor user experience.

Instead, we usually want to look for an element on the page and wait until that element appears.

#![allow(unused)]
fn main() {
driver
    .query(By::ClassName("firstHeading"))
    .desc("Wikipedia article heading")
    .single()
    .await?;
assert_eq!(driver.title().await?, "Selenium - Wikipedia");
}

Better Element Queries

To have thirtyfour wait until an element appears, we use the query() method, which provides a builder interface for constructing flexible queries. Again, this uses one of the By selectors. The single() terminator expresses that the selector must match exactly one element; use first() instead when choosing the first of several matches is intentional.

By default, query() polls every half-second for up to 20 seconds. If no element matches, it times out and returns an error that includes the selector and any description added with desc(). The polling time and timeout duration can be changed using WebDriverConfig, or overridden for a given query.

The query() method is the recommended way to search for elements. The find() and find_all() methods are lower-level, one-shot operations shaped like the WebDriver specification. Use them when one immediate lookup is deliberate. For everyday automation, query() adds polling and other niceties, including more detail when an element was not found.

See the ElementQuery documentation for more details on the kinds of queries it supports.

Closing The Browser

Finally we need to close the browser window. If we don’t, it will remain open after our application has exited.

#![allow(unused)]
fn main() {
    driver.quit().await?;
}

And this concludes the introduction.

Happy Browser Automation!

Reliable AI-Generated Tests

Use this checklist when asking a coding agent to write or review thirtyfour automation. It is intentionally short enough to paste into project-level agent instructions:

- Use WebDriver::managed for local browsers; use WebDriver::new or builder only
  when connecting to a remote Selenium or Grid endpoint.
- Use query() for normal lookup. Use find() only for an intentional one-shot lookup.
- End unique queries with single(), and add desc(...) to important queries.
- Prefer By::Testid or another stable app-owned selector. Avoid generated classes,
  DOM-position chains, and XPath when stable CSS can express the target.
- Never use a fixed sleep for page readiness. Wait with query() or wait_until().
- Scope queries through a container element; use Components for repeated UI areas.
- Prefer run_browser_test(...) in tests; otherwise explicitly call
  driver.quit().await? when the session is finished.
- On failure, capture bounded diagnostics before cleanup with
  FailureArtifactCollector rather than dumping unbounded page source.
- Do not share one WebDriver session across independent concurrent flows. A shared
  WebDriverManager may launch separate sessions for parallel tests.
- Keep CDP and BiDi code isolated from portable WebDriver flows. Gate optional
  cdp-events and bidi APIs with their Cargo features, and opt BiDi sessions in.

Why These Rules

  • WebDriver::managed downloads, launches, and lifetime-manages a local driver. Use the normal constructor or WebDriver::builder when an external Selenium service owns that lifecycle. Explicit quit() reports shutdown errors and does not rely on asynchronous cleanup during Drop.
  • Element queries poll for page state and provide filters, descriptions, and cardinality checks. See the guidance for stable selectors, choosing single() or first(), describing important queries, and intentional one-shot lookup.
  • Element waits express the state an existing element must reach. A fixed sleep only guesses how long the page needs; a query or wait completes as soon as the required state exists and produces a useful timeout when it does not.
  • Element-scoped queries prevent unrelated matches. For recurring UI areas, Components keep selectors private and expose user-intent methods while their resolvers handle polling and stale elements.
  • A cloned WebDriver controls the same browser session, so independent flows can race over tabs, navigation, and browser state. The manager’s shared configuration can instead launch a separate session for each test or worker.
  • CDP is Chromium-specific, while BiDi is the cross-browser W3C protocol. Keep either behind a small boundary so the rest of the automation remains portable, and follow the feature flag and BiDi opt-in requirements. The cdp feature is enabled by default; cdp-events and bidi are opt-in.

Treat these as review rules, not merely generation hints: generated code should not be accepted until its selectors, waits, cleanup, concurrency, and feature gates satisfy the same checklist.

Cleanup That Survives Test Failures

Use run_browser_test as the default test shape:

#![allow(unused)]
fn main() {
use thirtyfour::{
    prelude::*,
    testing::{BrowserTestError, run_browser_test},
};

#[tokio::test]
async fn page_has_heading() -> Result<(), BrowserTestError> {
    run_browser_test(
        WebDriver::managed(DesiredCapabilities::chrome()),
        |driver| async move {
            driver.goto("https://example.com").await?;
            driver.query(By::Css("h1")).single().await?;
            Ok(())
        },
    )
    .await
}
}

The runner keeps its own session handle, passes a clone to the test body, and awaits quit() after success, an early ? return, or a panicking assertion. After a panic it resumes the original panic; if cleanup also fails, the cleanup error is written through tracing. panic = "abort" cannot run cleanup. The runner future must be allowed to finish: cancelling or aborting its task can interrupt asynchronous cleanup.

Error precedence is explicit:

Test bodyquit()Result
succeedssucceedsbody value
failssucceedsBrowserTestError::Body
succeedsfailsBrowserTestError::Cleanup
failsfailsBrowserTestError::BodyAndCleanup with both errors

The first argument accepts any future that creates a WebDriver, so the same runner works with WebDriver::managed(...), WebDriver::builder(...), and WebDriver::new(...).

For CI diagnostics, attach FailureArtifactCollector before the test body and call capture() on the error path before the runner cleans up. Each artifact succeeds or fails independently, source and log text are hard-bounded by default, and the display report never prints PNG bytes. See Failure Artifacts And Logs for the complete pattern and browser/feature limitations.

Feature Flags

  • rustls-tls: (Default) Use rustls to provide TLS support (via reqwest).
  • native-tls: Use native TLS (via reqwest).
  • component: (Default) Enable the Component derive macro (via thirtyfour_macros) and export Component / ElementResolver from the prelude.
  • manager: (Default) Automatic webdriver download and process management; see WebDriver Manager. Disable this if you’d rather manage the webdriver yourself — see Manual WebDriver Setup.
  • cdp: (Default) Typed Chrome DevTools Protocol commands and the WebDriver::cdp() / WebElement::cdp_*() integrations. Works on any Chromium-based session via the WebDriver vendor endpoint goog/cdp/execute — no extra connection required. See the CDP overview.
  • cdp-events: WebSocket-based CDP session with event subscription. Adds Cdp::connect() and CdpSession; pulls in tokio-tungstenite. Off by default. See CDP Events.
  • bidi: WebDriver BiDi (W3C) — typed commands and event subscription over a WebSocket negotiated via the webSocketUrl: true capability. Off by default; pulls in tokio-tungstenite. See the BiDi overview.

Recipe Conventions

These recipes are small, task-shaped starting points for common browser automation. Copy the closest recipe, replace its URL and selectors with the contract of your application, and keep the reliability shape intact.

Every example is marked no_run because it needs a local browser, an application or fixture that is not part of this book, or an optional protocol feature. The examples are otherwise complete tests. They all:

  • use WebDriver::managed for a local browser;
  • locate normal page elements with query(), stable app-owned test IDs, readable descriptions, and intentional cardinality;
  • wait for observable state with a query or wait_until(), never a fixed sleep;
  • retain the task result so driver.quit() is attempted after a command failure; and
  • assert a user-visible or protocol-visible result.

The placeholder https://example.test URLs and data-testid values describe the application contract the recipe expects. Add those stable hooks to an app you control. For a third-party page, replace them with the most stable semantic selectors the real page exposes.

Choose A Recipe

For generated code, apply the copyable Reliable AI-Generated Tests checklist before accepting the result.

Forms And Page Content

The recipes on this page use app-owned data-testid hooks. The important pattern is the same for any app: scope queries to the smallest meaningful container, wait for an interactable control, perform the action, and query for the user-visible result.

Submit A Login Form

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;

#[tokio::test]
async fn logs_in() -> WebDriverResult<()> {
    let driver = WebDriver::managed(DesiredCapabilities::chrome()).await?;

    let test_result: WebDriverResult<()> = async {
        driver.goto("https://example.test/login").await?;
        let form = driver
            .query(By::Testid("login-form"))
            .desc("login form")
            .single()
            .await?;

        form.query(By::Testid("email"))
            .desc("email input")
            .single()
            .await?
            .send_keys("ada@example.test")
            .await?;
        form.query(By::Testid("password"))
            .desc("password input")
            .single()
            .await?
            .send_keys("correct horse battery staple")
            .await?;

        let submit = form
            .query(By::Testid("login-submit"))
            .desc("login button")
            .single()
            .await?;
        submit.wait_until().clickable().await?;
        submit.click().await?;

        driver
            .query(By::Testid("account-heading"))
            .with_text("Welcome, Ada")
            .and_displayed()
            .desc("signed-in account heading")
            .single()
            .await?;
        Ok(())
    }
    .await;

    let quit_result = driver.quit().await;
    test_result?;
    quit_result
}
}

The final query is the assertion. It waits for the application to expose the signed-in state instead of assuming the click or navigation has finished.

Exercise A Search Flow

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;

#[tokio::test]
async fn searches_the_catalogue() -> WebDriverResult<()> {
    let driver = WebDriver::managed(DesiredCapabilities::chrome()).await?;

    let test_result: WebDriverResult<()> = async {
        driver.goto("https://example.test/catalogue").await?;
        let form = driver
            .query(By::Testid("catalogue-search"))
            .desc("catalogue search form")
            .single()
            .await?;

        form.query(By::Testid("search-input"))
            .desc("catalogue search input")
            .single()
            .await?
            .send_keys("webdriver")
            .await?;
        let submit = form
            .query(By::Css("button[type='submit']"))
            .desc("catalogue search button")
            .single()
            .await?;
        submit.wait_until().clickable().await?;
        submit.click().await?;

        let results = driver
            .query(By::Testid("search-results"))
            .desc("catalogue search results")
            .single()
            .await?;
        results
            .query(By::Testid("result-title"))
            .with_text("Thirtyfour browser automation")
            .and_displayed()
            .desc("matching catalogue result title")
            .first()
            .await?;
        Ok(())
    }
    .await;

    let quit_result = driver.quit().await;
    test_result?;
    quit_result
}
}

first() is intentional here: the result list may contain several matching titles, and the test needs one visible result with the expected text. Use single() instead if duplicate matching results would violate the page contract.

Interact With An HTML Modal

This recipe targets an in-page HTML modal, not a browser-native JavaScript alert. Native alerts use get_alert_text(), accept_alert(), and dismiss_alert().

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;

#[tokio::test]
async fn confirms_account_deletion() -> WebDriverResult<()> {
    let driver = WebDriver::managed(DesiredCapabilities::chrome()).await?;

    let test_result: WebDriverResult<()> = async {
        driver.goto("https://example.test/account").await?;
        let open = driver
            .query(By::Testid("delete-account"))
            .desc("delete account button")
            .single()
            .await?;
        open.wait_until().clickable().await?;
        open.click().await?;

        let modal = driver
            .query(By::Testid("delete-account-modal"))
            .and_displayed()
            .desc("delete account confirmation modal")
            .single()
            .await?;
        modal
            .query(By::Testid("modal-title"))
            .with_text("Delete account?")
            .desc("delete account modal title")
            .single()
            .await?;

        let cancel = modal
            .query(By::Testid("cancel-delete"))
            .desc("cancel account deletion button")
            .single()
            .await?;
        cancel.wait_until().clickable().await?;
        cancel.click().await?;
        modal.wait_until().stale().await?;
        Ok(())
    }
    .await;

    let quit_result = driver.quit().await;
    test_result?;
    quit_result
}
}

This page contract removes the modal from the DOM when it closes, so stale() is the correct completion condition. If your app keeps the modal mounted and hides it, use not_displayed() instead.

Assert A Table Or List Row

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;

#[tokio::test]
async fn shows_the_shipped_order() -> WebDriverResult<()> {
    let driver = WebDriver::managed(DesiredCapabilities::chrome()).await?;

    let test_result: WebDriverResult<()> = async {
        driver.goto("https://example.test/orders").await?;
        let table = driver
            .query(By::Testid("orders-table"))
            .desc("orders table")
            .single()
            .await?;

        let order = table
            .query(By::Css("tbody tr[data-order-id='1234']"))
            .desc("row for order 1234")
            .single()
            .await?;
        order
            .query(By::Testid("order-status"))
            .with_text("Shipped")
            .and_displayed()
            .desc("shipped status for order 1234")
            .single()
            .await?;
        Ok(())
    }
    .await;

    let quit_result = driver.quit().await;
    test_result?;
    quit_result
}
}

The first query scopes the assertion to the table. The stable app-owned data-order-id attribute enforces that exactly one row represents the order, and the nested status query checks the user-visible state without depending on column position.

Frames, Shadow DOM, And Files

Frames and shadow roots create new query scopes. File inputs cross from the browser into the machine running the driver. These recipes make each boundary explicit.

Work Inside An iframe

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;

#[tokio::test]
async fn submits_embedded_checkout() -> WebDriverResult<()> {
    let driver = WebDriver::managed(DesiredCapabilities::chrome()).await?;

    let test_result: WebDriverResult<()> = async {
        driver.goto("https://example.test/checkout").await?;
        driver
            .query(By::Testid("payment-frame"))
            .desc("payment iframe")
            .single()
            .await?
            .enter_frame()
            .await?;

        let frame_result: WebDriverResult<()> = async {
            let pay = driver
                .query(By::Testid("pay-now"))
                .desc("pay now button inside payment iframe")
                .single()
                .await?;
            pay.wait_until().clickable().await?;
            pay.click().await?;
            Ok(())
        }
        .await;

        // Frame switching has no guard, so restore the outer context even if
        // the interaction inside the frame fails.
        let restore_result = driver.enter_default_frame().await;
        frame_result?;
        restore_result?;
        driver
            .query(By::Testid("payment-status"))
            .with_text("Payment complete")
            .and_displayed()
            .desc("completed payment status")
            .single()
            .await?;
        Ok(())
    }
    .await;

    let quit_result = driver.quit().await;
    test_result?;
    quit_result
}
}

After enter_frame(), driver-level queries target that frame. There is no frame guard, so retain the inner result and attempt enter_default_frame() before propagating it or querying outer content.

Query A Shadow Root

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;

#[tokio::test]
async fn opens_the_shadow_dom_menu() -> WebDriverResult<()> {
    let driver = WebDriver::managed(DesiredCapabilities::chrome()).await?;

    let test_result: WebDriverResult<()> = async {
        driver.goto("https://example.test/dashboard").await?;
        let host = driver
            .query(By::Testid("account-menu-host"))
            .desc("account menu shadow host")
            .single()
            .await?;
        let root = host.get_shadow_root().await?;

        let open = root
            .query(By::Testid("open-account-menu"))
            .desc("open account menu button")
            .single()
            .await?;
        open.wait_until().clickable().await?;
        open.click().await?;
        root.query(By::Testid("account-menu"))
            .and_displayed()
            .desc("opened account menu")
            .single()
            .await?;
        Ok(())
    }
    .await;

    let quit_result = driver.quit().await;
    test_result?;
    quit_result
}
}

get_shadow_root() returns a queryable element handle. Scope selectors through that root; a normal driver query does not pierce a shadow boundary.

Upload A File

The path must exist on the machine running the browser driver. When Selenium Grid runs the driver remotely, use the Grid’s file-transfer setup rather than assuming a path on the test client is visible to the remote node.

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;

#[tokio::test]
async fn uploads_an_avatar() -> WebDriverResult<()> {
    let driver = WebDriver::managed(DesiredCapabilities::chrome()).await?;

    let test_result: WebDriverResult<()> = async {
        driver.goto("https://example.test/profile").await?;
        let input = driver
            .query(By::Testid("avatar-file"))
            .desc("avatar file input")
            .single()
            .await?;

        input.send_keys("/absolute/path/to/avatar.png").await?;
        let upload = driver
            .query(By::Testid("upload-avatar"))
            .desc("upload avatar button")
            .single()
            .await?;
        upload.wait_until().clickable().await?;
        upload.click().await?;

        driver
            .query(By::Testid("avatar-status"))
            .with_text("Upload complete")
            .and_displayed()
            .desc("completed avatar upload status")
            .single()
            .await?;
        Ok(())
    }
    .await;

    let quit_result = driver.quit().await;
    test_result?;
    quit_result
}
}

Send the absolute file path directly to the <input type="file">; do not automate the operating system’s file-picker window.

Failure Artifacts And Logs

Diagnostics are most useful when their collection does not prevent browser cleanup or replace the original test error. These recipes keep those outcomes separate.

Capture A Bounded Failure Report

Create FailureArtifactCollector before the test body so it can record live managed-driver-process output. Its defaults capture URL, title, PNG bytes, a page-source excerpt capped at 64 KiB, and the newest 200 browser and driver process log entries. Driver-provided log text also has per-entry and total UTF-8 byte limits.

#![allow(unused)]
fn main() {
use thirtyfour::{
    prelude::*,
    testing::{BrowserTestError, FailureArtifactCollector, run_browser_test},
};

#[tokio::test]
async fn diagnoses_checkout_failure() -> Result<(), BrowserTestError> {
    run_browser_test(
        WebDriver::managed(DesiredCapabilities::chrome()),
        |driver| async move {
            let collector = FailureArtifactCollector::new(&driver);
            let result: WebDriverResult<()> = async {
                driver.goto("https://example.test/checkout").await?;
                driver
                    .query(By::Testid("order-confirmation"))
                    .with_text("Order received")
                    .single()
                    .await?;
                Ok(())
            }
            .await;

            if result.is_err() {
                // Capture while the session is alive, before the runner quits it.
                eprintln!("{}", collector.capture().await);
            }
            result
        },
    )
    .await
}
}

Capture is best-effort and never fail-fast. Every field is independently Captured, Unavailable, or Disabled, so an unsupported browser-log endpoint does not hide a screenshot or source excerpt. The Display report is intended for CI and AI debugging: it prints only the screenshot byte count, never PNG bytes or base64. The screenshot bytes remain available on FailureArtifacts for writing to a CI artifact file.

This example captures returned Err values only. A panicking assertion unwinds past the capture() call, although run_browser_test still catches the unwind to quit the session before resuming it. Prefer fallible query checks when you need this report, or add a panic-aware wrapper or test-harness hook that calls capture() before cleanup.

Browser logs require logging capabilities set before session creation and are not supported by every driver. Managed-driver-process logs are available only with the manager feature, for managed sessions, and only after collector attachment. The manager may reuse one driver process across sessions, so this process-scoped stream can include lines from another concurrent session. Avoid sharing a managed driver process when per-session log isolation is required. CDP and BiDi diagnostics are intentionally outside this portable baseline. Customize or disable capture through FailureArtifactConfig; configured byte and entry limits remain hard bounds and UTF-8 truncation never splits a code point.

URLs, titles, source excerpts, screenshots, and logs can still contain secrets or personal data. Disable fields that are unsafe for your application, apply application-specific redaction before uploading artifacts, and do not treat the default size bounds as a privacy filter.

Take A Screenshot On Failure

#![allow(unused)]
fn main() {
use std::path::Path;
use thirtyfour::prelude::*;

#[tokio::test]
async fn captures_a_failed_checkout() -> WebDriverResult<()> {
    let driver = WebDriver::managed(DesiredCapabilities::chrome()).await?;

    let test_result: WebDriverResult<()> = async {
        driver.goto("https://example.test/checkout").await?;
        driver
            .query(By::Testid("order-confirmation"))
            .with_text("Order received")
            .and_displayed()
            .desc("completed order confirmation")
            .single()
            .await?;
        Ok(())
    }
    .await;

    let screenshot_result = if test_result.is_err() {
        driver.screenshot(Path::new("checkout-failure.png")).await
    } else {
        Ok(())
    };
    let quit_result = driver.quit().await;

    test_result?;
    screenshot_result?;
    quit_result
}
}

Create the artifact directory before the test if the path includes one. The ordering above always attempts quit(), then gives the original test failure priority over a screenshot or cleanup failure. For a larger suite, move this shape into the test fixture rather than duplicating it in every test.

This result-capture shape handles returned Err values, not Rust panics from assert!. Prefer query-based assertions like the one above, or use a panic-aware test harness when screenshotting panics is also required.

Read Browser And Driver Logs

The legacy browser-log endpoint is implemented by Chromium drivers, not geckodriver. Enable goog:loggingPrefs before creating the session. Driver process logs are separate and are available for locally managed sessions.

#![allow(unused)]
fn main() {
use std::time::Duration;
use tokio::time::{Instant, sleep};
use thirtyfour::{LoggingPrefsLogLevel, prelude::*};

#[tokio::test]
async fn records_chromium_logs() -> WebDriverResult<()> {
    let mut caps = DesiredCapabilities::chrome();
    caps.set_browser_log_level(LoggingPrefsLogLevel::All)?;
    let driver = WebDriver::managed(caps)
        .on_driver_log(|entry| eprintln!("chromedriver: {}", entry.line))
        .await?;

    let test_result: WebDriverResult<()> = async {
        driver
            .goto("data:text/html,<script>console.error('checkout failed')</script>")
            .await?;

        // Log delivery is asynchronous and each call drains the buffer, so
        // accumulate entries until the expected marker arrives or times out.
        let deadline = Instant::now() + Duration::from_secs(5);
        let mut entries = Vec::new();
        loop {
            entries.extend(driver.browser_log().await?);
            if entries.iter().any(|entry| entry.message.contains("checkout failed")) {
                break;
            }
            if Instant::now() >= deadline {
                return Err(WebDriverError::Timeout("browser log marker did not arrive".into()));
            }
            sleep(Duration::from_millis(100)).await;
        }

        for entry in entries {
            println!("[{}] {}", entry.level, entry.message);
        }
        Ok(())
    }
    .await;

    let quit_result = driver.quit().await;
    test_result?;
    quit_result
}
}

For cross-browser console events, prefer the BiDi log subscription described in BiDi Events. For manager status events, log buffering, and per-session driver subscriptions, see WebDriver Manager. Driver-process subscriptions require the default-on manager feature and the manager’s default StdioMode::Tracing; Inherit and Null do not create the stdout/stderr pumps that feed callbacks. The short polling interval above is intentional timing behavior for asynchronous log delivery, not a page-readiness sleep.

CDP And BiDi

Keep protocol-specific code behind a small boundary. CDP is Chromium-only and enabled by thirtyfour’s default features. BiDi is the W3C cross-browser path, but it requires both a Cargo feature and a session capability. The currently documented and tested implementations are Chromium with chromedriver 115 or newer and Firefox with geckodriver 0.31 or newer.

Clear Chromium’s Cache With CDP

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;

#[tokio::test]
async fn clears_the_browser_cache() -> WebDriverResult<()> {
    let driver = WebDriver::managed(DesiredCapabilities::chrome()).await?;

    let test_result: WebDriverResult<()> = async {
        driver.goto("https://example.test/catalogue").await?;
        driver.cdp().network().clear_browser_cache().await?;

        let version = driver.cdp().browser().get_version().await?;
        println!("cleared cache in {}", version.product);
        Ok(())
    }
    .await;

    let quit_result = driver.quit().await;
    test_result?;
    quit_result
}
}

This recipe needs the cdp feature, which is enabled by default, and a Chromium-backed driver. Typed commands use the existing WebDriver connection; no event WebSocket is required. See the CDP guide for supported domains and the untyped escape hatch.

Subscribe To A BiDi Load Event

Enable the optional bidi feature and add futures-util for StreamExt:

[dependencies]
futures-util = "0.3"
thirtyfour = { version = "0.37.4", features = ["bidi"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
#![allow(unused)]
fn main() {
use std::time::Duration;
use futures_util::StreamExt;
use thirtyfour::bidi::events::Load;
use thirtyfour::prelude::*;

#[tokio::test]
async fn observes_a_page_load() -> WebDriverResult<()> {
    let mut caps = DesiredCapabilities::chrome();
    caps.enable_bidi()?;
    let driver = WebDriver::managed(caps).await?;

    let test_result: WebDriverResult<()> = async {
        let bidi = driver.bidi().await?;
        let mut loads = bidi.subscribe::<Load>().await?;
        let context = bidi.browsing_context().top_level().await?;

        bidi.browsing_context()
            .navigate(context, "https://example.test/dashboard", None)
            .await?;
        let load = tokio::time::timeout(Duration::from_secs(10), loads.next())
            .await
            .map_err(|_| WebDriverError::Timeout("BiDi load event timed out".into()))?
            .ok_or_else(|| WebDriverError::FatalError("BiDi event stream closed".into()))?;
        println!("loaded: {}", load.url);

        // Dropping the last typed stream schedules a best-effort unsubscribe.
        drop(loads);
        Ok(())
    }
    .await;

    let quit_result = driver.quit().await;
    test_result?;
    quit_result
}
}

enable_bidi() requests the WebSocket URL during session creation, and subscribe::<Load>() subscribes while the typed stream is alive. Dropping the last stream schedules a best-effort background unsubscribe; the following driver.quit() closes the WebDriver session regardless. The BiDi overview covers browser support, and the BiDi Events guide covers typed streams, scoping, and interception.

Element Queries

To find elements on a page, call .query(...) on a WebDriver or a WebElement. query() is the recommended way to locate elements: it knows how to wait for the element to appear, can describe what you were looking for in error messages, and lets you chain filters and alternatives until the query returns exactly what you want.

#![allow(unused)]
fn main() {
let elem = driver.query(By::Id("search-form")).single().await?;
}

That’s the basic shape. The rest of this chapter unpacks each piece — the selectors you can pass, the filters you can chain, and the terminator at the end that decides what comes back.

How It Works

A query has three parts:

  1. A starting selector (By::Id("search-form")).
  2. Optional filters and chained alternatives (e.g. .with_text("Hello"), .or(By::Css("..."))).
  3. A terminator that decides what to return: a single element, all matches, just a boolean for existence, etc.

The query polls under the hood. By default it tries every 500ms for up to 20 seconds; if the selector matches at any point, the query returns immediately. If the timeout elapses with no match, you get a structured error that includes the selector(s) you used and any descriptions you attached.

WebElement::query() works the same way and scopes the search to the element’s subtree.

Selectors

Pass any of these By variants to query():

SelectorMatches
By::Id("foo")Element with id="foo"
By::Css("...")CSS selector
By::XPath("...")XPath expression
By::Tag("button")Element by tag name
By::ClassName("x")Element with class x
By::Name("user")Element with name="user"
By::LinkText("...")<a> whose visible text matches exactly
By::PartialLinkText("...")<a> whose visible text contains the string
By::Testid("...")Element with data-testid="..."

By::Css can express compound CSS selectors, while By::XPath covers relationships CSS cannot represent. The other variants are convenient, purpose-specific selectors, and most are implemented as CSS under the hood.

Choosing Stable Selectors

Use selectors in this order when the application gives you the choice:

  1. App-owned test IDs. Add a stable data-testid to important controls and select it with By::Testid. This keeps tests independent of styling and displayed copy.
  2. Stable semantic CSS. When no test ID exists, target durable attributes and element roles, such as button[type='submit'], rather than generated classes or a long chain of DOM positions.
  3. Visible text when the copy is the behavior. Text matching is useful for verifying an error message, heading, or labeled action. It is brittle as a default element identity because copy changes, localization, and duplicate labels can break an otherwise-correct flow.
  4. XPath only when CSS cannot express the target. XPath is useful for relationships or document structures CSS cannot select, but is usually harder to read and easier to couple to the current DOM.

For an app-owned test hook:

#![allow(unused)]
fn main() {
let save_button = driver
    .query(By::Testid("settings-save"))
    .desc("settings save button")
    .single()
    .await?;
}

Use a strong selector before a text filter when the visible wording also matters:

#![allow(unused)]
fn main() {
let status = driver
    .query(By::Testid("settings-status"))
    .with_text("Saved")
    .desc("saved settings status")
    .single()
    .await?;
}

By::Testid targets the conventional data-testid attribute. Teams that use another app-owned attribute can express it with CSS, for example By::Css("[data-qa='settings-save']").

Picking An Element

The terminator at the end of the chain decides what comes back. Pick the one that matches what you actually need:

TerminatorReturns
.first().await?The first matching element. Errors if none.
.single().await?The matching element. Errors if 0 or 2+.
.first_opt().await?Option<WebElement>None if none match.
.all_from_selector().await?Elements from the first branch that matched.
.all_from_selector_required().await?Same, but errors if empty.
.any().await?Elements from every branch combined, possibly empty.
.any_required().await?Same, but errors if empty.
.exists().await?Waits for a match; returns false on timeout.
.not_exists().await?Waits for no matches; returns false on timeout.
.wait_until_gone().await?Waits for no matches; timeout is an error.

.any() and .all_from_selector() differ when you’ve used .or(): .any() runs every branch and returns the union of matches; .all_from_selector() short-circuits on the first branch that finds something.

The semantic difference between single() and first() is worth calling out: single() is a contract that there should be exactly one match. If two elements appear it returns an error rather than silently picking one — useful for catching a sloppy selector.

Multiple Selectors With .or()

Chain .or() to try multiple selectors in parallel. The first branch that matches wins:

#![allow(unused)]
fn main() {
let elem = driver
    .query(By::Css(".legacy-button"))
    .or(By::Css(".new-button"))
    .first()
    .await?;
}

Each branch is checked once per poll iteration, so a slow page that serves either layout will resolve as soon as one appears.

Filters

Narrow a branch with chained filters. State filters short-circuit on the WebDriver side (cheap):

#![allow(unused)]
fn main() {
let button = driver
    .query(By::Css("button.submit"))
    .and_displayed()
    .and_enabled()
    .and_clickable()
    .first()
    .await?;
}

Negative variants are also available: .and_not_displayed(), .and_not_enabled(), .and_not_selected(), .and_not_clickable().

Attribute, property, text, and class filters take a Needle — any type that implements the stringmatch crate’s matching trait. A plain &str is exact-match; use StringMatch for partial / case-insensitive / word-boundary matches:

#![allow(unused)]
fn main() {
use thirtyfour::stringmatch::StringMatchable;

let btn = driver
    .query(By::Testid("account-submit"))
    .with_text("Submit".match_partial().case_insensitive())
    .first()
    .await?;
}

Available filter families (each has a with_* and a without_* form):

  • Text: .with_text(needle) — visible text content
  • Class: .with_class("name")class attribute contains name
  • Tag: .with_tag("button")
  • Id: .with_id("submit")
  • Value: .with_value(needle) — for inputs
  • Attribute(s): .with_attribute("data-state", "ready"), .with_attributes([(name, needle), ...])
  • Property(ies): .with_property(name, needle), .with_properties(...)
  • CSS property(ies): .with_css_property("color", "rgb(0, 0, 0)"), .with_css_properties(...)

Each filter triggers an extra WebDriver round trip per poll iteration, so prefer narrowing the initial By selector when you can. Start with an app-owned test ID or stable CSS selector, add filters only for behavior the test needs to verify, and reserve XPath for targets CSS cannot express.

Custom Predicates

When a built-in filter isn’t enough, supply your own:

#![allow(unused)]
fn main() {
let chosen = driver
    .query(By::Tag("li"))
    .with_filter(|elem| async move {
        Ok(elem.text().await?.starts_with("Status:"))
    })
    .first()
    .await?;
}

A predicate is any async function returning WebDriverResult<bool> for a given &WebElement.

Timeouts And Polling

Override the poll cadence on a single query:

#![allow(unused)]
fn main() {
use std::time::Duration;

let slow = driver
    .query(By::Id("late-loader"))
    .wait(Duration::from_secs(60), Duration::from_secs(1))
    .single()
    .await?;
}

Use .nowait() to opt out of polling entirely (one attempt, return immediately):

#![allow(unused)]
fn main() {
let exists = driver.query(By::Id("maybe")).nowait().exists().await?;
let absent = driver.query(By::Id("maybe")).nowait().not_exists().await?;
}

Each branch is attempted at most once with .nowait(). exists() may stop at the first matching branch; not_exists() must check every branch because all of them must be absent. Without .nowait(), not_exists() polls until the query has no matches and returns false if matches remain when polling ends.

When disappearance is required rather than optional, use the error-returning form:

#![allow(unused)]
fn main() {
driver
    .query(By::Testid("saving-indicator"))
    .desc("saving indicator")
    .wait_until_gone()
    .await?;
}

“Gone” means no element currently matches any branch after that branch’s filters in the query’s source context. A replacement node that still matches keeps the query waiting. This returns immediately when the query is already absent and returns a WebDriverError::Timeout if any branch still matches at the deadline.

The default poller is 20 seconds with 500ms intervals. To change it for the whole WebDriver, supply a custom WebDriverConfig — see [ElementPollerWithTimeout] in the API docs.

Better Error Messages

Attach a human-readable description so timeout errors say what you were looking for:

#![allow(unused)]
fn main() {
let cart = driver
    .query(By::Css("[data-cart-count]"))
    .desc("shopping cart badge")
    .first()
    .await?;
}

If the query times out, the error includes "shopping cart badge" instead of just the raw CSS selector.

A Note On find() / find_all()

You may run across find() and find_all() methods on WebDriver and WebElement. They exist to mirror the W3C WebDriver specification — a one-shot lookup with no polling, no filters, and a thin error if nothing matches. They’re fine for the rare case where you genuinely want exactly that, but for everyday automation prefer query(): it handles slow loads, missing elements, and flickering DOMs more gracefully and gives better diagnostics when something goes wrong.

API Reference

For the full method list and per-method semantics, see ElementQuery on docs.rs.

Waiting For Element Changes

ElementQuery repeatedly evaluates selectors and filters, including when you need all matching elements to disappear. ElementWaiter watches an element you already have until it reaches a particular state — visible, clickable, stale, with certain text, and so on. Reach for it whenever you’ve clicked something and need the held element to change before you continue.

#![allow(unused)]
fn main() {
let button = driver.query(By::Css(".save")).single().await?;
button.click().await?;
button.wait_until().not_displayed().await?;
}

wait_until() is available on every WebElement. It returns an ElementWaiter that polls the element until either a predicate matches or the timeout elapses.

Keep Interactions Explicit

For a fresh target, put readiness on a described query, choose cardinality, perform the action, and then query for the user-visible outcome:

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;

async fn save_settings(driver: &WebDriver) -> WebDriverResult<()> {
    let save = driver
        .query(By::Testid("settings-save"))
        .and_clickable()
        .desc("settings save button")
        .single()
        .await?;

    save.click().await?;

    driver
        .query(By::Testid("settings-saved"))
        .and_displayed()
        .with_text("Settings saved")
        .desc("settings saved confirmation")
        .single()
        .await?;
    Ok(())
}
}

For an element you already resolved and intentionally want to keep, wait on that element before acting:

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;
async fn submit(save: WebElement) -> WebDriverResult<()> {
save.click_when_ready().await?;
Ok(())
}
}

click_when_ready() is the common two-step sequence wait_until().clickable().await? followed by click().await. Keeping the readiness helper on a resolved object preserves the selector and cardinality choices made when that object was found.

Text entry has application-specific replacement semantics, so make the choice to clear or append visible in the code:

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;

async fn replace_email(driver: &WebDriver) -> WebDriverResult<()> {
    let email = driver
        .query(By::Testid("account-email"))
        .and_displayed()
        .and_enabled()
        .desc("account email input")
        .single()
        .await?;

    email.clear().await?;
    email.send_keys("ada@example.test").await?;
    Ok(())
}
}

Here, “clickable” means displayed and enabled. It cannot guarantee that an overlay will not intercept the click, that the DOM will not replace the element, or that page state will not change between the readiness check and the action. A held element can become stale; re-query when re-rendering is expected, or keep the selector in an ElementResolver inside a Component.

Selector-taking helpers such as driver.click(selector) or clear_and_type(selector, text) would hide cardinality, descriptions, timeout, clearing, stale-element handling, and the expected outcome. A builder exposing those choices would duplicate ElementQuery without providing a stronger safety guarantee, so there is no selector-taking generic interaction API. Resolved objects can still provide concise helpers for unambiguous common sequences: WebElement::click_when_ready() and the same-named ElementResolver<WebElement> methods keep their resolution behavior explicit in the receiver type. Retrying clicks automatically can repeat a non-idempotent action, so these helpers do not retry the click itself. Put repeated application behavior in a Component intent method such as save_settings() or submit_credentials(), where those choices and the outcome are known.

Built-In Predicates

State predicates polled directly via WebDriver:

MethodWaits until the element is…
.displayed().await?rendered (isDisplayed returns true)
.not_displayed().await?hidden
.enabled().await?not disabled
.not_enabled().await?disabled
.selected().await?selected (checkboxes, options, radios)
.not_selected().await?deselected
.clickable().await?both displayed and enabled
.not_clickable().await?hidden or disabled
.stale().await?detached from the DOM

.stale() is especially useful right after a click: it waits for the concrete remote element you acted on to detach. It does not prove that navigation or the next page has finished loading.

Text, Class, Attribute, Property Waits

Each of these takes a Needle (from the stringmatch crate) — a plain &str for exact match, or a StringMatch for partial / case-insensitive / word-boundary matches.

MethodWaits until…
.has_text(needle)the element’s text matches
.lacks_text(needle)the element’s text does not match
.has_class("name")the element’s class list contains it
.lacks_class("name")the class is no longer present
.has_value(needle)the input’s value matches
.lacks_value(needle)the input’s value no longer matches
.has_attribute(name, needle)a single attribute matches
.lacks_attribute(name, needle)a single attribute no longer matches
.has_attributes([...])several attributes match together
.lacks_attributes([...])none of those attributes match
.has_property(name, needle)a JS property matches
.lacks_property(name, needle)a JS property does not match
.has_properties([...])several properties match together
.lacks_properties([...])none of those properties match
.has_css_property(name, needle)a computed CSS property matches
.lacks_css_property(name, needle)a computed CSS property does not match
.has_css_properties([...])several CSS properties match together
.lacks_css_properties([...])none of those CSS properties match
#![allow(unused)]
fn main() {
use thirtyfour::stringmatch::StringMatchable;

elem.wait_until()
    .has_text("Order received".match_partial().case_insensitive())
    .await?;
}

Custom Timeouts And Error Messages

Override the poll cadence on a single wait:

#![allow(unused)]
fn main() {
use std::time::Duration;

elem.wait_until()
    .wait(Duration::from_secs(60), Duration::from_secs(1))
    .clickable()
    .await?;
}

Attach a custom error message so a timeout reads in plain English:

#![allow(unused)]
fn main() {
elem.wait_until()
    .error("Timed out waiting for the spinner to disappear")
    .stale()
    .await?;
}

Custom Predicates

For anything the built-ins don’t cover, pass your own predicate. It gets a &WebElement and returns WebDriverResult<bool>:

#![allow(unused)]
fn main() {
elem.wait_until()
    .condition(|elem| async move {
        let value = elem.value().await?.unwrap_or_default();
        Ok(value.parse::<u32>().map_or(false, |n| n > 100))
    })
    .await?;
}

Pre-built predicate constructors live in the thirtyfour::extensions::query::conditions module. They share the same shape, so you can compose several into one wait:

#![allow(unused)]
fn main() {
use thirtyfour::extensions::query::conditions;

elem.wait_until()
    .conditions(vec![
        conditions::element_is_displayed(true),
        conditions::element_is_clickable(true),
    ])
    .await?;
}

The conditions module is also useful as a source of filter functions for ElementQuery::with_filter().

When To Reach For Which

  • Looking for an element on the page? Use ElementQuery.
  • Already have an element and waiting for it to change? Use ElementWaiter (this chapter).
  • Waiting for an element to disappear? Use query(...).wait_until_gone() when no element may match the complete query; use elem.wait_until().stale() when that specific resolved element must be detached. Use not_exists() when you want the same query polling as a boolean instead of a timeout error.

The distinction matters during re-rendering. stale() succeeds when its one remote element ID detaches, even if a replacement matches the old selector. wait_until_gone() re-runs every selector and filter and keeps waiting while a replacement matches. Neither wait implies that a navigation or the next page has completed.

API Reference

For the full method lists, see ElementQuery and ElementWaiter on docs.rs.

Components

When you automate a real web app, the same selectors and helper methods tend to show up over and over: “find the search bar,” “click the submit button,” “read the cart count.” Without structure, that logic spreads across your code and gets brittle. Components let you wrap a piece of UI — a single button, a form, a whole page — in a Rust struct, attach methods to it, and then reuse it anywhere.

This is the same idea as the Page Object Model from other Selenium ecosystems. In thirtyfour it’s just a derive macro on a struct, and any DOM node — not only “pages” — can be a Component.

A Quick Example

Suppose your page contains a search form:

<form id="search-form">
    <input type="text" id="search-input" />
    <button type="submit" data-testid="search-submit">Search</button>
</form>

Wrap it in a Component:

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;

#[derive(Debug, Clone, Component)]
pub struct SearchForm {
    base: WebElement,                              // The <form> itself.
    #[by(id = "search-input")]
    input: ElementResolver<WebElement>,            // The <input>.
    #[by(testid = "search-submit", description = "search submit button")]
    submit: ElementResolver<WebElement>,           // The <button>.
}

impl SearchForm {
    pub async fn search(&self, term: &str) -> WebDriverResult<()> {
        self.input.resolve().await?.send_keys(term).await?;
        self.submit.resolve().await?.click().await?;
        Ok(())
    }
}
}

Find the <form> and turn it into a SearchForm:

#![allow(unused)]
fn main() {
let form_el = driver.query(By::Id("search-form")).single().await?;
let form: SearchForm = form_el.into();         // From<WebElement> is derived.
form.search("Selenium").await?;
}

A few things are happening here:

  • The base: WebElement field is mandatory — it holds the outer element the component wraps. The derive macro implements From<WebElement> for you, so any WebElement becomes a SearchForm with .into().
  • Each #[by(...)] field is an ElementResolver. It doesn’t query anything until you call .resolve(), and it caches the result so subsequent calls don’t hit WebDriver again.
  • Resolver queries start from base, so components normally stay scoped to their own subtree. XPath is the exception: an expression beginning with // is document-rooted. Use .// when an XPath resolver must stay inside the component.

Component Shape And Generated API

Component can only be derived for a struct with named fields. The struct must contain exactly one base WebElement:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Component)]
pub struct Dialog {
    #[base]
    root: WebElement,
}
}

A field named base is detected automatically. Use #[base] when the field has another name. Do not use both forms in the same struct; the derive rejects multiple base fields. The base field type must be WebElement (either imported or written as thirtyfour::WebElement).

For each component, the derive generates:

  • pub fn new(base: WebElement) -> Self;
  • From<WebElement>, which delegates to new;
  • the Component trait implementation, including base_element().

Fields with #[by(...)] become element resolvers. Other fields are initialised with Default::default(), so each such field type must implement Default. A #[cfg(...)] attribute on a field is preserved in the generated constructor, allowing feature- or target-specific resolver and state fields.

The #[by(...)] Attribute

Every resolver field needs one #[by(...)] attribute. Use either exactly one selector or one custom resolver. Selector and description values must be string literals.

AttributeSelector used
id = "..."By::Id
css = "..."By::Css
xpath = "..."By::XPath
tag = "..."By::Tag
class = "..."By::ClassName
name = "..."By::Name
link = "..."By::LinkText
partial_link = "..."By::PartialLinkText
testid = "..."By::Testid

All selectors start a query from the component’s base element. For XPath, remember that //div searches from the document root while .//div searches within the base element.

Resolver Types And Cardinality

Built-in selector resolvers can return WebElement, a nested Component, or a Vec of either. The macro infers single- versus multi-element behaviour from the field type:

Field typeInferred modeDefault cardinality
ElementResolver<T>singleexactly one
ElementResolver<Vec<T>>multione or more
ElementResolverSinglesingleexactly one
ElementResolverMultimultione or more
user-defined resolver type aliassingleuse multi to force multi mode

Here, T is either WebElement or a type implementing Component + Clone. Nested components are constructed from each matched WebElement through their generated From<WebElement> implementation.

ElementResolver<Vec<T>> is detected through the normal ElementResolver, components::ElementResolver, and thirtyfour::components::ElementResolver paths. If a type alias hides the Vec, add the multi option. The convenience alias ElementResolverMulti is also detected when used directly.

Query Options

Pair a selector with options, separated by commas:

OptionApplies toEffect
singlesingleRequire exactly one match. This is the default.
firstsingleReturn the first match instead of requiring uniqueness.
not_emptymultiRequire one or more matches. This is the default.
allow_emptymultiReturn all matches, including an empty Vec.
multimultiForce multi mode when a type alias hides Vec<T>.
description = "..."bothInclude a human-readable label in query errors.
ignore_errorsbothIgnore query errors while polling and keep trying until the timeout.
wait(timeout_ms = N, interval_ms = N)bothOverride both the polling timeout and interval. Both arguments are required.
nowaitbothPoll once instead of waiting.

The default mode uses the query system’s default polling configuration. single and first are mutually exclusive, as are not_empty and allow_empty, and wait(...) and nowait. Single-only options cannot be used for a multi resolver, and multi-only options cannot be used for a single resolver. The derive also rejects repeated options and multiple selectors.

Examples:

#![allow(unused)]
fn main() {
type LinkListResolver = ElementResolver<Vec<WebElement>>;

// Use the first match if there are several.
#[by(id = "search-input", first)]
input: ElementResolver<WebElement>,

// All <li> elements; empty list is fine.
#[by(tag = "li", allow_empty)]
items: ElementResolver<Vec<WebElement>>,

// Wait up to 60 seconds, polling every second, with a friendly description.
#[by(css = ".loading-spinner", description = "loading spinner",
     wait(timeout_ms = 60_000, interval_ms = 1_000))]
spinner: ElementResolver<WebElement>,

// A custom alias that contains ElementResolver<Vec<WebElement>>.
#[by(partial_link = "Guide", multi, allow_empty)]
guide_links: LinkListResolver,
}

ElementResolver Methods

Every resolver field exposes the same handful of methods:

MethodBehaviour
.resolve().await?Run the query (or return the cached value) and return the result.
.resolve_present().await?Like .resolve(), but if the cached value is stale (detached from the DOM), re-query first. Use this when the page may have re-rendered.
.resolve_force().await?Drop the cache and re-query unconditionally.
.invalidate()Drop the cache without querying. The next .resolve() will run the query again.
.validate().await?Return the cached value if it’s still in the DOM, or None.

An ElementResolver<WebElement> also provides common element operations directly. Each method calls resolve_present() first, then forwards to the resolved WebElement:

MethodReturnsBehaviour
.click().await?()Resolve a present element and click it.
.click_when_ready().await?()Resolve, wait until displayed and enabled, then click.
.clear().await?()Resolve and clear the element’s contents.
.send_keys(value).await?()Resolve and send keys to the element.
.text().await?StringResolve and return its rendered text.
.value().await?Option<String>Resolve and return its value property.

The resolver receiver makes the re-resolution behavior part of the API, so the methods deliberately use the same names as WebElement:

#![allow(unused)]
fn main() {
self.email.clear().await?;
self.email.send_keys("ada@example.test").await?;
self.submit.click_when_ready().await?;
}

These helpers validate and potentially re-query once before the operation. They do not retry the operation if the page changes afterward or wait for an application outcome. They are available only on ElementResolver<WebElement>, not resolvers of a Component, Vec, or another custom type. Each call validates independently. If two operations must target exactly the same node, resolve once instead:

#![allow(unused)]
fn main() {
let email = self.email.resolve_present().await?;
email.clear().await?;
email.send_keys("ada@example.test").await?;
}

Two macros wrap the most common calls:

#![allow(unused)]
fn main() {
use thirtyfour::{resolve, resolve_present};

let elem = resolve!(self.input);            // self.input.resolve().await?
let elem = resolve_present!(self.input);    // self.input.resolve_present().await?
}

The macros are useful for chained method calls without scattering .await? around. They are exported from the crate root instead of the prelude so broad macro names do not get glob-imported accidentally:

#![allow(unused)]
fn main() {
resolve!(self.submit).click().await?;
}

Caching And Staleness

Resolvers cache the resolved value, which is what makes them cheap to call repeatedly inside helper methods. If the page changes underneath you — a re-render, a SPA route change, a click that swapped the DOM — the cached WebElement may go stale. Two strategies:

  • Use resolve_present!(field) instead of resolve!(field). It checks whether the cached value is still attached to the DOM and re-queries if not.
  • Call .invalidate() (or .resolve_force()) when you know the underlying DOM has moved.

For elements that change frequently, resolve_present is the safe default. For elements that are stable for the lifetime of the component, plain resolve is faster.

Nested Components

ElementResolver<T> works whenever T implements Component, so a Component can contain other Components:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Component)]
pub struct CheckboxOption {
    base: WebElement,                              // The <label>.
    #[by(css = "input[type='checkbox']")]
    input: ElementResolver<WebElement>,
}

impl CheckboxOption {
    pub async fn is_ticked(&self) -> WebDriverResult<bool> {
        let input = resolve!(self.input);
        Ok(input.prop("checked").await?.unwrap_or_default() == "true")
    }

    pub async fn tick(&self) -> WebDriverResult<()> {
        let input = resolve_present!(self.input);
        if input.is_clickable().await? && !self.is_ticked().await? {
            input.click().await?;
        }
        Ok(())
    }
}

#[derive(Debug, Clone, Component)]
pub struct CheckboxSection {
    base: WebElement,
    #[by(tag = "label", allow_empty)]
    options: ElementResolver<Vec<CheckboxOption>>,
}
}

When the resolver’s element type is itself a Component, the derive calls From<WebElement> on each match, so you get a Vec<CheckboxOption> back — already wrapped, ready to call methods on:

#![allow(unused)]
fn main() {
let section_el = driver.query(By::Id("checkbox-section")).single().await?;
let section: CheckboxSection = section_el.into();

for option in section.options.resolve().await? {
    option.tick().await?;
}
}

Non-Element Fields

Fields without a #[by(...)] attribute are initialised via Default::default(), so you can tack on bookkeeping state without extra boilerplate:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Component)]
pub struct LoginForm {
    base: WebElement,
    #[by(id = "username")]
    username: ElementResolver<WebElement>,
    attempt_count: u32,                            // Initialised to 0.
}
}

Custom Resolvers

If a built-in selector can’t express what you need — say, “find the button whose text starts with ‘Run’” — write the resolver yourself and reference it from custom = ...:

#![allow(unused)]
fn main() {
use thirtyfour::stringmatch::StringMatchable;

async fn resolve_run_button(elem: WebElement) -> WebDriverResult<WebElement> {
    elem.query(By::Tag("button"))
        .with_text("Run".match_partial().case_insensitive())
        .first()
        .await
}

#[derive(Debug, Clone, Component)]
pub struct Header {
    base: WebElement,
    #[by(custom = resolve_run_button)]
    run_button: ElementResolver<WebElement>,
}
}

A custom resolver receives the component’s base element by value and returns a WebDriverResult<T> matching the field’s ElementResolver<T> type. Pass a function path or another compatible expression, not a quoted string:

#![allow(unused)]
fn main() {
// Assume Row is another Component.
async fn resolve_rows(base: WebElement) -> WebDriverResult<Vec<Row>> {
    let elements = base.query(By::Css("tbody > tr")).all_from_selector().await?;
    Ok(elements.into_iter().map(Row::from).collect())
}

#[derive(Debug, Clone, Component)]
pub struct Table {
    base: WebElement,
    #[by(custom = resolve_rows)]
    rows: ElementResolver<Vec<Row>>,
}
}

The resolver function owns the complete lookup policy, including cardinality, polling, filtering, and error handling. Therefore custom = ... cannot be combined with a selector or any other #[by(...)] option. Single and Vec field types are still inferred normally; do not add multi to a custom resolver.

When To Reach For A Component

Any time you’re going to interact with the same UI element in more than one place, wrapping it in a Component is usually worth it. You get:

  • A single place to update if the underlying selectors change.
  • Scoped queries that can’t accidentally match unrelated elements.
  • Methods named after what the user does (“form.search(...), option.tick()”) instead of low-level click/type plumbing.
  • Cheap repeated access via the resolver cache.

For a one-off lookup deep in a single test, a plain driver.query(...) is fine. Once a piece of UI shows up in two or three places, lift it into a Component.

API Reference

For the full list of methods and attribute combinations, see:

With the default component feature enabled, use thirtyfour::prelude::*; imports the Component derive macro and ElementResolver. Import resolve! and resolve_present! explicitly from thirtyfour when you want the shorthand macros.

WebDriver Manager

thirtyfour automatically downloads and runs the appropriate webdriver binary (chromedriver, geckodriver, msedgedriver) for your installed browser, so you don’t have to manage the driver process yourself. The simple form is WebDriver::managed():

use thirtyfour::prelude::*;

#[tokio::main]
async fn main() -> WebDriverResult<()> {
    let driver = WebDriver::managed(DesiredCapabilities::chrome()).await?;
    driver.goto("https://www.rust-lang.org/").await?;
    driver.quit().await?;
    Ok(())
}

The first run downloads a matching driver into your system cache directory; subsequent runs reuse the cached binary. The driver subprocess starts when you create the session and is torn down when your last WebDriver handle drops.

Supported browsers: Chrome / Chromium, Firefox, Microsoft Edge, and Safari (macOS only — uses the system safaridriver, no download).

The rest of this chapter covers what else the manager can do — version pinning, sharing one manager across many sessions, supplying a pre-installed driver binary, and observing what’s happening as the manager works.

Picking A Driver Version

WebDriver::managed(caps) returns a builder. Awaiting it directly uses the defaults; chained methods customize what gets downloaded:

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;
use thirtyfour::manager::BrowserKind;
async fn run() -> WebDriverResult<()> {
let caps = DesiredCapabilities::chrome();

// Default: match the locally-installed browser.
let driver = WebDriver::managed(caps.clone()).await?;

// Latest stable from upstream metadata.
let driver = WebDriver::managed(caps.clone()).latest().await?;

// Pin a specific version (full or major-only for Chrome/Edge).
let driver = WebDriver::managed(caps.clone()).exact("126").await?;

// Read `browserVersion` from the capabilities object.
let driver = WebDriver::managed(caps.clone()).from_caps().await?;

// Skip the download/cache flow entirely — use an already-installed
// driver binary at this path. See "Using A Pre-Installed Driver" below.
let driver = WebDriver::managed(caps)
    .driver_binary(BrowserKind::Chrome, "/usr/local/bin/chromedriver")
    .await?;
Ok(()) }
}

Note on Firefox: Firefox releases and geckodriver releases don’t share version numbers (Firefox is on 150.x while geckodriver is on 0.36.0). The manager picks a compatible geckodriver from an embedded compatibility table. For .exact(...) on Firefox, pass a geckodriver tag like "0.36.0" — not a Firefox version.

Sharing One Manager Across Sessions

Each WebDriver::managed(caps) call constructs its own manager and spawns its own driver subprocess. To share one manager — and the driver subprocesses it owns — across many sessions, construct it explicitly with WebDriverManager::builder() and call .launch(caps) for each session:

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;
use thirtyfour::manager::WebDriverManager;
async fn run() -> WebDriverResult<()> {
let manager = WebDriverManager::builder().latest().build();

// One manager, multiple browsers.
let chrome  = manager.launch(DesiredCapabilities::chrome()).await?;
let firefox = manager.launch(DesiredCapabilities::firefox()).await?;
Ok(()) }
}

A single manager can drive any combination of supported browsers; it spawns one driver subprocess per (browser, version, host) combination as needed, and reuses an existing subprocess when a later .launch() call matches one that’s still alive.

Configuration

The most useful builder methods (all available on both WebDriverManager::builder() and WebDriver::managed(caps)):

MethodPurpose
.latest()Use the latest stable driver from upstream metadata.
.match_local()Match the locally-installed browser (default).
.from_caps()Read browserVersion from the capabilities.
.exact("126")Pin a specific driver version.
.driver_binary(browser, path)Use an already-installed driver binary; skip the download/cache flow for that browser.
.cache_dir(path)Override the on-disk driver cache.
.host(addr)Bind the driver to an address other than 127.0.0.1.
.download_timeout(d)Cap upstream metadata + download time (default 60s).
.ready_timeout(d)Cap how long to wait for /status (default 30s).
.offline() / .online()Forbid / allow downloads (default: online).
.stdio(StdioMode::Inherit)Show driver stdout/stderr in the parent terminal.
.on_status(fn)Attach a permanent status-event subscriber.
.on_driver_log(fn)Attach a permanent driver-log subscriber.

The default cache directory is <system cache dir>/thirtyfour/drivers.

Using A Pre-Installed Driver

If you’d rather manage the driver binary yourself — for instance because you ship a pinned chromedriver in a CI image — point the manager at it with .driver_binary(browser, path):

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;
use thirtyfour::manager::{WebDriverManager, BrowserKind};
async fn run() -> WebDriverResult<()> {
let manager = WebDriverManager::builder()
    .driver_binary(BrowserKind::Chrome, "/usr/local/bin/chromedriver")
    .build();
let driver = manager.launch(DesiredCapabilities::chrome()).await?;
Ok(()) }
}

Bare command names (e.g. "chromedriver") are resolved against the OS PATH. The version-resolution and download/cache flow is skipped for that browser; the binary is spawned as-is. If it doesn’t match the installed browser’s version, expect a runtime error from the driver when the session is started.

Offline Mode

If the driver you want is already in the cache, you can launch with no network access at all:

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;
use thirtyfour::manager::WebDriverManager;
async fn run() -> WebDriverResult<()> {
let manager = WebDriverManager::builder().offline().build();
let driver = manager.launch(DesiredCapabilities::chrome()).await?;
Ok(()) }
}

In offline mode, a cache miss returns an error rather than reaching out to the network.

Observing What The Manager Is Doing

The manager emits structured Status events at every step (resolving the version, hitting the cache, downloading, spawning the process, waiting for /status, starting / ending sessions, shutting the driver down). These events are also forwarded to the tracing ecosystem under the thirtyfour::manager target — for human-readable logs, just install a tracing-subscriber and you’re done.

For programmatic access, register a subscriber:

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;
use thirtyfour::manager::{WebDriverManager, Status};
async fn run() -> WebDriverResult<()> {
let manager = WebDriverManager::builder()
    .on_status(|s: &Status| println!("manager: {s}"))
    .build();
let driver = manager.launch(DesiredCapabilities::chrome()).await?;
Ok(()) }
}

You can also subscribe to raw stdout/stderr lines from the driver process itself via WebDriverManager::on_driver_log (manager-wide) or WebDriver::on_driver_log (just one session’s driver).

Further Reading

See the thirtyfour::manager module documentation for the full API, including WebDriverManager, WebDriverManagerBuilder, DriverVersion, and the Status event vocabulary.

Chrome DevTools Protocol

Chrome DevTools Protocol (CDP) is the lower-level inspection and control API that Chromium-based browsers expose for debugging, profiling, and automation. It predates W3C WebDriver BiDi and only works on Chromium browsers (Chrome, Edge, Brave, Opera, …) — but it covers a large surface area that WebDriver itself doesn’t, including network throttling, fine-grained DOM inspection, runtime JS evaluation with object handles, request interception, device emulation, and more.

thirtyfour ships typed bindings for the most-used CDP commands so you can call them like normal Rust async methods, plus an optional WebSocket-based session for event subscription (network requests firing, lifecycle events, console messages, …).

When To Use CDP vs BiDi

You want…Use
Cross-browser support (Chrome and Firefox)BiDi
Rich Chromium-only features (full Network, Fetch, DOM)CDP
W3C-standard, future-proof bidirectional protocolBiDi
To call any of Network.*, Fetch.*, Runtime.*, etc.CDP
To resolve a WebElement to a CDP RemoteObjectIdCDP

Both can be used in the same session. CDP is on by default; BiDi opts in via a capability and a feature flag — see the BiDi chapter for details.

Quick Start

CDP is enabled by the cdp feature, which is on by default. Reach the handle via WebDriver::cdp():

use thirtyfour::cdp::domains::network::{ConnectionType, NetworkConditions};
use thirtyfour::prelude::*;

#[tokio::main]
async fn main() -> WebDriverResult<()> {
    let driver = WebDriver::managed(DesiredCapabilities::chrome()).await?;

    // Domain-grouped typed methods.
    let info = driver.cdp().browser().get_version().await?;
    println!("Chrome: {}", info.product);

    // Throttle the network like Chrome DevTools' "Slow 3G".
    driver.cdp().network().emulate_network_conditions(NetworkConditions {
        offline: false,
        latency: 200,
        download_throughput: 256 * 1024,
        upload_throughput: 64 * 1024,
        connection_type: Some(ConnectionType::Cellular3G),
    }).await?;

    driver.cdp().network().clear_browser_cache().await?;

    driver.quit().await
}

driver.cdp() is cheap to call — it just clones the underlying Arc<SessionHandle>. There is no separate connection: typed commands flow over the WebDriver vendor endpoint goog/cdp/execute, so they work on any session backed by a Chromium driver (chromedriver, msedgedriver, Brave’s driver, …) and even through Selenium Grid.

Typed Commands

Every command in cdp::domains is a Rust struct that pairs the request type with its response type and the wire method name. The domain facades on Cdp (browser(), network(), page(), …) wrap the most common ones in convenient async methods:

DomainExamples
browserget_version
pagenavigate, reload, get_frame_tree, capture_screenshot
networkenable, clear_browser_cache, set_extra_http_headers, emulate_network_conditions
fetchenable, continue_request, fail_request, fulfill_request
runtimeevaluate, call_function_on, enable, disable
domdescribe_node, get_box_model, query_selector
emulationset_device_metrics_override, set_user_agent_override
inputdispatch_mouse_event, dispatch_key_event
targetget_targets, attach_to_target, detach_from_target
storageclear_data_for_origin, get_cookies
logenable, clear
performanceenable, get_metrics

For the full list of fields on each command and response, see the thirtyfour::cdp::domains API docs.

The Untyped Escape Hatch

For one-off commands not in the curated set, use Cdp::send_raw. The CDP protocol viewer is the canonical reference for method names and parameter shapes — every domain has its own page (e.g. Browser, Page, Network).

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;
async fn run(driver: WebDriver) -> WebDriverResult<()> {
// No-arg command: pass `()` for the params.
let info = driver.cdp().send_raw("Browser.getVersion", ()).await?;
println!("user agent: {}", info["userAgent"]);

// With params:
driver.cdp().send_raw(
    "Page.navigate",
    serde_json::json!({ "url": "https://example.com" }),
).await?;
Ok(()) }
}

send_raw accepts anything Serialize for the params, so you can also pass your own request struct. If you find yourself reaching for send_raw on the same command repeatedly, implement CdpCommand for your own request struct and get the typed send method back. This is the same trait the curated domains use — there’s no second-class API.

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use thirtyfour::cdp::CdpCommand;

#[derive(Serialize)]
struct GetTitle;

#[derive(Deserialize)]
struct GetTitleResult { title: String }

impl CdpCommand for GetTitle {
    const METHOD: &'static str = "Page.getTitle";
    type Returns = GetTitleResult;
}
}

Resolving WebElements To CDP Handles

When you already have a WebElement and want to hand it to a CDP command (e.g. DOM.getBoxModel, Runtime.callFunctionOn), there are two helpers on WebElement:

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;
async fn run(driver: WebDriver) -> WebDriverResult<()> {
use thirtyfour::cdp::domains::dom::GetBoxModel;

let elem = driver.find(By::Css("button.submit")).await?;
let backend_id = elem.cdp_backend_node_id().await?;

let box_model = driver.cdp().send(GetBoxModel {
    backend_node_id: Some(backend_id),
    ..Default::default()
}).await?;
println!("box model: {:?}", box_model.model);
Ok(()) }
}

These are bridges between the WebDriver and CDP worlds — they let you use WebElement to find things and CDP to inspect them.

Where Next

CDP Events

The default cdp feature gives you typed CDP commands over the WebDriver vendor endpoint. To listen for CDP events (Network.requestWillBeSent, Page.lifecycleEvent, Runtime.consoleAPICalled, …) you need a real WebSocket connection to the browser’s DevTools port. That’s what the optional cdp-events feature provides.

[dependencies]
thirtyfour = { version = "0.37.4", features = ["cdp-events"] }

cdp-events pulls in tokio-tungstenite and adds:

  • Cdp::connect — open a WebSocket session.
  • CdpSession — a session bound to one CDP sessionId, supporting both commands and event subscriptions.

How A Session Is Established

Cdp::connect discovers the browser-level CDP WebSocket URL from the session’s capabilities (in priority order: Selenium Grid’s se:cdp, the W3C webSocketDebuggerUrl field, then resolving goog:chromeOptions.debuggerAddress via /json/version). It dials the socket, calls Target.getTargets, picks the active page target, and runs Target.attachToTarget in flat mode so a single connection can multiplex multiple sessions.

You don’t have to think about any of that — it all happens inside connect:

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;
async fn run() -> WebDriverResult<()> {
let driver = WebDriver::managed(DesiredCapabilities::chrome()).await?;
let session = driver.cdp().connect().await?;
println!("attached as session {:?}", session.session_id());
driver.quit().await }
}

Subscribing To A Typed Event

Subscribe by type with CdpSession::subscribe. The session remembers which CDP domains have been enabled and sends Network.enable / Page.enable / Runtime.enable / Log.enable for you the first time you ask for an event from that domain — no manual session.send(network::Enable::default()) ceremony needed:

#![allow(unused)]
fn main() {
use futures_util::StreamExt;
use thirtyfour::cdp::events::RequestWillBeSent;
use thirtyfour::prelude::*;

async fn run(driver: WebDriver) -> WebDriverResult<()> {
let session = driver.cdp().connect().await?;

let mut requests = session.subscribe::<RequestWillBeSent>().await?;

driver.goto("https://example.com").await?;

while let Some(event) = requests.next().await {
    println!("→ {} (frame {:?})", event.document_url, event.loader_id);
    if event.document_url.starts_with("https://example.com") { break; }
}
driver.quit().await }
}

Each typed event implements CdpEvent, which pairs the wire method name with a Deserialize struct and (optionally) a domain-enable command. The stream is backed by a broadcast channel scoped to the session — call subscribe as many times as you like.

Common Event Types

The most-used events are re-exported from thirtyfour::cdp::events so a single use covers the common cases:

#![allow(unused)]
fn main() {
use thirtyfour::cdp::events::{
    RequestWillBeSent, ResponseReceived, LoadingFinished, LoadingFailed,
    LifecycleEvent, FrameNavigated, LoadEventFired,
    ConsoleApiCalled, ExceptionThrown,
    LogEntryAdded,
    RequestPaused,                         // Fetch — manual enable required
    AttachedToTarget, DetachedFromTarget,  // Target — manual enable required
};
}
DomainAuto-enabled?Typical events
networkYesRequestWillBeSent, ResponseReceived, LoadingFinished, LoadingFailed
pageYesLifecycleEvent, FrameNavigated, LoadEventFired
runtimeYesConsoleApiCalled, ExceptionThrown
logYesLogEntryAdded
fetchNoRequestPaused — call session.send(fetch::Enable { ... }) first
targetNoAttachedToTarget, DetachedFromTarget — call Target.setDiscoverTargets

For the full list and field details, see the thirtyfour::cdp::domains API docs.

Raw Events

When you need every event on the connection — for example because you’re observing child sessions opened by auto-attach, or because the event isn’t yet in the curated set — switch to a raw stream:

#![allow(unused)]
fn main() {
use futures_util::StreamExt;
use thirtyfour::prelude::*;
async fn run(driver: WebDriver) -> WebDriverResult<()> {
let session = driver.cdp().connect().await?;
let mut all = session.subscribe_all();
while let Some(raw) = all.next().await {
    println!("[{:?}] {}: {}", raw.session_id, raw.method, raw.params);
}
driver.quit().await }
}

Note that raw streams won’t auto-enable any domain — that’s only the typed subscribe::<E>() path. For raw, send the relevant *.enable yourself.

Adding Your Own Typed Event

If a CDP event isn’t in the curated set, write a Deserialize struct matching the wire shape and impl CdpEvent for …. Then subscribe::<MyEvent>().await? just works. Method names, parameter shapes, and which *.enable (if any) gates the event all come from the CDP protocol viewer — each domain has its own page, e.g. Network.

#![allow(unused)]
fn main() {
use serde::Deserialize;
use thirtyfour::cdp::CdpEvent;

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WebSocketCreated {
    request_id: String,
    url: String,
}

impl CdpEvent for WebSocketCreated {
    const METHOD: &'static str = "Network.webSocketCreated";
    // Network.* events fire only after Network.enable. The auto-enable path
    // picks this up from the const below.
    const ENABLE: Option<&'static str> = Some("Network.enable");
}
}

Set ENABLE = None for events whose domain doesn’t have a generic enable command (or where the default-enable would be too aggressive, like Fetch.enable with empty patterns intercepting everything). The user can still use the event — they just have to send the appropriate enable themselves.

Watch out for documentURL and friends. CDP uses SCREAMING acronyms on a number of fields (documentURL, baseURL, requestURL). A blanket rename_all = "camelCase" produces documentUrl (lowercase u) which won’t deserialise. Add an explicit #[serde(rename = "documentURL")] to those fields. Events whose wire shape can’t be deserialised as the typed struct are logged via tracing::warn! (target thirtyfour::cdp) and skipped — install a tracing subscriber to see them.

Lifecycle

A CdpSession is Clone (the underlying transport is Arc-shared) and detaches when explicitly dropped via CdpSession::detach. Letting it fall out of scope is also fine — it just won’t send a Target.detachFromTarget.

WebDriver BiDi

WebDriver BiDi is the W3C-standard bidirectional protocol that succeeds parts of Chrome DevTools Protocol with a cross-browser equivalent. It runs over a WebSocket negotiated by the standard webSocketUrl: true capability on New Session, and both Chromium-based browsers (chromedriver ≥ 115) and Firefox (geckodriver ≥ 0.31) implement it today.

If you’ve used CDP, BiDi will feel familiar: typed commands grouped by module (browsingContext.*, script.*, network.*, …), event subscriptions, request interception. The trade-off is coverage vs. portability:

CDP (Chromium-only)BiDi (cross-browser)
Surface areaLarger, Chromium-specificFull W3C BiDi spec covered
Portable across browsersNoYes
StandardisedNoYes (W3C)
ConnectionDirect WebSocketVia WebDriver session

Enabling BiDi

BiDi requires both a runtime opt-in (the webSocketUrl: true capability on the session) and the bidi feature flag at compile time:

[dependencies]
thirtyfour = { version = "0.37.4", features = ["bidi"] }

enable_bidi() on a capabilities builder sets the W3C webSocketUrl: true flag — that’s what tells the driver to spin up a BiDi WebSocket and return its URL on the session capabilities.

Quick Start

use thirtyfour::bidi::events::Load;
use thirtyfour::prelude::*;
use futures_util::StreamExt;

#[tokio::main(flavor = "multi_thread")]
async fn main() -> WebDriverResult<()> {
    let mut caps = DesiredCapabilities::chrome();
    caps.enable_bidi()?;             // <-- the W3C `webSocketUrl: true` opt-in.
    let driver = WebDriver::managed(caps).await?;

    // Lazy-connect the BiDi WebSocket on first use; cached afterwards.
    let bidi = driver.bidi().await?;

    let status = bidi.session().status().await?;
    println!("driver ready: {}", status.ready);

    // Pick the active browsing context.
    let context = bidi.browsing_context().top_level().await?;

    // Subscribe to load events and navigate. The typed `subscribe::<E>()`
    // call sends `session.subscribe` for us automatically; when the stream
    // drops, `session.unsubscribe` is sent in the background.
    let mut loads = bidi.subscribe::<Load>().await?;
    bidi.browsing_context().navigate(context.clone(), "https://example.com", None).await?;

    if let Some(load) = loads.next().await {
        println!("loaded: {}", load.url);
    }

    driver.quit().await
}

driver.bidi() is async because it lazily dials the WebSocket on first use; it’s cached afterwards, so subsequent calls just clone the handle. Cloning a BiDi is cheap (the transport is Arc-shared).

Modules

The curated typed bindings live under bidi::modules:

ModuleUse it for
sessionstatus, subscribe/unsubscribe, end
browserBrowser-wide control, user contexts, client windows, download behavior
browsing_contextTabs, frames, navigation, screenshots, printing, locating nodes, CSP
scriptevaluate, callFunction, preload scripts, realms
networkInterception, modify request/response, auth, data collectors, headers
storageCookies and partition lookup
loglog.entryAdded events
inputperformActions, releaseActions, setFiles, fileDialogOpened events
permissionssetPermission
emulationGeolocation, locale, timezone, screen, user-agent, scripting & touch
web_extensionInstall / uninstall web extensions

Each command in those modules is a Rust struct that implements BidiCommand, pairing the request type with its response type and the wire method name. Module facades on BiDi (session(), browsing_context(), network(), …) wrap the most common ones in convenient async methods. For everything else, build the struct directly and call bidi.send(MyCommand { ... }).await.

A few helpers worth knowing about:

  • bidi.browsing_context().top_level() — the id of the first top-level context (i.e. “the active tab”). Saves you doing tree.contexts[0].context.clone() by hand.
  • bidi.browsing_context().top_levels() — every top-level context.
  • bidi.network().add_intercept(...) — returns an InterceptGuard you can .remove().await explicitly, or just let drop (best-effort cleanup runs in the background).

Emulation Overrides

The emulation module overrides browser-emulated APIs (geolocation, locale, timezone, screen, user agent, scripting, touch, …). Each command applies globally by default; build the command struct directly to scope it to specific browsing contexts or user contexts. Pass None to clear an override.

#![allow(unused)]
fn main() {
use thirtyfour::bidi::modules::emulation::GeolocationCoordinates;
use thirtyfour::prelude::*;
async fn run(bidi: thirtyfour::bidi::BiDi) -> WebDriverResult<()> {
bidi.emulation()
    .set_geolocation_override(Some(GeolocationCoordinates {
        latitude: -33.8688,
        longitude: 151.2093,
        accuracy: Some(50.0),
        altitude: None,
        altitude_accuracy: None,
        heading: None,
        speed: None,
    }))
    .await?;

bidi.emulation().set_timezone_override(Some("Australia/Sydney".into())).await?;
bidi.emulation().set_locale_override(Some("en-AU".into())).await?;
Ok(()) }
}

Driver support for emulation.* is uneven at the time of writing — chromedriver implements most, geckodriver lags. The wire shape is stable; commands return error("unsupported operation") where the driver doesn’t yet handle them.

The Untyped Escape Hatch

Method names and parameter shapes for commands not in the curated set come from the BiDi spec’s Commands section — every module (session, browsingContext, script, network, …) lists its commands and their wire signatures, e.g. browsingContext.activate.

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;
async fn run(bidi: thirtyfour::bidi::BiDi) -> WebDriverResult<()> {
// No-arg command:
let res = bidi.send_raw("session.status", ()).await?;
println!("{res}");

// With params:
bidi.send_raw(
    "browsingContext.activate",
    serde_json::json!({ "context": "abc" }),
).await?;
Ok(()) }
}

send_raw accepts anything Serialize for the params. If you reach for it repeatedly on the same command, implement BidiCommand for your own request struct — the same trait the curated modules use. There’s no second-class API.

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use thirtyfour::bidi::BidiCommand;

#[derive(Serialize)]
struct ActivateContext { context: String }

#[derive(Deserialize)]
struct ActivateResult {}

impl BidiCommand for ActivateContext {
    const METHOD: &'static str = "browsingContext.activate";
    type Returns = ActivateResult;
}
}

What About CDP?

You can use both in the same session: CDP is on by default, BiDi opts in via the capability + feature flag. CDP gives you Chromium-only surface area that BiDi doesn’t yet cover; BiDi gives you a single event-driven control plane that also works on Firefox. See the CDP chapter for the comparison.

Where Next

BiDi Events

The driver only delivers events the client has explicitly subscribed to via session.subscribe. The typed bidi.subscribe::<E>() call takes care of that for you — it sends the wire-level subscribe on first call (per event method, per connection) and tears it down with session.unsubscribe when the last stream drops. You just open a stream and pull events off it.

Typed Subscription

#![allow(unused)]
fn main() {
use futures_util::StreamExt;
use thirtyfour::bidi::events::LogEntryAdded;
use thirtyfour::bidi::modules::log::LogLevel;
use thirtyfour::prelude::*;

async fn run(driver: WebDriver) -> WebDriverResult<()> {
let bidi = driver.bidi().await?;

// Auto-sends `session.subscribe`.
let mut events = bidi.subscribe::<LogEntryAdded>().await?;

let context = bidi.browsing_context().top_level().await?;
bidi.browsing_context()
    .navigate(
        context,
        "data:text/html,<script>console.log('hi');console.warn('warn')</script>",
        None,
    )
    .await?;

while let Some(entry) = events.next().await {
    let level = match entry.level {
        LogLevel::Debug => "DEBUG",
        LogLevel::Info  => "INFO",
        LogLevel::Warn  => "WARN",
        LogLevel::Error => "ERROR",
        LogLevel::Unknown(ref s) => s.as_str(),
    };
    println!("[{level}] {}", entry.text.as_deref().unwrap_or(""));
}
Ok(()) }
}

A typed event implements BidiEvent, pairing the wire method name with a Deserialize struct. Items where the wire shape can’t be deserialised as the requested type are logged via tracing::warn! (target thirtyfour::bidi) and skipped — install a tracing subscriber to see them.

Common Event Types

Every typed event is re-exported from thirtyfour::bidi::events so a single use covers the common cases:

#![allow(unused)]
fn main() {
use thirtyfour::bidi::events::{
    // Page lifecycle
    Load, DomContentLoaded, FragmentNavigated, HistoryUpdated,
    NavigationStarted, NavigationCommitted, NavigationAborted, NavigationFailed,
    // Tabs / frames
    ContextCreated, ContextDestroyed,
    // Downloads
    DownloadWillBegin, DownloadEnd,
    // User prompts and file dialogs
    UserPromptOpened, UserPromptClosed, FileDialogOpened,
    // Network
    BeforeRequestSent, ResponseStarted, ResponseCompleted, FetchError, AuthRequired,
    // Script / log
    RealmCreated, RealmDestroyed, ScriptMessage,
    LogEntryAdded,
};
}

Scoped Subscriptions

For events scoped to one browsing context (or one user context), use the explicit session.send(Subscribe { ... }) form — the auto-subscribe path is global. Two patterns:

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;
async fn run(bidi: thirtyfour::bidi::BiDi) -> WebDriverResult<()> {
use thirtyfour::bidi::modules::session::Subscribe;

let context = bidi.browsing_context().top_level().await?;

// Wire-level subscribe scoped to one tab. Then open the local stream
// to read it (the typed subscribe also bumps the global refcount, so
// `unsubscribe` won't fire mid-test if you mix them — but the cleanest
// pattern is to pick one approach per stream).
bidi.send(Subscribe {
    events: vec!["browsingContext.load".into()],
    contexts: vec![context],
    user_contexts: vec![],
}).await?;
let mut raw = bidi.subscribe_raw();
Ok(()) }
}

You can also subscribe by whole module name (e.g. "browsingContext") to opt in to every event in that module, again via the explicit form.

The full list of valid event names lives in the BiDi spec’s Events section — each module documents its own events, e.g. browsingContext.load. Module-level subscribes ("browsingContext", "network", "log", …) match the module headers in the spec.

Raw Events

When the event isn’t yet in the curated set, or you want a single firehose for debugging, switch to a raw stream:

#![allow(unused)]
fn main() {
use futures_util::StreamExt;
use thirtyfour::prelude::*;
async fn run(bidi: thirtyfour::bidi::BiDi) -> WebDriverResult<()> {
bidi.session().subscribe_many(["network".into(), "browsingContext".into()]).await?;
let mut all = bidi.subscribe_raw();
while let Some(event) = all.next().await {
    println!("{}: {}", event.method, event.params);
}
Ok(()) }
}

BiDi::subscribe_raw yields every event delivered on the connection without filtering or deserialisation. Note it doesn’t auto-subscribe — that’s why we explicitly call session().subscribe_many(...) first.

Network Interception

Network interception is a request-pause-and-continue loop driven by events:

  1. Subscribe to the appropriate event (BeforeRequestSent for the request phase, ResponseStarted for the response phase) before registering the intercept — otherwise you might miss the event you’re waiting on.
  2. Register the intercept with bidi.network().add_intercept(...), choosing one or more InterceptPhases and optional URL match patterns. The return value is an InterceptGuard that wraps the underlying id.
  3. The next matching request pauses on the wire. The event arrives with is_blocked = true and a request.id to identify it.
  4. Continue the request unmodified (bidi.network().continue_request(event.request.id)), modify it, fail it (fail_request), or synthesize a response (provide_response). For response-phase interception, use continue_response.
  5. When you’re done, drop the guard or call intercept.remove().await for explicit error handling.
#![allow(unused)]
fn main() {
use futures_util::StreamExt;
use thirtyfour::bidi::events::BeforeRequestSent;
use thirtyfour::bidi::modules::browsing_context::ReadinessState;
use thirtyfour::bidi::modules::network;
use thirtyfour::prelude::*;

async fn run(driver: WebDriver) -> WebDriverResult<()> {
let bidi = driver.bidi().await?;

// (1) Subscribe BEFORE adding the intercept (auto-sends `session.subscribe`).
let mut events = bidi.subscribe::<BeforeRequestSent>().await?;

// (2) Register the intercept. The returned guard removes the intercept
//     when it drops; call .remove() explicitly if you want the error.
let intercept = bidi
    .network()
    .add_intercept(vec![network::InterceptPhase::BeforeRequestSent], None)
    .await?;

let context = bidi.browsing_context().top_level().await?;

// (3) Kick off navigation in the background — it won't return until
//     we continue the paused request.
let nav = {
    let bidi = bidi.clone();
    let context = context.clone();
    tokio::spawn(async move {
        bidi.browsing_context()
            .navigate(context, "https://example.com/", Some(ReadinessState::Complete))
            .await
    })
};

// (4) Wait for the paused request, then let it through.
while let Some(event) = events.next().await {
    if event.is_blocked && event.request.url.starts_with("https://example.com/") {
        bidi.network().continue_request(event.request.id).await?;
        break;
    }
}

nav.await.map_err(|e| WebDriverError::FatalError(format!("nav join: {e}")))??;

// (5) Clean up — explicit form, surfaces any error.
intercept.remove().await?;
Ok(()) }
}

For the response phase, swap the phase enum and call continue_response instead. The shape of the loop is otherwise identical.

Adding Your Own Typed Event

Write a Deserialize struct matching the wire shape and impl BidiEvent:

#![allow(unused)]
fn main() {
use serde::Deserialize;
use thirtyfour::bidi::BidiEvent;

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PromptOpened {
    context: String,
    prompt_type: String,
    message: String,
}

impl BidiEvent for PromptOpened {
    const METHOD: &'static str = "browsingContext.userPromptOpened";
}
}

Then bidi.subscribe::<PromptOpened>().await? works the same as for the curated events. The wire shape and method name for this event are specified in browsingContext.userPromptOpened.

Lifecycle

A BiDi handle is Clone (the underlying transport is Arc-shared). Each typed subscribe::<E>() bumps a per-method refcount; when the last stream for a given event drops, session.unsubscribe is sent in the background on the current tokio runtime. Calling session.end() ends the BiDi session entirely.

Running against selenium

NOTE: This documentation assumes you are already familiar with Selenium. To learn more about Selenium, visit the documentation

NOTE: To run the selenium example, start selenium server and then run:

cargo run --example selenium_example

Below, you can find my recommended development environment for running selenium tests.

Essentially, you need three main things set up as a minimum:

  1. Selenium standalone running on some server, usually localhost at port 4444.

    For example, http://localhost:4444

  2. The webdriver for your browser somewhere in your PATH, e.g., chromedriver (Chrome) or geckodriver (Firefox)

  3. Your code that imports this library

If you want you can download selenium and the webdriver manually, copy the webdriver to somewhere in your path, then run selenium manually using java -jar selenium.jar.

However, this is a lot of messing around, and you’ll need to do it all again any time either selenium or the webdriver gets updated. A better solution is to run both selenium and webdriver in a docker container, following the instructions below.

Setting up Docker and Selenium

To install docker, see https://docs.docker.com/install/ (follow the SERVER section if you’re on Linux, then look for the Community Edition)

Once you have docker installed, you can start the selenium server, as follows:

docker run --rm -d -p 4444:4444 -p 5900:5900 --name selenium-server -v /dev/shm:/dev/shm selenium/standalone-chrome:4.1.0-20211123

For more information on running selenium in docker, visit docker-selenium

Translating Selenium And Playwright To thirtyfour

Use this guide when porting an existing Selenium or Playwright flow, or when an AI-generated example uses an API that does not exist in thirtyfour. The goal is to preserve the intent of the original test while expressing waits, selectors, session ownership, and reusable UI structure explicitly.

Concept Map

Selenium / Playwright conceptIdiomatic thirtyfour
Selenium find_elementquery(...).single().await? for a unique target, or first() when first-match behavior is intentional
Selenium find_elementsquery(...).all_from_selector_required().await?, any_required(), or their empty-allowed variants
Selenium WebDriverWait / expected conditionsQuery filters while locating, then element.wait_until() for later state changes
Playwright locator()driver.query(...) or a scoped element.query(...); queries are builders, not persistent locator handles
Playwright locator auto-waitingExplicit query filters such as and_displayed() and explicit element waits such as wait_until().clickable()
Selenium page objectsComponent derive plus ElementResolver, with selectors kept inside the component
Playwright browser contextsSeparate WebDriver sessions for isolation, or explicit cookie/storage cleanup when session reuse is deliberate
Local browser launchWebDriver::managed(...)
Selenium Grid / remote WebDriverWebDriver::new(...) or WebDriver::builder(...)

Element Lookup

Selenium commonly performs a one-shot lookup:

submit = driver.find_element(By.CSS_SELECTOR, "[data-testid='checkout-submit']")

Playwright represents the target as a locator:

const submit = page.getByTestId("checkout-submit");

In thirtyfour, build a polling query and choose the cardinality that is part of the page contract:

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;
async fn example(driver: &WebDriver) -> WebDriverResult<()> {
let submit = driver
    .query(By::Testid("checkout-submit"))
    .and_displayed()
    .desc("checkout submit button")
    .single()
    .await?;
Ok(())
}
}

find() and find_all() still exist as low-level, one-shot WebDriver operations. They are not replacements for Selenium’s explicit-wait patterns or Playwright’s auto-waiting. Prefer element queries for normal automation.

For multiple elements, make empty-result behavior explicit:

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;
async fn example(driver: &WebDriver) -> WebDriverResult<()> {
let rows = driver
    .query(By::Css("[data-testid='results'] > [role='row']"))
    .desc("search result rows")
    .all_from_selector_required()
    .await?;
Ok(())
}
}

Use all_from_selector() when an empty collection is valid. When a query has .or(...) branches, use any() / any_required() to combine matches from all branches.

Waiting And Interaction Readiness

Selenium often combines WebDriverWait with an expected condition, while Playwright waits inside actions. thirtyfour keeps the required state visible in the test.

Use query filters when locating an element that must already be ready:

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;
async fn example(driver: &WebDriver) -> WebDriverResult<()> {
let save = driver
    .query(By::Testid("settings-save"))
    .and_clickable()
    .desc("settings save button")
    .single()
    .await?;
save.click().await?;
Ok(())
}
}

Use wait_until() when an element you already hold must change state:

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;
async fn example(driver: &WebDriver) -> WebDriverResult<()> {
let spinner = driver
    .query(By::Testid("save-spinner"))
    .desc("save progress indicator")
    .single()
    .await?;
spinner.wait_until().not_displayed().await?;

driver
    .query(By::Testid("save-status"))
    .with_text("Saved")
    .and_displayed()
    .desc("saved settings status")
    .single()
    .await?;
Ok(())
}
}

Do not translate an implicit or auto-wait into a fixed sleep. See Waiting For Element Changes for the full predicate set and timeout controls.

Scoped Locators

A Playwright locator chain scopes later lookups. Do the same by querying from a container WebElement:

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;
async fn example(driver: &WebDriver) -> WebDriverResult<()> {
let dialog = driver
    .query(By::Testid("delete-dialog"))
    .and_displayed()
    .desc("delete confirmation dialog")
    .single()
    .await?;

dialog
    .query(By::Testid("confirm-delete"))
    .and_clickable()
    .desc("confirm delete button")
    .single()
    .await?
    .click()
    .await?;
Ok(())
}
}

Unlike a Playwright Locator, an ElementQuery terminator resolves concrete WebElement values; later actions do not automatically re-resolve those elements. Run the query again when you need to locate the target after a render, or use an ElementResolver inside a Component for reusable stale-element recovery.

Page Objects Become Components

Translate a Selenium page object into a focused Component that owns selectors and exposes user-intent methods:

use thirtyfour::prelude::*;

#[derive(Debug, Clone, Component)]
struct LoginForm {
    base: WebElement,
    #[by(testid = "login-email")]
    email: ElementResolver<WebElement>,
    #[by(testid = "login-password")]
    password: ElementResolver<WebElement>,
    #[by(testid = "login-submit")]
    submit: ElementResolver<WebElement>,
}

impl LoginForm {
    async fn sign_in(&self, email: &str, password: &str) -> WebDriverResult<()> {
        self.email.resolve_present().await?.send_keys(email).await?;
        self.password
            .resolve_present()
            .await?
            .send_keys(password)
            .await?;

        let submit = self.submit.resolve_present().await?;
        submit.wait_until().clickable().await?;
        submit.click().await
    }
}

The default component feature exports Component and ElementResolver through thirtyfour::prelude::*. See Components for resolver cardinality, nesting, caching, and stale-element behavior.

Local, Remote, And Isolated Sessions

For a local installed browser, replace Playwright’s browser launcher or a manually started local driver with the managed setup:

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;
async fn example() -> WebDriverResult<()> {
let driver = WebDriver::managed(DesiredCapabilities::chrome()).await?;
let flow_result: WebDriverResult<()> = async {
    driver.goto("https://example.test").await?;
    Ok(())
}
.await;

let quit_result = driver.quit().await;
flow_result?;
quit_result
}
}

For Selenium Grid or another externally owned endpoint, keep that lifecycle external:

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;
async fn example() -> WebDriverResult<()> {
let driver = WebDriver::builder(
    "http://selenium-grid:4444",
    DesiredCapabilities::chrome(),
)
.await?;
driver.quit().await?;
Ok(())
}
}

See WebDriver Manager for local driver ownership and Manual WebDriver Setup for remote builder configuration.

A Playwright browser context is not the same as a cloned WebDriver. Clones control the same browser session and must not run independent flows concurrently. Prefer a fresh managed session per isolated test. If session reuse is intentional, explicitly reset cookies and application storage between flows; do not assume a new context was created.

APIs Not Provided By thirtyfour

These names are common translation mistakes, especially in generated code:

Do not inventUse instead
page.locator(...) / driver.locator(...)driver.query(...) or element.query(...)
get_by_role, get_by_label, get_by_textBy::Testid, stable semantic CSS, or a strong selector plus a text filter
expect(locator).to_be_visible()Query with and_displayed() or call element.wait_until().displayed()
locator.click() with hidden auto-waitingQuery with and_clickable(), or call WebElement::click_when_ready() on a deliberately held element
browser.new_context()A separate WebDriver session, or deliberate cookie/storage cleanup
Selenium WebDriverWait / ExpectedConditions typesquery() filters and wait_until() predicates
Selenium ActionChains spellingdriver.action_chain() and the ActionChain API

thirtyfour is a WebDriver client, so it intentionally exposes WebDriver sessions and elements rather than reproducing Playwright’s object model. When a translation needs richer structure, build it from queries, explicit waits, and Components instead of guessing at a similarly named method.

Translation Checklist

  • Replace routine one-shot lookup with a described polling query.
  • Choose single(), first(), or a multi-element terminator deliberately.
  • Preserve interaction readiness with query filters or wait_until().
  • Scope through a container element or Component.
  • Use WebDriver::managed locally and new / builder for remote services.
  • Use separate sessions for isolated concurrent flows.
  • Preserve explicit quit() on both success and failure paths.

Apply the Reliable AI-Generated Tests checklist after translating a flow.

For the source ecosystem behavior referenced here, see Selenium’s official element finder and waiting documentation, and Playwright’s official locator and actionability documentation.

Running the tests for thirtyfour

You only need to run the tests if you plan on contributing to the development of thirtyfour. If you just want to use the crate in your own project, you can skip this section.

The integration tests use WebDriver::managed, so they auto-download and lifetime-manage their own chromedriver / geckodriver. You only need a local browser install — Chrome by default.

cargo test

The first run downloads the matching driver into your system cache (~/.cache/thirtyfour/drivers on Linux, ~/Library/Caches/thirtyfour/drivers on macOS); subsequent runs reuse it. Within each test binary, sibling tests share a single chromedriver subprocess via a static WebDriverManager plus an “anchor” session, so launching is a one-time cost per binary.

To run against Firefox instead, set THIRTYFOUR_BROWSER:

THIRTYFOUR_BROWSER=firefox cargo test

Manager-gated test suites

A few test files are gated behind the manager-tests cargo feature because they exercise the manager subsystem itself or run the heavier CDP suites:

# manager lifecycle smokes (Chrome, Firefox, Edge, Safari)
cargo test -p thirtyfour --features manager-tests --test managed -- --test-threads=1

# typed CDP commands
cargo test -p thirtyfour --features manager-tests --test cdp_typed -- --test-threads=1

# CDP event subscription (also needs cdp-events)
cargo test -p thirtyfour --features manager-tests,cdp-events --test cdp_events -- --test-threads=1

# ElementQuery filter predicates
cargo test -p thirtyfour --features manager-tests --test query_filters -- --test-threads=1

Manual WebDriver Setup

This appendix covers running the webdriver yourself instead of letting thirtyfour manage it for you. Common reasons:

  • You’re connecting to a remote Selenium grid or a driver running in a container.
  • You want a long-lived driver process you can reuse across many runs of your program.
  • You’ve disabled the manager Cargo feature to slim your build.

In any of these cases you’ll need to download a webdriver binary, start it on a known port, and pass that URL to WebDriver::new(...).

Downloading A WebDriver Binary

Pick the binary that matches your browser:

  • For Chrome, download chromedriver
  • For Firefox, download geckodriver
  • For Microsoft Edge, download msedgedriver
  • For Safari (macOS), safaridriver ships with the OS — run safaridriver --enable once to allow remote automation.

The webdriver may be zipped. Unzip it and place the binary somewhere in your PATH. Make sure it is executable and that you have permission to run it.

Make sure the webdriver version matches the version of the browser you have installed. If they don’t match, the driver returns an error when you try to start a session. Browser auto-updates are a common cause of drift here.

Starting The WebDriver

Open a terminal and run the binary directly:

chromedriver        # listens on port 9515 by default
geckodriver         # listens on port 4444 by default
msedgedriver        # listens on port 9515 by default

Leave it running in that terminal — it’s the server that thirtyfour will talk to.

Connecting From Your Code

Pass the driver’s URL to WebDriver::new(...):

use thirtyfour::prelude::*;

#[tokio::main]
async fn main() -> WebDriverResult<()> {
    let caps = DesiredCapabilities::chrome();
    let driver = WebDriver::new("http://localhost:9515", caps).await?;
    driver.goto("https://www.rust-lang.org/").await?;
    driver.quit().await?;
    Ok(())
}

For Firefox, use DesiredCapabilities::firefox() and the geckodriver URL (http://localhost:4444).

Frequently Asked Questions

If you have a question not answered here, head over to the discussion page and ask your question there.

Remember to search through existing discussions and issues to check that your question hasn’t already been asked/answered.

Why Doesn’t The Browser Close On Exit?

Rust does not have async destructors, which means there is no reliable way to execute an async HTTP request on Drop and wait for it to complete. This means you are in charge of closing the browser at the end of your code, via a call to WebDriver::quit() as in the above example.

If you do not call WebDriver::quit() then the browser will stay open until it is either explicitly closed later outside your code, or the session times out.

Further Reading

Thirtyfour Reference Documentation

You can find the Reference Documentation here.

Selenium Documentation

The Selenium Project provides a lot of documentation around Web Browser automation.

Check it out here

The W3C WebDriver V1 Specification

For low level documentation for the W3C WebDriver V1 specification, click here