chrome-agent 0.6.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
use std::collections::HashSet;
use std::time::{Duration, Instant};

use serde_json::json;

use crate::cdp::client::CdpClient;
use crate::cdp::types::EvaluateResult;

/// Whether an event flipped the page's idle state.
#[derive(Debug, PartialEq, Eq)]
pub enum Transition {
    /// Went from idle to at least one request in flight.
    BecameBusy,
    /// Went from busy back to zero requests in flight.
    BecameIdle,
    /// Idle state unchanged (e.g. an extra concurrent request, or an ignored event).
    NoChange,
}

/// Tracks in-flight network requests by requestId so we can tell when the page
/// has gone quiet. Keying on a set (not a bare counter) makes duplicate `start`
/// events and repeated `finish`/`fail` events harmless — removing an id that is
/// not present is a no-op.
#[derive(Default)]
pub struct InFlightTracker {
    in_flight: HashSet<String>,
}

impl InFlightTracker {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Update the set from a CDP event. Unrelated events are ignored.
    pub fn on_event(&mut self, method: &str, request_id: Option<&str>) {
        let Some(id) = request_id else { return };
        match method {
            "Network.requestWillBeSent" => {
                self.in_flight.insert(id.to_string());
            }
            "Network.loadingFinished" | "Network.loadingFailed" => {
                self.in_flight.remove(id);
            }
            _ => {}
        }
    }

    /// Apply an event and report whether it flipped the idle state. This is the
    /// exact decision the wait loop uses to start/stop its idle clock.
    pub fn observe(&mut self, method: &str, request_id: Option<&str>) -> Transition {
        let was_idle = self.is_idle();
        self.on_event(method, request_id);
        match (was_idle, self.is_idle()) {
            (true, false) => Transition::BecameBusy,
            (false, true) => Transition::BecameIdle,
            _ => Transition::NoChange,
        }
    }

    #[must_use]
    pub fn count(&self) -> usize {
        self.in_flight.len()
    }

    #[must_use]
    pub fn is_idle(&self) -> bool {
        self.in_flight.is_empty()
    }
}

/// Turn an in-page evaluation exception into a clear, actionable error.
///
/// `wait text` compiles the pattern with `new RegExp(pattern)`, so an invalid
/// pattern throws a `SyntaxError` in the page. Without this the probe would just
/// return no match and time out with a misleading "waiting for text" message.
#[must_use]
fn eval_exception_message(what: &str, pattern: &str, detail: &str) -> String {
    if what == "text" {
        format!(
            "Invalid regex for `wait text` pattern \"{pattern}\": {detail}. \
             The pattern is compiled as a JavaScript RegExp — escape metacharacters \
             (e.g. \\( \\[ \\\\ ) to match them literally."
        )
    } else {
        format!("Error evaluating wait condition ({what} \"{pattern}\"): {detail}")
    }
}

