Skip to content

seleniumbase-rs 0.5.0

Choose a tag to compare

@MustCodeAl MustCodeAl released this 17 Oct 15:17
· 75 commits to main since this release

A release of API clean-ups and new structure. The fingerprint API is one
Spoof per setting with a separate StealthPolicy, built once and never
mutated. BaseCase text assertions and waits take their arguments in Python's
order, and the run settings, runner, CDP page and behave APIs are cleaned up.
The command line moved to its own workspace crate, seleniumbase-rs-cli, and
parses with usage instead of clap. The built-in plugins can be chosen by name
from features, the settings file, SB_PLUGINS or sbrs --plugin. PDF text
extraction is the opt-in feature pdf. The BREAKING changes, those that break a
call, are listed first.

Breaking

  • sbrs and sbrs-mcp are built by the new workspace package
    seleniumbase-rs-cli (crates/cli), not by seleniumbase-rs.
    The library
    stays at the repository root with the same name and path, so
    seleniumbase-rs = { git = ... } keeps working, but it no longer has a binary
    and no longer compiles clap, clap_complete or clap_complete_fig (15 crates
    fewer in a default build). The commands, flags, aliases, defaults and exit
    statuses of sbrs are the same (its parser changed, see Changed), with one
    difference: a plain cargo build, cargo install or cargo run now includes
    pdf, so sbrs pdf-text and sbrs assert-pdf-text work where they used to exit
    with status 2.

    # before
    cargo install --path . --bin sbrs
    cargo install --path . --bin sbrs-mcp --features mcp-server
    cargo install --git https://github.com/MustCodeAl/seleniumbase-rs --features mcp-server
    # after
    cargo install --path crates/cli
    cargo install --git https://github.com/MustCodeAl/seleniumbase-rs seleniumbase-rs-cli
    cargo binstall --git https://github.com/MustCodeAl/seleniumbase-rs seleniumbase-rs-cli

    The new package has pdf and mcp-server on by default (the release archives
    always had them), and plugins-builtin (the built-in browser plugins, see
    Added), and forwards tui, gui, turso, test-util,
    full-tracing and tokio-console to the library, so
    cargo install ... seleniumbase-rs-cli --features tui is the way to add the
    terminal UI. default-members names both packages, so cargo build,
    cargo test and cargo run --bin sbrs at the repository root work as before;
    use -p seleniumbase-rs for the library alone. The release archives keep their
    names (seleniumbase-rs-v<version>-<target>). The messages sbrs prints for a
    build without tui or pdf now name the package that builds it
    (cargo install --git https://github.com/MustCodeAl/seleniumbase-rs seleniumbase-rs-cli --features tui), instead of seleniumbase-rs, which no
    longer has a binary. The Homebrew formula has to build --path crates/cli from
    the first release that has this change.

  • PDF text extraction is the opt-in feature pdf. pdf-extract (lopdf, fonts,
    ciphers) was 34 of the crates of a default build. BaseCase::get_pdf_text,
    assert_pdf_text and the module api::pdf need it; printing a page to PDF
    (print_to_pdf, save_as_pdf) does not. sbrs pdf-text and
    assert-pdf-text stay in --help and, in a build without the feature, exit
    with status 2 and say how to reinstall it with the feature
    (cargo install --git https://github.com/MustCodeAl/seleniumbase-rs seleniumbase-rs-cli --features pdf).

  • Text assertions take the text first and the selector second, as Python's
    assert_text(text, selector) does. A call with the old order still compiles
    (both arguments are &str), so search for these names: assert_text,
    assert_exact_text, assert_exact_text_not_visible, assert_shadow_text,
    RecorderSession::assert_text, AssertionApi::assert_text and the macro
    sb_assert_text!. A failing assertion whose arguments look swapped says so in
    its message.

    // before
    sb.assert_text("h1", "Example Domain").await?;
    sb_assert_text!(sb, "#status", "Saved")?;
    // after
    sb.assert_text("Example Domain", "h1").await?;
    sb_assert_text!(sb, "Saved", "#status")?;

    The generators follow: the recorder, the scenario runner, sbrs mkdir,
    sbrs mkfile, the MCP export tools and the Python importer now write the new
    order. RecorderSession::assert_text(sb, text, css) and the
    AssertionApi::assert_text(expected, css) trait method changed with it. If
    you generated tests with an earlier version, run the same search over them.

  • The text waits take (text, css, timeout_secs), as Python's
    wait_for_text(text, selector, timeout) does. wait_for_text,
    wait_for_exact_text_visible and wait_for_exact_text_not_visible swapped
    their first two arguments (a silent change: both are &str).
    wait_for_text_visible, wait_for_text_not_visible and wait_for_exact_text
    gained the selector they lacked, so an old call stops compiling. Pass "body"
    to look at the whole page, as the old wait_for_text_visible(text, timeout)
    did.

    // before
    sb.wait_for_text("#status", "Saved", 10).await?;
    sb.wait_for_text_visible("Saved", 10).await?;
    sb.wait_for_exact_text("Saved", 10).await?;
    // after
    sb.wait_for_text("Saved", "#status", 10).await?;
    sb.wait_for_text_visible("Saved", "body", 10).await?;
    sb.wait_for_exact_text("Saved", "#status", 10).await?;

    wait_for_text now waits for the text to be displayed, like
    wait_for_text_visible (in Python the first is an alias of the second).

  • The deferred and delayed text checks take (text, css).
    deferred_assert_exact_text, delayed_assert_exact_text and
    delayed_assert_text took the selector first. delayed_assert_text also
    passed its arguments on in the other order, so delayed_assert_text("Welcome", "h1") looked for an element called Welcome; all three now follow Python's
    (text, selector), like deferred_assert_text.

    // before
    sb.deferred_assert_exact_text("#status", "Ready").await?;
    // after
    sb.deferred_assert_exact_text("Ready", "#status").await?;
  • find_text, find_exact_text and find_non_empty_text follow Python.
    They took no selector, searched all of <body> for the deepest element that
    held the text, and returned Result<Option<WebElement>> (the Option was
    never None). They now take the selector and return Result<WebElement>,
    the element of that selector, as Python's find_text(text, selector) does.
    Pass "body" to look at the page.

    // before
    if let Some(el) = sb.find_text("Welcome").await? { /* ... */ }
    // after
    let el = sb.find_text("Welcome", "h1").await?;
  • post_message_and_highlight(message, css) takes the element to highlight,
    as Python's post_message_and_highlight(message, selector) does. It used to
    highlight the toast it had just shown. An error while highlighting is now
    returned instead of ignored.

    // before
    sb.post_message_and_highlight("Saved").await?;
    // after
    sb.post_message_and_highlight("Saved", "#save-button").await?;
  • The extra browsers are named by a DriverId, and switching to the default
    one is repeatable.
    get_new_driver(Option<BrowserConfig>) returned the
    index of the browser it had just parked, and switch_to_driver(index) swapped
    the active browser with the one in that slot, so the slot held whichever
    browser was left last: switch_to_default_driver() (slot 0) reached the
    first browser only once, and its second call went back to the other. A
    DriverId now names a browser for good, and switch_to_default_driver()
    always reaches the first one. Changes:

    • get_new_driver() takes no argument and get_new_driver_with(config) takes
      the configuration (no more None); both return the new browser's
      DriverId.
    • switch_to_driver(DriverId) makes that browser active and can be repeated;
      active_driver() says which one is.
    • quit_extra_driver(DriverId) refuses DriverId::DEFAULT (that browser is
      closed by quit()); quitting the active extra browser falls back to the
      default one, as in Python. A browser that was quit is an error to switch
      to, not a disconnected session that panics.
    • quit() closes every browser the case opened, not only the active one.
    • driver_count() counts the browsers that are open, the active one
      included (it counted the parked ones, quit placeholders too).
    // before
    let first = sb.get_new_driver(None).await?; // the index of the parked browser
    sb.switch_to_driver(first).await?;          // swaps: the slot now holds the other
    // after
    let second = sb.get_new_driver().await?;    // the new browser's id
    sb.switch_to_default_driver().await?;       // always the first browser
    sb.switch_to_driver(second).await?;
  • wait(seconds) takes whole or fractional seconds, and no u64. It is
    sleep, as in Python, and takes impl Into<f64>: wait(2) and wait(0.5)
    compile, but a u64 variable (or a usize, an i64) no longer does.

    // before
    let seconds: u64 = 3;
    sb.wait(seconds).await;
    // after
    sb.wait(seconds as f64).await; // or keep the variable a u32 or an f64
  • check_window's level is a u8, not an f64: check_window("home", 3)
    instead of check_window("home", 3.0); the same for deferred_check_window
    and delayed_check_window. A float literal stops compiling. As before, a
    level other than 0, 1, 2 or 3 is an InvalidConfig error.

    // before
    sb.check_window("home", 3.0).await?;
    // after
    sb.check_window("home", 3).await?;
  • The cookie helpers take a name and use saved_cookies/, as Python's do.
    save_cookies(path) and load_cookies(path) took any file path, so a call
    written like Python's (save_cookies("login")) wrote a file called login
    in the working directory. They now take a name: the file is
    saved_cookies/<name>.txt in the working directory (only the last part of
    the name is used, .txt is added, a name ending with / is refused), still
    JSON. save_cookies returns the path it wrote. get_saved_cookies(name) is a
    method (it was an associated function that took a path), and
    delete_saved_cookies(name) deletes that file (it looked in latest_logs and
    the working directory). load_cookies now does what Python's does: a cookie
    of another domain gets the page's, and the expiry is dropped, so the cookies
    last for the browser session; load_cookies_with(name, CookieExpiry) keeps
    the saved expiry or sets a new one.

    For a file at a path of your own there are save_cookies_to_path(&Path) and
    load_cookies_from_path(&Path, CookieExpiry); the MCP server and
    sbrs save-cookies / load-cookies use them. The Rust-only
    save_saved_cookies and load_saved_cookies are deprecated.

    // before
    sb.save_cookies("cookies.json").await?;           // any path
    sb.load_cookies("cookies.json").await?;
    // after
    sb.save_cookies("login").await?;                  // saved_cookies/login.txt
    sb.load_cookies("login").await?;
    sb.save_cookies_to_path(Path::new("cookies.json")).await?;      // any path
    sb.load_cookies_from_path(Path::new("cookies.json"), CookieExpiry::Saved).await?;
  • assert_data_in_downloaded_file(data, file) takes the data first, as
    Python's does, and assert_data_in_downloaded_file and
    assert_downloaded_file_regex are async: they wait up to the default timeout
    for the download, as Python's do, instead of failing on a file that is still
    arriving. The data is compared without the whitespace at its ends.

    // before
    sb.assert_data_in_downloaded_file("report.csv", "id,name")?;
    // after
    sb.assert_data_in_downloaded_file("id,name", "report.csv").await?;
  • sb_assert_title! and sb_assert_url! check the whole value, as
    Python's assert_title and assert_url do; they checked that the title or
    URL contained the text. The old behaviour is in the new
    sb_assert_title_contains! and sb_assert_url_contains!. A test that passed
    a part of the title keeps passing only with the _contains macro.

    // before
    sb_assert_title!(sb, "Dashboard")?;       // the title contained "Dashboard"
    // after
    sb_assert_title_contains!(sb, "Dashboard")?;
  • run_browser_test and Plugins::run_browser_test take an async closure
    (F: AsyncFnOnce(&mut BaseCase) -> Result<()>) instead of a closure that
    returns a boxed future. The old form |sb| Box::pin(async move { .. }) does
    not compile against the new bound, and neither did |sb| async move { .. }.
    Migration: run_browser_test(config, |sb| Box::pin(async move { body }))
    becomes run_browser_test(config, async |sb| { body }), and
    |sb| Box::pin(demo(sb, url)) becomes async |sb| demo(sb, url).await
    (use async move |sb| in a loop that moves its variables). To keep an old
    closure compiling for now, call run_browser_test_boxed
    (Plugins::run_browser_test_boxed), which is deprecated like
    BrowserTestFuture. sbrs mkdir, sbrs mkfile and sbrs import-python
    generate the new form.

  • RuntimeConfig::log_level is an Option<String> that defaults to None
    (it was "info"), so the binaries can tell "not set" from "set to info".
    Migration: config.log_level becomes config.log_level.as_deref() .unwrap_or("info") where you read it, and config.log_level = Some(..)
    where you set it.

  • Settings::load(path: Option<P>) is split in two, so nobody writes
    Settings::load(None::<&str>). Migration: Settings::load(None::<&str>)?
    becomes Settings::load()?, and Settings::load(Some(path))? becomes
    Settings::load_from(path)?. Settings::from_toml is deprecated; use
    from_file, which reads TOML for a .toml name.

  • Settings::from_file, load, load_from and from_env now validate
    (Settings::validate), so input that used to load is an InvalidConfig error:
    an unknown browser or mode, a window width without a height (or the
    reverse; the missing side used to default to 1920 by 1080), and a window
    dimension of zero. Migration: spell the browser (chrome, firefox, ...) and
    mode (webdriver, cdp, uc) as Browser and DriverMode parse them, set
    both window dimensions or neither, and call validate() yourself on a
    Settings built by hand. A blank SB_* variable counts as unset (SB_PLUGINS
    is the exception: an empty value turns every plugin off).

  • sb_test! binds its $sb to a &mut BaseCase instead of an owned
    BaseCase, and a skipped test passes instead of panicking. Migration: a body
    that moved sb (sb.quit().await, passing it by value) now borrows it, and
    the macro closes the browser.

  • Settings::timeout_seconds, window_width and window_height are
    Options
    that default to None. The old defaults (30 s, 1920, 1080) were read by
    nothing. Migration: settings.timeout_seconds is settings.timeout_seconds .unwrap_or(10) where you used the number; assign Some(..) where you set it.
    screenshot_dir is documented as stored for your code: the library never read
    it. Settings also has the new public fields webdriver_url and chrome_bin,
    and LaunchSettings the new run: a struct literal that names every field
    needs ..Default::default().

  • sb_cdp tab and window creation no longer takes an Option URL, which
    needed None::<&str> to open a blank tab. Browser::new_page,
    Browser::new_window, BrowserContext::new_page and the pool lease's
    new_page take no argument and open a blank one; new_page_at(url) and
    new_window_at(url) open one on a URL (a URL without a scheme is opened
    over HTTPS, as before).

    // before
    let blank = browser.new_page(None::<&str>).await?;
    let docs = browser.new_page(Some("https://example.com")).await?;
    // after
    let blank = browser.new_page().await?;
    let docs = browser.new_page_at("https://example.com").await?;
  • sb_cdp::Locator::type_text empties the field before it types, as Python's
    sb.cdp.type does ("clears the text field first"); it used to type after
    what the field held, which is Python's send_keys and the new
    Locator::send_keys. This changes behaviour without changing the signature,
    so a call that meant to append must be renamed. The MCP type_text tool keeps
    its fill_input and append modes. CdpDriver::type_text now sends key
    events too (it inserted the text in one step), like the other type_text
    methods.

    // before: appended
    page.locator("#note").type_text(" more").await?;
    // after
    page.locator("#note").send_keys(" more").await?;
  • behave::AsyncStepRegistry is Send + Sync: a run can be handed to
    tokio::spawn and the registry shared in an Arc. Its handlers get the
    BaseCase as SharedBaseCase (Arc<tokio::sync::Mutex<BaseCase>>) instead
    of Rc<RefCell<BaseCase>>, return an AsyncStepFuture (Send) and must be
    Send + Sync; with_base is async. New: base_case() and
    into_base_case(), which hands the BaseCase back after a run so the caller
    can quit it (no step needs to).

    // before
    registry.register_regex(r#"^I click "(.+?)"$"#, Box::new(|bc, _step, caps| {
        let selector = caps[0].clone();
        Box::pin(async move { bc.borrow_mut().click(&selector).await.map_err(|e| e.to_string()) })
    }));
    // after: lock the shared BaseCase
    registry.register_regex(r#"^I click "(.+?)"$"#, Box::new(|bc, _step, caps| {
        let selector = caps[0].clone();
        Box::pin(async move { bc.lock().await.click(&selector).await.map_err(|e| e.to_string()) })
    }));
    // before: registry.with_base(|sb| ...);  after: registry.with_base(|sb| ...).await
  • CdpDriver::evaluate returns the value, as Python's sb.cdp.evaluate does,
    and drops a return that starts the last line; it used to return the raw
    CDP result object. CdpDriver::find reports a selector the page rejects as
    InvalidSelector (it was CdpDriver).

    // before
    let title = driver.evaluate("document.title").await?["value"].clone();
    // after
    let title = driver.evaluate("document.title").await?;
  • api::cdp_page::CdpPage is stricter and truer to Python's sb.cdp (and
    the BaseCase::cdp_* methods on it):

    • evaluate (BaseCase::cdp_evaluate) returns the value instead of the raw
      CDP result object {"type": ..., "value": ...}, drops a final return, and
      a script that throws is CdpDriver (it was Unsupported).
    • click (cdp_click) waits up to 7 seconds for the element, scrolls it
      into view, and fails when the browser refuses an input event; it used to
      discard those errors and click an element below the fold off screen.
      click_with_timeout takes another wait.
    • get_text, get_attribute and get_property fail with ElementNotFound
      for an element that is not there; get_text used to answer "" and the
      others None. An element without the attribute still gives None.
    • select_option_by_value fails with InvalidSelector for a value the
      dropdown has no option for (it set the value and reported success), and
      all three select_option_by_* fail with ElementNotFound for no dropdown.
    • Only click waits for its element; the reads, type_text, fast_type and
      the selects look once (wait_for_element_present first for a late
      element). A selector the browser rejects is InvalidSelector in all of
      them, and wait_for_element_present returns a lost connection at once
      instead of polling it until the timeout and calling it ElementNotFound.
    // before: the value was inside the result object
    let title = page.evaluate("document.title").await?["value"].clone();
    // after
    let title = page.evaluate("document.title").await?;
  • click_with_offset and double_click_with_offset take center and count
    from the top-left corner of the element, as Python's do (click_with_offset( selector, x, y, center=False)). They passed (x, y) straight to WebDriver,
    which counts from the centre of the element, while their documentation said
    "from the top-left corner", so every click was off by half the element's
    size. The extra argument stops old calls compiling; to keep the old behaviour
    (and the click it really made) pass true, and to get what the
    documentation promised pass false. They also wait for the element to be
    displayed now. The recorder writes the new form
    (sb.click_with_offset("#a", 10, -4, false)), and a recording of an offset
    from the centre says x, y, center.

    // before: x and y counted from the centre, whatever the docs said
    sb.click_with_offset("#canvas", 10, -4).await?;
    // after
    sb.click_with_offset("#canvas", 10, -4, true).await?;  // from the centre, as before
    sb.click_with_offset("#canvas", 10, 4, false).await?;  // from the top-left corner
  • save_qa_report writes the active QA session. It took the steps and the
    title again (save_qa_report(&steps, title, path)) and ignored the session
    the test had built, so the caller cloned the steps out and passed the title a
    second time. save_qa_report() writes the session's report to the logs
    folder, save_qa_report_to(path) to a file of your choice, and both are an
    InvalidConfig error when there is no session. qa_report_markdown is
    deprecated for MasterQaSession::to_markdown.

    // before
    let steps = sb.qa_session().map(|s| s.steps.clone()).unwrap_or_default();
    let report = sb.save_qa_report(&steps, "Login", None::<&str>)?;
    let there = sb.save_qa_report(&steps, "Login", Some("qa.md"))?;
    // after
    let report = sb.save_qa_report()?;
    let there = sb.save_qa_report_to("qa.md")?;
  • BaseCase::set_messenger_theme takes Python's arguments, a
    MessengerSettings (theme, location, how many messages), or a
    MessengerTheme or MessengerLocation alone, as Page::set_messenger_theme
    does, instead of a CSS colour. It loads Messenger first. The colour setter it
    was is now set_messenger_color. A call with a colour string stops compiling.

    // before
    sb.set_messenger_theme("#336699").await?;
    // after
    sb.set_messenger_color("#336699").await?;       // the same thing
    sb.set_messenger_theme(MessengerTheme::Air).await?; // Python's set_messenger_theme
  • post_message_for(message, duration) takes a Duration, not a number of
    whole seconds, so a message can last 2.55 s as Python's default does. It also
    waits for the message to go now (see Changed).

    // before
    sb.post_message_for("Saved", 2).await?;
    // after
    sb.post_message_for("Saved", Duration::from_secs(2)).await?;
  • A setting is one Spoof<T>. Natural is the browser's own value (the
    default), Random is a valid value drawn when the fingerprint is built and
    coherent with the rest (same operating system, region and locale; repeatable
    with .seed(u64)), Value(T) is exactly this (a plain value converts to it),
    and Off turns the API off, for geolocation, WebRTC, media devices, battery,
    Bluetooth, speech voices, network information and Client Hints only.
    MaskingMode, NoiseMode, PopupMode, ProxyMaskingMode, StartupBehavior
    and CanvasNoiseMode are removed (CanvasNoise has Natural, Persistent,
    Random and Low).

  • FingerprintBuilder::build() returns Result<Fingerprint, FingerprintError>.
    It fails, naming the setting, when Off is given to a setting with no off
    state, when Random is given to one with nothing to draw (proxy, oscpu,
    build_id), when a proxy has no host, and when nothing coherent can be drawn
    (a random locale next to a time zone the table of places lacks).

  • A built Fingerprint has private fields and read accessors
    (fp.screen(), fp.user_agent(), ...). To change one, take fp.to_builder(),
    change it and build again. FingerprintBuilder setters take
    impl Into<Spoof<T>>; geolocation, webgl, webrtc, avail_screen and
    media_devices take a tuple, and screen_spoof is the Spoof form of
    screen(width, height). The value types are ScreenSize, Geolocation,
    WebGl, WebRtcAddresses and MediaDevices.

  • StealthFlags is StealthPolicy (StealthFlags stays as a deprecated
    alias). A policy is how the claims are delivered, and it never changes a
    setting: webrtc_policy, canvas_noise, audio_noise, native_spoofing,
    native_tostring, chrome_runtime, headless_fixes, grant_permissions,
    block_trackers, blocked_trackers, blocked_ports, quic, disable_csp
    and open_shadow_roots. Presets are default(), balanced(),
    native_only() and max(). Set it with .policy(..), .with_policy(|p| ..)
    and the shortcuts .webrtc_policy, .canvas_noise, .audio_noise,
    .native_spoofing, .grant_permissions, .block_trackers,
    .blocked_trackers, .blocked_ports, .disable_csp and .quic, in any
    order.

  • Removed: FingerprintBuilder::{flags, with_flags, canvas_seed, audio_seed, webrtc_ips, webgl_ids, altitude, accuracy, media_device_specs} and the
    deprecated media_devices(a, b, c) (the new media_devices takes a
    Spoof<MediaDevices>; media_device_counts(a, b, c) stays); the public fields
    of Fingerprint; Fingerprint::{fills_defaults, geolocation_popup, seed_value}. os_cpu is now oscpu. Fingerprint::seed() is a u64.

  • The built-in presets set their battery, network information, speech voices of
    the system and Web Bluetooth, and Safari has none of the three it lacks (Off).
    Fingerprint::randomized is the builder with the settings of a persona
    Random; its noise seed is the seed it was given.

  • A fingerprint is serialised with "natural" settings left out, "random" and
    "off" as words, and the value otherwise. A fingerprint saved by an older
    version (the flat screen_width, the flags object) no longer loads: it is an
    error that names the field, not a silent loss. A battery time that is infinite
    is written as null.

  • A fingerprint with a Random setting that has no .seed(..) takes fresh
    entropy, and a fingerprint with nothing to draw takes a seed from its user
    agent, so a preset is the same every time.

  • Profile payloads read one set of words for every key (see the table in
    docs/tutorials/fingerprint_stealth.md): natural, native, pass_through,
    whitelist are Natural; auto, and a flag the payload leaves out, are the
    value the payload holds, else Natural (the default was mask for the
    navigator, localization, time zone, graphics, fonts, screen, geolocation and
    WebRTC flags, which filled in generic values nobody asked for); mask,
    spoof, custom, generic, random, noise are the held value, else a
    drawn one; off, disabled, block, empty turn geolocation, WebRTC and
    media devices off (they used to leave them natural) and are read as Natural
    elsewhere; an unknown word is logged and read as Natural (it used to
    switch the dimension on and fill it with generic values). A literal is taken
    only in the format of its key (a locale or Accept-Language list, an IANA
    zone, a comma-separated list of fonts, a Mozilla/ user agent, vendor~renderer);
    any other text is an unknown word. An empty string in a section is a value the
    payload does not give, a navigator section without a core count no longer
    invents 8, and the payload's core_version is the Chromium major a drawn user
    agent claims. A profile's folder and name seed its draws.
    geolocation_popup is the state of the geolocation setting: block denies,
    prompt asks. to_browser_config, user_agent(), locale(), proxy_string()
    and apply_runtime_overrides read the built fingerprint, so natural and the
    off words launch with no --user-agent, locale, proxy, window size or position.

  • The Linux preset reports an ANGLE-over-OpenGL renderer instead of Direct3D,
    and the macOS preset reports ANGLE over Metal. The presets leave the position
    Natural, because a position is answered without a prompt and a fresh browser
    asks. The iOS preset claims no navigator.deviceMemory and no Client Hints
    (Safari has neither). validate() warns about a time zone without a position
    only when the policy grants permissions.

  • fingerprint!(edge) is the new Edge preset, not the Windows Chrome one.

  • profile_payloads::{Flags, Fingerprint, ProxyConfig} are renamed
    ProfileFlags, ProfileFingerprint and ProfileProxy (the old names are
    deprecated aliases).

  • The MCP tool build_stealth_bootstrap is removed: it returned the same script
    as get_stealth_bootstrap_script. That script, and the providers
    list_evasion_providers lists, are now the 27 a browser session runs
    (session_registry), not the 29 of default_registry. build_fingerprint
    returns the new JSON shape (screen: {width, height}, no flags).

  • Fingerprint::default() equals Fingerprint::builder().build(), and JSON
    without an os_type takes it from the user agent. A Value(true) or
    Value(false) for Bluetooth now changes getAvailability() on a browser that
    has Web Bluetooth.

  • The companion repository seleniumbase-rs-stealth
    takes this crate from git and still uses the old API (field writes such as
    fp.timezone = .., fp.flags.*). Its scrape and mimic crates must move to
    to_builder()...build()? before it follows this change; only codec is known
    to build against it.

  • evasions::launch_args passes --no-sandbox and --disable-setuid-sandbox
    only on Linux, as StealthOptions does.

