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:
| 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 |
expect_file_chooser | Page.fileChooserOpened | FileChooserExpectation → MatchedFileChooser |
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, 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:
- 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.
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
Laggedboundary on the shared CDP broadcast can abort anexpect_*wait even when the lag was on unrelated traffic, so callers on event-heavy pages may seeEventStreamIncompletemore 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— 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.expect_file_chooser— feed files into a button/label-triggered picker; reach forElement::upload_filesinstead 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.