/// Poll the page until a condition is met, or timeout.
pub async fn run(
    client: &CdpClient,
    what: &str,
    pattern: &str,
    timeout_secs: u64,
    idle_ms: u64,
) -> Result<String, crate::BoxError> {
    if what == "network-idle" {
        return wait_network_idle(client, timeout_secs, idle_ms).await;
    }

    let deadline = Instant::now() + Duration::from_secs(timeout_secs);
    let poll_interval = Duration::from_millis(200);

    let expression = match what {
        // Regex semantics: the pattern is compiled in-page with `new RegExp(pattern)`
        // and tested against `document.body.innerText`. This supports both plain
        // substrings and full regex (e.g. "Foo|Bar", "^Loading") because every literal
        // string is a valid regex that matches itself. The flip side: regex
        // metacharacters are significant — a pattern like "cost($5)" is an *invalid*
        // regex (unbalanced group) and throws a SyntaxError in the page rather than
        // matching literally. We surface that exception below instead of silently
        // timing out (see the `exception_details` check).
        "text" => format!(
            "new RegExp({}).test(document.body.innerText)",
            serde_json::to_string(pattern)?
        ),
        "url" => format!(
            "location.href.includes({})",
            serde_json::to_string(pattern)?
        ),
        "selector" => format!(
            "!!document.querySelector({})",
            serde_json::to_string(pattern)?
        ),
        other => return Err(format!(
            "Unknown wait type: {other}. Use \"text\", \"url\", \"selector\", or \"network-idle\"."
        ).into()),
    };

    loop {
        let result: EvaluateResult = client
            .call(
                "Runtime.evaluate",
                json!({
                    "expression": expression,
                    "returnByValue": true,
                }),
            )
            .await?;

        // A thrown in-page exception (most commonly an invalid `text` regex) leaves
        // `result.value` absent, which would otherwise look like "no match" and loop
        // until the misleading timeout. Detect it and surface a clear error instead.
        if let Some(exc) = result.exception_details.as_ref() {
            let detail = exc
                .exception
                .as_ref()
                .and_then(|o| o.description.as_ref())
                .map_or(exc.text.as_str(), String::as_str);
            // Errors' `description` carries a multi-line stack — keep just the message.
            let detail = detail.lines().next().unwrap_or(detail);
            return Err(eval_exception_message(what, pattern, detail).into());
        }

        let matched = result
            .result
            .value
            .as_ref()
            .and_then(serde_json::Value::as_bool)
            .unwrap_or(false);

        if matched {
            return Ok(format!("Found: {what} matching \"{pattern}\""));
        }

        if Instant::now() >= deadline {
            return Err(format!(
                "Timeout after {timeout_secs}s waiting for {what} matching \"{pattern}\""
            )
            .into());
        }

        tokio::time::sleep(poll_interval).await;
    }
}

/// Wait until there are zero in-flight network requests for `idle_ms` continuously,
/// bounded by `timeout_secs`. Opt-in (enables the Network domain) so it stays off
/// the stealth hot path.
async fn wait_network_idle(
    client: &CdpClient,
    timeout_secs: u64,
    idle_ms: u64,
) -> Result<String, crate::BoxError> {
    let mut rx = client.events();
    client.enable("Network").await?;

    let deadline = Instant::now() + Duration::from_secs(timeout_secs);
    let idle = Duration::from_millis(idle_ms);
    // Poll cap so we re-check the idle timer even when no events arrive.
    let poll = idle.min(Duration::from_millis(100)).max(Duration::from_millis(10));

    let mut tracker = InFlightTracker::new();

    // CDP does NOT replay `Network.requestWillBeSent` for requests that were
    // already in flight when we called `Network.enable`. So an empty tracker does
    // NOT mean the page is quiet — starting the idle clock right away produces a
    // false "idle" while the initial load is still fetching subresources. Gate the
    // clock on the document's load state instead: only treat the page as
    // idle-eligible once `document.readyState === 'complete'`. While it's still
    // loading we hold the clock off and re-probe, which lets the pre-existing
    // in-flight requests finish (via `Page`/`load`) before we ever declare idle.
    //
    // Limitation: a `fetch`/XHR kicked off *before* subscribe that keeps running
    // *after* `readyState` reaches "complete" still isn't individually tracked
    // (its `requestWillBeSent` was missed). readyState covers the common "page is
    // still loading" case; post-load background requests remain best-effort.
    let mut page_loaded = document_complete(client).await.unwrap_or(false);
    let mut idle_since = refresh_idle_clock(page_loaded, &tracker, None);

    loop {
        if let Some(since) = idle_since
            && since.elapsed() >= idle
        {
            return Ok(format!("Network idle for {idle_ms}ms"));
        }
        if Instant::now() >= deadline {
            return Err(format!(
                "Timeout after {timeout_secs}s waiting for network idle (in-flight: {})",
                tracker.count()
            )
            .into());
        }

        match tokio::time::timeout(poll, rx.recv()).await {
            Ok(Ok(event)) => {
                let request_id = event
                    .params
                    .get("requestId")
                    .and_then(serde_json::Value::as_str);
                tracker.observe(&event.method, request_id);
                idle_since = refresh_idle_clock(page_loaded, &tracker, idle_since);
            }
            // Missed events under load — conservatively restart the idle clock.
            Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => {
                idle_since = None;
            }
            Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => {
                return Err("Connection closed while waiting for network idle".into());
            }
            // No event within the poll window. Until the initial load completes,
            // re-probe readyState so pre-existing in-flight requests keep us busy;
            // then re-check the idle timer.
            Err(_) => {
                if !page_loaded {
                    page_loaded = document_complete(client).await.unwrap_or(page_loaded);
                }
                idle_since = refresh_idle_clock(page_loaded, &tracker, idle_since);
            }
        }
    }
}

