1use anyhow::Result;
4use serde::Serialize;
5use std::time::Duration;
6
7use crate::cli::output::{print_json, print_table};
8use crate::detect::{self, Engine};
9use crate::registry::{BrowserRow, Registry};
10
11#[derive(Debug, Serialize)]
14struct EnrichedBrowserRow {
15 #[serde(flatten)]
16 row: BrowserRow,
17 #[serde(skip_serializing_if = "Option::is_none")]
18 cdp_port: Option<u16>,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 cdp_ws_url: Option<String>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 bidi_ws_url: Option<String>,
23}
24
25async fn probe_ws_url(client: &reqwest::Client, endpoint: &str) -> Option<String> {
28 let base = crate::cli::wait::http_base_from_endpoint(endpoint);
29 let url = format!("{base}/json/version");
30 let resp = client.get(&url).send().await.ok()?;
31 if !resp.status().is_success() {
32 return None;
33 }
34 let v: serde_json::Value = resp.json().await.ok()?;
35 v.get("webSocketDebuggerUrl")
36 .and_then(|x| x.as_str())
37 .map(|s| s.to_string())
38}
39
40async fn enrich_rows(rows: Vec<BrowserRow>) -> Vec<EnrichedBrowserRow> {
42 let client = match reqwest::Client::builder()
43 .timeout(Duration::from_secs(2))
44 .build()
45 {
46 Ok(c) => c,
47 Err(_) => {
48 return rows
49 .into_iter()
50 .map(|row| {
51 let cdp_port = match row.engine {
52 Engine::Cdp => Some(row.port),
53 _ => None,
54 };
55 EnrichedBrowserRow {
56 row,
57 cdp_port,
58 cdp_ws_url: None,
59 bidi_ws_url: None,
60 }
61 })
62 .collect();
63 }
64 };
65
66 let probes = rows.into_iter().map(|row| {
67 let client = client.clone();
68 async move {
69 let ws = probe_ws_url(&client, &row.endpoint).await;
70 let (cdp_port, cdp_ws_url, bidi_ws_url) = match row.engine {
71 Engine::Cdp => (Some(row.port), ws, None),
72 Engine::Bidi => (None, None, ws),
73 };
74 EnrichedBrowserRow {
75 row,
76 cdp_port,
77 cdp_ws_url,
78 bidi_ws_url,
79 }
80 }
81 });
82 futures_util::future::join_all(probes).await
83}
84
85pub fn run_list_installed(json: bool) -> Result<()> {
87 let installed = detect::list_installed();
88 if json {
89 print_json(&mut std::io::stdout(), &installed)?;
90 } else {
91 let headers = ["KIND", "VERSION", "ENGINE", "EXECUTABLE"];
92 let rows = installed
93 .iter()
94 .map(|i| {
95 vec![
96 i.kind.as_str().to_string(),
97 i.version.clone(),
98 format!("{:?}", i.engine).to_lowercase(),
99 i.executable.display().to_string(),
100 ]
101 })
102 .collect::<Vec<_>>();
103 print_table(&mut std::io::stdout(), &headers, &rows)?;
104 }
105 Ok(())
106}
107
108pub async fn run_list_running(json: bool) -> Result<()> {
110 let reg = Registry::open()?;
111 let alive = reg.list_alive()?;
112 if json {
113 let enriched = enrich_rows(alive).await;
114 print_json(&mut std::io::stdout(), &enriched)?;
115 } else {
116 let headers = [
117 "NAME", "KIND", "PID", "ENGINE", "ENDPOINT", "PROFILE", "STARTED",
118 ];
119 let rows = alive
120 .iter()
121 .map(|r| {
122 vec![
123 r.name.clone(),
124 r.kind.as_str().to_string(),
125 r.pid.to_string(),
126 format!("{:?}", r.engine).to_lowercase(),
127 r.endpoint.clone(),
128 r.profile_dir.display().to_string(),
129 r.started_at.clone(),
130 ]
131 })
132 .collect::<Vec<_>>();
133 print_table(&mut std::io::stdout(), &headers, &rows)?;
134 }
135 Ok(())
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use crate::detect::Kind;
142 use std::path::PathBuf;
143
144 fn row_for(endpoint: &str, port: u16, engine: Engine) -> BrowserRow {
145 BrowserRow {
146 name: "test-name".to_string(),
147 kind: Kind::Chrome,
148 engine,
149 pid: 1,
150 endpoint: endpoint.to_string(),
151 port,
152 profile_dir: PathBuf::from("/tmp/profile"),
153 executable: PathBuf::from("/usr/bin/chrome"),
154 headless: false,
155 started_at: "2024-01-01T00:00:00Z".to_string(),
156 }
157 }
158
159 #[tokio::test]
160 async fn enriches_cdp_row_with_port_and_ws_url() {
161 let mut server = mockito::Server::new_async().await;
162 let m = server
163 .mock("GET", "/json/version")
164 .with_status(200)
165 .with_header("content-type", "application/json")
166 .with_body(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/devtools/browser/abc"}"#)
167 .create_async()
168 .await;
169
170 let url = server.url();
171 let port: u16 = url::Url::parse(&url).unwrap().port().unwrap();
172 let row = row_for(&url, port, Engine::Cdp);
173
174 let enriched = enrich_rows(vec![row]).await;
175 m.assert_async().await;
176 assert_eq!(enriched.len(), 1);
177 let e = &enriched[0];
178 assert_eq!(e.cdp_port, Some(port));
179 assert_eq!(
180 e.cdp_ws_url.as_deref(),
181 Some("ws://127.0.0.1:1234/devtools/browser/abc")
182 );
183 assert!(e.bidi_ws_url.is_none());
184
185 let mut buf: Vec<u8> = Vec::new();
186 print_json(&mut buf, &enriched).unwrap();
187 let v: serde_json::Value = serde_json::from_slice(&buf).unwrap();
188 assert_eq!(v[0]["cdp_port"], port);
189 assert_eq!(
190 v[0]["cdp_ws_url"],
191 "ws://127.0.0.1:1234/devtools/browser/abc"
192 );
193 assert_eq!(v[0]["name"], "test-name");
194 assert_eq!(v[0]["engine"], "cdp");
195 assert_eq!(v[0]["endpoint"], url);
196 assert!(v[0].get("bidi_ws_url").is_none());
197 }
198
199 #[tokio::test]
200 async fn cdp_port_present_even_when_probe_fails() {
201 let mut server = mockito::Server::new_async().await;
202 let _m = server
203 .mock("GET", "/json/version")
204 .with_status(500)
205 .create_async()
206 .await;
207
208 let url = server.url();
209 let port: u16 = url::Url::parse(&url).unwrap().port().unwrap();
210 let row = row_for(&url, port, Engine::Cdp);
211
212 let enriched = enrich_rows(vec![row]).await;
213 assert_eq!(enriched.len(), 1);
214 let e = &enriched[0];
215 assert_eq!(e.cdp_port, Some(port));
216 assert!(e.cdp_ws_url.is_none());
217
218 let mut buf: Vec<u8> = Vec::new();
219 print_json(&mut buf, &enriched).unwrap();
220 let v: serde_json::Value = serde_json::from_slice(&buf).unwrap();
221 assert_eq!(v[0]["cdp_port"], port);
222 assert!(v[0].get("cdp_ws_url").is_none());
223 }
224
225 #[tokio::test]
226 async fn enriches_bidi_row_with_ws_url_only() {
227 let mut server = mockito::Server::new_async().await;
228 let _m = server
229 .mock("GET", "/json/version")
230 .with_status(200)
231 .with_header("content-type", "application/json")
232 .with_body(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:5555/session"}"#)
233 .create_async()
234 .await;
235
236 let url = server.url();
237 let port: u16 = url::Url::parse(&url).unwrap().port().unwrap();
238 let row = row_for(&url, port, Engine::Bidi);
239
240 let enriched = enrich_rows(vec![row]).await;
241 let e = &enriched[0];
242 assert!(e.cdp_port.is_none());
243 assert!(e.cdp_ws_url.is_none());
244 assert_eq!(
245 e.bidi_ws_url.as_deref(),
246 Some("ws://127.0.0.1:5555/session")
247 );
248
249 let mut buf: Vec<u8> = Vec::new();
250 print_json(&mut buf, &enriched).unwrap();
251 let v: serde_json::Value = serde_json::from_slice(&buf).unwrap();
252 assert!(v[0].get("cdp_port").is_none());
253 assert_eq!(v[0]["bidi_ws_url"], "ws://127.0.0.1:5555/session");
254 }
255}