//! The DevTools Protocol, and the virtual authenticator that makes a passkey
//! testable without a person.
//!
//! ## The whole trick, in four calls
//!
//! ```text
//! WebAuthn.enable { enableUI: false }
//! WebAuthn.addVirtualAuthenticator{ options: { protocol: "ctap2",
//! transport: "internal",
//! hasResidentKey: true,
//! hasUserVerification: true,
//! isUserVerified: true,
//! hasPrf: true,
//! automaticPresenceSimulation: true } }
//! ```
//!
//! After that `navigator.credentials.create()` and `.get()` on the page succeed
//! against a software authenticator: the console's own JavaScript runs
//! unmodified, the ceremony is real (a real challenge, a real signature the
//! server verifies), and nobody has to touch a key. `isUserVerified` is what
//! stands in for the fingerprint or the PIN; `automaticPresenceSimulation` is
//! what stands in for the tap.
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use crate::http;
use crate::json::{esc, parse, Json};
use crate::ws::Ws;
pub struct Chrome {
child: Child,
pub port: u16,
}
/// **Extra switches every launch takes** — set once by `main` for a console
/// that is NOT on loopback (the monetize-admin box): its name mapped to the
/// address it is reached at (`--host-resolver-rules`) and ONLY its own
/// certificate trusted (`--ignore-certificate-errors-spki-list`, the SPKI pin),
/// so the page is a secure context and WebAuthn runs, and nothing else is
/// trusted beyond the system's roots.
pub static EXTRA_ARGS: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
impl Chrome {
pub fn launch(binary: &str, profile: &Path, port: u16) -> Result<Chrome, String> {
std::fs::create_dir_all(profile).map_err(|e| format!("profile dir: {e}"))?;
let child = Command::new(binary)
.args([
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
"--disable-extensions",
// The suite asserts on the page's own behaviour; a translate or
// update fetch is only noise on a box that may be offline.
"--disable-component-update",
"--disable-sync",
// **The browser must not ask the desktop for a keyring.**
//
// Chrome encrypts its cookie store with a key it fetches from
// the Secret Service over D-Bus, and it does that lazily, on
// the FIRST http:// navigation — not at start, and never for a
// `data:` page. On a box with a session bus and no unlocked
// keyring (every build host, and oden) that call gets no
// answer and blocks for D-Bus's default reply timeout, which
// is 25 SECONDS. Measured here 2026-09-12: the same
// `--dump-dom http://127.0.0.1:…/health` took 25.22 s without
// this flag and 0.35 s with it, against a console that served
// the request in 2 ms.
//
// What that did to this suite: every browser row's first
// navigation spent 25 s of its 30 s budget before the page
// began, so the passkey ceremony — the row everything after it
// depends on — timed out, and nine rows behind it failed with
// it. A cookie store this rig throws away at the end has
// nothing to protect, so `basic` (an obfuscated file) is both
// the honest setting and the fast one.
"--password-store=basic",
&format!("--remote-debugging-port={port}"),
&format!("--user-data-dir={}", profile.display()),
"about:blank",
])
.args(EXTRA_ARGS.get().map(Vec::as_slice).unwrap_or(&[]))
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("spawn {binary}: {e}"))?;
let hostport = format!("127.0.0.1:{port}");
let deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < deadline {
if let Ok(r) = http::req(&hostport, "GET", "/json/version", &[], None) {
if r.status == 200 {
return Ok(Chrome { child, port });
}
}
std::thread::sleep(Duration::from_millis(150));
}
let mut c = Chrome { child, port };
c.kill();
Err(format!("chrome did not open a debugging port on {hostport} within 20 s"))
}
pub fn kill(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Drop for Chrome {
fn drop(&mut self) {
self.kill();
}
}
pub struct Cdp {
ws: Ws,
id: u64,
/// Everything that arrived while waiting for a reply. Kept, not dropped:
/// `Runtime.consoleAPICalled` and `Log.entryAdded` are how a situation can
/// say WHY the page failed rather than only that it did.
pub events: Vec<Json>,
}
impl Cdp {
pub fn attach(port: u16) -> Result<Cdp, String> {
let r = http::req(&format!("127.0.0.1:{port}"), "GET", "/json/version", &[], None)?;
let url = r.json()?.text("webSocketDebuggerUrl");
if url.is_empty() {
return Err(format!("no webSocketDebuggerUrl in {}", http::cut(&r.body, 200)));
}
Ok(Cdp { ws: Ws::connect(&url)?, id: 0, events: Vec::new() })
}
/// One command. `session` is `None` for the browser target and `Some(id)`
/// for a page.
pub fn call(&mut self, session: Option<&str>, method: &str, params: &str) -> Result<Json, String> {
self.id += 1;
let id = self.id;
let sess = match session {
Some(s) => format!(r#","sessionId":{}"#, esc(s)),
None => String::new(),
};
let msg = format!(r#"{{"id":{id},"method":{},"params":{params}{sess}}}"#, esc(method));
self.ws.send_text(&msg)?;
loop {
let raw = self.ws.recv_text()?;
let v = parse(&raw)?;
match v.path("id").and_then(|i| i.as_f64()) {
Some(got) if got as u64 == id => {
if let Some(e) = v.path("error") {
return Err(format!("{method}: {}", e.dump()));
}
return Ok(v.path("result").cloned().unwrap_or(Json::Null));
}
// A reply to a command we are no longer waiting for. It cannot
// happen with one in-flight call at a time, and dropping it
// silently would be how a desynchronised stream stayed
// invisible, so it is kept where a failing row can print it.
Some(_) => self.events.push(v),
None => {
if self.events.len() < 500 {
self.events.push(v);
}
}
}
}
}
/// A fresh page, attached flat, with the domains the matrix reads.
pub fn page(&mut self) -> Result<String, String> {
let t = self.call(None, "Target.createTarget", r#"{"url":"about:blank"}"#)?;
let target = t.text("targetId");
let a = self.call(
None,
"Target.attachToTarget",
&format!(r#"{{"targetId":{},"flatten":true}}"#, esc(&target)),
)?;
let sess = a.text("sessionId");
if sess.is_empty() {
return Err("attachToTarget returned no sessionId".into());
}
self.call(Some(&sess), "Page.enable", "{}")?;
self.call(Some(&sess), "Runtime.enable", "{}")?;
self.call(Some(&sess), "Log.enable", "{}")?;
Ok(sess)
}
/// A page in its OWN browser context — a separate cookie jar and a
/// separate authenticator, which is what makes a SECOND user testable in
/// the same browser. Without it the invited user would inherit the admin's
/// session and its passkey, and every multi-user situation would be
/// measuring one user twice.
pub fn isolated_page(&mut self) -> Result<String, String> {
let ctx = self.call(None, "Target.createBrowserContext", "{}")?;
let ctx_id = ctx.text("browserContextId");
if ctx_id.is_empty() {
return Err("createBrowserContext returned no id".into());
}
let t = self.call(
None,
"Target.createTarget",
&format!(r#"{{"url":"about:blank","browserContextId":{}}}"#, esc(&ctx_id)),
)?;
let a = self.call(
None,
"Target.attachToTarget",
&format!(r#"{{"targetId":{},"flatten":true}}"#, esc(&t.text("targetId"))),
)?;
let sess = a.text("sessionId");
if sess.is_empty() {
return Err("attachToTarget returned no sessionId".into());
}
self.call(Some(&sess), "Page.enable", "{}")?;
self.call(Some(&sess), "Runtime.enable", "{}")?;
Ok(sess)
}
/// Turn on the virtual authenticator. Returns its id, which
/// `WebAuthn.removeVirtualAuthenticator` and `getCredentials` take.
pub fn add_authenticator(&mut self, sess: &str) -> Result<String, String> {
self.call(Some(sess), "WebAuthn.enable", r#"{"enableUI":false}"#)?;
let r = self.call(
Some(sess),
"WebAuthn.addVirtualAuthenticator",
r#"{"options":{"protocol":"ctap2","ctap2Version":"ctap2_1","transport":"internal","hasResidentKey":true,
"hasUserVerification":true,"isUserVerified":true,"hasPrf":true,
"automaticPresenceSimulation":true}}"#,
)?;
Ok(r.text("authenticatorId"))
}
/// A second virtual authenticator with these `options` (a JSON object):
/// a security key WITHOUT hmac-secret, for the refusal that names it.
pub fn add_authenticator_with(&mut self, sess: &str, options: &str) -> Result<String, String> {
let r = self.call(Some(sess), "WebAuthn.addVirtualAuthenticator", &format!(r#"{{"options":{options}}}"#))?;
Ok(r.text("authenticatorId"))
}
/// Remove a virtual authenticator.
pub fn remove_authenticator(&mut self, sess: &str, auth: &str) -> Result<(), String> {
self.call(Some(sess), "WebAuthn.removeVirtualAuthenticator", &format!(r#"{{"authenticatorId":{}}}"#, esc(auth))).map(|_| ())
}
/// Whether an authenticator answers a ceremony by itself (`false`: it waits
/// for a touch that never comes, so another authenticator answers).
pub fn presence(&mut self, sess: &str, auth: &str, on: bool) -> Result<(), String> {
self.call(
Some(sess),
"WebAuthn.setAutomaticPresenceSimulation",
&format!(r#"{{"authenticatorId":{},"enabled":{on}}}"#, esc(auth)),
)
.map(|_| ())
}
/// What the authenticator is actually holding — the proof that a
/// registration created a credential rather than that a page said it did.
pub fn credentials(&mut self, sess: &str, auth: &str) -> Result<Vec<Json>, String> {
let r = self.call(
Some(sess),
"WebAuthn.getCredentials",
&format!(r#"{{"authenticatorId":{}}}"#, esc(auth)),
)?;
Ok(r.path("credentials").map(|c| c.arr().to_vec()).unwrap_or_default())
}
/// Everything the page itself complained about since the last drain: an
/// uncaught exception, or a console/browser log entry at error level.
///
/// A WASM console that panics renders an empty div and answers every
/// question about its own state cheerfully, so "the heading appeared" is
/// not enough on its own — this is the other half of that assertion.
pub fn drain_page_errors(&mut self) -> Vec<String> {
let mut out = Vec::new();
for e in std::mem::take(&mut self.events) {
match e.text("method").as_str() {
"Runtime.exceptionThrown" => {
let d = e.text("params.exceptionDetails.exception.description");
let d = if d.is_empty() { e.text("params.exceptionDetails.text") } else { d };
out.push(format!("uncaught: {d}"));
}
"Log.entryAdded" if e.text("params.entry.level") == "error" => {
out.push(format!("log: {}", e.text("params.entry.text")));
}
"Runtime.consoleAPICalled" if e.text("params.type") == "error" => {
let args = e
.path("params.args")
.map(|a| {
a.arr()
.iter()
.map(|x| x.text("value"))
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
out.push(format!("console.error: {args}"));
}
_ => {}
}
}
out
}
/// Fix the viewport before anything is drawn. A screenshot of a window
/// whose size nobody chose is a different picture on every machine that
/// takes one, and a design change that is checked by looking has to be
/// looked at through the same window twice.
pub fn viewport(&mut self, sess: &str, w: u32, h: u32) -> Result<(), String> {
self.call(
Some(sess),
"Emulation.setDeviceMetricsOverride",
&format!(r#"{{"width":{w},"height":{h},"deviceScaleFactor":1,"mobile":false}}"#),
)?;
Ok(())
}
/// A PNG of the page as it is actually drawn, written where a person can
/// open it. Computed styles say what the rules resolved to; this is the
/// only artefact that says what the rules LOOK like, and a colour chosen so
/// a seam disappears is a claim about looking.
pub fn screenshot(&mut self, sess: &str, to: &Path) -> Result<usize, String> {
let b64 = self.capture(sess, None)?;
let bytes = b64_decode(&b64)?;
if let Some(dir) = to.parent() {
let _ = std::fs::create_dir_all(dir);
}
std::fs::write(to, &bytes).map_err(|e| format!("write {}: {e}", to.display()))?;
Ok(bytes.len())
}
/// The page as PNG bytes, still base64, optionally only a rectangle of it.
///
/// The clip is what makes a pixel measurement affordable: a strip thirteen
/// pixels wide down the whole height is a few kB of nearly flat PNG, where
/// the whole page is close to a megabyte, and both have to travel back
/// through one websocket frame and then out again into a page that can
/// decode them.
pub fn capture(&mut self, sess: &str, clip: Option<(i64, i64, i64, i64)>) -> Result<String, String> {
let params = match clip {
None => r#"{"format":"png"}"#.to_owned(),
Some((x, y, w, h)) => format!(
r#"{{"format":"png","clip":{{"x":{x},"y":{y},"width":{w},"height":{h},"scale":1}}}}"#
),
};
let r = self.call(Some(sess), "Page.captureScreenshot", ¶ms)?;
let b64 = r.text("data");
if b64.is_empty() {
return Err("Page.captureScreenshot returned no data".into());
}
Ok(b64)
}
/// One key, dispatched the way a person presses it rather than the way a
/// script focuses something. The whole reason this exists is
/// `:focus-visible`: an element focused by `.focus()` does not match it in
/// any engine, so a focus ring is exactly the rule that a programmatic test
/// cannot see. Borderless controls make that ring the only thing standing
/// between a keyboard user and a field they cannot find, so it is measured.
pub fn key(&mut self, sess: &str, key: &str, code: &str, vk: u32) -> Result<(), String> {
for kind in ["rawKeyDown", "keyUp"] {
self.call(
Some(sess),
"Input.dispatchKeyEvent",
&format!(
r#"{{"type":"{kind}","key":{},"code":{},"windowsVirtualKeyCode":{vk},"nativeVirtualKeyCode":{vk}}}"#,
esc(key),
esc(code)
),
)?;
}
Ok(())
}
pub fn navigate(&mut self, sess: &str, url: &str) -> Result<(), String> {
let r = self.call(Some(sess), "Page.navigate", &format!(r#"{{"url":{}}}"#, esc(url)))?;
let err = r.text("errorText");
if !err.is_empty() {
return Err(format!("navigate {url}: {err}"));
}
self.wait_for(sess, "document.readyState === 'complete'", 20_000)
}
/// [`Cdp::navigate`], certain to be answered by the NEW document.
///
/// `navigate` waits for `readyState === 'complete'`, which the page being
/// LEFT also answers until the new one commits; a row that then waits for
/// an element both pages have (the header, the switch, the estate board)
/// can read the old page. The theme rows read what a RELOAD answers (the
/// switch's label after a reload in surprise), which on the page being
/// left is already true, so a read of the old page would pass for the
/// wrong reason. So the old document is marked first, and the wait is for
/// a document without the mark. Not for a fragment-only URL: that keeps
/// the document, and the wait would never end.
pub fn navigate_fresh(&mut self, sess: &str, url: &str) -> Result<(), String> {
let _ = self.eval(sess, "(() => { window.__uitest_left = true; return true; })()");
self.navigate(sess, url)?;
self.wait_for(sess, "!window.__uitest_left && document.readyState === 'complete'", 20_000)
.map_err(|e| format!("navigate {url}: the new document never answered: {e}"))
}
/// Evaluate an expression, awaiting it if it is a promise, and hand back
/// the value. An exception is an `Err` carrying the page's own message —
/// which is the whole reason the console's `show(e.message)` path is worth
/// driving rather than reimplementing.
pub fn eval(&mut self, sess: &str, expr: &str) -> Result<Json, String> {
let p = format!(
r#"{{"expression":{},"awaitPromise":true,"returnByValue":true,"userGesture":true}}"#,
esc(expr)
);
let r = self.call(Some(sess), "Runtime.evaluate", &p)?;
if let Some(ex) = r.path("exceptionDetails") {
let msg = ex.text("exception.description");
let msg = if msg.is_empty() { ex.text("text") } else { msg };
return Err(format!("page threw: {}", http::cut(&msg, 300)));
}
Ok(r.path("result.value").cloned().unwrap_or(Json::Null))
}
pub fn eval_str(&mut self, sess: &str, expr: &str) -> Result<String, String> {
Ok(self.eval(sess, expr)?.as_str().unwrap_or("").to_owned())
}
/// Poll a boolean expression until it holds. Polling rather than waiting on
/// an event keeps every situation independent of which domain happened to
/// be enabled, and a timeout here always names the expression that never
/// became true.
pub fn wait_for(&mut self, sess: &str, expr: &str, ms: u64) -> Result<(), String> {
let deadline = Instant::now() + Duration::from_millis(ms);
let mut last = String::new();
while Instant::now() < deadline {
// `Promise.resolve(...).then(v => !!v)` and never a bare `!!(expr)`:
// `!!promise` is `true` for EVERY promise, so a `!!`-wrapped async
// predicate reports success the instant it is asked and the wait
// does not wait at all. Costly to find, trivial to prevent.
match self.eval(sess, &format!("Promise.resolve({expr}).then(v => !!v)")) {
Ok(Json::Bool(true)) => return Ok(()),
Ok(_) => {}
Err(e) => last = e,
}
std::thread::sleep(Duration::from_millis(100));
}
Err(format!("timed out after {ms} ms waiting for `{expr}`{}", if last.is_empty() { String::new() } else { format!(" ({last})") }))
}
/// **The first `method` event from page `sess` within `ms`, waited for
/// WITHOUT asking the page anything** — the socket is only read. A page
/// whose navigation is held (a `Fetch` pattern paused it) answers no
/// `Runtime` command until the request is failed, continued or
/// fulfilled, so a wait that pumped the socket with `eval(sess, "1")` sat
/// out [`crate::ws`]'s 60 s read timeout before it looked at the event it
/// already held (rung 1, 2026-10-05: every money row 60–75 s). What
/// arrives meanwhile is kept in `events`, as [`Cdp::call`] keeps it.
pub fn wait_event(&mut self, sess: &str, method: &str, ms: u64) -> Option<Json> {
let deadline = Instant::now() + Duration::from_millis(ms);
let ours = |e: &Json| e.text("method") == method && e.text("sessionId") == sess;
loop {
if let Some(e) = self.events.iter().find(|e| ours(e)) {
return Some(e.clone());
}
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
return None;
}
let v = match self.ws.recv_text_within(left) {
Ok(Some(raw)) => match parse(&raw) {
Ok(v) => v,
Err(_) => continue,
},
Ok(None) | Err(_) => return None,
};
// The awaited event is kept whatever the cap; the rest as `call` keeps them.
if ours(&v) || v.path("id").is_some() || self.events.len() < 500 {
self.events.push(v);
}
}
}
/// The request a `Fetch.enable` pattern paused on page `sess` — its
/// `(requestId, url)` — within `ms`, or `None`. See [`Cdp::wait_event`].
pub fn paused_request(&mut self, sess: &str, ms: u64) -> Option<(String, String)> {
self.wait_event(sess, "Fetch.requestPaused", ms).map(|e| (e.text("params.requestId"), e.text("params.request.url")))
}
}
/// Base64, decoded in about twenty lines, for the one place this crate needs it:
/// `Page.captureScreenshot` hands back a PNG as text. The alternative is a
/// dependency, and this crate's whole argument is that it has none — see
/// `Cargo.toml`. Whitespace is skipped, padding is ignored, and anything else
/// is an error rather than a silently shorter picture.
fn b64_decode(s: &str) -> Result<Vec<u8>, String> {
let mut out = Vec::with_capacity(s.len() / 4 * 3);
let mut acc: u32 = 0;
let mut bits: u32 = 0;
for (i, ch) in s.bytes().enumerate() {
let v = match ch {
b'A'..=b'Z' => ch - b'A',
b'a'..=b'z' => ch - b'a' + 26,
b'0'..=b'9' => ch - b'0' + 52,
b'+' => 62,
b'/' => 63,
b'=' => break,
b'\n' | b'\r' | b' ' | b'\t' => continue,
_ => return Err(format!("byte {i} of the screenshot is not base64: {ch:?}")),
};
acc = (acc << 6) | v as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read as _, Write as _};
use std::net::{TcpListener, TcpStream};
/// One frame from the client (masked), unmasked: `None` when it hung up.
fn read_frame(s: &mut TcpStream) -> Option<String> {
let mut h = [0u8; 2];
s.read_exact(&mut h).ok()?;
let len = match h[1] & 0x7f {
126 => {
let mut b = [0u8; 2];
s.read_exact(&mut b).ok()?;
u16::from_be_bytes(b) as usize
}
127 => {
let mut b = [0u8; 8];
s.read_exact(&mut b).ok()?;
u64::from_be_bytes(b) as usize
}
n => n as usize,
};
let mut mask = [0u8; 4];
s.read_exact(&mut mask).ok()?;
let mut body = vec![0u8; len];
s.read_exact(&mut body).ok()?;
body.iter_mut().enumerate().for_each(|(i, b)| *b ^= mask[i % 4]);
String::from_utf8(body).ok()
}
/// One frame to the client (a server does not mask).
fn write_frame(s: &mut TcpStream, text: &str) {
let mut f = vec![0x81u8];
match text.len() {
n if n < 126 => f.push(n as u8),
n => {
f.push(126);
f.extend_from_slice(&(n as u16).to_be_bytes());
}
}
f.extend_from_slice(text.as_bytes());
s.write_all(&f).unwrap();
}
/// **A peer that answers as Chrome does while a navigation is paused at a
/// `Fetch` pattern.** The press navigates the page to the vendor; the
/// pattern pauses that request and says `Fetch.requestPaused`; and until
/// the request is failed, continued or fulfilled, the page answers NO
/// `Runtime` command — DevTools holds a page's commands while its
/// navigation is pending. The `Fetch` domain itself answers at once.
/// Measured on the rung 1 rig (oden, 2026-10-05): a `Runtime.evaluate`
/// sent there waited 61–63 s, i.e. until the client's 60 s read timeout.
fn paused_chrome() -> u16 {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
std::thread::spawn(move || {
let (mut s, _) = l.accept().unwrap();
let mut head = Vec::new();
let mut byte = [0u8; 1];
while !head.ends_with(b"\r\n\r\n") {
s.read_exact(&mut byte).unwrap();
head.push(byte[0]);
}
s.write_all(b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n").unwrap();
let mut paused = false;
let mut held: Vec<String> = Vec::new();
while let Some(text) = read_frame(&mut s) {
let v = parse(&text).unwrap();
let id = v.path("id").and_then(|i| i.as_f64()).unwrap() as u64;
let sess = v.text("sessionId");
let answer = format!(r#"{{"id":{id},"result":{{"result":{{"type":"number","value":1}}}},"sessionId":{}}}"#, esc(&sess));
match v.text("method").as_str() {
"Fetch.enable" => {
write_frame(&mut s, &answer);
// The press: the page leaves for the vendor, and the
// pattern holds the request.
paused = true;
let ev = format!(
r#"{{"method":"Fetch.requestPaused","params":{{"requestId":"interception-job-1.0","request":{{"url":"https://checkout.fake-stripe.test/c/pay/cs_test_1"}}}},"sessionId":{}}}"#,
esc(&sess)
);
write_frame(&mut s, &ev);
}
"Fetch.failRequest" | "Fetch.continueRequest" | "Fetch.fulfillRequest" => {
paused = false;
write_frame(&mut s, &answer);
for a in held.drain(..) {
write_frame(&mut s, &a);
}
}
_ if paused => held.push(answer),
_ => write_frame(&mut s, &answer),
}
}
});
port
}
/// ★ **The paused checkout is seen at once, not after a minute** (rung 1,
/// 2026-10-05: every money row 60–75 s). The wait for the paused request
/// asked the page `1` to pump the socket; the page could not answer while
/// its navigation was the paused request, so the wait sat out the read
/// timeout — 60 s, swallowed — before it looked at the event it already
/// held. Read as "the grant is late"; the bench's `/pay` answered PAID in
/// ~0.1 s every time.
#[test]
fn a_paused_checkout_is_seen_without_asking_the_page() {
let port = paused_chrome();
let mut c = Cdp { ws: Ws::connect(&format!("ws://127.0.0.1:{port}/devtools/browser/fake")).unwrap(), id: 0, events: Vec::new() };
c.call(Some("PAGE"), "Fetch.enable", "{}").unwrap();
let began = Instant::now();
let got = c.paused_request("PAGE", 30_000);
let waited = began.elapsed();
assert_eq!(
got,
Some(("interception-job-1.0".to_owned(), "https://checkout.fake-stripe.test/c/pay/cs_test_1".to_owned())),
"the paused request"
);
assert!(waited < Duration::from_secs(5), "the paused request was seen after {} ms", waited.as_millis());
// Once the request is failed, the page answers again.
c.call(Some("PAGE"), "Fetch.failRequest", r#"{"requestId":"interception-job-1.0","errorReason":"Aborted"}"#).unwrap();
let began = Instant::now();
assert!(c.eval("PAGE", "1").is_ok());
assert!(began.elapsed() < Duration::from_secs(5));
// An event that never comes is `None` at the caller's deadline, not the socket's.
let began = Instant::now();
assert_eq!(c.wait_event("PAGE", "Page.loadEventFired", 300), None);
assert!(began.elapsed() < Duration::from_secs(5), "waited {} ms for 300", began.elapsed().as_millis());
}
}