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-eventsfeature. - WebDriver BiDi — the W3C-standard cross-browser bidirectional protocol,
available behind the
bidifeature. 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-eventsfeature - WebDriver BiDi — the W3C-standard cross-browser bidirectional protocol, behind the
bidifeature - 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
thirtyfourat 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:
- Picks a
chromedriverversion that matches your installed Chrome (downloading and caching the binary the first time, reusing it after). - Spawns it as a child process on a free local port and waits for it to be ready.
- 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
thirtyfourat 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
Inspectortab, 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 theComponentderive 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 theWebDriver::cdp()/WebElement::cdp_*()integrations. Works on any Chromium-based session via the WebDriver vendor endpointgoog/cdp/execute— no extra connection required. See the CDP overview.cdp-events: WebSocket-based CDP session with event subscription. AddsCdp::connect()andCdpSession; pulls intokio-tungstenite. Off by default. See CDP Events.bidi: WebDriver BiDi (W3C) — typed commands and event subscription over a WebSocket negotiated via thewebSocketUrl: truecapability. Off by default; pulls intokio-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:
- A starting selector (
By::Id("search-form")). - Optional filters and chained alternatives (e.g.
.with_text("Hello"),.or(By::Css("..."))). - 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():
| Selector | Matches |
|---|---|
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:
| Terminator | Returns |
|---|---|
.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")—classattribute containsname - 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:
| Method | Waits 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.
| Method | Waits 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: WebElementfield is mandatory — it holds the outer element the component wraps. The derive macro implementsFrom<WebElement>for you, so anyWebElementbecomes aSearchFormwith.into(). - Each
#[by(...)]field is anElementResolver. 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 aSearchFormcan only ever find elements inside its own<form>. That scoping is one of the big wins over scattereddriver.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:
| Attribute | Selector 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):
| Option | Applies to | Effect |
|---|---|---|
single | single | Match exactly one element. Errors if 0 or 2+. Default. |
first | single | Match the first element instead. |
not_empty | multi | Match at least one element. Errors if empty. Default. |
allow_empty | multi | Match zero or more elements. Empty Vec is OK. |
description = "..." | both | Attach a label that shows up in timeout error messages. |
wait(timeout_ms = N, interval_ms = N) | both | Override the poll cadence for this field. |
nowait | both | Try once without polling. |
ignore_errors | both | Forward ignore_errors to the underlying query. |
multi | multi | Force multi-resolver behaviour. Only needed for custom type aliases. |
custom = my_resolver_fn | both | Use 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:
| Method | Behaviour |
|---|---|
.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 ofresolve!(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:
thirtyfour::components— theComponenttrait,ElementResolver, and helper wrappers.thirtyfour_macros::Component— the derive macro and every supported attribute.
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
geckodriverreleases don’t share version numbers (Firefox is on150.xwhilegeckodriveris on0.36.0). The manager picks a compatiblegeckodriverfrom an embedded compatibility table. For.exact(...)on Firefox, pass ageckodrivertag 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)):
| Method | Purpose |
|---|---|
.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 protocol | BiDi |
To call any of Network.*, Fetch.*, Runtime.*, etc. | CDP |
To resolve a WebElement to a CDP RemoteObjectId | CDP |
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:
| Domain | Examples |
|---|---|
browser | get_version |
page | navigate, reload, get_frame_tree, capture_screenshot |
network | enable, clear_browser_cache, set_extra_http_headers, emulate_network_conditions |
fetch | enable, continue_request, fail_request, fulfill_request |
runtime | evaluate, call_function_on, enable, disable |
dom | describe_node, get_box_model, query_selector |
emulation | set_device_metrics_override, set_user_agent_override |
input | dispatch_mouse_event, dispatch_key_event |
target | get_targets, attach_to_target, detach_from_target |
storage | clear_data_for_origin, get_cookies |
log | enable, clear |
performance | enable, 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:
cdp_remote_object_id— short-livedRemoteObjectIdforRuntime.*calls.cdp_backend_node_id— stableBackendNodeIdforDOM.*calls.
#![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
- For event subscription (
Network.requestWillBeSent,Page.lifecycleEvent,Runtime.consoleAPICalled, …), enable thecdp-eventsfeature and read CDP Events. - For the cross-browser counterpart, see WebDriver BiDi.
- The
cdp::domainsAPI docs list every typed command and its fields. - The CDP spec itself lives at https://chromedevtools.github.io/devtools-protocol/.
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 CDPsessionId, 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
};
}
| Domain | Auto-enabled? | Typical events |
|---|---|---|
network | Yes | RequestWillBeSent, ResponseReceived, LoadingFinished, LoadingFailed |
page | Yes | LifecycleEvent, FrameNavigated, LoadEventFired |
runtime | Yes | ConsoleApiCalled, ExceptionThrown |
log | Yes | LogEntryAdded |
fetch | No | RequestPaused — call session.send(fetch::Enable { ... }) first |
target | No | AttachedToTarget, 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:
CdpSession::subscribe_all— every event on this session as a(method, params, session_id)tuple.CdpSession::subscribe_connection— every event on the underlying WebSocket regardless of session id.
#![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
documentURLand friends. CDP uses SCREAMING acronyms on a number of fields (documentURL,baseURL,requestURL). A blanketrename_all = "camelCase"producesdocumentUrl(lowercaseu) 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 viatracing::warn!(targetthirtyfour::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 area | Larger, Chromium-specific | Full W3C BiDi spec covered |
| Portable across browsers | No | Yes |
| Standardised | No | Yes (W3C) |
| Connection | Direct WebSocket | Via 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:
| Module | Use it for |
|---|---|
session | status, subscribe/unsubscribe, end |
browser | Browser-wide control, user contexts, client windows, download behavior |
browsing_context | Tabs, frames, navigation, screenshots, printing, locating nodes, CSP |
script | evaluate, callFunction, preload scripts, realms |
network | Interception, modify request/response, auth, data collectors, headers |
storage | Cookies and partition lookup |
log | log.entryAdded events |
input | performActions, releaseActions, setFiles, fileDialogOpened events |
permissions | setPermission |
emulation | Geolocation, locale, timezone, screen, user-agent, scripting & touch |
web_extension | Install / 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 doingtree.contexts[0].context.clone()by hand.bidi.browsing_context().top_levels()— every top-level context.bidi.network().add_intercept(...)— returns anInterceptGuardyou can.remove().awaitexplicitly, 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 returnerror("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
- For event subscription mechanics, network interception, and user-prompt handling, read BiDi Events.
- The
bidi::modulesAPI docs list every typed command, response, and event. - For commands and events not in the curated set, jump straight into the BiDi spec’s modules section.
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:
- Subscribe to the appropriate event (
BeforeRequestSentfor the request phase,ResponseStartedfor the response phase) before registering the intercept — otherwise you might miss the event you’re waiting on. - Register the intercept with
bidi.network().add_intercept(...), choosing one or moreInterceptPhases and optional URL match patterns. The return value is anInterceptGuardthat wraps the underlying id. - The next matching request pauses on the wire. The event arrives
with
is_blocked = trueand arequest.idto identify it. - 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, usecontinue_response. - When you’re done, drop the guard or call
intercept.remove().awaitfor 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:
-
Selenium standalone running on some server, usually localhost at port 4444.
For example,
http://localhost:4444 -
The webdriver for your browser somewhere in your PATH, e.g., chromedriver (Chrome) or geckodriver (Firefox)
-
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
managerCargo 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),
safaridriverships with the OS — runsafaridriver --enableonce 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.
The W3C WebDriver V1 Specification
For low level documentation for the W3C WebDriver V1 specification, click here