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.0"

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.0"
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.find(By::Id("search-form")).await?;

    // Find element from element.
    let elem_text = elem_form.find(By::Id("searchInput")).await?;

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

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

    // Look for header to implicitly wait for the page to load.
    driver.query(By::ClassName("firstHeading")).first().await?;
    assert_eq!(driver.title().await?, "Selenium – Wikipedia");

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

    Ok(())
}

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.

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.find(By::Id("search-form")).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.

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.find(By::Id("searchInput")).await?;
}

And again, 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.find(By::Css("button[type='submit']")).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 is visible.

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

Better Element Queries

To have thirtyfour “wait” until an element has loaded, we use the query() method which provides a “builder” interface for constructing more flexible queries. Again, this uses one of the By selectors, and we tell it to return only the first matching element.

The query() method will poll every half-second until it finds the element. If it cannot find the element within 30 seconds, it will timeout and return an error. The polling time and timeout duration can be changed using WebDriverConfig, or you can also override them for a given query.

The query() method is the recommended way to search for elements. The find() and find_all() methods exist only for compatibility with the webdriver spec. The query() method uses these under the hood, but adds polling and other niceties on top, including giving you more details about your query if 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!

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).
  • 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.

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 and By::XPath are the most expressive; the others are convenience wrappers and most are implemented as CSS under the hood.

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?bool — does it exist?
.not_exists().await?bool — does it stay absent for the timeout?

.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::Tag("button"))
    .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. CSS and XPath are usually the right tool for complex matches.

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?;
}

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 waits for an element to appear. ElementWaiter waits for an element you already have to reach a particular state — visible, clickable, gone, with certain text, etc. Reach for it whenever you’ve clicked something and need the page to settle 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.

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 lets you wait for the element you just acted on to disappear before assuming the next page is loaded.

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? Either works. query(...).not_exists() polls until the selector returns nothing; elem.wait_until().stale() polls until that specific element is detached.

API Reference

For the full method list, see 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">Search</button>
</form>

Wrap it in a Component:

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

#[derive(Debug, Clone, Component)]
pub struct SearchForm {
    base: WebElement,                              // The <form> itself.
    #[by(id = "search-input")]
    input: ElementResolver<WebElement>,            // The <input>.
    #[by(css = "button[type='submit']")]
    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.
  • Resolvers always search relative to base, so a SearchForm can only ever find elements inside its own <form>. That scoping is one of the big wins over scattered driver.query(...) calls — you can’t accidentally match elements from a different form on the page.

The #[by(...)] Attribute

Every resolver field needs a #[by(...)] attribute. The first part picks the selector:

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

Pair the selector with extra options, comma-separated. Which options apply depends on whether the resolver is single or multi (the macro infers that from the field type — ElementResolver<T> is single, ElementResolver<Vec<T>> is multi):

OptionApplies toEffect
singlesingleMatch exactly one element. Errors if 0 or 2+. Default.
firstsingleMatch the first element instead.
not_emptymultiMatch at least one element. Errors if empty. Default.
allow_emptymultiMatch zero or more elements. Empty Vec is OK.
description = "..."bothAttach a label that shows up in timeout error messages.
wait(timeout_ms = N, interval_ms = N)bothOverride the poll cadence for this field.
nowaitbothTry once without polling.
ignore_errorsbothForward ignore_errors to the underlying query.
multimultiForce multi-resolver behaviour. Only needed for custom type aliases.
custom = my_resolver_fnbothUse a custom resolver function (see Custom Resolvers). Mutually exclusive with the other options.

Examples:

#![allow(unused)]
fn main() {
// 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>,
}

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.

Two macros wrap the most common calls:

#![allow(unused)]
fn main() {
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:

#![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, Default)]
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 and returns a WebDriverResult<T> matching the field’s type. custom = ... is mutually exclusive with the selector and modifier options — the function is doing all of that work itself.

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:

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.0", 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.0", 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

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