chrome-agent 0.14.0

Browser automation for AI agents. Single binary, zero deps, CDP direct to Chrome.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;

use serde::de::DeserializeOwned;
use serde::Serialize;
use serde_json::Value;
use tokio::sync::{broadcast, oneshot, Mutex};

use super::transport::{self, CdpSender, CdpTransportError};
use super::types::{CdpEvent, CdpMessage, CdpRequest, CdpResponse};

type PendingMap = Arc<Mutex<HashMap<u64, oneshot::Sender<CdpResponse>>>>;

/// Deadline applied to a CDP response when the caller sets none. Matches the CLI's
/// `--timeout` default, which is the number a caller reaches for when asked how long they
/// are willing to wait.
const DEFAULT_CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);

/// Deadline for an input event's acknowledgement, whatever `--timeout` says.
///
/// An input event is not a computation the page might legitimately take half a minute over:
/// Chrome acknowledges one in single-digit milliseconds when the pipeline is healthy. The one
/// measured exception is a page that is not the active tab, where `Input.dispatchMouseEvent`
/// answers after a fixed 5.00 s — 5007, 5004, 5023 ms across runs — so a deadline at or below
/// five seconds would turn a slow-but-delivered click into an error. Eight seconds sits above
/// that and far below the 30 s default, which is the difference between an agent that learns
/// something is wrong and one that stares at a silent terminal for half a minute.
///
/// This is a deadline on the ANSWER, never on the event: see `element::input_timeout`, which
/// is why the failure it produces forbids the retry instead of inviting it.
const INPUT_ACK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(8);
const DIALOG_REQUEST_ID_START: u64 = 1_000_000_000;
const DIALOG_REQUEST_ID_MAX: u64 = i32::MAX as u64;

/// What a call that ran out of time says about itself.
///
/// Two failures, two sentences, and the difference is not cosmetic: only one of them may be
/// repeated safely. A call that computes something may legitimately outlast the caller's
/// patience, and raising `--timeout` is the answer. An input event is not a computation — what
/// expired is the ACKNOWLEDGEMENT, and the event itself may already be in the page, so the
/// sentence says "dispatched" first and never invites a second attempt. `hints::error_hint`
/// keys the recovery off this wording.
fn timeout_message(method: &str, deadline: std::time::Duration) -> String {
    if method.starts_with("Input.") {
        format!(
            "{method} was dispatched and Chrome did not acknowledge it within {}s, so what the \
             page did with it is unknown. The event may already have reached the page.",
            deadline.as_secs()
        )
    } else {
        format!(
            "{method} did not answer within {}s. An in-page promise that never settles \
             (awaitPromise) is the usual cause; raise --timeout if the page is merely slow.",
            deadline.as_secs()
        )
    }
}

/// Execution context bound to a specific frame by the `frame` command.
///
/// Once set, subsequent `eval` calls run in `context_id` (the frame's
/// isolated world) and `inspect`/snapshot scope to `frame_id`. Navigating
/// the top document invalidates the isolated world, so callers clear this
/// on navigation to avoid sending a dead `contextId`.
#[derive(Clone, Debug)]
pub struct FrameContext {
    /// `Page.FrameId` of the target frame.
    pub frame_id: String,
    /// `Runtime.ExecutionContextId` of the frame's isolated world.
    pub context_id: i64,
}

