Skip to main content

browser_control/cli/
wait.rs

1//! `browser-control wait` — block until the browser endpoint is ready.
2
3use anyhow::{anyhow, bail, Result};
4use std::time::{Duration, Instant};
5
6use crate::detect::Engine;
7
8pub async fn run(browser: Option<String>, ready: bool, timeout: u64) -> Result<()> {
9    if !ready {
10        bail!("`wait` requires --ready in this version");
11    }
12    let resolved = crate::cli::mcp::resolve_browser(browser).await?;
13    wait_until_ready(
14        &resolved.endpoint,
15        resolved.engine,
16        Duration::from_secs(timeout),
17    )
18    .await?;
19    println!("ready");
20    Ok(())
21}
22
23/// Convert a raw browser endpoint (which may be a `ws://host:port/...` URL or
24/// already an `http://host:port` base) into the HTTP base used for probing.
25pub(crate) fn http_base_from_endpoint(endpoint: &str) -> String {
26    if let Ok(u) = url::Url::parse(endpoint) {
27        let scheme = match u.scheme() {
28            "ws" | "http" => "http",
29            "wss" | "https" => "https",
30            other => other,
31        };
32        if let (Some(host), Some(port)) = (u.host_str(), u.port_or_known_default()) {
33            return format!("{scheme}://{host}:{port}");
34        }
35    }
36    endpoint.trim_end_matches('/').to_string()
37}
38
39async fn probe_once(client: &reqwest::Client, endpoint: &str, engine: Engine) -> bool {
40    let base = http_base_from_endpoint(endpoint);
41    let url = format!("{base}/json/version");
42    let resp = match client.get(&url).send().await {
43        Ok(r) => r,
44        Err(_) => return false,
45    };
46    if !resp.status().is_success() {
47        return false;
48    }
49    match engine {
50        Engine::Cdp => {
51            let v: serde_json::Value = match resp.json().await {
52                Ok(v) => v,
53                Err(_) => return false,
54            };
55            v.get("webSocketDebuggerUrl")
56                .and_then(|x| x.as_str())
57                .is_some()
58        }
59        Engine::Bidi => true,
60    }
61}
62
63pub(crate) async fn wait_until_ready(
64    endpoint: &str,
65    engine: Engine,
66    timeout: Duration,
67) -> Result<()> {
68    let client = reqwest::Client::builder()
69        .timeout(Duration::from_secs(2))
70        .build()
71        .map_err(|e| anyhow!("failed to build http client: {e}"))?;
72    let deadline = Instant::now() + timeout;
73    loop {
74        if probe_once(&client, endpoint, engine).await {
75            return Ok(());
76        }
77        if Instant::now() >= deadline {
78            return Err(anyhow!(
79                "timeout after {}s waiting for browser ready",
80                timeout.as_secs()
81            ));
82        }
83        tokio::time::sleep(Duration::from_millis(250)).await;
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn http_base_from_ws_endpoint_with_path() {
93        let got = http_base_from_endpoint(
94            "ws://127.0.0.1:52679/devtools/browser/992c1917-9f77-4eee-9bf7-90f400d826b5",
95        );
96        assert_eq!(got, "http://127.0.0.1:52679");
97    }
98
99    #[test]
100    fn http_base_from_wss_endpoint() {
101        let got = http_base_from_endpoint("wss://host.example:9222/session/abc");
102        assert_eq!(got, "https://host.example:9222");
103    }
104
105    #[test]
106    fn http_base_passes_through_http_base() {
107        let got = http_base_from_endpoint("http://127.0.0.1:9222");
108        assert_eq!(got, "http://127.0.0.1:9222");
109    }
110
111    #[tokio::test]
112    async fn wait_ready_succeeds_when_server_immediately_responds() {
113        let mut server = mockito::Server::new_async().await;
114        let _m = server
115            .mock("GET", "/json/version")
116            .with_status(200)
117            .with_header("content-type", "application/json")
118            .with_body(r#"{"webSocketDebuggerUrl":"ws://x"}"#)
119            .expect_at_least(1)
120            .create_async()
121            .await;
122
123        let url = server.url();
124        let res = tokio::time::timeout(
125            Duration::from_secs(1),
126            wait_until_ready(&url, Engine::Cdp, Duration::from_secs(5)),
127        )
128        .await
129        .expect("did not complete within 1s");
130        assert!(res.is_ok(), "expected Ok, got {res:?}");
131    }
132
133    #[tokio::test]
134    async fn wait_ready_times_out_when_server_500s() {
135        let mut server = mockito::Server::new_async().await;
136        let _m = server
137            .mock("GET", "/json/version")
138            .with_status(500)
139            .expect_at_least(1)
140            .create_async()
141            .await;
142
143        let url = server.url();
144        let res = wait_until_ready(&url, Engine::Cdp, Duration::from_secs(1)).await;
145        let err = res.expect_err("expected timeout error");
146        assert!(
147            err.to_string().contains("timeout"),
148            "unexpected error: {err}"
149        );
150    }
151
152    #[tokio::test]
153    async fn wait_ready_succeeds_after_initial_failure() {
154        let mut server = mockito::Server::new_async().await;
155        let m_fail = server
156            .mock("GET", "/json/version")
157            .with_status(500)
158            .expect(2)
159            .create_async()
160            .await;
161        let m_ok = server
162            .mock("GET", "/json/version")
163            .with_status(200)
164            .with_header("content-type", "application/json")
165            .with_body(r#"{"webSocketDebuggerUrl":"ws://x"}"#)
166            .expect_at_least(1)
167            .create_async()
168            .await;
169
170        let url = server.url();
171        let res = wait_until_ready(&url, Engine::Cdp, Duration::from_secs(5)).await;
172        assert!(res.is_ok(), "expected Ok, got {res:?}");
173        m_fail.assert_async().await;
174        m_ok.assert_async().await;
175    }
176}