A browser plugin is a Rust crate that changes how a browser starts and what
every tab gets: block images, add a script to every page, set a preference,
load an extension, route through a proxy. You add one to a PluginSet, give the
set to the launch options, and its hooks run at fixed points. It is the idea of
puppeteer-extra in Rust: plugins
are ordinary crates, compiled into your program, and anyone can publish one.
The design is ADR 004. The hooks run in both
engines: the Pure CDP engine (sb_cdp) and the WebDriver engine (BaseCase).
Browser plugins are not Test Plugins, which run around a test (screenshots on failure, reports), and not the browser extension, which runs inside the browser. A browser plugin is Rust code in the program that drives the browser.
What you will learn
- Turn plugins on and use one, with either engine
- Install a plugin from the plugins repository
- Write a plugin, and test it without a browser
- When each hook runs, and what happens when one fails
- What each browser lets a plugin do on the WebDriver engine
- What you trust when you add a plugin
Turning plugins on#
Plugins are behind the plugins feature, off by default. The feature is in
release 0.3.0 and later (the WebDriver engine from 0.4.0):
The official plugins are features of their own (plugin-shield,
plugin-block-resources, plugin-crx, and plugins-builtin for all of them),
turned on by name in the settings file, in SB_PLUGINS, in a builder call, or
on the command line (sbrs --plugin shield open https://example.com, with the
plugins compiled into the prebuilt sbrs and sbrs-mcp): see
Choosing plugins.
The plugin API is its own crate, seleniumbase-rs-plugin, in the
seleniumbase-rs-plugins
repository. This crate re-exports it as seleniumbase_rs::plugin_api, so a
program needs no second dependency to use or write plugins.
Using a plugin#
Build a set, then give it to the engine you use:
Build a set, give it to LaunchOptionsBuilder::plugins, and launch as usual:
To attach to a browser that is already running, use
Browser::connect_with_plugins(host, port, plugins) instead of connect. There
is nothing to launch, so before_launch does not run; the rest does.
With BaseCase, the same set goes to BrowserConfig::with_plugins:
BrowserSession::connect takes the same configuration. Chrome and the other
Chromium browsers give plugins everything the Pure CDP engine does; Firefox and
Safari give less, as The WebDriver engine explains.
PluginSet::with refuses a second plugin with the same name. Plugins run in
the order they were added, except those that ask to run last.
Installing a plugin#
Plugins are crates named seleniumbase-rs-plugin-<name>. The official ones live
in the plugins repository:
| Crate | What it does |
|---|---|
seleniumbase-rs-plugin-block-resources | Stops pages from loading images, fonts or media (Network.setBlockedURLs) |
seleniumbase-rs-plugin-crx | Loads packed Chrome extensions (.crx files): unpacks them and adds the folders to the start-up settings, which the launch loads |
seleniumbase-rs-plugin-wasm | Runs plugins written in any language that compiles to WebAssembly, in a sandbox (no host imports, fuel and memory limits) |
Add one with cargo add, from the same revision of the plugins repository
that this crate pins (the rev of seleniumbase-rs-plugin in its
Cargo.toml, currently 628991af0e6c5711298e3bd3692dc2974887401a):
The official plugins also come with this crate, one cargo feature each, and are turned on by name without code; see Choosing plugins.
This crate takes seleniumbase-rs-plugin from that revision, so the plugin and
this crate must resolve to the same source. Until the crates are on crates.io,
Cargo counts one git repository under two references (a branch, a tag, a
revision) as two different crates: a plugin added with another --rev, or with
no reference (the default branch), implements a different Plugin trait. The
compiler then says the trait Plugin is not implemented for the plugin, and
mentions two versions of seleniumbase_rs_plugin. When this crate moves to a new
revision, move the plugin crates with it.
Writing a plugin#
Implement Plugin. Every hook is optional; name is the only method you must
write.
A PageHandle has four methods: send (a protocol command to the tab),
add_init_script (a script for every document the tab loads from now on, run
in the current one too), evaluate (a JavaScript value from the current
document) and engine. A BrowserHandle has send, engine and family.
Plugins never see sb_cdp::Browser, Page or BaseCase, so a plugin keeps
working when those types grow, and the same plugin works with both engines.
A plugin crate depends on seleniumbase-rs-plugin (and serde_json), never on
seleniumbase-rs. The plugins repository has a template/ to copy.
Packages#
packages returns JavaScript and CSS to load into every tab:
Package::script(code), Package::style(css), Package::script_url(url) and
Package::style_url(url). Each must say where it loads, .only_on(["example.com"])
or .everywhere(); a package that says neither is refused when the browser
starts, so a plugin never loads a script into every site by accident. For a URL,
pin the version and add .integrity("sha384-...") so the browser checks the
file.
Requirements and dependencies#
requirements returns flags the set checks before anything starts:
| Flag | Refuses |
|---|---|
Requirements::HEADFUL | a headless browser |
Requirements::LAUNCH_ONLY | a browser attached with connect_with_plugins, and a WebDriver session on a server other than the local default (with_webdriver_url, such as a Selenium Grid) |
Requirements::CHROMIUM_ONLY | browsers that are not Chromium-based: Firefox and Safari on the WebDriver engine |
Requirements::RUN_LAST | nothing; the plugin runs after the others |
dependencies names other plugins that must be in the same set. The set is
checked again after every before_launch has run, so one plugin cannot switch
on headless mode under another that needs a window.
Testing a plugin#
Without a browser, the seleniumbase-rs-plugin-testing crate in the plugins
repository has recording fakes (RecordingPage, RecordingBrowser) that
remember every command and script a hook sends.
Through this crate, the test-util feature runs the real launch steps against a
scripted mock: Browser::launch_mocked(options, &mock) checks the set, runs
before_launch, loads the extensions, runs on_browser and prepares the first
tab, with no Chrome. Script the mock’s answers first, then read back what the
browser was sent. With the German plugin above:
When the hooks run#
| Hook | Pure CDP engine |
|---|---|
before_launch | In Browser::launch, before the process starts. The changed settings become the launch options: arguments, preferences (written into the profile’s Default/Preferences), extensions (loaded over the protocol), the proxy and headless mode. |
on_browser | Once the browser answers, before the first tab is prepared; after connect_with_plugins. |
packages, then on_page_created | For every tab the library attaches to (the first tab, new_page, new_window, pages, tabs in a BrowserContext), after the library’s own set-up, plugin by plugin. |
on_close | Once per launch: in Browser::close; or when the last handle is dropped without close, in the background; or, when the launch fails after before_launch ran, before launch returns the error. A first attempt that is retried does not count, so a plugin’s clean-up does not run under the retry. |
The proxy reaches before_launch as its server only: a password in the launch
options is not shown to plugins, and it stays if they leave the proxy as it is.
A plugin that sets a new proxy may include a password in it.
The WebDriver engine#
BrowserConfig::with_plugins gives a set to BaseCase::new, and to
BrowserSession::connect, which BaseCase::new calls. The hooks run at these
points:
| Hook | WebDriver engine |
|---|---|
before_launch | Before the driver is asked for a browser. The settings go back into the BrowserConfig the session starts with, and the set is checked again. |
on_browser | Once the session runs. |
packages, then on_page_created | For the first window; for each window opened with switch_to_new_window, open_new_window, open_new_tab or open_new_tab_url; and for a window you switched to yourself, such as a pop-up the page opened, when you call apply_plugins_to_window (on BaseCase or BrowserSession). Switching to a window runs no hook. |
on_close | At quit, while the browser still runs; in the background when the session is dropped without quit; and when the launch fails after before_launch ran. |
reconnect checks the plugins for the new browser first, so a refusal leaves
the session as it was; then the old browser’s plugins get on_close, and the
new browser goes through every hook, before_launch starting again from the
configuration. get_new_driver starts a browser with its own run of the hooks.
What reaches a plugin depends on the browser:
| Chromium browsers (Chrome, Edge, Chromium, Brave, Opera, Comet, Chrome for Testing) | Firefox | Safari | |
|---|---|---|---|
before_launch shows | the extra arguments, the prefs of goog:chromeOptions (ms:edgeOptions for Edge), the extension folders, the proxy (its server only), headless | Firefox’s own arguments and preferences, headless | headless, always off |
before_launch may not | (no limit: every change reaches the browser) | add extension folders (install add-ons with BaseCase::install_addon once the session runs) or set a proxy | change anything: a change is refused |
send, on the browser and on a page | chromedriver’s DevTools channel (goog/cdp/execute), which reaches the window the session is on | refused with an error | refused with an error |
add_init_script, and packages | Page.addScriptToEvaluateOnNewDocument for the window’s next documents, and run in the current one | run now as a WebDriver script, and again after each open | as Firefox |
evaluate | a WebDriver script in the current frame | the same | the same |
On Firefox and Safari a script for every document runs after the page’s own
scripts, not before them, and as the body of a function: its top-level
declarations stay local, so assign to window to share a value. It runs again
only after open (BrowserSession::goto), not after a click, a redirect,
refresh, go_back or go_forward takes the window to another document. A
plugin that must run before the page’s scripts, or that needs send, should
say Requirements::CHROMIUM_ONLY, so it is refused on those browsers instead
of half working.
The set is also checked against what the engine can give. A plugin that declares
what it needs (metadata()) is refused before anything starts when the engine
cannot provide it: WebDriver has no network observation or interception, and
Firefox and Safari have no DevTools channel. A WebDriver server that answers
browserName: tauri (tauri-plugin-sbrs-automation) is checked again once the
session exists, as an embedded webview, which gives page scripts and
evaluation only; a refusal quits the session. A plugin that declares nothing is
not refused for lack of a DevTools channel on those browsers, as before: it
fails when it calls send.
The launch settings that are Chromium switches (host_resolver_rules,
disable_features, enable_features, webrtc_policy, disable_quic) become
one --host-resolver-rules, --disable-features, --enable-features,
--force-webrtc-ip-handling-policy and --disable-quic argument, joined with
the ones the configuration already has. A plugin that sets one for Firefox or
Safari is refused. On every engine a plugin’s Fetch.* command is refused
(the host owns request interception), and Network.setBlockedURLs adds to the
window’s blocklist, with the ad_block list, instead of replacing it.
A Firefox preference must be true or false, a whole number or text. Internet
Explorer takes no plugins. Plugins cannot be combined with reuse_session: a
browser kept for reuse would get the hooks a second time, or not at all, so the
combination is refused. A session on a server other than the local default
(with_webdriver_url) counts as attaching for LAUNCH_ONLY; before_launch
still runs there, and its settings travel in the capabilities, so extension
folders must exist on the machine that runs the browser.
When a plugin fails#
Plugins fail closed. A refusal from the checks, or an error from
before_launch, on_browser, a package or on_page_created, stops the launch
or the tab and comes back as SeleniumBaseError::Plugin, which names the plugin
and the hook. A launch stopped by a plugin is not retried. A tab the library
opened for new_page or new_window is closed again rather than handed out
without what the plugin adds: a stealth or proxy plugin that did not run must
not leave an unprotected browser.
Errors from on_close are logged (event cdp.plugin.close.failed) and do not
stop the browser from closing.
On the WebDriver engine, a refusal, a launch setting the browser cannot take,
or an error from before_launch, on_browser, a package or on_page_created
for the first window stops BaseCase::new (or BrowserSession::connect) with
SeleniumBaseError::Plugin, and a browser that had started is quit first. For
a window opened later, the error is what the call that opened it returns; the
window stays open, so close it or stop the test. A value the browser cannot be
given (an extension folder whose path holds a comma, a Firefox preference that
is a fraction) is SeleniumBaseError::InvalidConfig, as is plugins with
reuse_session. Errors from on_close are logged as
webdriver.plugin.close.failed.
Security#
A plugin is Rust code that runs with your program’s rights. It sees every page, cookie and password the browser handles, and it can send any protocol command. Adding one is the same trust decision as adding any dependency.
- Read it, or trust whoever wrote it, before you add it.
- Keep
Cargo.lockin version control. It records the commit each git dependency resolved to, so a plugin cannot change under you until you runcargo update, and you can read the change before you commit it. - Run
cargo deny checkandcargo auditon the result; this repository’sdeny.tomlallows only the git sources it names.
Packages run inside the page, with the site’s origin. Pinned versions,
Subresource Integrity and only_on keep a compromised CDN or a typo from
reaching every site the browser visits.
What has and has not been verified#
On the Pure CDP engine, the hooks, their order, the commands they send, the
fail-closed errors, closing a tab a plugin refused, and on_close on close and
on drop are covered by tests against the mocked browser
(tests/sb_cdp_plugins.rs); the conversion between the launch options and
LaunchSettings, and the merging of preferences into a profile, by unit tests.
None of it has been run against a real Chrome yet: loading an extension with
Extensions.loadUnpacked follows the repository’s existing real-Chrome test of
the same command, and the Default/Preferences file is the one Chrome reads at
start-up, but neither was checked in a live browser for this feature.
connect_with_plugins is tested only for its checks, not against a running
browser. The same holds for the host’s part of the plugin platform: a fresh
instance of a per-launch plugin for each browser, the host check, the merged
launch switches, the one blocklist per tab and the refusal of Fetch.* are
covered by tests against the mocked browser and the mock WebDriver server, and
by unit tests that read the command line; no real Chrome has been shown to
accept the merged switches or to apply the blocklist in iframes, workers and
pop-ups.
On the WebDriver engine, the conversion between a BrowserConfig and
LaunchSettings for each browser, and what is refused, are covered by unit
tests; the hooks, their order, what reaches the driver, the DevTools channel on
Chromium and its refusal on Firefox, the scripts run again after open, the
windows a session and BaseCase open, reconnect, the fail-closed errors and
on_close at quit, on drop and after a failed launch, by tests against a mock
WebDriver server. tests/webdriver_plugins_chrome.rs runs one plugin through
BaseCase and chromedriver against a real Chrome: a preference set in
before_launch, Browser.getVersion through the DevTools channel, a time zone,
a package and an init script in the first window, the time zone and the init
script in a window open_new_window opens, evaluate, and on_close at quit.
It passed in Chrome 155, headless, on macOS. Firefox and Safari have run only
against the mock server, and the extension folders, the proxy and a remote
server were not tried with a live browser.