Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Expect()

The expect Cargo feature ports Playwright's "pre-register then await" pattern to zendriver-rs. Instead of polling for an event after triggering it (which races against the response coming back faster than your subscription registers), you register an expectation before the action and await the returned Future afterwards. The subscriber is live by the time expect_* returns, so the event cannot slip past.

Enable it in Cargo.toml:

[dependencies]
zendriver = { version = "0.1", features = ["expect"] }

Five entry points on Tab:

MethodCDP eventReturns
expect_requestNetwork.requestWillBeSentRequestExpectationMatchedRequest
expect_responseNetwork.responseReceivedResponseExpectationMatchedResponse
expect_dialogPage.javascriptDialogOpenedDialogExpectationMatchedDialog
expect_downloadPage.downloadWillBegin + progressDownloadExpectationMatchedDownload
expect_file_chooserPage.fileChooserOpenedFileChooserExpectationMatchedFileChooser

The race-free pattern

Naive polling races the network:

// WRONG — the click can fire the request and Chrome can return the
// response before our subscriber registers. We then poll forever.
go.click().await?;
let resp = wait_for_response("*/login").await?;  // race!

The correct flow pre-registers, then triggers:

// RIGHT — the oneshot subscription is live by the time expect_response
// returns. The request cannot complete before we're listening.
let expectation = tab.expect_response("*/login");
go.click().await?;
let resp = expectation.await?;  // safe

expect_response is sync — it spawns the subscriber task internally and returns the awaitable handle synchronously, so any event Chrome emits between the spawn point and the trigger action is captured.

URL matching

expect_request and expect_response take any value that implements Into<UrlMatcher>:

  • &str / String — substring match (URL contains the needle).
  • regex::Regex — full regex via is_match.
use regex::Regex;

let exp1 = tab.expect_response("/api/users");  // substring
let exp2 = tab.expect_response(Regex::new(r"^https://.*\.example\.com/v\d+/").unwrap());

expect_dialog, expect_download, and expect_file_chooser take no matcher — they fire on the first event of their kind. If you need to filter further, inspect the matched event in your code after .await?.

Full example: login response

This example renders a tiny form via data: URL, registers a response expectation against */login, clicks submit, and asserts the URL + status:

//! Demonstrates the P5 [`Tab::expect_response`] expectation API.
//!
//! Sequence:
//!   1. Render a tiny login-style form via `data:` URL. The form's submit
//!      handler `fetch()`s `https://example.com/login` (no real backend —
//!      example.com just 404s for that path, but the response still fires
//!      `Network.responseReceived`, which is what the expectation
//!      subscribes to).
//!   2. Register `tab.expect_response("*/login")` BEFORE clicking submit.
//!      The subscriber task is spawned synchronously inside the call, so it
//!      is live before the click — the response cannot slip past us.
//!   3. Click submit; the page fires the `fetch()`.
//!   4. Await the [`ResponseExpectation`]; assert the URL matched. Print the
//!      status code (404 from example.com, demonstrating that *any* response
//!      arrival satisfies the expectation regardless of HTTP status).
//!
//! Requires the `expect` cargo feature:
//! `cargo run --example expect_login_response --features expect`.

use std::time::Duration;

use zendriver::Browser;

const FORM_HTML: &str = "data:text/html,\
<!doctype html><html><body>\
<form id='f' onsubmit=\"event.preventDefault();fetch('https://example.com/login',{method:'POST',mode:'no-cors'});\">\
<input id='user' name='user' value='rin' />\
<input id='pass' name='pass' type='password' value='hunter2' />\
<button id='go' type='submit'>Log in</button>\
</form></body></html>";

#[tokio::main]
#[allow(clippy::result_large_err)] // example boundary; users wrap in their own Error
async fn main() -> zendriver::Result<()> {
    tracing_subscriber::fmt::init();

    let browser = Browser::builder().headless(true).launch().await?;
    let tab = browser.main_tab();
    tab.goto(FORM_HTML).await?;
    tab.wait_for_load().await?;

    // Register expectation BEFORE the trigger action — the subscriber is
    // live by the time `expect_response` returns, so the response cannot
    // race past us.
    let expectation = tab
        .expect_response("*/login")
        .timeout(Duration::from_secs(10));

    let go = tab.find().css("#go").one().await?;
    go.click().await?;

    let matched = expectation.await?;
    println!(
        "matched: url={} status={} status_text={:?}",
        matched.url, matched.status, matched.status_text
    );
    assert!(
        matched.url.contains("/login"),
        "matched url should contain /login; got {}",
        matched.url
    );

    browser.close().await?;
    Ok(())
}

