use std::collections::HashSet;
use std::time::{Duration, Instant};
use serde_json::json;
use crate::cdp::client::CdpClient;
use crate::cdp::types::EvaluateResult;
#[derive(Debug, PartialEq, Eq)]
pub enum Transition {
BecameBusy,
BecameIdle,
NoChange,
}
#[derive(Default)]
pub struct InFlightTracker {
in_flight: HashSet<String>,
}
impl InFlightTracker {
#[must_use]
pub fn new() -> Self {
Self::default()
}
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);
}
_ => {}
}
}
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()
}
}
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 {
"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?;
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;
}
}
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);
let poll = idle.min(Duration::from_millis(100)).max(Duration::from_millis(10));
let mut tracker = InFlightTracker::new();
let mut idle_since = Some(Instant::now());
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);
match tracker.observe(&event.method, request_id) {
Transition::BecameBusy => idle_since = None,
Transition::BecameIdle => idle_since = Some(Instant::now()),
Transition::NoChange => {}
}
}
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());
}
Err(_) => {}
}
}
}
#[cfg(test)]
mod tests {
use super::{InFlightTracker, Transition};
#[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")); t.on_event("Network.requestWillBeSent", None); assert!(t.is_idle());
}
#[test]
fn repeated_finish_is_harmless() {
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() {
let mut t = InFlightTracker::new();
assert_eq!(t.observe("Network.requestWillBeSent", Some("a")), Transition::BecameBusy);
assert_eq!(t.observe("Network.requestWillBeSent", Some("b")), Transition::NoChange);
assert_eq!(t.observe("Network.loadingFinished", Some("a")), Transition::NoChange);
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());
}
}