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
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}