Migrating a fingerprint:

// before: the intent is spread over a value, a flag and a mutation after build
let mut fp = Fingerprint::builder()
    .screen(1920, 1080)
    .geolocation(40.0, -74.0)
    .flags(StealthFlags::balanced())
    .build();
fp.flags.screen_masking = MaskingMode::Custom;
fp.flags.native_spoofing = true;
let width = fp.screen_width; // Option<u32>

// after: each intent is given once, and a built fingerprint is read, not edited
let fp = Fingerprint::builder()
    .screen(1920, 1080)
    .geolocation((40.0, -74.0))      // or Spoof::Random, or Spoof::Off to deny
    .policy(StealthPolicy::balanced())
    .native_spoofing(true)
    .build()?;
let width = fp.screen().value().map(|s| s.width);

// to change one thing later
let wider = fp.to_builder().screen(2560, 1440).build()?;
Before After
fp.flags.x_masking = MaskingMode::Natural leave the setting out, or give Spoof::Natural
MaskingMode::Mask with no value (a generic fill) Spoof::Random (a coherent drawn value)
PopupMode::Block .geolocation(Spoof::Off)
.webrtc_ips(public, local) .webrtc((public, local))
.webgl(vendor, renderer) and .webgl_ids(..) .webgl((vendor, renderer)), or WebGl::new(..).ids(..)
.geolocation(lat, lon).altitude(a).accuracy(m) .geolocation(Geolocation::new(lat, lon).altitude(a).accuracy(m))
.flags(StealthFlags::balanced()) .policy(StealthPolicy::balanced())
.with_flags(|f| f.grant_permissions = true) .grant_permissions(true) or .with_policy(|p| p.grant_permissions(true))
.canvas_seed(v), .seed(v) .seed(v) and .canvas_noise(CanvasNoise::Persistent)
fp.user_agent.as_deref() fp.user_agent().as_str()
fp.is_masked(Dimension::Screen) unchanged