/// High-level CDP client. Handles request/response correlation and event dispatch.
///
/// Built on top of the split transport (`CdpSender` + `CdpReceiver`).
/// Spawns a dispatcher task that routes incoming messages to either
/// pending request futures or broadcast event subscribers.
pub struct CdpClient {
    sender: CdpSender,
    next_id: AtomicU64,
    pending: PendingMap,
    events_tx: broadcast::Sender<CdpEvent>,
    _dispatcher: tokio::task::JoinHandle<()>,
    /// Frame the `frame` command switched into, if any. Interior-mutable so
    /// `eval`/`inspect` (which take `&self`) can read it without threading
    /// state through every call site.
    frame_ctx: std::sync::Mutex<Option<FrameContext>>,
    /// How long to wait for a response before giving up on it.
    ///
    /// Every `call` used to await its response channel with no deadline. Chrome answers
    /// promptly, but an evaluation sent with `awaitPromise` only answers when the page's
    /// promise settles — and a promise that never settles left the command hanging with no
    /// error, no output and no recovery, in pipe mode for the rest of the session. Nothing
    /// was broken enough to notice: the socket stayed open and the dispatcher kept running.
    call_timeout: std::sync::Mutex<std::time::Duration>,
    /// When the last input event went out on this connection.
    ///
    /// `no_effect` is only ever a claim about a window — "the page did not move for N ms
    /// after the event" — and the two ends of that window live in different modules: the
    /// dispatch is in `element`, the observation in the verdict wiring. Recording it here
    /// keeps the number measured rather than assumed, without threading an `Instant` through
    /// every dispatcher signature in all three modes.
    last_dispatch: std::sync::Mutex<Option<std::time::Instant>>,
    /// Whether this connection has already asked its page to come to the foreground.
    ///
    /// `Page.bringToFront` costs 3 ms and is idempotent, but it is a state change on the
    /// browser, so it is made once per connection and only by a path that dispatches pointer
    /// input — see `ensure_foreground`.
    foregrounded: AtomicBool,
    /// How long this action spent waiting for a page load it had reason to expect.
    ///
    /// Recorded here for the same reason `last_dispatch` is: the wait happens in `element`,
    /// the response is assembled in the verdict wiring, and threading a `Duration` through
    /// every dispatcher signature in all three modes to carry one number is worse than one
    /// interior-mutable slot on the connection they all share.
    settle_wait: std::sync::Mutex<Option<std::time::Duration>>,
    /// Whether chrome-agent should synthesize taps instead of mouse clicks for this target.
    ///
    /// Device emulation is reapplied when each connection opens, so this connection-local flag
    /// follows the target's persisted `--touch` setting without leaking it into sibling pages.
    touch_emulation: AtomicBool,
}

#[derive(Debug, thiserror::Error)]
pub enum CdpClientError {
    #[error("transport: {0}")]
    Transport(#[from] CdpTransportError),
    #[error("serialization: {0}")]
    Serialization(serde_json::Error),
    #[error("CDP error {code}: {message}")]
    Protocol { code: i64, message: String },
    #[error("response parse: {0}")]
    ResponseParse(serde_json::Error),
    #[error("timeout: {0}")]
    Timeout(String),
    #[error("dispatcher task exited")]
    DispatcherGone,
}

impl CdpClient {
    /// Connect to a Chrome `DevTools` Protocol endpoint.
    pub async fn connect(url: &str) -> Result<Self, CdpClientError> {
        let (sender, receiver) = transport::connect(url).await?;
        let pending: PendingMap = Arc::new(Mutex::new(HashMap::new()));
        let (events_tx, _) = broadcast::channel::<CdpEvent>(256);

        let dispatcher = tokio::spawn(dispatch_loop(
            receiver,
            Arc::clone(&pending),
            events_tx.clone(),
        ));

        Ok(Self {
            sender,
            next_id: AtomicU64::new(1),
            pending,
            events_tx,
            _dispatcher: dispatcher,
            frame_ctx: std::sync::Mutex::new(None),
            call_timeout: std::sync::Mutex::new(DEFAULT_CALL_TIMEOUT),
            last_dispatch: std::sync::Mutex::new(None),
            foregrounded: AtomicBool::new(false),
            settle_wait: std::sync::Mutex::new(None),
            touch_emulation: AtomicBool::new(false),
        })
    }

    /// Record that an input event has just gone out.
    pub fn mark_dispatch(&self) {
        if let Ok(mut slot) = self.last_dispatch.lock() {
            *slot = Some(std::time::Instant::now());
        }
        // A pipe session reuses one connection for every command, so a wait recorded by the
        // click that navigated would otherwise still be on the response of the next command,
        // which waited for nothing. Cleared where the action starts, taken where it is read.
        if let Ok(mut slot) = self.settle_wait.lock() {
            *slot = None;
        }
    }