Three things worth noting:

  1. The expectation is constructed before the click. Reversing those two lines reintroduces the race.
  2. The .timeout(Duration::from_secs(10)) overrides the default 30 s outer timeout. Use a tighter budget when you expect a fast local response — saves you 30 s of waiting on a test that's quietly broken.
  3. expectation.await? resolves on the first match. If you need to collect every matching request over a window, use a Stream (build one via repeated expect_request calls or fall back to the interception API's subscribe).

Dialogs

Pages that call alert() / confirm() / prompt() hang waiting for user input. expect_dialog lets you handle the dialog programmatically:

let dlg = tab.expect_dialog();

// Trigger code that opens the dialog.
tab.evaluate_main::<()>("alert('hi')").await?;

let matched = dlg.await?;
println!("dialog type: {:?}, message: {}", matched.dialog_type, matched.message);
matched.accept(None).await?;  // dismiss with no prompt response

MatchedDialog::accept(Some("text")) supplies a response for prompt() dialogs. MatchedDialog::dismiss() closes without accepting.

Downloads

expect_download does extra per-tab wiring on its first call: allocates a tempdir, dispatches Browser.setDownloadBehavior { behavior: "allowAndName", downloadPath }, and starts a long-running progress subscriber. Subsequent calls reuse the same setup, so the per-expect_download cost is one CDP event subscription.

use std::path::PathBuf;

let dl = tab.expect_download().await?;

let link = tab.find().css("a[download]").one().await?;
link.click().await?;

let matched = dl.await?;

// Wait for completion then copy out of the tempdir to a stable path:
matched.save_to(PathBuf::from("/tmp/result.pdf")).await?;

The path returned by MatchedDownload::path().await points into a per-tab tempdir (named by Chrome's CDP guid) and is None until the transfer completes. The tempdir lives as long as the Tab does, so call save_to to copy the file somewhere stable before the tab drops.

File pickers

Element::upload_files dispatches DOM.setFileInputFiles straight at a direct <input type="file">'s backend node — but it can't reach a button or label that opens the picker indirectly (a JS handler that calls hiddenInput.click(), or any custom upload widget). expect_file_chooser covers that case: it intercepts Page.fileChooserOpened via Page.setInterceptFileChooserDialog, so it answers the picker regardless of what triggered it — a hidden input, a visible one, or a custom widget that ultimately opens a native <input type="file"> chooser.

let fc = tab.expect_file_chooser(&["/tmp/photo.jpg"]).await?;

let button = tab.find().css("#upload-btn").one().await?;
button.click().await?; // opens the picker via a hidden input

let matched = fc.await?;
println!("chooser mode: {:?}", matched.mode); // SelectSingle / SelectMultiple

Unlike the other four, expect_file_chooser is async: it must await Page.setInterceptFileChooserDialog { enabled: true } reaching Chrome before returning, or the trigger click could race Chrome into showing the real OS dialog instead of firing the intercept event — the same reason expect_download is async for its own Browser.setDownloadBehavior setup call. It's also the only active expectation: matching doesn't just hand back data, it dispatches DOM.setFileInputFiles with the paths you passed to expect_file_chooser(...) and disables the intercept before resolving — there's nothing left to drive on the returned MatchedFileChooser.

If the expectation is dropped before a chooser opens (timeout, early return, panic unwind), the intercept is disabled best-effort so a later real file dialog isn't silently swallowed.

Timeout semantics

All five expectations default to 30 s. Override per-call:

use std::time::Duration;

let exp = tab.expect_request("/api/")
    .timeout(Duration::from_secs(5));

On timeout the future resolves to ZendriverError::Timeout. The subscriber task is canceled before the error returns, so there's no leaked listener.

If the connection to Chrome drops, reconnects, or a broadcast subscriber falls behind while an expectation is waiting, the future instead resolves to ZendriverError::EventStreamIncomplete — distinct from Timeout because the awaited event may in fact have fired, but the delivery gap means it can no longer be confirmed either way. Treat it as "re-establish and re-wait", not as "it didn't happen".

Note: the delivery lag that triggers this is connection-wide, not scoped to the awaited event — a Lagged boundary on the shared CDP broadcast can abort an expect_* wait even when the lag was on unrelated traffic, so callers on event-heavy pages may see EventStreamIncomplete more often.

When to use which

  • expect_response — confirm an API call returned (covers status + body). The most common one in tests.
  • expect_request — assert what a page sent (headers, body, method). Useful for verifying CSRF tokens, auth bearer formats.
  • expect_dialog — automate alert / confirm flows; required whenever the page may pop a dialog or your script will hang.
  • expect_download — capture file downloads end-to-end; replaces the headless-Chrome "downloads vanish silently" footgun.
  • expect_file_chooser — feed files into a button/label-triggered picker; reach for Element::upload_files instead when the target is a direct <input type="file"> (no CDP intercept round-trip needed).

For continuous capture (every request matching a pattern, not just the first), drop into Interception's subscribe() path instead.