browser_control/cli/
wait.rs1use 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
42pub(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}