    /// Bring this connection's page to the foreground, once.
    ///
    /// Measured, on a page that is not the active tab (`document.visibilityState === "hidden"`):
    /// `Input.dispatchMouseEvent` answers after 5007, 5004 and 5023 ms, while `Runtime.evaluate`
    /// on the same connection answers in 0–1 ms — so the renderer's main thread is not busy, the
    /// input pipeline is waiting for something a backgrounded page never produces, and Chrome
    /// gives up on a fixed five-second timer. `Page.bringToFront` costs 3 ms and takes the same
    /// events to 0–6 ms. A page becomes hidden without anyone asking: opening a second page
    /// backgrounds the first, and Chrome's own `chrome://settings/help` update check did it to a
    /// browser this tool had launched.
    ///
    /// Only the pointer paths call this, and the restraint is measured too: `Input.dispatchKeyEvent`
    /// answers in 1 ms on the same hidden page, so `press` and `type` have nothing to gain and
    /// would be paying a state change for it.
    ///
    /// Consequence, stated rather than hidden: with several pages open in one browser, clicking
    /// on one foregrounds it — which is what clicking means, and what `emulation` already does
    /// for the same class of reason. Best effort: a target that refuses to come forward is not a
    /// reason to refuse the click, it only costs the latency this exists to remove.
    pub async fn ensure_foreground(&self) {
        if self.foregrounded.swap(true, Ordering::Relaxed) {
            return;
        }
        // Boxed: this runs inside every pointer path, and those futures are held alive inside
        // `run::run`'s match arm. Inlining another `call` state machine four times over pushed
        // that frame past clippy's ceiling — a pin costs one allocation on a path that already
        // makes a round trip.
        let call = Box::pin(self.call::<_, Value>("Page.bringToFront", serde_json::json!({})));
        let _: Result<Value, _> = call.await;
    }

    /// Record how long an action waited for a page load after dispatching.
    pub fn note_settle_wait(&self, waited: std::time::Duration) {
        if let Ok(mut slot) = self.settle_wait.lock() {
            *slot = Some(waited);
        }
    }

    /// How long this action waited for a load, when it waited at all.
    ///
    /// Takes rather than reads: the number belongs to one action's response, and a connection
    /// that outlives the action (pipe, batch) must not hand it to the next one.
    #[must_use]
    pub fn take_settle_wait_ms(&self) -> Option<u64> {
        let waited = self.settle_wait.lock().ok()?.take()?;
        u64::try_from(waited.as_millis()).ok()
    }

    pub(crate) fn set_touch_emulation(&self, enabled: bool) {
        self.touch_emulation.store(enabled, Ordering::Relaxed);
    }

    pub(crate) fn touch_emulation_enabled(&self) -> bool {
        self.touch_emulation.load(Ordering::Relaxed)
    }

    /// How long ago the last input event went out, or `None` if none has.
    #[must_use]
    pub fn ms_since_dispatch(&self) -> Option<u64> {
        let at = (*self.last_dispatch.lock().ok()?)?;
        u64::try_from(at.elapsed().as_millis()).ok()
    }

    /// Return the frame context set by the `frame` command, if any.
    pub fn frame_context(&self) -> Option<FrameContext> {
        self.frame_ctx.lock().unwrap().clone()
    }

    /// Bind (`Some`) or clear (`None`) the current frame context. Setting it
    /// scopes subsequent `eval`/`inspect` to that frame; clearing restores the
    /// top document. Navigation clears it (the isolated world dies with it).
    pub fn set_frame_context(&self, ctx: Option<FrameContext>) {
        *self.frame_ctx.lock().unwrap() = ctx;
    }

    /// Send a CDP command and wait for the typed response.
    pub async fn call<P: Serialize, R: DeserializeOwned>(
        &self,
        method: &'static str,
        params: P,
    ) -> Result<R, CdpClientError> {
        self.call_with_session(method, params, None).await
    }

    /// Send a CDP command on a specific session.
    pub async fn call_with_session<P: Serialize, R: DeserializeOwned>(
        &self,
        method: &'static str,
        params: P,
        session_id: Option<String>,
    ) -> Result<R, CdpClientError> {
        self.call_within(method, params, session_id, self.call_timeout()).await
    }