Added

  • sb_assert_visible!, the macro assert_visible! was missing the sb_
    prefix of.

  • PlaywrightSession::connect_over_cdp(endpoint) attaches to a running
    Chromium (for example sb_cdp::Browser::http_url()) and works on the page it
    has open; close detaches and leaves the browser running. Python reaches
    Playwright the same way (sb.cdp.get_endpoint_url()).

  • run_cdp_test(options, test) and Plugins::run_cdp_test(name, options, test) run a test on the first tab of a Pure CDP browser and always close it,
    the way run_browser_test does for WebDriver.

  • RuntimeConfig::from_env_lenient (the config and the list of variables that
    did not parse), from_vars and from_vars_lenient (read the variables from
    a function instead of the process environment).

  • BrowserConfig::with_run_settings(RunSettings) (and run_settings()) sets
    the demo, slow, message and timeout settings a test starts with, as Python's
    SB(demo=, slow=, demo_sleep=, message_duration=) does. They stay out of
    configuration files and of the key that decides which tests share a browser.
    RunSettings::with_default_timeout is new and Settings::timeout_seconds
    feeds it. A BaseCase from BaseCase::new (so the test runners and
    sb_test! too) starts with them and with that default timeout.

  • BrowserConfig::with_remote_webdriver_timeout(Duration) sets
    remote_webdriver_timeout_seconds, which had no builder; a part of a second
    rounds up. The common with_* builders of BrowserConfig gained doc
    comments.

  • Settings::load_from(path) and Settings::validate; DriverMode implements
    FromStr and Display (webdriver, cdp, uc) and has name().

  • RunSettings::pause_for(PauseKind) names what pause(tiny: bool) took as a
    flag: PauseKind::Full after an action that changed the page, Short (1/3.4
    of the time in demo mode) after one that did not. message_duration() keeps
    the milliseconds of --message-duration (default DEFAULT_MESSAGE_DURATION,
    Python's 2.55 s) and message_duration_headless() cuts it to 0.75 s as
    Python does in a headless browser. New getters slow_mode() and
    demo_sleep(). pause and message_seconds are deprecated now that
    BaseCase uses the new ones.

  • BaseCase::demo_pauses() (feature test-util) lists the PauseKind of each
    pause demo and slow mode have made, so a test can assert that an action
    paused without timing it.

  • BaseCase::click_if_visible_with_timeout(selector, Duration) waits up to the
    time given for the element to be displayed before it gives up without an
    error, as Python's click_if_visible(selector, timeout=...) does;
    click_if_visible(selector) still looks once, like Python's timeout=0.

  • BaseCase::cdp_insert_text(text): Input.insertText into whatever has focus,
    the honest name of cdp_type_text, which types nothing (it inserts, with no
    key events).

  • RunSettings, PauseKind, Settings and SettingsOverrides are re-exported
    at the crate root, beside RuntimeConfig.

  • sb_cdp::Page::origin, locale_code, screen_rect (a ScreenRect of the
    display) and click_active_element: Python's sb.cdp.get_origin,
    get_locale_code, get_screen_rect and click_active_element.

  • sb_cdp::Locator::click_with_offset(x, y, center) (the offset from the
    top-left corner, or from the centre) and click_if_visible() (one look, like
    Python's default timeout=0; click_if_visible_with_timeout(timeout) waits):
    Python's sb.cdp.click_with_offset and click_if_visible.

  • behave::StepRegistry::register_step and AsyncStepRegistry::register_step
    (and try_register_step) register a handler for the step text alone, which
    matches whatever keyword the step is written with, or none, like behave's
    @step; behave::step_pattern (the regex they build) is public. The
    built-in async browser steps also understand the wording of Python
    SeleniumBase's --rec-behave recorder: Open "url", Click "selector",
    Type text "text" into "selector", Go back, Go forward, in single or
    double quotes, so a recording from Python runs here.

  • sb_cdp::Page::set_messenger_theme takes impl Into<MessengerSettings>, and
    MessengerSettings is From<MessengerTheme> and From<MessengerLocation>:
    page.set_messenger_theme(MessengerTheme::Air) changes one thing and keeps
    Python's defaults for the rest. Passing MessengerSettings still works.

  • The built-in plugins, one cargo feature each (ADR 005, core side of
    phase 1): plugin-shield, plugin-block-resources and plugin-crx, each of
    which turns on plugins and compiles only its own plugin crate, and
    plugins-builtin for all three. They come from the crate
    seleniumbase-rs-plugins-builtin, re-exported as seleniumbase_rs::builtin_plugins.

  • Choosing plugins by name, with no code. A [plugins] table in the
    settings file (enable = [...], and one options table per plugin) and the
    SB_PLUGINS variable (a comma-separated list, which replaces enable; an
    empty value turns every plugin off) become a strict PluginSet through the
    new plugin_host module. LaunchOptionsBuilder::plugins_named and
    plugin_selection (errors at build()), and BrowserConfig::with_plugins_named
    and with_plugin_selection, take the names. An unknown name is an error that
    lists the known ones and the cargo features that add the others; options for
    a plugin that is not enabled, options a plugin refuses and a name enabled
    twice are errors too. A build without the plugins feature refuses a
    non-empty selection when it loads the settings (Settings::plugins). sbrs
    and sbrs-mcp apply the table and the variable, and take command-line
    options (next entry). See docs/plugins/selecting.md.

  • Plugins on the command line (ADR 005, phase 1). sbrs and sbrs-mcp have
    the built-in plugins compiled in: plugins-builtin is a default feature of
    seleniumbase-rs-cli (with pdf and mcp-server, so the release archives and
    cargo install have them), and plugins, plugin-shield,
    plugin-block-resources and plugin-crx pass through to the library. They
    honour the [plugins] table of the settings file and SB_PLUGINS for every
    command that starts a browser in either engine (BaseCase, and sbrs devtools
    on the pure CDP engine), which sbrs ignored before, and add two global
    options that go before the command and repeat:
    --plugin NAME, and --plugin-opt NAME.KEY=VALUE with a TOML value, as in
    sbrs --plugin shield --plugin-opt 'shield.level="strict"' open URL. The
    precedence is the command line over SB_PLUGINS over the file (said in
    --help): --plugin adds a name after the ones the others turn on,
    --plugin-opt replaces one key and keeps the plugin's other options. New
    commands sbrs plugin list (name, category, whether it is on, summary) and
    sbrs plugin info NAME (category, phase, capabilities with their risk and
    whether the host enforces them, what it needs, ordering, the options in effect
    and, if the plugin refuses them or cannot start without any, why); an unknown
    name exits with status 2 and lists the known ones. sbrs test passes the
    names on to the tests as SB_PLUGINS and refuses --plugin-opt, which the
    variable cannot carry. sbrs-mcp takes --plugin and --plugin-opt too, and
    reads the table and the variable from the folder it runs in; the operator
    chooses the plugins, and no tool argument adds one. A mistake (an unknown
    name, an option of a plugin nothing turns on, an option the plugin refuses, a
    value that is not TOML such as shield.level=strict) is reported with exit
    status 2 before any browser starts or the MCP server listens. A build without
    plugins (--no-default-features) fails closed with the feature to build
    with. The Docker image is built without the default features and has no
    plugins. Only the [plugins] table of the settings file is read by sbrs devtools, sbrs plugin list|info and sbrs-mcp, so a value of the file that
    is wrong for a browser does not stop them; a command that starts no browser
    ignores the options, even a wrong one. A --plugin that turns a plugin back
    on after SB_PLUGINS left it out keeps the options the file gave it. Every
    active plugin is logged at INFO with the place that turned it on, and a
    plugin that declares launch.proxy, artifacts.write or a host.*
    capability is refused when only the sbase_config.toml of the current folder
    names it: a cloned project must not grant it (name it with --plugin or
    SB_PLUGINS, or use --config). The library side:
    PluginSelection::apply_flags, enable_once, prune_unused,
    set_enabled_keeping_options and to_list (and PluginSelection is Eq),
    Settings::load_plugin_selection and load_plugin_table (the plugin part of
    the settings only), the module cli::plugin_cmd (list, info,
    check_selection, PluginChoice, PluginSource, apply_to_config),
    DevtoolsRequest::plugins, mcp::Settings::with_plugin_selection and
    mcp::serve_with. See docs/plugins/selecting.md.

  • The shield plugin is documented (docs/plugins/shield.md): what it turns
    off, its levels, WebRTC modes and options, and what has not been verified in a
    real Chrome.

  • Browser::extension_ids() keeps the ids Chrome gave the extensions a launch
    loaded (Extensions.loadUnpacked), in load order.

  • Spoof<T> with is_natural, is_random, is_value, is_off, is_set,
    value, as_str and map, and the policy types StealthPolicy,
    CanvasNoise and AudioNoise.

  • Fingerprint::{to_builder, is_masked, validate, randomized, randomized_with_major}; Dimension lists what can be masked and
    Fingerprint::is_masked(Dimension) is the one question the page-script
    providers, the launch arguments, the protocol overrides and the proxy ask.

  • FingerprintBuilder::{seed, core_version, avail_screen, product_sub, build_id, app_version, media_device_counts} and the policy shortcuts.
    build() derives the language list from the locale and the OS persona and
    navigator.platform from a recognised user agent, only when unset.

  • Fingerprint::edge_desktop() (Edg/ user agent, Microsoft Edge Client
    Hints through randomize::{edge_user_agent, edge_hints}), also in the MCP
    preset list; randomize::locale_of_timezone.

  • evasions::session_script, the script the pure CDP engine runs, which the
    WebDriver Uc and Cdp modes now run too.

  • An Off API is removed from the page, as a browser without it would be:
    getBattery, navigator.connection, navigator.bluetooth,
    navigator.userAgentData, WebRTC; Off media devices and speech voices list
    nothing; a denied geolocation answers PERMISSION_DENIED.

  • CanvasNoise::{Random, Persistent, Low} and AudioNoise::Random add noise.

  • Fingerprint::validate() reports a WebGL renderer from another OS's graphics
    API and a MobileSafari persona with a Chrome/ user agent as errors, and a
    locale from another country than the time zone as a warning.

Deprecated

  • assert_visible!: use sb_assert_visible!.

  • save_saved_cookies and load_saved_cookies: use save_cookies and
    load_cookies.

  • PlaywrightSession::launch_headless, launch_with_fingerprint and
    launch_headless_with_fingerprint: they are shortcuts for
    launch_with_config(&BrowserConfig::default().with_headless(..) .with_fingerprint(..)).

  • run_settings::DEFAULT_MESSAGE_SECONDS (3): use DEFAULT_MESSAGE_DURATION
    (2.55 s). RunSettings::message_seconds now rounds message_duration() up,
    so the two cannot drift apart; the default is still 3. It is deprecated too,
    with RunSettings::pause(tiny): use message_duration() and
    pause_for(PauseKind).

  • sb_cdp::Locator::click_at(Point): use click_with_offset(x, y, false),
    Python's name for it. click_at calls it.

  • BaseCase::cdp_type_text: renamed cdp_insert_text.

  • BaseCase::qa_report_markdown: use MasterQaSession::to_markdown.

  • BaseCase::cdp_click_element: use cdp_click, which it now calls (see
    Fixed). sbrs cdp-click-element and cdp-type-text keep their names.

  • evasions::cdp_overrides: use safe_cdp_overrides, which leaves out the
    Proxy-Authorization header that would hand the proxy's login to every site.

  • StealthFlags (use StealthPolicy), StealthPolicy::{mask_all, all_custom}
    (use max).

Changed

  • sbrs and sbrs-mcp parse their command line with usage
    instead of clap.
    clap, clap_complete, clap_complete_fig and clap_usage
    leave the workspace. The commands, flags, aliases, defaults and exit statuses are
    those of 0.4.0: both builds were run on 256 command lines, 250 act the same and the
    six that do not fall into the three groups below. completions/sbrs.usage.kdl is
    now written from the Rust declaration by a test that fails when the file is stale
    (SB_UPDATE_COMPLETIONS=1 cargo test -p seleniumbase-rs-cli --bin sbrs usage_spec
    writes it again), and so are the new page docs/help/cli_reference.md (a CLI
    reference rendered from the spec) and the Fig spec docs/completions/fig-sbrs.ts.
    What you can notice:

    • sbrs --help is laid out by usage: the sections are Commands: and Flags:, the
      commands are listed alphabetically instead of in the order they were declared, a
      short description keeps the final period of its doc comment, an alias is shown as
      [aliases: new], and the usage line reads Usage: sbrs [FLAGS] <SUBCOMMAND>. The
      banner still comes first.
    • sbrs completions bash|zsh|fish|elvish|powershell print a short script that asks
      sbrs for the candidates when the completion key is pressed (so sbrs has to be
      on PATH), instead of a script that lists every command. A path argument
      completes file names, as before. completions fig is written by
      crates/cli/src/fig.rs from the tables of the parser: it marks an option as
      required or repeatable only when it is, where the clap_complete_fig output marked
      every option that takes a value as repeatable. completions usage prints the spec.
    • Three groups of command lines behave differently, because usage is looser than
      clap in these places. A flag repeated after a subcommand
      (sbrs wait-for-text --css a --css b) takes its last value instead of ending
      with status 2; the options before the
      subcommand and those of grid-hub, grid-node and prompt are still refused,
      since usage can set that rule on the root and on a struct, not on the inline
      variants of the command enum. A value written after a switch, --headless=false
      or --headed=true, is accepted and means what it says (the parser would have
      ignored the value and switched the option on); any other value, such as
      --headless=0, is still an error. sbrs help nosuch prints the main help and
      exits with 0, where it ended with status 2.
  • ActionRecorder::to_gherkin writes Python SeleniumBase's --rec-behave
    wording, Open "url", Click "selector", Type "text" into "selector",
    Go back and Go forward, where it wrote I open "url" and the like, so a
    recorded feature runs in either implementation. An argument that holds a
    double quote is written in single quotes, as Python does (it used to be
    skipped); one with both kinds of quote is still skipped. A feature written by
    an earlier version keeps running: the I open steps are still registered.
    The built-in Type step accepts what Python's recorder really writes,
    Type "text" into "selector" (it understood only Type text ...).

  • Demo mode and slow mode follow Python's rule for how long to pause, and
    reach the calls Python's do.
    open always paused in full, click always
    in full and typing always short; Python pauses after an action in full when
    the page's URL changed and for 1/3.4 of the time when it did not (slow mode:
    always in full). BaseCase now reads the URL before and after such an
    action, in demo mode only, and pauses by RunSettings::pause_for. The pauses
    and the outline now also cover double_click, context_click, the link
    clicks, click_active_element, choose_file, the selects, add_text,
    send_keys, set_value, submit, focus, go_back, go_forward, the JS
    and jQuery clicks, drag_and_drop, the offset clicks and the window size and
    position calls; press_keys pauses in full after a typed Enter. A demo or
    slow run is longer for it: leave them off unless a person watches.

  • post_message, post_message_for, post_success_message and
    post_error_message last the run's message duration to the millisecond

    (2.55 s by default, not 3 s), at most 0.75 s in a headless browser, and wait
    for the message to go and 0.15 s more
    , as Python's default pause=True
    does; they returned at once. They share one box: post_message_for no longer
    draws its own. post_message_and_highlight still does not wait.

  • check_window baselines can be told apart by test. The baseline folder
    was named after the thread's last path part, so tests of the same function
    name in different modules or files shared one folder, and a body run by
    #[tokio::main] or tokio::spawn was filed under main or
    tokio-runtime-worker. BaseCase::with_test_name("module::test") (builder,
    with test_name() to read it back) names the test, and the baselines go in
    visual_baseline/module__test/<name>. sb_test! sets it from the module path
    and the function, so its tests get a new folder: the first run saves a
    baseline there again. Without a test name nothing changes.

  • The element assertions and waits mean what Python's mean. assert_element,
    assert_element_visible, assert_elements and wait_for_element need the
    element to be displayed and wait for it (wait_for_element for its own
    timeout, the others for the default one); they used to look once for an
    element in the DOM, hidden or not. A test that asserted a hidden element (a hidden input, a
    display: none template) with assert_element must use
    assert_element_present, which now waits as well, like
    assert_elements_present. wait_for_query_selector is
    wait_for_element_present, as in Python, and wait_for_element_present
    takes &self. AssertionApi::assert_element is visible-and-waiting for the
    pure CDP Page too.

  • sb_test! counts a skipped test as passed. A body that ends with
    skip_test used to panic with test failed, and now logs the reason and
    passes, like run_browser_test (libtest cannot report a skip). A test that
    relied on the panic must fail with an error instead.

  • load_cookies waits for the page to finish loading first, as Python's does,
    and the domain it gives a foreign cookie is the page's host without its port
    (a browser driver refuses a cookie domain with a port).

  • assert_shadow_text(text, selector), the text waits and the deferred checks
    changed their argument order silently (both are &str). Only assert_text
    and assert_exact_text say "arguments look swapped" when they fail; search
    your tests for the other names listed above.

  • add_text waits for the element to be displayed (it used to fail on an
    element that was not there yet), send_keys is add_text, and update_text
    is type_text, as in Python, where they are the same methods.

  • wait is sleep, as in Python (the two differed: wait took whole seconds
    and skipped what sleep does); see the breaking change above for its new
    argument.

  • The getters and assertions that only read the page take &self instead of
    &mut self: get_title, get_current_url, get_text, assert_title,
    assert_title_contains, assert_url, assert_url_contains, assert_text,
    assert_exact_text, assert_text_visible, assert_text_not_visible,
    assert_exact_text_not_visible and assert_element_visible, so a test can
    call them from a closure that holds only &BaseCase. Existing calls compile
    as before.

  • The text assertions wait, as Python's do. assert_text,
    assert_exact_text, assert_text_visible, assert_text_not_visible and
    assert_exact_text_not_visible poll for up to the default timeout
    (set_timeout) instead of reading the page once, so a page that is still
    rendering no longer fails them. The first three also require the element to
    be displayed, and assert_exact_text and is_exact_text_visible ignore
    whitespace at the ends of both strings. A failing assertion now arrives after
    the timeout rather than at once: lower it with set_timeout where a test
    expects a failure. assert_text_visible is assert_text, as in Python. The
    deferred checks wait 2 seconds, whatever the default is.

  • convert_xpath_to_css handles more XPath: paths (//a/b becomes a > b,
    //a//b becomes a b, /html/body/...), a position before the other tests
    (tag[n] becomes tag:nth-of-type(n), *[n] becomes *:nth-child(n)),
    contains(@a,'v'), starts-with(@a,'v') (an addition; Python has none),
    id('x') and or (a comma list). A form with no exact CSS equivalent (a
    position after another test, text(), parent::) is still refused instead
    of being converted into something close.

  • The Python importer passes By.XPATH locators through as XPath, so XPath
    that CSS cannot say is no longer refused.

  • The Python importer matches the arguments of a call with Python's parameters:
    self.assert_text("x") gets Python's default selector body, and keyword
    arguments (selector=, text=, timeout=) work. It imports
    assert_text_visible, assert_text_not_visible,
    assert_exact_text_not_visible, wait_for_text, wait_for_exact_text, the
    deferred and delayed asserts, process_deferred_asserts and
    enter_mfa_code, which it used to leave as TODOs. An argument Rust cannot
    carry over (timeout= on an assertion, a by that is not CSS) is an error
    instead of being dropped. send_keys becomes add_text, not type_text:
    Python's and Selenium's send_keys do not clear the field. The timeout of
    wait_for_element_visible and wait_for_element_present is read from
    timeout=; a second positional argument is Python's by, not a timeout.

  • Both dependencies on the plugins repository are pinned to one revision
    (628991a): seleniumbase-rs-plugin and seleniumbase-rs-plugins-builtin
    must name the same source, or Cargo builds two plugin API crates with
    unrelated Plugin traits. tests/plugin_features.rs fails when they differ.
    A plugin crate added with cargo add --git now needs the same --rev; the
    plugins guide says so.

  • Plugin settings reach the browser. LaunchSettings::host_resolver_rules
    becomes one --host-resolver-rules switch, disable_features and
    enable_features one --disable-features and one --enable-features switch
    (the first merged with the Translate the library turns off), webrtc_policy
    --force-webrtc-ip-handling-policy, and disable_quic --disable-quic, on
    the pure CDP engine and through chromedriver. They were dropped before. The
    profile is shown to plugins before they run (persistent when user_data_dir
    is set), so shield's ephemeral_profile can refuse a persistent one. A
    plugin that sets one of these fields for Firefox or Safari is refused.

  • Plugins are checked against the engine that hosts them
    (PluginSet::check_with): the pure CDP engine supports every capability,
    WebDriver with Chromium, Firefox or Safari the ones its protocol allows, and a
    WebDriver server that answers browserName: tauri is re-checked as an
    embedded webview once the session exists. A plugin that declares no metadata
    keeps running on Firefox and Safari as before.

  • A plugin's raw Fetch.* command is refused, on the browser and on a tab,
    in both engines: the host owns request interception, and the library's own
    proxy login uses it. A plugin that blocks URLs sends Network.setBlockedURLs.

  • SeleniumBaseError::Plugin names the plugin for the Unsupported,
    Capability, Undeclared, Conflict and Sealed errors of the plugin API,
    and its hint explains them.

Fixed

  • BaseCase::get_locale_code made up en-US when the browser gave no
    language; Python returns what navigator.language || navigator.languages[0]
    answers, and so does this now (the empty string for no locale).

  • BaseCase::cdp_click_element pressed at WebDriver's rectangle of the
    element, which is in the document, but the DevTools mouse event takes
    viewport coordinates, so on a page scrolled by N pixels the click landed N
    pixels too low (or on another element). It is now cdp_click: it scrolls the
    element into view and presses at its centre in the viewport. The
    documentation of cdp_mouse_click said "screen coordinates"; they are
    viewport coordinates. Behaviour change: like cdp_click, it now takes a CSS
    selector only (it went through WebDriver and also took XPath, link= and
    the like), and a missing element is waited for before it fails. Use click
    for the other selector forms. A recording writes the click as cdp_click,
    so generated programs do not call the deprecated method.

  • BaseCase::drag_and_drop outlines the drop selector too in demo mode, as
    Python does; it outlined only the drag selector.

  • BaseCase::click_with_offset and double_click_with_offset scroll the
    element into view before they measure it, as Python does, so an offset on an
    element below the fold lands in the right place.

  • BrowserConfig::with_run_settings documents that it replaces the whole
    RunSettings, the default wait of Settings::to_browser_config included.

  • RunSettings demo and slow mode pause for 0.5 s after an action, as Python
    4.55.2 does (DEFAULT_DEMO_MODE_TIMEOUT); DEFAULT_DEMO_PAUSE was 1.2 s.
    Pass with_demo_sleep to keep a longer pause.

  • RunSettings::with_fast_mode (--fast) no longer cancels demo and slow
    mode. Python reads --fast only in CDP mode, where it skips short waits this
    crate never makes, so the method is deprecated and does nothing. Behaviour
    change: a run with demo or slow mode and --fast now pauses; drop the
    with_fast_mode(true) call to keep the old effect of "no pauses" by turning
    the mode off instead.

  • Settings::to_browser_config honours SB_WEBDRIVER_URL and SB_CHROME_BIN
    (the new fields Settings::webdriver_url and chrome_bin). Before, they
    reached only BrowserConfig::from_env and sbrs, which patched them in by
    hand, so the sbrs mkdir test helper ignored them. SB_WINDOW_WIDTH and
    SB_WINDOW_HEIGHT now size the browser window (when both are set).

  • An unknown browser or driver mode is an error. SB_BROWSER=chrom or
    mode = "stealth" used to give Chrome in WebDriver mode without a word;
    Settings::from_file, load, load_from and from_env now call the new
    Settings::validate, which also refuses a window width without a height.

  • sbrs and sbrs-mcp fall back to RUST_LOG when SB_LOG_LEVEL is unset,
    blank or invalid (then to info); RUST_LOG used to be ignored, and the
    docs said both. One bad SB_* runtime variable no longer drops the good
    ones: both binaries now use RuntimeConfig::from_env_lenient, so a typo in
    SB_SHUTDOWN_TIMEOUT_SECS keeps SB_LOG_LEVEL=debug and is logged as a
    warning after logging starts.

  • A test that panics no longer leaves its browser open. run_browser_test,
    Plugins::run_browser_test and sb_test! now share one lifecycle that
    catches the panic, closes the browser, tells the plugins the test failed and
    then raises the panic again. sb_test! used to panic on a skipped test and
    to hide the test's error when quit also failed; it now behaves like
    run_browser_test.

  • PlaywrightSession (feature playwright) sizes the window from
    BrowserConfig::with_window_size instead of always starting maximised at
    1920 by 1080. Without a configured size it behaves as before.

  • BaseCase::activate_playwright_mode (feature playwright) starts its
    Chromium as the case's BrowserConfig says (headless or not, proxy, user
    agent, locale, fingerprint, binary path, window size), where it always
    started a default headed one and ignored the configuration; the profile
    folder is left out, since the WebDriver browser holds it. quit() now closes
    the Playwright session too; it was left running. Behaviour change: a headless
    configuration now gives a headless Playwright browser, and a test that called
    session.close() itself before quit() should leave that to quit().

  • sb_cdp::Locator reads (text, html, attribute, info, bounding_box,
    viewport_rect) reported every failure as ElementNotFound. Only a timeout
    does now. An invalid CSS or XPath selector is InvalidSelector (for reads
    and for actions, which used to say CdpDriver), and a lost connection keeps
    its own error.

  • The pure CDP programs ActionRecorder::to_cdp_script and to_cdp_test write
    follow Locator::type_text now emptying the field (see Breaking): a recorded
    add_text or send_keys is .send_keys(..), which adds to the text, where
    .type_text(..) would have erased it; type_text and update_text are one
    .type_text(..) call, not a clear and a type_text; and a
    click_with_offset is .click_with_offset(x, y, false) (true for a
    recording that says x, y, center), not the deprecated click_at. A
    program recorded with an earlier version keeps working but clears the field
    before an add_text: record it again, or rename that call to send_keys.

A breaking change to the fingerprint API: one field per setting, and intent
given once.
Before, every setting was kept twice, as a value and as a mode that
said whether to use it (fp.screen_width and fp.flags.screen_masking), which
allowed states that mean nothing, and callers mutated the fingerprint after
build() to say what they meant. Now every setting is one Spoof<T>
(Natural, Random, Value, Off), the behaviour that is not a setting is a
StealthPolicy, and a built Fingerprint is never mutated. The Python-parity
methods (parity/api.toml) are unchanged; the fingerprint API is Rust-only. The
design is docs/design/fingerprint-api-v2.md.

  • enter_totp_code and enter_mfa_code now press Enter after the code, as
    Python's enter_mfa_code does (it types the code and a newline). A test that
    clicked a submit button after them submits twice and can drop the click.
  • wait_for_exact_text looked at every element of the page, <title>,
    <script> and hidden elements included, so it could find text that no
    visible element showed. It now looks at the selected element and requires it
    to be displayed.
  • sb_assert_not_visible! without a message did not compile, and sb_wait_and_click!
    without a message dropped the error of the wait. sb_test! quit the browser
    before it reported the test's result, so a quit that failed hid the test's own
    error; it now reports both.
  • Pooled browsers shared the state of a plugin. PluginSet::for_launch()
    now runs before every launch, attach and WebDriver session, so a plugin that
    offers per_launch (the CRX plugin's temporary folders, shield's origin list)
    gets a fresh instance for each browser, and on_close reaches the instance
    that started.
  • A plugin's Network.setBlockedURLs replaced the blocklist of the tab.
    Raw sends from several plugins, and the library's own ad-block list, are now
    merged into one list for each tab (each window with chromedriver). The
    tracker hosts of a fingerprint with block_trackers are part of that list on
    both engines, and with chromedriver a window keeps its union when the session
    switches away and back with ad_block on (switching re-sent the library's
    list alone, which replaced the plugins' patterns).
  • A switch in extra_args replaced the one plugins built. On the pure CDP
    engine --disable-features, --enable-features and --host-resolver-rules
    given in extra_args are now joined into the one switch (as the WebDriver
    engine already did), and the WebRTC policy of the options wins over a
    --force-webrtc-ip-handling-policy argument; before, Chrome kept the last
    one and dropped the merged list, shield's included.
  • SB_PLUGINS= did not turn plugins off when the file had an options table
    such as [plugins.shield] (the build failed with stray options), and in a
    build without the plugins feature the file's [plugins] table stopped the
    load before SB_PLUGINS could override it. The variable now drops the
    options of the plugins it no longer names and is applied before the check
    for plugin support.
  • A plugin's Network.setRequestInterception is refused like Fetch.*.