/// The page counts as quiet only when its initial load has completed AND no
/// tracked requests are in flight. Gating on the load state is what prevents a
/// false "idle" for requests already in flight before we subscribed (CDP does not
/// replay their `requestWillBeSent`).
#[must_use]
fn is_quiet(page_loaded: bool, tracker: &InFlightTracker) -> bool {
    page_loaded && tracker.is_idle()
}

/// Recompute the idle clock from the combined quiet state. The clock starts only
/// on a rising edge (busy → quiet) so a continuously-quiet stretch is measured
/// from its true start, and is cleared the moment the page goes busy again.
fn refresh_idle_clock(
    page_loaded: bool,
    tracker: &InFlightTracker,
    current: Option<Instant>,
) -> Option<Instant> {
    match (current.is_some(), is_quiet(page_loaded, tracker)) {
        (false, true) => Some(Instant::now()),
        (true, false) => None,
        _ => current,
    }
}

/// Probe whether the document has finished its initial load. Used to seed and
/// re-check the network-idle gate; see `wait_network_idle` for why an empty
/// in-flight tracker is not sufficient to declare the page quiet.
async fn document_complete(client: &CdpClient) -> Result<bool, crate::BoxError> {
    let result: EvaluateResult = client
        .call(
            "Runtime.evaluate",
            json!({
                "expression": "document.readyState === 'complete'",
                "returnByValue": true,
            }),
        )
        .await?;
    Ok(result
        .result
        .value
        .as_ref()
        .and_then(serde_json::Value::as_bool)
        .unwrap_or(false))
}

#[cfg(test)]
mod tests {
    use std::time::Instant;

    use super::{eval_exception_message, is_quiet, refresh_idle_clock, InFlightTracker, Transition};

    // --- A10c: invalid-regex exception surfacing ---------------------------

    #[test]
    fn text_exception_reports_invalid_regex() {
        let msg = eval_exception_message("text", "cost($5)", "SyntaxError: Invalid regular expression");
        assert!(msg.contains("Invalid regex"), "should name the regex problem: {msg}");
        assert!(msg.contains("cost($5)"), "should echo the offending pattern: {msg}");
        assert!(msg.contains("SyntaxError"), "should include the engine detail: {msg}");
        assert!(msg.contains("RegExp"), "should explain regex semantics: {msg}");
    }

    #[test]
    fn non_text_exception_is_generic() {
        let msg = eval_exception_message("selector", "div", "TypeError: boom");
        assert!(!msg.contains("Invalid regex"), "non-text wait is not a regex: {msg}");
        assert!(msg.contains("selector"));
        assert!(msg.contains("div"));
        assert!(msg.contains("TypeError: boom"));
    }

    // --- A10d: network-idle gating on load state ---------------------------

    #[test]
    fn not_quiet_until_page_loaded_even_with_empty_tracker() {
        // The core of the bug: an empty tracker (no observed requests) must NOT
        // count as idle while the initial load is still in progress.
        let t = InFlightTracker::new();
        assert!(t.is_idle(), "no observed requests");
        assert!(!is_quiet(false, &t), "page still loading -> not quiet");
        assert!(is_quiet(true, &t), "load complete + empty tracker -> quiet");
    }

    #[test]
    fn not_quiet_when_requests_in_flight_even_if_loaded() {
        let mut t = InFlightTracker::new();
        t.on_event("Network.requestWillBeSent", Some("r1"));
        assert!(!is_quiet(true, &t), "in-flight request keeps it busy");
    }

    #[test]
    fn idle_clock_holds_off_until_page_loaded() {
        let t = InFlightTracker::new();
        // While loading, the clock never starts despite the empty tracker.
        assert!(refresh_idle_clock(false, &t, None).is_none());
        // Load completes -> clock starts (rising edge).
        let started = refresh_idle_clock(true, &t, None);
        assert!(started.is_some(), "load complete should start the idle clock");
        // Staying quiet must preserve the original start (no reset).
        let kept = refresh_idle_clock(true, &t, started);
        assert_eq!(kept, started, "continuously-quiet must not reset the clock");
    }

