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"] }
Four entry points on Tab:
| Method | CDP event | Returns |
|---|---|---|
expect_request | Network.requestWillBeSent | RequestExpectation → MatchedRequest |
expect_response | Network.responseReceived | ResponseExpectation → MatchedResponse |
expect_dialog | Page.javascriptDialogOpened | DialogExpectation → MatchedDialog |
expect_download | Page.downloadWillBegin + progress | DownloadExpectation → MatchedDownload |
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 viais_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 and expect_download 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:
- The expectation is constructed before the click. Reversing those two lines reintroduces the race.
- 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. expectation.await?resolves on the first match. If you need to collect every matching request over a window, use aStream(build one via repeatedexpect_requestcalls or fall back to the interception API'ssubscribe).
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.
Timeout semantics
All four 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.
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— automatealert/confirmflows; 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.
For continuous capture (every request matching a pattern, not just the
first), drop into Interception's subscribe()
path instead.