Skip to main content

browser_control/cli/
wait.rs

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