    #[test]
    fn idle_clock_clears_when_page_goes_busy() {
        let mut t = InFlightTracker::new();
        let running = Some(Instant::now());
        t.on_event("Network.requestWillBeSent", Some("r1"));
        assert!(
            refresh_idle_clock(true, &t, running).is_none(),
            "a new request must stop the idle clock"
        );
    }

    #[test]
    fn starts_idle() {
        let t = InFlightTracker::new();
        assert!(t.is_idle());
        assert_eq!(t.count(), 0);
    }

    #[test]
    fn request_then_finish_returns_to_idle() {
        let mut t = InFlightTracker::new();
        t.on_event("Network.requestWillBeSent", Some("r1"));
        assert!(!t.is_idle());
        assert_eq!(t.count(), 1);
        t.on_event("Network.loadingFinished", Some("r1"));
        assert!(t.is_idle());
    }

    #[test]
    fn failed_request_also_clears() {
        let mut t = InFlightTracker::new();
        t.on_event("Network.requestWillBeSent", Some("r1"));
        t.on_event("Network.loadingFailed", Some("r1"));
        assert!(t.is_idle());
    }

    #[test]
    fn concurrent_requests_need_all_to_finish() {
        let mut t = InFlightTracker::new();
        t.on_event("Network.requestWillBeSent", Some("a"));
        t.on_event("Network.requestWillBeSent", Some("b"));
        assert_eq!(t.count(), 2);
        t.on_event("Network.loadingFinished", Some("a"));
        assert!(!t.is_idle(), "still one request in flight");
        t.on_event("Network.loadingFinished", Some("b"));
        assert!(t.is_idle());
    }

    #[test]
    fn duplicate_request_id_not_double_counted() {
        let mut t = InFlightTracker::new();
        t.on_event("Network.requestWillBeSent", Some("dup"));
        t.on_event("Network.requestWillBeSent", Some("dup"));
        assert_eq!(t.count(), 1);
        t.on_event("Network.loadingFinished", Some("dup"));
        assert!(t.is_idle());
    }

    #[test]
    fn finish_for_unknown_id_is_noop() {
        let mut t = InFlightTracker::new();
        t.on_event("Network.loadingFinished", Some("ghost"));
        assert!(t.is_idle());
        assert_eq!(t.count(), 0);
    }

    #[test]
    fn unrelated_event_and_missing_id_ignored() {
        let mut t = InFlightTracker::new();
        t.on_event("Network.responseReceived", Some("r1")); // not a start/finish
        t.on_event("Network.requestWillBeSent", None); // no id
        assert!(t.is_idle());
    }

    #[test]
    fn repeated_finish_is_harmless() {
        // A late/duplicate finish for the same id must not underflow or flip state.
        let mut t = InFlightTracker::new();
        t.on_event("Network.requestWillBeSent", Some("r1"));
        t.on_event("Network.loadingFinished", Some("r1"));
        t.on_event("Network.loadingFinished", Some("r1"));
        assert!(t.is_idle());
        assert_eq!(t.count(), 0);
    }

    #[test]
    fn observe_reports_idle_transitions() {
        // This is the exact decision the wait loop drives its idle clock from.
        let mut t = InFlightTracker::new();
        assert_eq!(t.observe("Network.requestWillBeSent", Some("a")), Transition::BecameBusy);
        // A second concurrent request does not re-trigger "busy".
        assert_eq!(t.observe("Network.requestWillBeSent", Some("b")), Transition::NoChange);
        // First of two finishing keeps us busy.
        assert_eq!(t.observe("Network.loadingFinished", Some("a")), Transition::NoChange);
        // Last one finishing flips to idle exactly once.
        assert_eq!(t.observe("Network.loadingFinished", Some("b")), Transition::BecameIdle);
    }

    #[test]
    fn observe_ignores_noise_without_transition() {
        let mut t = InFlightTracker::new();
        assert_eq!(t.observe("Network.responseReceived", Some("x")), Transition::NoChange);
        assert_eq!(t.observe("Network.requestWillBeSent", None), Transition::NoChange);
        assert!(t.is_idle());
    }
}