1use anyhow::Result;
8use serde_json::{json, Value};
9use std::collections::HashMap;
10use std::sync::Arc;
11use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
12use tokio::sync::{Mutex, RwLock};
13
14use crate::cli::env_resolver::ResolvedBrowser;
15use crate::session::backend::TabBackend;
16
17pub type BidiCache = Arc<Mutex<Option<Arc<crate::bidi::BidiClient>>>>;
22
23#[derive(Clone)]
35pub struct ServerState {
36 pub browser: Arc<RwLock<ResolvedBrowser>>,
37 pub bidi: BidiCache,
38 pub bidi_lock: Arc<Mutex<BidiLockState>>,
45 pub backend: Arc<Mutex<Option<TabBackend>>>,
50 pub active_target_id: Arc<Mutex<Option<String>>>,
55 pub origin_target_ids: Arc<Mutex<HashMap<String, String>>>,
61 pub sidecar: Arc<Mutex<Option<crate::sidecar::Sidecar>>>,
68 pub sidecar_config: crate::sidecar::SidecarConfig,
71 pub op_barrier: Arc<RwLock<()>>,
81}
82
83#[derive(Default)]
87pub enum BidiLockState {
88 #[default]
89 Pending,
90 Acquired(crate::registry::BidiLockGuard),
91 NotApplicable,
92}
93
94impl ServerState {
95 pub fn new(browser: ResolvedBrowser) -> Self {
96 Self::with_sidecar_config(browser, crate::sidecar::SidecarConfig::default())
97 }
98
99 pub fn with_sidecar_config(
102 browser: ResolvedBrowser,
103 sidecar_config: crate::sidecar::SidecarConfig,
104 ) -> Self {
105 Self {
106 browser: Arc::new(RwLock::new(browser)),
107 bidi: Arc::new(Mutex::new(None)),
108 bidi_lock: Arc::new(Mutex::new(BidiLockState::Pending)),
109 backend: Arc::new(Mutex::new(None)),
110 active_target_id: Arc::new(Mutex::new(None)),
111 origin_target_ids: Arc::new(Mutex::new(HashMap::new())),
112 sidecar: Arc::new(Mutex::new(None)),
113 sidecar_config,
114 op_barrier: Arc::new(RwLock::new(())),
115 }
116 }
117
118 pub async fn ensure_sidecar(&self, tool_name: &str) -> Result<crate::sidecar::Sidecar> {
125 let resolved = self.browser_snapshot().await;
126 if resolved.engine != crate::detect::Engine::Cdp {
127 return Err(crate::errors::SessionError::EngineUnsupported {
128 tool: tool_name.to_string(),
129 required_engine: "Chromium (CDP)".into(),
130 current_engine: format!("{:?}", resolved.engine),
131 hint: "use engine-agnostic tools such as browser_get_html, browser_fetch, browser_take_screenshot, or switch to a Chromium browser via browser_select",
132 }
133 .into());
134 }
135 let mut guard = self.sidecar.lock().await;
136 if let Some(sc) = guard.as_ref() {
137 return Ok(sc.clone());
138 }
139 let sc = crate::sidecar::Sidecar::start(self.sidecar_config.clone()).await?;
140 sc.connect(&resolved.endpoint).await?;
141 *guard = Some(sc.clone());
142 Ok(sc)
143 }
144
145 pub async fn browser_snapshot(&self) -> ResolvedBrowser {
147 self.browser.read().await.clone()
148 }
149
150 pub async fn ensure_bidi_lock(&self) -> Result<()> {
154 use crate::cli::env_resolver::Source;
155 use crate::cli::mcp::acquire_bidi_lock_if_needed;
156 use crate::detect::Engine;
157 use crate::registry::Registry;
158 let mut guard = self.bidi_lock.lock().await;
159 if matches!(*guard, BidiLockState::Pending) {
160 let resolved = self.browser_snapshot().await;
161 if resolved.engine != Engine::Bidi || matches!(resolved.source, Source::External) {
162 *guard = BidiLockState::NotApplicable;
163 return Ok(());
164 }
165 let acquired = tokio::task::spawn_blocking(move || {
171 let registry = Registry::open()?;
172 acquire_bidi_lock_if_needed(®istry, &resolved)
173 })
174 .await??;
175 *guard = match acquired {
176 Some(lock) => BidiLockState::Acquired(lock),
177 None => BidiLockState::NotApplicable,
178 };
179 }
180 Ok(())
181 }
182
183 pub async fn ensure_backend(&self) -> Result<TabBackend> {
188 self.ensure_bidi_lock().await?;
189 let mut guard = self.backend.lock().await;
190 if let Some(b) = guard.as_ref() {
191 return Ok(b.clone());
192 }
193 let resolved = self.browser_snapshot().await;
194 let b = crate::session::backend::open_backend(&resolved.endpoint, resolved.engine).await?;
195 *guard = Some(b.clone());
196 Ok(b)
197 }
198
199 pub async fn current_tab(&self) -> Result<(TabBackend, String)> {
218 let backend = self.ensure_backend().await?;
219 let mut pointer = self.active_target_id.lock().await;
220 if let Some(tid) = pointer.as_ref() {
221 let live = backend.live_target_ids().await?;
222 if live.contains(tid) {
223 return Ok((backend, tid.clone()));
224 }
225 }
227 let new_tid = backend.create_tab("about:blank").await?;
228 *pointer = Some(new_tid.clone());
229 Ok((backend, new_tid))
230 }
231
232 pub async fn resolve_or_create_for_origin(&self, url: &str) -> Result<(TabBackend, String)> {
241 let want =
242 url::Url::parse(url).map_err(|e| anyhow::anyhow!("invalid fetch URL `{url}`: {e}"))?;
243 let origin_root = crate::session::attach::origin_root_url(&want);
244 let backend = self.ensure_backend().await?;
245 let mut origin_targets = self.origin_target_ids.lock().await;
246 let live_targets = backend.live_targets().await?;
247 let live_ids: std::collections::HashSet<&str> =
248 live_targets.iter().map(|t| t.id.as_str()).collect();
249
250 if let Some(cached) = origin_targets.get(&origin_root) {
251 if live_ids.contains(cached.as_str()) {
252 return Ok((backend, cached.clone()));
253 }
254 origin_targets.remove(&origin_root);
255 }
256
257 if let Some(existing) = live_targets.iter().find(|t| {
258 url::Url::parse(&t.url)
259 .map(|parsed| crate::session::attach::same_origin(&parsed, &want))
260 .unwrap_or(false)
261 }) {
262 origin_targets.insert(origin_root, existing.id.clone());
263 return Ok((backend, existing.id.clone()));
264 }
265
266 let new_tid = backend.create_tab(&origin_root).await?;
267 origin_targets.insert(origin_root, new_tid.clone());
268 Ok((backend, new_tid))
269 }
270
271 pub async fn resolve_target_for_args(&self, args: &Value) -> Result<(TabBackend, String)> {
286 let tab = args.get("tab").and_then(|v| v.as_str()).map(String::from);
287 let target = args
288 .get("target")
289 .and_then(|v| v.as_str())
290 .map(String::from);
291 match (tab, target) {
292 (Some(_), Some(_)) => Err(anyhow::anyhow!("`tab` and `target` are mutually exclusive")),
293 (Some(name), None) => {
294 let backend = self.ensure_backend().await?;
295 let browser_name = self.registered_browser_name().await?;
296 let bn = browser_name.clone();
300 let n = name.clone();
301 let row = sync_registry_op(move |reg| reg.tab_get(&bn, &n))
302 .await?
303 .ok_or_else(|| crate::errors::SessionError::TabNotFound {
304 browser: browser_name.clone(),
305 name: name.clone(),
306 })?;
307 let live = backend.live_target_ids().await?;
308 if !live.contains(&row.target_id) {
309 let bn = browser_name.clone();
311 let n = name.clone();
312 sync_registry_op(move |reg| reg.tab_delete(&bn, &n)).await?;
313 return Err(crate::errors::SessionError::TabNotFound {
314 browser: browser_name,
315 name,
316 }
317 .into());
318 }
319 let bn = browser_name.clone();
320 let n = name.clone();
321 sync_registry_op(move |reg| reg.tab_touch(&bn, &n)).await?;
322 Ok((backend, row.target_id))
323 }
324 (None, Some(regex)) => {
325 let backend = self.ensure_backend().await?;
326 let target_id = resolve_target_by_regex(&backend, ®ex).await?;
327 Ok((backend, target_id))
328 }
329 (None, None) => self.current_tab().await,
330 }
331 }
332
333 pub async fn registered_browser_name(&self) -> Result<String> {
336 use crate::cli::env_resolver::Source;
337 let resolved = self.browser_snapshot().await;
338 match resolved.source {
339 Source::Registered { name } => Ok(name),
340 Source::External => Err(anyhow::anyhow!(
341 "operation requires a registered browser; external URL endpoints \
342 don't have a stable identity"
343 )),
344 }
345 }
346
347 pub async fn switch_browser(&self, new_browser: ResolvedBrowser) -> Result<()> {
359 {
361 let mut bidi = self.bidi.lock().await;
362 if let Some(client) = bidi.take() {
363 let _ = client.session_end().await;
364 }
365 }
366 {
369 let mut backend = self.backend.lock().await;
370 *backend = None;
371 }
372 {
374 let mut lock = self.bidi_lock.lock().await;
375 *lock = BidiLockState::Pending;
376 }
377 {
379 let mut pointer = self.active_target_id.lock().await;
380 *pointer = None;
381 }
382 {
384 let mut origins = self.origin_target_ids.lock().await;
385 origins.clear();
386 }
387 {
391 let mut sidecar = self.sidecar.lock().await;
392 if let Some(sc) = sidecar.take() {
393 let _ = sc.call("dispose", serde_json::json!({})).await;
394 drop(sc);
396 }
397 }
398 {
400 let mut br = self.browser.write().await;
401 *br = new_browser;
402 }
403 self.ensure_bidi_lock().await?;
406 Ok(())
407 }
408}
409
410pub(crate) async fn sync_registry_op<T, F>(f: F) -> Result<T>
421where
422 F: FnOnce(&crate::registry::Registry) -> Result<T> + Send + 'static,
423 T: Send + 'static,
424{
425 tokio::task::spawn_blocking(move || {
426 let reg = crate::registry::Registry::open()?;
427 f(®)
428 })
429 .await?
430}
431
432pub(crate) async fn resolve_browser_send(
438 selector: crate::cli::env_resolver::BrowserSelector,
439) -> Result<ResolvedBrowser> {
440 use crate::cli::env_resolver::{BrowserSelector, DefaultResolver, Resolver};
441 match selector {
442 BrowserSelector::Url(u) => match u.scheme() {
443 "ws" | "wss" => Ok(ResolvedBrowser {
444 engine: if u.path().contains("/session") {
445 crate::detect::Engine::Bidi
446 } else {
447 crate::detect::Engine::Cdp
448 },
449 endpoint: u.to_string(),
450 source: crate::cli::env_resolver::Source::External,
451 }),
452 "http" | "https" => {
453 let base = u.as_str().trim_end_matches('/').to_string();
454 let ws = DefaultResolver.fetch_version(&base).await?;
455 let ws_url = url::Url::parse(&ws)?;
456 Ok(ResolvedBrowser {
457 engine: if ws_url.path().contains("/session") {
458 crate::detect::Engine::Bidi
459 } else {
460 crate::detect::Engine::Cdp
461 },
462 endpoint: ws,
463 source: crate::cli::env_resolver::Source::External,
464 })
465 }
466 other => anyhow::bail!("unsupported URL scheme: {other}"),
467 },
468 other => {
469 tokio::task::spawn_blocking(move || {
470 let reg = crate::registry::Registry::open()?;
471 let rt = tokio::runtime::Builder::new_current_thread().build()?;
476 rt.block_on(crate::cli::env_resolver::resolve_with(
477 other,
478 ®,
479 &DefaultResolver,
480 ))
481 })
482 .await?
483 }
484 }
485}
486
487async fn resolve_target_by_regex(backend: &TabBackend, regex: &str) -> Result<String> {
494 use crate::errors::SessionError;
495 use regex::Regex;
496 use std::time::Duration;
497 const PROBE: Duration = Duration::from_millis(500);
498
499 let re = Regex::new(regex)?;
500 let targets = backend.live_targets().await?;
501 let matches: Vec<_> = targets.iter().filter(|t| re.is_match(&t.url)).collect();
502 if matches.is_empty() {
503 return Err(anyhow::anyhow!("no target matched URL regex `{regex}`"));
504 }
505 let mut last_id: Option<String> = None;
506 let mut last_url: Option<String> = None;
507 for t in &matches {
508 last_id = Some(t.id.clone());
509 last_url = Some(t.url.clone());
510 let ok = matches!(
511 tokio::time::timeout(PROBE, backend.evaluate(&t.id, "1", false, PROBE)).await,
512 Ok(Ok(_))
513 );
514 if ok {
515 return Ok(t.id.clone());
516 }
517 }
518 Err(SessionError::TabHung {
519 target_id: last_id,
520 url: last_url,
521 timeout_ms: PROBE.as_millis() as u64,
522 hint: "all-matches-hung",
523 }
524 .into())
525}
526
527impl std::fmt::Debug for ServerState {
528 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
529 f.debug_struct("ServerState").finish()
530 }
531}
532
533pub type ToolHandler = std::sync::Arc<
535 dyn Fn(ServerState, Value) -> futures_util::future::BoxFuture<'static, Result<Value>>
536 + Send
537 + Sync,
538>;
539
540pub struct RegisteredTool {
541 pub name: String,
542 pub description: String,
543 pub input_schema: Value,
544 pub handler: ToolHandler,
545}
546
547#[derive(Clone, Default)]
548pub struct ToolRegistry {
549 inner: std::sync::Arc<std::sync::Mutex<Vec<RegisteredTool>>>,
550}
551
552impl ToolRegistry {
553 pub fn new() -> Self {
554 Self::default()
555 }
556
557 pub fn register(&self, t: RegisteredTool) {
558 self.inner.lock().unwrap().push(t);
559 }
560
561 pub fn list(&self) -> Vec<Value> {
562 self.inner
563 .lock()
564 .unwrap()
565 .iter()
566 .map(|t| {
567 json!({
568 "name": t.name,
569 "description": t.description,
570 "inputSchema": t.input_schema,
571 })
572 })
573 .collect()
574 }
575
576 pub fn handler(&self, name: &str) -> Option<ToolHandler> {
577 self.inner
578 .lock()
579 .unwrap()
580 .iter()
581 .find(|t| t.name == name)
582 .map(|t| t.handler.clone())
583 }
584}
585
586pub async fn run(state: ServerState, tools: ToolRegistry) -> Result<()> {
588 run_with_streams(state, tools, tokio::io::stdin(), tokio::io::stdout()).await
589}
590
591pub async fn run_with_streams<I, O>(
605 state: ServerState,
606 tools: ToolRegistry,
607 stdin: I,
608 mut stdout: O,
609) -> Result<()>
610where
611 I: tokio::io::AsyncRead + Unpin,
612 O: tokio::io::AsyncWrite + Unpin + Send + 'static,
613{
614 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
618 let writer = tokio::spawn(async move {
619 while let Some(frame) = rx.recv().await {
620 if stdout.write_all(&frame).await.is_err() {
621 break;
622 }
623 if stdout.flush().await.is_err() {
624 break;
625 }
626 }
627 });
628
629 let mut lines = BufReader::new(stdin).lines();
630 while let Some(line) = lines.next_line().await? {
631 if line.trim().is_empty() {
632 continue;
633 }
634 let req: Value = match serde_json::from_str(&line) {
635 Ok(v) => v,
636 Err(e) => {
637 let _ = tx.send(error_frame(
638 Value::Null,
639 -32700,
640 &format!("parse error: {e}"),
641 ));
642 continue;
643 }
644 };
645 let id = req.get("id").cloned().unwrap_or(Value::Null);
646 let method = req.get("method").and_then(|m| m.as_str()).unwrap_or("");
647 let params = req.get("params").cloned().unwrap_or(Value::Null);
648
649 if id.is_null() && method == "notifications/initialized" {
651 continue;
652 }
653
654 match method {
659 "initialize" => {
660 let _ = tx.send(handle_initialize(id));
661 }
662 "ping" => {
663 let _ = tx.send(handle_ping(id));
664 }
665 "tools/list" => {
666 let _ = tx.send(handle_tools_list(id, &tools));
667 }
668 "tools/call" => {
669 handle_tools_call(id, ¶ms, &state, &tools, &tx);
670 }
671 _ => {
672 let _ = tx.send(error_frame(
673 id,
674 -32601,
675 &format!("method not found: {method}"),
676 ));
677 }
678 }
679 }
680 drop(tx);
683 let _ = writer.await;
684 Ok(())
685}
686
687fn handle_initialize(id: Value) -> Vec<u8> {
690 result_frame(
691 id,
692 json!({
693 "protocolVersion": "2024-11-05",
694 "capabilities": {"tools": {}},
695 "serverInfo": {
696 "name": "browser-control",
697 "version": env!("CARGO_PKG_VERSION"),
698 },
699 }),
700 )
701}
702
703fn handle_ping(id: Value) -> Vec<u8> {
705 result_frame(id, json!({}))
706}
707
708fn handle_tools_list(id: Value, tools: &ToolRegistry) -> Vec<u8> {
710 result_frame(id, json!({"tools": tools.list()}))
711}
712
713fn handle_tools_call(
724 id: Value,
725 params: &Value,
726 state: &ServerState,
727 tools: &ToolRegistry,
728 tx: &tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
729) {
730 let name = params
731 .get("name")
732 .and_then(|v| v.as_str())
733 .unwrap_or("")
734 .to_string();
735 let args = params.get("arguments").cloned().unwrap_or(Value::Null);
736 let handler = tools.handler(&name);
737 let state = state.clone();
738 let tx = tx.clone();
739 let barrier = state.op_barrier.clone();
740 let is_exclusive = name == "browser_select";
741 tokio::spawn(async move {
742 let frame = match handler {
743 None => error_frame(id, -32602, &format!("tool not found: {name}")),
746 Some(h) => {
747 if is_exclusive {
748 let _guard = barrier.write().await;
753 match h(state, args).await {
754 Ok(v) => result_frame(id, v),
755 Err(e) => tool_error_frame(id, &e),
756 }
757 } else {
758 let _guard = barrier.read().await;
759 match h(state, args).await {
760 Ok(v) => result_frame(id, v),
761 Err(e) => tool_error_frame(id, &e),
762 }
763 }
764 }
765 };
766 let _ = tx.send(frame);
767 });
768}
769
770fn result_frame(id: Value, result: Value) -> Vec<u8> {
772 let resp = json!({"jsonrpc": "2.0", "id": id, "result": result});
773 let mut s = serde_json::to_vec(&resp).unwrap_or_else(|_| b"{}".to_vec());
774 s.push(b'\n');
775 s
776}
777
778fn tool_error_frame(id: Value, err: &anyhow::Error) -> Vec<u8> {
784 let message = tool_error_message(err);
785 result_frame(
786 id,
787 json!({
788 "content": [{ "type": "text", "text": message }],
789 "isError": true,
790 }),
791 )
792}
793
794fn tool_error_message(err: &anyhow::Error) -> String {
799 use crate::errors::SessionError;
800 use crate::registry::bidi_lock::BidiLockBusy;
801
802 if let Some(se) = err.downcast_ref::<SessionError>() {
803 return se.to_string();
806 }
807 if let Some(busy) = err.downcast_ref::<BidiLockBusy>() {
808 return busy.to_string();
809 }
810 format!("{err:#}")
813}
814
815fn error_frame(id: Value, code: i64, message: &str) -> Vec<u8> {
817 let resp = json!({
818 "jsonrpc": "2.0",
819 "id": id,
820 "error": {"code": code, "message": message},
821 });
822 let mut s = serde_json::to_vec(&resp).unwrap_or_else(|_| b"{}".to_vec());
823 s.push(b'\n');
824 s
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830 use crate::cli::env_resolver::Source;
831 use crate::detect::Engine;
832 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
833
834 fn dummy_resolved() -> ResolvedBrowser {
835 ResolvedBrowser {
836 endpoint: "ws://localhost:9999".into(),
837 engine: Engine::Cdp,
838 source: Source::External,
839 }
840 }
841
842 fn dummy_state() -> ServerState {
843 ServerState::new(dummy_resolved())
844 }
845
846 async fn send_recv(tools: ToolRegistry, requests: &[Value]) -> Vec<Value> {
847 let (mut client_w, server_r) = tokio::io::duplex(8192);
848 let (server_w, client_r) = tokio::io::duplex(8192);
849 let state = dummy_state();
850 let join = tokio::spawn(async move {
851 let _ = run_with_streams(state, tools, server_r, server_w).await;
852 });
853
854 for req in requests {
855 let mut s = serde_json::to_vec(req).unwrap();
856 s.push(b'\n');
857 client_w.write_all(&s).await.unwrap();
858 }
859 drop(client_w);
861
862 let mut reader = BufReader::new(client_r);
863 let mut responses = Vec::new();
864 loop {
865 let mut line = String::new();
866 let n = reader.read_line(&mut line).await.unwrap();
867 if n == 0 {
868 break;
869 }
870 responses.push(serde_json::from_str(line.trim()).unwrap());
871 }
872 let _ = join.await;
873 responses
874 }
875
876 fn echo_tool() -> RegisteredTool {
877 RegisteredTool {
878 name: "echo".to_string(),
879 description: "Echo arguments back".to_string(),
880 input_schema: json!({"type": "object"}),
881 handler: std::sync::Arc::new(|_state, args| {
882 Box::pin(async move { Ok(json!({"echoed": args})) })
883 }),
884 }
885 }
886
887 fn failing_tool() -> RegisteredTool {
888 RegisteredTool {
889 name: "boom".to_string(),
890 description: "Always fails with a typed SessionError".to_string(),
891 input_schema: json!({"type": "object"}),
892 handler: std::sync::Arc::new(|_state, _args| {
893 Box::pin(async move {
894 Err(anyhow::Error::new(crate::errors::SessionError::TabHung {
895 target_id: Some("T1".into()),
896 url: Some("https://example.test".into()),
897 timeout_ms: 20_000,
898 hint: "renderer wedged",
899 }))
900 })
901 }),
902 }
903 }
904
905 #[tokio::test]
906 async fn initialize_round_trip() {
907 let resp = send_recv(
908 ToolRegistry::new(),
909 &[json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}})],
910 )
911 .await;
912 assert_eq!(resp.len(), 1);
913 assert_eq!(resp[0]["id"], 1);
914 assert_eq!(resp[0]["result"]["protocolVersion"], "2024-11-05");
915 assert_eq!(resp[0]["result"]["serverInfo"]["name"], "browser-control");
916 }
917
918 #[tokio::test]
919 async fn tools_list_empty() {
920 let resp = send_recv(
921 ToolRegistry::new(),
922 &[json!({"jsonrpc":"2.0","id":2,"method":"tools/list"})],
923 )
924 .await;
925 assert_eq!(resp[0]["result"]["tools"], json!([]));
926 }
927
928 #[tokio::test]
929 async fn tools_list_after_register() {
930 let tools = ToolRegistry::new();
931 tools.register(echo_tool());
932 let resp = send_recv(
933 tools,
934 &[json!({"jsonrpc":"2.0","id":3,"method":"tools/list"})],
935 )
936 .await;
937 let list = resp[0]["result"]["tools"].as_array().unwrap();
938 assert_eq!(list.len(), 1);
939 assert_eq!(list[0]["name"], "echo");
940 }
941
942 #[tokio::test]
943 async fn tools_call_unknown_errors() {
944 let resp = send_recv(
945 ToolRegistry::new(),
946 &[json!({
947 "jsonrpc":"2.0","id":4,"method":"tools/call",
948 "params":{"name":"nope","arguments":{}}
949 })],
950 )
951 .await;
952 assert_eq!(resp[0]["error"]["code"], -32602);
954 assert!(resp[0]["error"]["message"]
955 .as_str()
956 .unwrap()
957 .contains("nope"));
958 }
959
960 #[tokio::test]
961 async fn tool_failure_returns_iserror_result_not_protocol_error() {
962 let tools = ToolRegistry::new();
963 tools.register(failing_tool());
964 let resp = send_recv(
965 tools,
966 &[json!({
967 "jsonrpc":"2.0","id":7,"method":"tools/call",
968 "params":{"name":"boom","arguments":{}}
969 })],
970 )
971 .await;
972 assert!(resp[0]["error"].is_null());
975 assert_eq!(resp[0]["result"]["isError"], true);
976 let text = resp[0]["result"]["content"][0]["text"].as_str().unwrap();
977 assert!(text.contains("tab hung"), "got: {text}");
979 assert!(text.contains("renderer wedged"), "got: {text}");
980 assert!(text.contains("T1"), "got: {text}");
981 }
982
983 #[tokio::test]
984 async fn tools_call_registered_returns_result() {
985 let tools = ToolRegistry::new();
986 tools.register(echo_tool());
987 let resp = send_recv(
988 tools,
989 &[json!({
990 "jsonrpc":"2.0","id":5,"method":"tools/call",
991 "params":{"name":"echo","arguments":{"hello":"world"}}
992 })],
993 )
994 .await;
995 assert_eq!(resp[0]["result"]["echoed"], json!({"hello":"world"}));
996 }
997
998 #[tokio::test]
999 async fn unknown_method_returns_minus_32601() {
1000 let resp = send_recv(
1001 ToolRegistry::new(),
1002 &[json!({"jsonrpc":"2.0","id":6,"method":"bogus"})],
1003 )
1004 .await;
1005 assert_eq!(resp[0]["error"]["code"], -32601);
1006 }
1007
1008 #[tokio::test]
1009 async fn ping_returns_empty_object() {
1010 let resp = send_recv(
1011 ToolRegistry::new(),
1012 &[json!({"jsonrpc":"2.0","id":7,"method":"ping"})],
1013 )
1014 .await;
1015 assert_eq!(resp[0]["result"], json!({}));
1016 }
1017
1018 use futures_util::{SinkExt, StreamExt};
1027 use std::sync::atomic::{AtomicUsize, Ordering};
1028 use tokio_tungstenite::tungstenite::Message;
1029
1030 async fn spawn_counting_cdp_mock(
1034 live: Vec<String>,
1035 ) -> (String, Arc<AtomicUsize>, tokio::sync::oneshot::Sender<()>) {
1036 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1037 let addr = listener.local_addr().unwrap();
1038 let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel::<()>();
1039 let conns = Arc::new(AtomicUsize::new(0));
1040 let conns_srv = conns.clone();
1041 tokio::spawn(async move {
1042 loop {
1043 let accept = tokio::select! {
1044 _ = &mut stop_rx => break,
1045 a = listener.accept() => a,
1046 };
1047 let (stream, _) = match accept {
1048 Ok(s) => s,
1049 Err(_) => break,
1050 };
1051 conns_srv.fetch_add(1, Ordering::SeqCst);
1052 let live = live.clone();
1053 tokio::spawn(async move {
1054 let mut ws = match tokio_tungstenite::accept_async(stream).await {
1055 Ok(w) => w,
1056 Err(_) => return,
1057 };
1058 while let Some(Ok(msg)) = ws.next().await {
1059 if let Message::Text(t) = msg {
1060 let req: Value = match serde_json::from_str(&t) {
1061 Ok(v) => v,
1062 Err(_) => continue,
1063 };
1064 let id = req["id"].as_u64().unwrap_or(0);
1065 let method = req["method"].as_str().unwrap_or("");
1066 let result = match method {
1067 "Target.getTargets" => {
1068 let infos: Vec<Value> = live
1069 .iter()
1070 .map(|tid| {
1071 json!({"targetId": tid, "type": "page", "url": ""})
1072 })
1073 .collect();
1074 json!({"targetInfos": infos})
1075 }
1076 "Target.attachToTarget" => json!({"sessionId": "S1"}),
1077 "Runtime.evaluate" => json!({"result": {"value": 1}}),
1078 _ => json!({}),
1079 };
1080 let resp = json!({"id": id, "result": result});
1081 if ws.send(Message::Text(resp.to_string())).await.is_err() {
1082 break;
1083 }
1084 }
1085 }
1086 });
1087 }
1088 });
1089 (format!("ws://{addr}"), conns, stop_tx)
1090 }
1091
1092 fn registered_state(name: &str, endpoint: &str) -> ServerState {
1095 ServerState::new(ResolvedBrowser {
1096 endpoint: endpoint.to_string(),
1097 engine: Engine::Cdp,
1098 source: Source::Registered { name: name.into() },
1099 })
1100 }
1101
1102 #[allow(clippy::await_holding_lock)]
1105 #[tokio::test]
1106 async fn resolve_named_tab_live_resolves_and_touches() {
1107 let _g = crate::test_support::ENV_LOCK
1108 .lock()
1109 .unwrap_or_else(|e| e.into_inner());
1110 let tmp = tempfile::TempDir::new().unwrap();
1111 std::env::set_var("BROWSER_CONTROL_DATA_DIR", tmp.path());
1112
1113 {
1115 let reg = crate::registry::Registry::open().unwrap();
1116 reg.tab_upsert("bx", "work", "T1", "about:blank", true)
1117 .unwrap();
1118 }
1119
1120 let (url, _conns, _stop) = spawn_counting_cdp_mock(vec!["T1".into()]).await;
1121 let state = registered_state("bx", &url);
1122
1123 let target_id = match state.resolve_target_for_args(&json!({"tab": "work"})).await {
1124 Ok((_backend, tid)) => tid,
1125 Err(e) => panic!("named tab should resolve: {e:#}"),
1126 };
1127 assert_eq!(target_id, "T1");
1128
1129 std::env::remove_var("BROWSER_CONTROL_DATA_DIR");
1130 }
1131
1132 #[allow(clippy::await_holding_lock)]
1134 #[tokio::test]
1135 async fn resolve_named_tab_stale_sweeps_and_errors() {
1136 let _g = crate::test_support::ENV_LOCK
1137 .lock()
1138 .unwrap_or_else(|e| e.into_inner());
1139 let tmp = tempfile::TempDir::new().unwrap();
1140 std::env::set_var("BROWSER_CONTROL_DATA_DIR", tmp.path());
1141
1142 {
1146 let reg = crate::registry::Registry::open().unwrap();
1147 reg.tab_upsert("bx", "gone", "T_DEAD", "about:blank", true)
1148 .unwrap();
1149 }
1150
1151 let (url, _conns, _stop) = spawn_counting_cdp_mock(vec!["T1".into()]).await;
1152 let state = registered_state("bx", &url);
1153
1154 let err = match state.resolve_target_for_args(&json!({"tab": "gone"})).await {
1155 Ok(_) => panic!("stale named tab must error"),
1156 Err(e) => e,
1157 };
1158 let typed = err
1159 .downcast_ref::<crate::errors::SessionError>()
1160 .expect("typed SessionError");
1161 assert!(
1162 matches!(typed, crate::errors::SessionError::TabNotFound { .. }),
1163 "expected TabNotFound, got {typed:?}"
1164 );
1165
1166 let reg = crate::registry::Registry::open().unwrap();
1168 assert!(
1169 reg.tab_get("bx", "gone").unwrap().is_none(),
1170 "row not swept"
1171 );
1172
1173 std::env::remove_var("BROWSER_CONTROL_DATA_DIR");
1174 }
1175
1176 #[allow(clippy::await_holding_lock)]
1178 #[tokio::test]
1179 async fn resolve_named_tab_missing_row_errors() {
1180 let _g = crate::test_support::ENV_LOCK
1181 .lock()
1182 .unwrap_or_else(|e| e.into_inner());
1183 let tmp = tempfile::TempDir::new().unwrap();
1184 std::env::set_var("BROWSER_CONTROL_DATA_DIR", tmp.path());
1185
1186 let (url, _conns, _stop) = spawn_counting_cdp_mock(vec!["T1".into()]).await;
1187 let state = registered_state("bx", &url);
1188
1189 let err = match state.resolve_target_for_args(&json!({"tab": "nope"})).await {
1190 Ok(_) => panic!("unknown named tab must error"),
1191 Err(e) => e,
1192 };
1193 let typed = err
1194 .downcast_ref::<crate::errors::SessionError>()
1195 .expect("typed SessionError");
1196 assert!(
1197 matches!(typed, crate::errors::SessionError::TabNotFound { .. }),
1198 "expected TabNotFound, got {typed:?}"
1199 );
1200
1201 std::env::remove_var("BROWSER_CONTROL_DATA_DIR");
1202 }
1203
1204 #[tokio::test]
1205 async fn resolve_tab_and_target_mutually_exclusive() {
1206 let state = dummy_state();
1208 let err = match state
1209 .resolve_target_for_args(&json!({"tab": "a", "target": "b"}))
1210 .await
1211 {
1212 Ok(_) => panic!("tab+target must error"),
1213 Err(e) => e,
1214 };
1215 assert!(
1216 err.to_string().contains("mutually exclusive"),
1217 "got: {err:#}"
1218 );
1219 }
1220
1221 #[tokio::test]
1222 async fn concurrent_ensure_backend_opens_one_backend() {
1223 let (url, conns, _stop) = spawn_counting_cdp_mock(vec!["T1".into()]).await;
1227 let state = ServerState::new(ResolvedBrowser {
1228 endpoint: url,
1229 engine: Engine::Cdp,
1230 source: Source::External,
1231 });
1232
1233 let mut handles = Vec::new();
1234 for _ in 0..8 {
1235 let s = state.clone();
1236 handles.push(tokio::spawn(async move { s.ensure_backend().await }));
1237 }
1238 for h in handles {
1239 h.await.unwrap().expect("ensure_backend should succeed");
1240 }
1241 assert_eq!(
1242 conns.load(Ordering::SeqCst),
1243 1,
1244 "expected exactly one backend (one WS connection) under concurrency"
1245 );
1246 }
1247
1248 #[allow(clippy::await_holding_lock)]
1249 #[tokio::test]
1250 async fn external_cdp_backend_does_not_open_registry() {
1251 let _g = crate::test_support::ENV_LOCK
1252 .lock()
1253 .unwrap_or_else(|e| e.into_inner());
1254 let stale_path = {
1255 let tmp = tempfile::TempDir::new().unwrap();
1256 tmp.path().to_path_buf()
1257 };
1258 std::env::set_var("BROWSER_CONTROL_DATA_DIR", &stale_path);
1259
1260 let (url, _conns, _stop) = spawn_counting_cdp_mock(vec!["T1".into()]).await;
1261 let state = ServerState::new(ResolvedBrowser {
1262 endpoint: url,
1263 engine: Engine::Cdp,
1264 source: Source::External,
1265 });
1266
1267 let result = state.ensure_backend().await;
1268
1269 std::env::remove_var("BROWSER_CONTROL_DATA_DIR");
1270 result.expect("external CDP backend must not depend on registry env");
1271 }
1272
1273 #[tokio::test]
1274 async fn initialized_notification_is_silently_ignored() {
1275 let resp = send_recv(
1278 ToolRegistry::new(),
1279 &[
1280 json!({"jsonrpc":"2.0","method":"notifications/initialized"}),
1281 json!({"jsonrpc":"2.0","id":8,"method":"ping"}),
1282 ],
1283 )
1284 .await;
1285 assert_eq!(resp.len(), 1);
1286 assert_eq!(resp[0]["id"], 8);
1287 }
1288}