sb_cdp drives Chrome directly over the DevTools Protocol. There is no
chromedriver, so there is none of the markers a WebDriver leaves in the page.
It is the engine behind the cdp MCP server.
What you will learn
- Launch Chrome and open a page
- Find elements with a
Locatorthat waits for them - Fill forms, click, and press keys with trusted inputInput sent through the DevTools Protocol, which the page sees as trusted events, as a person's input is.
- Wait and assert with retrying expectations
- Work with frames, cookies, storage and tabs
- Add scripts and styles to a page, and a script to every document it loads
- Record a tab’s network traffic as a HAR file
- Keep WebRTC from revealing the machine’s addresses
- Test code that drives pages without launching Chrome
Launch and navigate#
LaunchOptions::builder() validates when you call build(): combining
incognito with guest, or use_chromium with an explicit
executable_path, is an error before anything launches. A Proxy with
credentials never shows its password in Debug, Display or the command line.
Locators#
A [Locator] describes elements; it does not hold them. Every action finds the
element again and waits for it, so it keeps working when the page re-renders.
| Narrow with | Meaning |
|---|---|
.locator(sel) | search inside the matches (and inside a frame, see below) |
.nth(i), .first(), .last() | pick one match |
.visible() | keep only visible matches |
.parent() | the parent element |
.with_timeout(d) | how long actions wait |
Selectors are classified the way SeleniumBase does it: "#id" is CSS, "//div"
is XPath, "link=Home" is a link. Use SelectorBuf::css/xpath to be explicit.
A CSS selector may use SeleniumBase’s button:contains("Sign in"), which runs as
XPath. To find any element by its text, use
SelectorBuf::containing_text("Sign in", Some("button")) (with None for the
tag, the innermost elements holding the text); a text= prefix means link text.
Reading: text, html, attribute, count, info, bounding_box (from the
top of the document), viewport_rect (from the window, as Python’s
get_element_rect), is_visible, exists, is_checked, urls. all lists
the current matches; all_waiting waits for a first one. Acting: click,
click_with_offset, click_if_visible, click_and_hold, double_click, right_click, hover, drag_to,
fill, type_text, send_keys, set_value, clear, press, select_option, check,
submit, scroll_into_view, flash_with, screenshot.
fill empties the field and inserts the text in one step (fast, still real
input events); type_text empties it and sends one trusted key event per
character, for pages that listen to keys, as Python’s type does; send_keys
types the same way after what the field already holds. A \n in either presses
Enter.
Waiting and asserting#
Expectations retry until they hold or the timeout passes; the error names the locator and the state it was waiting for.
Frames#
A locator inside a frame is just a nested locator:
Same-origin frames nest to any depth and clicks land at the right pixel.
A cross-origin frame cannot be entered from page script, so it matches nothing.
Cookies, storage, tabs#
page.cookies() (all, set, clear, header, save, load),
page.local_storage() / page.session_storage(), page.window() and
page.emulation() (timezone, locale, geolocation, user agent, offline).
browser.new_page, pages, newest_page and page.bring_to_front manage
tabs. Cookie values are redacted in Debug.
Downloads#
The browser saves downloads into a folder you choose, and tells the page API when each one begins and ends, so a download call returns the path of the saved file or says why there is none:
download_file names the file after the server’s Content-Disposition header,
or the end of the address, and replaces a file already of that name (Chrome
does not number downloads that are controlled over DevTools). A bad status, a cancelled download or an address the
page may not fetch (CORS) is SeleniumBaseError::Download; a download that
does not begin or end in time is WaitTimeout. The download methods and
browser.set_download_dir choose the folder; until one is chosen the browser
saves wherever it would by default, and browser.download_dir() (what
assert_downloaded_file searches) is downloaded_files, as in SeleniumBase.
Human-paced input#
page.human(behavior) returns a Human whose clicks, typing and scrolling are
paced like a person’s, for pages that watch how input arrives:
- The pointer travels along a curve with a bell-shaped speed (slow start, fast middle, gentle arrival), for as long as Fitts’s law says a movement of that distance to a target that size takes. It carries a faint tremor, and a long movement sometimes overshoots and corrects. A click lands somewhere inside the element, not on its exact centre, and the button is held for a few tens of milliseconds.
- Typing has Gaussian intervals around the chosen speed with a slowly
drifting rhythm, longer pauses after spaces, punctuation and newlines, and the
occasional hesitation. With a
typo_rate, some letters are typed wrong, noticed, deleted and retyped; the field always ends up with exactly your text. - The pointer remembers where it was, so successive movements start from
there, and clones of a
Humanshare it.
Everything is still trusted DevTools input; only the timing and the path
differ. It is slower than locator.click() by design. Behavior::builder() .seed(n) makes the randomness reproducible. The planning is a pure module,
stealth::behavior, usable on its own.
Messages in the page#
page.post_message, page.set_messenger_theme and page.activate_messenger
show a short message in a corner of the page, the way Python SeleniumBase’s CDP
Mode does with the Messenger library.
The library and jQuery load from public CDNs, so the page needs network access;
post_message loads them itself when they are missing, and a navigation drops
them with the page.
The message is escaped before it is posted, so <b> appears as typed rather
than as bold text. With pause left on (the default) the call returns after the
message’s duration, so the next step does not start until it is gone.
Scripts and styles in the page#
page.add_script_tag and page.add_style_tag add a <script> tag or a
stylesheet to the current document, from a file (TagSource::url) or from the
code itself (TagSource::content). For a file they wait until the browser has
loaded it, up to the page’s timeout, and a file that does not load is an error;
inline script code that throws is an error too. page.add_init_script runs a
script in every document the tab loads from now on, in every frame, before the
page’s own scripts, and returns an identifier for page.remove_init_script.
They are Playwright’s addScriptTag, addStyleTag and addInitScript; on
BaseCase the counterparts are add_js_link, add_css_link and add_js_code.
A tag reaches only the current document, and a navigation drops it.
add_init_script leaves the document already loaded alone, as Playwright
does; run the script there with page.evaluate if it needs it too. The page’s
Content Security Policy still applies: it can refuse a file’s host, which is an
error, or forbid inline scripts, which stops TagSource::content code without
one. What you pass reaches the page as a string, never spliced into code.
Intercepting requests#
page.intercept(rules) pauses every request the page makes and answers it by
the first matching Rule: block it, serve a made-up response, or send it on
with changes. Requests no rule matches are untouched, and other tabs are never
affected.
A rule can match on a URL glob (* any run of characters, ? one character),
an HTTP method, and a resource type; conditions combine with “and”. modify
rules can set or remove headers, change the URL or method, and replace the body.
interception.log() lists the requests seen and how each was answered.
stealth::reactor::CdpReactor is the older, browser-wide alternative: it adds
headers to every request from every tab. Prefer page.intercept unless you
want exactly that.
Recording a HAR#
page.start_har() records everything the tab and its iframes request from
then on;
finish() stops and returns a Har (HTTP Archive 1.2), and save writes it
as a .har file that Chrome’s and Firefox’s developer tools, HAR viewers and
mitmproxy open.
Start the recorder before the navigation you want to see: only what happens
after start_har returns is recorded, and only for that tab. Each request is
an entry with its headers as they were sent (cookies included), query string,
POST body, status, response headers and cookies, server address and timings
(blocked, dns, connect, ssl, send, wait, receive, -1 for a phase
that did not happen). A redirect is one entry per hop; a failed request has
status 0 and the browser’s error in _error, as in Chrome’s own export; each
top-level navigation adds a page with its onContentLoad and onLoad times.
Bodies are off by default; with content(true) a body is fetched when it
finishes loading, binary ones as base64, and one over the limit is left out
with a comment saying so. The limit goes up to 64 MiB (MAX_CONTENT_BYTES),
because each body comes back whole in one DevTools message. Har and its
parts are serde types, so a saved HAR reads back with
serde_json::from_str::<Har>.
A cross-site iframe (an ad, a video player, a captcha) runs in a target of its
own, which reports its requests on its own session. The recorder attaches to
each one as it appears (Target.setAutoAttach), holds it until its requests
are reported, and records them with the tab’s; iframes inside it too. Requests
of service workers, and of other workers that run as targets of their own, are
not recorded. If the recorder falls so far behind the browser that it misses
events, log.comment says so.
Recording enables the tab’s Network domain and leaves it on, since other
features may rely on it. It attaches to the iframes only while it runs: finishing
or dropping the recorder detaches them, so run one recording per tab at a time.
A HAR records cookies and, with bodies, whatever the pages returned, so treat
the file like the session it came from. tests/sb_cdp_har_chrome.rs records a
visit to a local server, and a page with a cross-site iframe, in a real Chrome.
Isolated contexts#
browser.new_context() opens a BrowserContext: tabs that share no cookies,
storage or cache with anything else, like an incognito window. Open tabs in it
with context.new_page() or context.new_page_at(url) and throw everything away with context.dispose().
It is much cheaper than a second Chrome process.
A proxy per context#
Each context can have its own proxy, whatever the rest of the browser uses. A proxy with a username and password is answered automatically, for that context’s tabs only:
The password is never sent to Chrome except in the reply to the proxy’s own
prompt, and never appears in Debug output. A pool lease can have its own proxy
too: pool.acquire_with(ContextOptions::new().proxy(...)).
Many workers: the browser pool#
BrowserPool shares a few Chrome processes between many concurrent workers.
Each acquire() returns a Lease: its own isolated context and a tab.
- Bounded and fair. The pool never holds more than
max_browsers * contexts_per_browserleases; waiting workers queue in order, andacquiregives up with aWaitTimeoutafteracquire_timeout(60 s by default). - Lazy, reused, replaced. Chrome starts on the first
acquire, is reused, and is replaced afterretire_afterleases (100 by default) so a long-running pool does not accumulate leaked memory. A Chrome that has died is replaced too. - Isolated. Workers cannot see each other’s cookies.
- Sessions in memory.
pool.sessions()is aSessionStoreshared by every lease. Nothing is written to disk and itsDebugoutput never shows a value. Expired cookies are skipped on load, and local storage is restored only if the page is on the origin it was captured from. - Release it.
lease.release().awaitfrees the slot at once. Dropping a lease also frees it, but in the background.
Use BrowserPool::with_launcher to supply browsers yourself, for example to
connect to existing ones or, with test-util, to hand out mocked browsers.
Keeping WebRTC from leaking addresses#
A page can open an RTCPeerConnection without asking, and the candidates it
gathers describe the machine’s network. That is how a proxy or VPN gets
bypassed. page.webrtc_report() gathers candidates the way a tracking script
would (with no STUN server, so nothing leaves the machine) and tells you what a
page can learn:
- By default Chrome is left alone. On a typical machine the report lists
.localhost candidates, which stand in for the machine’s private addresses but still tell a script that the machine is gathering candidates. - The shield (
LaunchOptionsBuilder::shield_webrtc(true), orpage.shield_webrtc()for a tab that is already open) makes everyRTCPeerConnectionrelay-only and drops its STUN servers. Nothing is gathered, now or on any page the tab loads later. Pages that need a WebRTC call to connect will not, unless they bring a TURN relay. - Chrome’s own flag is available too:
webrtc_policy(WebRtcPolicy::...)takes the sameWebRtcPolicyaFingerprintcarries. On Chrome 155 none of its three values removes the.localcandidates, so use the shield when the goal is for a page to learn nothing, and the flag when you only want to limit which addresses are offered.
Looking like another browser#
A Fingerprint says what a browser claims to be: user agent and Client Hints,
language, time zone, screen, location and the hardware a page can probe.
Fingerprint::randomized(os, seed) makes a coherent one. Hand it to the launch
options and every tab, as it opens and before its first navigation, is made to
match:
page.apply_fingerprint(&fingerprint) does the same for one tab, for the pages
it loads from then on (the page that is open keeps its old identity until it
navigates). It works on a tab inside an isolated BrowserContext too.
- A launch setting made before
.fingerprint(..)wins over the fingerprint’s (user agent, language, window size, WebRTC flag, proxy); one made after it replaces it. - A fingerprint adds only what it holds and has switched on (see
One setting, four states): a
fingerprint with no WebRTC policy and no WebRTC masking passes no WebRTC flag,
and a dimension named
Naturaladds no argument and no override. - A fingerprint’s proxy password is never sent to the browser as a header, which would give it to every site. The proxy is answered through its own challenge.
- The fingerprint’s
cmd_paramsare not passed to Chrome, because a flag such as--renderer-cmd-prefixruns a command and fingerprints often come from a profile file. Add flags you trust with.arg(..). - Checked on real Chrome 155: the page reads the fingerprint’s user agent,
platform, language, time zone, core count, screen width and WebGL renderer,
and does not announce
navigator.webdriver. The page can still tell what the fingerprint does not cover; the browser’s real version, for one.
Script errors#
page.js_errors() lists the uncaught errors and unhandled promise rejections
the current document has raised, and page.assert_no_js_errors() fails on the
first. They are collected from the moment the document starts and cleared when
the tab navigates. A broken image is not a script error and is not listed.
One helper for either engine#
Page implements the same capability traits as BaseCase (BrowserApi,
ElementApi, AssertionApi, ScreenshotApi), so a helper written over them
runs on either engine:
On a Page, quit closes the tab, not the browser; call Browser::close for
that. ScreenshotApi::save_screenshot takes a bare file name and refuses one
that could leave the logs directory.
CAPTCHAs#
page.solve_captcha() attempts a Cloudflare Turnstile, reCAPTCHA, hCaptcha,
Friendly Captcha or DataDome widget with trusted mouse events and returns which
one it tried.
The widgets live in cross-origin frames, so success is up to the site: check the page afterwards.
To test your own pages with the vendors’ test keys, see Testing Pages with a CAPTCHA.
Some challenge frames refuse events that did not come from the operating
system. With the gui feature, page.gui_click_captcha() is the same attempt
with the machine’s own pointer, as Python’s gui_click_captcha does with
PyAutoGUI:
It brings the window to the front, works out the checkbox’s position on the
screen from the window (Browser.getWindowForTarget), the toolbar above the
page, the widget’s frame and where each vendor draws its box, and takes over
the mouse for about a second. The click goes to whatever window is on top at
that point. A headless browser and a minimised window are refused, as is a
point outside the main display. The position is exact for a window at 100
percent zoom on an unscaled display; a page zoom or a docked browser panel
shifts it.
It is covered by unit tests and a mocked browser, and has not been tried on a live CAPTCHA.
Plugins#
Featureplugins
With the plugins feature, LaunchOptionsBuilder::plugins takes a set of
browser plugins: crates whose hooks run before the browser starts, once it runs,
for every tab and at close, to block resources, add scripts, set preferences or
load extensions. See Browser Plugins. A Page is also the plugin
API’s PageHandle, so code written for plugins can be given one of your tabs.
Testing without Chrome#
Featuretest-util
With the test-util feature, Browser::new_mocked() returns a browser backed by
a scripted MockCtrl. Every protocol command is recorded and answered by the
handler you register, so you can test what your code sends:
Coming from Python#
sb.cdp.click(sel) is page.locator(sel).click(); sb.cdp.assert_text(...) is
locator.expect().to_contain_text(...); sb.cdp.get_text is locator.text().
The full mapping of every sb.cdp method is in the
API parity table.
Next steps#
Your first test, on either engine.
The cdp server runs on this engine.
What a Fingerprint says a browser is.
Hooks that run before the browser starts, for every tab and at close.
Test your own pages with the vendors’ test keys.
The full mapping of every sb.cdp method.