    /// Dispatch an input event and wait for Chrome to acknowledge it, under
    /// [`INPUT_ACK_DEADLINE`] rather than `--timeout`.
    ///
    /// The distinction is not tidiness. `--timeout` is the caller's patience for the page's
    /// own work — a slow load, an evaluation that awaits a promise — and an input event is
    /// none of that: the acknowledgement comes from the browser's input pipeline, and when it
    /// does not come, waiting thirty seconds tells the caller nothing that eight does not.
    pub async fn send_input<P: Serialize>(
        &self,
        method: &'static str,
        params: P,
    ) -> Result<(), CdpClientError> {
        let _: Value = self.call_within(method, params, None, INPUT_ACK_DEADLINE).await?;
        Ok(())
    }

    async fn call_within<P: Serialize, R: DeserializeOwned>(
        &self,
        method: &'static str,
        params: P,
        session_id: Option<String>,
        deadline: std::time::Duration,
    ) -> Result<R, CdpClientError> {
        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
        let params_value =
            serde_json::to_value(params).map_err(CdpClientError::Serialization)?;

        let request = CdpRequest {
            id,
            method,
            params: params_value,
            session_id,
        };

        let (tx, rx) = oneshot::channel();
        self.pending.lock().await.insert(id, tx);

        let json = serde_json::to_string(&request).map_err(CdpClientError::Serialization)?;
        if let Err(e) = self.sender.send(json).await {
            self.pending.lock().await.remove(&id);
            return Err(e.into());
        }

        let response = match tokio::time::timeout(deadline, rx).await {
            Ok(received) => received.map_err(|_| CdpClientError::DispatcherGone)?,
            Err(_) => {
                // Drop the slot: leaving it behind leaks one entry per timed-out call, and
                // a late answer would then be delivered to a receiver nobody awaits.
                self.pending.lock().await.remove(&id);
                return Err(CdpClientError::Timeout(timeout_message(method, deadline)));
            }
        };

        if let Some(error) = response.error {
            return Err(CdpClientError::Protocol {
                code: error.code,
                message: error.message,
            });
        }

        let result_value = response.result.unwrap_or_default();
        serde_json::from_value(result_value).map_err(CdpClientError::ResponseParse)
    }

    /// Send a CDP command that returns no meaningful result (e.g. `Page.enable`).
    pub async fn send<P: Serialize>(
        &self,
        method: &'static str,
        params: P,
    ) -> Result<(), CdpClientError> {
        let _: Value = self.call(method, params).await?;
        Ok(())
    }

    /// How long a call waits for its response.
    #[must_use]
    pub fn call_timeout(&self) -> std::time::Duration {
        self.call_timeout.lock().map_or(DEFAULT_CALL_TIMEOUT, |d| *d)
    }

    /// Set the deadline for every subsequent call, from the caller's `--timeout`.
    pub fn set_call_timeout(&self, timeout: std::time::Duration) {
        if let Ok(mut slot) = self.call_timeout.lock() {
            *slot = timeout;
        }
    }

    /// Subscribe to CDP events. Returns a broadcast receiver.
    pub fn events(&self) -> broadcast::Receiver<CdpEvent> {
        self.events_tx.subscribe()
    }

