browser_control/cli/
targets.rs1use anyhow::Result;
4
5use crate::cli::mcp::resolve_browser;
6use crate::cli::output::{print_json, print_table};
7use crate::session::targets::{self, TargetInfo};
8
9pub async fn run(browser: Option<String>, url: Option<String>, json: bool) -> Result<()> {
10 let resolved = resolve_browser(browser).await?;
11 let targets = targets::list(&resolved.endpoint, resolved.engine, url.as_deref()).await?;
12 let mut out = std::io::stdout();
13 if json {
14 print_json(&mut out, &targets)?;
15 } else {
16 let headers = ["KIND", "ID", "URL", "TITLE"];
17 let rows = table_rows(&targets);
18 print_table(&mut out, &headers, &rows)?;
19 }
20 Ok(())
21}
22
23fn table_rows(targets: &[TargetInfo]) -> Vec<Vec<String>> {
24 targets
25 .iter()
26 .map(|t| {
27 vec![
28 t.kind.clone(),
29 t.id.clone(),
30 t.url.clone(),
31 t.title.clone(),
32 ]
33 })
34 .collect()
35}
36
37#[cfg(test)]
38mod tests {
39 use super::*;
40 use crate::detect::Engine;
41 use futures_util::{SinkExt, StreamExt};
42 use serde_json::{json, Value};
43 use tokio_tungstenite::tungstenite::Message;
44
45 async fn spawn_cdp_mock(targets: Vec<Value>) -> String {
46 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
47 let addr = listener.local_addr().unwrap();
48 tokio::spawn(async move {
49 let (stream, _) = listener.accept().await.unwrap();
50 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
51 while let Some(Ok(Message::Text(t))) = ws.next().await {
52 let req: Value = serde_json::from_str(&t).unwrap();
53 let id = req["id"].as_u64().unwrap();
54 let method = req["method"].as_str().unwrap_or("");
55 let result = if method == "Target.getTargets" {
56 json!({"targetInfos": targets.clone()})
57 } else {
58 json!({})
59 };
60 let resp = json!({"id": id, "result": result});
61 ws.send(Message::Text(resp.to_string())).await.unwrap();
62 }
63 });
64 format!("ws://{addr}")
65 }
66
67 #[test]
68 fn table_rows_have_kind_id_url_title_in_order() {
69 let t = vec![TargetInfo {
70 id: "abc".into(),
71 url: "https://example.com/".into(),
72 title: "Example".into(),
73 kind: "page".into(),
74 }];
75 let rows = table_rows(&t);
76 assert_eq!(rows.len(), 1);
77 assert_eq!(rows[0], vec!["page", "abc", "https://example.com/", "Example"]);
78 }
79
80 #[tokio::test]
81 async fn lists_targets_against_cdp_mock_json_and_table() {
82 let endpoint = spawn_cdp_mock(vec![
83 json!({"targetId":"a","type":"page","url":"https://example.com/","title":"Ex"}),
84 json!({"targetId":"b","type":"page","url":"https://other.test/","title":"Other"}),
85 ])
86 .await;
87
88 let targets = targets::list(&endpoint, Engine::Cdp, None).await.unwrap();
89 assert_eq!(targets.len(), 2);
90
91 let mut buf: Vec<u8> = Vec::new();
92 print_json(&mut buf, &targets).unwrap();
93 let v: Value = serde_json::from_slice(&buf).unwrap();
94 for i in 0..2 {
95 for key in ["id", "url", "title", "kind"] {
96 assert!(v[i].get(key).is_some(), "missing {key} in {v:?}");
97 }
98 }
99 assert_eq!(v[0]["url"], "https://example.com/");
100
101 let headers = ["KIND", "ID", "URL", "TITLE"];
102 let rows = table_rows(&targets);
103 let mut tbuf: Vec<u8> = Vec::new();
104 print_table(&mut tbuf, &headers, &rows).unwrap();
105 let text = String::from_utf8(tbuf).unwrap();
106 assert!(text.contains("https://example.com/"));
107 assert!(text.contains("KIND"));
108 assert!(text.contains("page"));
109 }
110}