browser_control/cli/
wait.rs1use 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
23pub(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
63async fn wait_until_ready(endpoint: &str, engine: Engine, timeout: Duration) -> Result<()> {
64 let client = reqwest::Client::builder()
65 .timeout(Duration::from_secs(2))
66 .build()
67 .map_err(|e| anyhow!("failed to build http client: {e}"))?;
68 let deadline = Instant::now() + timeout;
69 loop {
70 if probe_once(&client, endpoint, engine).await {
71 return Ok(());
72 }
73 if Instant::now() >= deadline {
74 return Err(anyhow!(
75 "timeout after {}s waiting for browser ready",
76 timeout.as_secs()
77 ));
78 }
79 tokio::time::sleep(Duration::from_millis(250)).await;
80 }
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86
87 #[test]
88 fn http_base_from_ws_endpoint_with_path() {
89 let got = http_base_from_endpoint(
90 "ws://127.0.0.1:52679/devtools/browser/992c1917-9f77-4eee-9bf7-90f400d826b5",
91 );
92 assert_eq!(got, "http://127.0.0.1:52679");
93 }
94
95 #[test]
96 fn http_base_from_wss_endpoint() {
97 let got = http_base_from_endpoint("wss://host.example:9222/session/abc");
98 assert_eq!(got, "https://host.example:9222");
99 }
100
101 #[test]
102 fn http_base_passes_through_http_base() {
103 let got = http_base_from_endpoint("http://127.0.0.1:9222");
104 assert_eq!(got, "http://127.0.0.1:9222");
105 }
106
107 #[tokio::test]
108 async fn wait_ready_succeeds_when_server_immediately_responds() {
109 let mut server = mockito::Server::new_async().await;
110 let _m = server
111 .mock("GET", "/json/version")
112 .with_status(200)
113 .with_header("content-type", "application/json")
114 .with_body(r#"{"webSocketDebuggerUrl":"ws://x"}"#)
115 .expect_at_least(1)
116 .create_async()
117 .await;
118
119 let url = server.url();
120 let res = tokio::time::timeout(
121 Duration::from_secs(1),
122 wait_until_ready(&url, Engine::Cdp, Duration::from_secs(5)),
123 )
124 .await
125 .expect("did not complete within 1s");
126 assert!(res.is_ok(), "expected Ok, got {res:?}");
127 }
128
129 #[tokio::test]
130 async fn wait_ready_times_out_when_server_500s() {
131 let mut server = mockito::Server::new_async().await;
132 let _m = server
133 .mock("GET", "/json/version")
134 .with_status(500)
135 .expect_at_least(1)
136 .create_async()
137 .await;
138
139 let url = server.url();
140 let res = wait_until_ready(&url, Engine::Cdp, Duration::from_secs(1)).await;
141 let err = res.expect_err("expected timeout error");
142 assert!(
143 err.to_string().contains("timeout"),
144 "unexpected error: {err}"
145 );
146 }
147
148 #[tokio::test]
149 async fn wait_ready_succeeds_after_initial_failure() {
150 let mut server = mockito::Server::new_async().await;
151 let m_fail = server
152 .mock("GET", "/json/version")
153 .with_status(500)
154 .expect(2)
155 .create_async()
156 .await;
157 let m_ok = server
158 .mock("GET", "/json/version")
159 .with_status(200)
160 .with_header("content-type", "application/json")
161 .with_body(r#"{"webSocketDebuggerUrl":"ws://x"}"#)
162 .expect_at_least(1)
163 .create_async()
164 .await;
165
166 let url = server.url();
167 let res = wait_until_ready(&url, Engine::Cdp, Duration::from_secs(5)).await;
168 assert!(res.is_ok(), "expected Ok, got {res:?}");
169 m_fail.assert_async().await;
170 m_ok.assert_async().await;
171 }
172}