    /// Install a background task that auto-answers JS dialogs
    /// (`alert`/`confirm`/`prompt`/`beforeunload`) per `policy`.
    ///
    /// A native dialog blocks the page with no DOM signal; without this the next
    /// command silently hangs. No-op for `DialogPolicy::Manual`. The Page domain
    /// must be enabled for `Page.javascriptDialogOpening` to fire. The task lives
    /// as long as the connection (it ends when the event channel closes).
    pub fn spawn_dialog_handler(
        &self,
        policy: crate::setup::DialogPolicy,
        prompt_text: Option<String>,
    ) {
        if !policy.auto_handles() {
            return;
        }
        let mut rx = self.events();
        let sender = self.sender.clone();
        tokio::spawn(async move {
            // Keep fire-and-forget ids inside Chromium's accepted signed
            // 32-bit range. Values such as 2^40 are silently ignored, leaving
            // the dialog open and the command that triggered it blocked.
            // This offset remains far above normal sequential request ids.
            let mut local_id = DIALOG_REQUEST_ID_START;
            loop {
                match rx.recv().await {
                    Ok(event) if event.method == "Page.javascriptDialogOpening" => {
                        let dtype = event
                            .params
                            .get("type")
                            .and_then(Value::as_str)
                            .unwrap_or("alert");
                        let message = event
                            .params
                            .get("message")
                            .and_then(Value::as_str)
                            .unwrap_or("");
                        let decision =
                            crate::setup::dialog_decision(policy, dtype, prompt_text.as_deref());
                        let mut params = serde_json::json!({ "accept": decision.accept });
                        if let Some(pt) = &decision.prompt_text {
                            params["promptText"] = Value::String(pt.clone());
                        }
                        let request = serde_json::json!({
                            "id": local_id,
                            "method": "Page.handleJavaScriptDialog",
                            "params": params,
                        });
                        local_id = if local_id >= DIALOG_REQUEST_ID_MAX {
                            DIALOG_REQUEST_ID_START
                        } else {
                            local_id + 1
                        };
                        let _ = sender.send(request.to_string()).await;
                        eprintln!(
                            "dialog auto-{}: {dtype} {message:?}",
                            if decision.accept { "accepted" } else { "dismissed" }
                        );
                    }
                    Ok(_) => {}
                    Err(broadcast::error::RecvError::Lagged(n)) => {
                        // A dropped Page.javascriptDialogOpening leaves the page
                        // blocked with no DOM signal. Surface it on stderr (never
                        // stdout, so --json stays clean) and keep handling.
                        eprintln!(
                            "dialog handler lagged: {n} event(s) dropped; a dialog may be unanswered"
                        );
                    }
                    Err(broadcast::error::RecvError::Closed) => break,
                }
            }
        });
    }

    /// Wait for a specific CDP event matching the given method name.
    pub async fn wait_for_event(
        &self,
        method: &str,
        timeout: std::time::Duration,
    ) -> Result<CdpEvent, CdpClientError> {
        let mut rx = self.events();
        Self::wait_for_event_on(&mut rx, method, timeout).await
    }

    /// Wait for a specific CDP event on an already-subscribed receiver.
    ///
    /// Subscribe with [`Self::events`] *before* issuing the command that
    /// triggers the event, then wait here — this avoids the race where a fast
    /// (e.g. cached) response fires the event before a late subscription exists,
    /// which would otherwise stall until the timeout.
    pub async fn wait_for_event_on(
        rx: &mut broadcast::Receiver<CdpEvent>,
        method: &str,
        timeout: std::time::Duration,
    ) -> Result<CdpEvent, CdpClientError> {
        let result = tokio::time::timeout(timeout, async {
            loop {
                match rx.recv().await {
                    Ok(event) if event.method == method => return Ok(event),
                    Ok(_)
                    | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
                    Err(tokio::sync::broadcast::error::RecvError::Closed) => {
                        return Err(CdpClientError::DispatcherGone)
                    }
                }
            }
        })
        .await;

        match result {
            Ok(inner) => inner,
            Err(_) => Err(CdpClientError::Timeout(format!(
                "Timeout waiting for event {method}"
            ))),
        }
    }

