Gunnar

nordisk/gunnar-ui

uitest/src/cdp.rs

//! 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", &params)?;
        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());
    }
}