    /// Enable a CDP domain.
    pub async fn enable(&self, domain: &'static str) -> Result<(), CdpClientError> {
        let method = match domain {
            "Page" => "Page.enable",
            "Runtime" => "Runtime.enable",
            "DOM" => "DOM.enable",
            "Network" => "Network.enable",
            "Target" => "Target.setDiscoverTargets",
            _ => {
                return Err(CdpClientError::Protocol {
                    code: -1,
                    message: format!("Unknown domain: {domain}"),
                })
            }
        };

        if domain == "Target" {
            self.send(method, serde_json::json!({"discover": true}))
                .await
        } else {
            self.send(method, serde_json::json!({})).await
        }
    }
}

impl Drop for CdpClient {
    fn drop(&mut self) {
        self._dispatcher.abort();
    }
}

/// Dispatcher loop: reads from transport receiver, routes responses to pending
/// request futures, broadcasts events to subscribers.
async fn dispatch_loop(
    mut receiver: transport::CdpReceiver,
    pending: PendingMap,
    events_tx: broadcast::Sender<CdpEvent>,
) {
    loop {
        let Ok(Some(message)) = receiver.recv().await else {
            break;
        };

        let parsed: CdpMessage = match serde_json::from_str(&message) {
            Ok(m) => m,
            Err(_) => continue,
        };

        match parsed {
            CdpMessage::Response(response) => {
                if let Some(tx) = pending.lock().await.remove(&response.id) {
                    let _ = tx.send(response);
                }
            }
            CdpMessage::Event(event) => {
                let _ = events_tx.send(event);
            }
        }
    }

    // Transport closed — clear pending so callers get RecvError.
    pending.lock().await.clear();
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::Duration;

    /// The two timeouts do not share a sentence, because they do not share a recovery: one
    /// says raise the budget, the other says the action may already have happened.
    #[test]
    fn an_input_that_went_unacknowledged_never_reads_as_a_slow_page() {
        let input = timeout_message("Input.dispatchMouseEvent", INPUT_ACK_DEADLINE);
        assert!(input.starts_with("Input.dispatchMouseEvent was dispatched"), "{input}");
        assert!(input.contains("may already have reached the page"), "{input}");
        assert!(!input.contains("--timeout"), "raising the budget is not the recovery: {input}");
        assert!(input.contains("8s"), "the deadline it actually waited: {input}");

        let evaluate = timeout_message("Runtime.evaluate", DEFAULT_CALL_TIMEOUT);
        assert!(evaluate.contains("--timeout"), "{evaluate}");
        assert!(!evaluate.contains("dispatched"), "{evaluate}");
    }

    /// The deadline has to sit above the one stall Chrome is known to produce, or a click that
    /// works — slowly — becomes an error.
    #[test]
    fn the_input_deadline_clears_the_background_tab_stall_and_undercuts_the_default() {
        assert!(INPUT_ACK_DEADLINE > Duration::from_secs(5), "the measured stall is 5.00 s");
        assert!(INPUT_ACK_DEADLINE < DEFAULT_CALL_TIMEOUT);
    }

    fn event(method: &str) -> CdpEvent {
        CdpEvent {
            method: method.to_string(),
            params: Value::Null,
            session_id: None,
        }
    }

    // A10b: an event fired AFTER we subscribe but BEFORE we start waiting must
    // still be observed. This is the exact race goto.rs hits on cached loads —
    // subscribing before navigate keeps the (buffered) event, so the wait
    // returns immediately instead of stalling until timeout.
    #[tokio::test]
    async fn wait_for_event_on_sees_event_buffered_before_wait() {
        let (tx, _) = broadcast::channel::<CdpEvent>(16);
        let mut rx = tx.subscribe();
        // Event arrives before we begin waiting; the receiver buffers it.
        tx.send(event("Page.loadEventFired")).unwrap();

        let got = CdpClient::wait_for_event_on(
            &mut rx,
            "Page.loadEventFired",
            Duration::from_secs(5),
        )
        .await
        .expect("buffered event should be returned without timing out");
        assert_eq!(got.method, "Page.loadEventFired");
    }

    // Contrast: subscribing AFTER the event was sent (the pre-fix ordering)
    // misses it and hits the timeout — proving why the subscription must
    // happen before the triggering command.
    #[tokio::test]
    async fn wait_for_event_on_misses_event_sent_before_subscribe() {
        // Keep the initial receiver alive so `send` has a subscriber and succeeds;
        // our `rx` subscribes afterwards and therefore never sees this event.
        let (tx, _keep_alive) = broadcast::channel::<CdpEvent>(16);
        tx.send(event("Page.loadEventFired")).unwrap();
        let mut rx = tx.subscribe(); // too late — event is gone for this receiver

        let err = CdpClient::wait_for_event_on(
            &mut rx,
            "Page.loadEventFired",
            Duration::from_millis(50),
        )
        .await
        .expect_err("late subscriber must miss the event and time out");
        assert!(matches!(err, CdpClientError::Timeout(_)));
    }

    // Unrelated events are skipped; the target still resolves.
    #[tokio::test]
    async fn wait_for_event_on_skips_other_events() {
        let (tx, _) = broadcast::channel::<CdpEvent>(16);
        let mut rx = tx.subscribe();
        tx.send(event("Page.frameNavigated")).unwrap();
        tx.send(event("Page.loadEventFired")).unwrap();

        let got = CdpClient::wait_for_event_on(
            &mut rx,
            "Page.loadEventFired",
            Duration::from_secs(5),
        )
        .await
        .unwrap();
        assert_eq!(got.method, "Page.loadEventFired");
    }
}